From 1f529c4eb3faafc6bc78ec8050823201e3c632e5 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:56:41 +0200 Subject: [PATCH] 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 --- .../chat/Messages/OutputEditView.svelte | 8 ++-- .../chat/Messages/structuredOutput.ts | 43 ++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/lib/components/chat/Messages/OutputEditView.svelte b/src/lib/components/chat/Messages/OutputEditView.svelte index 0fc07373ac..f3a2e4d3f2 100644 --- a/src/lib/components/chat/Messages/OutputEditView.svelte +++ b/src/lib/components/chat/Messages/OutputEditView.svelte @@ -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(''); } diff --git a/src/lib/components/chat/Messages/structuredOutput.ts b/src/lib/components/chat/Messages/structuredOutput.ts index d4ebe0493e..612aca3dbe 100644 --- a/src/lib/components/chat/Messages/structuredOutput.ts +++ b/src/lib/components/chat/Messages/structuredOutput.ts @@ -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;