mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-12 18:27:39 +00:00
fix(agent-core-v2): drop interrupted thinking-only assistant messages at step settle (#2819)
* fix(agent-core-v2): drop interrupted thinking-only assistant messages at settle 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. * fix(agent-core-v2): preserve reasoning-only assistant history --------- Signed-off-by: 7Sageer <sag77r@hotmail.com>
This commit is contained in:
parent
30f56a2d2d
commit
fe3cdae5f8
3 changed files with 75 additions and 2 deletions
5
.changeset/fix-thinking-only-assistant-400.md
Normal file
5
.changeset/fix-thinking-only-assistant-400.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
readonly requestOptions: Record<string, unknown> | 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<Record<string, unknown>> {
|
||||
let captured: Record<string, unknown> | 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<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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue