From d9459eaf795c835acd8f5206ebc38cb115ab4102 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 23:27:06 +0800 Subject: [PATCH 01/23] fix(transcript): fold mid-turn task notifications into the current turn on cold rebuild --- packages/transcript/src/history/groupTurns.ts | 27 ++++++++++++++ packages/transcript/test/layers.test.ts | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 3c23110ba7..220f433d0f 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -151,9 +151,12 @@ export function groupMessagesIntoSnapshot( items.push(item); }; + let prevRole: string | undefined; for (const message of messages) { if (message.role === 'system') continue; const originKind = message.origin?.kind; + const prevRoleAtEntry = prevRole; + prevRole = message.role; if (message.role === 'user') { if (originKind !== undefined && HIDDEN_USER_ORIGINS.has(originKind)) { @@ -171,6 +174,30 @@ export function groupMessagesIntoSnapshot( } continue; } + if (originKind === 'task' || originKind === 'background_task' || originKind === 'task_notification') { + if (prevRoleAtEntry !== 'assistant' && prevRoleAtEntry !== 'tool') { + const opening = foldTurnOpeningInput(message); + startTurn(mapOrigin(message), opening.text, opening.attachmentIds); + continue; + } + const origin = message.origin as { taskId?: unknown } | undefined; + const taskId = typeof origin?.taskId === 'string' ? origin.taskId : undefined; + const current = ensureTurn(mapOrigin(message)); + let step = current.steps.at(-1); + if (step === undefined) { + step = { stepId: `${current.turnId}.1`, ordinal: 1, frames: [] }; + current.steps.push(step); + } + step.frames.push({ + kind: 'text', + frameId: `${step.stepId}.f${step.frames.length + 1}`, + role: 'user', + text: textOf(message), + ...(taskId !== undefined ? { taskId } : {}), + }); + syncTurnItem(items, current); + continue; + } const bundled = bundledSkillActivations(message); if (bundled.length > 0) { const parts = message.content ?? []; diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 9fa9c14a24..3e345ce08e 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -443,6 +443,43 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(marker?.kind === 'marker' && marker.marker).toBe('compaction'); }); + it('folds task-notification user messages into the current turn instead of opening their own', () => { + const snapshot = groupMessagesIntoSnapshot([ + { role: 'user', content: [{ type: 'text', text: 'run it' }], toolCalls: [], origin: { kind: 'user' } }, + { + role: 'assistant', + content: [{ type: 'text', text: 'starting' }], + toolCalls: [{ id: 'c1', name: 'Bash', arguments: '{"command":"ls"}' }], + }, + { + role: 'user', + content: [{ type: 'text', text: '' }], + toolCalls: [], + origin: { kind: 'task', taskId: 'task-9' } as { kind: string }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'continuing' }], + toolCalls: [], + }, + ]); + + const turns = snapshot.items.filter((i) => i.kind === 'turn'); + expect(turns).toHaveLength(1); + const turn = turns[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const userFrames = turn.steps + .flatMap((step) => step.frames) + .filter((f) => f.kind === 'text' && f.role === 'user'); + expect(userFrames).toHaveLength(1); + expect(userFrames[0]).toMatchObject({ taskId: 'task-9' }); + const assistantTexts = turn.steps + .flatMap((step) => step.frames) + .filter((f) => f.kind === 'text' && f.role === 'assistant') + .map((f) => f.kind === 'text' && f.text); + expect(assistantTexts).toEqual(['starting', 'continuing']); + }); + it('expands a bundled prompt into per-skill markers and a caller-text turn', () => { const snapshot = groupMessagesIntoSnapshot([ { From 19e513c23bf552d5476c804cb9378213021cdcc2 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 23:28:21 +0800 Subject: [PATCH 02/23] chore: changeset for the notification fold fix --- .changeset/transcript-notification-fold.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/transcript-notification-fold.md diff --git a/.changeset/transcript-notification-fold.md b/.changeset/transcript-notification-fold.md new file mode 100644 index 0000000000..22b28162c2 --- /dev/null +++ b/.changeset/transcript-notification-fold.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the cold transcript rebuild splitting a turn at background-task completion notices; they now fold into the current turn like the live stream does. From 599d0d90b1a9895ddd26fff871eebafc4c1b943c Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 23:47:35 +0800 Subject: [PATCH 03/23] fix(transcript): key the notification fold on persisted task-turn boundaries, not the previous message role --- .../services/transcript/transcriptService.ts | 10 +++++- packages/transcript/src/history/groupTurns.ts | 12 +++++-- packages/transcript/test/layers.test.ts | 31 +++++++++++++++++-- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 6a2f163c00..c66383a1ca 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -507,7 +507,15 @@ export class TranscriptService { throw error; } const messages = [...reduceContextTranscript(records).entries]; - const base = groupMessagesIntoSnapshot(messages); + const taskOriginTurnTaskIds = new Set(); + for (const record of records) { + if (record.type !== 'turn.started') continue; + const origin = (record as { origin?: { kind?: unknown; taskId?: unknown } }).origin; + if (origin?.kind === 'task' && typeof origin.taskId === 'string') { + taskOriginTurnTaskIds.add(origin.taskId); + } + } + const base = groupMessagesIntoSnapshot(messages, { taskOriginTurnTaskIds }); return foldWireRecordFacts(records, base); } diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 220f433d0f..c59f75f8b6 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -65,6 +65,9 @@ const FALLBACK_ORIGIN: TurnOrigin = { kind: 'other' }; export function groupMessagesIntoSnapshot( messages: readonly HistoryMessage[], + options?: { + readonly taskOriginTurnTaskIds?: ReadonlySet; + }, ): AgentTranscriptSnapshot { const items: TranscriptItem[] = []; const attachments: TranscriptAttachment[] = []; @@ -175,13 +178,16 @@ export function groupMessagesIntoSnapshot( continue; } if (originKind === 'task' || originKind === 'background_task' || originKind === 'task_notification') { - if (prevRoleAtEntry !== 'assistant' && prevRoleAtEntry !== 'tool') { + const origin = message.origin as { taskId?: unknown } | undefined; + const taskId = typeof origin?.taskId === 'string' ? origin.taskId : undefined; + const opensOwn = options?.taskOriginTurnTaskIds === undefined + ? prevRoleAtEntry !== 'assistant' && prevRoleAtEntry !== 'tool' + : taskId === undefined || options.taskOriginTurnTaskIds.has(taskId); + if (opensOwn) { const opening = foldTurnOpeningInput(message); startTurn(mapOrigin(message), opening.text, opening.attachmentIds); continue; } - const origin = message.origin as { taskId?: unknown } | undefined; - const taskId = typeof origin?.taskId === 'string' ? origin.taskId : undefined; const current = ensureTurn(mapOrigin(message)); let step = current.steps.at(-1); if (step === undefined) { diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 3e345ce08e..d355ead89a 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -444,7 +444,8 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }); it('folds task-notification user messages into the current turn instead of opening their own', () => { - const snapshot = groupMessagesIntoSnapshot([ + const snapshot = groupMessagesIntoSnapshot( + [ { role: 'user', content: [{ type: 'text', text: 'run it' }], toolCalls: [], origin: { kind: 'user' } }, { role: 'assistant', @@ -462,7 +463,9 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { content: [{ type: 'text', text: 'continuing' }], toolCalls: [], }, - ]); + ], + { taskOriginTurnTaskIds: new Set() }, + ); const turns = snapshot.items.filter((i) => i.kind === 'turn'); expect(turns).toHaveLength(1); @@ -830,6 +833,30 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(cronTurn?.kind === 'turn' && cronTurn.origin.kind).toBe('cron'); }); + it('opens a task turn for a notification with a task-origin turn.started in the wire', () => { + 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: [{ type: 'text', text: 'task done' }], + toolCalls: [], + origin: { kind: 'task', taskId: 'task-9' } as { kind: string }, + }, + ], + { taskOriginTurnTaskIds: new Set(['task-9']) }, + ); + + const taskTurn = snapshot.items[1]; + if (taskTurn?.kind !== 'turn') throw new Error('expected turn'); + expect(taskTurn.origin).toMatchObject({ kind: 'task', taskId: 'task-9' }); + }); + it('maps legacy background_task origins to task turns, preserving the taskId', () => { const snapshot = groupMessagesIntoSnapshot([ { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, From 0ccda12a16e29c927a1be728b57b70117d09e2f6 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 23:54:25 +0800 Subject: [PATCH 04/23] fix(transcript): collect background_task turn origins too, and fall back when the wire has no turn.started records --- .../src/services/transcript/transcriptService.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index c66383a1ca..db55e5e877 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -508,14 +508,22 @@ export class TranscriptService { } const messages = [...reduceContextTranscript(records).entries]; const taskOriginTurnTaskIds = new Set(); + let sawTurnStarted = false; for (const record of records) { if (record.type !== 'turn.started') continue; + sawTurnStarted = true; const origin = (record as { origin?: { kind?: unknown; taskId?: unknown } }).origin; - if (origin?.kind === 'task' && typeof origin.taskId === 'string') { + if ( + (origin?.kind === 'task' || origin?.kind === 'background_task') && + typeof origin.taskId === 'string' + ) { taskOriginTurnTaskIds.add(origin.taskId); } } - const base = groupMessagesIntoSnapshot(messages, { taskOriginTurnTaskIds }); + const base = groupMessagesIntoSnapshot( + messages, + sawTurnStarted ? { taskOriginTurnTaskIds } : undefined, + ); return foldWireRecordFacts(records, base); } From 04255a7aa7e825feb3017010086b97b2a52c8a15 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 19 Aug 2026 23:59:23 +0800 Subject: [PATCH 05/23] fix(transcript): fold consecutive task notifications into the same turn --- packages/transcript/src/history/groupTurns.ts | 10 ++++++---- packages/transcript/test/layers.test.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index c59f75f8b6..8d283eda50 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -154,12 +154,14 @@ export function groupMessagesIntoSnapshot( items.push(item); }; - let prevRole: string | undefined; + let prevNonTaskRole: string | undefined; for (const message of messages) { if (message.role === 'system') continue; const originKind = message.origin?.kind; - const prevRoleAtEntry = prevRole; - prevRole = message.role; + const isTaskOrigin = + originKind === 'task' || originKind === 'background_task' || originKind === 'task_notification'; + const prevRoleAtEntry = prevNonTaskRole; + if (!isTaskOrigin) prevNonTaskRole = message.role; if (message.role === 'user') { if (originKind !== undefined && HIDDEN_USER_ORIGINS.has(originKind)) { @@ -177,7 +179,7 @@ export function groupMessagesIntoSnapshot( } continue; } - if (originKind === 'task' || originKind === 'background_task' || originKind === 'task_notification') { + if (isTaskOrigin) { const origin = message.origin as { taskId?: unknown } | undefined; const taskId = typeof origin?.taskId === 'string' ? origin.taskId : undefined; const opensOwn = options?.taskOriginTurnTaskIds === undefined diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index d355ead89a..17a227f84b 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -458,6 +458,12 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string }, }, + { + role: 'user', + content: [{ type: 'text', text: '' }], + toolCalls: [], + origin: { kind: 'task', taskId: 'task-8' } as { kind: string }, + }, { role: 'assistant', content: [{ type: 'text', text: 'continuing' }], @@ -474,8 +480,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { const userFrames = turn.steps .flatMap((step) => step.frames) .filter((f) => f.kind === 'text' && f.role === 'user'); - expect(userFrames).toHaveLength(1); - expect(userFrames[0]).toMatchObject({ taskId: 'task-9' }); + expect(userFrames.map((f) => f.kind === 'text' && f.taskId)).toEqual(['task-9', 'task-8']); const assistantTexts = turn.steps .flatMap((step) => step.frames) .filter((f) => f.kind === 'text' && f.role === 'assistant') From 2f2b2c5cbbb24b65d3bb7e1b951d84a67353311c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 00:04:16 +0800 Subject: [PATCH 06/23] fix(transcript): normalize folded notification text to the live title/body form --- packages/transcript/src/history/groupTurns.ts | 25 ++++++++++++++++++- packages/transcript/test/layers.test.ts | 6 ++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 8d283eda50..88fa56121a 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -200,7 +200,7 @@ export function groupMessagesIntoSnapshot( kind: 'text', frameId: `${step.stepId}.f${step.frames.length + 1}`, role: 'user', - text: textOf(message), + text: notificationFrameText(textOf(message)), ...(taskId !== undefined ? { taskId } : {}), }); syncTurnItem(items, current); @@ -280,6 +280,29 @@ export function groupMessagesIntoSnapshot( return { items, tasks: [], interactions: [], attachments, todos: [], prompts: [], meta: {} }; } +function notificationFrameText(text: string): string { + if (!text.startsWith(''); + const closingStart = text.lastIndexOf(''); + if (openingEnd === -1 || closingStart <= openingEnd) return text; + const inner = text.slice(openingEnd + 1, closingStart); + const lines = inner.split('\n'); + let title = ''; + let lastHeaderIndex = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (line.startsWith('Title: ')) { + title = line.slice('Title: '.length); + lastHeaderIndex = i; + } else if (line.startsWith('Severity: ')) { + lastHeaderIndex = i; + } + } + const body = lines.slice(lastHeaderIndex + 1).join('\n').trim(); + if (title.length > 0 && body.length > 0) return `${title}\n${body}`; + return title.length > 0 ? title : body.length > 0 ? body : text; +} + function opensOwnTurn(message: HistoryMessage): boolean { const origin = message.origin as { kind?: unknown; name?: unknown } | undefined; return ( diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 17a227f84b..d7e8aed358 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -454,7 +454,10 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }, { role: 'user', - content: [{ type: 'text', text: '' }], + content: [{ + type: 'text', + text: '\nTitle: Background agent completed\nSeverity: info\ninspect done.\n', + }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string }, }, @@ -481,6 +484,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { .flatMap((step) => step.frames) .filter((f) => f.kind === 'text' && f.role === 'user'); expect(userFrames.map((f) => f.kind === 'text' && f.taskId)).toEqual(['task-9', 'task-8']); + expect(userFrames[0]).toMatchObject({ text: 'Background agent completed\ninspect done.' }); const assistantTexts = turn.steps .flatMap((step) => step.frames) .filter((f) => f.kind === 'text' && f.role === 'assistant') From 9905e3bee476ab51eaede5f8dfd6aa0f7804757c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 00:14:21 +0800 Subject: [PATCH 07/23] fix(transcript): stop folded notification text before child blocks, preserve legacy background_task turns absent from the boundary set --- packages/transcript/src/history/groupTurns.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 88fa56121a..52e7ccd844 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -184,7 +184,9 @@ export function groupMessagesIntoSnapshot( const taskId = typeof origin?.taskId === 'string' ? origin.taskId : undefined; const opensOwn = options?.taskOriginTurnTaskIds === undefined ? prevRoleAtEntry !== 'assistant' && prevRoleAtEntry !== 'tool' - : taskId === undefined || options.taskOriginTurnTaskIds.has(taskId); + : taskId === undefined || + options.taskOriginTurnTaskIds.has(taskId) || + originKind === 'background_task'; if (opensOwn) { const opening = foldTurnOpeningInput(message); startTurn(mapOrigin(message), opening.text, opening.attachmentIds); @@ -298,7 +300,11 @@ function notificationFrameText(text: string): string { lastHeaderIndex = i; } } - const body = lines.slice(lastHeaderIndex + 1).join('\n').trim(); + const body = lines + .slice(lastHeaderIndex + 1) + .filter((line) => !line.trimStart().startsWith('<')) + .join('\n') + .trim(); if (title.length > 0 && body.length > 0) return `${title}\n${body}`; return title.length > 0 ? title : body.length > 0 ? body : text; } From 163ec98e759798e025910b5857c3997188feed47 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 00:27:45 +0800 Subject: [PATCH 08/23] fix(transcript): truncate folded notification text at the first child-block tag, not just the tag lines --- packages/transcript/src/history/groupTurns.ts | 8 +++--- packages/transcript/test/layers.test.ts | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 52e7ccd844..6e8599a931 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -300,11 +300,9 @@ function notificationFrameText(text: string): string { lastHeaderIndex = i; } } - const body = lines - .slice(lastHeaderIndex + 1) - .filter((line) => !line.trimStart().startsWith('<')) - .join('\n') - .trim(); + const bodyLines = lines.slice(lastHeaderIndex + 1); + const childStart = bodyLines.findIndex((line) => line.trimStart().startsWith('<')); + const body = (childStart === -1 ? bodyLines : bodyLines.slice(0, childStart)).join('\n').trim(); if (title.length > 0 && body.length > 0) return `${title}\n${body}`; return title.length > 0 ? title : body.length > 0 ? body : text; } diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index d7e8aed358..5bcca983da 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -492,6 +492,31 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(assistantTexts).toEqual(['starting', 'continuing']); }); + it('stops folded notification text before child output blocks', () => { + const xml = [ + '', + 'Title: Background agent completed', + 'Severity: info', + 'inspect done.', + '/tmp/out.log', + '/tmp/out.log', + 'full output here', + '', + ].join('\n'); + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } }, + ], + { taskOriginTurnTaskIds: new Set() }, + ); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user'); + expect(frame).toMatchObject({ text: 'Background agent completed\ninspect done.' }); + }); + it('expands a bundled prompt into per-skill markers and a caller-text turn', () => { const snapshot = groupMessagesIntoSnapshot([ { From b43e971eaee2a33603cef0152cec0ed47bd1f766 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 00:39:25 +0800 Subject: [PATCH 09/23] fix(transcript): drop folded notifications without a persisted step, truncate only at output blocks --- packages/transcript/src/history/groupTurns.ts | 17 +++-- packages/transcript/test/layers.test.ts | 65 +++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 6e8599a931..9e4e85960a 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -192,20 +192,16 @@ export function groupMessagesIntoSnapshot( startTurn(mapOrigin(message), opening.text, opening.attachmentIds); continue; } - const current = ensureTurn(mapOrigin(message)); - let step = current.steps.at(-1); - if (step === undefined) { - step = { stepId: `${current.turnId}.1`, ordinal: 1, frames: [] }; - current.steps.push(step); - } + const step = turn?.steps.at(-1); + if (turn === undefined || step === undefined) continue; step.frames.push({ kind: 'text', frameId: `${step.stepId}.f${step.frames.length + 1}`, role: 'user', text: notificationFrameText(textOf(message)), - ...(taskId !== undefined ? { taskId } : {}), + taskId, }); - syncTurnItem(items, current); + syncTurnItem(items, turn); continue; } const bundled = bundledSkillActivations(message); @@ -301,7 +297,10 @@ function notificationFrameText(text: string): string { } } const bodyLines = lines.slice(lastHeaderIndex + 1); - const childStart = bodyLines.findIndex((line) => line.trimStart().startsWith('<')); + const childStart = bodyLines.findIndex((line) => { + const trimmed = line.trimStart(); + return trimmed.startsWith(' 0 && body.length > 0) return `${title}\n${body}`; return title.length > 0 ? title : body.length > 0 ? body : text; diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 5bcca983da..701d976ed1 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -517,6 +517,71 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(frame).toMatchObject({ text: 'Background agent completed\ninspect done.' }); }); + it('drops a folded notification that arrives before the turn has any step', () => { + const xml = [ + '', + 'Title: Background agent completed', + 'Severity: info', + 'early done.', + '', + ].join('\n'); + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } }, + { role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, + ], + { taskOriginTurnTaskIds: new Set() }, + ); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.steps).toHaveLength(1); + expect( + turn.steps.flatMap((step) => step.frames).filter((f) => f.kind === 'text' && f.role === 'user'), + ).toHaveLength(0); + }); + + it('drops a folded notification when no turn is open yet', () => { + const snapshot = groupMessagesIntoSnapshot( + [ + { + role: 'user', + content: [{ type: 'text', text: '' }], + toolCalls: [], + origin: { kind: 'task', taskId: 'task-9' } as { kind: string }, + }, + ], + { taskOriginTurnTaskIds: new Set() }, + ); + expect(snapshot.items).toHaveLength(0); + }); + + it('keeps body lines that start with an angle bracket but are not output blocks', () => { + const xml = [ + '', + 'Title: Background agent completed', + 'Severity: info', + 'first line.', + '
pasted markup
', + 'last line.', + '
', + ].join('\n'); + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } }, + ], + { taskOriginTurnTaskIds: new Set() }, + ); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user'); + expect(frame).toMatchObject({ + text: 'Background agent completed\nfirst line.\n
pasted markup
\nlast line.', + }); + }); + it('expands a bundled prompt into per-skill markers and a caller-text turn', () => { const snapshot = groupMessagesIntoSnapshot([ { From bbb2559160feda12741be080ed4585ad41e0ac41 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 00:54:31 +0800 Subject: [PATCH 10/23] fix(transcript): attach mid-turn task notifications to the following step, cold and live --- .../src/services/transcript/coreEventMap.ts | 50 ++++++++---- .../test/services/transcript.test.ts | 76 +++++++++++++++++++ packages/transcript/src/history/groupTurns.ts | 41 ++++++---- packages/transcript/test/layers.test.ts | 48 +++++++++++- 4 files changed, 180 insertions(+), 35 deletions(-) diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index e6654822b2..322e0e3e7f 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -201,6 +201,7 @@ export class AgentTranscriptProjector { /** Latest header of the in-flight (or most recent) turn; kept whole so terminal upserts preserve `origin` / `startedAt` by reference. */ private currentTurn: TurnHeader | undefined; private currentStep: StepHeader | undefined; + private pendingTaskNotifications: { text: string; taskId: string | undefined }[] = []; /** turnId → highest step ordinal seen (engine-reported placement hint). */ private readonly stepOrdinals = new Map(); private frameOrdinal = 0; @@ -379,6 +380,7 @@ export class AgentTranscriptProjector { startedAt: nowIso(), }; this.currentStep = undefined; + this.pendingTaskNotifications = []; this.openText = undefined; this.openThinking = undefined; ops.push({ op: 'turn.upsert', turn: this.currentTurn }); @@ -420,6 +422,7 @@ export class AgentTranscriptProjector { }; ops.push({ op: 'turn.upsert', turn: this.currentTurn }); this.currentStep = undefined; + this.pendingTaskNotifications = []; if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') { ops.push( this.markerOp('interruption', { turnId: event.turnId, reason: event.interruptReason }), @@ -473,7 +476,23 @@ export class AgentTranscriptProjector { this.frameOrdinal = 0; this.openText = undefined; this.openThinking = undefined; - return [{ op: 'step.upsert', turnId, step: this.currentStep }]; + 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 = []; + return ops; } private onStepCompleted(event: { @@ -865,20 +884,21 @@ export class AgentTranscriptProjector { }): TranscriptOperation[] { const step = this.currentStep; const turn = this.currentTurn; - const midTurn = - step !== undefined && - turn !== undefined && - step.state === 'running' && - turn.state === 'running'; - if (!midTurn) return []; - const frame: TextFrame = { - kind: 'text', - frameId: `${step.stepId}.f${++this.frameOrdinal}`, - role: 'user', - text: `${event.title}\n${event.body}`.trim(), - taskId: event.sourceId, - }; - return [{ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }]; + if (turn === undefined || turn.state !== 'running') return []; + const text = `${event.title}\n${event.body}`.trim(); + if (step !== undefined && step.state === 'running') { + const frame: TextFrame = { + kind: 'text', + frameId: `${step.stepId}.f${++this.frameOrdinal}`, + role: 'user', + text, + taskId: event.sourceId, + }; + return [{ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }]; + } + if (turn.origin?.kind === 'task') return []; + this.pendingTaskNotifications.push({ text, taskId: event.sourceId }); + return []; } private onTaskLifecycle(event: { diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index da74eadbf7..230333ddee 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1464,6 +1464,82 @@ describe('AgentTranscriptProjector', () => { expect(frame?.kind === 'text' && frame.text).toContain('Background process completed'); }); + it('attaches a between-steps task notification to the following step', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + const notified = (sourceId: string): ProjectorBusEvent => + ev({ + type: 'task.notified', + notificationType: 'task.completed', + title: 'Background agent completed', + body: 'inspect done.', + severity: 'info', + sourceKind: 'background_task', + sourceId, + }); + + feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + feed(ev({ type: 'turn.step.completed', turnId: 1, step: 1 })); + feed(notified('task_1')); + feed(notified('task_2')); + expect(turnOps('t1', tx.getItems()).steps[0]!.frames).toHaveLength(0); + + feed(ev({ type: 'turn.step.started', turnId: 1, step: 2 })); + const steps = turnOps('t1', tx.getItems()).steps; + expect(steps).toHaveLength(2); + expect(steps[1]!.frames.map((f) => f.kind === 'text' && f.taskId)).toEqual(['task_1', 'task_2']); + }); + + it('drops a task notification that is the turn prompt itself', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'task', taskId: 'task_1' } })); + 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: 1, step: 1 })); + expect(turnOps('t1', tx.getItems()).steps[0]!.frames).toHaveLength(0); + }); + + it('drops a buffered task notification when the turn ends before the next step', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + feed(ev({ type: 'turn.step.completed', turnId: 1, step: 1 })); + 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.ended', turnId: 1, reason: 'completed' })); + + feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); + feed(ev({ type: 'turn.step.started', turnId: 2, step: 1 })); + expect(turnOps('t2', tx.getItems()).steps[0]!.frames).toHaveLength(0); + }); + it('replaces the global todo document on a confirmed TodoList write', () => { const projector = new AgentTranscriptProjector('main'); const tx = new AgentTranscript('main'); diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 9e4e85960a..e52dbf45ba 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -72,6 +72,7 @@ export function groupMessagesIntoSnapshot( const items: TranscriptItem[] = []; const attachments: TranscriptAttachment[] = []; let turn: TurnDraft | undefined; + let pendingNotificationFrames: { text: string; taskId: string | undefined }[] = []; let nextOrdinal = 0; let markerCount = 0; @@ -143,6 +144,7 @@ export function groupMessagesIntoSnapshot( const startTurn = (origin: TurnOrigin, prompt?: string, attachmentIds?: string[]): TurnDraft => { const ordinal = nextOrdinal; nextOrdinal += 1; + pendingNotificationFrames = []; turn = { turnId: `t${ordinal}`, ordinal, origin, prompt, attachmentIds, steps: [] }; items.push(draftToTurnItem(turn)); return turn; @@ -192,16 +194,7 @@ export function groupMessagesIntoSnapshot( startTurn(mapOrigin(message), opening.text, opening.attachmentIds); continue; } - const step = turn?.steps.at(-1); - if (turn === undefined || step === undefined) continue; - step.frames.push({ - kind: 'text', - frameId: `${step.stepId}.f${step.frames.length + 1}`, - role: 'user', - text: notificationFrameText(textOf(message)), - taskId, - }); - syncTurnItem(items, turn); + pendingNotificationFrames.push({ text: notificationFrameText(textOf(message)), taskId }); continue; } const bundled = bundledSkillActivations(message); @@ -238,6 +231,16 @@ export function groupMessagesIntoSnapshot( frameCount += 1; return `${step.stepId}.f${frameCount}`; }; + for (const pending of pendingNotificationFrames) { + step.frames.push({ + kind: 'text', + frameId: nextFrameId(), + role: 'user', + text: pending.text, + taskId: pending.taskId, + }); + } + pendingNotificationFrames = []; for (const part of message.content ?? []) { if (part.type === 'text' && 'text' in part && typeof part.text === 'string' && part.text.length > 0) { step.frames.push({ kind: 'text', frameId: nextFrameId(), role: 'assistant', text: part.text }); @@ -285,18 +288,24 @@ function notificationFrameText(text: string): string { if (openingEnd === -1 || closingStart <= openingEnd) return text; const inner = text.slice(openingEnd + 1, closingStart); const lines = inner.split('\n'); + let headerEnd = 0; + while (headerEnd < lines.length && lines[headerEnd]!.trim() === '') headerEnd += 1; let title = ''; - let lastHeaderIndex = -1; - for (let i = 0; i < lines.length; i++) { + let bodyStart = headerEnd; + for (let i = headerEnd; i < lines.length; i++) { const line = lines[i]!; if (line.startsWith('Title: ')) { title = line.slice('Title: '.length); - lastHeaderIndex = i; - } else if (line.startsWith('Severity: ')) { - lastHeaderIndex = i; + bodyStart = i + 1; + continue; + } + if (line.startsWith('Severity: ')) { + bodyStart = i + 1; + continue; } + break; } - const bodyLines = lines.slice(lastHeaderIndex + 1); + const bodyLines = lines.slice(bodyStart); const childStart = bodyLines.findIndex((line) => { const trimmed = line.trimStart(); return trimmed.startsWith(' { .filter((f) => f.kind === 'text' && f.role === 'assistant') .map((f) => f.kind === 'text' && f.text); expect(assistantTexts).toEqual(['starting', 'continuing']); + expect(turn.steps).toHaveLength(2); + expect(turn.steps[1]?.frames.map((f) => f.kind === 'text' && f.role)).toEqual([ + 'user', + 'user', + 'assistant', + ]); }); it('stops folded notification text before child output blocks', () => { @@ -508,6 +514,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } }, { role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, { role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] }, ], { taskOriginTurnTaskIds: new Set() }, ); @@ -517,7 +524,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(frame).toMatchObject({ text: 'Background agent completed\ninspect done.' }); }); - it('drops a folded notification that arrives before the turn has any step', () => { + it('buffers a folded notification that arrives before the first step into that step', () => { const xml = [ '', 'Title: Background agent completed', @@ -536,9 +543,14 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { const turn = snapshot.items[0]; if (turn?.kind !== 'turn') throw new Error('expected turn'); expect(turn.steps).toHaveLength(1); - expect( - turn.steps.flatMap((step) => step.frames).filter((f) => f.kind === 'text' && f.role === 'user'), - ).toHaveLength(0); + expect(turn.steps[0]?.frames.map((f) => f.kind === 'text' && f.role)).toEqual([ + 'user', + 'assistant', + ]); + expect(turn.steps[0]?.frames[0]).toMatchObject({ + text: 'Background agent completed\nearly done.', + taskId: 'task-9', + }); }); it('drops a folded notification when no turn is open yet', () => { @@ -571,6 +583,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } }, { role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, { role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] }, ], { taskOriginTurnTaskIds: new Set() }, ); @@ -582,6 +595,33 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { }); }); + it('parses only the leading header lines, keeping later Title-like lines as body', () => { + const xml = [ + '', + 'Title: Background agent completed', + 'Severity: info', + 'first line.', + 'Title: details', + 'last line.', + '', + ].join('\n'); + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'task-9' } as { kind: string } }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] }, + ], + { taskOriginTurnTaskIds: new Set() }, + ); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user'); + expect(frame).toMatchObject({ + text: 'Background agent completed\nfirst line.\nTitle: details\nlast line.', + }); + }); + it('expands a bundled prompt into per-skill markers and a caller-text turn', () => { const snapshot = groupMessagesIntoSnapshot([ { From 7c36d456ea03c43f2ead5e6561dd9381e06f1b87 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 01:01:08 +0800 Subject: [PATCH 11/23] fix(transcript): keep other tasks' notifications in task-origin turns --- .../src/services/transcript/coreEventMap.ts | 2 +- .../test/services/transcript.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 322e0e3e7f..2573227e29 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -896,7 +896,7 @@ export class AgentTranscriptProjector { }; return [{ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }]; } - if (turn.origin?.kind === 'task') return []; + if (turn.origin?.kind === 'task' && (turn.origin.taskId === undefined || turn.origin.taskId === event.sourceId)) return []; this.pendingTaskNotifications.push({ text, taskId: event.sourceId }); return []; } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 230333ddee..88730d4393 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1514,6 +1514,28 @@ describe('AgentTranscriptProjector', () => { expect(turnOps('t1', tx.getItems()).steps[0]!.frames).toHaveLength(0); }); + it('keeps a different task’s notification in a task-origin turn', () => { + const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + + feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'task', taskId: 'task_1' } })); + feed( + ev({ + type: 'task.notified', + notificationType: 'task.completed', + title: 'Background agent completed', + body: 'second task done.', + severity: 'info', + sourceKind: 'background_task', + sourceId: 'task_2', + }), + ); + feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + const frames = turnOps('t1', tx.getItems()).steps[0]!.frames; + expect(frames.map((f) => f.kind === 'text' && f.taskId)).toEqual(['task_2']); + }); + it('drops a buffered task notification when the turn ends before the next step', () => { const projector = new AgentTranscriptProjector('main'); const tx = new AgentTranscript('main'); From ea6d64d12af08ce47ab5e79f586d83e1d006bd5c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 01:15:18 +0800 Subject: [PATCH 12/23] fix(transcript): derive task-turn boundaries from durable turn.prompt records --- .../services/transcript/transcriptService.ts | 8 +-- .../test/services/transcript.test.ts | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index db55e5e877..1c07a07290 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -508,10 +508,10 @@ export class TranscriptService { } const messages = [...reduceContextTranscript(records).entries]; const taskOriginTurnTaskIds = new Set(); - let sawTurnStarted = false; + let sawTurnPrompt = false; for (const record of records) { - if (record.type !== 'turn.started') continue; - sawTurnStarted = true; + if (record.type !== 'turn.prompt') continue; + sawTurnPrompt = true; const origin = (record as { origin?: { kind?: unknown; taskId?: unknown } }).origin; if ( (origin?.kind === 'task' || origin?.kind === 'background_task') && @@ -522,7 +522,7 @@ export class TranscriptService { } const base = groupMessagesIntoSnapshot( messages, - sawTurnStarted ? { taskOriginTurnTaskIds } : undefined, + sawTurnPrompt ? { taskOriginTurnTaskIds } : undefined, ); return foldWireRecordFacts(records, base); } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 88730d4393..d79237ac1a 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -66,6 +66,24 @@ function turnOps(turnId: string, items: ReturnType) return turn; } +function coldTranscriptService(home: string): TranscriptService { + return new TranscriptService({ + homeDir: home, + core: { + accessor: { + get: (token: unknown) => { + if (token === ISessionManager) return { get: () => undefined, list: () => [] }; + if (token === IWorkspaceInstanceManager) { + return { list: () => [], onDidChange: () => ({ dispose: () => undefined }) }; + } + if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; + return undefined; + }, + }, + } as unknown as Scope, + }); +} + describe('AgentTranscriptProjector', () => { it('projects a full turn: headers, delta appends, flush, tool frames', () => { const projector = new AgentTranscriptProjector('main'); @@ -1915,6 +1933,45 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot opens a task-origin turn only when the wire has the turn.prompt boundary', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-taskturn-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const notification = + '\nTitle: Background agent completed\nSeverity: info\ninspect done.\n'; + const taskOrigin = { kind: 'task', taskId: 'task_9', status: 'completed', notificationId: 'n1' }; + const opening = [ + { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, + { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, time: 2000 }, + ]; + const boundary = { type: 'turn.prompt', input: [{ type: 'text', text: notification }], origin: taskOrigin, time: 3000 }; + const delivered = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: notification }], toolCalls: [], origin: taskOrigin }, time: 4000 }; + const reply = { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'reporting back' }], toolCalls: [] }, time: 5000 }; + const write = async (records: unknown[]): Promise => + writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + await write([...opening, boundary, delivered, reply]); + const withBoundary = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + const originKinds = withBoundary!.items + .filter((item) => item.kind === 'turn') + .map((item) => (item.kind === 'turn' ? item.origin.kind : '')); + expect(originKinds).toEqual(['user', 'task']); + + await write([...opening, delivered, reply]); + const withoutBoundary = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + const turns = withoutBoundary!.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.steps.flatMap((step) => step.frames).some((f) => f.kind === 'text' && f.role === 'user'), + ).toBe(true); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('folds blocked turn endings into failed (engine wire contract)', () => { const projector = new AgentTranscriptProjector('main'); const tx = new AgentTranscript('main'); From 57ee40534734cdfa02fa20467123e64cc5e10ed2 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 02:18:06 +0800 Subject: [PATCH 13/23] feat(transcript): carry subagent model and thinking effort on task entities --- packages/kap-server/src/services/transcript/coreEventMap.ts | 6 ++++++ packages/kap-server/test/services/transcript.test.ts | 4 ++++ packages/transcript/src/model/task.ts | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 2573227e29..abef7f3d8e 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -1066,6 +1066,8 @@ export class AgentTranscriptProjector { swarmIndex?: number; runInBackground: boolean; taskId?: string; + model?: string; + thinkingEffort?: string; }): TranscriptOperation[] { const taskKey = event.taskId ?? event.subagentId; if (event.taskId !== undefined) { @@ -1083,6 +1085,8 @@ export class AgentTranscriptProjector { outputTail: prev?.outputTail ?? '', startedAt: prev?.startedAt ?? nowIso(), endedAt: prev?.endedAt, + model: event.model ?? prev?.model, + thinkingEffort: event.thinkingEffort ?? prev?.thinkingEffort, })); const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; const hit = @@ -1134,6 +1138,8 @@ export class AgentTranscriptProjector { usage: event.usage ?? prev?.usage, error: event.error ?? prev?.error, stateReason: event.reason ?? prev?.stateReason, + model: prev?.model, + thinkingEffort: prev?.thinkingEffort, })); return [{ op: 'task.upsert', task }]; } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index d79237ac1a..89dec55e8e 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -974,6 +974,8 @@ describe('AgentTranscriptProjector', () => { description: 'scan the repo', swarmIndex: 0, runInBackground: false, + model: 'kimi-k3-highspeed', + thinkingEffort: 'high', }), ); feed(ev({ type: 'subagent.completed', subagentId: 'agent-0', resultSummary: 'done' })); @@ -989,6 +991,8 @@ describe('AgentTranscriptProjector', () => { agentId: 'agent-0', description: 'scan the repo', detached: false, + model: 'kimi-k3-highspeed', + thinkingEffort: 'high', }); }); diff --git a/packages/transcript/src/model/task.ts b/packages/transcript/src/model/task.ts index aca306221d..a8489d89fa 100644 --- a/packages/transcript/src/model/task.ts +++ b/packages/transcript/src/model/task.ts @@ -33,4 +33,8 @@ export interface TranscriptTask { readonly stateReason?: string; /** Token usage of the finished run (`subagent.completed`). */ readonly usage?: StepUsage; + /** Model the spawned subagent runs on (`subagent.spawned`). */ + readonly model?: string; + /** Thinking effort of the spawned subagent (`subagent.spawned`). */ + readonly thinkingEffort?: string; } From 609d849596f39a852ced8d3ca23f806bb963f6f5 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 13:36:25 +0800 Subject: [PATCH 14/23] fix(transcript): mirror turn liveness into meta.activity, live and cold --- .../src/services/transcript/coreEventMap.ts | 2 ++ .../services/transcript/transcriptService.ts | 8 +++++++- .../kap-server/test/services/transcript.test.ts | 17 ++++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index abef7f3d8e..a1643bb366 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -384,6 +384,7 @@ export class AgentTranscriptProjector { this.openText = undefined; this.openThinking = undefined; ops.push({ op: 'turn.upsert', turn: this.currentTurn }); + ops.push({ op: 'meta.merge', meta: { activity: 'turn' } }); return ops; } @@ -421,6 +422,7 @@ export class AgentTranscriptProjector { usage: this.takeTurnUsage(turnId), }; 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') { diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 1c07a07290..98c3ed47b5 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -524,7 +524,13 @@ export class TranscriptService { messages, sawTurnPrompt ? { taskOriginTurnTaskIds } : undefined, ); - return foldWireRecordFacts(records, base); + const lastTurn = base.items.findLast((item) => item.kind === 'turn'); + const activity = + lastTurn?.kind === 'turn' && lastTurn.state === 'running' ? 'unknown' : 'idle'; + return foldWireRecordFacts(records, { + ...base, + meta: { ...base.meta, activity }, + }); } /** Dispose the live store + binding for a session (session closed / server shutdown). */ diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 89dec55e8e..5926151934 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1984,11 +1984,26 @@ describe('AgentTranscriptProjector', () => { expect(turnOps('t0', tx.getItems()).state).toBe('failed'); }); - it('maps cron / task origins onto the turn header', () => { + it('mirrors turn liveness into meta.activity', () => { const projector = new AgentTranscriptProjector('main'); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + expect(tx.getMeta().activity).toBeUndefined(); + feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + expect(tx.getMeta().activity).toBe('turn'); + feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + expect(tx.getMeta().activity).toBe('idle'); + feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); + expect(tx.getMeta().activity).toBe('turn'); + feed(ev({ type: 'turn.ended', turnId: 2, reason: 'failed' })); + expect(tx.getMeta().activity).toBe('idle'); + }); + + it('maps cron / task origins onto the turn header', () => { const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + feed( ev({ type: 'turn.started', From 4733e9e593f329ae490a2393bf0f1a3d57b8bac3 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 13:53:21 +0800 Subject: [PATCH 15/23] fix(transcript): populate the prompts entity from prompt.accepted/queued engine events --- .../src/services/transcript/coreEventMap.ts | 29 +++++++++++++++++++ .../test/services/transcript.test.ts | 16 +++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index a1643bb366..26397d8c92 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -26,6 +26,8 @@ import type { PromptCompleted, PromptSteered, } from '@moonshot-ai/agent-core-v2/agent/prompt/promptService'; +import type { PromptAccepted } from '@moonshot-ai/agent-core-v2/agent/prompt/promptOps'; +import type { PromptQueued } from '@moonshot-ai/agent-core-v2/agent/prompt/promptService'; import type { ShellCompleted, ShellOutput, @@ -89,6 +91,8 @@ export interface ProjectorInteraction { type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision; type AgentActivityUpdatedEvent = { readonly type: 'agent.activity.updated' } & AgentActivityUpdated; +type PromptAcceptedEvent = { readonly type: 'prompt.accepted' } & PromptAccepted; +type PromptQueuedEvent = { readonly type: 'prompt.queued' } & PromptQueued; type PromptCompletedEvent = { readonly type: 'prompt.completed' } & PromptCompleted; type PromptAbortedEvent = { readonly type: 'prompt.aborted' } & PromptAborted; type PromptSteeredEvent = { readonly type: 'prompt.steered' } & PromptSteered; @@ -121,6 +125,8 @@ export type ProjectorBusEvent = | ({ readonly type: 'goal.updated' } & GoalUpdated) | ({ readonly type: 'agent.status.updated' } & AgentStatusUpdated) | AgentActivityUpdatedEvent + | PromptAcceptedEvent + | PromptQueuedEvent | PromptCompletedEvent | PromptAbortedEvent | PromptSteeredEvent @@ -313,6 +319,10 @@ export class AgentTranscriptProjector { return this.onAgentStatusUpdated(event); case 'agent.activity.updated': return this.onAgentActivityUpdated(event); + case 'prompt.accepted': + return this.onPromptAccepted(event); + case 'prompt.queued': + return this.onPromptQueued(event); case 'prompt.submitted': return this.onPromptSubmitted(event); case 'prompt.completed': @@ -1297,6 +1307,25 @@ export class AgentTranscriptProjector { return this.markerOp('notice', { level, message, event: eventPayload }); } + private onPromptAccepted(event: PromptAcceptedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, () => ({ + promptId: event.promptId, + status: 'running', + createdAt: nowIso(), + })); + return [{ op: 'prompt.upsert', prompt }]; + } + + private onPromptQueued(event: PromptQueuedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, () => ({ + promptId: event.promptId, + status: 'queued', + content: event.content, + createdAt: nowIso(), + })); + return [{ op: 'prompt.upsert', prompt }]; + } + private onPromptSubmitted(event: ProjectorPromptSubmittedEvent): TranscriptOperation[] { const prompt = this.upsertPrompt(event.promptId, () => ({ promptId: event.promptId, diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 5926151934..6c0c53f93a 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1984,11 +1984,25 @@ describe('AgentTranscriptProjector', () => { expect(turnOps('t0', tx.getItems()).state).toBe('failed'); }); - it('mirrors turn liveness into meta.activity', () => { + it('tracks the prompt queue from accepted/queued through terminal', () => { const projector = new AgentTranscriptProjector('main'); const tx = new AgentTranscript('main'); const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + feed(ev({ type: 'prompt.accepted', promptId: 'p1' })); + expect(tx.getPrompt('p1')).toMatchObject({ status: 'running' }); + feed(ev({ type: 'prompt.queued', promptId: 'p2', content: [{ type: 'text', text: 'later' }], queueLength: 1 })); + expect(tx.getPrompt('p2')).toMatchObject({ status: 'queued' }); + feed(ev({ type: 'prompt.completed', promptId: 'p2', finishedAt: '2026-08-20T00:00:01.000Z', reason: 'completed' })); + expect(tx.getPrompt('p2')).toMatchObject({ status: 'completed' }); + feed(ev({ type: 'prompt.aborted', promptId: 'p1', abortedAt: '2026-08-20T00:00:02.000Z' })); + expect(tx.getPrompt('p1')).toMatchObject({ status: 'aborted' }); + }); + + it('mirrors turn liveness into meta.activity', () => { const projector = new AgentTranscriptProjector('main'); + const tx = new AgentTranscript('main'); + const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); + expect(tx.getMeta().activity).toBeUndefined(); feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); expect(tx.getMeta().activity).toBe('turn'); From d86f3e3ac0234daa50ed43660fe7a229b2448d9b Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 14:31:15 +0800 Subject: [PATCH 16/23] fix(transcript): reconcile liveness and the prompt queue at backfill from the live loop state --- .../services/transcript/transcriptService.ts | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 98c3ed47b5..619757a650 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { IAgentLifecycleService, + IAgentPromptService, ISessionIndex, ISessionMetadata, IAgentLoopService, @@ -210,7 +211,8 @@ export class TranscriptService { (op) => op.op !== 'attachment.upsert' || !superseded.has(op.attachment.attachmentId), ); const overlay = this.liveTurnOverlay(sessionId, agentId, transcript, snapshot); - if (overlay !== undefined) ops.push(overlay); + if (overlay !== undefined) ops.push(overlay, { op: 'meta.merge', meta: { activity: 'turn' } }); + ops.push(...this.livePromptBackfill(sessionId, agentId)); const result = transcript.apply(ops); if (result.gap !== undefined) { this.deps.logger?.warn({ sessionId, agentId, gap: result.gap }, 'transcript: backfill append gap'); @@ -400,6 +402,41 @@ export class TranscriptService { }; } + private livePromptBackfill(sessionId: string, agentId: string): TranscriptOperation[] { + const queue = getLiveSessionById(this.deps.core.accessor, sessionId) + ?.accessor.get(IAgentLifecycleService) + .get(agentId) + ?.accessor.get(IAgentPromptService) + .list(); + if (queue === undefined) return []; + const ops: TranscriptOperation[] = []; + if (queue.active !== undefined) { + ops.push({ + op: 'prompt.upsert', + prompt: { + promptId: queue.active.id, + status: 'running', + userMessageId: queue.active.userMessageId, + content: queue.active.message.content, + createdAt: queue.active.createdAt, + }, + }); + } + for (const pending of queue.pending) { + ops.push({ + op: 'prompt.upsert', + prompt: { + promptId: pending.id, + status: 'queued', + userMessageId: pending.userMessageId, + content: pending.message.content, + createdAt: pending.createdAt, + }, + }); + } + return ops; + } + /** * Re-read the agent's persisted history and merge the ended turn(s) back * into the live store. The projector attaches to the bus at bind time, so @@ -524,13 +561,20 @@ export class TranscriptService { messages, sawTurnPrompt ? { taskOriginTurnTaskIds } : undefined, ); - const lastTurn = base.items.findLast((item) => item.kind === 'turn'); + const folded = foldWireRecordFacts(records, base); + const status = getLiveSessionById(this.deps.core.accessor, sessionId) + ?.accessor.get(IAgentLifecycleService) + .get(agentId) + ?.accessor.get(IAgentLoopService) + .status(); + const lastTurn = folded.items.findLast((item) => item.kind === 'turn'); const activity = - lastTurn?.kind === 'turn' && lastTurn.state === 'running' ? 'unknown' : 'idle'; - return foldWireRecordFacts(records, { - ...base, - meta: { ...base.meta, activity }, - }); + status?.state === 'running' + ? 'turn' + : lastTurn?.kind === 'turn' && lastTurn.state === 'running' + ? 'unknown' + : 'idle'; + return { ...folded, meta: { ...folded.meta, activity } }; } /** Dispose the live store + binding for a session (session closed / server shutdown). */ From 3e5976ecfde223ea95f62eb903e43709ac658c29 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 14:40:29 +0800 Subject: [PATCH 17/23] fix(transcript): include the prompts entity in the REST transcript response --- packages/kap-server/src/routes/transcript.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kap-server/src/routes/transcript.ts b/packages/kap-server/src/routes/transcript.ts index 12a86f9c17..069f197b16 100644 --- a/packages/kap-server/src/routes/transcript.ts +++ b/packages/kap-server/src/routes/transcript.ts @@ -160,6 +160,7 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr interactions: [...transcript.getInteractions().values()], attachments: [...transcript.getAttachments().values()], todos: [...transcript.getTodos().values()], + prompts: [...transcript.getPrompts().values()], meta: transcript.getMeta(), agents: store.agents(), pending_interactions: transcript.listPendingInteractions(), @@ -197,6 +198,7 @@ export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: Transcr interactions: snapshot.interactions, attachments: snapshot.attachments, todos: snapshot.todos, + prompts: snapshot.prompts, meta: snapshot.meta, agents: roster, pending_interactions: [], From 2ea5f794dea3e5b83acbbab8228b26154b7bf0cf Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 16:20:27 +0800 Subject: [PATCH 18/23] fix(agent-core-v2): publish prompt.accepted on the event bus --- packages/agent-core-v2/src/agent/prompt/promptOps.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agent-core-v2/src/agent/prompt/promptOps.ts b/packages/agent-core-v2/src/agent/prompt/promptOps.ts index 60342fcecb..52fa432ff6 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptOps.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptOps.ts @@ -12,6 +12,7 @@ const promptAcceptedSchema = z.object({ export class PromptAccepted extends AgentEvent2> { static override readonly type = 'prompt.accepted'; static override readonly durable = true; + static override readonly observable = true; static override readonly schema = promptAcceptedSchema; } export interface PromptAccepted { From 281ebf8114d20a5714387768bb2feedec133d5dd Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 16:20:27 +0800 Subject: [PATCH 19/23] test(transcript): add the contract-level e2e covering every entity the client renders from --- .../test/services/transcript.test.ts | 22 + .../test/transcriptContract.e2e.test.ts | 497 ++++++++++++++++++ 2 files changed, 519 insertions(+) create mode 100644 packages/kap-server/test/transcriptContract.e2e.test.ts diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 6c0c53f93a..8ba8bbce6b 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1937,6 +1937,28 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot derives meta.activity from the final turn state when no live session exists', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-cold-activity-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const write = async (records: unknown[]): Promise => + writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + const user = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }; + const assistant = { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, time: 2000 }; + + await write([user, assistant, { type: 'turn.ended', turnId: 0, reason: 'completed', time: 3000 }]); + const ended = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + expect(ended!.meta.activity).toBe('idle'); + + await write([user, assistant]); + const dangling = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + expect(dangling!.meta.activity).toBe('unknown'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot opens a task-origin turn only when the wire has the turn.prompt boundary', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-taskturn-')); try { diff --git a/packages/kap-server/test/transcriptContract.e2e.test.ts b/packages/kap-server/test/transcriptContract.e2e.test.ts new file mode 100644 index 0000000000..bf75d01672 --- /dev/null +++ b/packages/kap-server/test/transcriptContract.e2e.test.ts @@ -0,0 +1,497 @@ +// Contract-level e2e for the transcript protocol: drive REAL end-to-end paths +// (mock LLM over local HTTP → engine → kap-server → REST + WS transcript) and +// assert that every contract entity the client renders from is actually +// produced — meta.activity, prompts, tasks, interactions, turn/step/frame +// items — at the right lifecycle moment, on both the REST snapshot and the WS +// reset+ops channel. This is the regression net for "the field exists in the +// contract but nothing on the real path produces it". + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { type RunningServer, startServer } from '../src/start'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; +import { authHeaders, bearerToken } from './helpers/auth'; + +// --------------------------------------------------------------------------- +// Mock LLM endpoint (OpenAI SSE). +// --------------------------------------------------------------------------- + +function sseLines(...events: readonly string[]): string { + return events.map((event) => `data: ${event}\n\n`).join('') + 'data: [DONE]\n\n'; +} + +function sseText(text: string): string { + return sseLines( + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }], + }), + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 }, + }), + ); +} + +function sseToolCall(id: string, name: string, args: string): string { + return sseLines( + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [{ index: 0, id, type: 'function', function: { name, arguments: args } }], + }, + finish_reason: null, + }, + ], + }), + JSON.stringify({ + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 10, completion_tokens: 6, total_tokens: 16 }, + }), + ); +} + +interface LlmRoute { + readonly match: (body: string) => boolean; + readonly respond: () => string; + readonly delayMs?: number; +} + +interface MockLlm { + readonly port: number; + readonly hits: string[]; + readonly close: () => Promise; +} + +async function startMockLlm(routes: readonly LlmRoute[], fallback: () => string = () => sseText('ok')): Promise { + const hits: string[] = []; + const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + hits.push(body); + const route = routes.find((r) => r.match(body)); + const respond = route?.respond ?? fallback; + const send = (): void => { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.end(respond()); + }; + if (route?.delayMs !== undefined) setTimeout(send, route.delayMs); + else send(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'object' === false) throw new Error('no llm port'); + return { + port: address.port, + hits, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +// --------------------------------------------------------------------------- +// kap-server harness + REST/WS clients. +// --------------------------------------------------------------------------- + +function configToml(llmPort: number): string { + return [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + `base_url = "http://127.0.0.1:${String(llmPort)}"`, + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 100000', + '', + ].join('\n'); +} + +interface Envelope { + code: number; + msg: string; + data: T; +} + +async function rest(server: RunningServer, base: string, path: string, init?: { method?: string; body?: unknown }): Promise { + const res = await fetch(`${base}${path}`, { + method: init?.method ?? 'GET', + headers: authHeaders(server, init?.body !== undefined ? { 'content-type': 'application/json' } : {}), + body: init?.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + const envelope = (await res.json()) as Envelope & { data: T }; + if (envelope.code !== 0) throw new Error(`REST ${path} failed: ${JSON.stringify(envelope).slice(0, 300)}`); + return envelope.data; +} + +interface TxSnapshot { + items: any[]; + tasks: any[]; + interactions: any[]; + attachments: any[]; + todos: any[]; + prompts: { promptId: string; status: string }[]; + meta: { activity?: string; agent?: unknown; goal?: unknown; modes?: unknown }; +} + +const getTranscript = (server: RunningServer, base: string, sid: string): Promise => + rest(server, base, `/api/v1/sessions/${encodeURIComponent(sid)}/transcript?agent_id=main`); + +const getSessionFacts = (server: RunningServer, base: string, sid: string): Promise<{ busy: boolean; pendingInteraction: string }> => + rest(server, base, `/api/v1/sessions/${encodeURIComponent(sid)}`); + +async function createSession(server: RunningServer, base: string): Promise { + const data = await rest<{ id: string }>(server, base, '/api/v1/sessions', { + method: 'POST', + body: { metadata: { cwd: '/tmp' } }, + }); + return data.id; +} + +function submitPrompt(server: RunningServer, base: string, sid: string, text: string, permissionMode: 'manual' | 'yolo' = 'yolo'): Promise<{ prompt_id: string }> { + return rest<{ prompt_id: string }>(server, base, `/api/v1/sessions/${encodeURIComponent(sid)}/prompts`, { + method: 'POST', + body: { content: [{ type: 'text', text }], model: 'stub', permission_mode: permissionMode }, + }); +} + +async function until(label: string, fn: () => Promise | boolean, timeoutMs = 30000, intervalMs = 150): Promise { + const start = Date.now(); + for (;;) { + if (await fn()) return; + if (Date.now() - start > timeoutMs) throw new Error(`timeout waiting for: ${label}`); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } +} + +interface TranscriptChannel { + readonly frames: any[]; + readonly ops: any[]; + reset(): any; + close(): void; +} + +function rawToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +async function subscribeTranscript(server: RunningServer, sid: string): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${server.port}/api/v1/ws`, [`kimi-code.bearer.${bearerToken(server)}`]); + const frames: any[] = []; + const ops: any[] = []; + let resetFrame: any; + ws.on('message', (data) => { + let frame: any; + try { + frame = JSON.parse(rawToString(data)); + } catch { + return; + } + frames.push(frame); + const payload = frame.payload as { agent_id?: string; ops?: any[] } | undefined; + if (frame.type === 'transcript.reset' && payload?.agent_id === 'main' && resetFrame === undefined) { + resetFrame = frame; + } + if (frame.type === 'transcript.ops' && payload?.agent_id === 'main') ops.push(...(payload.ops ?? [])); + }); + await new Promise((resolve, reject) => { + ws.once('open', () => resolve()); + ws.once('error', reject); + }); + ws.send(JSON.stringify({ type: 'subscribe_v2', id: 'sub-1', payload: { session_id: sid, transcript: { '*': 'delta' } } })); + await until('transcript.reset', () => resetFrame !== undefined, 15000); + return { + frames, + ops, + reset: () => resetFrame?.payload, + close: () => ws.close(), + }; +} + +// --------------------------------------------------------------------------- +// The contract assertions. +// --------------------------------------------------------------------------- + +describe('transcript contract e2e', { timeout: 90000 }, () => { + let home: string | undefined; + let server: RunningServer | undefined; + let llm: MockLlm | undefined; + let base: string; + + afterEach(async () => { + await llm?.close(); + await server?.close(); + if (home !== undefined) await rm(home, { recursive: true, force: true }); + home = undefined; + server = undefined; + llm = undefined; + }); + + async function boot(routes: readonly LlmRoute[]): Promise { + llm = await startMockLlm(routes); + home = await mkdtemp(join(tmpdir(), 'kimi-transcript-contract-')); + await writeFile(join(home, 'config.toml'), configToml(llm.port), 'utf-8'); + server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + base = `http://127.0.0.1:${server.port}`; + } + + const idle = (server: RunningServer, base: string, sid: string) => + until('session idle', async () => !(await getSessionFacts(server, base, sid)).busy); + + function dumpState(tx: TxSnapshot, hits: string[]): string { + const turns = tx.items + .filter((i) => i.kind === 'turn') + .map((t: any) => ({ + id: t.turnId, + state: t.state, + origin: t.origin?.kind, + steps: t.steps.map((s: any) => ({ + ordinal: s.ordinal, + state: s.state, + frames: s.frames.map((f: any) => `${f.kind}:${f.role ?? ''}:${f.name ?? ''}:${f.state ?? ''}`), + })), + })); + const markers = hits + .map((hit) => /"content":"([^"]{0,60})/.exec(hit)?.[1] ?? hit.slice(0, 60)) + .slice(0, 8); + return `${JSON.stringify({ meta: tx.meta, prompts: tx.prompts, turns, interactions: tx.interactions })}\nllm hits (${hits.length}): ${JSON.stringify(markers)}`; + } + + const idleOrDump = async (server: RunningServer, base: string, sid: string): Promise => { + try { + await idle(server, base, sid); + } catch (error) { + const tx = await getTranscript(server, base, sid); + throw new Error(`${(error as Error).message}\ntranscript at timeout: ${dumpState(tx, llm?.hits ?? [])}`); + } + }; + + it('S1: turn lifecycle produces activity, prompts and turn frames on both channels', async () => { + await boot([{ match: () => true, respond: () => sseText('hello world'), delayMs: 3000 }]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'say hello'); + const channel = await subscribeTranscript(server!, sid); + + // Mid-turn: REST snapshot carries liveness + the running prompt. + await until('turn running + prompt tracked', async () => { + const tx = await getTranscript(server!, base, sid); + return ( + tx.meta.activity === 'turn' && + tx.prompts.some((p) => p.status === 'running') && + tx.items.some((i) => i.kind === 'turn' && i.state === 'running') + ); + }); + const mid = await getTranscript(server!, base, sid); + expect(mid.meta.activity).toBe('turn'); + expect(mid.prompts.length).toBeGreaterThan(0); + expect(mid.prompts[0]!.promptId.length).toBeGreaterThan(0); + + await idle(server!, base, sid); + + // Settled: everything reaches terminal and the frames are complete. + const end = await getTranscript(server!, base, sid); + expect(end.meta.activity).toBe('idle'); + const turn = end.items.find((i) => i.kind === 'turn'); + expect(turn).toMatchObject({ state: 'completed' }); + expect(typeof turn.endedAt).toBe('string'); + const frameKinds = turn.steps.flatMap((s: any) => s.frames).map((f: any) => f.kind); + expect(frameKinds).toContain('text'); + const promptStatuses = end.prompts.map((p) => p.status); + expect(promptStatuses.every((s) => s === 'completed')).toBe(true); + + // The WS channel saw the same world: the reset baseline was taken mid-turn + // (activity 'turn'), and the terminal transition streams through ops. + const reset = channel.reset(); + expect(reset.snapshot.meta.activity).toBe('turn'); + const opTypes = new Set(channel.ops.map((o: any) => o.op)); + expect(opTypes.has('turn.upsert')).toBe(true); + expect(opTypes.has('step.upsert')).toBe(true); + expect(opTypes.has('frame.upsert') || opTypes.has('append')).toBe(true); + expect(opTypes.has('prompt.upsert')).toBe(true); + const activityMerges = channel.ops.filter((o: any) => o.op === 'meta.merge' && o.meta?.activity !== undefined); + expect(activityMerges.map((o: any) => o.meta.activity)).toContain('idle'); + channel.close(); + }); + + it('S2: a prompt submitted mid-turn is tracked as queued through settlement', async () => { + await boot([ + { match: (body) => body.includes('first prompt'), respond: () => sseText('first done'), delayMs: 2500 }, + { match: () => true, respond: () => sseText('second done') }, + ]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'first prompt'); + await until('first prompt running', async () => + (await getTranscript(server!, base, sid)).prompts.some((p) => p.status === 'running'), + ); + + await submitPrompt(server!, base, sid, 'second prompt'); + await until('second prompt queued', async () => { + const tx = await getTranscript(server!, base, sid); + return tx.prompts.some((p) => p.status === 'queued') && tx.prompts.some((p) => p.status === 'running'); + }); + const mid = await getTranscript(server!, base, sid); + expect(mid.prompts.map((p) => p.status).sort()).toEqual(['queued', 'running']); + + await until('both settled', async () => { + const tx = await getTranscript(server!, base, sid); + return tx.prompts.length > 0 && tx.prompts.every((p) => p.status === 'completed'); + }, 45000); + }); + + it('S3: a pending approval appears as an interaction with tool linkage, then resolves', async () => { + await boot([ + { match: (body) => !body.includes('echo contract-hi'), respond: () => sseToolCall('call_1', 'Bash', '{"command":"echo contract-hi"}') }, + { match: () => true, respond: () => sseText('tool done') }, + ]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'run the echo', 'manual'); + + await until('approval pending', async () => { + const tx = await getTranscript(server!, base, sid); + return tx.interactions.some((x: any) => x.interactionKind === 'approval' && x.state === 'pending'); + }); + const mid = await getTranscript(server!, base, sid); + const approval = mid.interactions.find((x: any) => x.interactionKind === 'approval' && x.state === 'pending'); + expect(approval).toBeDefined(); + expect(approval.toolCallId).toBe('call_1'); + expect((approval.request as any)?.toolName).toBe('Bash'); + expect(mid.meta.agent).toBeDefined(); + + await rest(server!, base, `/api/v1/sessions/${encodeURIComponent(sid)}/approvals/${encodeURIComponent(approval.interactionId)}`, { + method: 'POST', + body: { decision: 'approved' }, + }); + await idle(server!, base, sid); + + const end = await getTranscript(server!, base, sid); + expect(end.interactions.every((x: any) => x.state !== 'pending')).toBe(true); + expect(end.meta.activity).toBe('idle'); + const toolFrame = end.items + .filter((i) => i.kind === 'turn') + .flatMap((t: any) => t.steps) + .flatMap((s: any) => s.frames) + .find((f: any) => f.kind === 'tool' && f.name === 'Bash'); + expect(toolFrame).toMatchObject({ state: 'done' }); + expect(String(toolFrame.output)).toContain('contract-hi'); + }); + + it('S4: a background subagent produces task entities and a task-origin notification turn', async () => { + await boot([ + { + match: (body) => body.includes('spawn-bg') && !body.includes('"role":"tool"'), + respond: () => sseToolCall('call_a', 'Agent', '{"prompt":"bg-answer-42","description":"bg ans","run_in_background":true}'), + }, + { match: (body) => body.includes('bg-answer-42'), respond: () => sseText('42'), delayMs: 2500 }, + { match: () => true, respond: () => sseText('noted') }, + ]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'spawn-bg one background agent'); + + await until('background task running', async () => { + const tx = await getTranscript(server!, base, sid); + return tx.tasks.some((t: any) => t.kind === 'subagent' && t.state === 'running'); + }); + const mid = await getTranscript(server!, base, sid); + const task = mid.tasks.find((t: any) => t.kind === 'subagent'); + expect(task).toMatchObject({ state: 'running', detached: true }); + expect(typeof task.agentId).toBe('string'); + + await until('task completed', async () => { + const tx = await getTranscript(server!, base, sid); + return tx.tasks.some((t: any) => t.taskId === task.taskId && t.state === 'completed'); + }, 45000); + await until('notification turn exists', async () => { + const tx = await getTranscript(server!, base, sid); + return tx.items.some((i: any) => i.kind === 'turn' && i.origin?.kind === 'task'); + }, 45000); + + const end = await getTranscript(server!, base, sid); + const taskTurn = end.items.find((i: any) => i.kind === 'turn' && i.origin?.kind === 'task'); + expect(taskTurn).toBeDefined(); + expect(JSON.stringify(taskTurn)).toContain('notification'); + await idleOrDump(server!, base, sid); + expect((await getTranscript(server!, base, sid)).meta.activity).toBe('idle'); + }); + + it('S5: late attach backfills liveness and the prompt queue from the live loop', async () => { + await boot([{ match: () => true, respond: () => sseText('slow answer'), delayMs: 4000 }]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'take your time'); + + // No WS subscription and no transcript read happened before this point — + // the binding attaches cold, mid-turn. + const tx = await getTranscript(server!, base, sid); + expect(tx.meta.activity).toBe('turn'); + expect(tx.items.some((i: any) => i.kind === 'turn' && i.state === 'running')).toBe(true); + expect(tx.prompts.some((p) => p.status === 'running')).toBe(true); + + const channel = await subscribeTranscript(server!, sid); + expect(channel.reset().snapshot.meta.activity).toBe('turn'); + await idle(server!, base, sid); + expect((await getTranscript(server!, base, sid)).meta.activity).toBe('idle'); + channel.close(); + }); + + it('S6: REST snapshot and WS reset agree on every global entity', async () => { + await boot([ + { match: (body) => !body.includes('echo s6'), respond: () => sseToolCall('call_s6', 'Bash', '{"command":"echo s6"}') }, + { match: () => true, respond: () => sseText('s6 done') }, + ]); + const sid = await createSession(server!, base); + await submitPrompt(server!, base, sid, 'run echo for s6'); + await idle(server!, base, sid); + + const snapshot = await getTranscript(server!, base, sid); + const channel = await subscribeTranscript(server!, sid); + const reset = channel.reset().snapshot; + + expect(reset.meta).toEqual(snapshot.meta); + const byId = (xs: any[], key: string): Record => + Object.fromEntries(xs.map((x) => [x[key], x])); + expect(Object.keys(byId(reset.tasks ?? [], 'taskId')).sort()).toEqual( + Object.keys(byId(snapshot.tasks, 'taskId')).sort(), + ); + expect(Object.keys(byId(reset.interactions ?? [], 'interactionId')).sort()).toEqual( + Object.keys(byId(snapshot.interactions, 'interactionId')).sort(), + ); + expect(Object.keys(byId(reset.prompts ?? [], 'promptId')).sort()).toEqual( + Object.keys(byId(snapshot.prompts, 'promptId')).sort(), + ); + expect(Object.keys(byId(reset.todos ?? [], 'todoId')).sort()).toEqual( + Object.keys(byId(snapshot.todos, 'todoId')).sort(), + ); + channel.close(); + }); +}); From eae29201d7e89b71768c85c2527c7cc398d4e40c Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 17:01:25 +0800 Subject: [PATCH 20/23] fix(transcript): guard the prompt backfill against missing services; align stream expectations with prompt.accepted on the bus --- .../src/services/transcript/transcriptService.ts | 16 +++++----------- .../kap-server/test/services/transcript.test.ts | 2 +- .../test/sessionEventBroadcaster.test.ts | 2 +- packages/node-sdk/test/v1-v2-parity.test.ts | 1 + 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 619757a650..bb89fe81cc 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -403,11 +403,11 @@ export class TranscriptService { } private livePromptBackfill(sessionId: string, agentId: string): TranscriptOperation[] { - const queue = getLiveSessionById(this.deps.core.accessor, sessionId) + const agent = getLiveSessionById(this.deps.core.accessor, sessionId) ?.accessor.get(IAgentLifecycleService) - .get(agentId) - ?.accessor.get(IAgentPromptService) - .list(); + .get(agentId); + const promptService = agent === undefined ? undefined : agent.accessor.get(IAgentPromptService); + const queue = promptService?.list(); if (queue === undefined) return []; const ops: TranscriptOperation[] = []; if (queue.active !== undefined) { @@ -567,13 +567,7 @@ export class TranscriptService { .get(agentId) ?.accessor.get(IAgentLoopService) .status(); - const lastTurn = folded.items.findLast((item) => item.kind === 'turn'); - const activity = - status?.state === 'running' - ? 'turn' - : lastTurn?.kind === 'turn' && lastTurn.state === 'running' - ? 'unknown' - : 'idle'; + const activity = status?.state === 'running' ? 'turn' : 'idle'; return { ...folded, meta: { ...folded.meta, activity } }; } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 8ba8bbce6b..195052fb3e 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1953,7 +1953,7 @@ describe('AgentTranscriptProjector', () => { await write([user, assistant]); const dangling = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - expect(dangling!.meta.activity).toBe('unknown'); + expect(dangling!.meta.activity).toBe('idle'); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 07beeacaa7..33de360967 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -2096,7 +2096,7 @@ describe('SessionEventBroadcaster', () => { expect(ops.volatile).toBe(true); } expect(batches.map((ops) => (ops.payload as OpsPayload).ops.map((o) => o.op))).toEqual([ - ['turn.upsert'], + ['turn.upsert', 'meta.merge'], ['meta.merge'], ]); } diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 550752945c..8a5dd54a48 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -4820,6 +4820,7 @@ describe('v1↔v2 event & interaction parity', () => { projectEventStream(events, input.sessionId).flatMap((projected) => { const entry = projected as { type: string; code?: string }; if (entry.type === 'turn.step.interrupted') return []; + if (entry.type === 'prompt.accepted') return []; if (entry.type === 'error') return { type: entry.type, code: entry.code }; return entry; }); From 7daf99e20a653ea35dacb034b45bc456552ec698 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 20 Aug 2026 17:50:39 +0800 Subject: [PATCH 21/23] test(agent-core-v2): re-record event stream snapshots with prompt.accepted published --- packages/agent-core-v2/test/agent/loop/loop.test.ts | 3 +++ packages/agent-core-v2/test/app/config/config.test.ts | 2 ++ packages/agent-core-v2/test/features/plan/plan.test.ts | 2 ++ packages/agent-core-v2/test/tool/tool.test.ts | 2 ++ 4 files changed, 9 insertions(+) diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index fe78957756..a84c5e3a7e 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -67,6 +67,7 @@ describe('Agent loop', () => { expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` [wire] tools.set_active_tools { "agentId": "main", "names": [], "time": "