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..5f61ed6b40b 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 } : {}), + 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..6fb0174b3d4 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,13 +474,20 @@ export class TranscriptService { } throw error; } - const messages = [...reduceContextTranscript(records).entries]; + const meta = await this.readSessionMeta(summary.workspaceId, sessionId); + const forkedFrom = meta?.agents?.[agentId]?.forkedFrom; + const projectionRecords = stripLegacyInheritedRecords(records, forkedFrom); + const messages = [...reduceContextTranscript(projectionRecords).entries].filter( + (message) => + message.inherited !== true && + (forkedFrom === undefined || message.origin?.kind !== 'compaction_summary'), + ); const taskOriginTurnTaskIds = new Set(); const steeredContents = new Map>(); 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++) { @@ -521,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), }); @@ -629,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 567dd2ffb0e..e814c5a2265 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2382,6 +2382,308 @@ 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 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 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 { + 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 { @@ -3762,6 +4064,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 {