diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 4ef7c67b34..6ae0ed1190 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 4d05448f6e..549ce88cac 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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 ); } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 0bc20d6f9b..adee21c781 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -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(); + // #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____` and every MCP call failed with // `unsupported call: mcp____`. 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; } diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 627d6671c8..f47cd1c5c0 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -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, diff --git a/open-sse/translator/response/openai-responses/requestToolIdentity.ts b/open-sse/translator/response/openai-responses/requestToolIdentity.ts new file mode 100644 index 0000000000..94d4b4cb80 --- /dev/null +++ b/open-sse/translator/response/openai-responses/requestToolIdentity.ts @@ -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)[toolName] + : undefined; + if (!identity || typeof identity !== "object" || Array.isArray(identity)) return null; + const { namespace, name } = identity as Record; + return typeof namespace === "string" && namespace && typeof name === "string" && name + ? { namespace, name } + : null; +} diff --git a/open-sse/translator/response/responsesToolItem.ts b/open-sse/translator/response/responsesToolItem.ts index a1bd062f43..4f8c5ea440 100644 --- a/open-sse/translator/response/responsesToolItem.ts +++ b/open-sse/translator/response/responsesToolItem.ts @@ -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; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 1dd99c6796..c031ddf385 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -143,6 +143,15 @@ type StreamOptions = { body?: unknown; onComplete?: ((payload: StreamCompletePayload) => void) | null; onFailure?: ((payload: StreamFailurePayload) => boolean | void | Promise) | 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 | null; }; type TranslateState = ReturnType & { @@ -161,6 +170,7 @@ type TranslateState = ReturnType & { /** #6951 — per-tool JSON Schema (from request `tools[]`), keyed by tool name. */ toolSchemas?: Map> | null; customToolNames?: ReadonlySet; + requestToolIdentityMap?: Map | 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` 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 | 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(), + 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) | null = null, copilotCompatibleReasoning = false, suppressThinkClose = false, - customToolNames: ReadonlySet = new Set() + customToolNames: ReadonlySet = new Set(), + requestToolIdentityMap: Map | 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) | null = null, - clientResponseFormat: string | null = null + clientResponseFormat: string | null = null, + requestToolIdentityMap: Map | null = null ) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, @@ -2793,6 +2861,7 @@ export function createPassthroughStreamWithLogger( onComplete, onFailure, clientResponseFormat, + requestToolIdentityMap, }); } diff --git a/tests/unit/namespace-tool-identity-integration.test.ts b/tests/unit/namespace-tool-identity-integration.test.ts new file mode 100644 index 0000000000..5406ae3e3f --- /dev/null +++ b/tests/unit/namespace-tool-identity-integration.test.ts @@ -0,0 +1 @@ +// @ts-nocheckimport test from "node:test";import assert from "node:assert/strict";const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");const originalFetch = globalThis.fetch;type ToolItem = { type: string; namespace?: string; name?: string };type SseFrame = { event: string | undefined; data: unknown };function requestBody(stream: boolean) { return { model: "gpt-4o-mini", stream, input: [ { type: "additional_tools", tools: [ { type: "namespace", name: "mcp__atlassian__cloud__tenant", tools: [{ name: "read_issue", parameters: { type: "object" } }], }, ], }, { type: "message", role: "user", content: [{ type: "input_text", text: "read it" }] }, ], };}function chatToolCall() { return { id: "chatcmpl_namespace_identity", object: "chat.completion", created: 1, model: "gpt-4o-mini", choices: [ { index: 0, message: { role: "assistant", tool_calls: [ { index: 0, id: "call_namespace_identity", type: "function", function: { name: "mcp__atlassian__cloud__tenant__read_issue", arguments: JSON.stringify({ key: "PROJ-1" }), }, }, ], }, finish_reason: "tool_calls", }, ], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, };}function streamingChatToolCall() { const toolCall = chatToolCall().choices[0].message.tool_calls[0]; return [ { id: "chatcmpl_namespace_identity", object: "chat.completion.chunk", created: 1, model: "gpt-4o-mini", choices: [ { index: 0, delta: { role: "assistant", tool_calls: [toolCall] }, finish_reason: "tool_calls", }, ], }, ];}function sse(events: unknown[]) { return ( events.map((event) => "data: " + JSON.stringify(event) + "\n\n").join("") + "data: [DONE]\n\n" );}async function invokeResponsesRequest(stream: boolean) { const calls: Array<{ body: Record }> = []; globalThis.fetch = async (_url, init = {}) => { const body = JSON.parse(String(init.body)); calls.push({ body }); const payload = stream ? sse(streamingChatToolCall()) : JSON.stringify(chatToolCall()); return new Response(payload, { status: 200, headers: { "Content-Type": stream ? "text/event-stream" : "application/json" }, }); }; try { const body = requestBody(stream); const result = await handleChatCore({ body: structuredClone(body), modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false }, credentials: { apiKey: "sk-test", providerSpecificData: {} }, log: { debug() {}, info() {}, warn() {}, error() {} }, clientRawRequest: { endpoint: "/v1/responses", body: structuredClone(body), headers: new Headers({ accept: stream ? "text/event-stream" : "application/json" }), }, userAgent: "namespace-identity-integration-test", } as never); assert.equal(result.success, true); assert.equal(calls.length, 1); assert.equal( "_toolNameMap" in calls[0].body, false, "identity metadata must not reach upstream" ); return result.response; } finally { globalThis.fetch = originalFetch; }}function parseSseFrames(body: string): SseFrame[] { return body .trim() .split(/\r?\n\r?\n/) .map((frame) => { const fields = frame.split(/\r?\n/); const event = fields.find((line) => line.startsWith("event: "))?.slice("event: ".length); const data = fields.find((line) => line.startsWith("data: "))?.slice("data: ".length); return { event, data: data === "[DONE]" ? data : JSON.parse(data ?? "null") }; });}function assertNamespaceTuple(item: ToolItem | undefined) { assert.ok(item, "expected a function-call output item"); assert.equal(item.type, "function_call"); assert.deepEqual( { namespace: item.namespace, name: item.name }, { namespace: "mcp__atlassian__cloud__tenant", name: "read_issue" } );}test.afterEach(() => { globalThis.fetch = originalFetch;});test("returns a namespace tuple for a non-streaming Responses request translated through chatCore", async () => { const response = await invokeResponsesRequest(false); const payload = await response.json(); assertNamespaceTuple(payload.output.find((item: ToolItem) => item.type === "function_call"));});test("emits namespace tuples for every streaming Responses function-call event translated through chatCore", async () => { const response = await invokeResponsesRequest(true); const frames = parseSseFrames(await response.text()); const added = frames.find((frame) => frame.event === "response.output_item.added"); assertNamespaceTuple((added?.data as { item?: ToolItem })?.item); const done = frames.find((frame) => frame.event === "response.output_item.done"); assertNamespaceTuple((done?.data as { item?: ToolItem })?.item); const completed = frames.find((frame) => frame.event === "response.completed"); assertNamespaceTuple( (completed?.data as { response?: { output?: ToolItem[] } })?.response?.output?.find( (item) => item.type === "function_call" ) );}); diff --git a/tests/unit/responses-chat-namespace-identity-map.test.ts b/tests/unit/responses-chat-namespace-identity-map.test.ts new file mode 100644 index 0000000000..c6129c3768 --- /dev/null +++ b/tests/unit/responses-chat-namespace-identity-map.test.ts @@ -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; +}; + +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"); +}); diff --git a/tests/unit/responses-chat-namespace-qualified-name.test.ts b/tests/unit/responses-chat-namespace-qualified-name.test.ts new file mode 100644 index 0000000000..94533efde0 --- /dev/null +++ b/tests/unit/responses-chat-namespace-qualified-name.test.ts @@ -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____ 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"] );}); diff --git a/tests/unit/responses-tool-search-call-skip.test.ts b/tests/unit/responses-tool-search-call-skip.test.ts new file mode 100644 index 0000000000..f94b5f6df4 --- /dev/null +++ b/tests/unit/responses-tool-search-call-skip.test.ts @@ -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"] + ); +}); diff --git a/tests/unit/translator-resp-openai-responses-namespace-identity.test.ts b/tests/unit/translator-resp-openai-responses-namespace-identity.test.ts new file mode 100644 index 0000000000..8072daee76 --- /dev/null +++ b/tests/unit/translator-resp-openai-responses-namespace-identity.test.ts @@ -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 & { + requestToolIdentityMap?: Map; +}; + +// 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; + tools: Array<{ function: { name: string } }>; + }; + assert.ok(request._toolNameMap instanceof Map); + return request._toolNameMap; +} + +function collectToolEvents( + name: string, + callId: string, + requestToolIdentityMap?: Map +) { + 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 }; + // 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" } + ); +});