fix: drop empty text blocks during projection (#910)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

This commit is contained in:
_Kerman 2026-06-19 23:33:38 +08:00 committed by GitHub
parent 710277a34b
commit 7644f1036c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 119 additions and 11 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix provider requests failing when restored conversation history contains empty text content blocks.

View file

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

View file

@ -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();