From 96c6e562270a9b0bb97689e919ab8e472f69c3ca Mon Sep 17 00:00:00 2001 From: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:03:36 -0300 Subject: [PATCH 1/6] fix: bound flattened tool wire names to 64 characters (#4679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict gateways cap function names — Command Code's AI gateway rejects name over 64 characters (400 'name must be at most 64 characters, got 66'), tripped by Codex Desktop built-in app tools like mcp__codex_apps__codex_document_control___get_document_tool_schemas (67). Responses-Lite catalogs bundle every declared tool, so the surface cannot be shrunk from config. namespacedToolName/dottedToolName now emit a deterministic, reversible bounded alias (longest fitting prefix + 12-hex sha256 of the native identity) for flattened names past 64, keeping declarations, history replay, toolNsMap/declaredToolNames, tool_choice resolution and the undeclared-tool guard consistent, so provider echoes restore to the native {namespace, name}. --- src/types/tools.ts | 52 +++++++++++++++++++++- tests/responses/bounded-tool-names.test.ts | 39 ++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/responses/bounded-tool-names.test.ts diff --git a/src/types/tools.ts b/src/types/tools.ts index fd80a4b100..838689efba 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + export interface OcxTool { name: string; description: string; @@ -26,9 +28,47 @@ export interface OcxTool { * "__" so they survive the chat-completions function-tool format; * the proxy maps this back to {namespace, name} on the return trip (Codex routes MCP * calls by an explicit `namespace` field, not by parsing the name). + * + * Strict gateways bound function names (Command Code's AI gateway rejects `name` over + * 64 characters — a real case is + * "mcp__codex_apps__safety_settings___prepare_parental_control_update", 65). Flattened + * names past the bound get a deterministic, reversible bounded alias instead: the + * longest prefix that fits plus a 12-hex sha256 digest of the native identity, so the + * alias is stable across restarts and the tool bridge maps restore the client's own + * {namespace, name} on the return trip. The digest is derived from the identity alone + * (never declaration order), and aliases are memoized per native identity. */ +const TOOL_NAME_WIRE_LIMIT = 64; +const BOUNDED_ALIAS_DIGEST_CHARS = 12; +const BOUNDED_ALIAS_SUFFIX_LENGTH = BOUNDED_ALIAS_DIGEST_CHARS + 1; + +const boundedToolAliasByNative = new Map(); +const claimedBoundedToolAliases = new Set(); + +function boundedToolWireAlias(namespace: string | undefined, name: string, flat: string): string { + const nativeKey = `${namespace ?? ""}\u0000${name}`; + const memo = boundedToolAliasByNative.get(nativeKey); + if (memo !== undefined) return memo; + for (let attempt = 0; ; attempt += 1) { + const digest = createHash("sha256") + .update(`${nativeKey}\0${attempt}`) + .digest("hex") + .slice(0, BOUNDED_ALIAS_DIGEST_CHARS); + const candidate = + `${flat.slice(0, TOOL_NAME_WIRE_LIMIT - BOUNDED_ALIAS_SUFFIX_LENGTH)}_${digest}`; + // Attempt 0 collides only on a 48-bit digest match; the loop keeps a collision from + // ever shipping a duplicate wire name, mirroring caller-driven alias tables. + if (claimedBoundedToolAliases.has(candidate)) continue; + claimedBoundedToolAliases.add(candidate); + boundedToolAliasByNative.set(nativeKey, candidate); + return candidate; + } +} + export function namespacedToolName(namespace: string | undefined, name: string): string { - return namespace ? `${namespace}__${name}` : name; + const flat = namespace ? `${namespace}__${name}` : name; + if (flat.length <= TOOL_NAME_WIRE_LIMIT) return flat; + return boundedToolWireAlias(namespace, name, flat); } /** @@ -39,7 +79,15 @@ export function namespacedToolName(namespace: string | undefined, name: string): * (mirroring the second entry of `toolChoiceAliases`). See #3402. */ export function dottedToolName(namespace: string | undefined, name: string): string { - return namespace ? `${namespace}.${name}` : name; + if (!namespace) return name; + const canonical = `${namespace}__${name}`; + // A bounded alias has no dotted spelling: it is already at the wire limit, so re-spelling + // it with a dot would hand the model a name the gateway rejects. The provider only ever + // sees — and can only echo — the alias itself. + if (canonical.length > TOOL_NAME_WIRE_LIMIT) { + return boundedToolWireAlias(namespace, name, canonical); + } + return `${namespace}.${name}`; } /** diff --git a/tests/responses/bounded-tool-names.test.ts b/tests/responses/bounded-tool-names.test.ts new file mode 100644 index 0000000000..688aa396ef --- /dev/null +++ b/tests/responses/bounded-tool-names.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { dottedToolName, namespacedToolName } from "../../src/types/tools"; + +describe("bounded tool wire names (#4679)", () => { + test("keeps flat names at or under the 64-char wire limit unchanged", () => { + expect(namespacedToolName(undefined, "exec_command")).toBe("exec_command"); + expect(namespacedToolName("collaboration", "spawn_agent")).toBe("collaboration__spawn_agent"); + expect(dottedToolName("collaboration", "spawn_agent")).toBe("collaboration.spawn_agent"); + }); + + test("aliases flattened names past the 64-char gateway bound deterministically", () => { + const namespace = "mcp__codex_apps__safety_settings"; + const name = "prepare_parental_control_update"; + expect(`${namespace}__${name}`.length).toBe(65); + const first = namespacedToolName(namespace, name); + expect(first.length).toBeLessThanOrEqual(64); + expect(first.startsWith("mcp__codex_apps__safety_settings__")).toBe(true); + // Same identity, same alias — stable across calls and process restarts. + expect(namespacedToolName(namespace, name)).toBe(first); + expect(dottedToolName(namespace, name)).toBe(first); + }); + + test("distinct identities keep distinct bounded wire names within one namespace", () => { + const namespace = "mcp__codex_apps__codex_document_control"; + const a = namespacedToolName(namespace, "get_document_tool_schemas"); + const b = namespacedToolName(namespace, "execute_document_command"); + expect(a.length).toBeLessThanOrEqual(64); + expect(b.length).toBeLessThanOrEqual(64); + expect(a).not.toBe(b); + }); + + test("bare names past the bound are aliased too", () => { + const name = "a_very_long_client_declared_function_name_exceeding_the_gateway_limit"; + expect(name.length).toBeGreaterThan(64); + const wire = namespacedToolName(undefined, name); + expect(wire.length).toBeLessThanOrEqual(64); + expect(namespacedToolName(undefined, name)).toBe(wire); + }); +}); From 8f6fa49a0b84e34ded542fc58a6f6321b25c4d72 Mon Sep 17 00:00:00 2001 From: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:10:00 -0300 Subject: [PATCH 2/6] fix: collapse duplicate tool choice alias for bounded names (#4715) A bounded alias has no distinct dotted spelling, so toolChoiceAliases returned the same string twice ([alias, alias]). Callers treating the array as a set are unaffected; array consumers would see duplicates. Also extends the #4679 regression tests with the alias-collapse case (5 pass, 0 fail). --- src/types/tools.ts | 5 ++++- tests/responses/bounded-tool-names.test.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/types/tools.ts b/src/types/tools.ts index 838689efba..a584da838a 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -190,7 +190,10 @@ export function declaresCodeModeExec(declared: ReadonlySet | undefined): export function toolChoiceAliases(tool: Pick): string[] { const wireName = namespacedToolName(tool.namespace, tool.name); - return tool.namespace ? [wireName, dottedToolName(tool.namespace, tool.name)] : [wireName]; + if (!tool.namespace) return [wireName]; + // A bounded alias has no distinct dotted spelling, so the two spellings collapse into one. + const dotted = dottedToolName(tool.namespace, tool.name); + return dotted === wireName ? [wireName] : [wireName, dotted]; } function sameToolIdentity( diff --git a/tests/responses/bounded-tool-names.test.ts b/tests/responses/bounded-tool-names.test.ts index 688aa396ef..123f748fcd 100644 --- a/tests/responses/bounded-tool-names.test.ts +++ b/tests/responses/bounded-tool-names.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { dottedToolName, namespacedToolName } from "../../src/types/tools"; +import { dottedToolName, namespacedToolName, toolChoiceAliases } from "../../src/types/tools"; describe("bounded tool wire names (#4679)", () => { test("keeps flat names at or under the 64-char wire limit unchanged", () => { @@ -37,3 +37,10 @@ describe("bounded tool wire names (#4679)", () => { expect(namespacedToolName(undefined, name)).toBe(wire); }); }); + + test("tool choice aliases collapse to a single entry for a bounded alias", () => { + const identity = { namespace: "mcp__codex_apps__safety_settings", name: "prepare_parental_control_update" }; + const aliases = toolChoiceAliases(identity); + expect(aliases.length).toBe(1); + expect(aliases[0].length).toBeLessThanOrEqual(64); + }); From eaf5bdb549eae813f566677796aedbccb4a7abdb Mon Sep 17 00:00:00 2001 From: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:20:45 -0300 Subject: [PATCH 3/6] fix: two-way wire-name claims and registry cap for bounded aliases Review follow-up (#4679): claim canonical spellings too, so a bounded alias can never shadow another identity's plain name (or be shadowed by one) and route a call to the wrong tool after a restart or catalog reorder. The claim registry doubles as collision state for the alias loop. A registry cap bounds retained memory on pathologically dynamic catalogs; derivation is a pure identity digest, so resets only matter on genuine digest collisions. Adds the missing docstrings on the touched helpers. --- src/types/tools.ts | 69 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/src/types/tools.ts b/src/types/tools.ts index a584da838a..0acfff7602 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -41,13 +41,53 @@ export interface OcxTool { const TOOL_NAME_WIRE_LIMIT = 64; const BOUNDED_ALIAS_DIGEST_CHARS = 12; const BOUNDED_ALIAS_SUFFIX_LENGTH = BOUNDED_ALIAS_DIGEST_CHARS + 1; +// Safety valve for pathologically dynamic catalogs: past this many claimed wire names the +// registries reset and identities re-derive. Derivation is a pure digest of the identity, +// so a reset only changes an alias when a genuine digest collision reorders the claim — +// never on ordinary restarts or catalog refreshes. +const BOUNDED_ALIAS_REGISTRY_LIMIT = 8192; -const boundedToolAliasByNative = new Map(); -const claimedBoundedToolAliases = new Set(); +// Every wire name handed out — canonical and bounded alike — is claimed by exactly one +// native identity. Two-way claiming is what makes the alias safe: a bounded alias can +// never shadow another tool's plain spelling (or be shadowed by it) and end up +// authorizing a call against the wrong tool. +const wireNameOwners = new Map(); +const boundedAliasByNative = new Map(); -function boundedToolWireAlias(namespace: string | undefined, name: string, flat: string): string { - const nativeKey = `${namespace ?? ""}\u0000${name}`; - const memo = boundedToolAliasByNative.get(nativeKey); +/** + * Identity key of a native (namespace, name) pair. Doubles as the alias digest input and + * the ownership value in the wire-name claim registry. + */ +function nativeKeyOf(namespace: string | undefined, name: string): string { + return `${namespace ?? ""}\u0000${name}`; +} + +/** + * Claim a wire name for a native identity. Returns false when a DIFFERENT identity + * already holds the name, so the caller must derive another spelling instead of + * shadowing it. + */ +function claimWireName(wireName: string, nativeKey: string): boolean { + const owner = wireNameOwners.get(wireName); + if (owner === undefined) { + if (wireNameOwners.size >= BOUNDED_ALIAS_REGISTRY_LIMIT) { + wireNameOwners.clear(); + boundedAliasByNative.clear(); + } + wireNameOwners.set(wireName, nativeKey); + return true; + } + return owner === nativeKey; +} + +/** + * Bounded wire alias for one native identity: the longest fitting prefix of the flat + * name plus a 12-hex sha256 digest keyed by the identity and the collision attempt. + * Memoized per identity; derivation is a pure digest, so aliases survive process + * restarts unchanged. + */ +function boundedToolWireAlias(nativeKey: string, flat: string): string { + const memo = boundedAliasByNative.get(nativeKey); if (memo !== undefined) return memo; for (let attempt = 0; ; attempt += 1) { const digest = createHash("sha256") @@ -56,19 +96,22 @@ function boundedToolWireAlias(namespace: string | undefined, name: string, flat: .slice(0, BOUNDED_ALIAS_DIGEST_CHARS); const candidate = `${flat.slice(0, TOOL_NAME_WIRE_LIMIT - BOUNDED_ALIAS_SUFFIX_LENGTH)}_${digest}`; - // Attempt 0 collides only on a 48-bit digest match; the loop keeps a collision from - // ever shipping a duplicate wire name, mirroring caller-driven alias tables. - if (claimedBoundedToolAliases.has(candidate)) continue; - claimedBoundedToolAliases.add(candidate); - boundedToolAliasByNative.set(nativeKey, candidate); + if (!claimWireName(candidate, nativeKey)) continue; + boundedAliasByNative.set(nativeKey, candidate); return candidate; } } export function namespacedToolName(namespace: string | undefined, name: string): string { const flat = namespace ? `${namespace}__${name}` : name; - if (flat.length <= TOOL_NAME_WIRE_LIMIT) return flat; - return boundedToolWireAlias(namespace, name, flat); + const nativeKey = nativeKeyOf(namespace, name); + if (flat.length <= TOOL_NAME_WIRE_LIMIT) { + // Canonical names are claimed too, so a bounded alias can never take (or lose) the + // spelling and route a call to the wrong tool after a restart or catalog reorder. + claimWireName(flat, nativeKey); + return flat; + } + return boundedToolWireAlias(nativeKey, flat); } /** @@ -85,7 +128,7 @@ export function dottedToolName(namespace: string | undefined, name: string): str // it with a dot would hand the model a name the gateway rejects. The provider only ever // sees — and can only echo — the alias itself. if (canonical.length > TOOL_NAME_WIRE_LIMIT) { - return boundedToolWireAlias(namespace, name, canonical); + return boundedToolWireAlias(nativeKeyOf(namespace, name), canonical); } return `${namespace}.${name}`; } From 098684f4efff90f713073133a608f4a04b3fe9fa Mon Sep 17 00:00:00 2001 From: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:17:23 -0300 Subject: [PATCH 4/6] test: assert bounded aliases are stable across fresh processes CodeRabbit follow-up (#4715): the same-process memoization assertion cannot distinguish an identity-derived alias from process-local state. Derive the alias in two fresh Bun processes and require identical output, which pins the restart contract (pure identity digest, no process-local derivation state). Same-process memoization assertion kept. --- tests/responses/bounded-tool-names.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/responses/bounded-tool-names.test.ts b/tests/responses/bounded-tool-names.test.ts index 123f748fcd..e591f32b92 100644 --- a/tests/responses/bounded-tool-names.test.ts +++ b/tests/responses/bounded-tool-names.test.ts @@ -1,4 +1,6 @@ +import { spawnSync } from "node:child_process"; import { describe, expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; import { dottedToolName, namespacedToolName, toolChoiceAliases } from "../../src/types/tools"; describe("bounded tool wire names (#4679)", () => { @@ -44,3 +46,23 @@ describe("bounded tool wire names (#4679)", () => { expect(aliases.length).toBe(1); expect(aliases[0].length).toBeLessThanOrEqual(64); }); + + test("bounded aliases are stable across fresh processes (restart contract)", () => { + const identity = { namespace: "mcp__codex_apps__safety_settings", name: "prepare_parental_control_update" }; + const script = + "(async () => {" + + `const m = await import(new URL(${JSON.stringify("file://" + repoPath("src", "types", "tools.ts"))}).href);` + + `console.log(m.namespacedToolName(${JSON.stringify(identity.namespace)}, ${JSON.stringify(identity.name)}));` + + "})();"; + const run = () => spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + const first = run(); + const second = run(); + expect(first.status).toBe(0); + expect(second.status).toBe(0); + const alias = first.stdout.trim(); + expect(alias.length).toBeLessThanOrEqual(64); + // A fresh process derives the same alias for the same identity: no process-local state + // participates in the derivation, so the restart contract holds. + expect(second.stdout.trim()).toBe(alias); + expect(namespacedToolName(identity.namespace, identity.name)).toBe(alias); + }); From 5fdc16df0f3868a06ec107f3ebced05794f60012 Mon Sep 17 00:00:00 2001 From: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:30:06 -0300 Subject: [PATCH 5/6] test: keep all bounded-name tests inside the describe block CodeRabbit follow-up (#4715): the tool-choice and cross-process tests had been appended outside the describe block. Move them inside so the file parses as a single suite under strict parsers. --- tests/responses/bounded-tool-names.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/responses/bounded-tool-names.test.ts b/tests/responses/bounded-tool-names.test.ts index e591f32b92..8077df9a8c 100644 --- a/tests/responses/bounded-tool-names.test.ts +++ b/tests/responses/bounded-tool-names.test.ts @@ -38,7 +38,6 @@ describe("bounded tool wire names (#4679)", () => { expect(wire.length).toBeLessThanOrEqual(64); expect(namespacedToolName(undefined, name)).toBe(wire); }); -}); test("tool choice aliases collapse to a single entry for a bounded alias", () => { const identity = { namespace: "mcp__codex_apps__safety_settings", name: "prepare_parental_control_update" }; @@ -66,3 +65,4 @@ describe("bounded tool wire names (#4679)", () => { expect(second.stdout.trim()).toBe(alias); expect(namespacedToolName(identity.namespace, identity.name)).toBe(alias); }); +}); From b6fd8c942f867bf6332232bf816bb5ef84ba349e Mon Sep 17 00:00:00 2001 From: Hulian Buligon <205309211+HulianBuligon@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:47:36 -0300 Subject: [PATCH 6/6] fix: reserve the whole catalog's wire names before bounded aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit follow-up (#4715): reserveToolWireNames runs once per parsed request — pass one claims every in-limit canonical spelling, pass two allocates bounded aliases for over-limit identities in stable nativeKey order. namespacedToolName then memo-hits, so the identity-to-wire mapping never depends on declaration order or on which surface derives the name first. Regression test derives the mapping in two fresh processes with opposite declaration orders and requires identical identity-to-alias maps. --- src/responses/parser.ts | 6 +++- src/types.ts | 1 + src/types/tools.ts | 34 +++++++++++++++++++--- tests/responses/bounded-tool-names.test.ts | 27 ++++++++++++++++- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 396f2170b2..1b7647714e 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -11,7 +11,7 @@ import type { OcxToolCall, OcxReasoningReplayScopeRef, } from "../types"; -import { createToolChoiceResolver, namespacedToolName } from "../types"; +import { createToolChoiceResolver, namespacedToolName, reserveToolWireNames } from "../types"; import { responsesRequestSchema } from "./schema"; import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata"; import { lookupReplayThoughtSignature } from "./thought-signature-replay"; @@ -512,6 +512,10 @@ export function parseRequest( ...(mergedTools.length > 0 ? { tools: mergedTools } : {}), }; + // Reserve the whole catalog's wire names up front so bounded-alias allocation never + // depends on the order later surfaces touch the tools in (#4679 review). + reserveToolWireNames(mergedTools); + const options: OcxRequestOptions = {}; if (data.max_output_tokens !== undefined) options.maxOutputTokens = data.max_output_tokens; if (data.temperature !== undefined) options.temperature = data.temperature; diff --git a/src/types.ts b/src/types.ts index 234dbdc0d5..56ccebc132 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ export { CODE_MODE_EXEC_TOOL_NAME, dottedToolName, namespacedToolName, + reserveToolWireNames, normalizeDeclaredToolName, toolChoiceAliases, createToolChoiceResolver, diff --git a/src/types/tools.ts b/src/types/tools.ts index 0acfff7602..3a4d7b266b 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -106,14 +106,40 @@ export function namespacedToolName(namespace: string | undefined, name: string): const flat = namespace ? `${namespace}__${name}` : name; const nativeKey = nativeKeyOf(namespace, name); if (flat.length <= TOOL_NAME_WIRE_LIMIT) { - // Canonical names are claimed too, so a bounded alias can never take (or lose) the - // spelling and route a call to the wrong tool after a restart or catalog reorder. - claimWireName(flat, nativeKey); - return flat; + // A canonical that collides with an already-allocated bounded alias is re-aliased + // instead of shadowing it. In the normal request flow `reserveToolWireNames` ran first, + // so this path never triggers and in-limit names keep their plain spelling. + if (claimWireName(flat, nativeKey)) return flat; + return boundedToolWireAlias(nativeKey, flat); } return boundedToolWireAlias(nativeKey, flat); } +/** + * Pass-one wire-name reservation for a complete request catalog: every in-limit canonical + * name is claimed first, then bounded aliases for over-limit identities are allocated in + * stable identity (nativeKey) order. Call once per parsed request before any wire name is + * derived; afterwards `namespacedToolName`/`dottedToolName` memo-hits, so the identity→wire + * mapping never depends on the order later callers touch the tools in (#4679 review). + */ +export function reserveToolWireNames(tools: readonly Pick[] | undefined): void { + if (!tools || tools.length === 0) return; + const overLimit: { nativeKey: string; flat: string }[] = []; + for (const tool of tools) { + if (!tool || typeof tool.name !== "string" || tool.name.length === 0) continue; + const flat = tool.namespace ? `${tool.namespace}__${tool.name}` : tool.name; + if (flat.length <= TOOL_NAME_WIRE_LIMIT) { + claimWireName(flat, nativeKeyOf(tool.namespace, tool.name)); + continue; + } + overLimit.push({ nativeKey: nativeKeyOf(tool.namespace, tool.name), flat }); + } + overLimit.sort((left, right) => (left.nativeKey < right.nativeKey ? -1 : left.nativeKey > right.nativeKey ? 1 : 0)); + for (const entry of overLimit) { + if (!boundedAliasByNative.has(entry.nativeKey)) boundedToolWireAlias(entry.nativeKey, entry.flat); + } +} + /** * Dotted alias of a namespaced tool's wire name. Some routed providers (observed: muse-spark * via opencode-go) echo a namespaced tool call as "." instead of the flattened diff --git a/tests/responses/bounded-tool-names.test.ts b/tests/responses/bounded-tool-names.test.ts index 8077df9a8c..10a2bd4567 100644 --- a/tests/responses/bounded-tool-names.test.ts +++ b/tests/responses/bounded-tool-names.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { describe, expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; -import { dottedToolName, namespacedToolName, toolChoiceAliases } from "../../src/types/tools"; +import { dottedToolName, namespacedToolName, reserveToolWireNames, toolChoiceAliases } from "../../src/types/tools"; describe("bounded tool wire names (#4679)", () => { test("keeps flat names at or under the 64-char wire limit unchanged", () => { @@ -66,3 +66,28 @@ describe("bounded tool wire names (#4679)", () => { expect(namespacedToolName(identity.namespace, identity.name)).toBe(alias); }); }); + + test("alias mapping is independent of declaration order (catalog reservation)", () => { + const catalog = [ + { namespace: "mcp__codex_apps__safety_settings", name: "prepare_parental_control_update" }, + { namespace: "mcp__codex_apps__safety_settings", name: "update_parental_controls" }, + { namespace: "mcp__codex_apps__codex_document_control", name: "get_document_tool_schemas" }, + ]; + const runOrder = (order: number[]) => { + const tools = JSON.stringify(order.map(i => catalog[i])); + const script = + "(async () => {" + + `const m = await import(new URL(${JSON.stringify("file://" + repoPath("src", "types", "tools.ts"))}).href);` + + `const tools = ${tools};` + + "m.reserveToolWireNames(tools);" + + "console.log(JSON.stringify(tools.map(t => m.namespacedToolName(t.namespace, t.name))));" + + "})();"; + const r = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + expect(r.status).toBe(0); + return Object.fromEntries(JSON.parse(r.stdout.trim()).map((wire: string, i: number) => [`${catalog[order[i]].namespace}__${catalog[order[i]].name}`, wire])); + }; + const forward = runOrder([0, 1, 2]); + const reversed = runOrder([2, 1, 0]); + expect(reversed).toEqual(forward); + for (const wire of Object.values(forward)) expect(wire.length).toBeLessThanOrEqual(64); + });