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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user