From 5fb8cf6e2c5cc946815a6f6c82c8b7a4c252e650 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 23 Jun 2026 18:14:09 +0800 Subject: [PATCH 1/6] fix(agent-core): realign mid-history interrupted tool calls on resume ContextMemory.finishResume only closed the trailing open tool exchange (pendingToolResultIds describes the tail by invariant), so a tool call left unresolved mid-history would keep hasOpenToolExchange true through replay, stranding every later message in deferredMessages and aligning only the final exchange. Close pending tool calls in place at each replayed step.begin boundary (extracted as closePendingToolResults, shared with finishResume). The deferral avalanche never starts, so later messages keep their recorded order. No-op for healthy histories: the invariant guarantees no pending at a live step boundary. Known limitation: an apply_compaction recorded while an exchange is open (legacy/corrupt sessions) can still slice the owning assistant step away and leave an orphaned result; this is pre-existing, not a regression, and is better fixed at the compaction source in a follow-up. --- .../fix-mid-history-interrupted-tool-call.md | 5 + .../agent-core/src/agent/context/index.ts | 19 +++- packages/agent-core/test/agent/resume.test.ts | 93 +++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-mid-history-interrupted-tool-call.md diff --git a/.changeset/fix-mid-history-interrupted-tool-call.md b/.changeset/fix-mid-history-interrupted-tool-call.md new file mode 100644 index 0000000000..d3cfb1c0ad --- /dev/null +++ b/.changeset/fix-mid-history-interrupted-tool-call.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index fc7948953a..a854482179 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -227,10 +227,20 @@ export class ContextMemory { } finishResume(): void { - const interruptedToolCallIds = [...this.pendingToolResultIds]; this.openSteps.clear(); - if (interruptedToolCallIds.length === 0) return; + this.closePendingToolResults(); + } + // Synthesize interrupted tool results for any still-open tool calls, closing + // the exchange in place. Called at every replayed step boundary (see the + // `step.begin` case) so a tool call left unresolved mid-history is closed + // exactly where it occurred — otherwise it would keep `hasOpenToolExchange` + // true and strand every later message in `deferredMessages`, so only the + // trailing exchange ends up aligned. `finishResume` runs the same routine once + // more to close a genuine trailing interruption at end of resume. + private closePendingToolResults(): void { + if (this.pendingToolResultIds.size === 0) return; + const interruptedToolCallIds = [...this.pendingToolResultIds]; for (const toolCallId of interruptedToolCallIds) { this.appendLoopEvent({ type: 'tool.result', @@ -251,6 +261,11 @@ export class ContextMemory { }); switch (event.type) { case 'step.begin': { + // A new assistant step means any tool calls still pending from an + // earlier step were interrupted (the invariant guarantees this never + // happens live, so this is a no-op outside replay). Close them in place + // before opening the new step so mid-history gaps stay aligned. + this.closePendingToolResults(); const message: ContextMessage = { role: 'assistant', content: [], diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index ee963e4139..cdec609f0f 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -686,6 +686,99 @@ describe('Agent resume', () => { expect(textContent(resumedAgain.agent.context.history[4])).toBe('continue after resume'); }); + it('closes an interrupted tool call mid-history so later turns stay aligned', async () => { + // An interrupted tool call (`call_interrupted`) sits in the MIDDLE of the + // recorded stream: a later user prompt and a fully-run assistant turn follow + // it. Without in-place reconciliation the unresolved exchange keeps + // `hasOpenToolExchange` true, stranding the later user prompt in + // `deferredMessages` and only aligning the trailing turn. + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Run the lookup' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call-interrupted', + turnId: '0', + step: 1, + stepUuid: 'interrupted-step', + toolCallId: 'call_interrupted', + name: 'Lookup', + args: { query: 'one' }, + }, + }, + // Recorded while the interrupted exchange was still open, so live deferral + // captured it after the unresolved tool call. + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'keep going' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ...loopEventsForTurn('1', 'All done.'), + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + expect(ctx.agent.context.history.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + 'assistant', + ]); + // The synthetic result is spliced in place (index 2), directly after the + // interrupted assistant step — not flushed to the tail. + const synthetic = ctx.agent.context.history[2]; + expect(synthetic).toMatchObject({ + role: 'tool', + toolCallId: 'call_interrupted', + isError: true, + }); + expect(textContent(synthetic)).toContain( + 'Tool execution was interrupted before its result was recorded', + ); + // The deferred user prompt is restored in its recorded position, between the + // closed exchange and the following turn. + expect(textContent(ctx.agent.context.history[3])).toBe('keep going'); + expect(textContent(ctx.agent.context.history[4])).toBe('All done.'); + + expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + 'assistant', + ]); + + // Option A: the mid-history result is re-derived on every resume and is not + // persisted as a positioned record (replay logging is suppressed). + expect( + persistence.appended.filter( + (record) => + record.type === 'context.append_loop_event' && record.event.type === 'tool.result', + ), + ).toEqual([]); + + await ctx.expectResumeMatches(); + }); + it('rebuilds goal completion replay cards without adding model-visible context', async () => { const persistence = new RecordingAgentPersistence([ { From ce929455daa6e5146cd85d2f7b1f5d0c40faedbf Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 23 Jun 2026 18:25:35 +0800 Subject: [PATCH 2/6] fix(agent-core): mirror mid-history tool-call closure in wire reducer reduceWireRecords folds the wire log the same way ContextMemory folds history, and MessageService merges context.history.slice(foldedLength) onto the reduced entries. The replay-time closePendingToolResults added for mid-history interruptions is NOT re-persisted to the wire log, so the reducer's step.begin must reconstruct it: synthesize the interrupted tool result in place and flush the deferred message. Otherwise foldedLength undercounts and the live-tail merge duplicates/reorders the later turn and omits the synthetic error. --- .../src/services/message/transcript.ts | 29 +++++++++++++ .../test/services/message-transcript.test.ts | 43 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/agent-core/src/services/message/transcript.ts b/packages/agent-core/src/services/message/transcript.ts index ae53f9811e..46c4a6354e 100644 --- a/packages/agent-core/src/services/message/transcript.ts +++ b/packages/agent-core/src/services/message/transcript.ts @@ -58,6 +58,8 @@ const TOOL_EMPTY_STATUS = 'Tool output is empty.'; const TOOL_EMPTY_ERROR_STATUS = 'ERROR: Tool execution failed. Tool output is empty.'; const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.'; +const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; export interface TranscriptEntry { readonly message: ContextMessage; @@ -116,6 +118,32 @@ export function reduceWireRecords(records: Iterable): { push(...deferred); deferred = []; }; + // Mirrors ContextMemory.closePendingToolResults: a tool call left open when a + // new step begins was interrupted, so synthesize its error result in place. + // ContextMemory does this during replay, where the synthetic result is NOT + // re-persisted to the wire log, so the reducer must reconstruct it to keep + // `foldedLength` aligned with the live folded history. + const closePendingToolResults = (time: number | undefined): void => { + if (pendingToolResultIds.size === 0) return; + const interruptedToolCallIds = [...pendingToolResultIds]; + for (const toolCallId of interruptedToolCallIds) { + push({ + message: { + role: 'tool', + content: toolResultContent({ + output: TOOL_INTERRUPTED_ON_RESUME_OUTPUT, + isError: true, + }), + toolCalls: [], + toolCallId, + isError: true, + }, + time, + }); + pendingToolResultIds.delete(toolCallId); + } + flushDeferredIfToolExchangeClosed(); + }; const resetOpenState = (): void => { openSteps.clear(); pendingToolResultIds.clear(); @@ -125,6 +153,7 @@ export function reduceWireRecords(records: Iterable): { const applyLoopEvent = (event: LoopRecordedEvent, time: number | undefined): void => { switch (event.type) { case 'step.begin': { + closePendingToolResults(time); const entry: MutableEntry = { message: { role: 'assistant', content: [], toolCalls: [] }, time, diff --git a/packages/agent-core/test/services/message-transcript.test.ts b/packages/agent-core/test/services/message-transcript.test.ts index 97602524e7..a4b1e16e6f 100644 --- a/packages/agent-core/test/services/message-transcript.test.ts +++ b/packages/agent-core/test/services/message-transcript.test.ts @@ -192,6 +192,49 @@ describe('reduceWireRecords', () => { expect(textOf(entries[1]!.message)).toBe('ok'); }); + it('closes a tool call interrupted mid-history at the next step.begin', () => { + // Mirrors ContextMemory's replay-time closePendingToolResults: the synthetic + // interrupted result is re-derived (not persisted to the wire log), so the + // reducer must reconstruct it and flush the deferred message in place — and + // foldedLength must match the live folded history length so the downstream + // tail merge slices from the right index. + const { entries, foldedLength } = reduceWireRecords([ + appendMessage(userMessage('u1')), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }), + loopEvent({ + type: 'tool.call', + uuid: 'c1', + turnId: 't', + step: 0, + stepUuid: 's1', + toolCallId: 'call_interrupted', + name: 'Lookup', + args: { query: 'one' }, + }), + // Recorded while the exchange was open, so it was deferred live. + appendMessage(userMessage('keep going')), + ...assistantStep('s2', 'a2'), + ]); + expect(entries.map((e) => e.message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + 'assistant', + ]); + // Synthetic result spliced in place (index 2), before the deferred prompt. + expect(entries[2]!.message.toolCallId).toBe('call_interrupted'); + expect(entries[2]!.message.isError).toBe(true); + expect(textOf(entries[2]!.message)).toBe( + 'ERROR: Tool execution failed.\n' + + 'Tool execution was interrupted before its result was recorded. ' + + 'Do not assume the tool completed successfully.', + ); + expect(textOf(entries[3]!.message)).toBe('keep going'); + expect(textOf(entries[4]!.message)).toBe('a2'); + expect(foldedLength).toBe(5); + }); + it('wraps tool errors and empty outputs with statuses like agent-core', () => { const { entries } = reduceWireRecords([ loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }), From 9c522246352cd0f2f5f4cb74b183a48c529e79a0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 23 Jun 2026 18:26:25 +0800 Subject: [PATCH 3/6] docs(changeset): note wire transcript reducer mirroring --- .changeset/fix-mid-history-interrupted-tool-call.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-mid-history-interrupted-tool-call.md b/.changeset/fix-mid-history-interrupted-tool-call.md index d3cfb1c0ad..d97f7d9982 100644 --- a/.changeset/fix-mid-history-interrupted-tool-call.md +++ b/.changeset/fix-mid-history-interrupted-tool-call.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. +Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. From f8518dc58590ceee2a0977bf593c4df2945cd387 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 23 Jun 2026 18:43:30 +0800 Subject: [PATCH 4/6] style(agent-core): trim helper/test comments to one WHY line Per services/AGENTS.md: no block docstrings on internal helpers; one short WHY line max. --- packages/agent-core/src/services/message/transcript.ts | 7 ++----- .../agent-core/test/services/message-transcript.test.ts | 5 ----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/agent-core/src/services/message/transcript.ts b/packages/agent-core/src/services/message/transcript.ts index 46c4a6354e..9edfcb74ba 100644 --- a/packages/agent-core/src/services/message/transcript.ts +++ b/packages/agent-core/src/services/message/transcript.ts @@ -118,11 +118,8 @@ export function reduceWireRecords(records: Iterable): { push(...deferred); deferred = []; }; - // Mirrors ContextMemory.closePendingToolResults: a tool call left open when a - // new step begins was interrupted, so synthesize its error result in place. - // ContextMemory does this during replay, where the synthetic result is NOT - // re-persisted to the wire log, so the reducer must reconstruct it to keep - // `foldedLength` aligned with the live folded history. + // ContextMemory closes these during replay without persisting the synthetic + // result, so the reducer must reconstruct it to keep foldedLength aligned. const closePendingToolResults = (time: number | undefined): void => { if (pendingToolResultIds.size === 0) return; const interruptedToolCallIds = [...pendingToolResultIds]; diff --git a/packages/agent-core/test/services/message-transcript.test.ts b/packages/agent-core/test/services/message-transcript.test.ts index a4b1e16e6f..45d610b4b2 100644 --- a/packages/agent-core/test/services/message-transcript.test.ts +++ b/packages/agent-core/test/services/message-transcript.test.ts @@ -193,11 +193,6 @@ describe('reduceWireRecords', () => { }); it('closes a tool call interrupted mid-history at the next step.begin', () => { - // Mirrors ContextMemory's replay-time closePendingToolResults: the synthetic - // interrupted result is re-derived (not persisted to the wire log), so the - // reducer must reconstruct it and flush the deferred message in place — and - // foldedLength must match the live folded history length so the downstream - // tail merge slices from the right index. const { entries, foldedLength } = reduceWireRecords([ appendMessage(userMessage('u1')), loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }), From 50552623efbe0c16c3409501e0dc79b10f212e60 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 23 Jun 2026 20:17:52 +0800 Subject: [PATCH 5/6] fix(agent-core): drop tool results for ids not awaiting one A legacy session repaired by the old tail-only finishResume has a synthetic interrupted tool.result persisted at the log tail. On the next resume the new in-place step.begin closure already answers that id, so replaying the tail copy appended a duplicate/orphan (the tool.result handler pushed unconditionally); the wire reducer had the same flaw, desyncing foldedLength and duplicating the later turn in /messages. Guard both reducers: skip a tool.result whose id is not in pendingToolResultIds. In a well-formed stream every result's id is pending (added by its tool.call), so this only drops stale duplicates and orphans (call compacted away / missing), which would otherwise make an invalid request anyway. With in-place closure running during replay, finishResume already only writes the trailing step's open calls, so no new mid-history results are persisted. Two rendering fixtures appended bare tool.results without a tool.call; made them well-formed. Adds regression tests for the legacy stale-tail duplicate in both ContextMemory and the wire reducer. --- .../fix-mid-history-interrupted-tool-call.md | 2 +- .../agent-core/src/agent/context/index.ts | 4 + .../src/services/message/transcript.ts | 3 + .../agent-core/test/agent/context.test.ts | 33 ++++++++ packages/agent-core/test/agent/resume.test.ts | 78 +++++++++++++++++++ .../test/services/message-transcript.test.ts | 51 ++++++++++++ 6 files changed, 170 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-mid-history-interrupted-tool-call.md b/.changeset/fix-mid-history-interrupted-tool-call.md index d97f7d9982..7192f664fb 100644 --- a/.changeset/fix-mid-history-interrupted-tool-call.md +++ b/.changeset/fix-mid-history-interrupted-tool-call.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. +Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. Replay also drops a tool result whose call is no longer awaiting one, so a stale interrupted result left at the log tail by an older resume of a damaged session is not re-applied as a duplicate. diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index a854482179..88edda53f9 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -331,6 +331,10 @@ export class ContextMemory { return; } case 'tool.result': { + // Drop a result for an id that is not awaiting one: it was already + // closed in place at a step boundary (a stale duplicate from an older + // tail-only finishResume), or its call is gone. + if (!this.pendingToolResultIds.has(event.toolCallId)) return; const message = createToolMessage(event.toolCallId, toolResultOutputForModel(event.result)); this.pushHistory({ ...message, diff --git a/packages/agent-core/src/services/message/transcript.ts b/packages/agent-core/src/services/message/transcript.ts index 9edfcb74ba..e98bed516a 100644 --- a/packages/agent-core/src/services/message/transcript.ts +++ b/packages/agent-core/src/services/message/transcript.ts @@ -183,6 +183,9 @@ export function reduceWireRecords(records: Iterable): { return; } case 'tool.result': { + // Drop a result for an id not awaiting one (already closed in place, or + // its call is gone) — mirrors ContextMemory. + if (!pendingToolResultIds.has(event.toolCallId)) return; push({ message: { role: 'tool', diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index ddc8531840..ad36a56ca2 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -18,6 +18,19 @@ describe('Agent context', () => { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 'origin-step', turnId: '', step: 1 }, }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'origin-tool', + turnId: '', + step: 1, + stepUuid: 'origin-step', + toolCallId: 'call_origin', + name: 'Run', + args: {}, + }, + }); ctx.dispatch({ type: 'context.append_loop_event', event: { type: 'step.end', uuid: 'origin-step', turnId: '', step: 1 }, @@ -45,6 +58,25 @@ describe('Agent context', () => { const ctx = testAgent(); ctx.configure(); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 }, + }); + for (const toolCallId of ['call_error', 'call_empty']) { + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: toolCallId, + turnId: 't', + step: 1, + stepUuid: 's1', + toolCallId, + name: 'Run', + args: {}, + }, + }); + } ctx.dispatch({ type: 'context.append_loop_event', event: { @@ -65,6 +97,7 @@ describe('Agent context', () => { }); expect(ctx.agent.context.messages).toMatchObject([ + { role: 'assistant', toolCalls: [{ id: 'call_error' }, { id: 'call_empty' }] }, { role: 'tool', content: [ diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index cdec609f0f..9ec6bbc604 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -779,6 +779,84 @@ describe('Agent resume', () => { await ctx.expectResumeMatches(); }); + it('drops a stale tail interrupted result already closed in place on resume', async () => { + // Legacy log: an older tail-only finishResume appended the synthetic result + // for `call_interrupted` at the END of the stream (after the later turn from + // the deferral avalanche). The new in-place closure handles it at step.begin, + // so the trailing persisted copy must be dropped rather than duplicated. + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Run the lookup' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call-interrupted', + turnId: '0', + step: 1, + stepUuid: 'interrupted-step', + toolCallId: 'call_interrupted', + name: 'Lookup', + args: { query: 'one' }, + }, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'keep going' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ...loopEventsForTurn('1', 'All done.'), + // The stale synthetic result an older resume appended at the tail. + { + type: 'context.append_loop_event', + event: { + type: 'tool.result', + parentUuid: 'call_interrupted', + toolCallId: 'call_interrupted', + result: { + output: + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.', + isError: true, + }, + }, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + // The trailing duplicate is dropped: exactly one synthetic result, in place. + expect(ctx.agent.context.history.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + 'assistant', + ]); + expect(ctx.agent.context.history[2]).toMatchObject({ + role: 'tool', + toolCallId: 'call_interrupted', + isError: true, + }); + expect(textContent(ctx.agent.context.history[4])).toBe('All done.'); + await ctx.expectResumeMatches(); + }); + it('rebuilds goal completion replay cards without adding model-visible context', async () => { const persistence = new RecordingAgentPersistence([ { diff --git a/packages/agent-core/test/services/message-transcript.test.ts b/packages/agent-core/test/services/message-transcript.test.ts index 45d610b4b2..38cb059721 100644 --- a/packages/agent-core/test/services/message-transcript.test.ts +++ b/packages/agent-core/test/services/message-transcript.test.ts @@ -230,9 +230,60 @@ describe('reduceWireRecords', () => { expect(foldedLength).toBe(5); }); + it('drops a stale tail interrupted result already closed in place', () => { + const { entries, foldedLength } = reduceWireRecords([ + appendMessage(userMessage('u1')), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }), + loopEvent({ + type: 'tool.call', + uuid: 'c1', + turnId: 't', + step: 0, + stepUuid: 's1', + toolCallId: 'call_interrupted', + name: 'Lookup', + args: { query: 'one' }, + }), + appendMessage(userMessage('keep going')), + ...assistantStep('s2', 'a2'), + // The stale synthetic result an older tail-only resume appended. + loopEvent({ + type: 'tool.result', + parentUuid: 'call_interrupted', + toolCallId: 'call_interrupted', + result: { + output: + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.', + isError: true, + }, + }), + ]); + expect(entries.map((e) => e.message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'user', + 'assistant', + ]); + expect(entries[2]!.message.toolCallId).toBe('call_interrupted'); + expect(foldedLength).toBe(5); + }); + it('wraps tool errors and empty outputs with statuses like agent-core', () => { const { entries } = reduceWireRecords([ loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }), + ...['call_err', 'call_empty'].map((toolCallId) => + loopEvent({ + type: 'tool.call', + uuid: toolCallId, + turnId: 't', + step: 0, + stepUuid: 's1', + toolCallId, + name: 'Run', + args: {}, + }), + ), loopEvent({ type: 'tool.result', parentUuid: 's1', From 8ed41c4be5b40b12e8ae7dcccc2063cb84b9c58b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 23 Jun 2026 21:17:09 +0800 Subject: [PATCH 6/6] test(agent-core): cover mid-history tool-repair edge cases Add coverage for the resume/wire-reducer repair paths: multiple open calls in one interrupted step (closed in tool-call order), partial resolution (only the unresolved call synthesized), consecutive interrupted steps each closed at their own boundary, and orphan tool results (no matching call) dropped. Reducer cases also assert foldedLength stays aligned. --- packages/agent-core/test/agent/resume.test.ts | 223 ++++++++++++++++++ .../test/services/message-transcript.test.ts | 43 ++++ 2 files changed, 266 insertions(+) diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 9ec6bbc604..301e2533a5 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -857,6 +857,229 @@ describe('Agent resume', () => { await ctx.expectResumeMatches(); }); + it('closes every open call of a multi-call interrupted step in order', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Run both' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 }, + }, + ...['call_a', 'call_b'].map((toolCallId) => ({ + type: 'context.append_loop_event' as const, + event: { + type: 'tool.call' as const, + uuid: toolCallId, + turnId: '0', + step: 1, + stepUuid: 'interrupted-step', + toolCallId, + name: 'Lookup', + args: {}, + }, + })), + ...loopEventsForTurn('1', 'All done.'), + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + // Both open calls get a synthetic result, in tool-call order, before the + // next turn. + expect(ctx.agent.context.history.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'tool', + 'assistant', + ]); + expect(ctx.agent.context.history[2]).toMatchObject({ + role: 'tool', + toolCallId: 'call_a', + isError: true, + }); + expect(ctx.agent.context.history[3]).toMatchObject({ + role: 'tool', + toolCallId: 'call_b', + isError: true, + }); + await ctx.expectResumeMatches(); + }); + + it('synthesizes only the unresolved call when a step is partially resolved', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Run both' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'interrupted-step', turnId: '0', step: 1 }, + }, + ...['call_done', 'call_open'].map((toolCallId) => ({ + type: 'context.append_loop_event' as const, + event: { + type: 'tool.call' as const, + uuid: toolCallId, + turnId: '0', + step: 1, + stepUuid: 'interrupted-step', + toolCallId, + name: 'Lookup', + args: {}, + }, + })), + { + type: 'context.append_loop_event', + event: { + type: 'tool.result', + parentUuid: 'call_done', + toolCallId: 'call_done', + result: { output: 'real result' }, + }, + }, + ...loopEventsForTurn('1', 'All done.'), + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + expect(ctx.agent.context.history.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'tool', + 'assistant', + ]); + // The recorded result is kept verbatim; only the open call is synthesized. + expect(ctx.agent.context.history[2]).toMatchObject({ toolCallId: 'call_done' }); + expect(textContent(ctx.agent.context.history[2])).toBe('real result'); + expect(ctx.agent.context.history[3]).toMatchObject({ + toolCallId: 'call_open', + isError: true, + }); + expect(textContent(ctx.agent.context.history[3])).toContain( + 'Tool execution was interrupted before its result was recorded', + ); + await ctx.expectResumeMatches(); + }); + + it('closes consecutive interrupted steps each at their own boundary', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Go' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + // First interrupted step. + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'step-1', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call_one', + turnId: '0', + step: 1, + stepUuid: 'step-1', + toolCallId: 'call_one', + name: 'Lookup', + args: {}, + }, + }, + // Second interrupted step (closes the first in place at its step.begin). + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'step-2', turnId: '1', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'call_two', + turnId: '1', + step: 1, + stepUuid: 'step-2', + toolCallId: 'call_two', + name: 'Lookup', + args: {}, + }, + }, + // Final fully-run turn (closes the second in place). + ...loopEventsForTurn('2', 'Done.'), + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + expect(ctx.agent.context.history.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'assistant', + 'tool', + 'assistant', + ]); + expect(ctx.agent.context.history[2]).toMatchObject({ toolCallId: 'call_one', isError: true }); + expect(ctx.agent.context.history[4]).toMatchObject({ toolCallId: 'call_two', isError: true }); + await ctx.expectResumeMatches(); + }); + + it('drops an orphan tool result whose call was never recorded', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Hi' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ...loopEventsForTurn('0', 'Hello.'), + // A result with no matching tool.call (e.g. its call was compacted away). + { + type: 'context.append_loop_event', + event: { + type: 'tool.result', + parentUuid: 'ghost', + toolCallId: 'call_ghost', + result: { output: 'orphaned' }, + }, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + expect(ctx.agent.context.history.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + ]); + expect( + ctx.agent.context.history.some((message) => message.role === 'tool'), + ).toBe(false); + await ctx.expectResumeMatches(); + }); + it('rebuilds goal completion replay cards without adding model-visible context', async () => { const persistence = new RecordingAgentPersistence([ { diff --git a/packages/agent-core/test/services/message-transcript.test.ts b/packages/agent-core/test/services/message-transcript.test.ts index 38cb059721..4ec462f2d8 100644 --- a/packages/agent-core/test/services/message-transcript.test.ts +++ b/packages/agent-core/test/services/message-transcript.test.ts @@ -269,6 +269,49 @@ describe('reduceWireRecords', () => { expect(foldedLength).toBe(5); }); + it('closes every open call of a multi-call interrupted step, keeping foldedLength aligned', () => { + const { entries, foldedLength } = reduceWireRecords([ + loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }), + ...['call_a', 'call_b'].map((toolCallId) => + loopEvent({ + type: 'tool.call', + uuid: toolCallId, + turnId: 't', + step: 0, + stepUuid: 's1', + toolCallId, + name: 'Run', + args: {}, + }), + ), + ...assistantStep('s2', 'a2'), + ]); + expect(entries.map((e) => e.message.role)).toEqual([ + 'assistant', + 'tool', + 'tool', + 'assistant', + ]); + expect(entries[1]!.message.toolCallId).toBe('call_a'); + expect(entries[2]!.message.toolCallId).toBe('call_b'); + expect(foldedLength).toBe(4); + }); + + it('drops an orphan tool result whose call was never recorded', () => { + const { entries, foldedLength } = reduceWireRecords([ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + loopEvent({ + type: 'tool.result', + parentUuid: 'ghost', + toolCallId: 'call_ghost', + result: { output: 'orphaned' }, + }), + ]); + expect(entries.map((e) => e.message.role)).toEqual(['user', 'assistant']); + expect(foldedLength).toBe(2); + }); + it('wraps tool errors and empty outputs with statuses like agent-core', () => { const { entries } = reduceWireRecords([ loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),