From a16b97081c8fbe5f012023eec8b91a062c344d46 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 1 Sep 2026 21:04:15 +0800 Subject: [PATCH 1/5] fix(transcript): keep steer frame promptIds and arrival order across cold rebuilds --- .changeset/steer-frame-prompt-ids.md | 6 ++ .../src/services/transcript/coreEventMap.ts | 65 ++++++++++--------- .../services/transcript/transcriptService.ts | 28 +++++++- .../test/services/transcript.test.ts | 64 ++++++++++++++++++ packages/transcript/src/history/groupTurns.ts | 13 ++-- packages/transcript/test/layers.test.ts | 63 +++++++++++++++--- 6 files changed, 192 insertions(+), 47 deletions(-) create mode 100644 .changeset/steer-frame-prompt-ids.md diff --git a/.changeset/steer-frame-prompt-ids.md b/.changeset/steer-frame-prompt-ids.md new file mode 100644 index 00000000000..18156ce52fb --- /dev/null +++ b/.changeset/steer-frame-prompt-ids.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kap-server": patch +"@moonshot-ai/transcript": patch +--- + +Stamp cold-rebuilt steer frames with the promptIds paired from prompt.steered records, and flush pending steer/notification frames in arrival order so post-turn heal can no longer drop prompt pairing or misorder frames. diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index ebe2c334f1b..40b9d62b34d 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -193,12 +193,15 @@ export interface ToolFrameRecord { export class AgentTranscriptProjector { private currentTurn: TurnHeader | undefined; private currentStep: StepHeader | undefined; - private pendingTaskNotifications: { text: string; taskId: string | undefined }[] = []; - private pendingSteers: { - input: readonly ContentPart[]; - promptIds: readonly string[] | undefined; - origin: TranscriptUserOrigin; - }[] = []; + private pendingUserFrames: ( + | { kind: 'notification'; text: string; taskId: string | undefined } + | { + kind: 'steer'; + input: readonly ContentPart[]; + promptIds: readonly string[] | undefined; + origin: TranscriptUserOrigin; + } + )[] = []; private unpairedSteerPromptIds: string[][] = []; private readonly stepOrdinals = new Map(); private frameOrdinal = 0; @@ -403,8 +406,7 @@ export class AgentTranscriptProjector { startedAt: nowIso(), }; this.currentStep = undefined; - this.pendingTaskNotifications = []; - this.pendingSteers = []; + this.pendingUserFrames = []; this.openText = undefined; this.openThinking = undefined; ops.push({ op: 'turn.upsert', turn: this.currentTurn }); @@ -428,7 +430,8 @@ export class AgentTranscriptProjector { this.currentStep = step; ops.push({ op: 'step.upsert', turnId: step.turnId, step }); } - if (this.currentStep === undefined && this.pendingSteers.length > 0) { + const pendingSteers = this.pendingUserFrames.filter((pending) => pending.kind === 'steer'); + if (this.currentStep === undefined && pendingSteers.length > 0) { const ordinal = (this.stepOrdinals.get(turnId) ?? this.lookups?.stepOrdinal?.(turnId) ?? 0) + 1; const step: StepHeader = { kind: 'step', @@ -443,7 +446,7 @@ export class AgentTranscriptProjector { ops.push({ op: 'step.upsert', turnId, step }); } if (this.currentStep !== undefined) { - for (const pending of this.pendingSteers) { + for (const pending of pendingSteers) { this.steerUserFrame( ops, turnId, @@ -454,7 +457,7 @@ export class AgentTranscriptProjector { ); } } - this.pendingSteers = []; + this.pendingUserFrames = []; const prev = this.currentTurn?.turnId === turnId ? this.currentTurn : this.lookups?.turn?.(turnId); const state = mapTurnEndState(event.reason); @@ -476,7 +479,6 @@ export class AgentTranscriptProjector { ops.push({ op: 'turn.upsert', turn: this.currentTurn }); ops.push({ op: 'meta.merge', meta: { activity: 'idle' } }); this.currentStep = undefined; - this.pendingTaskNotifications = []; if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') { ops.push( this.markerOp('interruption', { turnId: event.turnId, reason: event.interruptReason }), @@ -523,25 +525,25 @@ export class AgentTranscriptProjector { this.openText = undefined; this.openThinking = undefined; const ops: TranscriptOperation[] = [{ op: 'step.upsert', turnId, step: this.currentStep }]; - for (const pending of this.pendingTaskNotifications) { - ops.push({ - op: 'frame.upsert', - turnId, - stepId, - frame: { - kind: 'text', - frameId: `${stepId}.f${++this.frameOrdinal}`, - role: 'user', - text: pending.text, - taskId: pending.taskId, - }, - }); - } - this.pendingTaskNotifications = []; - for (const pending of this.pendingSteers) { + for (const pending of this.pendingUserFrames) { + if (pending.kind === 'notification') { + ops.push({ + op: 'frame.upsert', + turnId, + stepId, + frame: { + kind: 'text', + frameId: `${stepId}.f${++this.frameOrdinal}`, + role: 'user', + text: pending.text, + taskId: pending.taskId, + }, + }); + continue; + } this.steerUserFrame(ops, turnId, stepId, pending.input, pending.promptIds, pending.origin); } - this.pendingSteers = []; + this.pendingUserFrames = []; return ops; } @@ -892,7 +894,7 @@ export class AgentTranscriptProjector { return [{ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }]; } if (turn.origin?.kind === 'task' && (turn.origin.taskId === undefined || turn.origin.taskId === event.sourceId)) return []; - this.pendingTaskNotifications.push({ text, taskId: event.sourceId }); + this.pendingUserFrames.push({ kind: 'notification', text, taskId: event.sourceId }); return []; } @@ -1458,7 +1460,8 @@ export class AgentTranscriptProjector { ); return ops; } - this.pendingSteers.push({ + this.pendingUserFrames.push({ + kind: 'steer', input, promptIds: this.unpairedSteerPromptIds.shift(), origin: frameOrigin, diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 887f1c1c90f..d2deef505af 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -469,7 +469,8 @@ export class TranscriptService { } const messages = [...reduceContextTranscript(records).entries]; const taskOriginTurnTaskIds = new Set(); - const steeredContents = new Map>(); + const steeredContents = new Map>(); + const steeredPromptIdQueues = new Map(); const anchorStack: { taskIdsSnapshot: Set }[] = []; let anchorFloor = 0; let sawTurnPrompt = false; @@ -494,14 +495,35 @@ export class TranscriptService { } continue; } + if (record.type === 'prompt.steered') { + const content = record['content']; + const promptIds = record['promptIds']; + if ( + Array.isArray(content) && + Array.isArray(promptIds) && + promptIds.every((id) => typeof id === 'string') + ) { + const key = JSON.stringify(content); + const queue = steeredPromptIdQueues.get(key) ?? []; + queue.push(promptIds as readonly string[]); + steeredPromptIdQueues.set(key, queue); + } + continue; + } if (record.type === 'turn.steer') { const input = record['input']; if (Array.isArray(input)) { const key = JSON.stringify(input); 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); + const idQueue = steeredPromptIdQueues.get(key); + const promptIds = + idQueue !== undefined && idQueue.length > 0 ? idQueue.shift() : undefined; + const byKind = + steeredContents.get(key) ?? new Map(); + const queue = byKind.get(kind) ?? []; + queue.push(promptIds); + byKind.set(kind, queue); 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 a31cd4a76c2..53d95073949 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2105,6 +2105,41 @@ describe('AgentTranscriptProjector', () => { }); }); + it('flushes queued steers and task notifications at step start in arrival order', () => { + const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed(ev({ type: 'turn.started', turnId: 4, origin: { kind: 'user' }, prompt: 'active' })); + feed(ev({ type: 'turn.step.started', turnId: 4, step: 1 })); + feed(ev({ type: 'turn.step.completed', turnId: 4, step: 1 })); + feed( + ev({ + type: 'turn.steer', + input: [{ type: 'text', text: 'steered in' }], + origin: { kind: 'user' }, + }), + ); + feed( + ev({ + type: 'task.notified', + notificationType: 'task.completed', + title: 'Background agent completed', + body: 'inspect done.', + severity: 'info', + sourceKind: 'background_task', + sourceId: 'task_1', + }), + ); + + feed(ev({ type: 'turn.step.started', turnId: 4, step: 2 })); + const frames = turnOps('t4', tx.getItems()).steps[1]!.frames; + expect(frames.map((f) => f.kind === 'text' && 'text' in f && f.text)).toEqual([ + 'steered in', + 'Background agent completed\ninspect done.', + ]); + }); + it('projects turn.steer into the running step immediately, with daemon media as attachments', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); const tx = new AgentTranscript('main'); @@ -2680,6 +2715,35 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot stamps the steered frame with promptIds paired from prompt.steered', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-ids-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + 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: 'prompt.steered', activePromptId: 'msg_active', promptIds: ['msg_steered'], content: [{ type: 'text', text: 'steered in' }], steeredAt: '2026-09-01T10:00:00.000Z', time: 2500 }, + { type: 'turn.steer', input: [{ type: 'text', text: 'steered in' }], origin: { kind: 'user' }, time: 3000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'steered in' }], toolCalls: [], origin: { kind: 'user' } }, time: 3001 }, + { 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 turn = snapshot!.items.find((item) => item.kind === 'turn'); + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.steps[1]?.frames[0]).toMatchObject({ + kind: 'text', + role: 'user', + text: 'steered in', + promptIds: ['msg_steered'], + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot preserves safe bundled skill provenance before the first step', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-bundled-steer-')); try { diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 57d7a34fa7a..d78598c6e77 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -70,7 +70,10 @@ export function groupMessagesIntoSnapshot( messages: readonly HistoryMessage[], options?: { readonly taskOriginTurnTaskIds?: ReadonlySet; - readonly steeredContents?: ReadonlyMap>; + readonly steeredContents?: ReadonlyMap< + string, + ReadonlyMap + >; }, ): AgentTranscriptSnapshot { const items: TranscriptItem[] = []; @@ -243,9 +246,10 @@ export function groupMessagesIntoSnapshot( const contentKey = JSON.stringify(message.content ?? []); 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 steeredQueue = steeredByKind?.get(steerKind); + if (steeredByKind !== undefined && steeredQueue !== undefined && steeredQueue.length > 0) { + const promptIds = steeredQueue[0]; + steeredByKind.set(steerKind, steeredQueue.slice(1)); const bundled = bundledSkillActivations(message); const parts = message.content ?? []; bundled.forEach((activation, index) => { @@ -260,6 +264,7 @@ export function groupMessagesIntoSnapshot( text: opening.text, taskId: undefined, attachmentIds: opening.attachmentIds, + promptIds, origin: projectTranscriptUserOrigin(message.origin), steered: true, }); diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 6c4ce2bfe3d..572e5b404c9 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -534,7 +534,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' }]), new Map([['user', 1]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', [undefined]]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn']); @@ -548,6 +548,51 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }); }); + it('stamps the folded steer frame with the prompt ids from the steer queue', () => { + 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: [{ 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' }]), new Map([['user', [['prompt_a']]]])]]) }, + ); + + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.steps[1]?.frames[0]).toMatchObject({ + kind: 'text', + role: 'user', + text: 'steered in', + promptIds: ['prompt_a'], + }); + }); + + it('pairs repeated steers of identical content with their prompt ids in order', () => { + 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: [{ type: 'text', text: 'same' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'same' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'noted again' }], toolCalls: [] }, + ], + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'same' }]), new Map([['user', [['prompt_a'], ['prompt_b']]]])]]) }, + ); + + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const steerFrames = turn.steps.flatMap((step) => + step.frames.filter((frame) => frame.kind === 'text' && frame.role === 'user'), + ); + expect(steerFrames.map((frame) => frame.kind === 'text' && frame.role === 'user' ? frame.promptIds : undefined)).toEqual([ + ['prompt_a'], + ['prompt_b'], + ]); + }); + it('keeps a trailing steered message visible by appending it to the last step', () => { const snapshot = groupMessagesIntoSnapshot( [ @@ -555,7 +600,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' }]), new Map([['user', 1]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', [undefined]]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn']); @@ -578,7 +623,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' }]), new Map([['user', 1]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', [undefined]]])]]) }, ); const turns = snapshot.items.filter((i) => i.kind === 'turn'); @@ -602,7 +647,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' }]), new Map([['user', 1]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', [undefined]]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn', 'turn']); @@ -1143,7 +1188,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }, { role: 'assistant', content: [{ type: 'text', text: 'used the skill' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify(skillContent), new Map([['skill_activation', 1]])]]) }, + { steeredContents: new Map([[JSON.stringify(skillContent), new Map([['skill_activation', [undefined]]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']); @@ -1166,7 +1211,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }, { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify(cronContent), new Map([['cron_job', 1]])]]) }, + { steeredContents: new Map([[JSON.stringify(cronContent), new Map([['cron_job', [undefined]]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']); @@ -1196,7 +1241,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }, { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify(slashContent), new Map([['skill_activation', 1]])]]) }, + { steeredContents: new Map([[JSON.stringify(slashContent), new Map([['skill_activation', [undefined]]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']); @@ -1227,7 +1272,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { 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]])]]) }, + { steeredContents: new Map([[JSON.stringify(shared), new Map([['skill_activation', [undefined]]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker', 'turn']); @@ -1248,7 +1293,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { 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]])]]) }, + { steeredContents: new Map([[JSON.stringify(shared), new Map([['user', [undefined]]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']); From 0c5a6057b80c8562c3f71917dd8eb692a134b0dd Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 1 Sep 2026 21:15:55 +0800 Subject: [PATCH 2/5] fix(transcript): pair steer promptIds across bundled skill blocks in cold rebuilds --- .../services/transcript/transcriptService.ts | 10 +++++-- .../test/services/transcript.test.ts | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index d2deef505af..7f5e3eb72d6 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -514,9 +514,13 @@ export class TranscriptService { const input = record['input']; if (Array.isArray(input)) { const key = JSON.stringify(input); - const steerOrigin = (record as { origin?: { kind?: unknown } }).origin?.kind; - const kind = typeof steerOrigin === 'string' ? steerOrigin : 'user'; - const idQueue = steeredPromptIdQueues.get(key); + const steerOrigin = (record as { origin?: { kind?: unknown; skillActivations?: unknown } }).origin; + const kind = typeof steerOrigin?.kind === 'string' ? steerOrigin.kind : 'user'; + const skillBlockCount = Array.isArray(steerOrigin?.skillActivations) + ? steerOrigin.skillActivations.length + : 0; + const pairKey = skillBlockCount > 0 ? JSON.stringify(input.slice(skillBlockCount)) : key; + const idQueue = steeredPromptIdQueues.get(pairKey); const promptIds = idQueue !== undefined && idQueue.length > 0 ? idQueue.shift() : undefined; const byKind = diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 53d95073949..ea664433efc 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2744,6 +2744,36 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot pairs promptIds when the steer carries bundled skill blocks', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-skill-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const activation = { activationId: 'a1', skillName: 'deploy' }; + 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: 'prompt.steered', activePromptId: 'msg_active', promptIds: ['msg_steered'], content: [{ type: 'text', text: 'deploy now' }], steeredAt: '2026-09-01T10:00:00.000Z', time: 2500 }, + { type: 'turn.steer', input: [{ type: 'text', text: '/deploy' }, { type: 'text', text: 'deploy now' }], origin: { kind: 'user', skillActivations: [activation] }, time: 3000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: '/deploy' }, { type: 'text', text: 'deploy now' }], toolCalls: [], origin: { kind: 'user', skillActivations: [activation] } }, time: 3001 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], 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 turn = snapshot!.items.find((item) => item.kind === 'turn'); + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.steps[1]?.frames[0]).toMatchObject({ + kind: 'text', + role: 'user', + text: 'deploy now', + promptIds: ['msg_steered'], + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot preserves safe bundled skill provenance before the first step', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-bundled-steer-')); try { From 77688802730b4a464fa93a212fcb0f2b2332dadb Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 1 Sep 2026 21:31:52 +0800 Subject: [PATCH 3/5] fix(transcript): pair steer promptIds by wire arrival order and roll back on undo --- .../services/transcript/transcriptService.ts | 69 +++++++++++-------- .../test/services/transcript.test.ts | 37 ++++++++++ packages/transcript/src/history/groupTurns.ts | 17 ++--- packages/transcript/test/layers.test.ts | 22 +++--- 4 files changed, 97 insertions(+), 48 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 7f5e3eb72d6..feaf0e4e81d 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -469,19 +469,36 @@ export class TranscriptService { } const messages = [...reduceContextTranscript(records).entries]; const taskOriginTurnTaskIds = new Set(); - const steeredContents = new Map>(); - const steeredPromptIdQueues = new Map(); - const anchorStack: { taskIdsSnapshot: Set }[] = []; + let steeredContents = new Map>(); + const steeredPromptIds: (readonly string[] | undefined)[] = []; + const pendingSteerPromptIds: (readonly string[])[] = []; + const anchorStack: { + taskIdsSnapshot: Set; + steeredSnapshot: Map>; + pendingSteerCount: number; + steeredIdsCount: number; + }[] = []; let anchorFloor = 0; let sawTurnPrompt = false; for (const record of records) { if (record.type === 'context.undo') { const count = typeof record['count'] === 'number' ? (record['count'] as number) : 0; + let poppedAny = false; for (let i = 0; i < count && anchorStack.length > anchorFloor; i++) { const popped = anchorStack.pop()!; + poppedAny = true; taskOriginTurnTaskIds.clear(); for (const id of popped.taskIdsSnapshot) taskOriginTurnTaskIds.add(id); } + if (poppedAny) { + const top = + anchorStack.length > anchorFloor ? anchorStack[anchorStack.length - 1] : undefined; + pendingSteerPromptIds.length = top?.pendingSteerCount ?? 0; + steeredPromptIds.length = top?.steeredIdsCount ?? 0; + steeredContents = new Map( + [...(top?.steeredSnapshot ?? [])].map(([key, byKind]) => [key, new Map(byKind)]), + ); + } continue; } if (record.type === 'context.clear') { @@ -491,22 +508,21 @@ export class TranscriptService { if (record.type === 'context.append_message') { const message = (record as { message?: ContextMessage }).message; if (message !== undefined && isUndoAnchor(message)) { - anchorStack.push({ taskIdsSnapshot: new Set(taskOriginTurnTaskIds) }); + anchorStack.push({ + taskIdsSnapshot: new Set(taskOriginTurnTaskIds), + steeredSnapshot: new Map( + [...steeredContents].map(([key, byKind]) => [key, new Map(byKind)]), + ), + pendingSteerCount: pendingSteerPromptIds.length, + steeredIdsCount: steeredPromptIds.length, + }); } continue; } if (record.type === 'prompt.steered') { - const content = record['content']; const promptIds = record['promptIds']; - if ( - Array.isArray(content) && - Array.isArray(promptIds) && - promptIds.every((id) => typeof id === 'string') - ) { - const key = JSON.stringify(content); - const queue = steeredPromptIdQueues.get(key) ?? []; - queue.push(promptIds as readonly string[]); - steeredPromptIdQueues.set(key, queue); + if (Array.isArray(promptIds) && promptIds.every((id) => typeof id === 'string')) { + pendingSteerPromptIds.push(promptIds as readonly string[]); } continue; } @@ -514,21 +530,14 @@ export class TranscriptService { const input = record['input']; if (Array.isArray(input)) { const key = JSON.stringify(input); - const steerOrigin = (record as { origin?: { kind?: unknown; skillActivations?: unknown } }).origin; - const kind = typeof steerOrigin?.kind === 'string' ? steerOrigin.kind : 'user'; - const skillBlockCount = Array.isArray(steerOrigin?.skillActivations) - ? steerOrigin.skillActivations.length - : 0; - const pairKey = skillBlockCount > 0 ? JSON.stringify(input.slice(skillBlockCount)) : key; - const idQueue = steeredPromptIdQueues.get(pairKey); - const promptIds = - idQueue !== undefined && idQueue.length > 0 ? idQueue.shift() : undefined; - const byKind = - steeredContents.get(key) ?? new Map(); - const queue = byKind.get(kind) ?? []; - queue.push(promptIds); - byKind.set(kind, queue); + 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); + steeredPromptIds.push( + pendingSteerPromptIds.length > 0 ? pendingSteerPromptIds.shift() : undefined, + ); } continue; } @@ -545,7 +554,9 @@ export class TranscriptService { } const base = groupMessagesIntoSnapshot( messages, - sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined, + sawTurnPrompt || steeredContents.size > 0 + ? { taskOriginTurnTaskIds, steeredContents, steeredPromptIds } + : undefined, ); const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base, { resolvePlanRevisionKey: (key) => diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index ea664433efc..84615b94029 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2774,6 +2774,43 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot rolls back steer id queues across context.undo', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-undo-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, time: 2000 }, + { type: 'prompt.steered', activePromptId: 'msg_active', promptIds: ['msg_a'], content: [{ type: 'text', text: 'same' }], steeredAt: '2026-09-01T10:00:00.000Z', time: 2500 }, + { type: 'turn.steer', input: [{ type: 'text', text: 'same' }], origin: { kind: 'user' }, time: 3000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'same' }], toolCalls: [], origin: { kind: 'user' } }, time: 3001 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, time: 4000 }, + { type: 'context.undo', count: 1, time: 5000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } }, time: 6000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working2' }], toolCalls: [] }, time: 6500 }, + { type: 'prompt.steered', activePromptId: 'msg_active2', promptIds: ['msg_b'], content: [{ type: 'text', text: 'same' }], steeredAt: '2026-09-01T10:01:00.000Z', time: 7000 }, + { type: 'turn.steer', input: [{ type: 'text', text: 'same' }], origin: { kind: 'user' }, time: 7500 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'same' }], toolCalls: [], origin: { kind: 'user' } }, time: 7501 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'noted2' }], toolCalls: [] }, time: 8000 }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + const turns = snapshot!.items.filter((item) => item.kind === 'turn'); + expect(turns).toHaveLength(2); + const turn = turns[1]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const steerFrames = turn.steps.flatMap((step) => + step.frames.filter((frame) => frame.kind === 'text' && frame.role === 'user'), + ); + expect(steerFrames).toHaveLength(1); + expect(steerFrames[0]).toMatchObject({ text: 'same', promptIds: ['msg_b'] }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot preserves safe bundled skill provenance before the first step', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-bundled-steer-')); try { diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index d78598c6e77..13a1d6abf12 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -70,10 +70,8 @@ export function groupMessagesIntoSnapshot( messages: readonly HistoryMessage[], options?: { readonly taskOriginTurnTaskIds?: ReadonlySet; - readonly steeredContents?: ReadonlyMap< - string, - ReadonlyMap - >; + readonly steeredContents?: ReadonlyMap>; + readonly steeredPromptIds?: readonly (readonly string[] | undefined)[]; }, ): AgentTranscriptSnapshot { const items: TranscriptItem[] = []; @@ -81,6 +79,8 @@ export function groupMessagesIntoSnapshot( const steeredContents = new Map( [...(options?.steeredContents ?? [])].map(([key, byKind]) => [key, new Map(byKind)]), ); + const steeredPromptIds = options?.steeredPromptIds ?? []; + let steeredPromptIdIndex = 0; let turn: TurnDraft | undefined; let pendingNotificationFrames: { text: string; @@ -246,10 +246,11 @@ export function groupMessagesIntoSnapshot( const contentKey = JSON.stringify(message.content ?? []); const steerKind = originKind ?? 'user'; const steeredByKind = steeredContents.get(contentKey); - const steeredQueue = steeredByKind?.get(steerKind); - if (steeredByKind !== undefined && steeredQueue !== undefined && steeredQueue.length > 0) { - const promptIds = steeredQueue[0]; - steeredByKind.set(steerKind, steeredQueue.slice(1)); + const steeredRemaining = steeredByKind?.get(steerKind) ?? 0; + if (steeredByKind !== undefined && steeredRemaining > 0) { + steeredByKind.set(steerKind, steeredRemaining - 1); + const promptIds = steeredPromptIds[steeredPromptIdIndex]; + steeredPromptIdIndex += 1; const bundled = bundledSkillActivations(message); const parts = message.content ?? []; bundled.forEach((activation, index) => { diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 572e5b404c9..27e1cc73184 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -534,7 +534,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' }]), new Map([['user', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn']); @@ -556,7 +556,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' }]), new Map([['user', [['prompt_a']]]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]), steeredPromptIds: [['prompt_a']] }, ); const turn = snapshot.items[0]; @@ -579,7 +579,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'user', content: [{ type: 'text', text: 'same' }], toolCalls: [], origin: { kind: 'user' } }, { role: 'assistant', content: [{ type: 'text', text: 'noted again' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'same' }]), new Map([['user', [['prompt_a'], ['prompt_b']]]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'same' }]), new Map([['user', 2]])]]), steeredPromptIds: [['prompt_a'], ['prompt_b']] }, ); const turn = snapshot.items[0]; @@ -600,7 +600,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' }]), new Map([['user', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn']); @@ -623,7 +623,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' }]), new Map([['user', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); const turns = snapshot.items.filter((i) => i.kind === 'turn'); @@ -647,7 +647,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' }]), new Map([['user', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify([{ type: 'text', text: 'steered in' }]), new Map([['user', 1]])]]) }, ); expect(snapshot.items.map((i) => i.kind)).toEqual(['turn', 'turn']); @@ -1188,7 +1188,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }, { role: 'assistant', content: [{ type: 'text', text: 'used the skill' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify(skillContent), new Map([['skill_activation', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify(skillContent), new Map([['skill_activation', 1]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']); @@ -1211,7 +1211,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }, { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify(cronContent), new Map([['cron_job', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify(cronContent), new Map([['cron_job', 1]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']); @@ -1241,7 +1241,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }, { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, ], - { steeredContents: new Map([[JSON.stringify(slashContent), new Map([['skill_activation', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify(slashContent), new Map([['skill_activation', 1]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn']); @@ -1272,7 +1272,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { 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', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify(shared), new Map([['skill_activation', 1]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker', 'turn']); @@ -1293,7 +1293,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { 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', [undefined]]])]]) }, + { steeredContents: new Map([[JSON.stringify(shared), new Map([['user', 1]])]]) }, ); expect(snapshot.items.map((item) => item.kind)).toEqual(['turn', 'marker']); From 88b19841c709016b1219b9e67f39a177a7fc8204 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 1 Sep 2026 21:57:14 +0800 Subject: [PATCH 4/5] fix(transcript): shift steer id queues only for user-origin steers --- .../services/transcript/transcriptService.ts | 4 +- .../test/services/transcript.test.ts | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index feaf0e4e81d..928a4c762ef 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -536,7 +536,9 @@ export class TranscriptService { byKind.set(kind, (byKind.get(kind) ?? 0) + 1); steeredContents.set(key, byKind); steeredPromptIds.push( - pendingSteerPromptIds.length > 0 ? pendingSteerPromptIds.shift() : undefined, + kind === 'user' && pendingSteerPromptIds.length > 0 + ? pendingSteerPromptIds.shift() + : undefined, ); } continue; diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 84615b94029..864796cd1de 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2811,6 +2811,72 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot keeps user steer ids when a non-user turn.steer lands in between', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-nonuser-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const skillOrigin = { kind: 'skill_activation', trigger: 'user-slash', skillName: 'deploy' }; + 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: 1500 }, + { type: 'prompt.steered', activePromptId: 'msg_active', promptIds: ['msg_u'], content: [{ type: 'text', text: 'user steer' }], steeredAt: '2026-09-01T10:00:00.000Z', time: 2000 }, + { type: 'turn.steer', input: [{ type: 'text', text: '/deploy' }], origin: skillOrigin, time: 2500 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: '/deploy' }], toolCalls: [], origin: skillOrigin }, time: 2501 }, + { type: 'turn.steer', input: [{ type: 'text', text: 'user steer' }], origin: { kind: 'user' }, time: 3000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'user steer' }], toolCalls: [], origin: { kind: 'user' } }, time: 3001 }, + { 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 turn = snapshot!.items.find((item) => item.kind === 'turn'); + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const steerFrames = turn.steps.flatMap((step) => + step.frames.filter((frame) => frame.kind === 'text' && frame.role === 'user'), + ); + expect(steerFrames).toHaveLength(2); + expect(steerFrames[0]).toMatchObject({ text: '/deploy' }); + expect(steerFrames[0]!.kind === 'text' && steerFrames[0]!.role === 'user' && steerFrames[0]!.promptIds).toBeUndefined(); + expect(steerFrames[1]).toMatchObject({ text: 'user steer', promptIds: ['msg_u'] }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('readColdSnapshot keeps the surviving turn\'s steer ids when undo drops the newest prompt', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-survive-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, time: 1500 }, + { type: 'prompt.steered', activePromptId: 'msg_active', promptIds: ['msg_s'], content: [{ type: 'text', text: 'same' }], steeredAt: '2026-09-01T10:00:00.000Z', time: 2000 }, + { type: 'turn.steer', input: [{ type: 'text', text: 'same' }], origin: { kind: 'user' }, time: 2500 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'same' }], toolCalls: [], origin: { kind: 'user' } }, time: 3001 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, time: 3500 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } }, time: 4000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working2' }], toolCalls: [] }, time: 4500 }, + { type: 'context.undo', count: 1, time: 5000 }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + 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'); + const steerFrames = turn.steps.flatMap((step) => + step.frames.filter((frame) => frame.kind === 'text' && frame.role === 'user'), + ); + expect(steerFrames).toHaveLength(1); + expect(steerFrames[0]).toMatchObject({ text: 'same', promptIds: ['msg_s'] }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot preserves safe bundled skill provenance before the first step', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-bundled-steer-')); try { From 6ba9eb8c274796713bfca87515f8e74f504ed961 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 1 Sep 2026 22:10:06 +0800 Subject: [PATCH 5/5] fix(transcript): align steer id indexes for hidden and marker steers and preserve pre-clear steer state --- .../services/transcript/transcriptService.ts | 22 ++++++- .../test/services/transcript.test.ts | 65 +++++++++++++++++++ packages/transcript/src/history/groupTurns.ts | 21 +++--- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 928a4c762ef..9a05cfd17a5 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -479,6 +479,13 @@ export class TranscriptService { steeredIdsCount: number; }[] = []; let anchorFloor = 0; + let floorSteerState: + | { + steeredSnapshot: Map>; + pendingSteerCount: number; + steeredIdsCount: number; + } + | undefined; let sawTurnPrompt = false; for (const record of records) { if (record.type === 'context.undo') { @@ -493,16 +500,25 @@ export class TranscriptService { if (poppedAny) { const top = anchorStack.length > anchorFloor ? anchorStack[anchorStack.length - 1] : undefined; - pendingSteerPromptIds.length = top?.pendingSteerCount ?? 0; - steeredPromptIds.length = top?.steeredIdsCount ?? 0; + pendingSteerPromptIds.length = top?.pendingSteerCount ?? floorSteerState?.pendingSteerCount ?? 0; + steeredPromptIds.length = top?.steeredIdsCount ?? floorSteerState?.steeredIdsCount ?? 0; steeredContents = new Map( - [...(top?.steeredSnapshot ?? [])].map(([key, byKind]) => [key, new Map(byKind)]), + [...(top?.steeredSnapshot ?? floorSteerState?.steeredSnapshot ?? [])].map( + ([key, byKind]) => [key, new Map(byKind)], + ), ); } continue; } if (record.type === 'context.clear') { anchorFloor = anchorStack.length; + floorSteerState = { + steeredSnapshot: new Map( + [...steeredContents].map(([key, byKind]) => [key, new Map(byKind)]), + ), + pendingSteerCount: pendingSteerPromptIds.length, + steeredIdsCount: steeredPromptIds.length, + }; continue; } if (record.type === 'context.append_message') { diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 864796cd1de..c190f9063b4 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2877,6 +2877,71 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot keeps user steer ids when a marker-folded steer lands in between', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-marker-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const skillOrigin = { kind: 'skill_activation', trigger: 'model-tool', skillName: 'deploy' }; + 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: 1500 }, + { type: 'prompt.steered', activePromptId: 'msg_active', promptIds: ['msg_u'], content: [{ type: 'text', text: 'user steer' }], steeredAt: '2026-09-01T10:00:00.000Z', time: 2000 }, + { type: 'turn.steer', input: [{ type: 'text', text: '/deploy' }], origin: skillOrigin, time: 2500 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: '/deploy' }], toolCalls: [], origin: skillOrigin }, time: 2501 }, + { type: 'turn.steer', input: [{ type: 'text', text: 'user steer' }], origin: { kind: 'user' }, time: 3000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'user steer' }], toolCalls: [], origin: { kind: 'user' } }, time: 3001 }, + { 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 turn = snapshot!.items.find((item) => item.kind === 'turn'); + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const steerFrames = turn.steps.flatMap((step) => + step.frames.filter((frame) => frame.kind === 'text' && frame.role === 'user'), + ); + expect(steerFrames).toHaveLength(1); + expect(steerFrames[0]).toMatchObject({ text: 'user steer', promptIds: ['msg_u'] }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('readColdSnapshot preserves pre-clear steer ids when undo drops the first post-clear prompt', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-clear-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'one' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, time: 1500 }, + { type: 'prompt.steered', activePromptId: 'msg_active', promptIds: ['msg_s'], content: [{ type: 'text', text: 'same' }], steeredAt: '2026-09-01T10:00:00.000Z', time: 2000 }, + { type: 'turn.steer', input: [{ type: 'text', text: 'same' }], origin: { kind: 'user' }, time: 2500 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'same' }], toolCalls: [], origin: { kind: 'user' } }, time: 3001 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, time: 3500 }, + { type: 'context.clear', time: 4000 }, + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'two' }], toolCalls: [], origin: { kind: 'user' } }, time: 5000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working2' }], toolCalls: [] }, time: 5500 }, + { type: 'context.undo', count: 1, time: 6000 }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + 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'); + const steerFrames = turn.steps.flatMap((step) => + step.frames.filter((frame) => frame.kind === 'text' && frame.role === 'user'), + ); + expect(steerFrames).toHaveLength(1); + expect(steerFrames[0]).toMatchObject({ text: 'same', promptIds: ['msg_s'] }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot preserves safe bundled skill provenance before the first step', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-bundled-steer-')); try { diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 13a1d6abf12..a6921c69047 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -228,6 +228,16 @@ export function groupMessagesIntoSnapshot( if (!isTaskOrigin) prevNonTaskRole = message.role; if (message.role === 'user') { + const contentKey = JSON.stringify(message.content ?? []); + const steerKind = originKind ?? 'user'; + const steeredByKind = steeredContents.get(contentKey); + const steeredRemaining = steeredByKind?.get(steerKind) ?? 0; + const matchedSteer = steeredByKind !== undefined && steeredRemaining > 0; + const steeredPromptId = matchedSteer ? steeredPromptIds[steeredPromptIdIndex] : undefined; + if (matchedSteer) { + steeredByKind.set(steerKind, steeredRemaining - 1); + steeredPromptIdIndex += 1; + } if (originKind !== undefined && HIDDEN_USER_ORIGINS.has(originKind)) { if (opensOwnTurn(message)) { const opening = @@ -243,14 +253,7 @@ export function groupMessagesIntoSnapshot( pushMarker(markerKey, { text: textOf(message), origin: message.origin }); continue; } - const contentKey = JSON.stringify(message.content ?? []); - 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 promptIds = steeredPromptIds[steeredPromptIdIndex]; - steeredPromptIdIndex += 1; + if (matchedSteer) { const bundled = bundledSkillActivations(message); const parts = message.content ?? []; bundled.forEach((activation, index) => { @@ -265,7 +268,7 @@ export function groupMessagesIntoSnapshot( text: opening.text, taskId: undefined, attachmentIds: opening.attachmentIds, - promptIds, + promptIds: steeredPromptId, origin: projectTranscriptUserOrigin(message.origin), steered: true, });