fix: structured output renderer crashing on an empty output slot (#29250)

A chat could hard-fail in the browser with "TypeError: can't access property content" and stop rendering the assistant message until a reload.

Streamed response items and content parts were placed at the index the provider reports. That index is not bounded by the length of the array the client has built up, so an entry could land past the end and leave a gap behind. Spreading the array on the next event turned that gap into a real empty entry, and the renderer then dereferenced it while looking for message text.

Output items that would land past the end are now appended, since later events locate them by id anyway. Content and summary parts are padded up to the index instead, because a part carries no id and the text streamed for it is addressed by that same index. The renderer and the structured editor now skip an empty entry as well, so chats already saved in the broken state still display and edit.

Fixes #29244
This commit is contained in:
Classic298 2026-08-30 17:56:41 +02:00 committed by GitHub
parent a93c508038
commit 1f529c4eb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 38 additions and 13 deletions

View file

@ -138,7 +138,7 @@
function getMessageText(item: any): string {
return (item.content ?? [])
.filter((p: any) => p.type === 'output_text' || 'text' in p)
.filter((p: any) => p && (p.type === 'output_text' || 'text' in p))
.map((p: any) => p.text ?? '')
.join('\n');
}
@ -146,7 +146,9 @@
function updateMessageText(idx: number, text: string) {
const next = [...output];
const item = { ...next[idx] };
const parts = (item.content ?? []).filter((p: any) => p.type === 'output_text' || 'text' in p);
const parts = (item.content ?? []).filter(
(p: any) => p && (p.type === 'output_text' || 'text' in p)
);
item.content = [{ ...(parts[0] ?? { type: 'output_text' }), text }];
next[idx] = item;
output = next;
@ -155,7 +157,7 @@
function getReasoningText(item: any): string {
return (item.summary ?? item.content ?? [])
.filter((p: any) => 'text' in p)
.filter((p: any) => p && 'text' in p)
.map((p: any) => p.text ?? '')
.join('');
}

View file

@ -374,7 +374,11 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
};
output.forEach((item, index) => {
if (item?.type === 'function_call_output') {
if (!item) {
return;
}
if (item.type === 'function_call_output') {
const inlineFile = getInlineFileFromToolOutput(toolCallByCallId[item.call_id ?? ''], item);
if (inlineFile) {
flushDetails();
@ -388,14 +392,14 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
}
if (
item?.type === 'function_call' &&
item.type === 'function_call' &&
item.name === 'ask_user' &&
(item.status === 'pending' || item.status === 'in_progress')
) {
return;
}
if (item?.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
if (item.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
const token = buildDetailToken(item, index === output.length - 1, toolOutputByCallId);
if (token) {
currentDetailTokens.push(token);
@ -403,7 +407,7 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
return;
}
if (item?.type === 'message') {
if (item.type === 'message') {
const text = getMessageText(item);
if (text.trim()) {
flushDetails();
@ -481,6 +485,17 @@ function ensurePart(parts: OutputContentPart[], index: number, fallback?: Output
return parts[index];
}
function setPart(
parts: OutputContentPart[],
index: number,
part: OutputContentPart,
fallback?: OutputContentPart
): void {
// Assigning past the end leaves a hole that later spreads turn into undefined parts.
ensurePart(parts, index, fallback);
parts[index] = part;
}
function findOutputItemIndex(output: OutputItem[], item: OutputItem): number {
return output.findIndex(
(existing) =>
@ -529,7 +544,7 @@ export function applyResponseStreamEvent(
} else if (outputIndex < nextOutput.length) {
nextOutput.splice(outputIndex, 0, item);
} else {
nextOutput[outputIndex] = item;
nextOutput.push(item);
}
return nextOutput;
}
@ -540,7 +555,13 @@ export function applyResponseStreamEvent(
}
const item = { ...event.item };
const existingIndex = findOutputItemIndex(nextOutput, item);
nextOutput[existingIndex >= 0 ? existingIndex : outputIndex] = item;
if (existingIndex >= 0) {
nextOutput[existingIndex] = item;
} else if (outputIndex < nextOutput.length) {
nextOutput[outputIndex] = item;
} else {
nextOutput.push(item);
}
return nextOutput;
}
@ -565,7 +586,7 @@ export function applyResponseStreamEvent(
return nextOutput;
}
item.content = [...(item.content ?? [])];
item.content[event.content_index ?? item.content.length] = { ...event.part };
setPart(item.content, event.content_index ?? item.content.length, { ...event.part });
return nextOutput;
}
@ -574,7 +595,8 @@ export function applyResponseStreamEvent(
return nextOutput;
}
item.summary = [...(item.summary ?? [])];
item.summary[event.summary_index ?? item.summary.length] = { ...event.part };
const summaryIndex = event.summary_index ?? item.summary.length;
setPart(item.summary, summaryIndex, { ...event.part }, { type: 'summary_text', text: '' });
return nextOutput;
}
@ -604,7 +626,8 @@ export function applyResponseStreamEvent(
const typeName = eventType.split('.')[1];
if (typeName === 'content_part' && event.part) {
item.content = [...(item.content ?? [])];
item.content[event.content_index ?? Math.max(item.content.length - 1, 0)] = { ...event.part };
const contentIndex = event.content_index ?? Math.max(item.content.length - 1, 0);
setPart(item.content, contentIndex, { ...event.part });
} else if (typeName === 'function_call_arguments' && event.arguments !== undefined) {
item.arguments = event.arguments;
} else if (
@ -636,7 +659,7 @@ export function replaceOutputMessageText(
}
const partIndex = item.content.findIndex(
(part) => typeof part.text === 'string' && part.text.includes(oldContent)
(part) => typeof part?.text === 'string' && part.text.includes(oldContent)
);
if (partIndex === -1) {
return item;