From c497e927465bc5adf7e43575c0ebbb24d8770347 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 27 Sep 2026 00:33:02 +0900 Subject: [PATCH 1/2] test(responses): reproduce Muse tool-choice incompatibility --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + .../responses-muse-tool-choice.test.ts | 195 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 tests/responses/responses-muse-tool-choice.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7729d699366..27f3e3b3c76 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1558,6 +1558,7 @@ "responses-json-events.test.ts": "responses", "responses-legacy-dotted-tool-name-repair.test.ts": "responses", "responses-muse-tool-name-alias.test.ts": "responses", + "responses-muse-tool-choice.test.ts": "responses", "responses-native-main-refresh.test.ts": "responses", "responses-opaque-blob-recovery.test.ts": "responses", "responses-parser-agent-message.test.ts": "responses", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 61df140603b..7a47abd64a5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1384,6 +1384,7 @@ "responses-json-events.test.ts": "responses", "responses-legacy-dotted-tool-name-repair.test.ts": "responses", "responses-muse-tool-name-alias.test.ts": "responses", + "responses-muse-tool-choice.test.ts": "responses", "responses-native-main-refresh.test.ts": "responses", "responses-opaque-blob-recovery.test.ts": "responses", "responses-parser-agent-message.test.ts": "responses", diff --git a/tests/responses/responses-muse-tool-choice.test.ts b/tests/responses/responses-muse-tool-choice.test.ts new file mode 100644 index 00000000000..d5f7e95d6c1 --- /dev/null +++ b/tests/responses/responses-muse-tool-choice.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { handleResponses, handleResponsesCompact } from "../../src/server/responses"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const createAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +const meta = { adapter: "openai-responses", baseUrl: "https://api.meta.ai/v1", apiKey: "test-key" } as OcxProviderConfig; +const xai = { ...meta, baseUrl: "https://api.x.ai/v1" } as OcxProviderConfig; +const longName = "mcp__plugin_huggingface-skills_huggingface-skills__hub_repo_search"; +const longWireName = "mcp__plugin_huggingface-skills_huggingface-skills__hub__dec57ce4"; +const tool = { type: "function", name: longName, parameters: { type: "object", properties: {} } }; +let releaseSpendHome: (() => void) | undefined; + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; +}); + +function build(provider: OcxProviderConfig, rawBody: Record) { + return createAdapter(provider).buildRequest({ + modelId: "muse-spark-1.3", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: { model: "muse-spark-1.3", input: "continue", ...rawBody }, + }, { headers: new Headers() }); +} + +describe("Meta Muse tool choice", () => { + test("none removes declaration carriers while preserving history and caller input", () => { + const raw = { + input: [ + { type: "function_call", name: longName, call_id: "c1", arguments: "{}" }, + { type: "function_call_output", call_id: "c1", output: "done" }, + { type: "additional_tools", tools: [tool] }, + ], + tools: [tool], + tool_choice: "none", + parallel_tool_calls: true, + }; + const original = structuredClone(raw); + const body = JSON.parse(build(meta, raw).body) as Record; + + expect(body.tools).toEqual([]); + expect(body.input).toEqual([ + { ...original.input[0], name: longWireName }, + original.input[1], + ]); + expect(body).not.toHaveProperty("tool_choice"); + expect(body).not.toHaveProperty("parallel_tool_calls"); + expect(raw).toEqual(original); + }); + + test("auto and omitted choice remain unchanged", () => { + for (const choice of [undefined, "auto"] as const) { + const raw = { tools: [tool], ...(choice ? { tool_choice: choice } : {}) }; + const original = structuredClone(raw); + const body = JSON.parse(build(meta, raw).body) as Record; + expect(body.tools).toHaveLength(1); + expect((body.tools as Array<{ name: string }>)[0]!.name).toHaveLength(64); + expect(body.tool_choice).toBe(choice); + expect(raw).toEqual(original); + } + }); + + test("raw forced hosted choice fails if provider filtering removes its only tool", () => { + const provider = { ...meta, unsupportedHostedTools: ["web_search"] }; + for (const choice of ["required", { type: "web_search" }]) { + expect(() => build(provider, { + tools: [{ type: "web_search" }], + tool_choice: choice, + })).toThrow("This tool_choice cannot be preserved for Meta Responses; use auto or none."); + } + }); + + test("allowed_tools, null and unknown selectors fail with the compatibility error", () => { + for (const choice of [ + { type: "allowed_tools", mode: "auto", tools: [tool] }, + null, + { type: "unknown" }, + ]) { + expect(() => build(meta, { tools: [tool], tool_choice: choice })) + .toThrow("This tool_choice cannot be preserved for Meta Responses; use auto or none."); + } + }); + + test("non-Meta Responses destinations retain tool choices", () => { + const raw = { tools: [tool], tool_choice: { type: "function", name: longName } }; + const body = JSON.parse(build(xai, raw).body) as Record; + expect(body.tool_choice).toEqual(raw.tool_choice); + expect(body.tools).toEqual(raw.tools); + }); + + test("compaction does not bypass forced-choice rejection for a namespaced tool", async () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { fixture: { ...meta, authMode: "key" } }, + } as OcxConfig; + const savedFetch = globalThis.fetch; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return Response.json({ id: "unexpected", status: "completed", output: [] }); + }) as typeof fetch; + try { + releaseSpendHome ??= acquireOwnedSpendHome(); + const response = await handleResponsesCompact(new Request("http://localhost/v1/responses/compact", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + input: [{ type: "function_call", name: longName, call_id: "c1", arguments: "{}" }], + tools: [tool], + tool_choice: { type: "function", name: longName }, + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(400); + const error = await response.json() as { error: { message: string } }; + expect(error.error.message).toBe("This tool_choice cannot be preserved for Meta Responses; use auto or none."); + expect(sends).toBe(0); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses returns a client 400 before any upstream send", async () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { fixture: { ...meta, authMode: "key" } }, + } as OcxConfig; + const savedFetch = globalThis.fetch; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return new Response("unexpected upstream send", { status: 200 }); + }) as typeof fetch; + try { + releaseSpendHome ??= acquireOwnedSpendHome(); + for (const toolChoice of ["required", { type: "function", name: longName }]) { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + input: "use the selected tool", + tools: [tool], + tool_choice: toolChoice, + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(400); + const error = await response.json() as { error: { type: string; code: string; message: string } }; + expect(error).toMatchObject({ error: { type: "invalid_request_error", code: "invalid_request_error" } }); + expect(error.error.message).toBe("This tool_choice cannot be preserved for Meta Responses; use auto or none."); + } + expect(sends).toBe(0); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("none succeeds through handleResponses with no declared tools", async () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { fixture: { ...meta, authMode: "key" } }, + } as OcxConfig; + const savedFetch = globalThis.fetch; + let outbound: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ id: "resp_none", status: "completed", output: [] }), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + releaseSpendHome ??= acquireOwnedSpendHome(); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/muse-spark-1.3", input: "no tools", tools: [tool], tool_choice: "none" }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(outbound?.tools).toEqual([]); + expect(outbound).not.toHaveProperty("tool_choice"); + } finally { + globalThis.fetch = savedFetch; + } + }); +}); From a1d6e4338bc823f89a4a89f4c89dae01e88a0272 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Sun, 27 Sep 2026 00:36:04 +0900 Subject: [PATCH 2/2] fix(responses): preserve Muse tool-choice semantics --- design-debt.md | 30 ++++ .../docs/ko/reference/platform-support.md | 2 + .../docs/reference/platform-support.md | 6 + .../openai-responses/muse-tool-choice.ts | 31 ++++ src/adapters/openai-responses/passthrough.ts | 7 +- src/server/responses/passthrough-dispatch.ts | 2 + structure/providers-and-adapters.md | 12 ++ structure/transports/responses.md | 2 +- tests/providers/muse-tool-name-alias.test.ts | 6 +- .../responses-muse-tool-name-alias.test.ts | 144 +++++++++--------- 10 files changed, 165 insertions(+), 77 deletions(-) create mode 100644 design-debt.md create mode 100644 src/adapters/openai-responses/muse-tool-choice.ts diff --git a/design-debt.md b/design-debt.md new file mode 100644 index 00000000000..b6caadf9dd6 --- /dev/null +++ b/design-debt.md @@ -0,0 +1,30 @@ +# Scoped design debt + +This audit covers the Meta Muse Responses tool-choice change only. All three modules below were swept against the design-review flags. It records confirmed findings only. + +## Inventory + +| Module | Role | Review | +| --- | --- | --- | +| `src/adapters/openai-responses/muse-tool-choice.ts` | Validates the caller's original and effective selector, then normalizes Muse's final request body. | Swept against the design-review flags. No confirmed findings. | +| `src/adapters/openai-responses/passthrough.ts` | Applies the normalizer at the final Meta Responses body boundary. | Full module swept. No confirmed findings. | +| `src/server/responses/passthrough-dispatch.ts` | Maps the compatibility error to an initial HTTP 400 before dispatch. | Full module swept. No confirmed findings. | + +Provider registry and configuration modules are excluded. The selected design adds no provider +flag or persisted setting. Other adapters, transports, and request-selection paths are outside this +behavior's scope. + +## Confirmed findings + +| ID | Severity | Flag | Evidence | Smallest redesign | Status | +| --- | --- | --- | --- | --- | --- | +| - | - | - | No confirmed findings in the three-module sweep. | - | - | + +Severity counts are S1: 0, S2: 0, S3: 0. No findings were refuted because none were raised. No +security findings are recorded here. + +Audit date: 2026-09-27. Modules swept: 3. Modules inconclusive: 0. + +The nose comparison covered 60 files in Responses normalization and provider registry code. +It retained 109 duplication families, with no new family. One recheck contained two unchanged +streaming accumulator regions whose line locations moved. Both regions were compared with `origin/dev`. diff --git a/docs-site/src/content/docs/ko/reference/platform-support.md b/docs-site/src/content/docs/ko/reference/platform-support.md index 5c74818a3e7..375f47368e0 100644 --- a/docs-site/src/content/docs/ko/reference/platform-support.md +++ b/docs-site/src/content/docs/ko/reference/platform-support.md @@ -38,6 +38,8 @@ macOS에서는 `muse login` 뒤 Muse Code CLI가 이미 저장한 API 키를 ope 다른 플랫폼에서는 키를 붙여넣도록 요청합니다. Meta는 네이티브 Windows CLI를 제공하지 않습니다. Linux에는 CLI가 있지만 자격 증명을 저장하는 위치가 검증되지 않아 opencodex가 저장소를 추측하지 않습니다. 같은 키는 [Meta 개발자 콘솔](https://dev.meta.ai)에서도 볼 수 있습니다. 붙여넣은 키도 가져온 키와 똑같은 형식 검사와 Model API에 대한 실시간 검증을 거칩니다. +Meta로 보내는 Responses 요청은 tool 선택을 생략하거나 `auto`로 지정할 수 있습니다. 명시적인 `none`은 `tools: []`로 보내고 `input` 안의 `additional_tools` 항목을 제거합니다. 강제 선택, 함수 이름 지정, `allowed_tools` 선택은 Muse가 `auto`만 지원하므로 Meta로 보내기 전에 HTTP 400으로 거부합니다. 다른 Responses 대상의 tool 선택 동작은 바뀌지 않습니다. + ## Windows 참고 사항 Windows 서비스는 Task Scheduler 또는 네이티브 WinSW 서비스로 실행할 수 있으며 둘을 동시에 사용할 수는 없습니다. `ocx service repair`가 두 방식의 상태를 모두 발견하면 진행을 거부합니다. 어느 쪽을 원하는지 추측하면 한 컴퓨터에서 두 프록시가 같은 포트를 놓고 충돌할 수 있기 때문입니다. diff --git a/docs-site/src/content/docs/reference/platform-support.md b/docs-site/src/content/docs/reference/platform-support.md index 29beee473a9..5309bf7badc 100644 --- a/docs-site/src/content/docs/reference/platform-support.md +++ b/docs-site/src/content/docs/reference/platform-support.md @@ -59,6 +59,12 @@ offers an input surface. Pasted keys use the same format and Model API validatio as imported ones. Management login requires a dashboard session before either credential-acquisition path; see the [provider guide](/guides/providers/). +For Responses requests to Meta, omitted or `auto` tool selection is supported. +Explicit `none` sends `tools: []` and removes `additional_tools` items from the +input. Forced, named, and `allowed_tools` selections return HTTP 400 before the +request reaches Meta because Muse accepts only `auto`. Other Responses +destinations keep their existing tool-selection behavior. + ## Windows notes The Windows service can run under Task Scheduler or as a native WinSW service, diff --git a/src/adapters/openai-responses/muse-tool-choice.ts b/src/adapters/openai-responses/muse-tool-choice.ts new file mode 100644 index 00000000000..7cdd8230210 --- /dev/null +++ b/src/adapters/openai-responses/muse-tool-choice.ts @@ -0,0 +1,31 @@ +import { isPlainObject } from "./internal"; + +export class MuseToolChoiceCompatibilityError extends Error { + constructor() { + super("This tool_choice cannot be preserved for Meta Responses; use auto or none."); + this.name = "MuseToolChoiceCompatibilityError"; + } +} + +function selectorKind(choice: unknown): "auto" | "none" { + if (choice === undefined || choice === "auto") return "auto"; + if (choice === "none") return "none"; + throw new MuseToolChoiceCompatibilityError(); +} + +export function normalizeMuseToolChoice(body: unknown, originalChoice: unknown): unknown { + const originalKind = selectorKind(originalChoice); + const effectiveKind = selectorKind(isPlainObject(body) ? body.tool_choice : undefined); + if (originalKind !== "none" && effectiveKind !== "none") return body; + if (!isPlainObject(body)) return body; + + const input = Array.isArray(body.input) + ? body.input.filter(item => !isPlainObject(item) || item.type !== "additional_tools") + : body.input; + const { + tool_choice: _toolChoice, + parallel_tool_calls: _parallelToolCalls, + ...rest + } = body; + return { ...rest, tools: [], ...(input !== body.input ? { input } : {}) }; +} diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 147a96419b1..7540c3ac876 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -44,6 +44,7 @@ import { applyTierDecisionToResponsesBody, normalizeCanonicalForwardContinuation import { normalizeImageGenClientTools, preferConfiguredHostedTools } from "./image-gen"; import { stripMuseSparkUnsupportedWebSearchFields, stripOpenAiOnlyWebSearchFields } from "./web-search"; import { observeOutbound } from "../../usage/cache-diagnostic"; +import { normalizeMuseToolChoice } from "./muse-tool-choice"; /** * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need @@ -495,7 +496,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed.modelId, ); // Normalize the wire model before deriving model-dependent transport metadata. - const finalBody = + let finalBody = provider.modelSuffixBracketStrip && unnormalizedBody !== null && typeof unnormalizedBody === "object" @@ -503,6 +504,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): && typeof (unnormalizedBody as { model?: unknown }).model === "string" ? { ...(unnormalizedBody as Record), model: stripBracketedModelSuffix((unnormalizedBody as { model: string }).model) } : unnormalizedBody; + if (isMetaAiResponsesDestination(url)) { + const originalChoice = isPlainObject(parsed._rawBody) ? parsed._rawBody.tool_choice : undefined; + finalBody = normalizeMuseToolChoice(finalBody, originalChoice); + } if (isCanonicalOpenAiForwardProvider(provider)) { const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 41bdbfe931d..1b953bc0f8f 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -44,6 +44,7 @@ import { } from "../../responses/namespace-tool-compat"; import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat"; import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema"; +import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice"; import { formatErrorResponse } from "../../bridge"; import { redactSecretString } from "../../lib/redact"; import { @@ -337,6 +338,7 @@ export async function preparePassthroughExchange( error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError || error instanceof RoutedCustomToolCompatError + || error instanceof MuseToolChoiceCompatibilityError ) { return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 8f6309a4a2c..14c659a22cf 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -388,6 +388,18 @@ this field in the tree, so a value that survives file load still cannot be spent silently by design; config-time is where the operator is told why. The planner requires the provider name for that assessment, so `planPassthroughWebSearchBridge` takes it explicitly. +## Meta Responses tool selection + +At the final request boundary for `api.meta.ai`, `src/adapters/openai-responses/passthrough.ts` +uses `src/adapters/openai-responses/muse-tool-choice.ts` to normalize `tool_choice`. Omitted or +`auto` selection keeps its meaning. Explicit `none` sets `tools` to an empty list, removes +`additional_tools` items from `input`, and omits `tool_choice` and `parallel_tool_calls` from the +outgoing body. Forced, named, and `allowed_tools` selections fail with HTTP 400 before the +upstream send because Muse supports only `auto`. Filtering a required tool never changes the +caller's obligation into `none` or `auto`. This rule applies only to the Meta Responses +destination. The input body, historical tool calls and results, and non-Meta requests keep their +existing meaning. + ## Shared type declarations `src/types/` holds the declarations every layer imports: config types (`src/types/config.ts`), diff --git a/structure/transports/responses.md b/structure/transports/responses.md index f47a747511c..db9c51b2914 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -118,7 +118,7 @@ echoed bare name to its namespaced identity before authorizing anything echo is a guess rather than a nomination. The bridges check the declared set before consulting `toolNsMap`, so there a bare helper echo is refused either way. A genuine namespace-free declaration is untouched throughout: that is the caller declaring the tool, not a namespace being -discarded to manufacture a bare name. +discarded to manufacture a bare name. Meta Responses also applies [tool-selection compatibility](../providers-and-adapters.md#meta-responses-tool-selection). Function-call wrappers around freeform bodies are restored by `src/responses/apply-patch-envelope.ts`. The declared `input` field is authoritative. For bare diff --git a/tests/providers/muse-tool-name-alias.test.ts b/tests/providers/muse-tool-name-alias.test.ts index 52228c20bde..a59da21a2aa 100644 --- a/tests/providers/muse-tool-name-alias.test.ts +++ b/tests/providers/muse-tool-name-alias.test.ts @@ -124,7 +124,7 @@ describe("#4410 Meta Muse 64-char tool-name aliasing", () => { expect(aliases?.size).toBe(20); }); - test("history function_call and tool_choice are aliased on api.meta.ai", () => { + test("history function_call is aliased while auto tool_choice remains unchanged", () => { const longName = LONG_ISSUE_NAMES[0]!; const wire = hashedName(longName); const { body, aliases } = buildForProvider(META_PROVIDER, "muse-spark-1.3-contributor", { @@ -133,7 +133,7 @@ describe("#4410 Meta Muse 64-char tool-name aliasing", () => { { type: "function_call", name: longName, call_id: "c1", arguments: "{\"q\":\"hub\"}" }, { type: "function_call_output", call_id: "c1", output: "ok" }, ], - tool_choice: { type: "function", name: longName }, + tool_choice: "auto", }); expect((body.tools as Array<{ name: string }>)[0]!.name).toBe(wire); expect((body.input as Array>)[0]).toMatchObject({ @@ -141,7 +141,7 @@ describe("#4410 Meta Muse 64-char tool-name aliasing", () => { name: wire, arguments: "{\"q\":\"hub\"}", }); - expect((body.tool_choice as { name: string }).name).toBe(wire); + expect(body.tool_choice).toBe("auto"); expect(aliases?.get(wire)).toBe(longName); }); diff --git a/tests/responses/responses-muse-tool-name-alias.test.ts b/tests/responses/responses-muse-tool-name-alias.test.ts index 30fc04cfee3..a378417d152 100644 --- a/tests/responses/responses-muse-tool-name-alias.test.ts +++ b/tests/responses/responses-muse-tool-name-alias.test.ts @@ -281,7 +281,7 @@ describe("muse tool-name inbound restore through handleResponses", () => { return [JSON.parse(data) as Record]; }); - test("non-stream function_call and tool_choice restore the original MCP name", async () => { + test("non-stream function_call restores the original MCP name", async () => { const savedFetch = globalThis.fetch; let outbound: Record | undefined; globalThis.fetch = (async (_input, init) => { @@ -289,7 +289,7 @@ describe("muse tool-name inbound restore through handleResponses", () => { return new Response(JSON.stringify({ id: "resp_json", status: "completed", - tool_choice: { type: "function", name: wire }, + tool_choice: "auto", output: [{ type: "function_call", name: wire, call_id: "c1", arguments: "{\"q\":\"x\"}" }], }), { headers: { "content-type": "application/json" } }); }) as typeof fetch; @@ -303,14 +303,14 @@ describe("muse tool-name inbound restore through handleResponses", () => { stream: false, input: "search", tools: [{ type: "function", name: original, parameters: { type: "object" } }], - tool_choice: { type: "function", name: original }, + tool_choice: "auto", }), }), config, { model: "", provider: "" }); - const json = await response.json() as { output: Array>; tool_choice?: { name: string } }; + const json = await response.json() as { output: Array>; tool_choice?: string }; expect((outbound?.tools as Array<{ name: string }>)[0]!.name).toBe(wire); - expect((outbound?.tool_choice as { name: string }).name).toBe(wire); + expect(outbound?.tool_choice).toBe("auto"); expect(json.output[0]).toMatchObject({ type: "function_call", name: original }); - expect(json.tool_choice?.name).toBe(original); + expect(json.tool_choice).toBe("auto"); } finally { globalThis.fetch = savedFetch; } @@ -348,75 +348,75 @@ describe("muse tool-name inbound restore through handleResponses", () => { } }); - for (const selectorKind of ["named", "allowed_tools"] as const) { - test(`sparse terminal reconstruction keeps the restored identity for ${selectorKind}`, async () => { - const savedFetch = globalThis.fetch; - let outbound: Record | undefined; - const itemId = "fc_sparse"; - const callId = "call_sparse"; - globalThis.fetch = (async (_input, init) => { - outbound = JSON.parse(String(init?.body)) as Record; - const choice = outbound.tool_choice as Record; - const wireName = selectorKind === "named" - ? choice.name as string - : ((choice.tools as Array<{ name: string }>)[0]!.name); - const item = { - type: "function_call", - id: itemId, - call_id: callId, - name: wireName, - arguments: "{}", - status: "completed", - }; - const upstream = [ - frame("response.output_item.done", { output_index: 0, item }), - frame("response.completed", { response: { id: "resp_sparse", status: "completed", output: [] } }), - "data: [DONE]", - ].join("\n\n") + "\n\n"; - return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); - }) as typeof fetch; - try { - takeSpendHome(); - const toolChoice = selectorKind === "named" - ? { type: "function", name: original } - : { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: original }] }; - const response = await handleResponses(new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - model: "fixture/muse-spark-1.3", - stream: true, - input: "search", - tools: [{ type: "function", name: original, parameters: { type: "object" } }], - tool_choice: toolChoice, - }), - }), config, { model: "", provider: "", surface: "grok" }); - const outboundChoice = outbound?.tool_choice as Record; - const wireName = selectorKind === "named" - ? outboundChoice.name as string - : ((outboundChoice.tools as Array<{ name: string }>)[0]!.name); - expect(wireName).not.toBe(original); - expect((outbound?.tools as Array<{ name: string }>)[0]!.name).toBe(wireName); - - const terminal = ssePayloads(await response.text()) - .find(payload => payload.type === "response.completed"); - expect(terminal).toBeDefined(); - expect((terminal!.response as { output: unknown[] }).output).toEqual([expect.objectContaining({ - type: "function_call", - id: itemId, - call_id: callId, - name: original, - })]); - } finally { - globalThis.fetch = savedFetch; - } - }); - } + test("sparse terminal reconstruction keeps the restored identity with auto selection", async () => { + const savedFetch = globalThis.fetch; + let outbound: Record | undefined; + const itemId = "fc_sparse"; + const callId = "call_sparse"; + globalThis.fetch = (async (_input, init) => { + outbound = JSON.parse(String(init?.body)) as Record; + const wireName = (outbound.tools as Array<{ name: string }>)[0]!.name; + const item = { + type: "function_call", + id: itemId, + call_id: callId, + name: wireName, + arguments: "{}", + status: "completed", + }; + const upstream = [ + frame("response.output_item.done", { output_index: 0, item }), + frame("response.completed", { response: { id: "resp_sparse", status: "completed", output: [] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + try { + takeSpendHome(); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/muse-spark-1.3", + stream: true, + input: "search", + tools: [{ type: "function", name: original, parameters: { type: "object" } }], + tool_choice: "auto", + }), + }), config, { model: "", provider: "", surface: "grok" }); + const wireName = (outbound?.tools as Array<{ name: string }>)[0]!.name; + expect(wireName).not.toBe(original); + expect((outbound?.tools as Array<{ name: string }>)[0]!.name).toBe(wireName); - test("sparse terminal reconstruction still refuses an unselected aliased tool", async () => { + const terminal = ssePayloads(await response.text()) + .find(payload => payload.type === "response.completed"); + expect(terminal).toBeDefined(); + expect((terminal!.response as { output: unknown[] }).output).toEqual([expect.objectContaining({ + type: "function_call", + id: itemId, + call_id: callId, + name: original, + })]); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("non-Meta named selection still refuses an unselected tool", async () => { const savedFetch = globalThis.fetch; const selected = original; const unselected = "mcp__plugin_android-emulator_android-emulator__android_install_app"; + const nonMetaConfig = { + ...config, + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig; globalThis.fetch = (async (_input, init) => { const outbound = JSON.parse(String(init?.body)) as Record; const selectedWire = (outbound.tool_choice as { name: string }).name; @@ -452,7 +452,7 @@ describe("muse tool-name inbound restore through handleResponses", () => { ], tool_choice: { type: "function", name: selected }, }), - }), config, { model: "", provider: "", surface: "grok" }); + }), nonMetaConfig, { model: "", provider: "", surface: "grok" }); const terminal = ssePayloads(await response.text()) .find(payload => payload.type === GROK_REFUSED_TERMINAL_EVENT_TYPE); expect(terminal).toBeDefined();