From d30c8dfcd4cba3823ce0a5b80a3909f8cf09425d Mon Sep 17 00:00:00 2001 From: Brendan McMullen Date: Wed, 3 Jun 2026 22:21:03 -0700 Subject: [PATCH 1/2] track open ai response format tool calls --- .../codex/src/exchange-projector.js | 147 ++++++++++++--- test/plugins/codex-exchange-projector.test.js | 168 ++++++++++++++++++ 2 files changed, 292 insertions(+), 23 deletions(-) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 9bd8589..ff3e8f5 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -270,6 +270,9 @@ function openAiResponsesMessages(reqBody, responseBody, streamEvents) { } /** + * Mirror `codex/src/backfill.js`: fan items out so each `function_call` / + * `function_call_output` becomes its own projected message. + * * @param {unknown} input * @returns {AiGatewayProjectedMessage[]} */ @@ -283,6 +286,17 @@ function responsesInputMessages(input) { const out = [] for (const item of input) { if (!isPlainObject(item)) continue + const itemType = stringValue(item.type) + if (itemType === 'function_call' || itemType === 'custom_tool_call') { + const block = toolUseBlockFromPayload(item) + if (block) out.push({ role: 'assistant', content: [block] }) + continue + } + if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') { + const block = toolResultBlockFromPayload(item) + if (block) out.push({ role: 'tool', content: [block] }) + continue + } const role = stringValue(item.role) ?? 'user' const blocks = openAiContentBlocks(item.content) if (blocks.length === 0) continue @@ -314,29 +328,34 @@ function openAiContentBlocks(content) { */ function responsesAssistantFromBody(responseBody) { if (!isPlainObject(responseBody)) return undefined - const outputText = stringValue(responseBody.output_text) - if (outputText) { - return { role: 'assistant', content: [{ type: 'text', text: outputText }] } - } - const output = Array.isArray(responseBody.output) ? responseBody.output : [] /** @type {JsonObject[]} */ const content = [] + const output = Array.isArray(responseBody.output) ? responseBody.output : [] for (const item of output) { if (!isPlainObject(item)) continue - if (item.type === 'message' || item.role === 'assistant') { + const itemType = stringValue(item.type) + if (itemType === 'function_call' || itemType === 'custom_tool_call') { + const block = toolUseBlockFromPayload(item) + if (block) content.push(block) + } else if (itemType === 'message' || item.role === 'assistant') { content.push(...openAiContentBlocks(item.content)) } } + // Fall back to `output_text` only when output[] gave us no message text. + const hasText = content.some((block) => stringValue(block.type) === 'text') + if (!hasText) { + const outputText = stringValue(responseBody.output_text) + if (outputText) content.unshift({ type: 'text', text: outputText }) + } if (content.length === 0) return undefined return { role: 'assistant', content } } /** - * Stitch a streamed OpenAI Responses assistant message back together - * from the captured SSE events. Each `response.output_text.delta` - * carries a `delta` token; `response.completed` may also carry the - * full final text as a snapshot, which we treat as a fallback when no - * deltas arrived. + * Stitch a streamed Responses assistant message from SSE events. When + * `response.completed` arrives, its body is preferred for text and tool + * calls; any streamed tool_use not present in that body is appended so a + * truncated completed body cannot silently drop a captured call. * * @param {Array<{ event: string, data: string }>} streamEvents * @returns {AiGatewayProjectedMessage | undefined} @@ -345,6 +364,10 @@ function responsesAssistantFromStream(streamEvents) { let text = '' /** @type {string | undefined} */ let responseId + /** @type {Map} */ + const toolUsesByCallId = new Map() + /** @type {AiGatewayProjectedMessage | undefined} */ + let completedMessage for (const row of streamEvents) { const payload = parseEventData(row.data) if (!isPlainObject(payload)) continue @@ -352,15 +375,18 @@ function responsesAssistantFromStream(streamEvents) { if (type === 'response.output_text.delta' || type === 'response.output_text.annotation.added') { const delta = stringValue(payload.delta) if (delta) text += delta + } else if (type === 'response.output_item.done') { + const item = isPlainObject(payload.item) ? payload.item : undefined + if (item) { + const block = toolUseBlockFromPayload(item) + if (block) { + const id = stringValue(block.id) + if (id && !toolUsesByCallId.has(id)) toolUsesByCallId.set(id, block) + } + } } else if (type === 'response.completed') { const response = isPlainObject(payload.response) ? payload.response : payload - const completed = responsesAssistantFromBody(response) - if (!text && completed) { - const completedText = textFromBlocks(/** @type {JsonObject[]} */ ( - Array.isArray(completed.content) ? completed.content : [] - )) - if (completedText) text = completedText - } + completedMessage = responsesAssistantFromBody(response) const maybeId = stringValue(payload.id) ?? stringValue(/** @type {Record} */ (response).id) if (maybeId) responseId = maybeId } else if (type === 'response.created' && !responseId) { @@ -369,12 +395,30 @@ function responsesAssistantFromStream(streamEvents) { if (maybeId) responseId = maybeId } } - if (!text) return undefined - /** @type {AiGatewayProjectedMessage} */ - const message = { role: 'assistant', content: [{ type: 'text', text }] } - if (responseId) { - message.raw_frame = { response_id: responseId } + /** @type {JsonObject[]} */ + let content + if (completedMessage && Array.isArray(completedMessage.content) && completedMessage.content.length > 0) { + content = [.../** @type {JsonObject[]} */ (completedMessage.content)] + /** @type {Set} */ + const seenIds = new Set() + for (const block of content) { + if (stringValue(block.type) !== 'tool_use') continue + const id = stringValue(block.id) + if (id) seenIds.add(id) + } + for (const block of toolUsesByCallId.values()) { + const id = stringValue(block.id) + if (id && !seenIds.has(id)) content.push(block) + } + } else { + content = [] + if (text) content.push({ type: 'text', text }) + for (const block of toolUsesByCallId.values()) content.push(block) } + if (content.length === 0) return undefined + /** @type {AiGatewayProjectedMessage} */ + const message = { role: 'assistant', content } + if (responseId) message.raw_frame = { response_id: responseId } return message } @@ -696,6 +740,63 @@ function firstChoice(responseBody) { return isPlainObject(choice) ? choice : undefined } +/** + * Mirror `codex/src/backfill.js` so live-captured tool calls land in the + * same shape as backfilled ones. + * + * @param {Record} payload + * @returns {JsonObject | undefined} + */ +function toolUseBlockFromPayload(payload) { + const name = stringValue(payload.name) + const callId = stringValue(payload.call_id) ?? stringValue(payload.id) + if (!name || !callId) return undefined + const rawArgs = payload.arguments !== undefined ? payload.arguments : payload.input + return { type: 'tool_use', id: callId, name, input: normalizeToolInput(rawArgs) } +} + +/** + * @param {Record} payload + * @returns {JsonObject | undefined} + */ +function toolResultBlockFromPayload(payload) { + const callId = stringValue(payload.call_id) ?? stringValue(payload.id) + if (!callId) return undefined + const text = toolOutputText(payload.output) + /** @type {JsonObject} */ + const block = { type: 'tool_result', tool_use_id: callId } + if (text !== undefined) block.content = text + return block +} + +/** @param {unknown} value */ +function normalizeToolInput(value) { + if (typeof value === 'string') { + const parsed = parseMaybeJson(value) + return parsed === value ? value : /** @type {any} */ (parsed) + } + if (value === undefined) return null + return /** @type {any} */ (value) +} + +/** + * Codex tool output can arrive as a string, a `{ output | content | text }` + * wrapper, or a structured payload — fall back to JSON.stringify so the + * row keeps a faithful trace. + * + * @param {unknown} output + * @returns {string | undefined} + */ +function toolOutputText(output) { + if (typeof output === 'string') return output.length > 0 ? output : undefined + if (isPlainObject(output)) { + const inner = stringValue(output.output) ?? stringValue(output.content) ?? stringValue(output.text) + return inner ?? JSON.stringify(output) + } + if (output === undefined || output === null) return undefined + return JSON.stringify(output) +} + /** @param {JsonObject[]} blocks */ function textFromBlocks(blocks) { const parts = blocks diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index bca4b73..57248f3 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -128,6 +128,174 @@ test('OpenAI Responses SSE deltas reconstruct the assistant body', () => { assert.deepEqual(projection.messages[1].raw_frame, { response_id: 'resp_2' }) }) +test('OpenAI Responses function_call in input becomes an assistant tool_use message', () => { + const projector = createCodexExchangeProjector({ env: {} }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + provider: 'openai', + request_body: JSON.stringify({ + model: 'gpt-5', + input: [ + { role: 'user', content: [{ type: 'input_text', text: 'ls please' }] }, + { + type: 'function_call', + call_id: 'call_abc', + name: 'exec_command', + arguments: '{"cmd":"ls"}', + }, + { + type: 'function_call_output', + call_id: 'call_abc', + output: 'a.txt\nb.txt', + }, + ], + }), + response_body: JSON.stringify({ id: 'resp_1', output_text: 'done' }), + }), context())) + + assert.deepEqual( + projection.messages.map((/** @type {any} */ m) => m.role), + ['user', 'assistant', 'tool', 'assistant'] + ) + const toolUse = projection.messages[1].content[0] + assert.equal(toolUse.type, 'tool_use') + assert.equal(toolUse.id, 'call_abc') + assert.equal(toolUse.name, 'exec_command') + assert.deepEqual(toolUse.input, { cmd: 'ls' }) + const toolResult = projection.messages[2].content[0] + assert.equal(toolResult.type, 'tool_result') + assert.equal(toolResult.tool_use_id, 'call_abc') + assert.equal(toolResult.content, 'a.txt\nb.txt') +}) + +test('OpenAI Responses custom_tool_call uses payload.input when arguments is missing', () => { + const projector = createCodexExchangeProjector({ env: {} }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + provider: 'openai', + request_body: JSON.stringify({ + model: 'gpt-5', + input: [ + { + type: 'custom_tool_call', + call_id: 'call_x', + name: 'spawn_agent', + input: 'raw-string-input', + }, + ], + }), + response_body: JSON.stringify({ id: 'resp_2', output_text: 'k' }), + }), context())) + + const toolUse = projection.messages[0].content[0] + assert.equal(toolUse.type, 'tool_use') + assert.equal(toolUse.id, 'call_x') + assert.equal(toolUse.name, 'spawn_agent') + assert.equal(toolUse.input, 'raw-string-input') +}) + +test('OpenAI Responses function_call in response.output becomes a tool_use on the assistant message', () => { + const projector = createCodexExchangeProjector({ env: {} }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + provider: 'openai', + request_body: JSON.stringify({ + model: 'gpt-5', + input: [{ role: 'user', content: [{ type: 'input_text', text: 'list files' }] }], + }), + response_body: JSON.stringify({ + id: 'resp_3', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'on it' }] }, + { + type: 'function_call', + call_id: 'call_42', + name: 'exec_command', + arguments: '{"cmd":"ls"}', + }, + ], + }), + }), context())) + + assert.deepEqual( + projection.messages.map((/** @type {any} */ m) => m.role), + ['user', 'assistant'] + ) + const assistantContent = projection.messages[1].content + assert.equal(assistantContent.length, 2) + assert.deepEqual(assistantContent[0], { type: 'text', text: 'on it' }) + assert.equal(assistantContent[1].type, 'tool_use') + assert.equal(assistantContent[1].id, 'call_42') + assert.equal(assistantContent[1].name, 'exec_command') + assert.deepEqual(assistantContent[1].input, { cmd: 'ls' }) +}) + +test('OpenAI Responses SSE captures tool_use from response.output_item.done', () => { + const projector = createCodexExchangeProjector({ env: {} }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + is_sse: true, + request_body: JSON.stringify({ + model: 'gpt-5', + input: [{ role: 'user', content: [{ type: 'input_text', text: 'go' }] }], + }), + response_body: '', + stream_events: [ + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 0, event: 'response.created', data: JSON.stringify({ id: 'resp_4', type: 'response.created' }) }, + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 1, event: 'response.output_text.delta', data: JSON.stringify({ type: 'response.output_text.delta', delta: 'sure' }) }, + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 2, event: 'response.output_item.done', data: JSON.stringify({ + type: 'response.output_item.done', + item: { type: 'function_call', call_id: 'call_stream', name: 'exec_command', arguments: '{"cmd":"pwd"}' }, + }) }, + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 3, event: 'response.completed', data: JSON.stringify({ type: 'response.completed', id: 'resp_4', status: 'completed' }) }, + ], + }), context())) + + const assistantContent = projection.messages[1].content + // No body in response.completed → use streamed accumulators (text + tool_use). + assert.deepEqual(assistantContent[0], { type: 'text', text: 'sure' }) + assert.equal(assistantContent[1].type, 'tool_use') + assert.equal(assistantContent[1].id, 'call_stream') + assert.equal(assistantContent[1].name, 'exec_command') + assert.deepEqual(assistantContent[1].input, { cmd: 'pwd' }) + assert.deepEqual(projection.messages[1].raw_frame, { response_id: 'resp_4' }) +}) + +test('OpenAI Responses SSE prefers full response.completed body when present', () => { + const projector = createCodexExchangeProjector({ env: {} }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + is_sse: true, + request_body: JSON.stringify({ + model: 'gpt-5', + input: [{ role: 'user', content: [{ type: 'input_text', text: 'go' }] }], + }), + response_body: '', + stream_events: [ + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 0, event: 'response.created', data: JSON.stringify({ id: 'resp_5', type: 'response.created' }) }, + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 1, event: 'response.output_text.delta', data: JSON.stringify({ type: 'response.output_text.delta', delta: 'ignored' }) }, + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 2, event: 'response.completed', data: JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp_5', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'final' }] }, + { type: 'function_call', call_id: 'call_body', name: 'apply_patch', arguments: '{"path":"x"}' }, + ], + }, + }) }, + ], + }), context())) + + const assistantContent = projection.messages[1].content + // Completed body is authoritative — discard streamed text. + assert.deepEqual(assistantContent[0], { type: 'text', text: 'final' }) + assert.equal(assistantContent[1].type, 'tool_use') + assert.equal(assistantContent[1].id, 'call_body') + assert.equal(assistantContent[1].name, 'apply_patch') + assert.deepEqual(assistantContent[1].input, { path: 'x' }) +}) + test('Codex turn metadata + headers project into first-class columns and codex.* attributes', () => { const projector = createCodexExchangeProjector({ env: {} }) const workspace = '/home/me/workspace' From b482bfc03fa77b1cd9fc7d0a2d77a0367d61da08 Mon Sep 17 00:00:00 2001 From: Brendan McMullen Date: Thu, 4 Jun 2026 00:13:28 -0700 Subject: [PATCH 2/2] fix review bugs --- .../codex/src/exchange-projector.js | 104 ++++++++------ test/plugins/codex-exchange-projector.test.js | 134 +++++++++++++++--- 2 files changed, 172 insertions(+), 66 deletions(-) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index ff3e8f5..d9b078f 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -263,9 +263,9 @@ function openAiChatMessageToProjected(message) { function openAiResponsesMessages(reqBody, responseBody, streamEvents) { /** @type {AiGatewayProjectedMessage[]} */ const messages = responsesInputMessages(reqBody.input) - const assistant = responsesAssistantFromBody(responseBody) - ?? responsesAssistantFromStream(streamEvents) - if (assistant) messages.push(assistant) + let assistant = responsesAssistantMessagesFromBody(responseBody) + if (assistant.length === 0) assistant = responsesAssistantMessagesFromStream(streamEvents) + for (const msg of assistant) messages.push(msg) return messages } @@ -323,51 +323,59 @@ function openAiContentBlocks(content) { } /** + * Fan out response `output[]` items so each becomes its own assistant + * message — same per-item shape `responsesInputMessages` produces for + * replayed input items, so turn-1 response rows hash equal to turn-2 + * input rows in the kernel's content-hash dedupe. + * * @param {unknown} responseBody - * @returns {AiGatewayProjectedMessage | undefined} + * @returns {AiGatewayProjectedMessage[]} */ -function responsesAssistantFromBody(responseBody) { - if (!isPlainObject(responseBody)) return undefined - /** @type {JsonObject[]} */ - const content = [] +function responsesAssistantMessagesFromBody(responseBody) { + if (!isPlainObject(responseBody)) return [] + /** @type {AiGatewayProjectedMessage[]} */ + const out = [] + let sawMessage = false const output = Array.isArray(responseBody.output) ? responseBody.output : [] for (const item of output) { if (!isPlainObject(item)) continue const itemType = stringValue(item.type) if (itemType === 'function_call' || itemType === 'custom_tool_call') { const block = toolUseBlockFromPayload(item) - if (block) content.push(block) + if (block) out.push({ role: 'assistant', content: [block] }) } else if (itemType === 'message' || item.role === 'assistant') { - content.push(...openAiContentBlocks(item.content)) + const blocks = openAiContentBlocks(item.content) + if (blocks.length > 0) { + out.push({ role: 'assistant', content: blocks }) + sawMessage = true + } } } - // Fall back to `output_text` only when output[] gave us no message text. - const hasText = content.some((block) => stringValue(block.type) === 'text') - if (!hasText) { + if (!sawMessage) { const outputText = stringValue(responseBody.output_text) - if (outputText) content.unshift({ type: 'text', text: outputText }) + if (outputText) out.unshift({ role: 'assistant', content: [{ type: 'text', text: outputText }] }) } - if (content.length === 0) return undefined - return { role: 'assistant', content } + return out } /** - * Stitch a streamed Responses assistant message from SSE events. When - * `response.completed` arrives, its body is preferred for text and tool - * calls; any streamed tool_use not present in that body is appended so a - * truncated completed body cannot silently drop a captured call. + * Stitch streamed Responses assistant messages from SSE events. When + * `response.completed` arrives, its body is preferred (already per-item + * via `responsesAssistantMessagesFromBody`); streamed text and tool_uses + * not represented there are merged in so a truncated completed body + * cannot silently drop captured content. * * @param {Array<{ event: string, data: string }>} streamEvents - * @returns {AiGatewayProjectedMessage | undefined} + * @returns {AiGatewayProjectedMessage[]} */ -function responsesAssistantFromStream(streamEvents) { +function responsesAssistantMessagesFromStream(streamEvents) { let text = '' /** @type {string | undefined} */ let responseId /** @type {Map} */ const toolUsesByCallId = new Map() - /** @type {AiGatewayProjectedMessage | undefined} */ - let completedMessage + /** @type {AiGatewayProjectedMessage[]} */ + let completedMessages = [] for (const row of streamEvents) { const payload = parseEventData(row.data) if (!isPlainObject(payload)) continue @@ -386,7 +394,7 @@ function responsesAssistantFromStream(streamEvents) { } } else if (type === 'response.completed') { const response = isPlainObject(payload.response) ? payload.response : payload - completedMessage = responsesAssistantFromBody(response) + completedMessages = responsesAssistantMessagesFromBody(response) const maybeId = stringValue(payload.id) ?? stringValue(/** @type {Record} */ (response).id) if (maybeId) responseId = maybeId } else if (type === 'response.created' && !responseId) { @@ -395,31 +403,41 @@ function responsesAssistantFromStream(streamEvents) { if (maybeId) responseId = maybeId } } - /** @type {JsonObject[]} */ - let content - if (completedMessage && Array.isArray(completedMessage.content) && completedMessage.content.length > 0) { - content = [.../** @type {JsonObject[]} */ (completedMessage.content)] + /** @type {AiGatewayProjectedMessage[]} */ + let messages + if (completedMessages.length > 0) { + messages = [...completedMessages] /** @type {Set} */ - const seenIds = new Set() - for (const block of content) { - if (stringValue(block.type) !== 'tool_use') continue - const id = stringValue(block.id) - if (id) seenIds.add(id) + const seenCallIds = new Set() + let hasTextMessage = false + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue + for (const block of msg.content) { + const blockType = stringValue(block.type) + if (blockType === 'text') hasTextMessage = true + if (blockType === 'tool_use') { + const id = stringValue(block.id) + if (id) seenCallIds.add(id) + } + } + } + if (!hasTextMessage && text) { + messages.unshift({ role: 'assistant', content: [{ type: 'text', text }] }) } for (const block of toolUsesByCallId.values()) { const id = stringValue(block.id) - if (id && !seenIds.has(id)) content.push(block) + if (id && !seenCallIds.has(id)) messages.push({ role: 'assistant', content: [block] }) } } else { - content = [] - if (text) content.push({ type: 'text', text }) - for (const block of toolUsesByCallId.values()) content.push(block) + messages = [] + if (text) messages.push({ role: 'assistant', content: [{ type: 'text', text }] }) + for (const block of toolUsesByCallId.values()) messages.push({ role: 'assistant', content: [block] }) } - if (content.length === 0) return undefined - /** @type {AiGatewayProjectedMessage} */ - const message = { role: 'assistant', content } - if (responseId) message.raw_frame = { response_id: responseId } - return message + if (messages.length === 0) return [] + if (responseId) { + for (const msg of messages) msg.raw_frame = { ...msg.raw_frame, response_id: responseId } + } + return messages } // --------------------------------------------------------------------- diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 57248f3..b99abe4 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -194,7 +194,7 @@ test('OpenAI Responses custom_tool_call uses payload.input when arguments is mis assert.equal(toolUse.input, 'raw-string-input') }) -test('OpenAI Responses function_call in response.output becomes a tool_use on the assistant message', () => { +test('OpenAI Responses fans out response.output items into per-item assistant messages', () => { const projector = createCodexExchangeProjector({ env: {} }) const projection = /** @type {any} */ (projector.project(exchange({ path: '/v1/responses', @@ -217,17 +217,59 @@ test('OpenAI Responses function_call in response.output becomes a tool_use on th }), }), context())) + // Each output[] item becomes its own assistant message so it hashes the + // same as a turn-2 replay (where input items are fanned out too). assert.deepEqual( projection.messages.map((/** @type {any} */ m) => m.role), - ['user', 'assistant'] + ['user', 'assistant', 'assistant'] ) - const assistantContent = projection.messages[1].content - assert.equal(assistantContent.length, 2) - assert.deepEqual(assistantContent[0], { type: 'text', text: 'on it' }) - assert.equal(assistantContent[1].type, 'tool_use') - assert.equal(assistantContent[1].id, 'call_42') - assert.equal(assistantContent[1].name, 'exec_command') - assert.deepEqual(assistantContent[1].input, { cmd: 'ls' }) + assert.deepEqual(projection.messages[1].content, [{ type: 'text', text: 'on it' }]) + const toolUse = projection.messages[2].content[0] + assert.equal(toolUse.type, 'tool_use') + assert.equal(toolUse.id, 'call_42') + assert.equal(toolUse.name, 'exec_command') + assert.deepEqual(toolUse.input, { cmd: 'ls' }) +}) + +test('OpenAI Responses turn-1 response shape matches turn-2 input replay shape (dedupe)', () => { + const projector = createCodexExchangeProjector({ env: {} }) + // Turn 1: assistant emits text + a function_call as response output. + const turn1 = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + provider: 'openai', + request_body: JSON.stringify({ + model: 'gpt-5', + input: [{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] }], + }), + response_body: JSON.stringify({ + id: 'resp_a', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'on it' }] }, + { type: 'function_call', call_id: 'call_z', name: 'exec', arguments: '{"x":1}' }, + ], + }), + }), context())) + + // Turn 2: same output items now arrive as input replay (plus a tool result and follow-up). + const turn2 = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + provider: 'openai', + request_body: JSON.stringify({ + model: 'gpt-5', + input: [ + { role: 'user', content: [{ type: 'input_text', text: 'hi' }] }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'on it' }] }, + { type: 'function_call', call_id: 'call_z', name: 'exec', arguments: '{"x":1}' }, + { type: 'function_call_output', call_id: 'call_z', output: 'ok' }, + ], + }), + response_body: JSON.stringify({ id: 'resp_b', output_text: 'done' }), + }), context())) + + // Turn 1's assistant text + tool_use must match turn 2's replayed input items + // block-for-block — that's what makes content-hash dedupe collapse them. + assert.deepEqual(turn1.messages[1].content, turn2.messages[1].content) + assert.deepEqual(turn1.messages[2].content, turn2.messages[2].content) }) test('OpenAI Responses SSE captures tool_use from response.output_item.done', () => { @@ -251,14 +293,19 @@ test('OpenAI Responses SSE captures tool_use from response.output_item.done', () ], }), context())) - const assistantContent = projection.messages[1].content - // No body in response.completed → use streamed accumulators (text + tool_use). - assert.deepEqual(assistantContent[0], { type: 'text', text: 'sure' }) - assert.equal(assistantContent[1].type, 'tool_use') - assert.equal(assistantContent[1].id, 'call_stream') - assert.equal(assistantContent[1].name, 'exec_command') - assert.deepEqual(assistantContent[1].input, { cmd: 'pwd' }) + // No body in response.completed → use streamed accumulators, fanned out. + assert.deepEqual( + projection.messages.map((/** @type {any} */ m) => m.role), + ['user', 'assistant', 'assistant'] + ) + assert.deepEqual(projection.messages[1].content, [{ type: 'text', text: 'sure' }]) + const toolUse = projection.messages[2].content[0] + assert.equal(toolUse.type, 'tool_use') + assert.equal(toolUse.id, 'call_stream') + assert.equal(toolUse.name, 'exec_command') + assert.deepEqual(toolUse.input, { cmd: 'pwd' }) assert.deepEqual(projection.messages[1].raw_frame, { response_id: 'resp_4' }) + assert.deepEqual(projection.messages[2].raw_frame, { response_id: 'resp_4' }) }) test('OpenAI Responses SSE prefers full response.completed body when present', () => { @@ -287,13 +334,54 @@ test('OpenAI Responses SSE prefers full response.completed body when present', ( ], }), context())) - const assistantContent = projection.messages[1].content - // Completed body is authoritative — discard streamed text. - assert.deepEqual(assistantContent[0], { type: 'text', text: 'final' }) - assert.equal(assistantContent[1].type, 'tool_use') - assert.equal(assistantContent[1].id, 'call_body') - assert.equal(assistantContent[1].name, 'apply_patch') - assert.deepEqual(assistantContent[1].input, { path: 'x' }) + // Completed body is authoritative and is already fanned out per-item; + // streamed 'ignored' text is dropped because the message item supplied text. + assert.deepEqual( + projection.messages.map((/** @type {any} */ m) => m.role), + ['user', 'assistant', 'assistant'] + ) + assert.deepEqual(projection.messages[1].content, [{ type: 'text', text: 'final' }]) + const toolUse = projection.messages[2].content[0] + assert.equal(toolUse.type, 'tool_use') + assert.equal(toolUse.id, 'call_body') + assert.equal(toolUse.name, 'apply_patch') + assert.deepEqual(toolUse.input, { path: 'x' }) +}) + +test('OpenAI Responses SSE merges streamed text into a tool-only completed body', () => { + const projector = createCodexExchangeProjector({ env: {} }) + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + is_sse: true, + request_body: JSON.stringify({ + model: 'gpt-5', + input: [{ role: 'user', content: [{ type: 'input_text', text: 'go' }] }], + }), + response_body: '', + stream_events: [ + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 0, event: 'response.created', data: JSON.stringify({ id: 'resp_6', type: 'response.created' }) }, + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 1, event: 'response.output_text.delta', data: JSON.stringify({ type: 'response.output_text.delta', delta: 'thinking out loud' }) }, + { kind: 'stream_event', exchange_id: 'ex-1', t_ms: 2, event: 'response.completed', data: JSON.stringify({ + type: 'response.completed', + response: { + id: 'resp_6', + output: [ + { type: 'function_call', call_id: 'call_only', name: 'apply_patch', arguments: '{"path":"x"}' }, + ], + }, + }) }, + ], + }), context())) + + // Completed body had only a function_call; streamed text is preserved as + // its own message so dedupe matches a future replay. + assert.deepEqual( + projection.messages.map((/** @type {any} */ m) => m.role), + ['user', 'assistant', 'assistant'] + ) + assert.deepEqual(projection.messages[1].content, [{ type: 'text', text: 'thinking out loud' }]) + assert.equal(projection.messages[2].content[0].type, 'tool_use') + assert.equal(projection.messages[2].content[0].id, 'call_only') }) test('Codex turn metadata + headers project into first-class columns and codex.* attributes', () => {