diff --git a/.changeset/steered-contents-origin-guard.md b/.changeset/steered-contents-origin-guard.md new file mode 100644 index 00000000000..e20412dfd95 --- /dev/null +++ b/.changeset/steered-contents-origin-guard.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fixed skill instructions injected by the Skill tool showing up as ordinary user messages in the rebuilt transcript. diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 49db89cb046..776786cdcbb 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -466,7 +466,7 @@ export class TranscriptService { } const messages = [...reduceContextTranscript(records).entries]; const taskOriginTurnTaskIds = new Set(); - const steeredContents = new Map(); + const steeredContents = new Map>(); const anchorStack: { taskIdsSnapshot: Set }[] = []; let anchorFloor = 0; let sawTurnPrompt = false; @@ -495,7 +495,11 @@ export class TranscriptService { const input = record['input']; if (Array.isArray(input)) { const key = JSON.stringify(input); - steeredContents.set(key, (steeredContents.get(key) ?? 0) + 1); + const steerOrigin = (record as { origin?: { kind?: unknown } }).origin?.kind; + const kind = typeof steerOrigin === 'string' ? steerOrigin : 'user'; + const byKind = steeredContents.get(key) ?? new Map(); + byKind.set(kind, (byKind.get(kind) ?? 0) + 1); + steeredContents.set(key, byKind); } continue; } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 78aba43ddf3..9fb9acc22e5 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2589,6 +2589,50 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot keeps skill-activation steers as skill markers instead of user frames', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-skillsteer-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const skillOrigin = { + kind: 'skill_activation', + activationId: 'a1', + skillName: 'write-tui', + trigger: 'model-tool', + skillSource: 'project', + }; + const nestedOrigin = { ...skillOrigin, activationId: 'a2', skillName: 'design', trigger: 'nested-skill' }; + const skillText = 'Skill tool loaded instructions for this request. Follow them.'; + const nestedText = 'Nested skill instructions.'; + const records = [ + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, time: 2000 }, + { type: 'turn.steer', input: [{ type: 'text', text: skillText }], origin: skillOrigin, time: 3000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: skillText }], toolCalls: [], origin: skillOrigin }, time: 3001 }, + { type: 'turn.steer', input: [{ type: 'text', text: nestedText }], origin: nestedOrigin, time: 3002 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: nestedText }], toolCalls: [], origin: nestedOrigin }, time: 3003 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, time: 4000 }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + const markers = snapshot!.items.filter((item) => item.kind === 'marker'); + expect(markers).toHaveLength(2); + expect(markers.every((item) => item.kind === 'marker' && item.marker === 'skill')).toBe(true); + const turns = snapshot!.items.filter((item) => item.kind === 'turn'); + expect(turns).toHaveLength(1); + const turn = turns[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.prompt).toBe('active'); + const userFrames = turn.steps + .flatMap((step) => step.frames) + .filter((frame) => frame.kind === 'text' && frame.role === 'user'); + expect(userFrames).toHaveLength(0); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot drops undone task-turn boundaries so a redelivered notification folds', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-undoboundary-')); try { diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 82693f8b864..6e41bb27976 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -67,12 +67,14 @@ export function groupMessagesIntoSnapshot( messages: readonly HistoryMessage[], options?: { readonly taskOriginTurnTaskIds?: ReadonlySet; - readonly steeredContents?: ReadonlyMap; + readonly steeredContents?: ReadonlyMap>; }, ): AgentTranscriptSnapshot { const items: TranscriptItem[] = []; const attachments: TranscriptAttachment[] = []; - const steeredContents = new Map(options?.steeredContents ?? []); + const steeredContents = new Map( + [...(options?.steeredContents ?? [])].map(([key, byKind]) => [key, new Map(byKind)]), + ); let turn: TurnDraft | undefined; let pendingNotificationFrames: { text: string; @@ -217,10 +219,17 @@ export function groupMessagesIntoSnapshot( } continue; } + const markerKey = originKind !== undefined ? MARKER_USER_ORIGINS[originKind] : undefined; + if (markerKey !== undefined && !isUserSlashPrompt(message)) { + pushMarker(markerKey, { text: textOf(message), origin: message.origin }); + continue; + } const contentKey = JSON.stringify(message.content ?? []); - const steeredRemaining = steeredContents.get(contentKey) ?? 0; - if (steeredRemaining > 0) { - steeredContents.set(contentKey, steeredRemaining - 1); + const steerKind = originKind ?? 'user'; + const steeredByKind = steeredContents.get(contentKey); + const steeredRemaining = steeredByKind?.get(steerKind) ?? 0; + if (steeredByKind !== undefined && steeredRemaining > 0) { + steeredByKind.set(steerKind, steeredRemaining - 1); const bundled = bundledSkillActivations(message); const parts = message.content ?? []; bundled.forEach((activation, index) => { @@ -239,7 +248,6 @@ export function groupMessagesIntoSnapshot( }); continue; } - const markerKey = originKind !== undefined ? MARKER_USER_ORIGINS[originKind] : undefined; if (markerKey !== undefined) { const opening = isUserSlashPrompt(message) ? foldTurnOpeningInput(message) : undefined; pushMarker(markerKey, { text: opening?.text ?? textOf(message), origin: message.origin }); diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 95f80ba6943..8fb5a501301 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -506,7 +506,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'user', content: [{ type: 'text', text: 'steered in' }], toolCalls: [], origin: { kind: 'user' } }, { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), 1]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn']); @@ -527,7 +527,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, { role: 'user', content: [{ type: 'text', text: 'steered in' }], toolCalls: [], origin: { kind: 'user' } }, ], - { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), 1]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn']); @@ -550,7 +550,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'user', content: [{ type: 'text', text: 'next question' }], toolCalls: [], origin: { kind: 'user' } }, { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), 1]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); const turns = snapshot.items.filter((i) => i.kind === 'turn'); @@ -574,7 +574,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, { role: 'user', content: [{ type: 'text', text: 'plain follow-up' }], toolCalls: [], origin: { kind: 'user' } }, ], - { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), 1]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn', 'turn']); @@ -1099,6 +1099,139 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(slashTurn.steps).toHaveLength(2); }); + it('keeps model-tool skill activations as markers even when their content matches a steer record', () => { + const skillContent = [{ type: 'text', text: 'skill body' }]; + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, + { + role: 'user', + content: skillContent, + toolCalls: [], + origin: { kind: 'skill_activation', trigger: 'model-tool', skillName: 'write-tui' } as { + kind: string; + }, + }, + { role: 'assistant', content: [{ type: 'text', text: 'used the skill' }], toolCalls: [] }, + ], + { steeredContents: new Map([[JSON.stringify(skillContent), new Map([['skill_activation', 1]])]]) }, + ); + + expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']); + const marker = snapshot.items[1]; + if (marker?.kind !== 'marker') throw new Error('expected marker'); + expect(marker.marker).toBe('skill'); + }); + + it('still folds cron-origin steers into the running turn by content match', () => { + const cronContent = [{ type: 'text', text: 'cron tick' }]; + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, + { + role: 'user', + content: cronContent, + toolCalls: [], + origin: { kind: 'cron_job', jobId: 'job1' } as { kind: string }, + }, + { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, + ], + { steeredContents: new Map([[JSON.stringify(cronContent), new Map([['cron_job', 1]])]]) }, + ); + + expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.steps).toHaveLength(2); + expect(turn.steps[1]?.frames[0]).toMatchObject({ + kind: 'text', + role: 'user', + text: 'cron tick', + }); + }); + + it('still folds user-slash skill activations into the running turn by content match', () => { + const slashContent = [{ type: 'text', text: 'slash skill body' }]; + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, + { + role: 'user', + content: slashContent, + toolCalls: [], + origin: { kind: 'skill_activation', trigger: 'user-slash', skillName: 'gen-docs' } as { + kind: string; + }, + }, + { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, + ], + { steeredContents: new Map([[JSON.stringify(slashContent), new Map([['skill_activation', 1]])]]) }, + ); + + expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.steps).toHaveLength(2); + expect(turn.steps[1]?.frames[0]).toMatchObject({ + kind: 'text', + role: 'user', + text: 'slash skill body', + }); + }); + + it('consumes the steer count for marker-only activations so a later identical prompt opens its own turn', () => { + const shared = [{ type: 'text', text: 'same text' }]; + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, + { + role: 'user', + content: shared, + toolCalls: [], + origin: { kind: 'skill_activation', trigger: 'model-tool', skillName: 'x' } as { + kind: string; + }, + }, + { role: 'user', content: shared, toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, + ], + { steeredContents: new Map([[JSON.stringify(shared), new Map([['skill_activation', 1]])]]) }, + ); + + expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker', 'turn']); + }); + + it('does not consume a user steer count for a compaction summary with identical content', () => { + const shared = [{ type: 'text', text: 'same text' }]; + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, + { + role: 'user', + content: shared, + toolCalls: [], + origin: { kind: 'compaction_summary' } as { kind: string }, + }, + { role: 'user', content: shared, toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, + ], + { steeredContents: new Map([[JSON.stringify(shared), new Map([['user', 1]])]]) }, + ); + + expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const steered = turn.steps + .flatMap((step) => step.frames) + .filter((frame) => frame.kind === 'text' && frame.role === 'user'); + expect(steered).toHaveLength(1); + }); + it('starts a promptless turn for turn-opening system triggers (goal continuation)', () => { const snapshot = groupMessagesIntoSnapshot([ { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } },