diff --git a/changelog.d/fixes/8295-namespace-leaf-collision.md b/changelog.d/fixes/8295-namespace-leaf-collision.md new file mode 100644 index 0000000000..4cccd343fa --- /dev/null +++ b/changelog.d/fixes/8295-namespace-leaf-collision.md @@ -0,0 +1 @@ +- fix(api): fold namespace into the flattened Chat tool name so cross-namespace leaves do not collide diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 2c42a7ce90..980eab7818 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -13,6 +13,7 @@ import { requiresPlainStringContent, } from "../../config/providerRegistry.ts"; import { collectResponsesTools } from "./openai-responses/additionalTools.ts"; +import { flattenNamespaceToolName } from "./openai-responses/namespaceFlatten.ts"; import { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; import { JsonRecord, @@ -87,10 +88,11 @@ export function openaiResponsesToOpenAIRequest( const result: JsonRecord = { ...root }; // Request-scoped response-side identity for Responses namespace child tools. - // The Chat wire `tool.function.name` is the bare leaf (per #7905 #7936), and - // the original `{namespace, name}` pair is retained in this side-band map so - // the response translator can emit codex-compatible `namespace` + `name` - // fields without reparsing the wire name. + // The Chat wire `tool.function.name` is the namespace-qualified name (#8295: + // folding the namespace in makes cross-namespace leaf collisions structurally + // impossible), and the original `{namespace, name}` pair is retained in this + // side-band map so the response translator can emit codex-compatible + // `namespace` + `name` fields without reparsing the wire name. const namespaceToolIdentityMap = new Map(); // #7533: `verbosity` and `prompt_cache_key` are GPT-5/OpenAI-only Chat Completions @@ -423,28 +425,20 @@ export function openaiResponsesToOpenAIRequest( .filter((sub) => toString(sub.name)) .map((sub) => { const leaf = toString(sub.name); - // Stamp the identity for the response-side seam. The wire name - // remains the bare leaf (matching #7905), so `namespaceToolIdentityMap` - // keys on the leaf. A later child that shares the same leaf name - // with a different namespace is ambiguous: drop the conflicting - // entry rather than silently overwriting. + // #8295: fold the namespace into the wire name so two namespaces + // sharing a leaf (e.g. two MCP servers both exposing `_search`) + // never collide into duplicate Chat tool names. Stamp the + // identity for the response-side seam keyed on that qualified + // wire name — qualified names cannot collide across namespaces, + // so there is no ambiguity to detect/drop here anymore. + const wireName = flattenNamespaceToolName(nsName, leaf); if (nsName && leaf) { - const identity = { namespace: nsName, name: leaf }; - const existingIdentity = namespaceToolIdentityMap.get(leaf); - if ( - !existingIdentity || - (existingIdentity.namespace === identity.namespace && - existingIdentity.name === identity.name) - ) { - namespaceToolIdentityMap.set(leaf, identity); - } else { - namespaceToolIdentityMap.delete(leaf); - } + namespaceToolIdentityMap.set(wireName, { namespace: nsName, name: leaf }); } return { type: "function", function: { - name: leaf, + name: wireName, description: toString(sub.description), parameters: toString(sub.type) === "custom" diff --git a/open-sse/translator/request/openai-responses/namespaceFlatten.ts b/open-sse/translator/request/openai-responses/namespaceFlatten.ts new file mode 100644 index 0000000000..6af4474e8d --- /dev/null +++ b/open-sse/translator/request/openai-responses/namespaceFlatten.ts @@ -0,0 +1,41 @@ +// #8295 — deterministic wire-name qualification for Responses "namespace" tool +// groups flattened onto Chat Completions. See openai-responses.ts's +// `toolType === "namespace"` branch for the caller. +// +// Two different namespaces can declare a child tool with the same leaf name +// (e.g. `mcp__codex_apps__atlassian_rovo._search` and +// `mcp__codex_apps__linear._search`). Emitting both as the bare leaf `_search` +// produces duplicate Chat `tool.function.name` entries, which every +// strict-name-uniqueness upstream (DeepSeek, etc.) rejects with a 400. Folding +// the namespace into the wire name makes collisions structurally impossible. +import { createHash } from "node:crypto"; + +// Chat Completions function names must match ^[a-zA-Z0-9_-]+$ and are commonly +// capped at 64 chars by OpenAI-family providers. +const MAX_TOOL_NAME_LEN = 64; + +/** + * Fold a Responses "namespace" tool's container name and a child leaf name + * into a single, collision-safe Chat Completions wire name. + * + * Rules (mirrors the pre-existing `mcp____` container convention + * documented in open-sse/executors/codex/tools.ts): + * - No container name -> the bare leaf, unchanged. + * - A leaf that already carries its own `__`-qualified prefix -> preserved + * verbatim (never double-prefixed). + * - A container name already ending in `__` -> collapses onto the leaf + * without adding a second `__` separator. + * - Otherwise -> `${nsName}__${leaf}`. + * - Names over the 64-char Chat limit are hash-truncated deterministically + * (same scheme as open-sse/utils/kiroSanitizer.ts) so the same input always + * produces the same truncated wire name. + */ +export function flattenNamespaceToolName(nsName: string, leaf: string): string { + if (!nsName) return leaf; + if (leaf.includes("__")) return leaf; + const prefix = nsName.endsWith("__") ? nsName : `${nsName}__`; + const qualified = `${prefix}${leaf}`; + if (qualified.length <= MAX_TOOL_NAME_LEN) return qualified; + const hash = createHash("sha256").update(qualified).digest("hex").slice(0, 7); + return `${qualified.slice(0, MAX_TOOL_NAME_LEN - 8)}_${hash}`; +} diff --git a/tests/unit/8295-namespace-leaf-collision.test.ts b/tests/unit/8295-namespace-leaf-collision.test.ts new file mode 100644 index 0000000000..710f6fa5b6 --- /dev/null +++ b/tests/unit/8295-namespace-leaf-collision.test.ts @@ -0,0 +1,77 @@ +// #8295 — two Responses "namespace" tool groups that declare a child with the +// same leaf name must not collapse into duplicate Chat Completions +// `tool.function.name` entries. Every strict-name-uniqueness upstream +// (DeepSeek, reported in the issue) 400s on `{name:"_search"},{name:"_search"}`. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = await import( + "../../open-sse/translator/request/openai-responses.ts" +); + +type NamespaceIdentity = { namespace: string; name: string }; +type ChatRequest = { + tools: Array<{ function: { name: string } }>; + _toolNameMap?: Map; +}; + +function translate(tools: unknown[]): ChatRequest { + return openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { type: "additional_tools", tools }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ], + }, + false, + { provider: "any-provider" } + ) as ChatRequest; +} + +test("#8295: two namespaces sharing a leaf name must not produce duplicate Chat tool names", () => { + const result = translate([ + { + type: "namespace", + name: "mcp__codex_apps__atlassian_rovo", + tools: [{ name: "_search", parameters: { type: "object" } }], + }, + { + type: "namespace", + name: "mcp__codex_apps__linear", + tools: [{ name: "_search", parameters: { type: "object" } }], + }, + ]); + + const names = result.tools.map((tool) => tool.function.name); + assert.equal( + new Set(names).size, + names.length, + `translated Chat tool names must be unique, got: ${JSON.stringify(names)}` + ); + assert.deepEqual(names, [ + "mcp__codex_apps__atlassian_rovo___search", + "mcp__codex_apps__linear___search", + ]); +}); + +test("#8295: qualified wire names round-trip back to {namespace, name} via the identity ledger", () => { + const result = translate([ + { + type: "namespace", + name: "mcp__codex_apps__atlassian_rovo", + tools: [{ name: "_search", parameters: { type: "object" } }], + }, + { + type: "namespace", + name: "mcp__codex_apps__linear", + tools: [{ name: "_search", parameters: { type: "object" } }], + }, + ]); + + assert.ok(result._toolNameMap instanceof Map); + const rovo = result._toolNameMap.get("mcp__codex_apps__atlassian_rovo___search"); + const linear = result._toolNameMap.get("mcp__codex_apps__linear___search"); + assert.deepEqual(rovo, { namespace: "mcp__codex_apps__atlassian_rovo", name: "_search" }); + assert.deepEqual(linear, { namespace: "mcp__codex_apps__linear", name: "_search" }); +}); diff --git a/tests/unit/responses-additional-tools.test.ts b/tests/unit/responses-additional-tools.test.ts index 5923b75e2c..abb8774b2f 100644 --- a/tests/unit/responses-additional-tools.test.ts +++ b/tests/unit/responses-additional-tools.test.ts @@ -74,7 +74,7 @@ test("Responses -> Chat merges additional_tools into the universal tool conversi ]); assert.deepEqual( result.tools.map((tool) => tool.function?.name), - ["exec", "wait", "spawn_agent"] + ["exec", "wait", "collaboration__spawn_agent"] ); assert.deepEqual(result.tools[0].function.parameters, { type: "object", diff --git a/tests/unit/responses-chat-namespace-identity-map.test.ts b/tests/unit/responses-chat-namespace-identity-map.test.ts index c6129c3768..c1daf43cd5 100644 --- a/tests/unit/responses-chat-namespace-identity-map.test.ts +++ b/tests/unit/responses-chat-namespace-identity-map.test.ts @@ -24,11 +24,14 @@ function translate(tools: unknown[]): ChatRequest { ) as ChatRequest; } -// #7936 — namespace sub-tools are flattened to Chat with the BARE LEAF as the -// wire-visible `tool.function.name` (per #7905's chat-completions contract), while -// the original `{namespace, name}` pair is carried in a side-band `_toolNameMap` -// for the response translator to restore on `response.output_item.*` items. -test("namespace children keep bare-leaf wire name + side-band identity ledger", () => { +// #8295 — namespace sub-tools are flattened to Chat with the NAMESPACE-QUALIFIED +// name as the wire-visible `tool.function.name` (folding the namespace into the +// wire name, superseding #7905/#7936's bare-leaf contract), so two namespaces that +// declare a child with the same leaf name never collapse into duplicate Chat +// `tool.function.name` entries. The original `{namespace, name}` pair is carried +// in a side-band `_toolNameMap` for the response translator to restore on +// `response.output_item.*` items. +test("namespace children get namespace-qualified wire names + side-band identity ledger", () => { const result = translate([ { type: "namespace", @@ -48,24 +51,24 @@ test("namespace children keep bare-leaf wire name + side-band identity ledger", { type: "function", name: "top_level", parameters: { type: "object" } }, ]); - // Wire-visible names stay as bare leaves (mcp__alpha sends the model "read", not - // "mcp__alpha__read", avoiding upstream truncation/rewriting of long `__` names - // by non-OpenAI providers). + // Wire-visible names fold the namespace in, so mcp__alpha/read and mcp__beta/read + // (same leaf, different namespace) never collide into duplicate Chat tool names. assert.deepEqual( result.tools.map((tool) => tool.function.name), - ["read", "read", "write", "top_level"] + ["mcp__alpha__read", "mcp__beta__read", "mcp__trailing__write", "top_level"] ); - // Side-band identity ledger keys on the bare leaf emitted on the wire, so the - // response translator can resolve back to `{namespace, name}` without parsing. - // When the same leaf belongs to two different namespaces (mcp__alpha/read and - // mcp__beta/read), the entry is ambiguous and dropped — the response translator - // then echoes the bare leaf with no `namespace`, leaving the codex client to - // fall back to its native dispatch table. + // Side-band identity ledger keys on the qualified wire name, so the response + // translator can resolve back to `{namespace, name}` without parsing. Qualified + // names cannot collide across namespaces, so every namespace child gets an entry + // — there is no more ambiguity to detect/drop. assert.ok(result._toolNameMap instanceof Map); assert.deepEqual( [...result._toolNameMap.entries()], - [["write", { namespace: "mcp__trailing__", name: "write" }]] + [ + ["mcp__alpha__read", { namespace: "mcp__alpha", name: "read" }], + ["mcp__beta__read", { namespace: "mcp__beta", name: "read" }], + ["mcp__trailing__write", { namespace: "mcp__trailing__", name: "write" }], + ] ); - assert.ok(!result._toolNameMap.has("read"), "ambiguous read leaf must be dropped"); }); diff --git a/tests/unit/responses-chat-namespace-qualified-name.test.ts b/tests/unit/responses-chat-namespace-qualified-name.test.ts index 94533efde0..981cd13780 100644 --- a/tests/unit/responses-chat-namespace-qualified-name.test.ts +++ b/tests/unit/responses-chat-namespace-qualified-name.test.ts @@ -1,11 +1,149 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { openaiResponsesToOpenAIRequest } = - await import("../../open-sse/translator/request/openai-responses.ts"); + +const { openaiResponsesToOpenAIRequest } = await import( + "../../open-sse/translator/request/openai-responses.ts" +); + interface ChatTool { function: { name: string; description?: string; parameters?: unknown }; } interface ChatRequest { messages: unknown[]; tools: ChatTool[]; -} // Helper: drive the Responses->Chat translator with one namespace tool and// return the flattened Chat function names in order.function flattenNamespace(nsName: string, subNames: string[]): string[] { const result = openaiResponsesToOpenAIRequest( "any-model", { input: [ { type: "additional_tools", tools: [ { type: "namespace", name: nsName, tools: subNames.map((name) => ({ name, description: "sub-tool", parameters: { type: "object", properties: {} }, })), }, ], }, { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, ], }, false, { provider: "any-provider" } ) as ChatRequest; return result.tools.map((t) => t.function?.name).filter(Boolean) as string[];}test("namespace sub-tool with a bare leaf name is flattened to nsName__leaf", () => { // Real Codex client shape: container mcp__1mcp, sub-tool name is the bare leaf "tool_list". const names = flattenNamespace("mcp__1mcp", ["tool_list", "tool_schema"]); assert.deepEqual(names, ["mcp__1mcp__tool_list", "mcp__1mcp__tool_schema"]);});test("non-mcp__-prefixed container (multi_agent_v1) is still qualified", () => { // Codex adjudicator dispatches by splitting on "__" and routing to the trailing // leaf; the prefix is not gated on "mcp__". Empirically, multi_agent_v1__close_agent // is accepted and routed to close_agent (failing only on missing args, not the name). const names = flattenNamespace("multi_agent_v1", ["close_agent", "spawn_agent"]); assert.deepEqual(names, ["multi_agent_v1__close_agent", "multi_agent_v1__spawn_agent"]);});test("codex_app and mcp__context_mode namespaces all get the qualified form", () => { const ctx = flattenNamespace("mcp__context_mode", ["ctx_search", "ctx_execute"]); assert.deepEqual(ctx, ["mcp__context_mode__ctx_search", "mcp__context_mode__ctx_execute"]); const app = flattenNamespace("codex_app", ["read_thread_terminal"]); assert.deepEqual(app, ["codex_app__read_thread_terminal"]);});test("container name ending with __ (mcp____ convention) collapses to nsName + leaf", () => { // The mcp__atlassian__ trailing-"__" container-name convention (documented in // open-sse/executors/codex/tools.ts comments) must NOT produce mcp__atlassian____leaf // (four underscores). It collapses to mcp__atlassian__leaf (still three trailing chars // before the leaf, matching the convention). const names = flattenNamespace("mcp__atlassian__", ["read", "write"]); assert.deepEqual(names, ["mcp__atlassian__read", "mcp__atlassian__write"]);});test("sub-tool name already containing __ (self-prefixed leaf) is preserved verbatim", () => { // A sub-tool whose name already carries its own prefix (e.g. a fixture that names a // sub-tool "mcp__server__read" directly) is NOT double-prefixed into nsName + "__" + leaf. // Real Codex clients never send this shape (they use bare leaf names), but legacy // fixtures / unusual test inputs can; the guard keeps them from producing a pathological // double-prefixed wire name. const names = flattenNamespace("server", ["mcp__server__read"]); assert.deepEqual(names, ["mcp__server__read"]);});test("empty container name falls back to the bare leaf (no prefix appended)", () => { const names = flattenNamespace("", ["bare_tool"]); assert.deepEqual(names, ["bare_tool"]);});test("namespace flatten still lets adjacent top-level function tools pass through", () => { const result = openaiResponsesToOpenAIRequest( "any-model", { input: [ { type: "additional_tools", tools: [ { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }, { type: "namespace", name: "mcp__1mcp", tools: [{ name: "tool_list", parameters: { type: "object", properties: {} } }], }, ], }, { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, ], }, false, { provider: "any-provider" } ) as ChatRequest; assert.deepEqual( result.tools.map((t) => t.function?.name), ["lookup", "mcp__1mcp__tool_list"] );}); +} + +// Helper: drive the Responses->Chat translator with one namespace tool and +// return the flattened Chat function names in order. +function flattenNamespace(nsName: string, subNames: string[]): string[] { + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + tools: [ + { + type: "namespace", + name: nsName, + tools: subNames.map((name) => ({ + name, + description: "sub-tool", + parameters: { type: "object", properties: {} }, + })), + }, + ], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ], + }, + false, + { provider: "any-provider" } + ) as ChatRequest; + return result.tools.map((t) => t.function?.name).filter(Boolean) as string[]; +} + +test("namespace sub-tool with a bare leaf name is flattened to nsName__leaf", () => { + // Real Codex client shape: container mcp__1mcp, sub-tool name is the bare leaf "tool_list". + const names = flattenNamespace("mcp__1mcp", ["tool_list", "tool_schema"]); + assert.deepEqual(names, ["mcp__1mcp__tool_list", "mcp__1mcp__tool_schema"]); +}); + +test("non-mcp__-prefixed container (multi_agent_v1) is still qualified", () => { + // Codex adjudicator dispatches by splitting on "__" and routing to the trailing + // leaf; the prefix is not gated on "mcp__". Empirically, multi_agent_v1__close_agent + // is accepted and routed to close_agent (failing only on missing args, not the name). + const names = flattenNamespace("multi_agent_v1", ["close_agent", "spawn_agent"]); + assert.deepEqual(names, ["multi_agent_v1__close_agent", "multi_agent_v1__spawn_agent"]); +}); + +test("codex_app and mcp__context_mode namespaces all get the qualified form", () => { + const ctx = flattenNamespace("mcp__context_mode", ["ctx_search", "ctx_execute"]); + assert.deepEqual(ctx, ["mcp__context_mode__ctx_search", "mcp__context_mode__ctx_execute"]); + const app = flattenNamespace("codex_app", ["read_thread_terminal"]); + assert.deepEqual(app, ["codex_app__read_thread_terminal"]); +}); + +test("container name ending with __ (mcp____ convention) collapses to nsName + leaf", () => { + // The mcp__atlassian__ trailing-"__" container-name convention (documented in + // open-sse/executors/codex/tools.ts comments) must NOT produce mcp__atlassian____leaf + // (four underscores). It collapses to mcp__atlassian__leaf (still three trailing chars + // before the leaf, matching the convention). + const names = flattenNamespace("mcp__atlassian__", ["read", "write"]); + assert.deepEqual(names, ["mcp__atlassian__read", "mcp__atlassian__write"]); +}); + +test("sub-tool name already containing __ (self-prefixed leaf) is preserved verbatim", () => { + // A sub-tool whose name already carries its own prefix (e.g. a fixture that names a + // sub-tool "mcp__server__read" directly) is NOT double-prefixed into nsName + "__" + leaf. + // Real Codex clients never send this shape (they use bare leaf names), but legacy + // fixtures / unusual test inputs can; the guard keeps them from producing a pathological + // double-prefixed wire name. + const names = flattenNamespace("server", ["mcp__server__read"]); + assert.deepEqual(names, ["mcp__server__read"]); +}); + +test("empty container name falls back to the bare leaf (no prefix appended)", () => { + const names = flattenNamespace("", ["bare_tool"]); + assert.deepEqual(names, ["bare_tool"]); +}); + +test("namespace flatten still lets adjacent top-level function tools pass through", () => { + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + tools: [ + { type: "function", name: "lookup", parameters: { type: "object", properties: {} } }, + { + type: "namespace", + name: "mcp__1mcp", + tools: [{ name: "tool_list", parameters: { type: "object", properties: {} } }], + }, + ], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ], + }, + false, + { provider: "any-provider" } + ) as ChatRequest; + assert.deepEqual( + result.tools.map((t) => t.function?.name), + ["lookup", "mcp__1mcp__tool_list"] + ); +}); + +// #8295 — the regression this file was originally meant to guard against: two +// namespaces sharing a leaf name must not collapse into duplicate Chat tool names. +test("#8295: cross-namespace leaf collisions produce distinct qualified wire names", () => { + const result = openaiResponsesToOpenAIRequest( + "any-model", + { + input: [ + { + type: "additional_tools", + tools: [ + { + type: "namespace", + name: "mcp__codex_apps__atlassian_rovo", + tools: [{ name: "_search", parameters: { type: "object", properties: {} } }], + }, + { + type: "namespace", + name: "mcp__codex_apps__linear", + tools: [{ name: "_search", parameters: { type: "object", properties: {} } }], + }, + ], + }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, + ], + }, + false, + { provider: "any-provider" } + ) as ChatRequest; + const names = result.tools.map((t) => t.function?.name); + assert.equal(new Set(names).size, names.length, "Chat tool names must be unique"); +}); diff --git a/tests/unit/translator-resp-openai-responses-namespace-identity.test.ts b/tests/unit/translator-resp-openai-responses-namespace-identity.test.ts index 8072daee76..e92a9bef8c 100644 --- a/tests/unit/translator-resp-openai-responses-namespace-identity.test.ts +++ b/tests/unit/translator-resp-openai-responses-namespace-identity.test.ts @@ -14,8 +14,9 @@ type RuntimeState = ReturnType & { }; // Build the side-band identity ledger for a single namespace sub-tool. The -// ledger keys on the BARE LEAF the model echoes back on the Chat wire; the -// response translator resolves back to `{namespace, name}` without splitting. +// ledger keys on the NAMESPACE-QUALIFIED name the model echoes back on the +// Chat wire (#8295); the response translator resolves back to +// `{namespace, name}` without splitting. function identityMapFor(namespace: string, name: string) { const request = openaiResponsesToOpenAIRequest( "any-model", @@ -92,12 +93,17 @@ function functionItems(events: ResponseEvent[]) { }; } -// The model echoes back `tool_list` (the bare leaf we stamped on the Chat wire in -// #7905). The response translator resolves that leaf against the side-band ledger -// and emits the codex-compatible `{namespace, name}` tuple on every output item. +// The model echoes back `mcp__1mcp__tool_list` (the namespace-qualified name we +// stamped on the Chat wire in #8295). The response translator resolves that +// qualified name against the side-band ledger and emits the codex-compatible +// `{namespace, name}` tuple on every output item. test("Chat -> Responses emits namespace tuple in added, done, and completed output", () => { - const leaf = "tool_list"; - const events = collectToolEvents(leaf, "call_1mcp", identityMapFor("mcp__1mcp", "tool_list")); + const wireName = "mcp__1mcp__tool_list"; + const events = collectToolEvents( + wireName, + "call_1mcp", + identityMapFor("mcp__1mcp", "tool_list") + ); for (const item of Object.values(functionItems(events))) { assert.deepEqual( { namespace: item.namespace, name: item.name }, @@ -106,12 +112,11 @@ test("Chat -> Responses emits namespace tuple in added, done, and completed outp } }); -// Multiple namespaces share the same leaf name. The ledger entry for that leaf is -// ambiguous and dropped (see openai-responses.ts request translator), so the bare -// leaf echoes back verbatim with no `namespace` field — the codex client falls -// back to its own native dispatch table lookup by `name`. -test("Chat -> Responses leaves ambiguous leaves without a namespace (collision safety)", () => { - // Build one ledger whose leaf "read" exists from two namespaces: ambiguous → dropped. +// #8295 — multiple namespaces sharing the same leaf name each get their own +// namespace-qualified wire name (mcp__alpha__read vs mcp__beta__read), so the +// ledger no longer needs an ambiguity-drop: both entries resolve correctly, with +// no collision possible. +test("Chat -> Responses restores both identities when namespaces share a leaf name", () => { const request = openaiResponsesToOpenAIRequest( "any-model", { @@ -129,14 +134,23 @@ test("Chat -> Responses leaves ambiguous leaves without a namespace (collision s false, { provider: "any-provider" } ) as { _toolNameMap?: Map }; - // Ambiguous leaf dropped → whole ledger is empty → _toolNameMap not injected at - // all (translator skips defineProperty when size === 0). - assert.ok(!request._toolNameMap || request._toolNameMap.size === 0); + assert.ok(request._toolNameMap instanceof Map); + assert.equal(request._toolNameMap.size, 2); - const events = collectToolEvents("read", "call_amb", request._toolNameMap); - for (const item of Object.values(functionItems(events))) { - assert.equal(item.name, "read"); - assert.equal("namespace" in item, false); + const alphaEvents = collectToolEvents("mcp__alpha__read", "call_alpha", request._toolNameMap); + for (const item of Object.values(functionItems(alphaEvents))) { + assert.deepEqual( + { namespace: item.namespace, name: item.name }, + { namespace: "mcp__alpha", name: "read" } + ); + } + + const betaEvents = collectToolEvents("mcp__beta__read", "call_beta", request._toolNameMap); + for (const item of Object.values(functionItems(betaEvents))) { + assert.deepEqual( + { namespace: item.namespace, name: item.name }, + { namespace: "mcp__beta", name: "read" } + ); } }); @@ -181,16 +195,15 @@ test("Chat -> Responses keeps apply_patch as a custom tool without namespace res } }); -test("Chat -> Responses keeps same leaves isolated between per-request stream states", () => { - // Two independent streams issuing the same bare leaf "read" resolve to +test("Chat -> Responses keeps same wire names isolated between per-request stream states", () => { + // Two independent streams issuing the same qualified wire name resolve to // different namespaces because the ledger is per-request, not global. + const wireName = "mcp__shared__read"; const firstMap = identityMapFor("mcp__shared", "read"); - const secondMap = new Map(firstMap); - secondMap.delete("read"); - secondMap.set("read", { namespace: "mcp__two", name: "read" }); + const secondMap = new Map([[wireName, { namespace: "mcp__two", name: "read" }]]); - const first = functionItems(collectToolEvents("read", "call_one", firstMap)); - const second = functionItems(collectToolEvents("read", "call_two", secondMap)); + const first = functionItems(collectToolEvents(wireName, "call_one", firstMap)); + const second = functionItems(collectToolEvents(wireName, "call_two", secondMap)); assert.deepEqual( { namespace: first.completed.namespace, name: first.completed.name }, { namespace: "mcp__shared", name: "read" }