Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-thinking-only-assistant-400.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -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;
}
Expand Down
60 changes: 58 additions & 2 deletions packages/agent-core-v2/test/kosong/provider/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -603,6 +605,7 @@ async function captureOpenAIBody(
async function captureAnthropicBody(
provider: ChatProvider,
options?: GenerateOptions,
history: Message[] = PROBE_HISTORY,
): Promise<{
readonly params: Record<string, unknown>;
readonly requestOptions: Record<string, unknown> | undefined;
Expand All @@ -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');
}
Expand All @@ -636,6 +639,7 @@ async function captureAnthropicBody(
async function captureGoogleBody(
provider: ChatProvider,
options?: GenerateOptions,
history: Message[] = PROBE_HISTORY,
): Promise<Record<string, unknown>> {
let captured: Record<string, unknown> | undefined;
const client = sdkClient(provider) as { models: { generateContent: unknown } };
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<Record<string, unknown>>;

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<Record<string, unknown>>;

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<Record<string, unknown>>;

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',
Expand Down
Loading