From a21aa370e789db6f634cf8e2512fd154b0acd6c5 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Fri, 24 Jul 2026 22:40:00 -0500 Subject: [PATCH] fix(responses): safely handle stream deltas on uninitialized properties --- src/lib/responses/ResponseAccumulator.ts | 16 +++--- tests/lib/ResponseAccumulator.test.ts | 62 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/lib/responses/ResponseAccumulator.ts b/src/lib/responses/ResponseAccumulator.ts index f3dee49f0..698a8f914 100644 --- a/src/lib/responses/ResponseAccumulator.ts +++ b/src/lib/responses/ResponseAccumulator.ts @@ -90,8 +90,8 @@ export function accumulateResponse( if (content.type !== 'output_text') { throw new OpenAIError(`expected content to be 'output_text', got ${content.type}`); } - content.text += event.delta; - snapshot.output_text += event.delta; + content.text = (content.text ?? '') + event.delta; + snapshot.output_text = (snapshot.output_text ?? '') + event.delta; } break; } @@ -127,7 +127,7 @@ export function accumulateResponse( if (content.type !== 'refusal') { throw new OpenAIError(`expected content to be 'refusal', got ${content.type}`); } - content.refusal += event.delta; + content.refusal = (content.refusal ?? '') + event.delta; } break; } @@ -145,7 +145,7 @@ export function accumulateResponse( case 'response.function_call_arguments.delta': { const output = getOutput(snapshot, event.output_index); if (output.type === 'function_call') { - output.arguments += event.delta; + output.arguments = (output.arguments ?? '') + event.delta; } break; } @@ -166,7 +166,7 @@ export function accumulateResponse( if (content.type !== 'reasoning_text') { throw new OpenAIError(`expected content to be 'reasoning_text', got ${content.type}`); } - content.text += event.delta; + content.text = (content.text ?? '') + event.delta; } break; } @@ -203,7 +203,7 @@ export function accumulateResponse( const output = getOutput(snapshot, event.output_index); if (output.type === 'reasoning') { const part = getContent(output.summary, event.summary_index); - part.text += event.delta; + part.text = (part.text ?? '') + event.delta; } break; } @@ -218,7 +218,7 @@ export function accumulateResponse( case 'response.custom_tool_call_input.delta': { const output = getOutput(snapshot, event.output_index); if (output.type === 'custom_tool_call') { - output.input += event.delta; + output.input = (output.input ?? '') + event.delta; } break; } @@ -232,7 +232,7 @@ export function accumulateResponse( case 'response.mcp_call_arguments.delta': { const output = getOutput(snapshot, event.output_index); if (output.type === 'mcp_call') { - output.arguments += event.delta; + output.arguments = (output.arguments ?? '') + event.delta; } break; } diff --git a/tests/lib/ResponseAccumulator.test.ts b/tests/lib/ResponseAccumulator.test.ts index 0fbcb111a..df83bd10f 100644 --- a/tests/lib/ResponseAccumulator.test.ts +++ b/tests/lib/ResponseAccumulator.test.ts @@ -222,6 +222,68 @@ describe('ResponseAccumulator', () => { content: [{ type: 'refusal', refusal: 'I cannot help with that.' }], }); }); + + it('safely accumulates deltas when initial string fields are uninitialized', () => { + const snapshot = accumulateEvents([ + { type: 'response.created', sequence_number: 0, response: makeResponse() }, + { + type: 'response.output_item.added', + sequence_number: 1, + output_index: 0, + item: { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + name: 'get_weather', + status: 'in_progress', + } as any, + }, + { + type: 'response.function_call_arguments.delta', + sequence_number: 2, + item_id: 'fc_1', + output_index: 0, + delta: '{"city": "Paris"}', + }, + { + type: 'response.output_item.added', + sequence_number: 3, + output_index: 1, + item: { + id: 'msg_123', + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [], + }, + }, + { + type: 'response.content_part.added', + sequence_number: 4, + item_id: 'msg_123', + output_index: 1, + content_index: 0, + part: { type: 'refusal' } as any, + }, + { + type: 'response.refusal.delta', + sequence_number: 5, + item_id: 'msg_123', + output_index: 1, + content_index: 0, + delta: 'Permission denied', + }, + ]); + + expect(snapshot.output[0]).toMatchObject({ + type: 'function_call', + arguments: '{"city": "Paris"}', + }); + expect(snapshot.output[1]).toMatchObject({ + type: 'message', + content: [{ type: 'refusal', refusal: 'Permission denied' }], + }); + }); }); function accumulateEvents(events: ResponseStreamEvent[]): Response {