From ceb122c21fb5ef69f76ce4fe2cfcd388df7bc8e2 Mon Sep 17 00:00:00 2001 From: liukx0205 <69756503+liukx0205@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:14:12 +0000 Subject: [PATCH 1/3] fix(transcript): project only a fork's own turns into its transcript Forked agents (e.g. /btw children) persist the inherited source context in their wire log. The cold transcript projection rebuilt those inherited messages as the fork's own turns, so fork transcripts contained the main conversation, ordinals collided with the live projection, and prompts paired with the wrong answers. Mark fork-seeded context messages as inherited and exclude them from the transcript snapshot projection. Fixes #3672 --- .changeset/fork-transcript-own-turns.md | 5 + .../agent-core-v2/docs/state-manifest.d.ts | 1 + .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../agent/contextMemory/contextTranscript.ts | 2 + .../src/agent/contextMemory/types.ts | 1 + .../agentLifecycle/agentLifecycleService.ts | 8 +- .../agentLifecycle/agentLifecycle.test.ts | 23 +++ .../services/transcript/transcriptService.ts | 4 +- .../test/services/transcript.test.ts | 164 ++++++++++++++++++ 9 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 .changeset/fork-transcript-own-turns.md diff --git a/.changeset/fork-transcript-own-turns.md b/.changeset/fork-transcript-own-turns.md new file mode 100644 index 00000000000..c2de0765375 --- /dev/null +++ b/.changeset/fork-transcript-own-turns.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix /btw side-chat transcripts showing inherited main-conversation turns with mismatched prompts and answers. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 4595a4028eb..e25b664e424 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1241,6 +1241,7 @@ export interface AgentStateSnapshot { detail?: unknown; }>; readonly note?: string; + readonly inherited?: boolean; })[]; // src/agent/contextProjector/contextProjectorService.ts 'contextProjector.lastRepairSignature': string | null; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 9ed1e55e8df..9996151322a 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -140,6 +140,7 @@ interface ContextAppendMessagePayload { isError?: boolean; toolCallDisplays?: Record; note?: string; + inherited?: boolean; }; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 5f7fd4cee15..0e3a01bfad7 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -30,6 +30,7 @@ interface MutableMessage { isError?: boolean; note?: string; origin?: ContextMessage['origin']; + inherited?: boolean; } interface MutableEntry { @@ -175,6 +176,7 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), ...(message.isError !== undefined ? { isError: message.isError } : {}), ...(message.origin !== undefined ? { origin: message.origin } : {}), + ...(message.inherited !== undefined ? { inherited: message.inherited } : {}), }, time, }; diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index bd36616faef..e93ee1ea830 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -126,6 +126,7 @@ export type ContextMessage = Message & { readonly isError?: boolean; toolCallDisplays?: Record; readonly note?: string; + readonly inherited?: boolean; }; export interface UserMessageRecord { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 4c0204a4791..dc79bbfd0e1 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -320,9 +320,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get(); if (sourceMessages !== undefined && sourceMessages.length > 0) { - child.accessor - .get(IAgentContextMemoryService) - ?.append(...closeTrailingOpenToolExchange(sourceMessages)); + const inherited = closeTrailingOpenToolExchange(sourceMessages).map((message) => ({ + ...message, + inherited: true, + })); + child.accessor.get(IAgentContextMemoryService)?.append(...inherited); } return childContext; } diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index f24584ee531..896ff347b48 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -1258,6 +1258,29 @@ describe('AgentLifecycleService', () => { }); }); + it('fork marks the seeded context messages as inherited without touching the source', async () => { + const svc = ix.get(IAgentLifecycleService); + const source = await svc.create({ agentId: 'main' }); + const sourceHandle = svc.handleOf('main')!; + const history: ContextMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'analyze this repo' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] }, + ]; + sourceHandle.accessor.get(IAgentContextMemoryService).append(...history); + + const child = await svc.fork(agentContextOf(sourceHandle), { agentId: 'forked' }); + + const seeded = svc.handleOf(child.agentId)!.accessor.get(IAgentContextMemoryService).get(); + expect(seeded).toHaveLength(2); + expect(seeded.every((message) => message.inherited === true)).toBe(true); + expect( + sourceHandle.accessor + .get(IAgentContextMemoryService) + .get() + .every((message) => message.inherited === undefined), + ).toBe(true); + }); + it('fork leaves the child context empty when the source history is empty', async () => { const svc = ix.get(IAgentLifecycleService); const source = await svc.create({ agentId: 'main' }); diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 887f1c1c90f..2134b23fd69 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -467,7 +467,9 @@ export class TranscriptService { } throw error; } - const messages = [...reduceContextTranscript(records).entries]; + const messages = [...reduceContextTranscript(records).entries].filter( + (message) => message.inherited !== true, + ); const taskOriginTurnTaskIds = new Set(); const steeredContents = new Map>(); const anchorStack: { taskIdsSnapshot: Set }[] = []; diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 567dd2ffb0e..f8a145274d4 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2382,6 +2382,86 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot excludes fork-inherited messages from the fork transcript', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-fork-inherited-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'agent-1'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'main question' }], + toolCalls: [], + origin: { kind: 'user' }, + inherited: true, + }, + time: 1000, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'main answer' }], + toolCalls: [], + inherited: true, + }, + time: 2000, + }, + { + type: 'turn.prompt', + promptId: 'prompt-side', + origin: { kind: 'user' }, + input: [{ type: 'text', text: 'side question' }], + time: 3000, + }, + { + type: 'context.append_message', + message: { + id: 'msg-side', + role: 'user', + content: [{ type: 'text', text: 'side question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: 3001, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'side answer' }], + toolCalls: [], + }, + time: 4000, + }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'agent-1'); + + expect(snapshot).toBeDefined(); + const turns = snapshot!.items.filter((item) => item.kind === 'turn'); + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + turnId: 't0', + ordinal: 0, + state: 'completed', + prompt: 'side question', + origin: { kind: 'user' }, + }); + const frames = turns[0]!.kind === 'turn' ? turns[0]!.steps.flatMap((step) => step.frames) : []; + expect(frames).toContainEqual( + expect.objectContaining({ kind: 'text', role: 'assistant', text: 'side answer' }), + ); + expect(JSON.stringify(snapshot!.items)).not.toContain('main question'); + expect(JSON.stringify(snapshot!.items)).not.toContain('main answer'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot folds task/todo/goal/plan/interaction records into the cold snapshot', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-facts-')); try { @@ -3762,6 +3842,90 @@ describe('bindSessionTranscript', () => { } }); + it('backfills a fork transcript with only its own turns, keeping prompt and answer paired', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-fork-backfill-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'agent-1'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'main question' }], + toolCalls: [], + origin: { kind: 'user' }, + inherited: true, + }, + time: 1000, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'main answer' }], + toolCalls: [], + inherited: true, + }, + time: 2000, + }, + { + type: 'context.append_message', + message: { + id: 'msg-side', + role: 'user', + content: [{ type: 'text', text: 'side question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: 3000, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'side answer' }], + toolCalls: [], + }, + time: 4000, + }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + + const agents = new FakeAgents(); + agents.add('main'); + const fork = agents.add('agent-1', { loopStatus: { state: 'running', activeTurnId: 0 } }); + const service = new TranscriptService({ + homeDir: home, + core: fakeCoreWithAgents(agents), + }); + const store = service.forSessionLive('s1'); + fork.bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'side question' })); + fork.bus.emit(ev({ type: 'turn.step.started', turnId: 0, step: 1 })); + fork.bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'side answer' })); + await service.whenReady('s1'); + await service.ensureAgentHistory('s1', 'agent-1'); + + const items = store?.getAgent('agent-1')?.getItems() ?? []; + const turns = items.filter((item) => item.kind === 'turn'); + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + turnId: 't0', + ordinal: 0, + prompt: 'side question', + origin: { kind: 'user' }, + }); + expect(turns.map((turn) => turn.ordinal)).toEqual([0]); + const serialized = JSON.stringify(items); + expect(serialized).toContain('side answer'); + expect(serialized).not.toContain('main question'); + expect(serialized).not.toContain('main answer'); + service.dropSession('s1'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('re-asserts running when the backfill rebuilds the live turn completed', async () => { const home = await seedWireHome(); try { From e2f960ee3a0918e7c67a0abfc32362588b7d1b1c Mon Sep 17 00:00:00 2001 From: liukx0205 <69756503+liukx0205@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:01:32 +0000 Subject: [PATCH 2/3] fix(transcript): address codex review on fork transcript projection Pass the optional inherited field directly instead of using a conditional spread, per the repo's optional-property rule. Side chats forked before the inherited marker existed keep their seeded main-conversation messages unmarked, so the marker filter alone still rebuilt them as the fork's own turns when reopening an old /btw chat. Detect that legacy shape in the cold projection: when the agent meta records a forkedFrom parent and the wire log carries no inherited marker, drop context.append_message records preceding the fork's first turn.prompt before reducing the transcript. --- .../agent/contextMemory/contextTranscript.ts | 2 +- .../services/transcript/transcriptService.ts | 50 ++++-- .../test/services/transcript.test.ts | 143 ++++++++++++++++++ 3 files changed, 184 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 0e3a01bfad7..5f61ed6b40b 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -176,7 +176,7 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), ...(message.isError !== undefined ? { isError: message.isError } : {}), ...(message.origin !== undefined ? { origin: message.origin } : {}), - ...(message.inherited !== undefined ? { inherited: message.inherited } : {}), + inherited: message.inherited, }, time, }; diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 2134b23fd69..ca6ed5e2d2e 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -425,19 +425,26 @@ export class TranscriptService { async readColdRoster(sessionId: string): Promise { const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); if (summary === undefined) return undefined; - let meta: SessionMeta; + const meta = await this.readSessionMeta(summary.workspaceId, sessionId); + if (meta === undefined) return []; + return Object.entries(meta.agents ?? {}).map(([agentId, agentMeta]) => + descriptorFromMeta(agentId, agentMeta), + ); + } + + private async readSessionMeta( + workspaceId: string, + sessionId: string, + ): Promise { try { const raw = await readFile( - join(this.deps.homeDir, SESSIONS_ROOT, summary.workspaceId, sessionId, STATE_FILE), + join(this.deps.homeDir, SESSIONS_ROOT, workspaceId, sessionId, STATE_FILE), 'utf-8', ); - meta = JSON.parse(raw) as SessionMeta; + return JSON.parse(raw) as SessionMeta; } catch { - return []; + return undefined; } - return Object.entries(meta.agents ?? {}).map(([agentId, agentMeta]) => - descriptorFromMeta(agentId, agentMeta), - ); } async readColdSnapshot( @@ -467,7 +474,12 @@ export class TranscriptService { } throw error; } - const messages = [...reduceContextTranscript(records).entries].filter( + const meta = await this.readSessionMeta(summary.workspaceId, sessionId); + const projectionRecords = stripLegacyInheritedRecords( + records, + meta?.agents?.[agentId]?.forkedFrom, + ); + const messages = [...reduceContextTranscript(projectionRecords).entries].filter( (message) => message.inherited !== true, ); const taskOriginTurnTaskIds = new Set(); @@ -475,7 +487,7 @@ export class TranscriptService { const anchorStack: { taskIdsSnapshot: Set }[] = []; let anchorFloor = 0; let sawTurnPrompt = false; - for (const record of records) { + for (const record of projectionRecords) { if (record.type === 'context.undo') { const count = typeof record['count'] === 'number' ? (record['count'] as number) : 0; for (let i = 0; i < count && anchorStack.length > anchorFloor; i++) { @@ -523,7 +535,7 @@ export class TranscriptService { messages, sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined, ); - const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base, { + const folded = foldWireRecordFacts(projectQuestionInteractionRecords(projectionRecords, sessionId), base, { resolvePlanRevisionKey: (key) => join(SESSIONS_ROOT, summary.workspaceId, sessionId, AGENTS_DIR, agentId, key), }); @@ -631,6 +643,24 @@ const TERMINAL_TURN_STATES: ReadonlySet = new Set([ 'cancelled', ]); +function stripLegacyInheritedRecords( + records: readonly ContextRecord[], + forkedFrom: string | undefined, +): readonly ContextRecord[] { + if (forkedFrom === undefined) return records; + const marked = records.some( + (record) => + record.type === 'context.append_message' && + (record as { message?: ContextMessage }).message?.inherited === true, + ); + if (marked) return records; + const firstPrompt = records.findIndex((record) => record.type === 'turn.prompt'); + const cutoff = firstPrompt === -1 ? records.length : firstPrompt; + return records.filter( + (record, index) => index >= cutoff || record.type !== 'context.append_message', + ); +} + function projectQuestionInteractionRecords( records: readonly ContextRecord[], sessionId: string, diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index f8a145274d4..d8ce7f5165e 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2462,6 +2462,149 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot drops the unmarked inherited prefix of a legacy fork transcript', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-fork-legacy-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'agent-1'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'main question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: 1000, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'main answer' }], + toolCalls: [], + }, + time: 2000, + }, + { + type: 'turn.prompt', + promptId: 'prompt-side', + origin: { kind: 'user' }, + input: [{ type: 'text', text: 'side question' }], + time: 3000, + }, + { + type: 'context.append_message', + message: { + id: 'msg-side', + role: 'user', + content: [{ type: 'text', text: 'side question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: 3001, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'side answer' }], + toolCalls: [], + }, + time: 4000, + }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + await writeFile( + join(home, 'sessions', 'ws', 's1', 'state.json'), + JSON.stringify({ + id: 's1', + createdAt: 1, + updatedAt: 1, + archived: false, + agents: { 'agent-1': { type: 'sub', forkedFrom: 'main' } }, + }), + ); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'agent-1'); + + expect(snapshot).toBeDefined(); + const turns = snapshot!.items.filter((item) => item.kind === 'turn'); + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + turnId: 't0', + ordinal: 0, + state: 'completed', + prompt: 'side question', + origin: { kind: 'user' }, + }); + const frames = turns[0]!.kind === 'turn' ? turns[0]!.steps.flatMap((step) => step.frames) : []; + expect(frames).toContainEqual( + expect.objectContaining({ kind: 'text', role: 'assistant', text: 'side answer' }), + ); + expect(JSON.stringify(snapshot!.items)).not.toContain('main question'); + expect(JSON.stringify(snapshot!.items)).not.toContain('main answer'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + it('readColdSnapshot keeps unmarked leading messages when the agent is not a fork', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-fork-legacy-main-')); + try { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + const records = [ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'main question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: 1000, + }, + { + type: 'turn.prompt', + promptId: 'prompt-main', + origin: { kind: 'user' }, + input: [{ type: 'text', text: 'main question' }], + time: 2000, + }, + { + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'main answer' }], + toolCalls: [], + }, + time: 3000, + }, + ]; + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + await writeFile( + join(home, 'sessions', 'ws', 's1', 'state.json'), + JSON.stringify({ + id: 's1', + createdAt: 1, + updatedAt: 1, + archived: false, + agents: { main: { type: 'main' } }, + }), + ); + + const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); + + expect(snapshot).toBeDefined(); + expect(JSON.stringify(snapshot!.items)).toContain('main question'); + expect(JSON.stringify(snapshot!.items)).toContain('main answer'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot folds task/todo/goal/plan/interaction records into the cold snapshot', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-cold-facts-')); try { From 0ff83d03955f34cf9f42453d8e30716de07cc5f8 Mon Sep 17 00:00:00 2001 From: liukx0205 <69756503+liukx0205@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:30:32 +0000 Subject: [PATCH 3/3] fix(transcript): exclude compaction summaries from fork cold snapshots A fork that compacts summarizes its inherited parent history into the compaction summary, but reduceContextTranscript synthesizes that summary as an unmarked compaction_summary message, so the own-content projection kept it and groupMessagesIntoSnapshot exposed its full text as a compaction marker. When the agent meta records a forkedFrom parent, drop compaction_summary messages from the cold projection alongside the inherited ones; the main agent still projects its own summaries. --- .../services/transcript/transcriptService.ts | 10 +-- .../test/services/transcript.test.ts | 79 +++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index ca6ed5e2d2e..6fb0174b3d4 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -475,12 +475,12 @@ export class TranscriptService { throw error; } const meta = await this.readSessionMeta(summary.workspaceId, sessionId); - const projectionRecords = stripLegacyInheritedRecords( - records, - meta?.agents?.[agentId]?.forkedFrom, - ); + const forkedFrom = meta?.agents?.[agentId]?.forkedFrom; + const projectionRecords = stripLegacyInheritedRecords(records, forkedFrom); const messages = [...reduceContextTranscript(projectionRecords).entries].filter( - (message) => message.inherited !== true, + (message) => + message.inherited !== true && + (forkedFrom === undefined || message.origin?.kind !== 'compaction_summary'), ); const taskOriginTurnTaskIds = new Set(); const steeredContents = new Map>(); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index d8ce7f5165e..e814c5a2265 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2550,6 +2550,85 @@ describe('AgentTranscriptProjector', () => { } }); + it('readColdSnapshot excludes compaction summaries from a fork transcript', async () => { + const home = await mkdtemp(join(tmpdir(), 'transcript-fork-compaction-')); + try { + const records = [ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'main question' }], + toolCalls: [], + origin: { kind: 'user' }, + inherited: true, + }, + time: 1000, + }, + { + type: 'turn.prompt', + promptId: 'prompt-side', + origin: { kind: 'user' }, + input: [{ type: 'text', text: 'side question' }], + time: 3000, + }, + { + type: 'context.append_message', + message: { + id: 'msg-side', + role: 'user', + content: [{ type: 'text', text: 'side question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + time: 3001, + }, + { + type: 'context.apply_compaction', + summary: 'compacted: the user asked main question and then side question', + compactedCount: 2, + time: 4000, + }, + ]; + for (const agentId of ['agent-1', 'main']) { + const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', agentId); + await mkdir(wireDir, { recursive: true }); + await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); + } + await writeFile( + join(home, 'sessions', 'ws', 's1', 'state.json'), + JSON.stringify({ + id: 's1', + createdAt: 1, + updatedAt: 1, + archived: false, + agents: { 'agent-1': { type: 'sub', forkedFrom: 'main' } }, + }), + ); + + const service = coldTranscriptService(home); + const forkSnapshot = await service.readColdSnapshot('s1', 'agent-1'); + expect(forkSnapshot).toBeDefined(); + expect(JSON.stringify(forkSnapshot!.items)).not.toContain('compacted:'); + expect(JSON.stringify(forkSnapshot!.items)).not.toContain('main question'); + expect( + forkSnapshot!.items.some((item) => item.kind === 'marker' && item.marker === 'compaction'), + ).toBe(false); + const forkTurns = forkSnapshot!.items.filter((item) => item.kind === 'turn'); + expect(forkTurns).toHaveLength(1); + expect(forkTurns[0]).toMatchObject({ prompt: 'side question' }); + + const mainSnapshot = await service.readColdSnapshot('s1', 'main'); + expect(mainSnapshot).toBeDefined(); + expect( + mainSnapshot!.items.some((item) => item.kind === 'marker' && item.marker === 'compaction'), + ).toBe(true); + expect(JSON.stringify(mainSnapshot!.items)).toContain('compacted:'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('readColdSnapshot keeps unmarked leading messages when the agent is not a fork', async () => { const home = await mkdtemp(join(tmpdir(), 'transcript-fork-legacy-main-')); try {