From 7ce02f7548426fd9ba45bf3712f03a4a17773f7d Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Wed, 15 Jul 2026 00:15:30 +0200 Subject: [PATCH 1/3] fix: recover completed Responses text --- .changeset/quiet-dragons-write.md | 5 + .../src/adapters/responses-text.ts | 45 +++++++++ .../openai-base/tests/responses-text.test.ts | 71 ++++++++++++++ testing/e2e/src/routeTree.gen.ts | 22 +++++ .../api.openai-completed-response-text.ts | 95 +++++++++++++++++++ .../openai-completed-response-text.spec.ts | 14 +++ 6 files changed, 252 insertions(+) create mode 100644 .changeset/quiet-dragons-write.md create mode 100644 testing/e2e/src/routes/api.openai-completed-response-text.ts create mode 100644 testing/e2e/tests/openai-completed-response-text.spec.ts diff --git a/.changeset/quiet-dragons-write.md b/.changeset/quiet-dragons-write.md new file mode 100644 index 000000000..ef9492af2 --- /dev/null +++ b/.changeset/quiet-dragons-write.md @@ -0,0 +1,5 @@ +--- +'@tanstack/openai-base': patch +--- + +Recover successful Responses API text that is present only on the completed response event. diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index d48018209..bb2b9e063 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -1063,6 +1063,17 @@ export abstract class OpenAIBaseResponsesTextAdapter< // handle content_part added events for text, reasoning and refusals if (chunk.type === 'response.content_part.added') { const contentPart = chunk.part + // The Responses API can announce a text part with an empty + // placeholder before putting the actual text only on the completed + // response. An empty placeholder is not streamed content and must + // not suppress the completion backstop below. + if ( + (contentPart.type === 'output_text' || + contentPart.type === 'reasoning_text') && + !contentPart.text + ) { + continue + } // Emit TEXT_MESSAGE_START if this is text content if ( contentPart.type === 'output_text' && @@ -1396,6 +1407,40 @@ export abstract class OpenAIBaseResponsesTextAdapter< } if (chunk.type === 'response.completed') { + // Some Responses API streams, notably reasoning-model responses, + // can omit text deltas and carry the successful final text only in + // response.completed.output. Recover that text so consumers never + // observe an empty result for a successful response. + const completedText = chunk.response.output + .flatMap((item) => (item.type === 'message' ? item.content : [])) + .filter((part) => part.type === 'output_text') + .map((part) => part.text) + .join('') + + if (accumulatedContent.length === 0 && completedText.length > 0) { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } + } + + accumulatedContent = completedText + hasStreamedContentDeltas = true + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + delta: completedText, + content: accumulatedContent, + } + } + // Final backstop for function_call lifecycle: if a function_call // appears in `response.output[]` but was never matched by an // output_item.added/done with a name, recover the missing START diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 884954267..8e67b3ba2 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -1398,6 +1398,77 @@ describe('OpenAIBaseResponsesTextAdapter', () => { expect(startIdx).toBeLessThan(contentIdx) }) + it('recovers final text from response.completed when no text was streamed', async () => { + const streamChunks = [ + { + type: 'response.created', + response: { + id: 'resp-completed-text', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.content_part.added', + part: { type: 'output_text', text: '' }, + }, + { + type: 'response.completed', + response: { + id: 'resp-completed-text', + model: 'test-model', + status: 'completed', + output: [ + { + id: 'msg-completed-text', + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'Recovered final answer', + annotations: [], + }, + ], + }, + ], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + }, + ] + + setupMockResponsesClient(streamChunks) + const adapter = new TestResponsesAdapter(testConfig, 'test-model') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + logger: testLogger, + model: 'test-model', + messages: [{ role: 'user', content: 'Answer carefully' }], + })) { + chunks.push(chunk) + } + + const contentChunks = chunks.filter( + (chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT, + ) + expect(contentChunks).toHaveLength(1) + expect(contentChunks[0]).toMatchObject({ + delta: 'Recovered final answer', + content: 'Recovered final answer', + }) + + const eventTypes = chunks.map((chunk) => chunk.type) + const startIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_START) + const contentIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_CONTENT) + const endIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_END) + const finishedIndex = eventTypes.indexOf(EventType.RUN_FINISHED) + expect(startIndex).toBeGreaterThanOrEqual(0) + expect(contentIndex).toBeGreaterThan(startIndex) + expect(endIndex).toBeGreaterThan(contentIndex) + expect(finishedIndex).toBeGreaterThan(endIndex) + }) + it('skips content_part.done when deltas were already streamed', async () => { const streamChunks = [ { diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 14ffbf330..3cb3e0450 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -33,6 +33,7 @@ import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.open import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost' import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details' import { Route as ApiOpenaiShellSkillsWireRouteImport } from './routes/api.openai-shell-skills-wire' +import { Route as ApiOpenaiCompletedResponseTextRouteImport } from './routes/api.openai-completed-response-text' import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.multimodal-tool-result-wire' import { Route as ApiMiddlewareTestRouteImport } from './routes/api.middleware-test' import { Route as ApiMcpTestRouteImport } from './routes/api.mcp-test' @@ -181,6 +182,12 @@ const ApiOpenaiShellSkillsWireRoute = path: '/api/openai-shell-skills-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiOpenaiCompletedResponseTextRoute = + ApiOpenaiCompletedResponseTextRouteImport.update({ + id: '/api/openai-completed-response-text', + path: '/api/openai-completed-response-text', + getParentRoute: () => rootRouteImport, + } as any) const ApiMultimodalToolResultWireRoute = ApiMultimodalToolResultWireRouteImport.update({ id: '/api/multimodal-tool-result-wire', @@ -335,6 +342,7 @@ export interface FileRoutesByFullPath { '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute @@ -385,6 +393,7 @@ export interface FileRoutesByTo { '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute @@ -436,6 +445,7 @@ export interface FileRoutesById { '/api/mcp-test': typeof ApiMcpTestRoute '/api/middleware-test': typeof ApiMiddlewareTestRoute '/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute + '/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute '/api/openai-shell-skills-wire': typeof ApiOpenaiShellSkillsWireRoute '/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute '/api/openrouter-cost': typeof ApiOpenrouterCostRoute @@ -488,6 +498,7 @@ export interface FileRouteTypes { | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-completed-response-text' | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' @@ -538,6 +549,7 @@ export interface FileRouteTypes { | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-completed-response-text' | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' @@ -588,6 +600,7 @@ export interface FileRouteTypes { | '/api/mcp-test' | '/api/middleware-test' | '/api/multimodal-tool-result-wire' + | '/api/openai-completed-response-text' | '/api/openai-shell-skills-wire' | '/api/openai-usage-details' | '/api/openrouter-cost' @@ -639,6 +652,7 @@ export interface RootRouteChildren { ApiMcpTestRoute: typeof ApiMcpTestRoute ApiMiddlewareTestRoute: typeof ApiMiddlewareTestRoute ApiMultimodalToolResultWireRoute: typeof ApiMultimodalToolResultWireRoute + ApiOpenaiCompletedResponseTextRoute: typeof ApiOpenaiCompletedResponseTextRoute ApiOpenaiShellSkillsWireRoute: typeof ApiOpenaiShellSkillsWireRoute ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute @@ -824,6 +838,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOpenaiShellSkillsWireRouteImport parentRoute: typeof rootRouteImport } + '/api/openai-completed-response-text': { + id: '/api/openai-completed-response-text' + path: '/api/openai-completed-response-text' + fullPath: '/api/openai-completed-response-text' + preLoaderRoute: typeof ApiOpenaiCompletedResponseTextRouteImport + parentRoute: typeof rootRouteImport + } '/api/multimodal-tool-result-wire': { id: '/api/multimodal-tool-result-wire' path: '/api/multimodal-tool-result-wire' @@ -1084,6 +1105,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMcpTestRoute: ApiMcpTestRoute, ApiMiddlewareTestRoute: ApiMiddlewareTestRoute, ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute, + ApiOpenaiCompletedResponseTextRoute: ApiOpenaiCompletedResponseTextRoute, ApiOpenaiShellSkillsWireRoute: ApiOpenaiShellSkillsWireRoute, ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute, ApiOpenrouterCostRoute: ApiOpenrouterCostRoute, diff --git a/testing/e2e/src/routes/api.openai-completed-response-text.ts b/testing/e2e/src/routes/api.openai-completed-response-text.ts new file mode 100644 index 000000000..9da0ee1b4 --- /dev/null +++ b/testing/e2e/src/routes/api.openai-completed-response-text.ts @@ -0,0 +1,95 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat } from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' + +const DUMMY_KEY = 'sk-e2e-test-dummy-key' +const FINAL_TEXT = 'Recovered from response.completed' + +function makeCompletionOnlyResponsesStream(): ReadableStream { + const encoder = new TextEncoder() + const responseId = 'resp_completion_only' + const events = [ + { + type: 'response.created', + response: { + id: responseId, + object: 'response', + status: 'in_progress', + model: 'gpt-5.2', + output: [], + }, + }, + { + type: 'response.content_part.added', + response_id: responseId, + item_id: 'msg_completion_only', + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '' }, + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + model: 'gpt-5.2', + output: [ + { + id: 'msg_completion_only', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { type: 'output_text', text: FINAL_TEXT, annotations: [] }, + ], + }, + ], + usage: { + input_tokens: 5, + output_tokens: 4, + total_tokens: 9, + }, + }, + }, + ] + + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) +} + +export const Route = createFileRoute('/api/openai-completed-response-text')({ + server: { + handlers: { + POST: async () => { + const adapter = createOpenaiChat('gpt-5.2', DUMMY_KEY, { + fetch: async () => + new Response(makeCompletionOnlyResponsesStream(), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }), + }) + + const text = await chat({ + adapter, + messages: [ + { + role: 'user', + content: '[completed-response-text] answer carefully', + }, + ], + stream: false, + }) + + return Response.json({ text }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/openai-completed-response-text.spec.ts b/testing/e2e/tests/openai-completed-response-text.spec.ts new file mode 100644 index 000000000..9a9ac8166 --- /dev/null +++ b/testing/e2e/tests/openai-completed-response-text.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from './fixtures' + +test.describe('openai — completion-only response text', () => { + test('returns final text when response.completed is the only populated text event', async ({ + request, + }) => { + const response = await request.post('/api/openai-completed-response-text') + + expect(response.ok()).toBe(true) + await expect(response.json()).resolves.toEqual({ + text: 'Recovered from response.completed', + }) + }) +}) From 6288bf20db5454a10d720f1134ed26322f94e22f Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 17 Jul 2026 14:20:21 +0200 Subject: [PATCH 2/3] fix: recover completed OpenRouter response text --- .changeset/quiet-dragons-write.md | 3 +- .../src/adapters/responses-text.ts | 45 ++++++++++++ .../openrouter-responses-adapter.test.ts | 70 +++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/.changeset/quiet-dragons-write.md b/.changeset/quiet-dragons-write.md index ef9492af2..d4fe47191 100644 --- a/.changeset/quiet-dragons-write.md +++ b/.changeset/quiet-dragons-write.md @@ -1,5 +1,6 @@ --- '@tanstack/openai-base': patch +'@tanstack/ai-openrouter': patch --- -Recover successful Responses API text that is present only on the completed response event. +Recover successful OpenAI and OpenRouter Responses API text that is present only on the completed response event. diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 7659366eb..95b87b7ea 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -1061,6 +1061,17 @@ export class OpenRouterResponsesTextAdapter< // handle content_part added events for text, reasoning and refusals if (chunk.type === 'response.content_part.added' && chunk.part) { const contentPart = chunk.part + // Some Responses-compatible providers announce an empty text part + // and put the actual text only on response.completed. Do not count + // that placeholder as streamed content; the completion backstop + // below must remain eligible to recover the final text. + if ( + (contentPart.type === 'output_text' || + contentPart.type === 'reasoning_text') && + !contentPart.text + ) { + continue + } if ( contentPart.type === 'output_text' && !hasEmittedTextMessageStart @@ -1344,6 +1355,40 @@ export class OpenRouterResponsesTextAdapter< ? responseObj.output : [] + const completedText = outputItems + .flatMap((item) => + item.type === 'message' && Array.isArray(item.content) + ? item.content + : [], + ) + .filter((part) => part.type === 'output_text') + .map((part) => part.text) + .join('') + + if (accumulatedContent.length === 0 && completedText.length > 0) { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } + } + + accumulatedContent = completedText + hasStreamedContentDeltas = true + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + delta: completedText, + content: accumulatedContent, + } + } + // Final backstop for function_call lifecycle. for (const item of outputItems) { if (item.type !== 'function_call' || !item.id) continue diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 84c74522e..3b49656c9 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -641,6 +641,76 @@ describe('OpenRouter responses adapter — stream event bridge', () => { }) }) + it('recovers final text from response.completed when no text was streamed', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.content_part.added', + sequenceNumber: 1, + outputIndex: 0, + contentIndex: 0, + itemId: 'msg_1', + part: { type: 'output_text', text: '' }, + }, + { + type: 'response.completed', + sequenceNumber: 2, + response: { + model: 'm', + output: [ + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'Recovered final answer', + annotations: [], + }, + ], + }, + ], + usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: 'openai/gpt-4o-mini' as any, + messages: [{ role: 'user', content: 'Answer carefully' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + const contentChunks = chunks.filter( + (chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT, + ) + expect(contentChunks).toHaveLength(1) + expect(contentChunks[0]).toMatchObject({ + delta: 'Recovered final answer', + content: 'Recovered final answer', + }) + + const eventTypes = chunks.map((chunk) => chunk.type) + const startIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_START) + const contentIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_CONTENT) + const endIndex = eventTypes.indexOf(EventType.TEXT_MESSAGE_END) + const finishedIndex = eventTypes.indexOf(EventType.RUN_FINISHED) + expect(startIndex).toBeGreaterThanOrEqual(0) + expect(contentIndex).toBeGreaterThan(startIndex) + expect(endIndex).toBeGreaterThan(contentIndex) + expect(finishedIndex).toBeGreaterThan(endIndex) + }) + it('routes function-call args through TOOL_CALL_START/ARGS/END', async () => { setupMockSdkClient([ { From cef1905641ac68bd066e32089d6ab6acd93cee0d Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 17 Jul 2026 19:46:11 +0200 Subject: [PATCH 3/3] fix: handle OpenRouter terminal text events --- .../src/adapters/responses-text.ts | 38 +++++- .../openrouter-responses-adapter.test.ts | 127 ++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 95b87b7ea..7a0150032 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -984,6 +984,36 @@ export class OpenRouterResponsesTextAdapter< } } + // Some Responses-compatible providers omit text deltas and expose the + // completed text only on the dedicated done event. + if ( + chunk.type === 'response.output_text.done' && + chunk.text && + accumulatedContent.length === 0 + ) { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + role: 'assistant', + } + } + + accumulatedContent = chunk.text + hasStreamedContentDeltas = true + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: aguiState.messageId, + model: model || options.model, + timestamp: Date.now(), + delta: chunk.text, + content: accumulatedContent, + } + } + // Handle reasoning deltas if (chunk.type === 'response.reasoning_text.delta' && chunk.delta) { const reasoningDelta = Array.isArray(chunk.delta) @@ -1355,7 +1385,7 @@ export class OpenRouterResponsesTextAdapter< ? responseObj.output : [] - const completedText = outputItems + const outputItemText = outputItems .flatMap((item) => item.type === 'message' && Array.isArray(item.content) ? item.content @@ -1364,6 +1394,11 @@ export class OpenRouterResponsesTextAdapter< .filter((part) => part.type === 'output_text') .map((part) => part.text) .join('') + const completedText = + typeof responseObj.outputText === 'string' && + responseObj.outputText.length > 0 + ? responseObj.outputText + : outputItemText if (accumulatedContent.length === 0 && completedText.length > 0) { if (!hasEmittedTextMessageStart) { @@ -1909,6 +1944,7 @@ function camelCaseResponseShape( const out: Record = { ...src } if ('incomplete_details' in src) out.incompleteDetails = src.incomplete_details + if ('output_text' in src) out.outputText = src.output_text if ( 'input_tokens' in src || 'output_tokens' in src || diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 3b49656c9..ea8d45a20 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -711,6 +711,133 @@ describe('OpenRouter responses adapter — stream event bridge', () => { expect(finishedIndex).toBeGreaterThan(endIndex) }) + it('recovers final text from response.output_text.done when no delta was streamed', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_text.done', + sequenceNumber: 1, + outputIndex: 0, + contentIndex: 0, + itemId: 'msg_1', + logprobs: [], + text: 'Recovered done-event answer', + }, + { + type: 'response.completed', + sequenceNumber: 2, + response: { + model: 'm', + output: [], + usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: adapter.model, + messages: [{ role: 'user', content: 'Answer carefully' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + expect( + chunks.filter((chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT), + ).toEqual([ + expect.objectContaining({ + delta: 'Recovered done-event answer', + content: 'Recovered done-event answer', + }), + ]) + }) + + it('recovers final text from response.completed outputText', async () => { + setupMockSdkClient([ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.completed', + sequenceNumber: 1, + response: { + model: 'm', + output: [], + outputText: 'Recovered outputText answer', + usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: adapter.model, + messages: [{ role: 'user', content: 'Answer carefully' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + expect( + chunks.filter((chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT), + ).toEqual([ + expect.objectContaining({ + delta: 'Recovered outputText answer', + content: 'Recovered outputText answer', + }), + ]) + }) + + it('recovers final text from raw response.completed output_text', async () => { + setupMockSdkClient([ + { + isUnknown: true, + raw: { + type: 'response.completed', + sequence_number: 1, + response: { + model: 'm', + output: [], + output_text: 'Recovered raw output_text answer', + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + }, + }, + }, + }, + ]) + const adapter = createAdapter() + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + model: adapter.model, + messages: [{ role: 'user', content: 'Answer carefully' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + expect( + chunks.filter((chunk) => chunk.type === EventType.TEXT_MESSAGE_CONTENT), + ).toEqual([ + expect.objectContaining({ + delta: 'Recovered raw output_text answer', + content: 'Recovered raw output_text answer', + }), + ]) + }) + it('routes function-call args through TOOL_CALL_START/ARGS/END', async () => { setupMockSdkClient([ {