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:
7Sageer 2026-08-12 22:17:02 +08:00 committed by GitHub
parent 30f56a2d2d
commit fe3cdae5f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 75 additions and 2 deletions

View 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.

View file

@ -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;
}

View file

@ -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',