diff --git a/.changeset/recover-interrupted-tool-exchange.md b/.changeset/recover-interrupted-tool-exchange.md
new file mode 100644
index 00000000..d86ba76a
--- /dev/null
+++ b/.changeset/recover-interrupted-tool-exchange.md
@@ -0,0 +1,6 @@
+---
+"@moonshot-ai/agent-core": patch
+"@moonshot-ai/kimi-code": patch
+---
+
+Recover resumed sessions that were interrupted after recording a tool call but before recording its tool result.
diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts
index 4b3810f9..344dd0a2 100644
--- a/packages/agent-core/src/agent/context/index.ts
+++ b/packages/agent-core/src/agent/context/index.ts
@@ -19,6 +19,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 INTERRUPTED_TOOL_RESULT =
+ 'Kimi Code was interrupted before this tool call could record a result. Treat this tool call as failed and continue from the latest user instruction.';
export class ContextMemory {
private _history: ContextMessage[] = [];
@@ -206,6 +208,27 @@ export class ContextMemory {
this.pushHistory(message);
}
+ recoverInterruptedToolExchanges(): number {
+ // A sealed step can intentionally keep waiting for async tool output across
+ // context operations. An open step at replay EOF means the process stopped
+ // before the loop could finish pairing recorded tool calls.
+ const missingToolResultIds = this.openSteps.size > 0 ? [...this.pendingToolResultIds] : [];
+ for (const toolCallId of missingToolResultIds) {
+ this.appendLoopEvent({
+ type: 'tool.result',
+ parentUuid: toolCallId,
+ toolCallId,
+ result: {
+ output: INTERRUPTED_TOOL_RESULT,
+ isError: true,
+ },
+ });
+ }
+ this.openSteps.clear();
+ this.flushDeferredMessagesIfToolExchangeClosed();
+ return missingToolResultIds.length;
+ }
+
private flushDeferredMessagesIfToolExchangeClosed(): void {
if (this.pendingToolResultIds.size > 0 || this.deferredMessages.length === 0) {
return;
diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts
index cf79270f..0df1fb46 100644
--- a/packages/agent-core/src/agent/records/index.ts
+++ b/packages/agent-core/src/agent/records/index.ts
@@ -185,6 +185,10 @@ export class AgentRecords {
this.persistence.rewrite(replayedRecords);
await this.persistence.flush();
}
+ const recoveredToolResults = this.agent.context.recoverInterruptedToolExchanges();
+ if (recoveredToolResults > 0) {
+ await this.persistence.flush();
+ }
if (this.agent.blobStore !== undefined) {
for (const msg of this.agent.context.history) {
await this.agent.blobStore.rehydrateParts(msg.content);
diff --git a/packages/agent-core/test/agent/records/index.test.ts b/packages/agent-core/test/agent/records/index.test.ts
index a35e0a8d..bbd04fe3 100644
--- a/packages/agent-core/test/agent/records/index.test.ts
+++ b/packages/agent-core/test/agent/records/index.test.ts
@@ -91,6 +91,87 @@ describe('AgentRecords persistence metadata', () => {
expect(persistence.records.filter((record) => record.type === 'metadata')).toHaveLength(1);
});
+ it('repairs orphan tool calls after replaying an interrupted session', async () => {
+ const stepUuid = 'interrupted-step';
+ const persistence = new InMemoryAgentRecordPersistence([
+ {
+ type: 'metadata',
+ protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
+ created_at: 1,
+ },
+ {
+ type: 'context.append_message',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: 'wait for task output' }],
+ toolCalls: [],
+ origin: { kind: 'user' },
+ },
+ },
+ {
+ type: 'context.append_loop_event',
+ event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 },
+ },
+ {
+ type: 'context.append_loop_event',
+ event: {
+ type: 'tool.call',
+ uuid: 'call_task_output',
+ turnId: '0',
+ step: 1,
+ stepUuid,
+ toolCallId: 'call_task_output',
+ name: 'TaskOutput',
+ args: { block: true },
+ },
+ },
+ {
+ type: 'context.append_message',
+ message: {
+ role: 'user',
+ content: [{ type: 'text', text: 'continue' }],
+ toolCalls: [],
+ origin: { kind: 'user' },
+ },
+ },
+ ]);
+ const ctx = testAgent({ persistence });
+
+ await ctx.agent.records.replay();
+
+ expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
+ 'user',
+ 'assistant',
+ 'tool',
+ 'user',
+ ]);
+ expect(ctx.agent.context.history[2]).toMatchObject({
+ role: 'tool',
+ toolCallId: 'call_task_output',
+ isError: true,
+ });
+ expect(ctx.agent.context.messages[2]?.content).toEqual([
+ {
+ type: 'text',
+ text: expect.stringContaining(
+ 'Kimi Code was interrupted before this tool call could record a result.',
+ ),
+ },
+ ]);
+ expect(persistence.records.at(-1)).toMatchObject({
+ type: 'context.append_loop_event',
+ event: {
+ type: 'tool.result',
+ parentUuid: 'call_task_output',
+ toolCallId: 'call_task_output',
+ result: {
+ output: expect.stringContaining('interrupted before this tool call'),
+ isError: true,
+ },
+ },
+ });
+ });
+
it('does not rewrite records that already use the current wire version', async () => {
const persistence = new RecordingInMemoryAgentRecordPersistence([
{