From 7644f1036ca1079e4527c0b1c825ec5384d6d8da Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 19 Jun 2026 23:33:38 +0800 Subject: [PATCH] fix: drop empty text blocks during projection (#910) --- .changeset/clean-empty-text-blocks.md | 5 ++ .../agent-core/src/agent/context/projector.ts | 44 +++++++--- .../agent-core/test/agent/context.test.ts | 81 +++++++++++++++++++ 3 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 .changeset/clean-empty-text-blocks.md diff --git a/.changeset/clean-empty-text-blocks.md b/.changeset/clean-empty-text-blocks.md new file mode 100644 index 000000000..93a5f6bdd --- /dev/null +++ b/.changeset/clean-empty-text-blocks.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix provider requests failing when restored conversation history contains empty text content blocks. diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index e0ae99972..02e574c3d 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -1,23 +1,18 @@ import type { ContentPart, Message, TextPart } from '@moonshot-ai/kosong'; +import { ErrorCodes, KimiError } from '../../errors'; import type { ContextMessage } from './types'; export function project(history: readonly ContextMessage[]): Message[] { - // Keep partial or empty assistant placeholders away from providers. - // They can appear when a turn is aborted or errors before any content - // or tool call is appended. - const usable = history.filter((message) => { - return ( - message.partial !== true && - !(message.role === 'assistant' && message.content.length === 0 && message.toolCalls.length === 0) - ); - }); - return mergeAdjacentUserMessages(usable); + return mergeAdjacentUserMessages(history); } function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[] { const out: ContextMessage[] = []; - for (const message of history) { + for (const source of history) { + const message = prepareMessageForProjection(source); + if (message === null) continue; + const previous = out.at(-1); if ( canMergeUserMessage(message) && @@ -32,6 +27,33 @@ function mergeAdjacentUserMessages(history: readonly ContextMessage[]): Message[ return out.map(stripContextMetadata); } +function prepareMessageForProjection(message: ContextMessage): ContextMessage | null { + if (message.partial === true) return null; + + let content: ContentPart[] | undefined; + for (const [index, part] of message.content.entries()) { + if (part.type === 'text' && part.text.length === 0) { + content ??= message.content.slice(0, index); + continue; + } + content?.push(part); + } + + const next = content === undefined ? message : { ...message, content }; + if (next.role === 'tool' && next.content.length === 0) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + 'Tool result message content cannot be empty after removing empty text blocks.', + { + details: { + toolCallId: next.toolCallId, + }, + }, + ); + } + return next.content.length === 0 && next.toolCalls.length === 0 ? null : next; +} + function canMergeUserMessage(message: ContextMessage): boolean { return message.role === 'user' && message.origin?.kind === 'user'; } diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 2a8a33c71..3ba820c6a 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -80,6 +80,87 @@ describe('Agent context', () => { ]); }); + it('drops empty text parts only in LLM projection', () => { + const history: ContextMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: '' }, + { type: 'text', text: 'Run the tool' }, + ], + toolCalls: [], + }, + { + role: 'assistant', + content: [{ type: 'text', text: '' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [{ type: 'text', text: '' }], + toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }], + }, + { + role: 'assistant', + content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }], + toolCalls: [], + }, + { + role: 'user', + content: [{ type: 'text', text: ' ' }], + toolCalls: [], + }, + ]; + + expect(project(history)).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Run the tool' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }], + }, + { + role: 'assistant', + content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }], + toolCalls: [], + }, + { + role: 'user', + content: [{ type: 'text', text: ' ' }], + toolCalls: [], + }, + ]); + expect(history[0]?.content).toEqual([ + { type: 'text', text: '' }, + { type: 'text', text: 'Run the tool' }, + ]); + expect(history[1]?.content).toEqual([{ type: 'text', text: '' }]); + }); + + it('rejects tool result messages left empty by LLM projection cleanup', () => { + const history: ContextMessage[] = [ + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: '' }], + toolCallId: 'call_empty', + toolCalls: [], + }, + ]; + + expect(() => project(history)).toThrow( + 'Tool result message content cannot be empty after removing empty text blocks.', + ); + }); + it('projects hook result messages into LLM projection', async () => { const ctx = testAgent(); ctx.configure();