diff --git a/.changeset/fix-thinking-only-assistant-400.md b/.changeset/fix-thinking-only-assistant-400.md new file mode 100644 index 000000000..0ea2a008f --- /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/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 5161f8a94..99dd83f42 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. * * The SDK client is built with `maxRetries: 0`: the SDK's internal backoff * sleep never observes the turn's AbortSignal, so rate-limit / server / @@ -273,6 +276,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/kosong/provider/composition.test.ts b/packages/agent-core-v2/test/kosong/provider/composition.test.ts index 164c0dea9..cf68c5d72 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); @@ -603,6 +605,7 @@ async function captureOpenAIBody( async function captureAnthropicBody( provider: ChatProvider, options?: GenerateOptions, + history: Message[] = PROBE_HISTORY, ): Promise<{ readonly params: Record; readonly requestOptions: Record | undefined; @@ -626,7 +629,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'); } @@ -636,6 +639,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 } }; @@ -649,7 +653,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; } @@ -736,6 +740,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',