diff --git a/.changeset/restore-kimi-preserved-thinking.md b/.changeset/restore-kimi-preserved-thinking.md new file mode 100644 index 0000000000..8734c06d77 --- /dev/null +++ b/.changeset/restore-kimi-preserved-thinking.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Kimi sessions getting stuck when preserved-thinking history contains an empty reasoning step. 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 917490e951..ea51e0bb66 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -104,7 +104,7 @@ function isEffectivelyEmptyContent(parts: ContentPart[]): boolean { return true; } -function convertMessage(message: Message): OpenAIMessage { +function convertMessage(message: Message, preservedThinkingEnabled: boolean): OpenAIMessage { let reasoningContent = ''; let hasReasoningPart = false; const nonThinkParts: ContentPart[] = []; @@ -156,7 +156,7 @@ function convertMessage(message: Message): OpenAIMessage { result.tool_call_id = message.toolCallId; } - if (hasReasoningPart) { + if (hasReasoningPart || (preservedThinkingEnabled && message.role === 'assistant')) { result.reasoning_content = reasoningContent; } @@ -434,9 +434,12 @@ export class KimiChatProvider implements ChatProvider { if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } + const thinking = this._generationKwargs.extra_body?.thinking; + const preservedThinkingEnabled = + thinking?.keep === 'all' && thinking.type !== 'disabled'; const normalizedHistory = normalizeToolCallIdsForProvider(history, KIMI_TOOL_CALL_ID_POLICY); for (const msg of normalizedHistory) { - messages.push(convertMessage(msg)); + messages.push(convertMessage(msg, preservedThinkingEnabled)); } const kwargs: Record = { 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 index 04081008b2..184934d804 100644 --- 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 @@ -1,6 +1,6 @@ /** - * Scenario: providers receive or replay a thinking block whose text is explicitly empty. - * Responsibilities: preserve field/block presence and provider-specific opaque reasoning data. + * Scenario: providers receive or replay empty thinking, including Kimi histories missing the field. + * Responsibilities: preserve explicit field/block presence and satisfy Kimi preserved-thinking wire requirements. * 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 */ @@ -47,27 +47,161 @@ async function collectParts( return parts; } +async function captureKimiMessages( + history: Message[], + configure?: (provider: KimiChatProvider) => KimiChatProvider, +): Promise>> { + let captured: Record | undefined; + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve(chatCompletionResponse({ role: 'assistant', content: 'done' })); + }); + let provider = new KimiChatProvider({ + model: 'kimi-k2', + apiKey: '', + stream: false, + clientFactory: () => ({ chat: { completions: { create } } }) as never, + }); + if (configure !== undefined) { + provider = configure(provider); + } + + const response = await provider.generate('', [], history); + await collectParts(response); + + if (captured === undefined) { + throw new Error('Expected Kimi provider to send a request.'); + } + return captured['messages'] as Array>; +} + 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 messages = await captureKimiMessages(EMPTY_THINKING_TOOL_HISTORY); + expect(messages[0]).toHaveProperty('reasoning_content', ''); + }); - const response = await provider.generate('', [], EMPTY_THINKING_TOOL_HISTORY); - await collectParts(response); + it('Kimi backfills an assistant tool-call message when preserved thinking is active', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', ''); + }); + + it('Kimi backfills a text assistant message when keep=all omits thinking.type', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Done.' }], + toolCalls: [], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { keep: 'all' } }), + ); - const messages = captured?.['messages'] as Array>; expect(messages[0]).toHaveProperty('reasoning_content', ''); }); + it.each([ + ['empty', ''], + ['non-empty', 'reasoning text'], + ])( + 'Kimi sends an existing %s ThinkPart verbatim when preserved thinking is active', + async (_kind, think) => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think }], + toolCalls: [], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', think); + }, + ); + + it.each([ + ['missing', undefined], + ['null', null], + ['false', false], + ['off', 'off'], + ])( + 'Kimi does not backfill reasoning_content when thinking.keep is %s', + async (_kind, keep) => { + const history: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep } }), + ); + + expect(messages[0]).not.toHaveProperty('reasoning_content'); + }, + ); + + it('Kimi does not backfill reasoning_content when thinking is disabled', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'disabled', keep: 'all' } }), + ); + + expect(messages[0]).not.toHaveProperty('reasoning_content'); + }); + + it('Kimi does not backfill reasoning_content on non-assistant messages', async () => { + const history: Message[] = [ + { role: 'system', content: [{ type: 'text', text: 'System.' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'User.' }], toolCalls: [] }, + { + role: 'tool', + content: [{ type: 'text', text: 'Tool result.' }], + toolCalls: [], + toolCallId: 'call_1', + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + for (const message of messages) { + expect(message).not.toHaveProperty('reasoning_content'); + } + }); + it('Kimi keeps an explicitly empty response reasoning_content as a ThinkPart', async () => { const create = vi.fn().mockResolvedValue( chatCompletionResponse({ diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index d3d295f067..21e42e5430 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -116,7 +116,7 @@ function isEffectivelyEmptyContent(parts: ContentPart[]): boolean { return true; } -function convertMessage(message: Message): OpenAIMessage { +function convertMessage(message: Message, preservedThinkingEnabled: boolean): OpenAIMessage { let reasoningContent = ''; let hasReasoningPart = false; const nonThinkParts: ContentPart[] = []; @@ -170,7 +170,7 @@ function convertMessage(message: Message): OpenAIMessage { result.tool_call_id = message.toolCallId; } - if (hasReasoningPart) { + if (hasReasoningPart || (preservedThinkingEnabled && message.role === 'assistant')) { result.reasoning_content = reasoningContent; } @@ -483,9 +483,12 @@ export class KimiChatProvider implements ChatProvider { if (systemPrompt) { messages.push({ role: 'system', content: systemPrompt }); } + const thinking = this._generationKwargs.extra_body?.thinking; + const preservedThinkingEnabled = + thinking?.keep === 'all' && thinking.type !== 'disabled'; const normalizedHistory = normalizeToolCallIdsForProvider(history, KIMI_TOOL_CALL_ID_POLICY); for (const msg of normalizedHistory) { - messages.push(convertMessage(msg)); + messages.push(convertMessage(msg, preservedThinkingEnabled)); } const kwargs: Record = { diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index e5e18290d5..80ca99ed43 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -74,6 +74,36 @@ async function captureRequestBody( return capturedBody; } +async function captureKimiMessages( + history: Message[], + configure?: (provider: KimiChatProvider) => KimiChatProvider, +): Promise>> { + let captured: Record | undefined; + const create = vi.fn().mockImplementation((params: unknown) => { + captured = params as Record; + return Promise.resolve(makeChatCompletionResponse('kimi-k2')); + }); + let provider = new KimiChatProvider({ + model: 'kimi-k2', + apiKey: 'test-key', + stream: false, + clientFactory: () => ({ chat: { completions: { create } } }) as never, + }); + if (configure !== undefined) { + provider = configure(provider); + } + + const response = await provider.generate('', [], history); + for await (const part of response) { + void part; + } + + if (captured === undefined) { + throw new Error('Expected Kimi provider to send a request.'); + } + return captured['messages'] as Array>; +} + const ADD_TOOL: Tool = { name: 'add', description: 'Add two integers.', @@ -606,6 +636,127 @@ describe('KimiChatProvider', () => { }, ]); }); + + it('backfills an assistant tool-call message when preserved thinking is active', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', ''); + }); + + it('backfills a text assistant message when keep=all omits thinking.type', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'text', text: 'Done.' }], + toolCalls: [], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', ''); + }); + + it.each([ + ['empty', ''], + ['non-empty', 'reasoning text'], + ])( + 'sends an existing %s ThinkPart verbatim when preserved thinking is active', + async (_kind, think) => { + const history: Message[] = [ + { + role: 'assistant', + content: [{ type: 'think', think }], + toolCalls: [], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + expect(messages[0]).toHaveProperty('reasoning_content', think); + }, + ); + + it.each([ + ['missing', undefined], + ['null', null], + ['false', false], + ['off', 'off'], + ])( + 'does not backfill reasoning_content when thinking.keep is %s', + async (_kind, keep) => { + const history: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep } }), + ); + + expect(messages[0]).not.toHaveProperty('reasoning_content'); + }, + ); + + it('does not backfill reasoning_content when thinking is disabled', async () => { + const history: Message[] = [ + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call_1', name: 'lookup', arguments: '{"q":"test"}' }, + ], + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'disabled', keep: 'all' } }), + ); + + expect(messages[0]).not.toHaveProperty('reasoning_content'); + }); + + it('does not backfill reasoning_content on non-assistant messages', async () => { + const history: Message[] = [ + { role: 'system', content: [{ type: 'text', text: 'System.' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: 'User.' }], toolCalls: [] }, + { + role: 'tool', + content: [{ type: 'text', text: 'Tool result.' }], + toolCalls: [], + toolCallId: 'call_1', + }, + ]; + + const messages = await captureKimiMessages(history, (provider) => + provider.withExtraBody({ thinking: { type: 'enabled', keep: 'all' } }), + ); + + for (const message of messages) { + expect(message).not.toHaveProperty('reasoning_content'); + } + }); }); describe('generation kwargs', () => {