fix(responses): close namespace round-trip for Responses-Chat translation (#7936) (#8151)

* fix(responses): close namespace round-trip for Responses-Chat translation (#7936)

The #7905 custom-tool-call path landed in release/v3.8.49 but left #7936
open: Responses namespace sub-tools were flattened to a bare leaf on the
Chat wire with no response-side closure, so Codex's adjudicator rejected
every namespace sub-tool call with `unsupported call` -- it only has a
dispatch entry for the header bits (namespace+name), no entry for the
bare leaf.

This patch closes the round-trip without mutating the Chat wire name
(alignment with #7905's bare-leaf contract and with the issue author's
proposed fix):

* request side (openai-responses.ts): keep tool.function.name as the bare
  leaf, populate a side-band namespaceToolIdentityMap keyed on that leaf,
  and thread it through translatedBody._toolNameMap.
* request -> response seam (chatCore.ts): extract the identity map before
  dispatch and pass it through to the non-stream completion path and to
  all three stream pipelines (translate openai-responses, translate
  other, passthrough).
* response translator (response/openai-responses.ts): in emitToolCall
  (response.output_item.added) and closeToolCall (custom_tool_call /
  function_call output_item.done), call resolveRequestToolIdentity() to
  rewrite the bare leaf back to {namespace,name} and emit codex-compatible
  independent fields.
* passthrough (utils/stream.ts): add a response passthrough rewriter
  restoreResponsesPassthroughFunctionCallIdentity that intercepts
  response.output_item.added, response.output_item.done, and
  response.completed and stamps the same {namespace,name} tuple.
* helper (requestToolIdentity.ts): a 20-line stateless resolver; never
  parses a name.

The wire-visible Chat tool.function.name stays the bare leaf -- non-OpenAI
providers (NVIDIA, GLM, Kimi, Gemini, ...) frequently truncate or rewrite
long __-dotted names; bare leaves avoid that failure mode entirely. The
codex ResponseItem::FunctionCall schema (models.rs) declares an
independent namespace: Option<String> field and has a
function_call_deserializes_optional_namespace round-trip test, so
emitting it separately matches the codex adjudicator dispatch.

Includes 19 new test cases across 4 files:
- request-side bare-leaf wire + side-band ledger construction
- response-side tuple emit + unmapped passthrough + apply_patch exclusion
- ambiguous-leaf collision safety (entry dropped, leaf emits verbatim)
- per-request isolation between concurrent streams
- a precompiled Atlassian-style nested namespace override

* fix(responses): skip tool_search_call input items instead of 400 (#7936 addendum)

Codex 0.42+ emits `tool_search_call` (and later `tool_search_result`) input
items when the model uses the dynamic tool-search optimization. They are
metadata-only: they record that the model queried a subset of the
available tools, and carry nothing that OpenAI Chat Completions can
represent.

Without an explicit skip in openai-responses.ts, the input loop threw
Unsupported Responses API feature: input item type 'tool_search_call'
cannot be represented in Chat Completions -- and because these items
stay in the Responses API `input` for every follow-up turn, the whole
server returned 400 on EVERY subsequent /v1/responses in the same
session until the user cleared history.

Observed in the wild:
  /v1/responses 400 "Unsupported Responses API feature: input item
  type 'tool_search_call' cannot be represented in Chat Completions
  [longcat/LongCat-2.0 (400), longcat/LongCat-2.0 (400)]"

The meituan combo (longcat fallback) was the most visible victim, but
the underlying throw is source-format-side and hits any Responses-API
consumer whose upstream does not natively support Responses.

Fix: stop on the item type the same way `reasoning` is skipped --
display-only metadata, no chat side-effect. Covers both
`tool_search_call` and the follow-up `tool_search_result` shapes.

Adds 3 unit tests:
  - tool_search_call is silently skipped (no 400)
  - tool_search_result is silently skipped
  - tool_search_call items interspersed with real messages are skipped
    in order; real messages survive

* chore(quality): rebaseline openai-responses.ts + stream.ts own-growth (#7936 namespace round-trip)

---------

Co-authored-by: TonPro <hello@tonpro.fu>
Co-authored-by: RCrushMe <RCrushMe@users.noreply.github.com>
This commit is contained in:
RCrushMe
2026-07-22 20:38:30 +00:00
committed by GitHub
parent 7ae17168db
commit 89bad0fa52
12 changed files with 622 additions and 32 deletions

View File

@@ -213,9 +213,10 @@
"open-sse/services/usage.ts": 3454,
"open-sse/translator/request/openai-to-gemini.ts": 906,
"open-sse/translator/request/openai-to-kiro.ts": 912,
"open-sse/translator/response/openai-responses.ts": 1092,
"_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.",
"open-sse/translator/response/openai-responses.ts": 1125,
"open-sse/utils/cursorAgentProtobuf.ts": 1521,
"open-sse/utils/stream.ts": 2814,
"open-sse/utils/stream.ts": 2869,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3120,

View File

@@ -110,7 +110,10 @@ import {
} from "../services/modelStrip.ts";
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { normalizeMimoThinking } from "../services/mimoThinking.ts";
import { isOpencodeGoProvider, stripBooleanReasoning } from "../services/opencodeReasoningSanitizer.ts";
import {
isOpencodeGoProvider,
stripBooleanReasoning,
} from "../services/opencodeReasoningSanitizer.ts";
import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts";
import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts";
import { applyDefaultReasoningEffort } from "../services/defaultReasoningEffort.ts";
@@ -2151,6 +2154,14 @@ export async function handleChatCore({
trace("post_translation");
// Keep the request translator's namespace identities separate from toolNameMap:
// the latter is a Kiro/Claude passthrough alias channel with string values,
// while namespace identities carry `{namespace, name}` for the #7936 response
// seam. Extract first because Kiro merge may reuse `_toolNameMap` below.
const requestToolIdentityMap =
translatedBody._toolNameMap instanceof Map ? translatedBody._toolNameMap : null;
delete translatedBody._toolNameMap;
// Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly
// formed request" for unsupported JSON-Schema keywords (anyOf/$ref/if-then,
// etc.) and tool names >64 chars. Strip those keys and hash-truncate long
@@ -4134,6 +4145,20 @@ export async function handleChatCore({
// Source format determines output shape. If we are outputting OpenAI shape or pseudo-OpenAI shape, sanitize.
if (clientResponseFormat === FORMATS.OPENAI_RESPONSES) {
translatedResponse = sanitizeResponsesApiResponse(translatedResponse);
// Responses-API non-stream path: restore `{namespace, name}` on every
// `function_call` item that was flattened from a namespace sub-tool on
// the request side (#7936 round-trip closure).
const responseOutput = translatedResponse?.output;
if (requestToolIdentityMap && Array.isArray(responseOutput)) {
for (const item of responseOutput) {
if (item?.type !== "function_call") continue;
const identity = requestToolIdentityMap.get(item.name);
if (identity) {
item.namespace = identity.namespace;
item.name = identity.name;
}
}
}
} else if (clientResponseFormat === FORMATS.OPENAI) {
// Port of decolua/9router#517: opt-in `x-omniroute-strip-reasoning` header
// unconditionally drops `reasoning_content` from the final non-streaming
@@ -4686,7 +4711,11 @@ export async function handleChatCore({
onStreamComplete,
apiKeyInfo,
handleStreamFailure,
copilotCompatibleReasoning
copilotCompatibleReasoning,
// openai-responses → openai translation still wants the namespace identity
// map for #7936-style round-trip closure when the client also speaks
// Responses (Codex CLI).
requestToolIdentityMap
);
} else if (needsTranslation(targetFormat, clientResponseFormat)) {
// Standard translation for other providers
@@ -4715,7 +4744,8 @@ export async function handleChatCore({
thinkingMarkerHeader,
clientResponseFormat,
}),
customToolNames
customToolNames,
requestToolIdentityMap
);
} else {
log?.debug?.("STREAM", `Standard passthrough mode`);
@@ -4729,7 +4759,8 @@ export async function handleChatCore({
onStreamComplete,
apiKeyInfo,
handleStreamFailure,
clientResponseFormat
clientResponseFormat,
requestToolIdentityMap
);
}

View File

@@ -82,6 +82,13 @@ 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.
const namespaceToolIdentityMap = new Map<string, { namespace: string; name: string }>();
// #7533: `verbosity` and `prompt_cache_key` are GPT-5/OpenAI-only Chat Completions
// parameters. A strict-protocol non-OpenAI upstream (NVIDIA confirmed by the reporter;
// likely also GLM/Kimi/Deepseek direct endpoints) 400s on unrecognized top-level
@@ -350,6 +357,17 @@ export function openaiResponsesToOpenAIRequest(
continue;
}
// Skip tool_search_call items. These are Responses-API-only metadata items
// emitted by Codex's dynamic tool-search optimization: they record that the
// model queried a subset of available tools, but carry no content that Chat
// Completions can represent. Throwing here would break every multi-turn
// conversation where Codex previously used tool_search (the whole session
// would carry tool_search_call items forward in `input`). Skipping matches
// the reasoning-item policy: display-only metadata, no chat side-effect.
if (itemType === "tool_search_call" || itemType === "tool_search_result") {
continue;
}
if (itemType === "additional_tools") {
// Already consumed by collectResponsesTools() before message conversion.
continue;
@@ -394,31 +412,53 @@ export function openaiResponsesToOpenAIRequest(
// one empty-schema function named `mcp__<server>__` and every MCP call failed with
// `unsupported call: mcp__<server>__`.
if (toolType === "namespace") {
const nsName = toString(tool.name);
const subTools = Array.isArray(tool.tools) ? tool.tools : [];
return subTools
.map((subValue) => toRecord(subValue))
.filter((sub) => toString(sub.name))
.map((sub) => ({
type: "function",
function: {
name: toString(sub.name),
description: toString(sub.description),
parameters:
toString(sub.type) === "custom"
? {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
additionalProperties: false,
}
: (sub.parameters ??
sub.input_schema ?? {
type: "object",
properties: {},
}),
strict: sub.strict,
},
}));
.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.
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);
}
}
return {
type: "function",
function: {
name: leaf,
description: toString(sub.description),
parameters:
toString(sub.type) === "custom"
? {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
additionalProperties: false,
}
: (sub.parameters ??
sub.input_schema ?? {
type: "object",
properties: {},
}),
strict: sub.strict,
},
};
});
}
// tool_search (#2766) is a Responses API built-in Codex sends with
// `execution: "client"` — the CLIENT (Codex CLI) resolves the call locally,
@@ -665,6 +705,17 @@ export function openaiResponsesToOpenAIRequest(
delete result.prompt_cache_options;
delete result.prompt_cache_retention;
if (namespaceToolIdentityMap.size > 0) {
// chatCore extracts and deletes this transient side channel before dispatch.
// Non-enumerability keeps internal request metadata off the upstream wire.
Object.defineProperty(result, "_toolNameMap", {
value: namespaceToolIdentityMap,
enumerable: false,
configurable: true,
writable: true,
});
}
return result;
}

View File

@@ -17,6 +17,7 @@ import {
} from "./openai-responses/pureHelpers.ts";
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
import { buildResponsesToolCallItem } from "./responsesToolItem.ts";
import { resolveRequestToolIdentity } from "./openai-responses/requestToolIdentity.ts";
import {
synthesizeCompletedToolCalls,
computeFinishReason,
@@ -446,10 +447,19 @@ function emitToolCall(state, emit, tc) {
const callId = state.funcCallIds[tcIdx];
if (callId && toolName && !state.funcItemAdded[tcIdx]) {
// #7936 — restore the codex-side `{namespace, name}` pair when the bare
// leaf on the Chat wire was flattened from a Responses namespace sub-tool.
// Codex dispatches from `namespace` independently of `name` (no `__` split).
const identity = resolveRequestToolIdentity(state.requestToolIdentityMap, toolName);
emit("response.output_item.added", {
type: "response.output_item.added",
output_index: outputIndex,
item: buildResponsesToolCallItem({ callId, toolName, custom: isCustomTool }),
item: buildResponsesToolCallItem({
callId,
toolName: identity ? identity.name : toolName,
custom: isCustomTool,
namespace: identity ? identity.namespace : null,
}),
});
state.funcItemAdded[tcIdx] = true;
@@ -531,6 +541,17 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
status: "completed",
};
// #7936 identity closure for custom_tool_call items (apply_patch stays
// bare; namespace sub-tools get back their `namespace` + `name`).
const customIdentity = resolveRequestToolIdentity(
state.requestToolIdentityMap,
state.funcNames[idx] || ""
);
if (customIdentity) {
funcItem.namespace = customIdentity.namespace;
funcItem.name = customIdentity.name;
}
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: normalizedIndex,
@@ -553,6 +574,19 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
status: "completed",
};
// #7936 identity closure: rewrite the function_call item's `name` back to
// its bare leaf and stamp the original `namespace` alongside it, matching
// the codex ResponseItem::FunctionCall schema (independent `namespace`
// field, NOT a `__` split on `name`).
const fnIdentity = resolveRequestToolIdentity(
state.requestToolIdentityMap,
state.funcNames[idx] || ""
);
if (fnIdentity) {
funcItem.namespace = fnIdentity.namespace;
funcItem.name = fnIdentity.name;
}
emit("response.output_item.done", {
type: "response.output_item.done",
output_index: normalizedIndex,

View File

@@ -0,0 +1,17 @@
/** * Resolve a flattened Chat function name back to the identity declared by the * request's Responses namespace tool. The request path supplies this map on * the response translation state; this resolver intentionally never parses a name. */ export function resolveRequestToolIdentity(
identityMap: unknown,
toolName: string
) {
if (!toolName || !identityMap) return null;
const identity =
identityMap instanceof Map
? identityMap.get(toolName)
: typeof identityMap === "object" && !Array.isArray(identityMap)
? (identityMap as Record<string, unknown>)[toolName]
: undefined;
if (!identity || typeof identity !== "object" || Array.isArray(identity)) return null;
const { namespace, name } = identity as Record<string, unknown>;
return typeof namespace === "string" && namespace && typeof name === "string" && name
? { namespace, name }
: null;
}

View File

@@ -2,9 +2,19 @@ export function buildResponsesToolCallItem(options: {
callId: string;
toolName: string;
custom: boolean;
namespace?: string | null;
}) {
const { callId, toolName, custom } = options;
return {
const { callId, toolName, custom, namespace } = options;
const item: {
id: string;
type: string;
arguments?: string;
input?: string;
call_id: string;
name: string;
namespace?: string;
status: string;
} = {
id: `fc_${callId}`,
type: custom ? "custom_tool_call" : "function_call",
...(custom ? { input: "" } : { arguments: "" }),
@@ -12,4 +22,11 @@ export function buildResponsesToolCallItem(options: {
name: toolName,
status: "in_progress",
};
// Codex's ResponseItem::FunctionCall / CustomToolCall both accept an optional
// `namespace` field and dispatch on it independently of `name` (see
// codex-rs/protocol/src/models.rs and the `function_call_deserializes_optional_namespace`
// round-trip test). Emit it whenever the request-side identity ledger resolved
// the bare leaf back to a namespace sub-tool.
if (namespace) item.namespace = namespace;
return item;
}

View File

@@ -143,6 +143,15 @@ type StreamOptions = {
body?: unknown;
onComplete?: ((payload: StreamCompletePayload) => void) | null;
onFailure?: ((payload: StreamFailurePayload) => boolean | void | Promise<void>) | null;
/**
* Request-scoped `{namespace, name}` ledger for Responses namespace child
* tools that were flattened to a bare leaf on the Chat wire (#7936
* round-trip closure). The Responses response translator keys on the leaf
* name emitted in `response.function_call_arguments.*` /
* `response.output_item.added` / `response.output_item.done` and emits
* codex-compatible `namespace` + `name` fields.
*/
requestToolIdentityMap?: Map<string, { namespace: string; name: string }> | null;
};
type TranslateState = ReturnType<typeof initState> & {
@@ -161,6 +170,7 @@ type TranslateState = ReturnType<typeof initState> & {
/** #6951 — per-tool JSON Schema (from request `tools[]`), keyed by tool name. */
toolSchemas?: Map<string, Record<string, unknown>> | null;
customToolNames?: ReadonlySet<string>;
requestToolIdentityMap?: Map<string, { namespace: string; name: string }> | null;
upstreamError?: {
status: number;
type: string;
@@ -182,6 +192,47 @@ function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
// #7936 — restore `{namespace, name}` on Responses passthrough `function_call`
// items when the request-side Responses→Chat flatten stamped the bare leaf on the
// Chat wire. Codex's ResponseItem::FunctionCall schema declares an independent
// `namespace: Option<String>` field (see codex-rs/protocol/src/models.rs and the
// `function_call_deserializes_optional_namespace` round-trip test); emit it back.
function restoreResponsesPassthroughFunctionCallIdentity(
parsed: JsonRecord,
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null | undefined
): boolean {
if (!(requestToolIdentityMap instanceof Map)) return false;
const restoreItem = (item: unknown): boolean => {
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
const functionCall = item as JsonRecord;
if (functionCall.type !== "function_call" || typeof functionCall.name !== "string")
return false;
const identity = requestToolIdentityMap.get(functionCall.name);
if (!identity) return false;
const changed =
functionCall.namespace !== identity.namespace || functionCall.name !== identity.name;
functionCall.namespace = identity.namespace;
functionCall.name = identity.name;
return changed;
};
if (parsed.type === "response.output_item.added" || parsed.type === "response.output_item.done") {
return restoreItem(parsed.item);
}
if (parsed.type === "response.completed" && Array.isArray(parsed.response?.output)) {
return (parsed.response as JsonRecord).output.reduce(
(changed: boolean, item: unknown) => restoreItem(item) || changed,
false
);
}
return false;
}
function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null {
const candidate = parseTextualToolCallCandidate(text);
return candidate?.kind === "complete" ? { name: candidate.name, args: candidate.args } : null;
@@ -634,6 +685,7 @@ export function createSSEStream(options: StreamOptions = {}) {
onFailure = null,
dropResponsesCommentary,
customToolNames = new Set<string>(),
requestToolIdentityMap = null,
} = options;
const signatureNamespace = connectionId;
// Request-body-size metric (for monitoring payload size distribution & correlation with TTFT).
@@ -709,6 +761,7 @@ export function createSSEStream(options: StreamOptions = {}) {
accumulatedReasoning: "",
toolSchemas: extractToolSchemaMap(body),
customToolNames,
requestToolIdentityMap,
}
: null;
@@ -1481,6 +1534,18 @@ export function createSSEStream(options: StreamOptions = {}) {
parsed.response.output
);
}
// #7936 — restore `namespace` + `name` fields on passthrough
// Responses function_call items for downstream Codex clients.
if (
parsed.type === "response.output_item.added" ||
parsed.type === "response.output_item.done" ||
parsed.type === "response.completed"
) {
restoreResponsesPassthroughFunctionCallIdentity(
parsed as JsonRecord,
requestToolIdentityMap
);
}
if (
parsed.type === "response.completed" &&
passthroughResponsesPendingFunctionCalls.size > 0
@@ -2748,7 +2813,8 @@ export function createSSETransformStreamWithLogger(
onFailure: ((payload: StreamFailurePayload) => void | Promise<void>) | null = null,
copilotCompatibleReasoning = false,
suppressThinkClose = false,
customToolNames: ReadonlySet<string> = new Set()
customToolNames: ReadonlySet<string> = new Set(),
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null
) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
@@ -2766,6 +2832,7 @@ export function createSSETransformStreamWithLogger(
copilotCompatibleReasoning,
suppressThinkClose,
customToolNames,
requestToolIdentityMap,
});
}
@@ -2779,7 +2846,8 @@ export function createPassthroughStreamWithLogger(
onComplete: ((payload: StreamCompletePayload) => void) | null = null,
apiKeyInfo: unknown = null,
onFailure: ((payload: StreamFailurePayload) => void | Promise<void>) | null = null,
clientResponseFormat: string | null = null
clientResponseFormat: string | null = null,
requestToolIdentityMap: Map<string, { namespace: string; name: string }> | null = null
) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
@@ -2793,6 +2861,7 @@ export function createPassthroughStreamWithLogger(
onComplete,
onFailure,
clientResponseFormat,
requestToolIdentityMap,
});
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,71 @@
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;
}
// #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", () => {
const result = translate([
{
type: "namespace",
name: "mcp__alpha",
tools: [{ name: "read", parameters: { type: "object" } }],
},
{
type: "namespace",
name: "mcp__beta",
tools: [{ name: "read", parameters: { type: "object" } }],
},
{
type: "namespace",
name: "mcp__trailing__",
tools: [{ name: "write", parameters: { type: "object" } }],
},
{ 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).
assert.deepEqual(
result.tools.map((tool) => tool.function.name),
["read", "read", "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.
assert.ok(result._toolNameMap instanceof Map);
assert.deepEqual(
[...result._toolNameMap.entries()],
[["write", { namespace: "mcp__trailing__", name: "write" }]]
);
assert.ok(!result._toolNameMap.has("read"), "ambiguous read leaf must be dropped");
});

View File

@@ -0,0 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
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"] );});

View File

@@ -0,0 +1,85 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { translateRequest } from "../../open-sse/translator/index.js";
/**
* Responses API emit `tool_search_call` (and later `tool_search_result`) items
* when the model uses Codex's dynamic tool-search optimization. They are
* metadata-only — there is no Chat Completions representation. Without an
* explicit skip in the Responses→Chat translator, the input loop throws
* `Unsupported Responses API feature: input item type 'tool_search_call' ...`
* and breaks the whole server. See logs: when Codex leaves
* `input:[{type:"tool_search_call", ...}]` in a follow-up round, every
* subsequent /v1/responses returns 400 until the user manually clears history.
*/
test("tool_search_call input item is silently skipped (not 400)", () => {
const body = {
model: "test-model",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] },
{ type: "tool_search_call", tool_search_query: "ctx tools", count: 3 },
],
stream: false,
};
let result;
assert.doesNotThrow(() => {
result = translateRequest("openai-responses", "openai", "test-model", body, false);
}, "tool_search_call must not throw");
assert.ok(result && typeof result === "object");
// message remains, not dropped
const messages = (result as { messages?: unknown }).messages as
Array<{ role?: string; content?: unknown }> | undefined;
assert.ok(Array.isArray(messages));
assert.equal(messages.length, 1, "only the user message should remain");
assert.equal(messages[0].role, "user");
});
test("tool_search_result input item is silently skipped", () => {
const body = {
model: "test-model",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "yo" }] },
{
type: "tool_search_result",
tool_search_query: "ctx tools",
matched: ["ctx_search", "ctx_insight"],
},
],
stream: false,
};
let result;
assert.doesNotThrow(() => {
result = translateRequest("openai-responses", "openai", "test-model", body, false);
}, "tool_search_result must not throw");
const messages = (result as { messages?: unknown }).messages as
Array<{ role?: string }> | undefined;
assert.ok(Array.isArray(messages));
assert.equal(messages.length, 1);
assert.equal(messages[0].role, "user");
});
test("multiple tool_search_call items interspersed with messages are skipped in order", () => {
const body = {
model: "test-model",
input: [
{ type: "tool_search_call", q: "first" },
{ type: "message", role: "user", content: [{ type: "input_text", text: "a" }] },
{ type: "tool_search_call", q: "mid" },
{ type: "tool_search_call", q: "second mid" },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "b" }] },
],
stream: false,
};
let result;
assert.doesNotThrow(() => {
result = translateRequest("openai-responses", "openai", "test-model", body, false);
});
const messages = (result as { messages?: unknown }).messages as
Array<{ role?: string; content?: unknown }> | undefined;
assert.ok(Array.isArray(messages));
assert.equal(messages.length, 2, "only the two real messages survive");
assert.deepEqual(
messages.map((m) => m.role),
["user", "assistant"]
);
});

View File

@@ -0,0 +1,202 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiResponsesToOpenAIRequest } =
await import("../../open-sse/translator/request/openai-responses.ts");
const { openaiToOpenAIResponsesResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
const { initState } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
type NamespaceIdentity = { namespace: string; name: string };
type RuntimeState = ReturnType<typeof initState> & {
requestToolIdentityMap?: Map<string, NamespaceIdentity>;
};
// 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.
function identityMapFor(namespace: string, name: string) {
const request = openaiResponsesToOpenAIRequest(
"any-model",
{
input: [
{
type: "additional_tools",
tools: [{ type: "namespace", name: namespace, tools: [{ name }] }],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] },
],
},
false,
{ provider: "any-provider" }
) as {
_toolNameMap?: Map<string, NamespaceIdentity>;
tools: Array<{ function: { name: string } }>;
};
assert.ok(request._toolNameMap instanceof Map);
return request._toolNameMap;
}
function collectToolEvents(
name: string,
callId: string,
requestToolIdentityMap?: Map<string, NamespaceIdentity>
) {
const state = initState(FORMATS.OPENAI_RESPONSES) as RuntimeState;
state.requestToolIdentityMap = requestToolIdentityMap;
const first = openaiToOpenAIResponsesResponse(
{
id: "chatcmpl-namespace-identity",
model: "gpt-4.1",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: callId,
type: "function",
function: { name, arguments: '{"path":"/tmp/file"}' },
},
],
},
finish_reason: "tool_calls",
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
state
);
return first;
}
type ResponseItem = { type: string; name: string; namespace?: string };
type ResponseEvent = {
event: string;
data: { item?: ResponseItem; response?: { output?: ResponseItem[] } };
};
function functionItems(events: ResponseEvent[]) {
const added = events.find((event) => event.event === "response.output_item.added");
const done = events.find((event) => event.event === "response.output_item.done");
const completed = events.find((event) => event.event === "response.completed");
assert.ok(added?.data.item, "expected response.output_item.added");
assert.ok(done?.data.item, "expected response.output_item.done");
assert.ok(completed?.data.response?.output?.[0], "expected response.completed");
return {
added: added.data.item,
done: done.data.item,
completed: completed.data.response.output[0],
};
}
// 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.
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"));
for (const item of Object.values(functionItems(events))) {
assert.deepEqual(
{ namespace: item.namespace, name: item.name },
{ namespace: "mcp__1mcp", name: "tool_list" }
);
}
});
// 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.
const request = openaiResponsesToOpenAIRequest(
"any-model",
{
input: [
{
type: "additional_tools",
tools: [
{ type: "namespace", name: "mcp__alpha", tools: [{ name: "read" }] },
{ type: "namespace", name: "mcp__beta", tools: [{ name: "read" }] },
],
},
{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] },
],
},
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);
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);
}
});
// A precompiled ledger (e.g. #2195 Atlassian nested namespace tenant) supplies
// its own mapped identity, overriding the leaf lookup. The response translator
// resolves whatever key the model echoed back against this ledger.
test("Chat -> Responses restores a precompiled Atlassian nested namespace", () => {
const leaf = "read_issue";
const events = collectToolEvents(
leaf,
"call_atlassian",
new Map([[leaf, { namespace: "mcp__atlassian__cloud__tenant", name: "read_issue" }]])
);
for (const item of Object.values(functionItems(events))) {
assert.deepEqual(
{ namespace: item.namespace, name: item.name },
{ namespace: "mcp__atlassian__cloud__tenant", name: "read_issue" }
);
}
});
test("Chat -> Responses leaves unmapped top-level tools without a namespace", () => {
const events = collectToolEvents("list_mcp_resources", "call_top_level");
for (const item of Object.values(functionItems(events))) {
assert.equal(item.name, "list_mcp_resources");
assert.equal("namespace" in item, false);
}
});
test("Chat -> Responses keeps apply_patch as a custom tool without namespace restoration", () => {
const events = collectToolEvents("apply_patch", "call_patch");
const added = events.find((event) => event.event === "response.output_item.added");
const done = events.find((event) => event.event === "response.output_item.done");
const completed = events.find((event) => event.event === "response.completed");
assert.ok(added);
assert.ok(done);
assert.ok(completed);
for (const item of [added.data.item, done.data.item, completed.data.response.output[0]]) {
assert.equal(item.type, "custom_tool_call");
assert.equal(item.name, "apply_patch");
assert.equal("namespace" in item, false);
}
});
test("Chat -> Responses keeps same leaves isolated between per-request stream states", () => {
// Two independent streams issuing the same bare leaf "read" resolve to
// different namespaces because the ledger is per-request, not global.
const firstMap = identityMapFor("mcp__shared", "read");
const secondMap = new Map(firstMap);
secondMap.delete("read");
secondMap.set("read", { namespace: "mcp__two", name: "read" });
const first = functionItems(collectToolEvents("read", "call_one", firstMap));
const second = functionItems(collectToolEvents("read", "call_two", secondMap));
assert.deepEqual(
{ namespace: first.completed.namespace, name: first.completed.name },
{ namespace: "mcp__shared", name: "read" }
);
assert.deepEqual(
{ namespace: second.completed.namespace, name: second.completed.name },
{ namespace: "mcp__two", name: "read" }
);
});