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/esc-interrupt-thinking-only-message.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
35 changes: 28 additions & 7 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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';
}
Expand Down
25 changes: 22 additions & 3 deletions packages/agent-core/test/agent/context/projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down