Compare commits

...
Author SHA1 Message Date
陈大猫 322485f667 fix(ai): never pass empty messages to AI SDK standardizePrompt (#4102)
pruneMessages({ emptyMessages: 'remove', reasoning: 'all' }) can yield []
when history is reasoning-only after DeepSeek/OpenRouter-style strip.
Guard prepareCattyMessagesForStream, prepareStep, and processCattyStream so
Invalid prompt: messages must not be empty cannot be reached that way.
2026-11-03 16:30:51 +08:00
4 changed files with 71 additions and 1 deletions

No files matched your search

+10 -1
View File
@@ -273,9 +273,18 @@ export function prepareCattyMessagesForStream(
messages: ModelMessage[],
options: { preserveReasoning?: boolean } = {},
): ModelMessage[] {
return pruneMessages({
const pruned = pruneMessages({
messages,
reasoning: options.preserveReasoning ? 'none' : 'all',
emptyMessages: 'remove',
});
// pruneMessages({ emptyMessages: 'remove' }) can yield [] when every message
// was reasoning-only (or otherwise empty after stripping). AI SDK
// standardizePrompt then throws "Invalid prompt: messages must not be empty"
// (#4102). Refuse to hand an emptied list downstream when we still had input.
if (pruned.length === 0 && messages.length > 0) {
console.warn('[Catty] prepareCattyMessagesForStream refused empty prune result (#4102)');
return messages;
}
return pruned;
}
@@ -819,3 +819,55 @@ test('durable summary keeps Anthropic signed thinking for DeepSeek (#4074 P2)',
assert.ok(reasoning.some((p) => p.text === 'unsigned recent'));
});
test('prepareCattyMessagesForStream does not return [] when prune empties reasoning-only history (#4102)', () => {
const reasoningOnly = [
{
role: 'assistant' as const,
content: [{ type: 'reasoning' as const, text: 'plan the ping' }],
},
{
role: 'assistant' as const,
content: [{ type: 'reasoning' as const, text: 'then run it' }],
},
];
const prepared = prepareCattyMessagesForStream(reasoningOnly);
assert.ok(prepared.length > 0, 'must not hand AI SDK an empty messages array');
assert.equal(prepared.length, reasoningOnly.length);
assert.equal(
(prepared[0].content as { type: string }[]).some((part) => part.type === 'reasoning'),
true,
'fallback keeps original reasoning when strip-all would empty the prompt',
);
});
test('prepareCattyMessagesForStream still strips reasoning when other content remains (#4102)', () => {
const mixed = [
{ role: 'user' as const, content: 'ping www.baidu.com -c 4' },
{
role: 'assistant' as const,
content: [
{ type: 'reasoning' as const, text: 'plan' },
{ type: 'text' as const, text: 'running it' },
{
type: 'tool-call' as const,
toolCallId: 'call-1',
toolName: 'terminal_execute',
input: { command: 'ping www.baidu.com -c 4' },
},
],
},
];
const prepared = prepareCattyMessagesForStream(mixed);
assert.equal(prepared.length, 2);
assert.equal(prepared[0].role, 'user');
const assistantContent = prepared[1].content;
assert.ok(Array.isArray(assistantContent));
assert.equal(assistantContent.some((part) => part.type === 'reasoning'), false);
assert.equal(assistantContent.some((part) => part.type === 'text'), true);
assert.equal(assistantContent.some((part) => part.type === 'tool-call'), true);
});
test('prepareCattyMessagesForStream leaves a true empty input empty', () => {
assert.deepEqual(prepareCattyMessagesForStream([]), []);
});
@@ -132,6 +132,9 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
} = input;
let runtimeContext = initialRuntimeContext;
if (!Array.isArray(sdkMessages) || sdkMessages.length === 0) {
throw new Error('Invalid prompt: Catty refused an empty messages array (#4102).');
}
const { tools, toolsContext } = toolsBundle;
const endpoint = input.providerConfig
? resolveProviderEndpoint(input.providerConfig, resolveProviderStyle(input.providerConfig), undefined)
@@ -518,6 +518,12 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
stepMessages = stripped.messages;
}
}
// Never replace the step prompt with [] (#4102). Prefer the SDK's
// pre-prepare messages over an emptied prepareStepContext result.
if (stepMessages.length === 0 && messages.length > 0) {
console.warn('[Catty] prepareStep produced empty messages; keeping prior step messages (#4102)');
stepMessages = messages;
}
emitContextSnapshot(stepMessages);
return {
messages: stepMessages,