diff --git a/.changeset/preserve-empty-reasoning.md b/.changeset/preserve-empty-reasoning.md new file mode 100644 index 0000000000..1659b30a8b --- /dev/null +++ b/.changeset/preserve-empty-reasoning.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve empty model reasoning blocks across providers so multi-step tool calls can continue. diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index ac6ac18c6a..a7183bdb6b 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -508,7 +508,7 @@ function convertMessage(message: Message, model: string): MessageParam { thinking: part.think, signature: part.encrypted, } satisfies ThinkingBlockParam); - } else if (part.think !== '' && shouldPreserveUnsignedThinking(model)) { + } else if (shouldPreserveUnsignedThinking(model)) { blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); } } else if (part.type === 'video_url') { diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts index a7a88c5455..893bf9d93c 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/google-genai.ts @@ -4,7 +4,7 @@ import { ChatProviderError, normalizeAPIStatusError, } from '../errors'; -import type { Message, StreamedMessagePart, ToolCall } from '../message'; +import type { Message, StreamedMessagePart, ThinkPart, ToolCall } from '../message'; import { isToolDeclarationOnlyMessage } from '../message'; import type { ChatProvider, @@ -127,6 +127,7 @@ interface GoogleContent { interface GooglePart { text?: string; + thought?: boolean; functionCall?: { name: string; args: Record }; functionResponse?: { name: string; @@ -218,8 +219,14 @@ function messageToGoogleGenAI(message: Message): GoogleContent { case 'text': parts.push({ text: part.text }); break; - case 'think': + case 'think': { + const thoughtPart: GooglePart = { text: part.think, thought: true }; + if (part.encrypted !== undefined && part.encrypted.length > 0) { + thoughtPart.thoughtSignature = part.encrypted; + } + parts.push(thoughtPart); break; + } case 'image_url': parts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); break; @@ -489,8 +496,13 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { for (const part of contentParts) { const p = part as Record; - if (p['thought'] === true && p['text']) { - parts.push({ type: 'think', think: p['text'] as string }); + if (p['thought'] === true && typeof p['text'] === 'string') { + const thoughtSignature = p['thoughtSignature'] ?? p['thought_signature']; + const thinkPart: ThinkPart = { type: 'think', think: p['text'] }; + if (typeof thoughtSignature === 'string' && thoughtSignature.length > 0) { + thinkPart.encrypted = thoughtSignature; + } + parts.push(thinkPart); } else if (p['text']) { parts.push({ type: 'text', text: p['text'] as string }); } else if (p['functionCall'] || p['function_call']) { @@ -500,15 +512,16 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { const id_ = (fc['id'] as string) ?? crypto.randomUUID(); const toolCallId = `${name}_${id_}`; const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature']; - parts.push({ + const toolCall: ToolCall = { type: 'function', id: toolCallId, name, arguments: fc['args'] ? JSON.stringify(fc['args']) : '{}', - ...(thoughtSigB64 - ? { extras: { thought_signature_b64: thoughtSigB64 as string } } - : {}), - } satisfies ToolCall); + }; + if (typeof thoughtSigB64 === 'string' && thoughtSigB64.length > 0) { + toolCall.extras = { thought_signature_b64: thoughtSigB64 }; + } + parts.push(toolCall); } } } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index ad6097b702..917490e951 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -106,10 +106,12 @@ function isEffectivelyEmptyContent(parts: ContentPart[]): boolean { function convertMessage(message: Message): OpenAIMessage { let reasoningContent = ''; + let hasReasoningPart = false; const nonThinkParts: ContentPart[] = []; for (const part of message.content) { if (part.type === 'think') { + hasReasoningPart = true; reasoningContent += part.think; } else { nonThinkParts.push(part); @@ -154,7 +156,7 @@ function convertMessage(message: Message): OpenAIMessage { result.tool_call_id = message.toolCallId; } - if (reasoningContent) { + if (hasReasoningPart) { result.reasoning_content = reasoningContent; } @@ -280,7 +282,7 @@ class KimiStreamedMessage implements StreamedMessage { if (!message) return; const rc = (message as unknown as Record)['reasoning_content']; - if (typeof rc === 'string' && rc) { + if (typeof rc === 'string') { yield { type: 'think', think: rc } satisfies StreamedMessagePart; } @@ -332,7 +334,7 @@ class KimiStreamedMessage implements StreamedMessage { const delta = choice.delta; const rc = (delta as unknown as Record)['reasoning_content']; - if (typeof rc === 'string' && rc) { + if (typeof rc === 'string') { yield { type: 'think', think: rc } satisfies StreamedMessagePart; } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts index f11dae0f14..3bd455b870 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-legacy.ts @@ -62,7 +62,7 @@ function extractReasoningContent( const keys: readonly string[] = explicitKey !== undefined ? [explicitKey] : KNOWN_REASONING_KEYS; for (const key of keys) { const value = record[key]; - if (typeof value === 'string' && value.length > 0) return value; + if (typeof value === 'string') return value; } return undefined; } @@ -155,10 +155,12 @@ function convertMessage( toolMessageConversion: ToolMessageConversion, ): OpenAIMessage { let reasoningContent = ''; + let hasReasoningPart = false; const nonThinkParts: ContentPart[] = []; for (const part of message.content) { if (part.type === 'think') { + hasReasoningPart = true; reasoningContent += part.think; } else { nonThinkParts.push(part); @@ -212,7 +214,7 @@ function convertMessage( result.tool_call_id = message.toolCallId; } - if (reasoningContent) { + if (hasReasoningPart) { result[reasoningKey ?? DEFAULT_OUTBOUND_REASONING_KEY] = reasoningContent; } @@ -354,7 +356,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { if (!message) return; const reasoning = extractReasoningContent(message, reasoningKey); - if (reasoning) { + if (reasoning !== undefined) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } @@ -405,7 +407,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { const delta = choice.delta; const reasoning = extractReasoningContent(delta, reasoningKey); - if (reasoning) { + if (reasoning !== undefined) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts index 4e96e9bd48..1a0040f01e 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/openai-responses.ts @@ -534,14 +534,14 @@ function convertMessage( if (part.type === 'think') { flushPendingParts(); const encryptedValue = part.encrypted; - const summaries: unknown[] = [{ type: 'summary_text', text: part.think || '' }]; + const summaries: unknown[] = [{ type: 'summary_text', text: part.think }]; i += 1; while (i < n) { const nextPart = message.content[i]; if (nextPart === undefined) break; if (nextPart.type !== 'think') break; if (nextPart.encrypted !== encryptedValue) break; - summaries.push({ type: 'summary_text', text: nextPart.think || '' }); + summaries.push({ type: 'summary_text', text: nextPart.think }); i += 1; } result.push({ @@ -707,9 +707,11 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { arguments: outputItem.arguments ?? null, } satisfies ToolCall; } else if (outputItem.type === 'reasoning') { + let hasReasoningSummary = false; for (const summary of outputItem.summary) { const text = readStringField(summary, 'text'); if (text === undefined) continue; + hasReasoningSummary = true; const thinkPart: StreamedMessagePart = { type: 'think', think: text, @@ -719,6 +721,13 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { } yield thinkPart; } + if (!hasReasoningSummary) { + const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; + if (outputItem.encryptedContent !== undefined) { + (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; + } + yield thinkPart; + } } } } diff --git a/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts b/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts new file mode 100644 index 0000000000..04081008b2 --- /dev/null +++ b/packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts @@ -0,0 +1,253 @@ +/** + * Scenario: providers receive or replay a thinking block whose text is explicitly empty. + * Responsibilities: preserve field/block presence and provider-specific opaque reasoning data. + * Wiring: real provider codecs with only their remote SDK clients replaced through clientFactory. + * Run: pnpm exec vitest run packages/agent-core-v2/test/app/llmProtocol/providers/empty-thinking-roundtrip.test.ts + */ +import type { Message, StreamedMessagePart } from '#/app/llmProtocol/message'; +import { AnthropicChatProvider } from '#/app/llmProtocol/providers/anthropic'; +import { + GoogleGenAIChatProvider, + GoogleGenAIStreamedMessage, +} from '#/app/llmProtocol/providers/google-genai'; +import { KimiChatProvider } from '#/app/llmProtocol/providers/kimi'; +import { OpenAILegacyChatProvider } from '#/app/llmProtocol/providers/openai-legacy'; +import { + OpenAIResponsesChatProvider, + OpenAIResponsesStreamedMessage, +} from '#/app/llmProtocol/providers/openai-responses'; +import { describe, expect, it, vi } from 'vitest'; + +const EMPTY_THINKING_TOOL_HISTORY: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '' }], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, +]; + +function chatCompletionResponse(message: Record) { + return { + id: 'chatcmpl-test', + object: 'chat.completion', + created: 0, + model: 'test-model', + choices: [{ index: 0, message, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; +} + +async function collectParts( + streamedMessage: AsyncIterable, +): Promise { + const parts: StreamedMessagePart[] = []; + for await (const part of streamedMessage) parts.push(part); + return parts; +} + +describe('empty thinking round-trip', () => { + it('Kimi sends an explicitly empty ThinkPart back as reasoning_content', async () => { + let captured: Record | undefined; + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve(chatCompletionResponse({ role: 'assistant', content: 'done' })); + }); + const provider = new KimiChatProvider({ + model: 'kimi-k2', + apiKey: '', + stream: false, + clientFactory: () => ({ chat: { completions: { create } } }) as never, + }); + + const response = await provider.generate('', [], EMPTY_THINKING_TOOL_HISTORY); + await collectParts(response); + + const messages = captured?.['messages'] as Array>; + expect(messages[0]).toHaveProperty('reasoning_content', ''); + }); + + it('Kimi keeps an explicitly empty response reasoning_content as a ThinkPart', async () => { + const create = vi.fn().mockResolvedValue( + chatCompletionResponse({ + role: 'assistant', + content: null, + reasoning_content: '', + }), + ); + const provider = new KimiChatProvider({ + model: 'kimi-k2', + apiKey: '', + stream: false, + clientFactory: () => ({ chat: { completions: { create } } }) as never, + }); + + const response = await provider.generate('', [], []); + + expect(await collectParts(response)).toEqual([{ type: 'think', think: '' }]); + }); + + it('OpenAI Chat Completions sends an empty ThinkPart through the configured reasoning field', async () => { + let captured: Record | undefined; + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve(chatCompletionResponse({ role: 'assistant', content: 'done' })); + }); + const provider = new OpenAILegacyChatProvider({ + model: 'compatible-reasoner', + apiKey: '', + stream: false, + reasoningKey: 'reasoning_details', + clientFactory: () => ({ chat: { completions: { create } } }) as never, + }); + + const response = await provider.generate('', [], EMPTY_THINKING_TOOL_HISTORY); + await collectParts(response); + + const messages = captured?.['messages'] as Array>; + expect(messages[0]).toHaveProperty('reasoning_details', ''); + }); + + it('OpenAI Chat Completions keeps an explicitly empty response reasoning field', async () => { + const create = vi.fn().mockResolvedValue( + chatCompletionResponse({ + role: 'assistant', + content: null, + reasoning_content: '', + }), + ); + const provider = new OpenAILegacyChatProvider({ + model: 'compatible-reasoner', + apiKey: '', + stream: false, + clientFactory: () => ({ chat: { completions: { create } } }) as never, + }); + + const response = await provider.generate('', [], []); + + expect(await collectParts(response)).toEqual([{ type: 'think', think: '' }]); + }); + + it('Google GenAI sends an explicitly empty ThinkPart back as a thought part', async () => { + let captured: Record | undefined; + const generateContent = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve({ + candidates: [{ content: { role: 'model', parts: [{ text: 'done' }] } }], + }); + }); + const provider = new GoogleGenAIChatProvider({ + model: 'gemini-3-flash', + apiKey: '', + stream: false, + clientFactory: () => ({ models: { generateContent } }) as never, + }); + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '', encrypted: 'thought-signature' }], + toolCalls: [], + }, + ]; + + const response = await provider.generate('', [], history); + await collectParts(response); + + const contents = captured?.['contents'] as Array<{ parts: unknown[] }>; + expect(contents[0]!.parts[0]).toEqual({ + text: '', + thought: true, + thoughtSignature: 'thought-signature', + }); + }); + + it('Google GenAI keeps an explicitly empty response thought part', async () => { + const response = new GoogleGenAIStreamedMessage( + { + candidates: [ + { + content: { + role: 'model', + parts: [{ text: '', thought: true, thoughtSignature: 'thought-signature' }], + }, + }, + ], + }, + false, + ); + + expect(await collectParts(response)).toEqual([ + { type: 'think', think: '', encrypted: 'thought-signature' }, + ]); + }); + + it('Anthropic-compatible providers send unsigned empty thinking blocks back', async () => { + let captured: Record | undefined; + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve({ + id: 'msg_test', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }); + const provider = new AnthropicChatProvider({ + model: 'compatible-model', + apiKey: '', + defaultMaxTokens: 1024, + stream: false, + clientFactory: () => ({ messages: { create } }) as never, + }); + + const response = await provider.generate('', [], EMPTY_THINKING_TOOL_HISTORY); + await collectParts(response); + + const messages = captured?.['messages'] as Array<{ content: unknown[] }>; + expect(messages[0]!.content[0]).toEqual({ type: 'thinking', thinking: '' }); + }); + + it('OpenAI Responses sends an explicitly empty ThinkPart as a reasoning item', async () => { + let captured: Record | undefined; + async function* responseStream() { + yield { type: 'response.output_text.delta', delta: 'done' }; + yield { + type: 'response.completed', + response: { id: 'resp_test', usage: { input_tokens: 1, output_tokens: 1 } }, + }; + } + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve(responseStream()); + }); + const provider = new OpenAIResponsesChatProvider({ + model: 'gpt-5', + apiKey: '', + clientFactory: () => ({ responses: { create } }) as never, + }); + + const response = await provider.generate('', [], EMPTY_THINKING_TOOL_HISTORY); + await collectParts(response); + + const input = captured?.['input'] as Array>; + expect(input.find((item) => item['type'] === 'reasoning')).toMatchObject({ + type: 'reasoning', + summary: [{ type: 'summary_text', text: '' }], + }); + }); + + it('OpenAI Responses keeps a non-stream reasoning item with no summaries', async () => { + const response = new OpenAIResponsesStreamedMessage( + { + id: 'resp_test', + status: 'completed', + output: [{ type: 'reasoning', encrypted_content: 'enc_empty', summary: [] }], + }, + false, + ); + + expect(await collectParts(response)).toEqual([ + { type: 'think', think: '', encrypted: 'enc_empty' }, + ]); + }); +}); diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 54733d060e..01449d339e 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -633,15 +633,14 @@ function convertMessage(message: Message, model: string): MessageParam { // ("thinking is enabled but reasoning_content is missing"). Dropping it // here is what broke multi-step tool use on those backends. Claude // models reject unsigned thinking blocks, so those are only preserved - // for non-Claude Anthropic-compatible models. An unsigned part with no - // text carries nothing, so it is skipped. + // for non-Claude Anthropic-compatible models. if (part.encrypted !== undefined) { blocks.push({ type: 'thinking', thinking: part.think, signature: part.encrypted, } satisfies ThinkingBlockParam); - } else if (part.think !== '' && shouldPreserveUnsignedThinking(model)) { + } else if (shouldPreserveUnsignedThinking(model)) { blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); } } else if (part.type === 'video_url') { diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index e39d27d50f..ad69fc0c59 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -4,7 +4,7 @@ import { ChatProviderError, normalizeAPIStatusError, } from '#/errors'; -import type { Message, StreamedMessagePart, ToolCall } from '#/message'; +import type { Message, StreamedMessagePart, ThinkPart, ToolCall } from '#/message'; import { isToolDeclarationOnlyMessage } from '#/message'; import type { ChatProvider, @@ -148,6 +148,7 @@ interface GoogleContent { interface GooglePart { text?: string; + thought?: boolean; functionCall?: { name: string; args: Record }; functionResponse?: { name: string; @@ -256,9 +257,14 @@ function messageToGoogleGenAI(message: Message): GoogleContent { case 'text': parts.push({ text: part.text }); break; - case 'think': - // Skip think parts (synthetic) + case 'think': { + const thoughtPart: GooglePart = { text: part.think, thought: true }; + if (part.encrypted !== undefined && part.encrypted.length > 0) { + thoughtPart.thoughtSignature = part.encrypted; + } + parts.push(thoughtPart); break; + } case 'image_url': parts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); break; @@ -569,8 +575,13 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { for (const part of contentParts) { const p = part as Record; - if (p['thought'] === true && p['text']) { - parts.push({ type: 'think', think: p['text'] as string }); + if (p['thought'] === true && typeof p['text'] === 'string') { + const thoughtSignature = p['thoughtSignature'] ?? p['thought_signature']; + const thinkPart: ThinkPart = { type: 'think', think: p['text'] }; + if (typeof thoughtSignature === 'string' && thoughtSignature.length > 0) { + thinkPart.encrypted = thoughtSignature; + } + parts.push(thinkPart); } else if (p['text']) { parts.push({ type: 'text', text: p['text'] as string }); } else if (p['functionCall'] || p['function_call']) { @@ -580,15 +591,16 @@ export class GoogleGenAIStreamedMessage implements StreamedMessage { const id_ = (fc['id'] as string) ?? crypto.randomUUID(); const toolCallId = `${name}_${id_}`; const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature']; - parts.push({ + const toolCall: ToolCall = { type: 'function', id: toolCallId, name, arguments: fc['args'] ? JSON.stringify(fc['args']) : '{}', - ...(thoughtSigB64 - ? { extras: { thought_signature_b64: thoughtSigB64 as string } } - : {}), - } satisfies ToolCall); + }; + if (typeof thoughtSigB64 === 'string' && thoughtSigB64.length > 0) { + toolCall.extras = { thought_signature_b64: thoughtSigB64 }; + } + parts.push(toolCall); } } } diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index 44f63268c6..d3d295f067 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -118,10 +118,12 @@ function isEffectivelyEmptyContent(parts: ContentPart[]): boolean { function convertMessage(message: Message): OpenAIMessage { let reasoningContent = ''; + let hasReasoningPart = false; const nonThinkParts: ContentPart[] = []; for (const part of message.content) { if (part.type === 'think') { + hasReasoningPart = true; reasoningContent += part.think; } else { nonThinkParts.push(part); @@ -168,7 +170,7 @@ function convertMessage(message: Message): OpenAIMessage { result.tool_call_id = message.toolCallId; } - if (reasoningContent) { + if (hasReasoningPart) { result.reasoning_content = reasoningContent; } @@ -307,7 +309,7 @@ class KimiStreamedMessage implements StreamedMessage { // reasoning_content (Moonshot proprietary) const rc = (message as unknown as Record)['reasoning_content']; - if (typeof rc === 'string' && rc) { + if (typeof rc === 'string') { yield { type: 'think', think: rc } satisfies StreamedMessagePart; } @@ -365,7 +367,7 @@ class KimiStreamedMessage implements StreamedMessage { // reasoning_content (Moonshot proprietary) const rc = (delta as unknown as Record)['reasoning_content']; - if (typeof rc === 'string' && rc) { + if (typeof rc === 'string') { yield { type: 'think', think: rc } satisfies StreamedMessagePart; } diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index 35d759051c..eb27617349 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -85,7 +85,7 @@ function extractReasoningContent( const keys: readonly string[] = explicitKey !== undefined ? [explicitKey] : KNOWN_REASONING_KEYS; for (const key of keys) { const value = record[key]; - if (typeof value === 'string' && value.length > 0) return value; + if (typeof value === 'string') return value; } return undefined; } @@ -163,10 +163,12 @@ function convertMessage( toolMessageConversion: ToolMessageConversion, ): OpenAIMessage { let reasoningContent = ''; + let hasReasoningPart = false; const nonThinkParts: ContentPart[] = []; for (const part of message.content) { if (part.type === 'think') { + hasReasoningPart = true; reasoningContent += part.think; } else { nonThinkParts.push(part); @@ -236,7 +238,7 @@ function convertMessage( // One API gateways) work without per-provider configuration. Servers that // don't understand the field ignore it; servers that require a specific // field can override via the explicit `reasoningKey`. - if (reasoningContent) { + if (hasReasoningPart) { result[reasoningKey ?? DEFAULT_OUTBOUND_REASONING_KEY] = reasoningContent; } @@ -386,7 +388,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // Reasoning content: honor the explicit key when set, otherwise scan the // de facto field set so hand-written configs work without it. const reasoning = extractReasoningContent(message, reasoningKey); - if (reasoning) { + if (reasoning !== undefined) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } @@ -441,7 +443,7 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // Reasoning content: honor the explicit key when set, otherwise scan // the de facto field set so hand-written configs work without it. const reasoning = extractReasoningContent(delta, reasoningKey); - if (reasoning) { + if (reasoning !== undefined) { yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; } diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 78bda29316..08c4213c1a 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -562,14 +562,14 @@ function convertMessage( flushPendingParts(); // Aggregate consecutive ThinkParts with the same `encrypted` value const encryptedValue = part.encrypted; - const summaries: unknown[] = [{ type: 'summary_text', text: part.think || '' }]; + const summaries: unknown[] = [{ type: 'summary_text', text: part.think }]; i += 1; while (i < n) { const nextPart = message.content[i]; if (nextPart === undefined) break; if (nextPart.type !== 'think') break; if (nextPart.encrypted !== encryptedValue) break; - summaries.push({ type: 'summary_text', text: nextPart.think || '' }); + summaries.push({ type: 'summary_text', text: nextPart.think }); i += 1; } result.push({ @@ -746,9 +746,11 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { arguments: outputItem.arguments ?? null, } satisfies ToolCall; } else if (outputItem.type === 'reasoning') { + let hasReasoningSummary = false; for (const summary of outputItem.summary) { const text = readStringField(summary, 'text'); if (text === undefined) continue; + hasReasoningSummary = true; const thinkPart: StreamedMessagePart = { type: 'think', think: text, @@ -758,6 +760,13 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage { } yield thinkPart; } + if (!hasReasoningSummary) { + const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; + if (outputItem.encryptedContent !== undefined) { + (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; + } + yield thinkPart; + } } } } diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index ca90054399..7dd1c61202 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -1323,6 +1323,24 @@ describe('AnthropicChatProvider', () => { }); }); + it('preserves unsigned empty thinking for Anthropic-compatible models', async () => { + const provider = createProvider(); + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '' }], + toolCalls: [ + { type: 'function', id: 'toolu_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + const messages = body['messages'] as Array<{ role: string; content: unknown[] }>; + + expect(messages[0]!.content[0]).toEqual({ type: 'thinking', thinking: '' }); + }); + it.each(['claude-opus-4-6', 'opus-4-6'])( 'drops unsigned thinking for Claude model %s before tool_use blocks', async (model) => { diff --git a/packages/kosong/test/generate.test.ts b/packages/kosong/test/generate.test.ts index 7b3266c211..152a89f6aa 100644 --- a/packages/kosong/test/generate.test.ts +++ b/packages/kosong/test/generate.test.ts @@ -269,6 +269,23 @@ describe('generate()', () => { expect(result.message.toolCalls.length).toBeGreaterThan(0); }); + it('preserves an explicitly empty ThinkPart alongside a tool call', async () => { + const stream = createMockStream([ + { type: 'think', think: '' }, + { + type: 'function', + id: 'tool#1', + name: 'read_file', + arguments: '{"path":"/tmp"}', + }, + ]); + const provider = createMockProvider(stream); + + const result = await generate(provider, '', [], []); + + expect(result.message.content).toEqual([{ type: 'think', think: '' }]); + }); + it('preserves stream id and usage', async () => { const usage: TokenUsage = { inputOther: 100, diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index 7f1fa81de3..b8a2468dd9 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -133,6 +133,26 @@ describe('GoogleGenAIChatProvider', () => { expect(config['systemInstruction']).toBe('You are helpful.'); }); + it('serializes an explicitly empty ThinkPart as a Google thought part', async () => { + const provider = createProvider({ stream: false }); + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '', encrypted: 'thought-signature' }], + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + + expect(body['contents']).toEqual([ + { + role: 'model', + parts: [{ text: '', thought: true, thoughtSignature: 'thought-signature' }], + }, + ]); + }); + it('maps json_schema response format to response config', async () => { const provider = createProvider(); const history: Message[] = [ @@ -1040,6 +1060,38 @@ describe('GoogleGenAIChatProvider', () => { inputCacheCreation: 0, }); }); + + it('yields an empty ThinkPart from an explicitly empty thought part', async () => { + const provider = createProvider({ stream: false }); + ((provider as any)._client.models as Record)['generateContent'] = vi + .fn() + .mockResolvedValue({ + candidates: [ + { + content: { + role: 'model', + parts: [ + { text: '', thought: true, thoughtSignature: 'thought-signature' }, + { functionCall: { name: 'lookup', args: {} } }, + ], + }, + }, + ], + }); + + const stream = await provider.generate('', [], []); + const parts = await collectParts(stream); + + expect(parts).toEqual([ + { type: 'think', think: '', encrypted: 'thought-signature' }, + { + type: 'function', + id: expect.stringMatching(/^lookup_/), + name: 'lookup', + arguments: '{}', + }, + ]); + }); }); describe('streaming', () => { @@ -1133,6 +1185,18 @@ describe('GoogleGenAIChatProvider', () => { ]); }); + it('yields an empty ThinkPart from an explicitly empty thought part', async () => { + async function* mockStream() { + yield { + candidates: [{ content: { parts: [{ text: '', thought: true }] } }], + }; + } + + const msg = new GoogleGenAIStreamedMessage(mockStream(), true); + + expect(await collectParts(msg)).toEqual([{ type: 'think', think: '' }]); + }); + it('yields function call from stream', async () => { async function* mockStream() { yield { diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index ff9577754e..e5e18290d5 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -577,6 +577,35 @@ describe('KimiChatProvider', () => { { role: 'user', content: 'Thanks!' }, ]); }); + + it('preserves an explicitly empty reasoning field on a tool-call message', async () => { + const provider = createProvider(); + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '' }], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + + expect(body['messages']).toEqual([ + { + role: 'assistant', + reasoning_content: '', + tool_calls: [ + { + type: 'function', + id: 'call_1', + function: { name: 'lookup', arguments: '{"q":"test"}' }, + }, + ], + }, + ]); + }); }); describe('generation kwargs', () => { @@ -1017,6 +1046,43 @@ describe('KimiChatProvider', () => { { type: 'text', text: 'The answer is 4.' }, ]); }); + + it('yields an empty ThinkPart when reasoning_content is explicitly empty', async () => { + const provider = createProvider(); + (provider as any)._client.chat.completions.create = vi.fn().mockResolvedValue({ + id: 'chatcmpl-empty-reasoning', + choices: [ + { + message: { + role: 'assistant', + content: null, + reasoning_content: '', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'lookup', arguments: '{"q":"test"}' }, + }, + ], + }, + }, + ], + }); + + const stream = await provider.generate('', [], []); + const parts = []; + for await (const part of stream) parts.push(part); + + expect(parts).toEqual([ + { type: 'think', think: '' }, + { + type: 'function', + id: 'call_1', + name: 'lookup', + arguments: '{"q":"test"}', + }, + ]); + }); }); describe('streaming tool call routing', () => { @@ -1057,6 +1123,25 @@ describe('KimiChatProvider', () => { } } + it('yields an empty ThinkPart from an explicitly empty streaming delta', async () => { + const provider = createProvider(true); + const chunks = [ + { + id: 'chatcmpl-empty-reasoning', + choices: [{ index: 0, delta: { reasoning_content: '' }, finish_reason: null }], + }, + ]; + ( + provider as unknown as { _client: { chat: { completions: { create: unknown } } } } + )._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks)); + + const stream = await provider.generate('', [], []); + const parts = []; + for await (const part of stream) parts.push(part); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + it('buffers indexed argument deltas until the real tool name arrives', async () => { const provider = createProvider(true); diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 1356261b3a..edad031a4b 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1072,6 +1072,27 @@ describe('OpenAILegacyChatProvider', () => { }); }); + it('serializes an explicitly empty ThinkPart to reasoning_content', async () => { + const provider = createProvider({ model: 'deepseek-reasoner' }); + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '' }], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + const messages = body['messages'] as Record[]; + + expect(messages[0]).toMatchObject({ + role: 'assistant', + reasoning_content: '', + }); + }); + it('explicit reasoningKey overrides the default outbound field', async () => { const provider = createProvider({ model: 'oddball-reasoner', @@ -1131,6 +1152,28 @@ describe('OpenAILegacyChatProvider', () => { ]); }); + it('yields an empty ThinkPart from an explicitly empty streaming reasoning field', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-reasoner', + apiKey: 'test-key', + stream: true, + }); + + async function* mockedStream(): AsyncIterable> { + yield { id: 'c1', choices: [{ index: 0, delta: { reasoning_content: '' } }] }; + } + + (provider as any)._client.chat.completions.create = vi + .fn() + .mockResolvedValue(mockedStream()); + + const stream = await provider.generate('', [], []); + const parts: StreamedMessagePart[] = []; + for await (const part of stream) parts.push(part); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + it('treats blank reasoning_key as unset so defaults still apply', async () => { // ModelAliasSchema accepts `reasoning_key = ""` (z.string().optional()). // A blank value must not route reads/writes through an empty property @@ -1478,6 +1521,26 @@ describe('OpenAILegacyChatProvider — non-stream response parsing', () => { ]); }); + it('yields an empty ThinkPart when the non-stream reasoning field is explicitly empty', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-reasoner', + apiKey: 'test-key', + stream: false, + reasoningKey: 'reasoning_content', + }); + + const parts = await collectFromMockedResponse( + provider, + makeNonStreamResponse({ + role: 'assistant', + content: null, + reasoning_content: '', + }), + ); + + expect(parts).toEqual([{ type: 'think', think: '' }]); + }); + it('non-stream response yields ToolCall parts when tool_calls present', async () => { const provider = new OpenAILegacyChatProvider({ model: 'gpt-4.1', diff --git a/packages/kosong/test/openai-responses.test.ts b/packages/kosong/test/openai-responses.test.ts index 7b13edcb50..750ecd72f0 100644 --- a/packages/kosong/test/openai-responses.test.ts +++ b/packages/kosong/test/openai-responses.test.ts @@ -359,6 +359,25 @@ describe('OpenAIResponsesChatProvider', () => { }); }); + it('serializes an explicitly empty ThinkPart as an empty reasoning summary', async () => { + const provider = createProvider(); + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: '' }], + toolCalls: [], + }, + ]; + + const body = await captureRequestBody(provider, '', [], history); + const input = body['input'] as Array>; + + expect(input.find((item) => item['type'] === 'reasoning')).toMatchObject({ + type: 'reasoning', + summary: [{ type: 'summary_text', text: '' }], + }); + }); + it('consecutive ThinkParts with different encrypted values produce separate reasoning items', async () => { const provider = createProvider(); const history: Message[] = [ @@ -1238,6 +1257,30 @@ describe('OpenAIResponsesChatProvider', () => { ]); }); + it('yields an empty ThinkPart from a non-stream reasoning item with no summaries', async () => { + const provider = createProvider(); + (provider as any)._stream = false; + ((provider as any)._client.responses as unknown as Record)['create'] = vi + .fn() + .mockResolvedValue({ + id: 'resp_empty_reasoning', + output: [ + { + type: 'reasoning', + encrypted_content: 'enc_empty', + summary: [], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + + const stream = await provider.generate('', [], []); + const parts: StreamedMessagePart[] = []; + for await (const part of stream) parts.push(part); + + expect(parts).toEqual([{ type: 'think', think: '', encrypted: 'enc_empty' }]); + }); + it('non-stream reasoning without encrypted_content yields ThinkPart without encrypted field', async () => { const provider = createProvider(); (provider as any)._stream = false;