From f73f3d220868fa3ca1a5d376243e9f157b2865dc Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:28:33 +0900 Subject: [PATCH] fix(images): retain canonical interception for alias choices --- src/images/plan.ts | 9 ++--- tests/images/plan.test.ts | 8 ++++- tests/images/z-handler-activation.test.ts | 40 +++++++++++++++++++++-- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/images/plan.ts b/src/images/plan.ts index b8780d0fb4..9ea2bdcbb2 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -48,11 +48,12 @@ export async function planImageBridge( if (config.images?.bridgeEnabled !== true) return undefined; if (!parsed._imageGeneration) return undefined; const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); - const toolNames = new Set( - [...parsed._imageGeneration.toolNames, IMAGE_GEN_TOOL_NAME] - .filter(name => toolAllowed({ name })), - ); + const toolNames = new Set([...parsed._imageGeneration.toolNames].filter(name => toolAllowed({ name }))); + if (toolAllowed({ name: IMAGE_GEN_TOOL_NAME })) toolNames.add(IMAGE_GEN_TOOL_NAME); if (toolNames.size === 0) return undefined; + // Responses advertises and rewrites authorized aliases to this synthetic name, so the loop + // must always intercept it once any image-generation name has armed the bridge. + toolNames.add(IMAGE_GEN_TOOL_NAME); // Don't intercept for OpenAI native passthrough const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })(); if (host === "api.openai.com") return undefined; diff --git a/tests/images/plan.test.ts b/tests/images/plan.test.ts index dfff91063a..2963ddf392 100644 --- a/tests/images/plan.test.ts +++ b/tests/images/plan.test.ts @@ -116,8 +116,14 @@ describe("planImageBridge", () => { parsed.options.toolChoice = { name: "generate_image" }; const aliasPlan = await planImageBridge(cfg, parsed, routed); expect(aliasPlan).toBeDefined(); - expect(aliasPlan!.toolNames.has("image_gen")).toBe(false); + expect(aliasPlan!.toolNames.has("image_gen")).toBe(true); expect(aliasPlan!.toolNames.has("generate_image")).toBe(true); + + parsed.options.toolChoice = { allowedTools: ["generate_image"], mode: "required" }; + const allowedAliasPlan = await planImageBridge(cfg, parsed, routed); + expect(allowedAliasPlan).toBeDefined(); + expect(allowedAliasPlan!.toolNames.has("image_gen")).toBe(true); + expect(allowedAliasPlan!.toolNames.has("generate_image")).toBe(true); }); test("xAI provider with OAuth only (no API key) → undefined (API-key-only bridge)", async () => { diff --git a/tests/images/z-handler-activation.test.ts b/tests/images/z-handler-activation.test.ts index 324b877328..5b6f578a79 100644 --- a/tests/images/z-handler-activation.test.ts +++ b/tests/images/z-handler-activation.test.ts @@ -28,6 +28,8 @@ const PREV_HOME = process.env.OPENCODEX_HOME; // --- Activation spies, flipped by the stubbed runners --- let imageBridgeRun = false; +let imageBridgeToolNames: string[] = []; +let imageBridgeToolChoice: unknown; let webSearchRun = false; /** Whether the stubbed adapter should expose runTurn (simulates Cursor-style adapters). */ let useRunTurnAdapter = false; @@ -71,8 +73,13 @@ beforeAll(async () => { const actualLoop = await import("../../src/images/loop"); mock.module("../../src/images/loop", () => ({ ...actualLoop, - runWithImageBridge: async () => { + runWithImageBridge: async (args: { + parsed: { options: { toolChoice?: unknown } }; + plan: { toolNames: Set }; + }) => { imageBridgeRun = true; + imageBridgeToolNames = [...args.plan.toolNames].sort(); + imageBridgeToolChoice = args.parsed.options.toolChoice; return new Response("data: {\"type\":\"done\"}\n\n", { status: 200, headers: { "content-type": "text/event-stream" }, }); @@ -123,12 +130,18 @@ function makeConfig(): OcxConfig { } as OcxConfig; } -function post(stream: boolean, tools: unknown[]): Promise { +function post(stream: boolean, tools: unknown[], toolChoice?: unknown): Promise { return handleResponses( new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "fixture/model", input: "hello", stream, tools }), + body: JSON.stringify({ + model: "fixture/model", + input: "hello", + stream, + tools, + ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}), + }), }), makeConfig(), { model: "", provider: "" } as never, @@ -144,6 +157,27 @@ describe("image bridge dispatch priority (handler activation)", () => { expect(res.headers.get("content-type")).toBe("text/event-stream"); }); + test("alias-only image tool_choice keeps canonical bridge interception armed", async () => { + imageBridgeRun = false; + imageBridgeToolNames = []; + imageBridgeToolChoice = undefined; + webSearchRun = false; + mockWsPlan = undefined; + const res = await post( + true, + [ + { type: "image_generation" }, + { type: "function", name: "generate_image", parameters: { type: "object" } }, + ], + { type: "function", name: "generate_image" }, + ); + expect(imageBridgeRun).toBe(true); + expect(imageBridgeToolChoice).toEqual({ name: "image_gen" }); + expect(imageBridgeToolNames).toContain("generate_image"); + expect(imageBridgeToolNames).toContain("image_gen"); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + }); + test("stream=false + image_generation tool → 400 (bridge requires stream=true)", async () => { imageBridgeRun = false; webSearchRun = false; mockWsPlan = undefined; const res = await post(false, [{ type: "image_generation" }]);