fix(ai,agent,coding-agent): normalize null message content at ingestion boundaries (#6343)
The Message types require content to always be present, but untyped JS extension tools, hand-built histories, and old or hand-edited session files can violate that contract, crashing rendering, compaction, and provider request conversion with 'content is not iterable'. Normalize null/missing content to an empty array at the ingestion boundaries instead of guarding every consumer: - transformMessages (choke point before every provider request) - createToolResultMessage in the agent loop - session entry loading (message and custom_message entries) - extension custom messages (sendCustomMessage, before_agent_start) - message_end extension replacements fixes #6259, fixes #6276
This commit is contained in:
@@ -68,7 +68,10 @@ export function transformMessages<TApi extends Api>(
|
||||
): Message[] {
|
||||
// Build a map of original tool call IDs to normalized IDs
|
||||
const toolCallIdMap = new Map<string, string>();
|
||||
const imageAwareMessages = downgradeUnsupportedImages(messages, model);
|
||||
// Normalize null/undefined content from untyped callers (custom tools, hand-built
|
||||
// histories, old session files) so downstream code can rely on the type contract.
|
||||
const normalizedMessages = messages.map((msg) => (msg.content == null ? { ...msg, content: [] } : msg));
|
||||
const imageAwareMessages = downgradeUnsupportedImages(normalizedMessages, model);
|
||||
|
||||
// First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization)
|
||||
const transformed = imageAwareMessages.map((msg) => {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The Message types require `content` to always be present, but untyped
|
||||
* callers (custom tools, hand-built histories, old session files) can violate
|
||||
* that contract. `transformMessages` is the choke point before every provider
|
||||
* request and is intentionally lax: it normalizes null/missing content to an
|
||||
* empty array (issues #6259, #6276).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transformMessages } from "../src/api/transform-messages.ts";
|
||||
import type { Message, Model } from "../src/types.ts";
|
||||
|
||||
// Text-only model so the image downgrade path (replaceImagesWithPlaceholder) runs,
|
||||
// which was the primary crash site for null tool result content.
|
||||
function makeTextOnlyModel(): Model<"openai-completions"> {
|
||||
return {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("lax message content handling", () => {
|
||||
it("normalizes null/missing content to an empty array instead of crashing", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: null, timestamp: Date.now() },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
model: "test-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "web_search",
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
] as unknown as Message[];
|
||||
|
||||
const result = transformMessages(messages, makeTextOnlyModel());
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
for (const msg of result) {
|
||||
expect(msg.content).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user