From b01e499fc50e6089dffacd4a99ca37a54ca53910 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 11 Aug 2026 20:40:56 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): drop interrupted thinking-only assistant messages at settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn interrupted while the model is still streaming thinking leaves the open assistant holding only an unsigned thinking fragment. The fold used to seal it into history because a non-empty thinking block is not vacuous; on OpenAI-compatible providers the serialized message then carries neither content nor tool_calls, and strict gateways reject every later request with a 400 (#1404). Treat unsigned-thinking-only content as unsendable at settle so the fold drops the message instead — replaying the records of an already bricked session repairs it. --- .changeset/fix-thinking-only-assistant-400.md | 5 +++ .../src/agent/contextMemory/loopEventFold.ts | 24 +++++++++-- .../agent/contextMemory/loopEventFold.test.ts | 8 +++- .../test/agent/loop/loop.test.ts | 40 +++++++++++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-thinking-only-assistant-400.md diff --git a/.changeset/fix-thinking-only-assistant-400.md b/.changeset/fix-thinking-only-assistant-400.md new file mode 100644 index 0000000000..0ea2a008f0 --- /dev/null +++ b/.changeset/fix-thinking-only-assistant-400.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions failing with a provider 400 error on every follow-up request after a turn is interrupted while the model is still thinking, on strict OpenAI-compatible providers. diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 325b94ba90..09fdad0f55 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -21,9 +21,11 @@ * - `step.end` → settle the assistant * "Settle" closes any tool exchange left open (interrupted result messages), * then drops the partial assistant when nothing sendable was recorded (no - * tool calls; every content part vacuous — an output-free assistant only - * trips provider message validation) and seals it (`partial: undefined`) - * when it carries output. v1 never produced + * tool calls; every content part vacuous or unsigned thinking — an + * output-free assistant only trips provider message validation, and an + * unsigned-thinking-only one serializes to no content and no tool calls, + * which strict providers reject with a 400) and seals it + * (`partial: undefined`) when it carries output. v1 never produced * `step.begin` without `step.end` (its retries stayed inside one request), so * the drop/seal rule is the v2 extension that makes loop-level retries — a * retried attempt is its own `step.begin` — replay to the same history the @@ -204,6 +206,20 @@ function appendToOpenAssistant( return next; } +/** + * Settle-time counterpart of `isVacuousContentPart`. An interrupted turn can + * leave the open assistant holding only partial thinking whose provider + * signature never arrived; sealed into history, that message serializes to + * neither `content` nor `tool_calls` on OpenAI-compatible providers (the + * thinking becomes `reasoning_content`), and strict gateways reject every + * later request with a 400 (#1404). Anthropic drops unsigned thinking blocks + * on its wire too, so only signed thinking (`encrypted`) counts as sendable + * output here. + */ +function isUnsendableContentPart(part: ContentPart): boolean { + return isVacuousContentPart(part) || (part.type === 'think' && part.encrypted === undefined); +} + function settleOpenStep( state: readonly ContextMessage[], ctx: FoldCtx, @@ -212,7 +228,7 @@ function settleOpenStep( const index = findOpenAssistantIndex(closed); if (index === -1) return closed; const open = closed[index]!; - if (open.toolCalls.length === 0 && open.content.every(isVacuousContentPart)) { + if (open.toolCalls.length === 0 && open.content.every(isUnsendableContentPart)) { return [...closed.slice(0, index), ...closed.slice(index + 1)]; } const next = closed.slice(); diff --git a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts index 25f9f73b39..37f5b3edcb 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts @@ -231,7 +231,11 @@ describe('loop-event fold parity', () => { ]); }); - it('seals a step whose thinking block has real content', () => { + it('drops a step whose thinking block has real content but no provider signature', () => { + // An interrupted turn leaves unsigned thinking behind; sealed into + // history it serializes to neither content nor tool calls on + // OpenAI-compatible providers, and strict gateways reject every later + // request with a 400 (#1404). context.appendLoopEvent({ type: 'step.begin', uuid: 's1' }); context.appendLoopEvent({ type: 'content.part', @@ -240,7 +244,7 @@ describe('loop-event fold parity', () => { }); context.appendLoopEvent({ type: 'step.end', uuid: 's1' }); - expect(context.get().at(-1)?.content).toEqual([{ type: 'think', think: 'real reasoning' }]); + expect(context.get()).toEqual([]); }); it('seals a step whose empty thinking block carries a provider signature', () => { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 6ed1e1c4f6..3750fcc98d 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1196,6 +1196,46 @@ describe('interruption reminder', () => { expect(interruptionReminders()).toHaveLength(1); }); + it('drops the interrupted thinking-only message before the next request', async () => { + ctx.mockNextResponse({ type: 'think', think: 'pondering' }, { type: 'text', text: 'answer' }); + const subscription = ctx.get(IEventBus).subscribe('thinking.delta', () => { + loop.cancel(); + }); + const turn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn; + await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); + subscription.dispose(); + // The interrupted thinking fragment stays in history right after the + // cancel, still marked partial. + expect(ctx.contextData().history).toContainEqual({ + role: 'assistant', + content: [{ type: 'think', think: 'pondering' }], + toolCalls: [], + partial: true, + }); + ctx.llmInputs(); + + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); + await ctx.untilTurnEnd(); + + // The next step settles it away: sealed into history it would serialize + // to neither content nor tool calls, which strict OpenAI-compatible + // providers reject with a 400 (#1404). + expect( + ctx + .contextData() + .history.some((message) => + message.content.some((part) => part.type === 'think' && part.think === 'pondering'), + ), + ).toBe(false); + expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` + messages: + + user: text "\\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user's next message continues the conversation.\\n" + user: text "Next" + `); + }); + it('records no partial content when the stream only produced whitespace', async () => { ctx.mockNextResponse({ type: 'text', text: ' ' }, { type: 'text', text: 'answer' }); const subscription = cancelOnFirstDelta(); From 170249bb52e376c95b10552738f71280d90e8cf5 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 12 Aug 2026 20:30:45 +0800 Subject: [PATCH 2/2] fix(agent-core-v2): preserve reasoning-only assistant history --- .../src/agent/contextMemory/loopEventFold.ts | 24 ++------ .../provider/bases/openai/openai-legacy.ts | 12 ++++ .../agent/contextMemory/loopEventFold.test.ts | 8 +-- .../test/agent/loop/loop.test.ts | 40 ------------- .../test/kosong/provider/composition.test.ts | 60 ++++++++++++++++++- 5 files changed, 76 insertions(+), 68 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 09fdad0f55..325b94ba90 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -21,11 +21,9 @@ * - `step.end` → settle the assistant * "Settle" closes any tool exchange left open (interrupted result messages), * then drops the partial assistant when nothing sendable was recorded (no - * tool calls; every content part vacuous or unsigned thinking — an - * output-free assistant only trips provider message validation, and an - * unsigned-thinking-only one serializes to no content and no tool calls, - * which strict providers reject with a 400) and seals it - * (`partial: undefined`) when it carries output. v1 never produced + * tool calls; every content part vacuous — an output-free assistant only + * trips provider message validation) and seals it (`partial: undefined`) + * when it carries output. v1 never produced * `step.begin` without `step.end` (its retries stayed inside one request), so * the drop/seal rule is the v2 extension that makes loop-level retries — a * retried attempt is its own `step.begin` — replay to the same history the @@ -206,20 +204,6 @@ function appendToOpenAssistant( return next; } -/** - * Settle-time counterpart of `isVacuousContentPart`. An interrupted turn can - * leave the open assistant holding only partial thinking whose provider - * signature never arrived; sealed into history, that message serializes to - * neither `content` nor `tool_calls` on OpenAI-compatible providers (the - * thinking becomes `reasoning_content`), and strict gateways reject every - * later request with a 400 (#1404). Anthropic drops unsigned thinking blocks - * on its wire too, so only signed thinking (`encrypted`) counts as sendable - * output here. - */ -function isUnsendableContentPart(part: ContentPart): boolean { - return isVacuousContentPart(part) || (part.type === 'think' && part.encrypted === undefined); -} - function settleOpenStep( state: readonly ContextMessage[], ctx: FoldCtx, @@ -228,7 +212,7 @@ function settleOpenStep( const index = findOpenAssistantIndex(closed); if (index === -1) return closed; const open = closed[index]!; - if (open.toolCalls.length === 0 && open.content.every(isUnsendableContentPart)) { + if (open.toolCalls.length === 0 && open.content.every(isVacuousContentPart)) { return [...closed.slice(0, index), ...closed.slice(index + 1)]; } const next = closed.slice(); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 5d4bd99e33..1e1f5e714e 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -22,6 +22,9 @@ * tool-result `extract_text` fallback and tool-declaration-only skip are * handed over to the trait wholesale: every history message is * base-converted, post-processed by the hook, and dropped on `null`. + * - A reasoning-only assistant is projected with explicit empty `content`. + * The reasoning field remains intact while strict Chat Completions + * gateways still see the required `content` or `tool_calls` shape. */ import OpenAI from 'openai'; @@ -268,6 +271,15 @@ function convertMessage( result.tool_call_id = message.toolCallId; } + if ( + message.role === 'assistant' && + hasReasoningPart && + result.content === undefined && + result.tool_calls === undefined + ) { + result.content = ''; + } + if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { result[reasoningKey] = reasoningContent; } diff --git a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts index 37f5b3edcb..25f9f73b39 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/loopEventFold.test.ts @@ -231,11 +231,7 @@ describe('loop-event fold parity', () => { ]); }); - it('drops a step whose thinking block has real content but no provider signature', () => { - // An interrupted turn leaves unsigned thinking behind; sealed into - // history it serializes to neither content nor tool calls on - // OpenAI-compatible providers, and strict gateways reject every later - // request with a 400 (#1404). + it('seals a step whose thinking block has real content', () => { context.appendLoopEvent({ type: 'step.begin', uuid: 's1' }); context.appendLoopEvent({ type: 'content.part', @@ -244,7 +240,7 @@ describe('loop-event fold parity', () => { }); context.appendLoopEvent({ type: 'step.end', uuid: 's1' }); - expect(context.get()).toEqual([]); + expect(context.get().at(-1)?.content).toEqual([{ type: 'think', think: 'real reasoning' }]); }); it('seals a step whose empty thinking block carries a provider signature', () => { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 3750fcc98d..6ed1e1c4f6 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1196,46 +1196,6 @@ describe('interruption reminder', () => { expect(interruptionReminders()).toHaveLength(1); }); - it('drops the interrupted thinking-only message before the next request', async () => { - ctx.mockNextResponse({ type: 'think', think: 'pondering' }, { type: 'text', text: 'answer' }); - const subscription = ctx.get(IEventBus).subscribe('thinking.delta', () => { - loop.cancel(); - }); - const turn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn; - await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); - subscription.dispose(); - // The interrupted thinking fragment stays in history right after the - // cancel, still marked partial. - expect(ctx.contextData().history).toContainEqual({ - role: 'assistant', - content: [{ type: 'think', think: 'pondering' }], - toolCalls: [], - partial: true, - }); - ctx.llmInputs(); - - ctx.mockNextResponse({ type: 'text', text: 'second answer' }); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); - await ctx.untilTurnEnd(); - - // The next step settles it away: sealed into history it would serialize - // to neither content nor tool calls, which strict OpenAI-compatible - // providers reject with a 400 (#1404). - expect( - ctx - .contextData() - .history.some((message) => - message.content.some((part) => part.type === 'think' && part.think === 'pondering'), - ), - ).toBe(false); - expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` - messages: - - user: text "\\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user's next message continues the conversation.\\n" - user: text "Next" - `); - }); - it('records no partial content when the stream only produced whitespace', async () => { ctx.mockNextResponse({ type: 'text', text: ' ' }, { type: 'text', text: 'answer' }); const subscription = cancelOnFirstDelta(); diff --git a/packages/agent-core-v2/test/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 5204c96bc4..b5b92214f3 100644 --- a/packages/agent-core-v2/test/kosong/provider/composition.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/composition.test.ts @@ -26,6 +26,8 @@ * * - the behavior probes for per-turn intent encoding (cacheKey / thinking / * budget) on the Kimi, OpenAI, and Anthropic wires; + * - reasoning-only assistant history remains canonical while each wire + * projects it into a provider-valid representation; * - the per-base `responseFormat` encodings (re-added from the deleted * llmProtocol structured-output suite; morph-seeded kwargs cases that no * longer have a channel are noted where they dropped); @@ -583,6 +585,7 @@ async function captureOpenAIBody( async function captureAnthropicBody( provider: ChatProvider, options?: GenerateOptions, + history: Message[] = PROBE_HISTORY, ): Promise<{ readonly params: Record; readonly requestOptions: Record | undefined; @@ -606,7 +609,7 @@ async function captureAnthropicBody( }); client.messages.create = create('standard'); client.beta.messages.create = create('beta'); - await drain(await provider.generate('', [], PROBE_HISTORY, options)); + await drain(await provider.generate('', [], history, options)); if (capturedParams === undefined || via === undefined) { throw new Error('expected messages.create to be called'); } @@ -616,6 +619,7 @@ async function captureAnthropicBody( async function captureGoogleBody( provider: ChatProvider, options?: GenerateOptions, + history: Message[] = PROBE_HISTORY, ): Promise> { let captured: Record | undefined; const client = sdkClient(provider) as { models: { generateContent: unknown } }; @@ -629,7 +633,7 @@ async function captureGoogleBody( modelVersion: 'probe', }); }); - await drain(await provider.generate('', [], PROBE_HISTORY, options)); + await drain(await provider.generate('', [], history, options)); if (captured === undefined) throw new Error('expected models.generateContent to be called'); return captured; } @@ -716,6 +720,58 @@ describe('per-turn intent wire encoding (behavior probes)', () => { }); }); +describe('reasoning-only assistant history projection', () => { + it('adds empty content on the OpenAI Chat Completions wire without dropping reasoning', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-v4-flash', + apiKey: 'sk-probe', + stream: false, + }); + + const body = await captureOpenAIBody(provider, undefined, THINK_HISTORY); + const messages = body['messages'] as Array>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: '', + reasoning_content: 'earlier reasoning', + }); + }); + + it('keeps unsigned thinking on the Kimi Anthropic wire', async () => { + const provider = registry.createChatProvider({ + protocol: 'anthropic', + providerType: 'kimi', + modelName: 'kimi-for-coding', + apiKey: 'sk-probe', + }); + + const { params } = await captureAnthropicBody(provider, undefined, THINK_HISTORY); + const messages = params['messages'] as Array>; + + expect(messages[0]).toEqual({ + role: 'assistant', + content: [{ type: 'thinking', thinking: 'earlier reasoning' }], + }); + }); + + it('keeps unsigned thinking on the Google GenAI wire', async () => { + const provider = new GoogleGenAIChatProvider({ + model: 'gemini-2.5-flash', + apiKey: 'sk-probe', + stream: false, + }); + + const body = await captureGoogleBody(provider, undefined, THINK_HISTORY); + const contents = body['contents'] as Array>; + + expect(contents[0]).toEqual({ + role: 'model', + parts: [{ text: 'earlier reasoning', thought: true }], + }); + }); +}); + describe('quota-exhausted classification through the real composition (behavior probes)', () => { const MOONSHOT_QUOTA_BODY = { type: 'error',