mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(api): fold namespace into the flattened Chat tool name so cross-namespace leaves do not collide (#8322)
This commit is contained in:
committed by
GitHub
parent
35541c06cd
commit
ff3d3762af
1
changelog.d/fixes/8295-namespace-leaf-collision.md
Normal file
1
changelog.d/fixes/8295-namespace-leaf-collision.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): fold namespace into the flattened Chat tool name so cross-namespace leaves do not collide
|
||||
@@ -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<string, { namespace: string; name: string }>();
|
||||
|
||||
// #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"
|
||||
|
||||
@@ -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__<server>__` 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}`;
|
||||
}
|
||||
77
tests/unit/8295-namespace-leaf-collision.test.ts
Normal file
77
tests/unit/8295-namespace-leaf-collision.test.ts
Normal file
@@ -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<string, NamespaceIdentity>;
|
||||
};
|
||||
|
||||
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" });
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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__<server>__ 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__<server>__ 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");
|
||||
});
|
||||
|
||||
@@ -14,8 +14,9 @@ type RuntimeState = ReturnType<typeof initState> & {
|
||||
};
|
||||
|
||||
// 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<string, NamespaceIdentity> };
|
||||
// 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" }
|
||||
|
||||
Reference in New Issue
Block a user