From 838218e150c6f8fde0beb6f9b7fdc257078952dc Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 17 Sep 2026 19:57:27 +0800 Subject: [PATCH 1/2] fix(responses): carry server-tool work through the image loop The image-generation loop rebuilds every response it hands back, but three things were keyed to the response rather than carried alongside it, so the rebuild silently dropped them. Server-tool executions are stored in a WeakMap keyed on Response identity (web-search-loop.ts). The loop rebuilt the response before the caller read them, so `getServerToolExecutions` always returned []: a turn that searched and drew a picture reported the image and dropped the search. They now travel on the loop result. The rebuild also copied the upstream's framing headers while swapping in a different body. Folding earlier hops' prose changes the length, so a copied `content-length` truncates it, and a copied `content-encoding: gzip` tells the client to decompress plaintext. Both are now dropped, along with `transfer-encoding`. When the iteration cap ends the loop, the closing hop's calls have already been executed and reported as `image_generation_call` items. Its payload still carried them, so the client also got a `function_call` for work already done, and its prose was appended twice. The closing hop is now cleared before folding. Finally, the buffered streaming path replayed text only in `response.completed`, with no `output_text.delta`. A client that renders as it reads subscribes to deltas and saw nothing until the turn ended. This affected any request declaring the tool, not just ones that used it, because buffering is decided by the declaration. The replay now emits the same message-item sequence the live path does. Also removes a self-assignment no-op on prepared.defaults.tools. --- lib/server/proxy/image-generation.ts | 73 ++++++++- lib/server/proxy/responses.ts | 135 +++++++++++----- tests/server/image-generation.test.ts | 217 ++++++++++++++++++++++++++ 3 files changed, 385 insertions(+), 40 deletions(-) diff --git a/lib/server/proxy/image-generation.ts b/lib/server/proxy/image-generation.ts index fabcf2f..1d35421 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,37 @@ const readMessageText = ( return typeof message?.content === 'string' ? message.content.trim() : ''; }; +/** + * Drops everything the closing hop contributed to a payload whose calls have + * already been executed. + * + * Used when the iteration cap ends the loop: that hop's 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. + */ +const clearClosingHop = ( + payload: ChatCompletionPayload, +): ChatCompletionPayload => { + const [first, ...rest] = payload.choices ?? []; + + if (!first) { + return payload; + } + + return { + ...payload, + choices: [ + { + ...first, + message: { ...(first.message ?? {}), content: null, tool_calls: [] }, + }, + ...rest, + ], + }; +}; + const buildImageGenerationExecution = ({ id, prompt, @@ -365,6 +417,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 +426,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 +439,7 @@ export const executeImageGenerationLoop = async ({ ?.toLowerCase() .includes('text/event-stream') ) { - return { executions, response }; + return { executions, response, serverToolExecutions }; } const payloadText = await response.text(); @@ -397,6 +455,7 @@ export const executeImageGenerationLoop = async ({ headers: response.headers, status: response.status, }), + serverToolExecutions, }; } @@ -421,6 +480,7 @@ export const executeImageGenerationLoop = async ({ texts: intermediateTexts, }).payload, ), + serverToolExecutions, }; } @@ -473,17 +533,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..25a927d 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,20 @@ 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); - const frames = [ + const frames: Array> = [ { response: { ...payload, output: [], status: 'in_progress' }, type: 'response.created', @@ -1509,21 +1526,60 @@ const mapChatResponseToResponsesStream = async ( response: { id: responseId, status: 'in_progress' }, type: 'response.in_progress', }, - ...output.map((item, output_index) => ({ + ...otherItems.map(({ item, output_index }) => ({ item, output_index, response_id: responseId, type: 'response.output_item.added', })), - ...output.map((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 +2261,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 +2293,7 @@ const createResponsesEventStream = async ( previousResponseId, proxyContext, executions, + serverToolExecutions, ); } @@ -2554,8 +2612,6 @@ export const handleResponsesRequest = async ( return compatibilityError; } - prepared.defaults.tools = prepared.defaults.tools; - if (body.stream) { return await createResponsesEventStream( request, @@ -2594,20 +2650,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 +2688,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..b15ea41 100644 --- a/tests/server/image-generation.test.ts +++ b/tests/server/image-generation.test.ts @@ -12,9 +12,14 @@ import { } from '@/lib/server/domain/credentials'; import { 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 +563,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 +1189,156 @@ 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'); + }); + }); + describe('isImageGenerationToolCall', () => { it('matches the rewritten function name loosely', () => { expect( From 21d2b8f443d755b0a4b77d3dd482bd5d8efb8ca8 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 17 Sep 2026 20:23:02 +0800 Subject: [PATCH 2/2] fix(responses): keep client calls and search lifecycle on the image path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review comments on #159. Clearing the capped hop removed every tool_call, not just the image calls the loop had executed. A hop can also carry a client-declared function, which is still the client's to resolve — so a turn that mixed an image call with a client function silently lost the client call entirely. Only calls matching `isImageGenerationToolCall` are removed now. The buffered replay also emitted the generic added/done pair for server-tool items, while the live path narrates a web search with `web_search_call.in_progress`, `.searching`, and `.completed`. A consumer watching for those never saw them on the buffered path. The replay now emits the same sequence, announcing the item as in-progress first. Also brings changed-branch coverage to 100% (25/25), up from 57.89%. The coverage gate failed the build even though every test, the typecheck, and lint passed. Most of the gap was defensive branches that are hard to reach through the loop, so `clearClosingHop` is exported and unit-tested directly; the remaining two are the no-message replay path. --- lib/server/proxy/image-generation.ts | 24 ++- lib/server/proxy/responses.ts | 58 +++++- tests/server/image-generation.test.ts | 253 ++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 10 deletions(-) diff --git a/lib/server/proxy/image-generation.ts b/lib/server/proxy/image-generation.ts index 1d35421..a6d473e 100644 --- a/lib/server/proxy/image-generation.ts +++ b/lib/server/proxy/image-generation.ts @@ -352,16 +352,24 @@ const readMessageText = ( }; /** - * Drops everything the closing hop contributed to a payload whose calls have + * 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 calls became + * 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. */ -const clearClosingHop = ( +export const clearClosingHop = ( payload: ChatCompletionPayload, ): ChatCompletionPayload => { const [first, ...rest] = payload.choices ?? []; @@ -370,12 +378,20 @@ const clearClosingHop = ( return payload; } + const message = first.message ?? {}; + return { ...payload, choices: [ { ...first, - message: { ...(first.message ?? {}), content: null, tool_calls: [] }, + message: { + ...message, + content: null, + tool_calls: (message.tool_calls ?? []).filter( + (toolCall) => !isImageGenerationToolCall(toolCall), + ), + }, }, ...rest, ], diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 25a927d..7414b3d 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -1517,6 +1517,55 @@ const mapChatResponseToResponsesStream = async ( .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: Array> = [ { response: { ...payload, output: [], status: 'in_progress' }, @@ -1526,12 +1575,9 @@ const mapChatResponseToResponsesStream = async ( response: { id: responseId, status: 'in_progress' }, type: 'response.in_progress', }, - ...otherItems.map(({ item, output_index }) => ({ - item, - output_index, - response_id: responseId, - type: 'response.output_item.added', - })), + ...otherItems.flatMap(({ item, output_index }) => + serverToolFrames({ item, output_index }), + ), ...otherItems.map(({ item, output_index }) => ({ item, output_index, diff --git a/tests/server/image-generation.test.ts b/tests/server/image-generation.test.ts index b15ea41..d88fd13 100644 --- a/tests/server/image-generation.test.ts +++ b/tests/server/image-generation.test.ts @@ -5,12 +5,15 @@ 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, @@ -1337,6 +1340,256 @@ describe('Responses image support', () => { // 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', () => {