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:
Armin Ronacher
2026-07-06 20:47:08 +02:00
committed by GitHub
parent 6efc09b7eb
commit 8c0ccd14b3
6 changed files with 264 additions and 7 deletions
+3 -1
View File
@@ -735,7 +735,9 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul
role: "toolResult",
toolCallId: finalized.toolCall.id,
toolName: finalized.toolCall.name,
content: finalized.result.content,
// Untyped tools (JS extensions) can return results without content; normalize
// so the null never enters session history or provider payloads.
content: finalized.result.content ?? [],
details: finalized.result.details,
isError: finalized.isError,
timestamp: Date.now(),
+4 -1
View File
@@ -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([]);
}
});
});
@@ -680,7 +680,17 @@ export class AgentSession {
};
const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent);
if (replacement) {
this._replaceMessageInPlace(event.message, replacement);
// Untyped extension handlers can return messages with null/missing content;
// normalize so it never enters agent state or session history.
const normalized =
(replacement.role === "user" ||
replacement.role === "assistant" ||
replacement.role === "toolResult" ||
replacement.role === "custom") &&
replacement.content == null
? ({ ...replacement, content: [] } as AgentMessage)
: replacement;
this._replaceMessageInPlace(event.message, normalized);
}
} else if (event.type === "tool_execution_start") {
const extensionEvent: ToolExecutionStartEvent = {
@@ -1143,7 +1153,8 @@ export class AgentSession {
messages.push({
role: "custom",
customType: msg.customType,
content: msg.content,
// Untyped extensions can pass null/missing content; normalize at ingestion.
content: msg.content ?? [],
display: msg.display,
details: msg.details,
timestamp: Date.now(),
@@ -1341,7 +1352,8 @@ export class AgentSession {
const appMessage = {
role: "custom" as const,
customType: message.customType,
content: message.content,
// Untyped extensions can pass null/missing content; normalize at ingestion.
content: message.content ?? [],
display: message.display,
details: message.details,
timestamp: Date.now(),
@@ -378,10 +378,21 @@ function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "
*/
export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {
if (entry.type === "message") {
return [entry.message];
const message = entry.message;
// Session files are parsed without validation; old versions, forks, or
// hand-edited files can contain messages with null/missing content.
if (
(message.role === "user" || message.role === "assistant" || message.role === "toolResult") &&
message.content == null
) {
return [{ ...message, content: [] }];
}
return [message];
}
if (entry.type === "custom_message") {
return [createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp)];
return [
createCustomMessage(entry.customType, entry.content ?? [], entry.display, entry.details, entry.timestamp),
];
}
if (entry.type === "branch_summary" && entry.summary) {
return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)];
@@ -0,0 +1,162 @@
/**
* 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. We are intentionally lax at the ingestion
* boundaries and normalize null/missing content to an empty array so it never
* reaches rendering, compaction, or provider request conversion
* (issues #6259, #6276).
*/
import type { AgentMessage, AgentToolResult } from "@earendil-works/pi-agent-core";
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { type SessionEntry, sessionEntryToContextMessages } from "../../src/core/session-manager.ts";
import type { ExtensionFactory } from "../../src/index.ts";
import { createHarness } from "./harness.ts";
function messageEntry(message: Record<string, unknown>): SessionEntry {
return {
type: "message",
id: "entry-1",
parentId: null,
timestamp: new Date().toISOString(),
message,
} as unknown as SessionEntry;
}
describe("lax message content handling", () => {
it("normalizes tool results from untyped tools that omit content", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.registerTool({
name: "web_search",
label: "Web Search",
description: "Custom tool that returns a result without content",
parameters: Type.Object({}),
// Simulate an untyped JS extension tool that omits content.
execute: async () => ({ details: {} }) as unknown as AgentToolResult<unknown>,
});
},
];
const harness = await createHarness({ extensionFactories });
try {
harness.setResponses([
fauxAssistantMessage(fauxToolCall("web_search", {}), { stopReason: "toolUse" }),
fauxAssistantMessage("done"),
]);
await harness.session.prompt("search something");
const toolResults = harness.session.messages.filter((message) => message.role === "toolResult");
expect(toolResults).toHaveLength(1);
expect(toolResults[0].content).toEqual([]);
// The follow-up turn consumed the normalized tool result without crashing.
expect(harness.getPendingResponseCount()).toBe(0);
} finally {
harness.cleanup();
}
});
it("normalizes null content in message_end extension replacements", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.on("message_end", async (event) => {
if (event.message.role !== "assistant") return undefined;
// Simulate an untyped JS extension replacing a message without content.
return { message: { ...event.message, content: null } as unknown as AgentMessage };
});
},
];
const harness = await createHarness({ extensionFactories });
try {
harness.setResponses([fauxAssistantMessage("hello")]);
await harness.session.prompt("hi");
const assistantMessages = harness.session.messages.filter((message) => message.role === "assistant");
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].content).toEqual([]);
} finally {
harness.cleanup();
}
});
it("normalizes null content in custom messages from extensions", async () => {
const harness = await createHarness();
try {
await harness.session.sendCustomMessage({
customType: "test",
content: null as unknown as string,
display: false,
details: undefined,
});
const customMessages = harness.session.messages.filter((message) => message.role === "custom");
expect(customMessages).toHaveLength(1);
expect(customMessages[0].content).toEqual([]);
} finally {
harness.cleanup();
}
});
it("normalizes null or missing content when loading session message entries", () => {
const badMessages = [
{ 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(),
},
];
for (const badMessage of badMessages) {
const [message] = sessionEntryToContextMessages(messageEntry(badMessage));
expect(message).toMatchObject({ role: badMessage.role, content: [] });
}
});
it("normalizes null content when loading custom message entries", () => {
const entry = {
type: "custom_message",
id: "entry-1",
parentId: null,
timestamp: new Date().toISOString(),
customType: "test",
content: null,
display: false,
details: undefined,
} as unknown as SessionEntry;
const [message] = sessionEntryToContextMessages(entry);
expect(message).toMatchObject({ role: "custom", content: [] });
});
it("keeps valid message content untouched when loading session entries", () => {
const [message] = sessionEntryToContextMessages(
messageEntry({ role: "user", content: "hello", timestamp: Date.now() }),
);
expect(message).toMatchObject({ role: "user", content: "hello" });
});
});