diff --git a/.changeset/esc-interrupt-thinking-only-message.md b/.changeset/esc-interrupt-thinking-only-message.md new file mode 100644 index 0000000000..e70b617ab6 --- /dev/null +++ b/.changeset/esc-interrupt-thinking-only-message.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sessions permanently wedging after interrupting a response (ESC) mid-thinking: the leftover reasoning-only assistant message is now dropped from outgoing requests instead of being sent empty and rejected by the provider. diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index 88227ba4e0..7733b1391b 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -8,7 +8,11 @@ * result invented for a lost one, an orphan/duplicate dropped, leading * non-user messages dropped, consecutive assistants merged, blank text * dropped, wholly-vacuous messages — nothing sendable was recorded, e.g. an - * assistant step that kept only an empty thinking part — dropped whole) are + * assistant step that kept only an empty thinking part or only unencrypted + * thinking (the OpenAI bases move thinking out of `content` into + * `reasoning_content`, the Anthropic base into `thinking` blocks, so a + * thinking-only assistant would serialize with neither content nor + * tool_calls; signed thinking is preserved) — dropped whole) are * reported through an optional sink and surfaced once here as a * single deduped warning plus a `context_projection_repaired` telemetry event, * so a silently-mangled history always leaves a trace. The mutable @@ -435,8 +439,14 @@ function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Mes const emit = (source: ContextMessage): void => { const content = projectedContent(source, onAnomaly); if (source.toolCalls.length === 0 && !hasDeclaredTools(source)) { - if (content.length === 0) return; - if (content.every(isVacuousContentPart)) { + const sendable = wireSendableContent(content); + if (sendable.length === 0) { + if (content.length > 0) { + onAnomaly?.({ kind: 'vacuous_message_dropped', role: source.role }); + } + return; + } + if (sendable.every(isVacuousContentPart)) { onAnomaly?.({ kind: 'vacuous_message_dropped', role: source.role }); return; } @@ -604,6 +614,10 @@ function isBlankText(part: ContentPart): boolean { return part.type === 'text' && part.text.trim().length === 0; } +function wireSendableContent(content: readonly ContentPart[]): ContentPart[] { + return content.filter((part) => part.type !== 'think' || part.encrypted !== undefined); +} + function canMergeUserMessage(message: ContextMessage): boolean { return message.role === 'user' && message.origin?.kind === 'user'; } diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index ad501f1c08..01ac0cbff6 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -615,10 +615,29 @@ describe('projector tool-exchange normalization', () => { expect(repairPayloads(warnings)).toEqual([]); }); - it('keeps a message whose think block has real content', () => { + it('drops a message whose only sendable part is an unencrypted think block', () => { const history = [user('u1'), thinkingAssistant([{ type: 'think', think: 'real reasoning' }])]; - expect(shape(history)).toEqual(['user', 'assistant']); - expect(repairPayloads(warnings)).toEqual([]); + expect(shape(history)).toEqual(['user']); + expect(repairPayloads(warnings)).toEqual([ + expect.objectContaining({ vacuousDropped: 1 }), + ]); + }); + + it('drops a thinking-only assistant sealed after an interrupted step (ESC cancel)', () => { + // A step cancelled mid-stream (ESC) seals a partial assistant that holds + // only a thinking fragment; projecting it into the request would produce + // an assistant message with neither content nor tool_calls and trip the + // provider's "content or tool_calls must be set" validation. + const history = [ + user('u1'), + thinkingAssistant([{ type: 'think', think: 'partial reasoning before cancel' }]), + reminder('The previous turn was interrupted by the user before completion'), + user('continue'), + ]; + expect(shape(history)).toEqual(['user', 'user', 'user']); + expect(repairPayloads(warnings)).toEqual([ + expect.objectContaining({ vacuousDropped: 1 }), + ]); }); it('keeps a signed think block even when its text is empty', () => { diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index 14d3970752..a9f4bbc156 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -428,13 +428,22 @@ function prepareMessageForProjection( if (next.content.length === 0) return null; // Every remaining part serializes to nothing on the wire — e.g. an // assistant step that recorded only an empty thinking part from a - // provider-filtered response. Sent as-is it becomes an assistant message - // with no content and no tool calls, which strict providers reject ("the - // message ... with role 'assistant' must not be empty") on every resend, - // permanently wedging the session. Drop the whole message here. A message - // that carries any real content keeps every part verbatim — including - // empty thinking blocks, which preserved-thinking providers require back. - if (next.content.every(isVacuousContentPart)) { + // provider-filtered response, or one cut off mid-thinking by the user + // (ESC) so it holds only unencrypted thinking. Sent as-is it becomes an + // assistant message with no content and no tool calls, which strict + // providers reject ("the message ... with role 'assistant' must not be + // empty") on every resend, permanently wedging the session. Drop the whole + // message here. A message that carries any real content keeps every part + // verbatim — including empty thinking blocks, which preserved-thinking + // providers require back. + const sendable = wireSendableContent(next.content); + if (sendable.length === 0) { + if (next.content.length > 0) { + onAnomaly?.({ kind: 'vacuous_message_dropped', role: next.role }); + } + return null; + } + if (sendable.every(isVacuousContentPart)) { onAnomaly?.({ kind: 'vacuous_message_dropped', role: next.role }); return null; } @@ -454,6 +463,18 @@ function isVacuousContentPart(part: ContentPart): boolean { return false; } +/** + * The parts of a message that the provider wire can actually carry as + * message content. Unencrypted thinking blocks are excluded: every protocol + * base moves them out of `content` (OpenAI → `reasoning_content`, Anthropic → + * `thinking` blocks), so a message whose only parts are unencrypted thinking + * would reach the wire with neither content nor tool_calls. Signed thinking + * (`encrypted`) must survive — reasoning providers require it back verbatim. + */ +function wireSendableContent(content: readonly ContentPart[]): ContentPart[] { + return content.filter((part) => part.type !== 'think' || part.encrypted !== undefined); +} + function canMergeUserMessage(message: ContextMessage): boolean { return message.role === 'user' && message.origin?.kind === 'user'; } diff --git a/packages/agent-core/test/agent/context/projector.test.ts b/packages/agent-core/test/agent/context/projector.test.ts index 09b0500e67..2eeabbca70 100644 --- a/packages/agent-core/test/agent/context/projector.test.ts +++ b/packages/agent-core/test/agent/context/projector.test.ts @@ -589,10 +589,29 @@ describe('project drops vacuous (thinking-only) messages', () => { expect(projected[1]?.content).toEqual([thinkPart(''), textPart('answer')]); }); - it('keeps a message whose think block has real content', () => { + it('drops a message whose only sendable part is an unencrypted think block', () => { const projected = project([user('u1'), thinkingAssistant([thinkPart('real reasoning')])]); - expect(projected.map((m) => m.role)).toEqual(['user', 'assistant']); - expect(projected[1]?.content).toEqual([thinkPart('real reasoning')]); + expect(projected.map((m) => m.role)).toEqual(['user']); + }); + + it('drops a thinking-only assistant sealed after an interrupted step (ESC cancel)', () => { + // A step cancelled mid-stream (ESC) seals a partial assistant that holds + // only a thinking fragment; projecting it into the request would produce + // an assistant message with neither content nor tool_calls and trip the + // provider's "content or tool_calls must be set" validation on every + // resend, permanently wedging the session. + const anomalies: ProjectionAnomaly[] = []; + const projected = project( + [ + user('u1'), + thinkingAssistant([thinkPart('partial reasoning before cancel')]), + notification('The previous turn was interrupted by the user before completion'), + user('continue'), + ], + { onAnomaly: (a) => anomalies.push(a) }, + ); + expect(projected.map((m) => m.role)).toEqual(['user', 'user', 'user']); + expect(anomalies).toEqual([{ kind: 'vacuous_message_dropped', role: 'assistant' }]); }); it('keeps a signed think block even when its text is empty', () => {