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