mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
* 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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1
tests/unit/namespace-tool-identity-integration.test.ts
Normal file
1
tests/unit/namespace-tool-identity-integration.test.ts
Normal file
File diff suppressed because one or more lines are too long
71
tests/unit/responses-chat-namespace-identity-map.test.ts
Normal file
71
tests/unit/responses-chat-namespace-identity-map.test.ts
Normal 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");
|
||||
});
|
||||
11
tests/unit/responses-chat-namespace-qualified-name.test.ts
Normal file
11
tests/unit/responses-chat-namespace-qualified-name.test.ts
Normal 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"] );});
|
||||
85
tests/unit/responses-tool-search-call-skip.test.ts
Normal file
85
tests/unit/responses-tool-search-call-skip.test.ts
Normal 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"]
|
||||
);
|
||||
});
|
||||
@@ -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" }
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user