diff --git a/lib/server/proxy/image-generation.ts b/lib/server/proxy/image-generation.ts index fabcf2f..a6d473e 100644 --- a/lib/server/proxy/image-generation.ts +++ b/lib/server/proxy/image-generation.ts @@ -23,10 +23,12 @@ import { getCodeBuddyApiEndpoint } from '../domain/config'; import type { ProxyContext } from './codebuddy'; import { buildUpstreamHeaders } from './codebuddy'; import { + getServerToolExecutions, withIntermediateTurns, type ChatCompletionMessage, type ChatCompletionPayload, type ChatCompletionToolCall, + type ServerToolExecution, } from './web-search-loop'; export const IMAGE_GENERATION_TOOL_TYPE = 'image_generation'; @@ -306,19 +308,38 @@ export interface ImageGenerationLoopResult { /** One entry per image call the model made, in call order. */ executions: ImageGenerationExecution[]; response: Response; + /** + * Server-tool executions the upstream reported, carried explicitly. + * + * They cannot be read back off `response`: the loop rebuilds it, and + * `getServerToolExecutions` keys on `Response` identity, so a rebuilt + * response looks like a turn that ran no tools at all. + */ + serverToolExecutions: ServerToolExecution[]; } /** * Rebuilds a response whose body was already read. The loop consumes each * response to inspect its tool calls, so anything handed back has to be * reconstructed from the text that was read. + * + * Upstream framing headers are dropped rather than copied: they describe the + * original body, which has since been decoded and re-serialized to a different + * length. Keeping `content-length` truncates the new body and keeping + * `content-encoding: gzip` makes a client try to decompress plaintext. */ const rebuildResponse = ( response: Response, payload: ChatCompletionPayload, ): Response => { + const headers = new Headers(response.headers); + + headers.delete('content-encoding'); + headers.delete('content-length'); + headers.delete('transfer-encoding'); + return new Response(JSON.stringify(payload), { - headers: response.headers, + headers, status: response.status, }); }; @@ -330,6 +351,53 @@ const readMessageText = ( return typeof message?.content === 'string' ? message.content.trim() : ''; }; +/** + * Drops what the closing hop contributed to a payload whose image calls have + * already been executed. + * + * Used when the iteration cap ends the loop: that hop's image calls became + * `image_generation_call` items rather than staying callable, and its prose is + * already carried in the intermediate texts the payload is folded with. Keeping + * either would report a `function_call` for work already done and repeat the + * text. + * + * + * Only image calls are removed. A hop can also carry calls this loop never + * runs — a client-declared function, say — and those still belong to the + * client to resolve, so dropping them would silently abandon the request. + * + * Exported for its own tests: the interesting cases are hard to reach through + * the loop, which only calls this on a payload it has already inspected. + */ +export const clearClosingHop = ( + payload: ChatCompletionPayload, +): ChatCompletionPayload => { + const [first, ...rest] = payload.choices ?? []; + + if (!first) { + return payload; + } + + const message = first.message ?? {}; + + return { + ...payload, + choices: [ + { + ...first, + message: { + ...message, + content: null, + tool_calls: (message.tool_calls ?? []).filter( + (toolCall) => !isImageGenerationToolCall(toolCall), + ), + }, + }, + ...rest, + ], + }; +}; + const buildImageGenerationExecution = ({ id, prompt, @@ -365,6 +433,7 @@ export const executeImageGenerationLoop = async ({ let currentBody: Record = body; const executions: ImageGenerationExecution[] = []; const intermediateTexts: string[] = []; + const serverToolExecutions: ServerToolExecution[] = []; // Carried across iterations so the cap can hand back the last response // instead of discarding every image already generated. let lastResponse: Response | null = null; @@ -373,6 +442,11 @@ export const executeImageGenerationLoop = async ({ for (let iteration = 0; iteration < MAX_IMAGE_ITERATIONS; iteration += 1) { const response = await callUpstream(currentBody); + // Executions have to be read here, off the response the upstream produced: + // they are keyed on `Response` identity, and every path below hands back a + // rebuilt response the caller can no longer look them up on. + serverToolExecutions.push(...getServerToolExecutions(response)); + // A stream has already begun emitting to the client, so it cannot be // resumed with a tool result; hand it back untouched. if ( @@ -381,7 +455,7 @@ export const executeImageGenerationLoop = async ({ ?.toLowerCase() .includes('text/event-stream') ) { - return { executions, response }; + return { executions, response, serverToolExecutions }; } const payloadText = await response.text(); @@ -397,6 +471,7 @@ export const executeImageGenerationLoop = async ({ headers: response.headers, status: response.status, }), + serverToolExecutions, }; } @@ -421,6 +496,7 @@ export const executeImageGenerationLoop = async ({ texts: intermediateTexts, }).payload, ), + serverToolExecutions, }; } @@ -473,17 +549,24 @@ export const executeImageGenerationLoop = async ({ // The cap was reached with the model still asking for images. Every image // generated so far is kept, and the last response is handed back so the // caller does not re-issue the request and discard them. + // + // The closing hop is folded through `clearClosingHop` first: its prose is + // already in `intermediateTexts`, and its calls were executed above and are + // already reported as `image_generation_call` items. Leaving either in the + // payload would repeat the prose and hand the client a `function_call` for a + // call that has already run. return { executions, response: rebuildResponse( lastResponse ?? new Response(null, { status: 502 }), withIntermediateTurns({ executions: [], - payload: lastPayload ?? {}, + payload: clearClosingHop(lastPayload ?? {}), reasonings: [], texts: intermediateTexts, }).payload, ), + serverToolExecutions, }; }; diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 230f556..7414b3d 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -1473,6 +1473,10 @@ const buildResponsesWebSearchCallItem = ( * executed locally, so the call cannot be forwarded before it is seen. The * client still asked for `stream: true`, so the buffered result is replayed as * the same event sequence a live stream would have produced. + * + * The text is replayed as delta events rather than arriving whole in + * `response.completed`: a client that renders as it reads subscribes to deltas + * and would otherwise show nothing until the turn ends. */ const mapChatResponseToResponsesStream = async ( upstreamPayload: Record, @@ -1482,6 +1486,7 @@ const mapChatResponseToResponsesStream = async ( previousResponseId: string | null, proxyContext: ProxyContext, imageExecutions: ImageGenerationExecution[], + serverToolExecutions: ServerToolExecution[] = [], ): Promise => { const payload = await mapChatResponseToResponsesPayload( proxyContext.accessKeyId, @@ -1491,7 +1496,7 @@ const mapChatResponseToResponsesStream = async ( model, previousResponseId, upstreamPayload, - [], + serverToolExecutions, imageExecutions, ); // The mapper creates and persists the session id, so the stream has to reuse @@ -1499,8 +1504,69 @@ const mapChatResponseToResponsesStream = async ( // turn, because nothing was stored under the id it was given. const responseId = String(payload.id); const output = payload.output as Array>; + const messageIndex = output.findIndex((item) => item.type === 'message'); + const messageItem = + messageIndex === -1 + ? null + : (output[messageIndex] as { + content?: Array<{ text?: string }>; + id?: string; + }); + const messageText = messageItem?.content?.[0]?.text ?? ''; + const otherItems = output + .map((item, output_index) => ({ item, output_index })) + .filter(({ output_index }) => output_index !== messageIndex); + + // The live path announces a server-tool item as in-progress and narrates its + // lifecycle before closing it, and consumers can subscribe to those events. + // A buffered replay that jumps straight to `done` hides the search entirely + // from a client watching for it. + const serverToolFrames = ({ + item, + output_index, + }: { + item: Record; + output_index: number; + }): Array> => { + const itemId = String(item.id ?? ''); + + if (item.type !== 'web_search_call') { + return [ + { + item, + output_index, + response_id: responseId, + type: 'response.output_item.added', + }, + ]; + } + + return [ + { + item: { ...item, status: 'in_progress' }, + output_index, + response_id: responseId, + type: 'response.output_item.added', + }, + { + item_id: itemId, + output_index, + type: 'response.web_search_call.in_progress', + }, + { + item_id: itemId, + output_index, + type: 'response.web_search_call.searching', + }, + { + item_id: itemId, + output_index, + type: 'response.web_search_call.completed', + }, + ]; + }; - const frames = [ + const frames: Array> = [ { response: { ...payload, output: [], status: 'in_progress' }, type: 'response.created', @@ -1509,21 +1575,57 @@ const mapChatResponseToResponsesStream = async ( response: { id: responseId, status: 'in_progress' }, type: 'response.in_progress', }, - ...output.map((item, output_index) => ({ - item, - output_index, - response_id: responseId, - type: 'response.output_item.added', - })), - ...output.map((item, output_index) => ({ + ...otherItems.flatMap(({ item, output_index }) => + serverToolFrames({ item, output_index }), + ), + ...otherItems.map(({ item, output_index }) => ({ item, output_index, response_id: responseId, type: 'response.output_item.done', })), - { response: { ...payload, id: responseId }, type: 'response.completed' }, ]; + // Mirrors the live path: the message item is announced, filled by deltas, + // then closed. No `content_part` events — the live path does not emit them. + if (messageItem && messageIndex !== -1) { + frames.push({ + item: { ...messageItem, status: 'in_progress' }, + output_index: messageIndex, + response_id: responseId, + type: 'response.output_item.added', + }); + + if (messageText) { + frames.push({ + delta: messageText, + item_id: messageItem.id, + output_index: messageIndex, + response_id: responseId, + type: 'response.output_text.delta', + }); + frames.push({ + item: messageItem, + output_index: messageIndex, + response_id: responseId, + text: messageText, + type: 'response.output_text.done', + }); + } + + frames.push({ + item: messageItem, + output_index: messageIndex, + response_id: responseId, + type: 'response.output_item.done', + }); + } + + frames.push({ + response: { ...payload, id: responseId }, + type: 'response.completed', + }); + const body = [ ...frames.map( (frame) => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}`, @@ -2205,21 +2307,22 @@ const createResponsesEventStream = async ( // gating on search/fetch would silently skip generation whenever those were // enabled. if (hasImageGenerationTool(defaults.tools)) { - const { executions, response } = await executeImageGenerationLoop({ - body: chatBody, - // Buffered so the tool call can be inspected before any delta reaches - // the client; the ordinary path below stays live. - callUpstream: (loopBody) => - proxyChatCompletions( - request, - { ...loopBody, stream: false } as never, - proxyContext, - debugTrace, - '/v1/responses', - ), - context: proxyContext, - request, - }); + const { executions, response, serverToolExecutions } = + await executeImageGenerationLoop({ + body: chatBody, + // Buffered so the tool call can be inspected before any delta reaches + // the client; the ordinary path below stays live. + callUpstream: (loopBody) => + proxyChatCompletions( + request, + { ...loopBody, stream: false } as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + context: proxyContext, + request, + }); // Always consumed, even when nothing was generated: the loop has already // sent the turn upstream, and re-issuing it would bill twice and could @@ -2236,6 +2339,7 @@ const createResponsesEventStream = async ( previousResponseId, proxyContext, executions, + serverToolExecutions, ); } @@ -2554,8 +2658,6 @@ export const handleResponsesRequest = async ( return compatibilityError; } - prepared.defaults.tools = prepared.defaults.tools; - if (body.stream) { return await createResponsesEventStream( request, @@ -2594,20 +2696,23 @@ export const handleResponsesRequest = async ( // the tool was actually declared; otherwise the loop returns null and the // ordinary upstream call runs. if (hasImageGenerationTool(prepared.defaults.tools)) { - const { executions, response: imageResponse } = - await executeImageGenerationLoop({ - body: chatBody, - callUpstream: (loopBody) => - proxyChatCompletions( - request, - loopBody as never, - proxyContext, - debugTrace, - '/v1/responses', - ), - context: proxyContext, - request, - }); + const { + executions, + response: imageResponse, + serverToolExecutions, + } = await executeImageGenerationLoop({ + body: chatBody, + callUpstream: (loopBody) => + proxyChatCompletions( + request, + loopBody as never, + proxyContext, + debugTrace, + '/v1/responses', + ), + context: proxyContext, + request, + }); // Always consumed: the loop has already sent the turn upstream, and // re-issuing it would bill twice and could return a different answer. @@ -2629,7 +2734,9 @@ export const handleResponsesRequest = async ( prepared.model, prepared.previousResponseId, imagePayload, - getServerToolExecutions(imageResponse), + // Read off the loop, not the response: the loop rebuilds it, so + // nothing is keyed under this response object any more. + serverToolExecutions, executions, ), ); diff --git a/tests/server/image-generation.test.ts b/tests/server/image-generation.test.ts index dae2136..d88fd13 100644 --- a/tests/server/image-generation.test.ts +++ b/tests/server/image-generation.test.ts @@ -5,16 +5,24 @@ import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as config from '@/lib/server/domain/config'; +import { updateSettings } from '@/lib/server/domain/config'; import { createAccessKey } from '@/lib/server/domain/access-keys'; +import { resetWebSearchProviders } from '@/lib/server/search'; import { addCredential, resetCredentialRuntimeState, } from '@/lib/server/domain/credentials'; import { + clearClosingHop, executeImageGeneration, + executeImageGenerationLoop, isImageGenerationToolCall, } from '@/lib/server/proxy/image-generation'; import type { ProxyContext } from '@/lib/server/proxy/codebuddy'; +import { + attachServerToolExecutions, + getServerToolExecutions, +} from '@/lib/server/proxy/web-search-loop'; import { handleResponsesRequest, resetResponseSessions, @@ -558,6 +566,68 @@ describe('Responses image support', () => { expect(text).toContain('event: response.completed'); }); + it('replays buffered text as delta events', async () => { + const secret = await addCredentialWith(); + let chatCall = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + if (String(url).includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + chatCall += 1; + return makeChatResponse( + chatCall === 1 + ? { + content: null, + tool_calls: [ + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'call_1', + type: 'function', + }, + ], + } + : { content: 'Here is your cat.' }, + ); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'draw me a cat', + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ type: 'image_generation' }], + } as never); + + const text = await response.text(); + + // A client that renders as it reads subscribes to deltas, so arriving + // whole in response.completed would show nothing until the turn ends. + expect(text).toContain('event: response.output_text.delta'); + expect(text).toContain('"delta":"Here is your cat."'); + expect(text).toContain('event: response.output_text.done'); + }); + + it('replays deltas even when the model never calls the tool', async () => { + // Regression: buffering happens because the tool was declared, so a + // turn that never used it still lost its streamed text. + const secret = await addCredentialWith(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + makeChatResponse({ content: 'A long answer about cats.' }), + ); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'tell me about cats', + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ type: 'image_generation' }], + } as never); + + const text = await response.text(); + expect(text).toContain('"delta":"A long answer about cats."'); + }); + it('marks a URL-only result completed without inline data', async () => { const secret = await addCredentialWith(); let chatCall = 0; @@ -1122,6 +1192,406 @@ describe('Responses image support', () => { }); }); + describe('rebuilt response shape', () => { + /** + * The loop rebuilds every response it hands back, and server-tool + * executions are keyed on `Response` identity — so reading them off the + * rebuilt response finds nothing and a search that ran is reported as no + * search at all. They have to travel with the loop result instead. + */ + it('carries server-tool executions past the rebuild', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + makeImageResponse([{ b64_json: 'QUJD' }]), + ); + + const executions = [ + { + id: 'ws_1', + input: { query: 'cats' }, + result: { content: 'Cats are small carnivores.', results: [] }, + type: 'web_search' as const, + }, + ]; + + const { response, serverToolExecutions } = + await executeImageGenerationLoop({ + body: { messages: [{ content: 'hi', role: 'user' }], model: 'm' }, + callUpstream: () => + Promise.resolve( + attachServerToolExecutions( + makeChatResponse({ content: 'the answer' }), + executions, + ), + ), + context: makeContext(), + request: makeRequest(), + }); + + expect(serverToolExecutions).toHaveLength(1); + // Regression: the executions used to be read off the rebuilt response, + // which always reported none. + expect(getServerToolExecutions(response)).toHaveLength(0); + }); + + it('drops framing headers the rebuilt body no longer matches', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + makeImageResponse([{ b64_json: 'QUJD' }]), + ); + + let chatCall = 0; + const { response } = await executeImageGenerationLoop({ + body: { messages: [{ content: 'hi', role: 'user' }], model: 'm' }, + callUpstream: () => { + chatCall += 1; + const body = JSON.stringify({ + choices: [ + { + message: + chatCall === 1 + ? { + content: 'Let me draw that for you right now.', + tool_calls: [ + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'call_1', + type: 'function', + }, + ], + } + : { content: 'Here it is.' }, + }, + ], + }); + + return Promise.resolve( + new Response(body, { + headers: { + 'Content-Encoding': 'gzip', + 'Content-Length': String(Buffer.byteLength(body)), + 'Content-Type': 'application/json', + }, + status: 200, + }), + ); + }, + context: makeContext(), + request: makeRequest(), + }); + + // The folded prose makes the body longer than the upstream declared, so + // a copied content-length would truncate it, and the body is plaintext + // regardless of how the upstream encoded its own. + expect(response.headers.get('content-length')).toBeNull(); + expect(response.headers.get('content-encoding')).toBeNull(); + const text = await response.text(); + expect(Buffer.byteLength(text)).toBeGreaterThan(0); + }); + + it('ends the turn without a dangling call when the cap is reached', async () => { + const secret = await addCredentialWith(); + let chatCall = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + if (String(url).includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + chatCall += 1; + // Every hop asks for another image, so the loop exits on the cap. + return makeChatResponse({ + content: `prose ${chatCall}`, + tool_calls: [ + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: `call_${chatCall}`, + type: 'function', + }, + ], + }); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'draw a cat', + model: 'claude-sonnet-4.6', + tools: [{ type: 'image_generation' }], + } as never); + + const payload = (await response.json()) as { + output: Array>; + }; + const types = payload.output.map((item) => item.type); + + // The closing hop's call was executed, so reporting it as a pending + // function_call would ask the client to resolve work already done. + expect(types).not.toContain('function_call'); + expect( + types.filter((type) => type === 'image_generation_call'), + ).toHaveLength(3); + + const message = payload.output.find( + (item) => item.type === 'message', + ) as { + content: Array<{ text: string }>; + }; + // Each hop's prose once — the closing hop used to be appended twice. + expect(message.content[0].text).toBe('prose 1\n\nprose 2\n\nprose 3'); + }); + + it('keeps a client-owned call on the capped hop', async () => { + // A hop can carry calls this loop never runs. The image calls were + // executed, but a client-declared function is still the client's to + // resolve — clearing the hop wholesale would silently drop it. + const secret = await addCredentialWith(); + let chatCall = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + if (String(url).includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + chatCall += 1; + return makeChatResponse({ + content: `prose ${chatCall}`, + tool_calls: [ + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: `img_${chatCall}`, + type: 'function', + }, + { + function: { + arguments: '{"city":"Berlin"}', + name: 'get_weather', + }, + id: `client_${chatCall}`, + type: 'function', + }, + ], + }); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'draw a cat and check the weather', + model: 'claude-sonnet-4.6', + tools: [ + { type: 'image_generation' }, + { + name: 'get_weather', + parameters: { type: 'object', properties: {} }, + type: 'function', + }, + ], + } as never); + + const payload = (await response.json()) as { + output: Array>; + }; + const functionCalls = payload.output.filter( + (item) => item.type === 'function_call', + ); + + // Only the client's function survives; the executed image call does not. + expect(functionCalls).toHaveLength(1); + expect(functionCalls[0]?.name).toBe('get_weather'); + expect( + payload.output.filter((item) => item.type === 'image_generation_call'), + ).toHaveLength(3); + }); + + it('emits the web-search lifecycle on a buffered stream', async () => { + delete process.env.SEARXNG_URL; + resetWebSearchProviders(); + await updateSettings({ CODEBUDDY_WEB_SEARCH_BACKEND: 'codebuddy' }); + + const secret = await addCredentialWith(); + let chatCall = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + + if (url.includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + + // The search backend has to actually answer for an execution to be + // recorded, or there is no web_search_call item to replay. + if (url.includes('/agenttool/v1/search')) { + return makeImageResponse({ + results: [ + { + content: 'Current result', + title: 'News', + url: 'https://news.test', + }, + ], + }); + } + + chatCall += 1; + + // The first hop asks the model for a search, which the chat pipeline + // executes locally before the image loop ever sees the response. + return makeChatResponse( + chatCall === 1 + ? { + content: null, + tool_calls: [ + { + function: { + arguments: '{"query":"cats"}', + name: 'web_search', + }, + id: 'search_1', + type: 'function', + }, + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'call_1', + type: 'function', + }, + ], + } + : { content: 'Here it is.' }, + ); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'search then draw', + model: 'claude-sonnet-4.6', + stream: true, + tools: [{ type: 'image_generation' }, { type: 'web_search_preview' }], + } as never); + + const text = await response.text(); + + // A consumer watching for the search lifecycle never sees it if the + // replay only emits the generic added/done pair. + expect(text).toContain('event: response.web_search_call.in_progress'); + expect(text).toContain('event: response.web_search_call.searching'); + expect(text).toContain('event: response.web_search_call.completed'); + }); + + it('replays a buffered stream whose turn produced no message', async () => { + // No prose anywhere and a surviving client call on the capped hop means + // the mapper emits no message item, so the replay has nothing to + // announce and no deltas to send. + const secret = await addCredentialWith(); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + if (String(url).includes('/v2/images/generations')) { + return makeImageResponse([{ b64_json: 'QUJD' }]); + } + return makeChatResponse({ + content: '', + tool_calls: [ + { + function: { + arguments: '{"prompt":"a cat"}', + name: 'image_generation', + }, + id: 'img_1', + type: 'function', + }, + { + function: { + arguments: '{"city":"Berlin"}', + name: 'get_weather', + }, + id: 'client_1', + type: 'function', + }, + ], + }); + }); + + const response = await handleResponsesRequest(makeRequest(secret), { + input: 'draw a cat and check the weather', + model: 'claude-sonnet-4.6', + stream: true, + tools: [ + { type: 'image_generation' }, + { + name: 'get_weather', + parameters: { type: 'object', properties: {} }, + type: 'function', + }, + ], + } as never); + + const text = await response.text(); + expect(text).toContain('event: response.created'); + expect(text).toContain('event: response.completed'); + expect(text).toContain('data: [DONE]'); + expect(text).not.toContain('response.output_text.delta'); + }); + }); + + describe('clearClosingHop', () => { + const imageCall = { + function: { arguments: '{"prompt":"a cat"}', name: 'image_generation' }, + id: 'img_1', + type: 'function', + }; + const clientCall = { + function: { arguments: '{"city":"Berlin"}', name: 'get_weather' }, + id: 'client_1', + type: 'function', + }; + + it('keeps calls the loop never ran', () => { + const payload = { + choices: [ + { + message: { content: 'prose', tool_calls: [imageCall, clientCall] }, + }, + ], + }; + const cleared = clearClosingHop(payload) as { + choices: Array<{ + message: { content: unknown; tool_calls: unknown[] }; + }>; + }; + + expect(cleared.choices[0].message.tool_calls).toEqual([clientCall]); + expect(cleared.choices[0].message.content).toBeNull(); + }); + + it('returns the payload untouched when it has no choice', () => { + const payload = { choices: [] }; + expect(clearClosingHop(payload)).toBe(payload); + }); + + it('returns a payload with no choices array untouched', () => { + // Nothing to clear, so the payload comes back as-is rather than gaining + // an empty `choices` it never had. + const payload = {}; + expect(clearClosingHop(payload)).toBe(payload); + }); + + it('tolerates a choice with no message', () => { + const cleared = clearClosingHop({ + choices: [{ finish_reason: 'stop' }], + }) as { + choices: Array<{ message: { tool_calls: unknown[] } }>; + }; + expect(cleared.choices[0].message.tool_calls).toEqual([]); + }); + + it('tolerates a choice whose message has no calls', () => { + const payload = { choices: [{ message: { content: 'prose' } }] }; + const cleared = clearClosingHop(payload) as { + choices: Array<{ message: { tool_calls: unknown[] } }>; + }; + expect(cleared.choices[0].message.tool_calls).toEqual([]); + }); + }); + describe('isImageGenerationToolCall', () => { it('matches the rewritten function name loosely', () => { expect(