diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index fd141ccfb8..e012a78198 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -743,7 +743,7 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: ( ? new Set(parsed.options.toolChoice.allowedTools) : undefined; const tools = allowed - ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed)) + ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools)) : parsed.context.tools; if (tools.length === 0) return undefined; const converted = tools.map(t => ({ diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 156ba5130a..8f2edbdb55 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -3,7 +3,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { opendir } from "node:fs/promises"; import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types"; -import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice, toolChoiceAliases } from "../types"; +import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types"; import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import type { TranslatorBudget } from "../lib/translator-budget"; import { readBoundedResponseBody } from "../lib/bounded-body"; @@ -157,10 +157,11 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] { const tools = parsed.context.tools ?? []; if (isAllowedToolChoice(choice)) { const allowed = new Set(choice.allowedTools); - return tools.filter(tool => toolAllowedByChoice(tool, allowed)); + return tools.filter(tool => toolAllowedByChoice(tool, allowed, tools)); } if (choice && typeof choice !== "string") { - return tools.filter(tool => toolChoiceAliases(tool).includes(choice.name)); + const selected = resolveToolChoiceWireName(tools, choice.name); + return tools.filter(tool => namespacedToolName(tool.namespace, tool.name) === selected); } return tools; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index ef0ad66341..94c9248488 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -255,7 +255,7 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { ? new Set(parsed.options.toolChoice.allowedTools) : undefined; const tools = allowed - ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed)) + ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed, parsed.context.tools)) : parsed.context.tools; if (tools.length === 0) return undefined; return [{ diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index a6d13fa1f4..1a3f8464dc 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1192,7 +1192,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record { diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 9325125691..626bd93d8d 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -135,7 +135,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools( toolChoice?: OcxRequestOptions["toolChoice"], toWireName: (tool: Pick) => string = tool => namespacedToolName(tool.namespace, tool.name), ): string | undefined { - const visible = tools?.filter(toolChoiceToolPredicate(toolChoice)); + const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools)); const visibleNames = visible?.map(toWireName); // Decide code mode from the tool OBJECTS, while the `freeform` flag still exists — reducing // to wire names first throws away the only thing that distinguishes Codex's JavaScript diff --git a/src/bridge.ts b/src/bridge.ts index 0a1dc860b3..bac73ae293 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -166,7 +166,7 @@ export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete"; export function bridgeToResponsesSSE( events: AsyncIterable, modelId: string, - toolNsMap?: Map, + toolNsMap?: Map, freeformToolNames?: Set, toolSearchToolNames?: Set, onCancel?: () => void, @@ -1049,7 +1049,9 @@ export function bridgeToResponsesSSE( } const ns = mapped?.namespace; const toolSearch = toolSearchToolNames?.has(realName) ?? false; - const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false); + const freeform = !toolSearch && (mapped + ? mapped.freeform === true + : (freeformToolNames?.has(realName) ?? false)); const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`; const item = toolSearch ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" } @@ -1439,7 +1441,7 @@ function buildResponseJSONWithBudget( modelId: string, options?: { hideThinkingSummary?: boolean; - toolNsMap?: Map; + toolNsMap?: Map; /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ declaredToolNames?: ReadonlySet; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ @@ -1620,7 +1622,9 @@ function buildResponseJSONWithBudget( const realName = mapped?.name ?? currentToolCallName; const ns = mapped?.namespace; const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false; - const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false); + const freeform = !toolSearch && (mapped + ? mapped.freeform === true + : (options?.freeformToolNames?.has(realName) ?? false)); // #1611: same integral-float repair as the streaming path. Keyed by the wire name // the request declared, which is the pre-namespace-mapping `currentToolCallName`. const coercedArgs = coerceIntegerToolArguments( @@ -1784,7 +1788,9 @@ function buildResponseJSONWithBudget( const mapped = options?.toolNsMap?.get(currentToolCallName); const realName = mapped?.name ?? currentToolCallName; const toolSearch = options?.toolSearchToolNames?.has(realName) ?? false; - const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false); + const freeform = !toolSearch && (mapped + ? mapped.freeform === true + : (options?.freeformToolNames?.has(realName) ?? false)); if (!freeform && !toolSearch) { flushToolCall("incomplete"); errorEvent = { diff --git a/src/images/loop.ts b/src/images/loop.ts index 1699906d94..834bcced93 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -676,13 +676,20 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise(); + const toolNsMap = new Map(); const freeform = new Set(); const toolSearch = new Set(); - const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); - for (const t of parsed.context.tools ?? []) { + const requestedTools = parsed.context.tools ?? []; + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools); + for (const t of requestedTools) { if (!toolAllowed(t)) continue; - if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name }); + if (t.namespace) { + toolNsMap.set(namespacedToolName(t.namespace, t.name), { + namespace: t.namespace, + name: t.name, + ...(t.freeform ? { freeform: true } : {}), + }); + } if (t.freeform) freeform.add(t.name); if (t.toolSearch) toolSearch.add(t.name); } diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 7c2393bd2e..f7f6326d3d 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -11,7 +11,7 @@ import type { OcxToolCall, OcxReasoningReplayScopeRef, } from "../types"; -import { namespacedToolName } from "../types"; +import { namespacedToolName, toolChoiceCandidates } from "../types"; import { responsesRequestSchema } from "./schema"; import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata"; import { lookupReplayThoughtSignature } from "./thought-signature-replay"; @@ -203,7 +203,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined; for (const inner of t.tools as unknown[]) { if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns); - else if (builtinFunctions && isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner); + else if (isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner, ns); } } else if (t.type === "custom" && typeof t.name === "string") { @@ -691,6 +691,15 @@ export function parseRequest( const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? []; const loadedTools = buildTools(loadedToolSpecs) ?? []; const loadedToolNames = new Set(loadedTools.map(t => namespacedToolName(t.namespace, t.name))); + const wireOwners = new Map(); + for (const tool of [...declaredTools, ...loadedTools]) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const previous = wireOwners.get(wireName); + if (previous && (previous.namespace !== tool.namespace || previous.name !== tool.name || previous.freeform !== tool.freeform || previous.toolSearch !== tool.toolSearch)) { + throw new Error(`ambiguous tool catalog: multiple logical tools map to wire name ${wireName}`); + } + wireOwners.set(wireName, tool); + } const seenTools = new Set(); const mergedTools = [...declaredTools, ...loadedTools] .filter(t => { @@ -716,6 +725,14 @@ export function parseRequest( options.stopSequences = typeof data.stop === "string" ? [data.stop] : data.stop; } const tc = mapToolChoice(data.tool_choice); + if (tc && typeof tc === "object") { + const selectors = "allowedTools" in tc ? tc.allowedTools : [tc.name]; + for (const selector of selectors) { + if (toolChoiceCandidates(mergedTools, selector).length > 1) { + throw new Error(`ambiguous tool_choice name: ${selector}`); + } + } + } if (tc !== undefined) options.toolChoice = tc; if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls; // Upstream codex-rs converts "ultra" to "max" at the inference boundary (core/src/client.rs diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 37fca4777f..f408f5a546 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -101,22 +101,23 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): { - toolNsMap: Map; + toolNsMap: Map; declaredToolNames: Set; /** Declared parameter schema per request-visible tool name (#1611 integer repair). */ toolParameterSchemas: Map>; freeformToolNames: Set; toolSearchToolNames: Set; } { - const toolNsMap = new Map(); + const toolNsMap = new Map(); const declaredToolNames = new Set(); const toolParameterSchemas = new Map>(); const freeformToolNames = new Set(); const toolSearchToolNames = new Set(); - const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); - for (const t of parsed.context.tools ?? []) { + const requestedTools = parsed.context.tools ?? []; + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools); + const authorizedTools = requestedTools.filter(toolAllowed); + for (const t of authorizedTools) { // Upstream output is untrusted: only restore calls for tools the caller authorized. - if (!toolAllowed(t)) continue; const wireName = namespacedToolName(t.namespace, t.name); budget?.chargeRetained(new TextEncoder().encode(wireName).byteLength, { kind: "retained_collectors" }); declaredToolNames.add(wireName); @@ -125,7 +126,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(wireName, t.parameters); if (t.namespace) { budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" }); - toolNsMap.set(wireName, { namespace: t.namespace, name: t.name }); + toolNsMap.set(wireName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) }); } if (t.freeform) { budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" }); @@ -136,6 +137,29 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato toolSearchToolNames.add(t.name); } } + // Some routed providers echo a bare tool_choice selector instead of the flattened catalog + // name. Accept only selectors the client actually sent and only when the full request catalog + // contains one tool with that logical name. + const choice = parsed.options.toolChoice; + const bareChoiceNames = new Set( + choice && typeof choice === "object" + ? ("allowedTools" in choice ? choice.allowedTools : [choice.name]) + : [], + ); + const bareNameCounts = new Map(); + for (const t of requestedTools) { + bareNameCounts.set(t.name, (bareNameCounts.get(t.name) ?? 0) + 1); + } + for (const t of authorizedTools) { + if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue; + budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" }); + declaredToolNames.add(t.name); + budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" }); + toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) }); + if (t.parameters && typeof t.parameters === "object") { + toolParameterSchemas.set(t.name, t.parameters); + } + } return { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames }; } diff --git a/src/types.ts b/src/types.ts index ecf375f265..d06bf89302 100644 --- a/src/types.ts +++ b/src/types.ts @@ -246,13 +246,59 @@ export function toolChoiceAliases(tool: Pick): st return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName]; } -export function toolAllowedByChoice(tool: Pick, allowedTools: ReadonlySet): boolean { - return toolChoiceAliases(tool).some(name => allowedTools.has(name)); +function sameToolIdentity( + left: Pick, + right: Pick, +): boolean { + return left.namespace === right.namespace && left.name === right.name; +} + +/** + * All tools that could be selected by one client-facing name. Bare logical names are included + * here because they are a compatibility selector for namespaced tools, while wire and dotted + * aliases come from `toolChoiceAliases`. A selector with more than one candidate is invalid. + */ +export function toolChoiceCandidates( + tools: readonly Pick[] | undefined, + name: string, +): Pick[] { + if (!tools) return []; + const candidates: Pick[] = []; + for (const tool of tools) { + if (tool.name !== name && !toolChoiceAliases(tool).includes(name)) continue; + if (!candidates.some(candidate => sameToolIdentity(candidate, tool))) candidates.push(tool); + } + return candidates; +} + +/** + * Newer Codex clients can select a tool nested in a namespace by its bare name. Resolve that + * shorthand only when the request contains one tool with the logical name, so an ambiguous name + * cannot authorize a tool from an unintended namespace. + */ +export function toolAllowedByChoice( + tool: Pick, + allowedTools: ReadonlySet, + tools?: readonly Pick[], +): boolean { + if (!tools) return toolChoiceAliases(tool).some(name => allowedTools.has(name)); + for (const name of [...toolChoiceAliases(tool), tool.name]) { + if (!allowedTools.has(name)) continue; + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1 && sameToolIdentity(candidates[0], tool)) return true; + } + return false; } export function resolveToolChoiceWireName(tools: readonly Pick[] | undefined, name: string): string { - const match = tools?.find(tool => toolChoiceAliases(tool).includes(name)); - return match ? namespacedToolName(match.namespace, match.name) : name; + const candidates = toolChoiceCandidates(tools, name); + if (candidates.length === 1) { + const match = candidates[0]; + return namespacedToolName(match.namespace, match.name); + } + // Keep unknown/ambiguous names unchanged for callers that only serialize a selector. The + // catalog-aware predicate rejects them, and parseRequest rejects ambiguous request selectors. + return name; } /** @@ -281,14 +327,17 @@ export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is /** Compile the request's tool-choice policy into a reusable advertisement/restoration predicate. */ export function toolChoiceToolPredicate( choice: OcxToolChoice | undefined, + tools?: readonly Pick[], ): (tool: Pick) => boolean { if (!choice || choice === "auto" || choice === "required") return () => true; if (choice === "none") return () => false; if (isAllowedToolChoice(choice)) { const allowed = new Set(choice.allowedTools); - return tool => toolAllowedByChoice(tool, allowed); + return tool => toolAllowedByChoice(tool, allowed, tools); } - return tool => toolChoiceAliases(tool).includes(choice.name); + if (!tools) return tool => toolChoiceAliases(tool).includes(choice.name); + const candidates = toolChoiceCandidates(tools, choice.name); + return tool => candidates.length === 1 && sameToolIdentity(candidates[0], tool); } export interface OcxRequestOptions { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index e6129bfc2c..4c4ad772ac 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -741,13 +741,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise(); + const toolNsMap = new Map(); const freeform = new Set(); const toolSearch = new Set(); - const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice); - for (const t of parsed.context.tools ?? []) { + const requestedTools = parsed.context.tools ?? []; + const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools); + for (const t of requestedTools) { if (!toolAllowed(t)) continue; - if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name }); + if (t.namespace) { + toolNsMap.set(namespacedToolName(t.namespace, t.name), { + namespace: t.namespace, + name: t.name, + ...(t.freeform ? { freeform: true } : {}), + }); + } if (t.freeform) freeform.add(t.name); if (t.toolSearch) toolSearch.add(t.name); } diff --git a/tests/adapter-tool-conformance.test.ts b/tests/adapter-tool-conformance.test.ts index 3e23c13f26..f70374ee47 100644 --- a/tests/adapter-tool-conformance.test.ts +++ b/tests/adapter-tool-conformance.test.ts @@ -100,6 +100,26 @@ function freeformParsed(wire: AdapterWire): OcxParsedRequest { }), wire); } +function namespacedCollisionParsed(wire: AdapterWire): OcxParsedRequest { + return prepareForWire(parseRequest({ + model: WIRE_MODELS[wire], + input: "Run the requested tool.", + stream: true, + tools: [ + { + type: "namespace", + name: "mcp__custom", + tools: [{ type: "custom", name: "exec", description: "Freeform execution." }], + }, + { + type: "namespace", + name: "mcp__remote", + tools: [{ type: "function", name: "exec", description: "Structured execution.", parameters: { type: "object", properties: {} } }], + }, + ], + }), wire); +} + function toolChoiceParsed(wire: AdapterWire, toolChoice?: "none"): OcxParsedRequest { return prepareForWire(parseRequest({ model: WIRE_MODELS[wire], @@ -431,6 +451,79 @@ describe("registry-derived routed tool conformance", () => { } }); + test("every buffered adapter preserves same-name tools from different namespaces", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + if (contract.wire === "openai-responses" || contract.wire === "cursor") { + // Native Responses passthrough and Cursor's protobuf transport do not use the routed + // adapter tool declaration surface exercised by this registry-wide check. + continue; + } + const body = await outbound(adapterId, namespacedCollisionParsed(contract.wire)); + const names = advertisedToolNames(contract.wire, body).filter(name => name.includes("exec")); + expect(new Set(names).size, adapterId).toBe(2); + } + }); + + test("every routed adapter fails closed for an ambiguous bare selector", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + if (contract.wire === "openai-responses" || contract.wire === "cursor") continue; + const parsed = namespacedCollisionParsed(contract.wire); + // parseRequest rejects this shape for real inbound traffic; keeping the policy mutation here + // also proves each adapter remains fail-closed when a caller reaches it with a prebuilt AST. + parsed.options.toolChoice = { allowedTools: ["exec"], mode: "required" }; + if (contract.wire === "kiro") { + await expect(outbound(adapterId, parsed)).rejects.toThrow("Kiro supports only automatic tool choice or tool_choice:none"); + continue; + } + const body = await outbound(adapterId, parsed); + expect(advertisedToolNames(contract.wire, body), adapterId).toHaveLength(0); + } + }); + + test("every streaming adapter restores namespaced custom/function collisions distinctly", async () => { + for (const [adapterId] of adapterDefinitions()) { + const contract = effectiveAdapterContract(adapterId); + const driver = TOOL_WIRE_DRIVERS[contract.wire]; + if (!driver.streamingToolCall || !driver.extractWireToolName) { + expect(["openai-responses", "cursor"]).toContain(contract.wire); + continue; + } + + const parsed = namespacedCollisionParsed(contract.wire); + const body = await outbound(adapterId, parsed); + const maps = buildToolBridgeMaps(parsed); + const cases = [ + { logicalName: "mcp__custom__exec", type: "custom_tool_call" }, + { logicalName: "mcp__remote__exec", namespace: "mcp__remote", type: "function_call" }, + ] as const; + for (const testCase of cases) { + const wireName = driver.extractWireToolName(body, testCase.logicalName); + const bridged = bridgeToResponsesSSE( + createRegisteredAdapter(providerFixture(adapterId, contract.wire)).parseStream( + driver.streamingToolCall(wireName, JSON.stringify({ input: "ok" })), + createTestTranslatorBudget(), + ), + parsed.modelId, + maps.toolNsMap, + maps.freeformToolNames, + maps.toolSearchToolNames, + undefined, + 2_000, + { declaredToolNames: maps.declaredToolNames }, + ); + const frames = parseResponsesFrames(await new Response(bridged).text()); + const item = frames.find(frame => frame.event === "response.output_item.added")?.data.item as Record | undefined; + expect(item, `${adapterId}:${testCase.logicalName}`).toMatchObject({ + type: testCase.type, + name: "exec", + ...(testCase.namespace ? { namespace: testCase.namespace } : {}), + }); + } + } + }); + test("every registered adapter replays the exact apply_patch input on continuation", async () => { for (const [adapterId] of adapterDefinitions()) { const contract = effectiveAdapterContract(adapterId); diff --git a/tests/command-code-provider.test.ts b/tests/command-code-provider.test.ts index 867d5dc21e..3453fc6d77 100644 --- a/tests/command-code-provider.test.ts +++ b/tests/command-code-provider.test.ts @@ -392,7 +392,7 @@ describe("Command Code provider", () => { expect(JSON.parse(built.body).params.tools).toEqual([]); }); - test("matches a forced namespaced tool choice by dot alias", async () => { + test("matches a forced namespaced tool choice by dot or unique bare alias", async () => { const namespacedParsed = { ...parsed(), context: { @@ -404,6 +404,12 @@ describe("Command Code provider", () => { const built = await builtRequest(namespacedParsed); const tools = JSON.parse(built.body).params.tools; expect(tools).toEqual([{ name: "functions__exec_command", description: "exec", input_schema: { type: "object" } }]); + + const bareBuilt = await builtRequest({ + ...namespacedParsed, + options: { toolChoice: { name: "exec_command" } }, + }); + expect(JSON.parse(bareBuilt.body).params.tools).toEqual(tools); }); test("refreshes a stale official effort record only after a reasoning rejection and retries without it", async () => { diff --git a/tests/helpers/responses-conformance.ts b/tests/helpers/responses-conformance.ts index 0991a7afda..520de93ceb 100644 --- a/tests/helpers/responses-conformance.ts +++ b/tests/helpers/responses-conformance.ts @@ -72,7 +72,7 @@ const isToolItem = (item: Record): boolean => String(item.type ?? "").includes("call"); type BridgeMaps = [ - toolNsMap?: Map, + toolNsMap?: Map, freeformToolNames?: Set, toolSearchToolNames?: Set, ]; diff --git a/tests/reasoning-effort.test.ts b/tests/reasoning-effort.test.ts index e2115beb3f..269da1a81a 100644 --- a/tests/reasoning-effort.test.ts +++ b/tests/reasoning-effort.test.ts @@ -487,6 +487,32 @@ describe("provider-specific reasoning effort mapping", () => { expect(body.tool_choice).toBe("required"); }); + test("OpenAI-compatible chat accepts a bare allowed_tools name for a unique namespace tool", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.umans.ai/v1", + }; + + const req = createOpenAIChatAdapter(provider).buildRequest({ + modelId: "umans-kimi-k2.7", + context: { + messages: [{ role: "user", content: "run it", timestamp: 0 }], + tools: [{ + namespace: "functions", + name: "exec", + description: "Run a command", + parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] }, + }], + }, + stream: false, + options: { toolChoice: { allowedTools: ["exec"], mode: "required" } }, + }); + const body = JSON.parse(req.body as string) as { tools: Array<{ function: { name: string } }>; tool_choice: string }; + + expect(body.tools.map(t => t.function.name)).toEqual(["functions__exec"]); + expect(body.tool_choice).toBe("required"); + }); + test("named namespaced tool_choice resolves to the chat wire name", async () => { const provider: OcxProviderConfig = { adapter: "openai-chat", @@ -551,6 +577,34 @@ describe("provider-specific reasoning effort mapping", () => { expect(body.tool_choice).toEqual({ type: "any" }); }); + test("Anthropic accepts a bare allowed_tools name for a unique namespace tool", async () => { + const provider: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com/v1", + apiKey: "test-key", + }; + + const req = await createAnthropicAdapter(provider).buildRequest({ + modelId: "claude-sonnet", + context: { + messages: [{ role: "user", content: "run it", timestamp: 0 }], + tools: [{ + namespace: "functions", + name: "exec", + description: "Run a command", + parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] }, + freeform: true, + }], + }, + stream: false, + options: { toolChoice: { allowedTools: ["exec"], mode: "required" } }, + }); + const body = JSON.parse(req.body as string) as { tools: Array<{ name: string }>; tool_choice: { type: string } }; + + expect(body.tools.map(t => t.name)).toEqual(["functions__exec"]); + expect(body.tool_choice).toEqual({ type: "any" }); + }); + test("sanitizeCodexReasoningEfforts keeps max and strips unknown catalog labels", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "test", id: "model-with-max", reasoningEfforts: ["low", "max", "turbo", "high"] }, diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index be6258f6a1..98443126f2 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { buildResponseJSON } from "../src/bridge"; import { parseRequest } from "../src/responses/parser"; import { buildToolBridgeMaps } from "../src/server/responses"; @@ -190,6 +191,102 @@ describe("Responses parser", () => { expect([...maps.toolSearchToolNames]).toEqual([]); }); + test("accepts a unique bare selector for a namespaced custom tool and rejects ambiguity", () => { + const parsed = parseRequest({ + model: "claude-opus-5", + input: "run it", + tools: [{ + type: "namespace", + name: "mcp__functions", + tools: [{ type: "custom", name: "exec", description: "Run a command" }], + }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "custom", name: "exec" }], + }, + }); + + let maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap]).toEqual([ + ["mcp__functions__exec", { namespace: "mcp__functions", name: "exec", freeform: true }], + ["exec", { namespace: "mcp__functions", name: "exec", freeform: true }], + ]); + expect([...maps.declaredToolNames]).toEqual(["mcp__functions__exec", "exec"]); + expect([...maps.freeformToolNames]).toEqual(["exec"]); + + const bridged = buildResponseJSON([ + { type: "tool_call_start", id: "call_exec", name: "exec" }, + { type: "tool_call_delta", arguments: '{"input":"pwd"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], "claude-opus-5", maps); + expect(bridged.status).toBe("completed"); + expect((bridged.output as Record[])[0]).toMatchObject({ + type: "custom_tool_call", + call_id: "call_exec", + name: "exec", + input: "pwd", + status: "completed", + }); + + parsed.options.toolChoice = { name: "exec" }; + maps = buildToolBridgeMaps(parsed); + expect([...maps.toolNsMap.keys()]).toEqual(["mcp__functions__exec", "exec"]); + + expect(() => parseRequest({ + model: "claude-opus-5", + input: "run it", + tools: [{ + type: "namespace", + name: "mcp__functions", + tools: [{ type: "custom", name: "exec" }], + }, { + type: "namespace", + name: "other", + tools: [{ type: "custom", name: "exec" }], + }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "custom", name: "exec" }], + }, + })).toThrow("ambiguous tool_choice name: exec"); + + const mixedKinds = parseRequest({ + model: "claude-opus-5", + input: "run it", + tools: [{ + type: "namespace", + name: "mcp__functions", + tools: [{ type: "custom", name: "exec" }], + }, { + type: "namespace", + name: "mcp__remote", + tools: [{ type: "function", name: "exec", parameters: { type: "object" } }], + }], + }); + const mixedMaps = buildToolBridgeMaps(mixedKinds); + const customCall = buildResponseJSON([ + { type: "tool_call_start", id: "call_custom", name: "mcp__functions__exec" }, + { type: "tool_call_delta", arguments: '{"input":"pwd"}' }, + { type: "tool_call_end" }, + { type: "done" }, + ], "claude-opus-5", mixedMaps); + const functionCall = buildResponseJSON([ + { type: "tool_call_start", id: "call_function", name: "mcp__remote__exec" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], "claude-opus-5", mixedMaps); + expect((customCall.output as Record[])[0]?.type).toBe("custom_tool_call"); + expect((functionCall.output as Record[])[0]).toMatchObject({ + type: "function_call", + name: "exec", + namespace: "mcp__remote", + }); + }); + test("maps hosted allowed_tools entries to their synthetic routed tool names", () => { const parsed = parseRequest({ model: "umans/umans-kimi-k2.7", @@ -206,6 +303,37 @@ describe("Responses parser", () => { expect(parsed.options.toolChoice).toEqual({ allowedTools: ["web_search"], mode: "required" }); }); + test("rejects wire-name collisions instead of dropping one logical tool", () => { + expect(() => parseRequest({ + model: "gpt-5.5", + input: "run it", + tools: [ + { + type: "namespace", + name: "foo", + tools: [{ type: "function", name: "bar", parameters: { type: "object" } }], + }, + { type: "function", name: "foo__bar", parameters: { type: "object" } }, + ], + })).toThrow("ambiguous tool catalog: multiple logical tools map to wire name foo__bar"); + }); + + test("rejects a dotted alias that also names a flat tool", () => { + expect(() => parseRequest({ + model: "gpt-5.5", + input: "run it", + tools: [ + { + type: "namespace", + name: "foo", + tools: [{ type: "function", name: "bar", parameters: { type: "object" } }], + }, + { type: "function", name: "foo.bar", parameters: { type: "object" } }, + ], + tool_choice: { type: "function", name: "foo.bar" }, + })).toThrow("ambiguous tool_choice name: foo.bar"); + }); + test("maps type-only hosted image_generation tool_choice to required image_gen", () => { const parsed = parseRequest({ model: "claude-opus-4-6", diff --git a/tests/responses-tool-conformance.test.ts b/tests/responses-tool-conformance.test.ts index aa23b3ee52..fd9763cbbe 100644 --- a/tests/responses-tool-conformance.test.ts +++ b/tests/responses-tool-conformance.test.ts @@ -172,18 +172,24 @@ describe("Responses tool-kind discrimination", () => { expect(parsed.context.tools ?? []).toEqual([]); }); - it("CURRENT BEHAVIOR: a non-function child inside a namespace disappears", () => { + it("preserves custom namespace children while unknown child kinds still disappear", () => { const parsed = parseRequest(request([], [ { type: "namespace", name: "ns", tools: [ { type: "function", name: "kept", parameters: { type: "object", properties: {} } }, - { type: "custom", name: "dropped" }, + { type: "custom", name: "freeform" }, + { type: "computer_use_preview", name: "dropped" }, ], }, ])); - expect(toolNames(parsed)).toEqual(["kept"]); + expect(toolNames(parsed)).toEqual(["kept", "freeform"]); + expect(parsed.context.tools?.[1]).toMatchObject({ + name: "freeform", + namespace: "ns", + freeform: true, + }); }); }); @@ -386,6 +392,33 @@ describe("streaming and non-streaming tool parity", () => { ]); }); + it("keeps namespaced custom and function tools distinct when their logical names collide", async () => { + const collidingNsMap = new Map([ + ["functions__exec", { namespace: "functions", name: "exec", freeform: true }], + ["mcp__remote__exec", { namespace: "mcp__remote", name: "exec" }], + ]); + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_custom", name: "functions__exec" }, + { type: "tool_call_delta", arguments: '{"input":"pwd"}' }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_function", name: "mcp__remote__exec" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ]; + + const view = await streamedView(events, MODEL, collidingNsMap, new Set(["exec"])); + const json = jsonToolItems(events, MODEL, { + toolNsMap: collidingNsMap, + freeformToolNames: new Set(["exec"]), + }); + + expect(view.incremental).toEqual(view.snapshot); + expect(view.snapshot).toEqual(json); + expect(view.snapshot.map(item => item.type)).toEqual(["custom_tool_call", "function_call"]); + expect(view.snapshot[1]).toMatchObject({ name: "exec", namespace: "mcp__remote" }); + }); + it("emits the exact custom input fragments on the streamed path only", async () => { const custom = cases.find(entry => entry.label.startsWith("custom"))!; const view = await streamedView(custom.events, MODEL, nsMap, freeform, toolSearch); diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index f764f001fa..6316892034 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -196,6 +196,18 @@ describe("non-OpenAI tool catalog nudge", () => { expect(note).not.toContain("`exec_command`,"); }); + test("keeps a uniquely named namespace tool visible when allowed_tools uses its bare name", () => { + const tools: OcxTool[] = [ + { name: "exec", namespace: "functions", description: "Run", parameters: {} }, + { name: "read_file", namespace: "mcp__fs", description: "Read", parameters: {} }, + ]; + + const note = buildNonOpenAIToolCatalogNudgeForTools(tools, { mode: "required", allowedTools: ["exec"] }); + + expect(note).toContain("`functions__exec`"); + expect(note).not.toContain("`mcp__fs__read_file`"); + }); + test("skips OpenAI and ChatGPT hosts", () => { expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://api.openai.com/v1" })).toBe(false); expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(false);