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