diff --git a/.changeset/fix-interrupted-thinking-resume.md b/.changeset/fix-interrupted-thinking-resume.md new file mode 100644 index 0000000000..f748fe8845 --- /dev/null +++ b/.changeset/fix-interrupted-thinking-resume.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kosong": patch +--- + +Fix resumed sessions failing after an assistant response is interrupted during thinking. diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 52f8c6c4fb..219e8ede9b 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -56,6 +56,8 @@ export class ContextMemory { private openSteps: Map = new Map(); private pendingToolResultIds = new Set(); private deferredMessages: ContextMessage[] = []; + private interruptedSteps = new WeakSet(); + private hasInterruptedSteps = false; private _lastAssistantAt: number | null = null; // Signature of the last logged set of projection repairs, so a repair that // recurs identically on every send is logged once rather than per step. @@ -177,6 +179,8 @@ export class ContextMemory { this.openSteps.clear(); this.pendingToolResultIds.clear(); this.deferredMessages = []; + this.interruptedSteps = new WeakSet(); + this.hasInterruptedSteps = false; this._lastAssistantAt = null; this.agent.microCompaction.reset(); this.agent.injection.onContextClear(); @@ -450,6 +454,11 @@ export class ContextMemory { } project(messages: readonly ContextMessage[], options?: ProjectOptions): Message[] { + const resumableMessages = this.hasInterruptedSteps + ? messages.map((message) => + this.interruptedSteps.has(message) ? { ...message, partial: true } : message, + ) + : messages; // Shape for the current model BEFORE projecting: a model without the // dynamically-loaded-tools capability must not see dynamic-tool schema // messages or loadable-tools announcements (the canonical history keeps @@ -458,8 +467,8 @@ export class ContextMemory { // setModel never rewrites history, so a mid-session switch // degrades/upgrades losslessly. const shaped = this.agent.toolSelectEnabled - ? this.agent.tools.shapeDynamicToolHistory(messages) - : stripDynamicToolContext(messages); + ? this.agent.tools.shapeDynamicToolHistory(resumableMessages) + : stripDynamicToolContext(resumableMessages); const anomalies: ProjectionAnomaly[] = []; const result = project(this.agent.microCompaction.compact(shaped), { ...options, @@ -597,6 +606,14 @@ export class ContextMemory { } finishResume(): void { + for (const message of this.openSteps.values()) { + if (message.toolCalls.length > 0) continue; + // A replayed step without step.end is an incomplete model response. Keep + // it in canonical history for diagnostics, but exclude it from provider + // projections just like the v2 context fold does with open steps. + this.interruptedSteps.add(message); + this.hasInterruptedSteps = true; + } this.openSteps.clear(); const closed = this.closePendingToolResults(); if (closed.length > 0) { diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 0bb76debe9..362d1ac770 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -24,6 +24,50 @@ const MOCK_PROVIDER = { } as const; describe('Agent resume', () => { + it('omits an assistant step interrupted after non-empty thinking from the next request', async () => { + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Write a plan' }], + 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: 'content.part', + uuid: 'interrupted-thinking', + turnId: '0', + step: 1, + stepUuid: 'interrupted-step', + part: { type: 'think', think: 'The plan is written. I should request approval.' }, + }, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + expect(ctx.agent.context.messages).toHaveLength(1); + expect(ctx.agent.context.messages[0]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'Write a plan' }], + toolCalls: [], + }); + }); + it('does not append metadata when resuming records that include legacy app version', async () => { const persistence = new RecordingAgentPersistence([ { diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index f40e59df7d..b64c2fee44 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -244,6 +244,14 @@ function convertMessage( return result; } +function isAssistantMessageWithoutContentOrToolCalls(message: OpenAIMessage): boolean { + return ( + message.role === 'assistant' && + message.content === undefined && + message.tool_calls === undefined + ); +} + // Chat Completions has no url-based audio/video content part (only base64 // `input_audio`), so unlike images these cannot be reattached as user input. // Note the omission inline in the tool message text instead. @@ -311,7 +319,13 @@ function convertHistoryMessages( if (msg.role !== 'tool') { appendToolResultMediaMessage(messages, pendingToolResultMedia); } - messages.push(convertMessage(msg, reasoningKey, toolMessageConversion)); + const converted = convertMessage(msg, reasoningKey, toolMessageConversion); + // `reasoning_content` is supplemental: OpenAI-compatible APIs still + // require an assistant message to carry content or tool calls. A process + // interrupted mid-thinking can leave this invalid shape in history. + if (!isAssistantMessageWithoutContentOrToolCalls(converted)) { + messages.push(converted); + } if (msg.role === 'tool') { pendingToolResultMedia.push(...toolResultImageParts(msg)); } @@ -553,10 +567,11 @@ export class OpenAILegacyChatProvider implements ChatProvider { history, OPENAI_CHAT_TOOL_CALL_ID_POLICY, ); + const reasoningKey = this._reasoningKeyDialect.outboundKey(); messages.push( ...convertHistoryMessages( normalizedHistory, - this._reasoningKeyDialect.outboundKey(), + reasoningKey, this._toolMessageConversion, ), ); @@ -594,10 +609,10 @@ export class OpenAILegacyChatProvider implements ChatProvider { effort !== 'off' && kwargs['reasoning_effort'] === undefined ) { - const hasThinkPart = history.some((message) => - message.content.some((part) => part.type === 'think'), + const hasReasoningContent = messages.some( + (message) => message[reasoningKey] !== undefined, ); - if (hasThinkPart) { + if (hasReasoningContent) { reasoningEffort = 'medium'; } } diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 3f5d8b5bf8..d6c4454557 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1361,6 +1361,32 @@ describe('OpenAILegacyChatProvider', () => { }); describe('default reasoning protocol (no explicit reasoningKey)', () => { + it('omits an assistant message with only non-empty reasoning from request history', async () => { + const provider = createProvider({ model: 'deepseek-reasoner' }); + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Write a plan' }], toolCalls: [] }, + { + role: 'assistant', + content: [ + { + type: 'think', + think: 'The plan is written. I should request approval.', + }, + ], + toolCalls: [], + }, + { role: 'user', content: [{ type: 'text', text: 'Continue' }], toolCalls: [] }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { role: 'user', content: 'Write a plan' }, + { role: 'user', content: 'Continue' }, + ]); + expect(body['reasoning_effort']).toBeUndefined(); + }); + it('serializes ThinkPart back to reasoning_content even without reasoningKey', async () => { // The whole point of issue #69: a hand-written config.toml never sets // reasoningKey, but the round-trip must still work against DeepSeek-style @@ -1404,9 +1430,16 @@ describe('OpenAILegacyChatProvider', () => { const body = await captureRequestBody(provider, '', [], history); const messages = body['messages'] as Record[]; - expect(messages[0]).toMatchObject({ + expect(messages[0]).toEqual({ role: 'assistant', reasoning_content: '', + tool_calls: [ + { + type: 'function', + id: 'call_1', + function: { name: 'lookup', arguments: '{"q":"test"}' }, + }, + ], }); });