From b3fb9e7ee224b64e6dd1c5abc6ece4de61b75f41 Mon Sep 17 00:00:00 2001 From: LifeJiggy Date: Thu, 11 Jun 2026 17:45:49 +0100 Subject: [PATCH 1/3] fix(agent-core): handle incomplete tool_calls on session resume When a session is killed mid-tool-call and later resumed, the history contains an assistant message with tool_calls but no matching tool results. This caused a 400 API error and silently deferred all new user messages. Fixes #660. Changes: - project() now applies trimTrailingOpenToolExchange() to strip trailing assistant messages with unanswered tool_calls before sending to the LLM - Added cleanupOrphanedToolCalls() to ContextMemory that clears stale pendingToolResultIds after resume, so new user messages are not silently deferred - Fixed trimTrailingOpenToolExchange edge case where history with no assistant message returned [] instead of the full history Tests: - project() trims trailing assistant with unanswered tool_calls - project() keeps assistant when all tool_calls are answered - cleanupOrphanedToolCalls clears stale pendingToolResultIds --- .../agent-core/src/agent/context/index.ts | 22 ++++++ .../agent-core/src/agent/context/projector.ts | 11 ++- packages/agent-core/src/agent/index.ts | 4 + .../agent-core/test/agent/context.test.ts | 78 +++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 19ca54f07..12c787160 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -298,6 +298,28 @@ export class ContextMemory { return this.pendingToolResultIds.size > 0; } + /** + * Remove stale entries from `pendingToolResultIds` that have no matching + * tool result in the history. This happens when a session is killed + * mid-tool-call and later resumed — the tool.call events are replayed + * but the tool.result events never arrived. Without this cleanup, + * `hasOpenToolExchange()` would remain true, silently deferring all + * new user messages. + */ + cleanupOrphanedToolCalls(): void { + const answeredIds = new Set(); + for (const message of this._history) { + if (message.role === 'tool' && typeof message.toolCallId === 'string') { + answeredIds.add(message.toolCallId); + } + } + for (const id of this.pendingToolResultIds) { + if (!answeredIds.has(id)) { + this.pendingToolResultIds.delete(id); + } + } + } + private pushHistory(...messages: ContextMessage[]): void { this._history.push(...messages); for (const message of messages) { diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index e0ae99972..51dee07a9 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -12,7 +12,11 @@ export function project(history: readonly ContextMessage[]): Message[] { !(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0) ); }); - return mergeAdjacentUserMessages(usable); + // Trim any trailing assistant message whose tool_calls were never answered + // (e.g. the session was killed mid-tool-call). Sending an assistant + // message with open tool_calls violates the API contract and causes a + // 400 error on resume. + return trimTrailingOpenToolExchange(mergeAdjacentUserMessages(usable)); } function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[] { @@ -77,8 +81,11 @@ export function trimTrailingOpenToolExchange(history: readonly Message[]): Messa lastNonToolIndex -= 1; } + // No assistant message found — nothing to trim. + if (lastNonToolIndex < 0) return [...history]; + const assistant = history[lastNonToolIndex]; - if (assistant === undefined) return []; + if (assistant === undefined) return [...history]; if (assistant.role !== 'assistant' || assistant.toolCalls.length === 0) return [...history]; const trailingToolCallIds = new Set( diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 1b90276f9..a44ea334e 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -300,6 +300,10 @@ export class Agent { await this.background.loadFromDisk(); await this.background.reconcile(); await this.cron?.loadFromDisk(); + // Clean up any tool_call IDs that were never answered (session killed + // mid-tool-call). Without this, new user messages would be silently + // deferred because `hasOpenToolExchange()` would remain true. + this.context.cleanupOrphanedToolCalls(); this.turn.finishResume(); return result; } diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 2a8a33c71..b7d590f65 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -776,6 +776,84 @@ describe('Agent context notification projection', () => { expect(textOf(messages[1]!)).toBe('No origin prompt'); expect(textOf(messages[2]!)).toBe('Third real prompt'); }); + + it('project() trims trailing assistant message with unanswered tool_calls', () => { + const history: ContextMessage[] = [ + userMessage('hello'), + { + role: 'assistant', + content: [{ type: 'text', text: 'I will run a tool' }], + toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{}' }], + }, + // No tool result for call_1 — session was killed. + ]; + const messages = project(history); + // The assistant message with open tool_calls should be trimmed. + expect(messages).toHaveLength(1); + expect(messages[0]!.role).toBe('user'); + }); + + it('project() keeps assistant message when all tool_calls are answered', () => { + const history: ContextMessage[] = [ + userMessage('hello'), + { + role: 'assistant', + content: [{ type: 'text', text: 'I will run a tool' }], + toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'tool output' }], + toolCalls: [], + toolCallId: 'call_1', + }, + ]; + const messages = project(history); + // All three messages should be present. + expect(messages).toHaveLength(3); + expect(messages[1]!.role).toBe('assistant'); + expect(messages[2]!.role).toBe('tool'); + }); + + it('cleanupOrphanedToolCalls clears stale pendingToolResultIds after resume', () => { + const ctx = testAgent(); + ctx.configure(); + + // Simulate a tool.call event that never got a tool.result (session killed). + ctx.dispatch({ + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'step-orphan', turnId: '', step: 1 }, + }); + ctx.dispatch({ + type: 'context.append_loop_event', + event: { + type: 'tool.call', + parentUuid: 'step-orphan', + stepUuid: 'step-orphan', + toolCallId: 'orphan_call', + name: 'Bash', + arguments: '{}', + }, + }); + + // The orphaned tool call should block new messages. + ctx.agent.context.appendUserMessage([{ type: 'text', text: 'follow up' }]); + // The message should be deferred, not in history. + const historyBefore = ctx.agent.context.history.filter( + (m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'follow up'), + ); + expect(historyBefore).toHaveLength(0); + + // Now cleanup the orphaned tool calls. + ctx.agent.context.cleanupOrphanedToolCalls(); + + // After cleanup, new messages should go through. + ctx.agent.context.appendUserMessage([{ type: 'text', text: 'after cleanup' }]); + const historyAfter = ctx.agent.context.history.filter( + (m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'after cleanup'), + ); + expect(historyAfter).toHaveLength(1); + }); }); function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage { From c066cba25e81967668f538e4827e46ee8d836554 Mon Sep 17 00:00:00 2001 From: LifeJiggy Date: Thu, 11 Jun 2026 17:48:12 +0100 Subject: [PATCH 2/3] fix: fix typecheck errors in session resume test and add changeset - Add missing type: 'function' to ToolCall objects in test - Fix LoopToolCallEvent shape (use stepUuid instead of parentUuid, add uuid/turnId/step/args) - Add changeset for agent-core and kimi-code patch bumps --- .changeset/fix-session-resume-incomplete-tool-calls.md | 7 +++++++ packages/agent-core/test/agent/context.test.ts | 10 ++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-session-resume-incomplete-tool-calls.md diff --git a/.changeset/fix-session-resume-incomplete-tool-calls.md b/.changeset/fix-session-resume-incomplete-tool-calls.md new file mode 100644 index 000000000..524482ee1 --- /dev/null +++ b/.changeset/fix-session-resume-incomplete-tool-calls.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch + +--- + +Fix session resume failing with 400 error when previous turn was interrupted mid-tool-call. diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index b7d590f65..dfa8c6c7e 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -783,7 +783,7 @@ describe('Agent context notification projection', () => { { role: 'assistant', content: [{ type: 'text', text: 'I will run a tool' }], - toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{}' }], + toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{}' }], }, // No tool result for call_1 — session was killed. ]; @@ -799,7 +799,7 @@ describe('Agent context notification projection', () => { { role: 'assistant', content: [{ type: 'text', text: 'I will run a tool' }], - toolCalls: [{ id: 'call_1', name: 'Bash', arguments: '{}' }], + toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{}' }], }, { role: 'tool', @@ -828,11 +828,13 @@ describe('Agent context notification projection', () => { type: 'context.append_loop_event', event: { type: 'tool.call', - parentUuid: 'step-orphan', + uuid: 'tool-orphan', stepUuid: 'step-orphan', + turnId: '', + step: 1, toolCallId: 'orphan_call', name: 'Bash', - arguments: '{}', + args: {}, }, }); From ac93e902a1e8dfa03a6bc1daee07034356b2b50b Mon Sep 17 00:00:00 2001 From: LifeJiggy Date: Fri, 12 Jun 2026 19:47:00 +0100 Subject: [PATCH 3/3] fix(agent-core): trim orphaned assistant messages from history on resume cleanupOrphanedToolCalls() now also removes assistant messages with unanswered tool_calls from _history. Without this, the orphaned assistant remains in history and the next user prompt creates a sequence that violates the API contract (assistant tool_calls without tool results), causing the same 400 error the fix was meant to prevent. --- .../agent-core/src/agent/context/index.ts | 34 +++++++++++++++---- .../agent-core/test/agent/context.test.ts | 15 ++++++-- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 12c787160..d4174abab 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -299,12 +299,13 @@ export class ContextMemory { } /** - * Remove stale entries from `pendingToolResultIds` that have no matching - * tool result in the history. This happens when a session is killed - * mid-tool-call and later resumed — the tool.call events are replayed - * but the tool.result events never arrived. Without this cleanup, - * `hasOpenToolExchange()` would remain true, silently deferring all - * new user messages. + * Remove stale entries from `pendingToolResultIds` and trim orphaned + * assistant messages from `_history`. This happens when a session is + * killed mid-tool-call and later resumed — the tool.call events are + * replayed but the tool.result events never arrived. Without this + * cleanup, `hasOpenToolExchange()` would remain true (deferring new + * messages) and the orphaned assistant would still be sent to the + * provider on the next turn, causing a 400 error. */ cleanupOrphanedToolCalls(): void { const answeredIds = new Set(); @@ -313,11 +314,30 @@ export class ContextMemory { answeredIds.add(message.toolCallId); } } + // Find tool_call_ids that have no matching tool result. + const orphanedIds = new Set(); for (const id of this.pendingToolResultIds) { if (!answeredIds.has(id)) { - this.pendingToolResultIds.delete(id); + orphanedIds.add(id); } } + this.pendingToolResultIds.clear(); + if (orphanedIds.size === 0) return; + + // Remove assistant messages with unanswered tool_calls, and any + // trailing tool messages whose IDs are orphaned. + this._history = this._history.filter((message) => { + if ( + message.role === 'assistant' && + message.toolCalls.some((tc) => orphanedIds.has(tc.id)) + ) { + return false; + } + if (message.role === 'tool' && typeof message.toolCallId === 'string' && orphanedIds.has(message.toolCallId)) { + return false; + } + return true; + }); } private pushHistory(...messages: ContextMessage[]): void { diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index dfa8c6c7e..6a1ae3601 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -815,7 +815,7 @@ describe('Agent context notification projection', () => { expect(messages[2]!.role).toBe('tool'); }); - it('cleanupOrphanedToolCalls clears stale pendingToolResultIds after resume', () => { + it('cleanupOrphanedToolCalls clears stale pendingToolResultIds and removes orphaned assistant from history', () => { const ctx = testAgent(); ctx.configure(); @@ -840,13 +840,16 @@ describe('Agent context notification projection', () => { // The orphaned tool call should block new messages. ctx.agent.context.appendUserMessage([{ type: 'text', text: 'follow up' }]); - // The message should be deferred, not in history. const historyBefore = ctx.agent.context.history.filter( (m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'follow up'), ); expect(historyBefore).toHaveLength(0); - // Now cleanup the orphaned tool calls. + // The orphaned assistant should be in history before cleanup. + const assistantBefore = ctx.agent.context.history.filter((m) => m.role === 'assistant'); + expect(assistantBefore.length).toBeGreaterThanOrEqual(1); + + // Cleanup should clear pendingToolResultIds AND remove orphaned assistant. ctx.agent.context.cleanupOrphanedToolCalls(); // After cleanup, new messages should go through. @@ -855,6 +858,12 @@ describe('Agent context notification projection', () => { (m) => m.role === 'user' && m.content.some((p) => p.type === 'text' && 'text' in p && p.text === 'after cleanup'), ); expect(historyAfter).toHaveLength(1); + + // The orphaned assistant should no longer be in history. + const assistantAfter = ctx.agent.context.history.filter( + (m) => m.role === 'assistant' && m.toolCalls.some((tc) => tc.id === 'orphan_call'), + ); + expect(assistantAfter).toHaveLength(0); }); });