From 890413a7104fbeca3b01cabb54140d501534465e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:01:12 +0900 Subject: [PATCH 1/8] docs: specify native function completion parity interfaces --- .../040_native_tool_parity.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md index c19a1e2233..afb85c5d50 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -10,3 +10,11 @@ MODIFY existing native Responses repair and namespace tests, or register new dom UPDATE structure/11_compatibility-contracts.md and guides/codex-integration.md with completion parity boundary and inventory. C: standalone synthetic imports with stub tools (no real execution) and remote focused tests/typecheck; all stack PR exact-head hosted CI must pass before merge. Keep original pi-filter/owned-refresh/responses-patch terminal criteria unchanged and satisfy them at final D with PR heads/CI/merge ancestry. Register native GitHub stack, merge approved prefix using async REST and SHA guard, wait for actual merged status, fetch dev and prove all merged SHAs ancestors. No release/deploy/local suites. Stop only verified DONE or actual external blocking evidence. Resource bounds inherited from000. + +## P revalidation at b477b731e + +030 lifecycle+raw payload boundaries are independently reviewed and55 remote tests pass. The existing bridge streams ordinary function argument previews then uses coerceIntegerToolArguments at authoritative arguments.done/item.done;040 mirrors that contract. Unlike executable exec source compilation, numeric representation repair does not require withholding previews. Do not synthesize corrected deltas; correct the authoritative completion events and JSON snapshots, and verify downstream Chat collector consumes those finals. Buffer only early identity-less completion events if correlation needs them; all retention remains budgeted. + +Implementation interfaces locked for disjoint delegation: function-call-compat.ts exports collectFunctionCallRepairSchemas(body), repairFunctionCalls(value, schemas): {value,changed}, repairFunctionCallsInJson(text,schemas). responses-function-tool-repair.ts exports createResponsesFunctionToolRepairBlockRewrite(schemas,budget?). The collector reads only original current-turn ordinary declarations and honors original function-kind/namespace selector restrictions; it can reuse pure namespace lowering to resolve selectors while preserving original schema values. Native forward routing receives an empty repair map. Empty ordinary completed arguments become{}; custom/native wrappers never enter. + +Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to new responses/tool-name-aliases.ts, guard import updates, core integration, namespace tests, layout registration, docs. Worker owns only the two new function repair modules and one new tests/responses/responses-function-tool-repair.test.ts. No shared write paths, no worker commits/FSM/local suites. Core captures schemas after successful adapter buildRequest from the previously captured clientToolAuthorizationBody, then uses same pure repair in remembered continuation and clientJSON, and block repair after custom/tool-search restores before final declaration guard. Each attempt receives fresh block state. From 7b5cb0dc1c67fbe50521f5aa07038f72e3c5b615 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:11:04 +0900 Subject: [PATCH 2/8] fix(responses): restore native function argument and namespace parity --- .../040_native_tool_parity.md | 6 + .../content/docs/guides/codex-integration.md | 6 + scripts/test-layout/layout.json | 1 + src/responses/function-call-compat.ts | 152 +++++++++ src/responses/namespace-tool-compat.ts | 18 +- src/responses/tool-name-aliases.ts | 79 +++++ src/server/responses-function-tool-repair.ts | 178 +++++++++++ src/server/responses-undeclared-tool-guard.ts | 69 +--- src/server/responses/core.ts | 19 +- structure/11_compatibility-contracts.md | 15 + tests/fixtures/test-layout-expected.json | 1 + tests/responses/namespace-tool-compat.test.ts | 39 ++- .../responses-function-tool-repair.test.ts | 301 ++++++++++++++++++ 13 files changed, 806 insertions(+), 78 deletions(-) create mode 100644 src/responses/function-call-compat.ts create mode 100644 src/responses/tool-name-aliases.ts create mode 100644 src/server/responses-function-tool-repair.ts create mode 100644 tests/responses/responses-function-tool-repair.test.ts diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md index afb85c5d50..e1c0c13c91 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -18,3 +18,9 @@ C: standalone synthetic imports with stub tools (no real execution) and remote f Implementation interfaces locked for disjoint delegation: function-call-compat.ts exports collectFunctionCallRepairSchemas(body), repairFunctionCalls(value, schemas): {value,changed}, repairFunctionCallsInJson(text,schemas). responses-function-tool-repair.ts exports createResponsesFunctionToolRepairBlockRewrite(schemas,budget?). The collector reads only original current-turn ordinary declarations and honors original function-kind/namespace selector restrictions; it can reuse pure namespace lowering to resolve selectors while preserving original schema values. Native forward routing receives an empty repair map. Empty ordinary completed arguments become{}; custom/native wrappers never enter. Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to new responses/tool-name-aliases.ts, guard import updates, core integration, namespace tests, layout registration, docs. Worker owns only the two new function repair modules and one new tests/responses/responses-function-tool-repair.test.ts. No shared write paths, no worker commits/FSM/local suites. Core captures schemas after successful adapter buildRequest from the previously captured clientToolAuthorizationBody, then uses same pure repair in remembered continuation and clientJSON, and block repair after custom/tool-search restores before final declaration guard. Each attempt receives fresh block state. + +## Implementation observations + +Namespace aliases are built after custom lowering (openai-responses.ts2410-2432), so an original custom tool can carry lowered kind=function. Preserve that existing namespace restoration kind behavior; only original-schema function repair enforces ordinary function kind. Dotted restoration adds spelling parity, not a new kind conversion. Explicit conflicting namespaces stay untouched. Reserved functions children participate in the shared collision inventory as bare names. The existing namespace tests are updated for additional alias entries rather than weakening their authorization assertions. + +Review-size exception: keep original-schema collection, native SSE/JSON/replay wiring and their end-to-end regressions in one layer because they jointly define the completion contract. Roughly half the added lines are focused regressions; the alias inventory is moved, not reimplemented. Prior catalog and patch concerns are already separate PRs. Additional Aside profile work remains separate future cycles. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index c015b71ebb..a5d864e673 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -330,6 +330,12 @@ Codex. Native custom calls and converted function calls use the same completion patch previews are held while their executable form is unresolved. JavaScript that merely contains patch text and unrelated native custom payloads stay unchanged. +Ordinary routed Responses function calls also use the original declared parameter schema at +completion: integral floats in integer fields and integral numbers in string-only fields are +normalized, while fractions and numeric unions stay unchanged. An explicitly empty completed +argument string becomes `{}`. Final events and locally stored continuation history agree. +Unambiguous dotted namespace spellings are restored to the declared namespace and tool name. + The selected provider must support function/tool calling. A text-only provider without tool-call support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool mode unchanged. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29dd2c5f1c..999863fbc0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1005,6 +1005,7 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts new file mode 100644 index 0000000000..bba6a8c8ea --- /dev/null +++ b/src/responses/function-call-compat.ts @@ -0,0 +1,152 @@ +import { coerceIntegerToolArguments } from "../lib/tool-argument-integers"; +import { namespacedToolName } from "../types/tools"; +import { rewriteRoutedNamespaceToolsForUpstream } from "./namespace-tool-compat"; +import { collectResponsesToolGroups } from "./tool-groups"; + +export interface FunctionCallRepairSchema { + name: string; + namespace?: string; + parameters?: Record; +} + +/** Keys are canonical original identities, never a bare-name fallback for a namespace. */ +export type FunctionCallRepairSchemas = ReadonlyMap; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function namespaceOf(value: unknown): string | undefined { + return typeof value === "string" && value !== "functions" ? value : undefined; +} + +function selectorAllows( + selector: unknown, + lowered: unknown, + wireName: string, + identity: FunctionCallRepairSchema, +): boolean { + if (!isObject(selector) || selector.type !== "function" || typeof selector.name !== "string") return false; + if ("namespace" in selector) { + if (typeof selector.namespace !== "string" || selector.namespace.length === 0) return false; + return namespaceOf(selector.namespace) === identity.namespace && selector.name === identity.name; + } + return isObject(lowered) && lowered.type === "function" && lowered.name === wireName; +} + +/** Caller supplies currentTurnWireToolCatalogBody BEFORE provider schema lowering. */ +export function collectFunctionCallRepairSchemas(body: unknown): Map { + const schemas = new Map(); + if (!isObject(body)) return schemas; + // Reuse namespace selector resolution, retaining schemas from the original objects below. + const lowered = rewriteRoutedNamespaceToolsForUpstream(body).body; + const choice = body.tool_choice; + const loweredChoice = isObject(lowered) ? lowered.tool_choice : undefined; + const occupied = new Map(); + const register = (tool: unknown, namespace?: string): void => { + if (!isObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return; + const identity: FunctionCallRepairSchema = { + name: tool.name, + ...(namespace ? { namespace } : {}), + ...(isObject(tool.parameters) ? { parameters: tool.parameters } : {}), + }; + const key = namespacedToolName(namespace, tool.name); + if (occupied.has(key)) { + const previous = occupied.get(key); + // Conflicting duplicate declarations cannot choose a schema by insertion order. + if (!previous || previous.kind !== tool.type + || previous.identity.namespace !== namespace + || previous.identity.name !== tool.name + || JSON.stringify(previous.identity.parameters) !== JSON.stringify(identity.parameters)) { + occupied.set(key, null); + } + } else occupied.set(key, { kind: tool.type, identity }); + }; + for (const group of collectResponsesToolGroups(body)) { + for (const tool of group) { + if (!isObject(tool)) continue; + if (tool.type === "namespace") { + if (typeof tool.name !== "string" || !tool.name || !Array.isArray(tool.tools)) continue; + for (const child of tool.tools) register(child, namespaceOf(tool.name)); + } else if (tool.type === "function" && isObject(tool.function)) { + register({ ...tool.function, type: "function" }); + } else register(tool); + } + } + for (const [key, entry] of occupied) { + if (!entry || entry.kind !== "function") continue; + let allowed = choice === undefined || choice === "auto" || choice === "required"; + if (isObject(choice)) { + if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + const selectors = isObject(loweredChoice) && Array.isArray(loweredChoice.tools) ? loweredChoice.tools : []; + allowed = choice.tools.some((selector, index) => selectorAllows(selector, selectors[index], key, entry.identity)); + } else allowed = selectorAllows(choice, loweredChoice, key, entry.identity); + } + if (allowed) schemas.set(key, entry.identity); + } + return schemas; +} + +function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed: boolean): unknown { + if (!isObject(item) || item.type !== "function_call" || typeof item.name !== "string" + || typeof item.arguments !== "string") return item; + if (item.status !== "completed" && !(item.status === undefined && completed)) return item; + if ("namespace" in item && (typeof item.namespace !== "string" || !item.namespace)) return item; + const namespace = namespaceOf(item.namespace); + const schema = schemas.get(namespacedToolName(namespace, item.name)); + if (!schema) return item; + // An explicit namespace is an identity coordinate, not another spelling to guess at. + if ("namespace" in item && (schema.namespace !== namespace || schema.name !== item.name)) return item; + const raw = item.arguments; + if (raw !== "") { + try { + let unsafe = false; + JSON.parse(raw, (_key, value: unknown) => { + if (typeof value === "number" && (!Number.isFinite(value) + || (Number.isInteger(value) && !Number.isSafeInteger(value)))) unsafe = true; + return value; + }); + // Re-stringifying another repaired field must not round an unsafe sibling number. + if (unsafe) return item; + } catch { return item; } + } + const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name); + return argumentsText === raw ? item : { ...item, arguments: argumentsText }; +} + +/** Only executable completion slots are visited; metadata and custom input are opaque. */ +export function repairFunctionCalls( + value: unknown, + schemas: FunctionCallRepairSchemas, +): { value: unknown; changed: boolean } { + if (schemas.size === 0 || !isObject(value)) return { value, changed: false }; + if (typeof value.status === "string" && ["failed", "incomplete", "cancelled", "in_progress", "queued"].includes(value.status)) return { value, changed: false }; + let next: unknown = value; + if (value.type === "function_call") next = repairItem(value, schemas, false); + else if (value.type === "response.output_item.done") { + const item = repairItem(value.item, schemas, true); + if (item !== value.item) next = { ...value, item }; + } else if (value.type === "response.completed" && isObject(value.response)) { + const response = value.response; + if ((response.status === undefined || response.status === "completed") && Array.isArray(response.output)) { + const original = response.output; + const output = original.map(item => repairItem(item, schemas, true)); + if (output.some((item, index) => item !== original[index])) next = { ...value, response: { ...response, output } }; + } + } else if (typeof value.type !== "string" || !value.type.startsWith("response.")) { + if (Array.isArray(value.output)) { + const original = value.output; + const output = original.map(item => repairItem(item, schemas, value.status === "completed")); + if (output.some((item, index) => item !== original[index])) next = { ...value, output }; + } + } + return { value: next, changed: next !== value }; +} + +export function repairFunctionCallsInJson(text: string, schemas: FunctionCallRepairSchemas): string { + if (schemas.size === 0) return text; + let payload: unknown; + try { payload = JSON.parse(text); } catch { return text; } + const repaired = repairFunctionCalls(payload, schemas); + return repaired.changed ? JSON.stringify(repaired.value) : text; +} diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3088ab4c5d..d13e19422a 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -1,5 +1,6 @@ -import { namespacedToolName } from "../types"; +import { dottedToolName, namespacedToolName } from "../types"; import { collectResponsesToolGroups } from "./tool-groups"; +import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases"; export interface RoutedNamespaceToolIdentity { namespace: string; @@ -365,6 +366,16 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { } const toolChoice = rewriteToolChoice(body.tool_choice, plan); + const aliases = authorizedAliases(plan.aliases, toolChoice); + const ambiguousDotted = collectAmbiguousDottedAliases(groups); + // Authorize canonical identities first, then add only unambiguous spellings. + // Selection cannot hide a collision elsewhere in the original declaration set. + for (const identity of [...aliases.values()]) { + const dotted = dottedToolName(identity.namespace, identity.name); + if (dottedAliasIsUnambiguous(identity.namespace, identity.name) + && !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted) + && !aliases.has(dotted)) aliases.set(dotted, identity); + } return { body: { ...body, @@ -372,7 +383,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { ...(input !== body.input ? { input } : {}), ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), }, - aliases: authorizedAliases(plan.aliases, toolChoice), + aliases, }; } @@ -404,7 +415,8 @@ export function restoreRoutedNamespaceCalls( && typeof value.name === "string" ) { const identity = aliases.get(value.name); - if (identity) { + if (identity + && (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) { restored.name = identity.name; restored.namespace = identity.namespace; changed = true; diff --git a/src/responses/tool-name-aliases.ts b/src/responses/tool-name-aliases.ts new file mode 100644 index 0000000000..1943a686bf --- /dev/null +++ b/src/responses/tool-name-aliases.ts @@ -0,0 +1,79 @@ +import { dottedToolName, namespacedToolName } from "../types"; + +const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * A dotted spelling is a safe alias only when it cannot ALSO be read as some other identity's + * canonical `ns__name`. + * + * `{namespace: "x__y", name: "z"}` produces the dotted spelling "x__y.z", which is exactly the + * canonical wire name of `{namespace: "x", name: "y.z"}`. If only the latter is declared, an + * echoed call for the former would still find "x__y.z" in the declared set and be authorized as + * a tool the caller never granted. Requiring both halves to be free of the `__` separator keeps + * a dotted alias from ever impersonating a canonical name. + */ +export function dottedAliasIsUnambiguous(namespace: string, name: string): boolean { + return !namespace.includes("__") && !name.includes("__"); +} + +export function wireToolInnerName(tool: unknown): string | undefined { + if (!isPlainObject(tool)) return undefined; + const nestedFunction = tool.type === "function" && isPlainObject(tool.function) + ? tool.function + : undefined; + return typeof tool.name === "string" && tool.name.length > 0 + ? tool.name + : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0 + ? nestedFunction.name + : undefined; +} + +/** + * Dotted aliases that more than one declared identity would claim, plus dotted aliases that + * collide with a canonical or bare declared name. + * + * Resolved over the WHOLE catalog before any name is registered, so which identity "wins" can + * never depend on declaration order -- an order the caller controls. + */ +export function collectAmbiguousDottedAliases(specGroups: readonly unknown[]): Set { + const owners = new Map(); + const claim = (alias: string, identity: string): void => { + const owner = owners.get(alias); + if (owner === undefined) owners.set(alias, identity); + else if (owner !== identity) owners.set(alias, null); + }; + for (const specs of specGroups) { + if (!Array.isArray(specs)) continue; + for (const spec of specs) { + if (!isPlainObject(spec)) continue; + if (spec.type === "namespace" && Array.isArray(spec.tools)) { + const namespace = typeof spec.name === "string" ? spec.name : undefined; + if (!namespace) continue; + for (const inner of spec.tools) { + const name = wireToolInnerName(inner); + if (!name) continue; + if (namespace === BUILTIN_FUNCTIONS_NAMESPACE) { + claim(name, JSON.stringify([undefined, name])); + continue; + } + const identity = JSON.stringify([namespace, name]); + claim(dottedToolName(namespace, name), identity); + // A canonical or bare name already owned by a different identity poisons the dotted + // alias that would shadow it. + claim(namespacedToolName(namespace, name), identity); + claim(name, identity); + } + continue; + } + const name = wireToolInnerName(spec); + if (name) claim(name, JSON.stringify([undefined, name])); + } + } + const ambiguous = new Set(); + for (const [alias, owner] of owners) if (owner === null) ambiguous.add(alias); + return ambiguous; +} + diff --git a/src/server/responses-function-tool-repair.ts b/src/server/responses-function-tool-repair.ts new file mode 100644 index 0000000000..8041e431cc --- /dev/null +++ b/src/server/responses-function-tool-repair.ts @@ -0,0 +1,178 @@ +import { + TRANSLATOR_MAX_TURN_BYTES, + TranslatorBudgetExceededError, + type TranslatorBudget, +} from "../lib/translator-budget"; +import { repairFunctionCalls, type FunctionCallRepairSchemas } from "../responses/function-call-compat"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; + +type Identity = { + itemId?: string; + outputIndex?: number; + item: Record; + bytes: number; +}; +type PendingCompletion = { + block: string; + itemId?: string; + outputIndex?: number; + bytes: number; +}; +const ENTRY_OVERHEAD_BYTES = 64; + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function outputIndexOf(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +/** Ordinary deltas remain upstream previews; only authoritative completions are repaired. */ +export function createResponsesFunctionToolRepairBlockRewrite( + schemas: FunctionCallRepairSchemas, + budget?: TranslatorBudget, +): SseBlockRewrite { + if (schemas.size === 0) return block => [block]; + const byId = new Map(); + const byIndex = new Map(); + let pending: PendingCompletion[] = []; + let retainedBytes = 0; + let disposed = false; + + const retain = (bytes: number): void => { + if (retainedBytes + bytes > TRANSLATOR_MAX_TURN_BYTES) { + throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES); + } + budget?.chargeRetained(bytes, { kind: "retained_collectors" }); + retainedBytes += bytes; + }; + const release = (bytes: number): void => { + budget?.releaseRetained(bytes, { kind: "retained_collectors" }); + retainedBytes -= bytes; + }; + const releaseIdentity = (identity: Identity): void => { + if (identity.itemId !== undefined) byId.delete(identity.itemId); + if (identity.outputIndex !== undefined) byIndex.delete(identity.outputIndex); + release(identity.bytes); + }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + release(retainedBytes); + byId.clear(); + byIndex.clear(); + pending = []; + }; + const lookup = (itemId: string | undefined, index: number | undefined): Identity | undefined => { + const identity = itemId === undefined ? undefined : byId.get(itemId); + if (identity) return index === undefined || identity.outputIndex === undefined || identity.outputIndex === index ? identity : undefined; + const indexed = index === undefined ? undefined : byIndex.get(index); + return indexed && (itemId === undefined || indexed.itemId === undefined || indexed.itemId === itemId) ? indexed : undefined; + }; + const register = (item: Record, index: number | undefined): Identity | undefined => { + const itemId = typeof item.id === "string" && item.id ? item.id : undefined; + if (itemId === undefined && index === undefined) return undefined; + // Repeated snapshots replace metadata; never retain provider argument bodies here. + const previous = new Set(); + if (itemId !== undefined && byId.has(itemId)) previous.add(byId.get(itemId)!); + if (index !== undefined && byIndex.has(index)) previous.add(byIndex.get(index)!); + for (const identity of previous) releaseIdentity(identity); + const metadata = { + type: item.type, + name: item.name, + ...("namespace" in item ? { namespace: item.namespace } : {}), + ...(item.status !== undefined && item.status !== "in_progress" && item.status !== "completed" ? { status: item.status } : {}), + }; + const bytes = ENTRY_OVERHEAD_BYTES + Buffer.byteLength(JSON.stringify([itemId, index, metadata]), "utf8"); + retain(bytes); + const identity = { itemId, outputIndex: index, item: metadata, bytes }; + if (itemId !== undefined) byId.set(itemId, identity); + if (index !== undefined) byIndex.set(index, identity); + return identity; + }; + const repairCompletion = (block: string, event: Record, identity: Identity): string => { + if (typeof event.arguments !== "string") return block; + const repaired = repairFunctionCalls({ ...identity.item, status: identity.item.status ?? "completed", arguments: event.arguments }, schemas); + if (!repaired.changed || !isObject(repaired.value)) return block; + return replaceSseDataPayload(block, JSON.stringify({ ...event, arguments: repaired.value.arguments })); + }; + const flushPending = (identity: Identity): string[] => { + const output: string[] = []; + const remaining: PendingCompletion[] = []; + for (const completion of pending) { + const matches = completion.itemId !== undefined + ? completion.itemId === identity.itemId + && (completion.outputIndex === undefined || identity.outputIndex === undefined || completion.outputIndex === identity.outputIndex) + : completion.outputIndex !== undefined && completion.outputIndex === identity.outputIndex; + if (!matches) { remaining.push(completion); continue; } + release(completion.bytes); + const payload = sseDataPayload(completion.block); + const event: unknown = payload === null ? undefined : JSON.parse(payload); + output.push(isObject(event) ? repairCompletion(completion.block, event, identity) : completion.block); + } + pending = remaining; + return output; + }; + + const rewrite: SseBlockRewrite = block => { + if (disposed) return [block]; + const payload = sseDataPayload(block); + if (payload === null) return [block]; + if (payload === "[DONE]") { + const unfinished = pending.map(entry => entry.block); + dispose(); + return [...unfinished, block]; + } + let event: unknown; + try { event = JSON.parse(payload); } catch { return [block]; } + if (!isObject(event)) return [block]; + const index = outputIndexOf(event.output_index); + const itemId = typeof event.item_id === "string" && event.item_id ? event.item_id : undefined; + try { + if ((event.type === "response.output_item.added" || event.type === "response.output_item.done") && isObject(event.item)) { + const identity = register(event.item, index); + const replayed = identity ? flushPending(identity) : []; + const repaired = repairFunctionCalls(event, schemas); + const output = repaired.changed ? replaceSseDataPayload(block, JSON.stringify(repaired.value)) : block; + if (identity && (event.type === "response.output_item.done" || replayed.length > 0)) releaseIdentity(identity); + return event.type === "response.output_item.added" ? [output, ...replayed] : [...replayed, output]; + } + if (event.type === "response.function_call_arguments.done" && typeof event.arguments === "string") { + const identity = lookup(itemId, index); + if (identity) { + const output = repairCompletion(block, event, identity); + releaseIdentity(identity); + return [output]; + } + if (itemId !== undefined || index !== undefined) { + const bytes = ENTRY_OVERHEAD_BYTES + Buffer.byteLength(block, "utf8"); + retain(bytes); + pending.push({ block, itemId, outputIndex: index, bytes }); + return []; + } + } + if (typeof event.type === "string" && ["response.completed", "response.failed", "response.incomplete", "response.cancelled"].includes(event.type)) { + const replayed: string[] = []; + if (event.type === "response.completed" && isObject(event.response) && Array.isArray(event.response.output) + && (event.response.status === undefined || event.response.status === "completed")) { + for (const [slot, item] of event.response.output.entries()) { + if (!isObject(item)) continue; + const identity = register(item, slot); + if (identity) replayed.push(...flushPending(identity)); + } + } + replayed.push(...pending.map(entry => entry.block)); + const repaired = repairFunctionCalls(event, schemas); + dispose(); + return [...replayed, repaired.changed ? replaceSseDataPayload(block, JSON.stringify(repaired.value)) : block]; + } + return [block]; + } catch (error) { + dispose(); + throw error; + } + }; + rewrite.dispose = dispose; + return rewrite; +} diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index acdd282b07..cad1b39011 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,3 +1,4 @@ +import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous, wireToolInnerName } from "../responses/tool-name-aliases"; import { CODE_MODE_EXEC_TOOL_NAME, dottedToolName, @@ -75,32 +76,6 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -/** - * A dotted spelling is a safe alias only when it cannot ALSO be read as some other identity's - * canonical `ns__name`. - * - * `{namespace: "x__y", name: "z"}` produces the dotted spelling "x__y.z", which is exactly the - * canonical wire name of `{namespace: "x", name: "y.z"}`. If only the latter is declared, an - * echoed call for the former would still find "x__y.z" in the declared set and be authorized as - * a tool the caller never granted. Requiring both halves to be free of the `__` separator keeps - * a dotted alias from ever impersonating a canonical name. - */ -function dottedAliasIsUnambiguous(namespace: string, name: string): boolean { - return !namespace.includes("__") && !name.includes("__"); -} - -function wireToolInnerName(tool: unknown): string | undefined { - if (!isPlainObject(tool)) return undefined; - const nestedFunction = tool.type === "function" && isPlainObject(tool.function) - ? tool.function - : undefined; - return typeof tool.name === "string" && tool.name.length > 0 - ? tool.name - : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0 - ? nestedFunction.name - : undefined; -} - function addWireToolName( names: Set, tool: unknown, @@ -171,48 +146,6 @@ function addWireToolSpecs( } } -/** - * Dotted aliases that more than one declared identity would claim, plus dotted aliases that - * collide with a canonical or bare declared name. - * - * Resolved over the WHOLE catalog before any name is registered, so which identity "wins" can - * never depend on declaration order -- an order the caller controls. - */ -function collectAmbiguousDottedAliases(specGroups: readonly unknown[]): Set { - const owners = new Map(); - const claim = (alias: string, identity: string): void => { - const owner = owners.get(alias); - if (owner === undefined) owners.set(alias, identity); - else if (owner !== identity) owners.set(alias, null); - }; - for (const specs of specGroups) { - if (!Array.isArray(specs)) continue; - for (const spec of specs) { - if (!isPlainObject(spec)) continue; - if (spec.type === "namespace" && Array.isArray(spec.tools)) { - const namespace = typeof spec.name === "string" ? spec.name : undefined; - if (!namespace || namespace === BUILTIN_FUNCTIONS_NAMESPACE) continue; - for (const inner of spec.tools) { - const name = wireToolInnerName(inner); - if (!name) continue; - const identity = JSON.stringify([namespace, name]); - claim(dottedToolName(namespace, name), identity); - // A canonical or bare name already owned by a different identity poisons the dotted - // alias that would shadow it. - claim(namespacedToolName(namespace, name), identity); - claim(name, identity); - } - continue; - } - const name = wireToolInnerName(spec); - if (name) claim(name, JSON.stringify([undefined, name])); - } - } - const ambiguous = new Set(); - for (const [alias, owner] of owners) if (owner === null) ambiguous.add(alias); - return ambiguous; -} - /** * Tool names the OUTBOUND Responses body actually declared. * diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 9e087bcc90..d992c07856 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -380,6 +380,8 @@ import { } from "../sse-payload-rewrite"; import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; +import { collectFunctionCallRepairSchemas, repairFunctionCalls, repairFunctionCallsInJson } from "../../responses/function-call-compat"; +import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; import { @@ -3976,6 +3978,9 @@ async function handleResponsesInner( } throw error; } + const functionRepairSchemas = isCanonicalOpenAiForwardProvider(route.provider) + ? new Map() + : collectFunctionCallRepairSchemas(clientToolAuthorizationBody); if (!isCanonicalOpenAiForwardProvider(route.provider)) { for (const name of request.convertedRoutedCustomToolNames ?? []) { if ( @@ -4153,12 +4158,12 @@ async function handleResponsesInner( const rememberPassthroughResponseChecked = rememberPassthroughResponse ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { if (inspectionSawUndeclaredTool) return; - const restoredResponse = restoreRoutedCustomCalls( - restoreAuthorizedBareNamespaceToolCalls(response), + const restoredResponse = repairFunctionCalls(restoreRoutedCustomCalls( + restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), routedCustomToolNames, routedCustomToolRepairNames, declaredWireToolNames, - ).value as { id?: unknown; output?: unknown; status?: unknown }; + ).value, functionRepairSchemas).value as { id?: unknown; output?: unknown; status?: unknown }; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -5056,6 +5061,9 @@ async function handleResponsesInner( ? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget) : undefined, createResponsesFieldBackfillBlockRewrite(), + functionRepairSchemas.size > 0 + ? createResponsesFunctionToolRepairBlockRewrite(functionRepairSchemas, translatorBudget) + : undefined, // Last: every rewrite above can still rename or reshape a call item, so the guard must // compare the names the client will actually receive against the declared catalog. undeclaredToolGuardActive @@ -5273,9 +5281,10 @@ async function handleResponsesInner( restored, routedToolSearchNames, ); + const restoredFunctionCalls = repairFunctionCallsInJson(restoredToolSearch, functionRepairSchemas); const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) - ? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody) - : restoredToolSearch; + ? repairResponsesSnapshotJson(restoredFunctionCalls, outboundRequestBody) + : restoredFunctionCalls; const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId) : backfillResponsesFieldsJson(repaired); diff --git a/structure/11_compatibility-contracts.md b/structure/11_compatibility-contracts.md index 295590788a..c503cc0991 100644 --- a/structure/11_compatibility-contracts.md +++ b/structure/11_compatibility-contracts.md @@ -75,3 +75,18 @@ resolver at input.done, output_item.done and terminal snapshots. Potential raw/w previews are withheld before compilation; ordinary native custom payloads retain their raw grammar. A string merely containing patch markers remains executable caller input and is never rewritten. Completion and disposal release retained preview buffers. + +## Native ordinary function completion + +The native Responses lane captures ordinary function schemas from the current caller-owned +catalog before provider lowering; historical replay catalogs cannot add repair authority. +Completion events, JSON responses and stored continuation output share schema-aware argument +repair. Preview deltas retain the existing bridge contract; authoritative completed arguments +carry representation fixes. Custom tool wrappers and native forward traffic are excluded. + +Namespace restoration and the undeclared-name guard share one dotted-alias collision inventory, +including bare declarations inside the reserved functions group. Canonical authorization happens +before dotted aliases are added. A conflicting explicit namespace is never overwritten. Namespace +restoration retains the existing lowered-kind handling because custom tools are lowered to +functions before the adapter constructs its alias map; ordinary argument repair independently +checks the original declaration kind. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 114c699eaf..cfab75fefb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -842,6 +842,7 @@ "responses-context-overflow.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", + "responses-function-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index 7724661246..622fe09411 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -67,6 +67,7 @@ describe("Responses namespace tool compatibility", () => { ]); expect([...rewritten.aliases]).toEqual([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); }); @@ -124,6 +125,7 @@ describe("Responses namespace tool compatibility", () => { }); expect([...allowed.aliases]).toEqual([ ["collaboration__safe", { namespace: "collaboration", name: "safe", kind: "function" }], + ["collaboration.safe", { namespace: "collaboration", name: "safe", kind: "function" }], ]); expect(restoreRoutedNamespaceCalls({ type: "function_call", @@ -237,7 +239,7 @@ describe("Responses namespace tool compatibility", () => { ], }, }); - expect([...aliases.keys()]).toEqual([wireName]); + expect([...aliases.keys()]).toEqual([wireName, "collaboration.safe"]); }); }); @@ -253,7 +255,7 @@ describe("Responses namespace tool compatibility", () => { const { aliases } = rewriteRoutedNamespaceToolsForUpstream( choice === undefined ? { tools } : { tools, tool_choice: choice }, ); - expect(aliases.size).toBe(2); + expect(aliases.size).toBe(4); } // A top-level selector for another tool kind states a restriction that no // namespace call satisfies, so it authorizes nothing. @@ -482,6 +484,7 @@ describe("Responses namespace tool compatibility", () => { test("restores only aliases authorized by this request in JSON and SSE payloads", () => { const aliases = new Map([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); const payload = { type: "response.completed", @@ -517,3 +520,35 @@ describe("Responses namespace tool compatibility", () => { expect(restoreRoutedNamespaceCallsInJson("not-json", aliases)).toBe("not-json"); }); }); + +describe("dotted namespace restoration uses the declaration collision boundary", () => { + const ping = { type: "namespace", name: "mcp", tools: [{ type: "function", name: "ping", parameters: {} }] }; + test("restores the dotted spelling after canonical tool-choice authorization", () => { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping], tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); + expect(restoreRoutedNamespaceCalls({ type: "function_call", name: "mcp.ping", arguments: "{}" }, aliases).value) + .toEqual({ type: "function_call", name: "ping", namespace: "mcp", arguments: "{}" }); + const conflicting = { type: "function_call", name: "mcp.ping", namespace: "other", arguments: "{}" }; + expect(restoreRoutedNamespaceCalls(conflicting, aliases).value).toEqual(conflicting); + }); + test.each([ + { type: "function", name: "mcp.ping", parameters: {} }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "mcp.ping", parameters: {} }] }, + ])("a bare canonical declaration prevents dotted shadowing in either order", collision => { + for (const tools of [[ping, collision], [collision, ping]]) { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools, tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); + expect(aliases.has("mcp.ping")).toBe(false); + expect(aliases.has("mcp__ping")).toBe(true); + } + }); + test("different dotted coordinates remain ambiguous and canonical forms remain distinct", () => { + for (const tools of [ + [{ type: "namespace", name: "a.b", tools: [{ type: "function", name: "c" }] }, { type: "namespace", name: "a", tools: [{ type: "function", name: "b.c" }] }], + [{ type: "namespace", name: "a", tools: [{ type: "function", name: "b.c" }] }, { type: "namespace", name: "a.b", tools: [{ type: "function", name: "c" }] }], + ]) { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools }); + expect(aliases.has("a.b.c")).toBe(false); + expect(aliases.has("a.b__c")).toBe(true); + expect(aliases.has("a__b.c")).toBe(true); + } + }); +}); diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts new file mode 100644 index 0000000000..2b8b129fb6 --- /dev/null +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -0,0 +1,301 @@ +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { describe, expect, test } from "bun:test"; +import { + collectFunctionCallRepairSchemas, + repairFunctionCalls, + repairFunctionCallsInJson, +} from "../../src/responses/function-call-compat"; +import { createResponsesFunctionToolRepairBlockRewrite } from "../../src/server/responses-function-tool-repair"; +import { createTranslatorBudget, TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; +import { sseDataPayload } from "../../src/server/sse-payload-rewrite"; + +const parameters = { type: "object", properties: { + cell_id: { type: "string" }, yield_time_ms: { type: "integer" }, + union: { type: ["number", "string"] }, +} }; +const wait = { type: "function", name: "wait", parameters }; +const schemas = collectFunctionCallRepairSchemas({ tools: [wait, { type: "function", name: "get_state" }] }); +const raw = '{"cell_id":4,"yield_time_ms":120000.0}'; +const canonical = '{"cell_id":"4","yield_time_ms":120000}'; + +function item(argumentsText: unknown = raw, overrides: Record = {}) { + return { type: "function_call", id: "fc_one", call_id: "call_one", name: "wait", arguments: argumentsText, status: "completed", ...overrides }; +} +function frame(type: string, fields: Record) { + return `event: ${type}\ndata: ${JSON.stringify({ type, ...fields })}`; +} +function payload(block: string): Record { + return JSON.parse(sseDataPayload(block)!) as Record; +} +function repairedItem(argumentsText: unknown, overrides: Record = {}) { + return repairFunctionCalls(item(argumentsText, overrides), schemas).value; +} + +describe("original function declaration authority", () => { + const groups = [ + { type: "namespace", name: "left", tools: [wait] }, + { type: "namespace", name: "right", tools: [{ ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }] }, + { type: "custom", name: "exec", description: "JavaScript" }, + { type: "web_search" }, + ]; + + test("preserves schema references and distinct same-inner-name identities", () => { + const map = collectFunctionCallRepairSchemas({ tools: groups }); + expect([...map.keys()]).toEqual(["left__wait", "right__wait"]); + expect(map.get("left__wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item('{"cell_id":4}', { namespace: "left" }), map).value) + .toMatchObject({ arguments: '{"cell_id":"4"}' }); + expect(repairFunctionCalls(item('{"cell_id":4}', { namespace: "right" }), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw, { name: "left__wait", namespace: "right" }), map).changed).toBe(false); + expect(repairFunctionCalls(item(raw, { name: "left__wait", namespace: "functions" }), map).changed).toBe(false); + }); + + test.each([ + [{ type: "function", namespace: "left", name: "wait" }, ["left__wait"]], + [{ type: "function", name: "left__wait" }, ["left__wait"]], + [{ type: "function", name: "left.wait" }, ["left__wait"]], + [{ type: "function", name: "wait" }, []], + [{ type: "custom", namespace: "left", name: "wait" }, []], + [{ type: "function", namespace: "right", name: "left__wait" }, []], + [{ type: "function", namespace: "", name: "left__wait" }, []], + [{ type: "function", namespace: null, name: "left__wait" }, []], + [{ type: "file_search", name: "left__wait" }, []], + ["none", []], + [null, []], + [{ type: "allowed_tools", tools: [{ type: "function", namespace: "right", name: "wait" }, { type: "custom", name: "left__wait" }] }, ["right__wait"]], + ])("honors exact selector %j", (tool_choice, keys) => { + expect([...collectFunctionCallRepairSchemas({ tools: groups, tool_choice }).keys()]).toEqual(keys); + }); + + test("reserved functions remain bare and support explicit namespace selectors", () => { + const map = collectFunctionCallRepairSchemas({ + tools: [{ type: "namespace", name: "functions", tools: [wait] }], + tool_choice: { type: "function", namespace: "functions", name: "wait" }, + }); + expect(map.get("wait")).toMatchObject({ name: "wait", parameters }); + expect(map.get("wait")).not.toHaveProperty("namespace"); + expect(repairFunctionCalls(item(raw, { namespace: "functions" }), map).value).toMatchObject({ arguments: canonical }); + }); + + test("reads supplied current-turn groups, not declarations nested in replay messages or metadata", () => { + const map = collectFunctionCallRepairSchemas({ + input: [{ type: "additional_tools", tools: [wait] }, { type: "message", tools: [{ type: "function", name: "old" }] }], + metadata: { tools: [{ type: "function", name: "shadow" }] }, + }); + expect([...map.keys()]).toEqual(["wait"]); + expect(collectFunctionCallRepairSchemas({ input: [{ type: "message", tools: [wait] }] }).size).toBe(0); + }); + + test("conflicting same-wire schemas cannot win by declaration order", () => { + const other = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + for (const tools of [[wait, other], [other, wait], [wait, { type: "custom", name: "wait" }]]) { + expect(collectFunctionCallRepairSchemas({ tools }).size).toBe(0); + } + }); + + test("namespace wait does not inherit bare wait number-field exceptions", () => { + const numberWait = { ...wait, parameters: { type: "object", properties: { yield_time_ms: { type: "number" } } } }; + const map = collectFunctionCallRepairSchemas({ tools: [numberWait, { type: "namespace", name: "remote", tools: [numberWait] }] }); + expect(repairFunctionCalls(item('{"yield_time_ms":1000.0}'), map).value).toMatchObject({ arguments: '{"yield_time_ms":1000}' }); + expect(repairFunctionCalls(item('{"yield_time_ms":1000.0}', { namespace: "remote" }), map).changed).toBe(false); + }); +}); + +describe("pure function completion repair", () => { + test("repairs integer/string arguments and explicit completed empty arguments", () => { + expect(repairedItem(raw)).toEqual(item(canonical)); + expect(repairedItem("", { name: "get_state" })).toEqual(item("{}", { name: "get_state" })); + const missing = { type: "function_call", name: "get_state", status: "completed" }; + expect(repairFunctionCalls(missing, schemas).value).toBe(missing); + }); + + test.each([" ", "{", '{"cell_id":4.5}', '{"yield_time_ms":1.5}', '{"union":4.0}', + '{"cell_id":9007199254740993}', '{"cell_id":4,"unknown":9007199254740993}', '{"cell_id":4,"unknown":1e400}']) + ("preserves invalid/disagreeing/unsafe payload %s", argumentsText => { + const value = item(argumentsText); + expect(repairFunctionCalls(value, schemas)).toEqual({ value, changed: false }); + }); + + test("preserves custom/helper/unknown calls, previews and failed/incomplete snapshots", () => { + for (const overrides of [{ type: "custom_tool_call", input: raw }, { name: "exec_command" }, { name: "exec" }, + { status: "in_progress" }, { status: "incomplete" }, { namespace: "missing" }]) { + const value = item(raw, overrides); + expect(repairFunctionCalls(value, schemas).value).toBe(value); + } + for (const status of ["failed", "incomplete", "in_progress"]) { + const value = { status, output: [item()] }; + expect(repairFunctionCalls(value, schemas).value).toBe(value); + } + const added = { type: "response.output_item.added", item: item("") }; + expect(repairFunctionCalls(added, schemas).value).toBe(added); + }); + + test("repairs JSON/item/terminal completions without visiting metadata or adding status", () => { + const shadow = item(); + const value = { status: "completed", output: [item()], metadata: { shadow } }; + const json = repairFunctionCallsInJson(JSON.stringify(value), schemas); + expect(JSON.parse(json)).toEqual({ ...value, output: [item(canonical)] }); + expect(repairFunctionCallsInJson(json, schemas)).toBe(json); + expect(repairFunctionCallsInJson("not JSON", schemas)).toBe("not JSON"); + const noStatus = { ...item("", { name: "get_state" }), status: undefined }; + expect(repairFunctionCalls({ type: "response.output_item.done", item: noStatus }, schemas).value) + .toEqual({ type: "response.output_item.done", item: { ...noStatus, arguments: "{}" } }); + expect(repairFunctionCalls({ type: "response.completed", response: { output: [noStatus] } }, schemas).value) + .toEqual({ type: "response.completed", response: { output: [{ ...noStatus, arguments: "{}" }] } }); + expect(repairFunctionCalls(value, new Map()).value).toBe(value); + }); +}); + +describe("native function completion SSE", () => { + test("keeps previews exact and repairs every authoritative completion without synthetic deltas", () => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + const added = frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) }); + expect(rewrite(added)).toEqual([added]); + const delta = frame("response.function_call_arguments.delta", { item_id: "fc_one", delta: raw }); + expect(rewrite(delta)).toEqual([delta]); + const done = rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw })); + expect(done).toHaveLength(1); + expect(payload(done[0]!)).toMatchObject({ type: "response.function_call_arguments.done", arguments: canonical }); + expect(budget.snapshot().currentBytes).toBe(0); + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, item: item() })); + expect(payload(itemDone[0]!).item).toEqual(item(canonical)); + const terminal = rewrite(frame("response.completed", { response: { status: "completed", output: [item()] } })); + expect(payload(terminal[0]!).response).toEqual({ status: "completed", output: [item(canonical)] }); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test.each(["response.output_item.done", "response.completed"])("no-arg %s works without arguments.done", type => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + const call = item("", { name: "get_state" }); + const fields = type === "response.completed" ? { response: { status: "completed", output: [call] } } : { output_index: 0, item: call }; + const result = rewrite(frame(type, fields)); + expect(result).toHaveLength(1); + expect(JSON.stringify(payload(result[0]!))).toContain('"arguments":"{}"'); + } finally { rewrite.dispose?.(); } + }); + + test("correlates early id-less completions and interleaved calls without mixing schemas", () => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + expect(rewrite(frame("response.function_call_arguments.done", { output_index: 1, arguments: "" }))).toEqual([]); + rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + expect(payload(rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }))[0]!)) + .toMatchObject({ arguments: canonical }); + const output = rewrite(frame("response.output_item.added", { output_index: 1, item: item("", { name: "get_state", id: "fc_two", status: "in_progress" }) })); + expect(output.map(block => payload(block).type)).toEqual(["response.output_item.added", "response.function_call_arguments.done"]); + expect(payload(output[1]!)).toMatchObject({ arguments: "{}", output_index: 1 }); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test("terminal snapshots resolve early completions; authoritative arguments beat previews", () => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + const preview = frame("response.function_call_arguments.delta", { item_id: "fc_one", delta: '{"cell_id":999}' }); + expect(rewrite(preview)).toEqual([preview]); + expect(rewrite(frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }))).toEqual([]); + const output = rewrite(frame("response.completed", { response: { output: [item()] } })); + expect(output).toHaveLength(2); + expect(payload(output[0]!)).toMatchObject({ arguments: canonical }); + expect(payload(output[1]!).response).toEqual({ output: [item(canonical)] }); + } finally { rewrite.dispose?.(); } + }); + + test.each(["response.failed", "response.incomplete", "response.cancelled"])("%s flushes unknown completions unchanged and frees retention", type => { + const budget = createTranslatorBudget(); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + const early = frame("response.function_call_arguments.done", { item_id: "fc_one", arguments: raw }); + expect(rewrite(early)).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + const terminal = frame(type, { response: { output: [item()] } }); + expect(rewrite(terminal)).toEqual([early, terminal]); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + }); + + test("charges identity metadata and early frames, releasing even on overflow or disposal", () => { + for (const early of [false, true]) { + const budget = createTranslatorBudget({ maxTurnBytes: 240 }); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas, budget); + try { + rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + if (early) { + expect(() => rewrite(frame("response.function_call_arguments.done", { item_id: "unknown", arguments: "x".repeat(300) }))) + .toThrow(TranslatorBudgetExceededError); + } + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { rewrite.dispose?.(); budget.dispose(); } + } + }); + + test("empty forward map leaves every frame byte-identical", () => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(new Map()); + const block = frame("response.output_item.done", { output_index: 0, item: item() }); + expect(rewrite(block)).toEqual([block]); + }); +}); + +test("native Responses JSON/SSE and replay share the original function schema repair", async () => { + const originalFetch = globalThis.fetch; + const expected = '{"cell_id":"4","yield_time_ms":120000}'; + const output = { type: "function_call", id: "fc_wait", call_id: "call_wait", name: "wait", arguments: '{"cell_id":4,"yield_time_ms":120000.0}', status: "completed" }; + const tools = [{ type: "function", name: "wait", parameters: { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" } } } }]; + const config = { + port: 0, defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-responses", baseUrl: "https://function-parity.invalid/v1", authMode: "key", apiKey: "fixture-key" } }, + } as OcxConfig; + let activeId = ""; + let captured: { input?: Array> } | undefined; + const sse = (type: string, payload: object) => `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (!url.startsWith("https://function-parity.invalid/")) throw new Error("unexpected parity fixture destination"); + const body = JSON.parse(String(init?.body)); + captured = body; + const response = { id: activeId, status: "completed", output: [output] }; + return body.stream ? new Response([ + sse("response.output_item.added", { output_index: 0, item: { ...output, arguments: "", status: "in_progress" } }), + sse("response.function_call_arguments.delta", { output_index: 0, item_id: output.id, delta: output.arguments }), + sse("response.function_call_arguments.done", { output_index: 0, item_id: output.id, arguments: output.arguments }), + sse("response.output_item.done", { output_index: 0, item: output }), + sse("response.completed", { response }), "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }) : Response.json(response); + }) as typeof fetch; + try { + for (const stream of [false, true]) { + activeId = `resp_fn_${crypto.randomUUID()}`; + const request = (extra: object = {}) => new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/grok-probe", stream, input: [{ role: "user", content: "synthetic" }], tools, ...extra }), + }); + const response = await handleResponses(request(), config); + expect(response.status).toBe(200); + const raw = await response.text(); + if (stream) { + const events = raw.split("\n").filter(line => line.startsWith("data:") && !line.includes("[DONE]")).map(line => JSON.parse(line.slice(5))); + expect(events.find(event => event.type === "response.function_call_arguments.done")?.arguments).toBe(expected); + expect(events.find(event => event.type === "response.output_item.done")?.item.arguments).toBe(expected); + expect(events.find(event => event.type === "response.completed")?.response.output[0].arguments).toBe(expected); + } else expect(JSON.parse(raw).output[0].arguments).toBe(expected); + const previous = activeId; + activeId = `resp_fn_followup_${crypto.randomUUID()}`; + const followup = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: "call_wait", output: "done" }] }), config); + await followup.text(); + expect(captured?.input?.find(item => item.type === "function_call" && item.call_id === "call_wait")?.arguments).toBe(expected); + } + } finally { globalThis.fetch = originalFetch; } +}); From e84d3d2a22321089d30e1adf16f70e036aa9c101 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:11:38 +0900 Subject: [PATCH 3/8] test(responses): supply route log context in parity regression --- tests/responses/responses-function-tool-repair.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index 2b8b129fb6..d848bafe1f 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -282,7 +282,7 @@ test("native Responses JSON/SSE and replay share the original function schema re method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "fixture/grok-probe", stream, input: [{ role: "user", content: "synthetic" }], tools, ...extra }), }); - const response = await handleResponses(request(), config); + const response = await handleResponses(request(), config, { model: "", provider: "" }); expect(response.status).toBe(200); const raw = await response.text(); if (stream) { @@ -293,7 +293,7 @@ test("native Responses JSON/SSE and replay share the original function schema re } else expect(JSON.parse(raw).output[0].arguments).toBe(expected); const previous = activeId; activeId = `resp_fn_followup_${crypto.randomUUID()}`; - const followup = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: "call_wait", output: "done" }] }), config); + const followup = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: "call_wait", output: "done" }] }), config, { model: "", provider: "" }); await followup.text(); expect(captured?.input?.find(item => item.type === "function_call" && item.call_id === "call_wait")?.arguments).toBe(expected); } From f121348a99603d3668e54a07a0e02cfc7297f486 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 03:16:53 +0900 Subject: [PATCH 4/8] fix(responses): preserve sparse and discovered tool completion parity --- .../040_native_tool_parity.md | 4 ++ src/responses/function-call-compat.ts | 12 +++- src/server/responses-function-tool-repair.ts | 9 ++- src/server/responses/core.ts | 26 ++++--- .../responses-function-tool-repair.test.ts | 68 ++++++++++++++++++- .../responses-snapshot-repair-server.test.ts | 31 +++++++++ 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md index e1c0c13c91..8d85a71fa0 100644 --- a/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md +++ b/devlog/_plan/260906_grok_catalog_and_patch/040_native_tool_parity.md @@ -24,3 +24,7 @@ Main owns namespace-tool-compat.ts, extraction of existing ambiguity helpers to Namespace aliases are built after custom lowering (openai-responses.ts2410-2432), so an original custom tool can carry lowered kind=function. Preserve that existing namespace restoration kind behavior; only original-schema function repair enforces ordinary function kind. Dotted restoration adds spelling parity, not a new kind conversion. Explicit conflicting namespaces stay untouched. Reserved functions children participate in the shared collision inventory as bare names. The existing namespace tests are updated for additional alias entries rather than weakening their authorization assertions. Review-size exception: keep original-schema collection, native SSE/JSON/replay wiring and their end-to-end regressions in one layer because they jointly define the completion contract. Roughly half the added lines are focused regressions; the alias inventory is moved, not reimplemented. Prior catalog and patch concerns are already separate PRs. Additional Aside profile work remains separate future cycles. + +## Review synthesis, round1 + +Accept three medium findings: (1) sparse JSON receives inferred completion status after the new repair, so normalize snapshot/required fields before function repair and reuse that normalization for stored replay; (2) an index-only early completion can be correlated but still lacks item_id, so attach the known id even if arguments stay unchanged; (3) current-turn tool_search_output declarations are promoted by the adapter but absent from the original-schema collector, so include their original definitions in collector/selector resolution after the replay-prefix cut. Do not broaden collectResponsesToolGroups globally or include historical loaded declarations. Main owns normalization order/replay regression; existing worker owns early-frame id and loaded-declaration collector fixes/tests. Original authorization and preservation constraints remain. diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts index bba6a8c8ea..01e4d1f873 100644 --- a/src/responses/function-call-compat.ts +++ b/src/responses/function-call-compat.ts @@ -38,8 +38,16 @@ function selectorAllows( export function collectFunctionCallRepairSchemas(body: unknown): Map { const schemas = new Map(); if (!isObject(body)) return schemas; + const groups = collectResponsesToolGroups(body); + if (Array.isArray(body.input)) { + for (const entry of body.input) { + if (isObject(entry) && entry.type === "tool_search_output" && Array.isArray(entry.tools)) groups.push(entry.tools); + } + } // Reuse namespace selector resolution, retaining schemas from the original objects below. - const lowered = rewriteRoutedNamespaceToolsForUpstream(body).body; + // This local catalog view includes loaded definitions without revisiting replay history or + // teaching the shared tool-group collector a new transport-wide interpretation. + const lowered = rewriteRoutedNamespaceToolsForUpstream({ ...body, tools: groups.flat(), input: [] }).body; const choice = body.tool_choice; const loweredChoice = isObject(lowered) ? lowered.tool_choice : undefined; const occupied = new Map(); @@ -62,7 +70,7 @@ export function collectFunctionCallRepairSchemas(body: unknown): Map, identity: Identity): string => { if (typeof event.arguments !== "string") return block; + const resolved = (typeof event.item_id !== "string" || event.item_id === "") && identity.itemId !== undefined + ? { ...event, item_id: identity.itemId } + : event; const repaired = repairFunctionCalls({ ...identity.item, status: identity.item.status ?? "completed", arguments: event.arguments }, schemas); - if (!repaired.changed || !isObject(repaired.value)) return block; - return replaceSseDataPayload(block, JSON.stringify({ ...event, arguments: repaired.value.arguments })); + if (repaired.changed && isObject(repaired.value)) { + return replaceSseDataPayload(block, JSON.stringify({ ...resolved, arguments: repaired.value.arguments })); + } + return resolved === event ? block : replaceSseDataPayload(block, JSON.stringify(resolved)); }; const flushPending = (identity: Identity): string[] => { const output: string[] = []; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index d992c07856..dd63864b57 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -380,7 +380,7 @@ import { } from "../sse-payload-rewrite"; import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; -import { collectFunctionCallRepairSchemas, repairFunctionCalls, repairFunctionCallsInJson } from "../../responses/function-call-compat"; +import { collectFunctionCallRepairSchemas, repairFunctionCallsInJson } from "../../responses/function-call-compat"; import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; @@ -4046,6 +4046,14 @@ async function handleResponsesInner( ); const restoreAuthorizedBareNamespaceToolCalls = (value: unknown): unknown => restoreRoutedNamespaceCalls(value, authorizedBareNamespaceToolAliases).value; + const normalizeFunctionCompletionJson = (text: string): string => { + const snapshot = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) + ? repairResponsesSnapshotJson(text, outboundRequestBody) + : text; + // Sparse gateways need completion status inferred before schema repair can + // distinguish completed arguments from in-progress placeholders. + return repairFunctionCallsInJson(backfillResponsesFieldsJson(snapshot), functionRepairSchemas); + }; let undeclaredToolGuardActive = false; const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => { outboundRequestBody = parseOutboundRequestBody(builtRequest.body); @@ -4158,12 +4166,15 @@ async function handleResponsesInner( const rememberPassthroughResponseChecked = rememberPassthroughResponse ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { if (inspectionSawUndeclaredTool) return; - const restoredResponse = repairFunctionCalls(restoreRoutedCustomCalls( + const restored = restoreRoutedCustomCalls( restoreAuthorizedBareNamespaceToolCalls(restoreRoutedNamespaceCalls(response, routedNamespaceToolAliases).value), routedCustomToolNames, routedCustomToolRepairNames, declaredWireToolNames, - ).value, functionRepairSchemas).value as { id?: unknown; output?: unknown; status?: unknown }; + ).value; + const restoredResponse = (functionRepairSchemas.size > 0 + ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) + : restored) as { id?: unknown; output?: unknown; status?: unknown }; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -5281,13 +5292,10 @@ async function handleResponsesInner( restored, routedToolSearchNames, ); - const restoredFunctionCalls = repairFunctionCallsInJson(restoredToolSearch, functionRepairSchemas); - const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) - ? repairResponsesSnapshotJson(restoredFunctionCalls, outboundRequestBody) - : restoredFunctionCalls; + const repaired = normalizeFunctionCompletionJson(restoredToolSearch); const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId - ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId) - : backfillResponsesFieldsJson(repaired); + ? rewriteResponsesModelJson(repaired, parsed._responseModelId) + : repaired; // The bounded-JSON answer bypasses the SSE payload rewrite, so content- // channel reasoning needs the same normalization here for the plain // JSON answer and every reframed-SSE variant built from clientJson. diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index d848bafe1f..f841135b7e 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -9,6 +9,7 @@ import { import { createResponsesFunctionToolRepairBlockRewrite } from "../../src/server/responses-function-tool-repair"; import { createTranslatorBudget, TranslatorBudgetExceededError } from "../../src/lib/translator-budget"; import { sseDataPayload } from "../../src/server/sse-payload-rewrite"; +import { currentTurnWireToolCatalogBody } from "../../src/server/responses-undeclared-tool-guard"; const parameters = { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" }, @@ -95,6 +96,50 @@ describe("original function declaration authority", () => { } }); + test("loaded tool_search_output functions retain their original schemas", () => { + const body = { input: [{ type: "tool_search_output", tools: [wait, { type: "custom", name: "exec" }] }] }; + const before = JSON.stringify(body); + const map = collectFunctionCallRepairSchemas(body); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(JSON.stringify(body)).toBe(before); + }); + + test.each([ + [{ type: "function", name: "left.wait" }, ["left__wait"]], + [{ type: "function", namespace: "right", name: "wait" }, ["right__wait"]], + [{ type: "function", name: "wait" }, []], + [{ type: "custom", name: "left.wait" }, []], + [{ type: "function", namespace: "right", name: "left__wait" }, []], + [{ type: "allowed_tools", tools: [{ type: "function", name: "right.wait" }] }, ["right__wait"]], + ["none", []], + ])("loaded namespace declarations honor selector %j", (tool_choice, keys) => { + const body = { tool_choice, input: [{ type: "tool_search_output", tools: groups }] }; + expect([...collectFunctionCallRepairSchemas(body).keys()]).toEqual(keys); + }); + + test("replay-trimmed loaded definitions cannot grant historical schema authority", () => { + const historical = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + const body = { input: [ + { type: "tool_search_output", tools: [historical, { type: "function", name: "old_only" }] }, + { type: "message", role: "user", content: [] }, + { type: "tool_search_output", tools: [wait] }, + ] }; + const map = collectFunctionCallRepairSchemas(currentTurnWireToolCatalogBody(body, 2)); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(parameters); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(collectFunctionCallRepairSchemas(currentTurnWireToolCatalogBody(body, 3)).size).toBe(0); + }); + + test("loaded and explicit conflicting declarations remain fail-closed in either order", () => { + const conflict = { ...wait, parameters: { type: "object", properties: { cell_id: { type: "number" } } } }; + for (const [explicit, loaded] of [[wait, conflict], [conflict, wait]]) { + expect(collectFunctionCallRepairSchemas({ tools: [explicit], input: [{ type: "tool_search_output", tools: [loaded] }] }).size).toBe(0); + } + }); + test("namespace wait does not inherit bare wait number-field exceptions", () => { const numberWait = { ...wait, parameters: { type: "object", properties: { yield_time_ms: { type: "number" } } } }; const map = collectFunctionCallRepairSchemas({ tools: [numberWait, { type: "namespace", name: "remote", tools: [numberWait] }] }); @@ -190,13 +235,34 @@ describe("native function completion SSE", () => { .toMatchObject({ arguments: canonical }); const output = rewrite(frame("response.output_item.added", { output_index: 1, item: item("", { name: "get_state", id: "fc_two", status: "in_progress" }) })); expect(output.map(block => payload(block).type)).toEqual(["response.output_item.added", "response.function_call_arguments.done"]); - expect(payload(output[1]!)).toMatchObject({ arguments: "{}", output_index: 1 }); + expect(payload(output[1]!)).toMatchObject({ arguments: "{}", output_index: 1, item_id: "fc_two" }); expect(budget.snapshot().currentBytes).toBe(0); rewrite.dispose?.(); expect(budget.snapshot().currentBytes).toBe(0); } finally { rewrite.dispose?.(); budget.dispose(); } }); + test.each([raw, canonical])("index-only completion gets downstream identity even when arguments stay unchanged: %s", argumentsText => { + const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); + try { + expect(rewrite(frame("response.function_call_arguments.done", { output_index: 0, arguments: argumentsText }))).toEqual([]); + const output = rewrite(frame("response.output_item.added", { output_index: 0, item: item("", { status: "in_progress" }) })); + const calls = new Map(); + for (const block of output) { + const event = payload(block); + if (event.type === "response.output_item.added") { + const call = event.item as { id: string; arguments: string }; + calls.set(call.id, call.arguments); + } else if (event.type === "response.function_call_arguments.done") { + expect(typeof event.item_id).toBe("string"); + expect(calls.has(event.item_id as string)).toBe(true); + calls.set(event.item_id as string, event.arguments as string); + } + } + expect([...calls]).toEqual([["fc_one", canonical]]); + } finally { rewrite.dispose?.(); } + }); + test("terminal snapshots resolve early completions; authoritative arguments beat previews", () => { const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); try { diff --git a/tests/responses/responses-snapshot-repair-server.test.ts b/tests/responses/responses-snapshot-repair-server.test.ts index 57916e8f4e..3d7702b1af 100644 --- a/tests/responses/responses-snapshot-repair-server.test.ts +++ b/tests/responses/responses-snapshot-repair-server.test.ts @@ -190,3 +190,34 @@ describe("responsesSnapshotRepair through /v1/responses", () => { } }); }); + +test("sparse JSON completion inference precedes function repair in client output and replay", async () => { + const expected = '{"cell_id":"4","yield_time_ms":120000}'; + const item = { type: "function_call", id: "fc_sparse_wait", call_id: "call_sparse_wait", name: "wait", arguments: '{"cell_id":4,"yield_time_ms":120000.0}' }; + let responseId = `resp_sparse_${crypto.randomUUID()}`; + let capturedInput: Array> = []; + globalThis.fetch = (async (_input, init) => { + capturedInput = JSON.parse(String(init?.body)).input; + return Response.json({ id: responseId, output: [item] }); + }) as typeof fetch; + const config = { + port: 0, defaultProvider: "sparse", + providers: { sparse: { adapter: "openai-responses", baseUrl: "https://sparse-function.invalid/v1", authMode: "key", apiKey: "fixture", responsesSnapshotRepair: true } }, + } as OcxConfig; + const request = (extra: object = {}) => new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "sparse/probe", stream: false, input: "synthetic", + tools: [{ type: "function", name: "wait", parameters: { type: "object", properties: { cell_id: { type: "string" }, yield_time_ms: { type: "integer" } } } }], + ...extra, + }), + }); + const first = await handleResponses(request(), config, { model: "", provider: "" }); + expect(first.status).toBe(200); + expect(await first.json()).toMatchObject({ status: "completed", output: [{ status: "completed", arguments: expected }] }); + const previous = responseId; + responseId = `resp_sparse_followup_${crypto.randomUUID()}`; + const second = await handleResponses(request({ previous_response_id: previous, input: [{ type: "function_call_output", call_id: item.call_id, output: "done" }] }), config, { model: "", provider: "" }); + await second.text(); + expect(capturedInput.find(value => value.type === "function_call" && value.call_id === item.call_id)?.arguments).toBe(expected); +}); From a3f0caba416df65bb154aefc75c292ba0f8a67e0 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:20:32 +0900 Subject: [PATCH 5/8] test(responses): retain dotted namespace alias in compaction inventory --- tests/responses/openai-responses-passthrough.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 7fa408ded4..07256e83e7 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -909,6 +909,7 @@ describe("routed compaction lowering order", () => { expect([...(built.convertedRoutedToolSearchNames ?? [])]).toEqual(["opencodex_tool_search"]); expect([...(built.convertedRoutedNamespaceToolAliases ?? new Map()).entries()]).toEqual([ ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], + ["collaboration.spawn_agent", { namespace: "collaboration", name: "spawn_agent", kind: "function" }], ]); }); From 96ee3efea15710e8413b868be9dbaa61dc5fd6d8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:27:43 +0900 Subject: [PATCH 6/8] fix(responses): compare duplicate repair schemas structurally --- src/responses/function-call-compat.ts | 15 ++++++- .../responses-function-tool-repair.test.ts | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts index 01e4d1f873..c4888cf1f7 100644 --- a/src/responses/function-call-compat.ts +++ b/src/responses/function-call-compat.ts @@ -16,6 +16,19 @@ function isObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** JSON object member order is immaterial; array elements retain their exact order. */ +function sameSchemaValue(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length + && left.every((value, index) => sameSchemaValue(value, right[index])); + } + if (!isObject(left) || !isObject(right)) return false; + const keys = Object.keys(left); + return keys.length === Object.keys(right).length + && keys.every(key => Object.hasOwn(right, key) && sameSchemaValue(left[key], right[key])); +} + function namespaceOf(value: unknown): string | undefined { return typeof value === "string" && value !== "functions" ? value : undefined; } @@ -65,7 +78,7 @@ export function collectFunctionCallRepairSchemas(body: unknown): Map { } }); + test.each([false, true])("equivalent duplicate schemas ignore object key order (loaded=%s)", loaded => { + const first = { type: "object", properties: { + cell_id: { type: "string", description: "Cell identifier" }, + yield_time_ms: { type: "integer", minimum: 0 }, + }, required: ["cell_id", "yield_time_ms"], additionalProperties: false }; + const reordered = { additionalProperties: false, required: ["cell_id", "yield_time_ms"], properties: { + yield_time_ms: { minimum: 0, type: "integer" }, + cell_id: { description: "Cell identifier", type: "string" }, + }, type: "object" }; + for (const [original, duplicate] of [[first, reordered], [reordered, first]]) { + const explicit = { ...wait, parameters: original }; + const repeated = { ...wait, parameters: duplicate }; + const body = loaded + ? { tools: [explicit], input: [{ type: "tool_search_output", tools: [repeated] }] } + : { tools: [explicit, repeated] }; + const before = JSON.stringify(body); + const map = collectFunctionCallRepairSchemas(body); + expect([...map.keys()]).toEqual(["wait"]); + expect(map.get("wait")?.parameters).toBe(original); + expect(repairFunctionCalls(item(), map).value).toEqual(item(canonical)); + expect(JSON.stringify(body)).toBe(before); + } + }); + + test("duplicate schema comparison keeps required and nested type arrays ordered", () => { + const original = { ...parameters, required: ["cell_id", "yield_time_ms"] }; + const differentArrays = [ + [original, { ...original, required: ["yield_time_ms", "cell_id"] }], + [ + { ...original, properties: { ...original.properties, union: { type: ["number", "string"] } } }, + { ...original, properties: { ...original.properties, union: { type: ["string", "number"] } } }, + ], + ]; + for (const [baseline, changed] of differentArrays) { + for (const pair of [[baseline, changed], [changed, baseline]]) { + const map = collectFunctionCallRepairSchemas({ tools: pair.map(parameters => ({ ...wait, parameters })) }); + expect(map.size).toBe(0); + const completed = item(); + expect(repairFunctionCalls(completed, map)).toEqual({ value: completed, changed: false }); + } + } + }); + test("loaded tool_search_output functions retain their original schemas", () => { const body = { input: [{ type: "tool_search_output", tools: [wait, { type: "custom", name: "exec" }] }] }; const before = JSON.stringify(body); From 2d2c4b86050f028682cbecd8220de86ab272d851 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:41:39 +0900 Subject: [PATCH 7/8] fix(responses): reject mismatched namespace call kinds --- src/responses/namespace-tool-compat.ts | 3 ++ tests/responses/namespace-tool-compat.test.ts | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index d13e19422a..049d74de09 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -416,6 +416,9 @@ export function restoreRoutedNamespaceCalls( ) { const identity = aliases.get(value.name); if (identity + // Custom declarations may be lowered to function calls upstream, but an + // ordinary function declaration never authorizes a custom call payload. + && (value.type !== "custom_tool_call" || identity.kind === "custom") && (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) { restored.name = identity.name; restored.namespace = identity.namespace; diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index 622fe09411..c8917aac7a 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { restoreRoutedCustomCalls, rewriteRoutedCustomToolsForUpstream } from "../../src/responses/custom-tool-compat"; import { createRoutedNamespaceCallRestoreRewrite, restoreRoutedNamespaceCalls, @@ -523,6 +524,39 @@ describe("Responses namespace tool compatibility", () => { describe("dotted namespace restoration uses the declaration collision boundary", () => { const ping = { type: "namespace", name: "mcp", tools: [{ type: "function", name: "ping", parameters: {} }] }; + test.each(["mcp.ping", "mcp__ping"])("preserves a custom call whose alias %s declares an ordinary function", name => { + const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping] }); + expect(aliases.get(name)?.kind).toBe("function"); + for (const namespace of [undefined, "mcp"]) { + const call = { type: "custom_tool_call", name, call_id: "call_ping", input: "raw custom input", + ...(namespace === undefined ? {} : { namespace }) }; + expect(restoreRoutedNamespaceCalls(call, aliases)).toEqual({ value: call, changed: false }); + expect(restoreRoutedNamespaceCalls(call, aliases).value).toBe(call); + const text = JSON.stringify({ type: "response.completed", response: { output: [call] } }, null, 2); + expect(restoreRoutedNamespaceCallsInJson(text, aliases)).toBe(text); + expect(createRoutedNamespaceCallRestoreRewrite(aliases)(text)).toBe(text); + } + }); + + test.each(["mcp.run", "mcp__run"])("restores the declared custom tool after upstream function downgrade via %s", name => { + const namespaced = rewriteRoutedNamespaceToolsForUpstream({ + tools: [{ type: "namespace", name: "mcp", tools: [{ type: "custom", name: "run", description: "Run raw input" }] }], + }); + const downgraded = rewriteRoutedCustomToolsForUpstream(namespaced.body, false); + expect(downgraded.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); + expect(downgraded.names.has("mcp__run")).toBe(true); + expect(namespaced.aliases.get(name)?.kind).toBe("custom"); + const call = { type: "function_call", name, id: "fc_run", call_id: "call_run", arguments: '{"input":"echo ready"}' }; + const restored = restoreRoutedNamespaceCalls(call, namespaced.aliases); + expect(restored).toEqual({ changed: true, value: { ...call, name: "run", namespace: "mcp" } }); + expect(restoreRoutedCustomCalls({ output: [restored.value] }, downgraded.names).value).toEqual({ + output: [{ type: "custom_tool_call", name: "run", namespace: "mcp", id: "ctc_run", call_id: "call_run", input: "echo ready" }], + }); + const nativeCustom = { type: "custom_tool_call", name, input: "raw custom input" }; + expect(restoreRoutedNamespaceCalls(nativeCustom, namespaced.aliases).value) + .toEqual({ ...nativeCustom, name: "run", namespace: "mcp" }); + }); + test("restores the dotted spelling after canonical tool-choice authorization", () => { const { aliases } = rewriteRoutedNamespaceToolsForUpstream({ tools: [ping], tool_choice: { type: "function", namespace: "mcp", name: "ping" } }); expect(restoreRoutedNamespaceCalls({ type: "function_call", name: "mcp.ping", arguments: "{}" }, aliases).value) From 848543e613db6b7a9e467fdd8f082ec5295fcbff Mon Sep 17 00:00:00 2001 From: t Date: Sun, 6 Sep 2026 05:50:00 +0900 Subject: [PATCH 8/8] fix(responses): preserve original custom kind through namespace lowering --- src/adapters/openai-responses.ts | 2 +- src/responses/namespace-tool-compat.ts | 10 +++- tests/responses/namespace-tool-compat.test.ts | 8 +-- .../openai-responses-passthrough.test.ts | 55 +++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c4bbae4a09..1faa9c0cbb 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2429,7 +2429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Codex 0.147 emits private namespace tool groups, while public/third-party Responses // gateways accept only flat tool variants. Run after custom/tool-search lowering so // namespace children already carry their final public kind before they are promoted. - const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); + const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 049d74de09..24b26e8a2e 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -341,7 +341,10 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se * `__` wire identity as the chat adapters. The returned request-local aliases * are the only names response restoration is allowed to expand. */ -export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { +export function rewriteRoutedNamespaceToolsForUpstream( + body: unknown, + convertedCustomToolNames?: ReadonlySet, +): { body: unknown; aliases: Map; } { @@ -376,6 +379,11 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { && !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted) && !aliases.has(dotted)) aliases.set(dotted, identity); } + // The adapter lowers custom tools before namespaces. Preserve their declared + // kind only in already-authorized response aliases; wire selectors remain lowered. + for (const identity of aliases.values()) { + if (convertedCustomToolNames?.has(namespacedToolName(identity.namespace, identity.name))) identity.kind = "custom"; + } return { body: { ...body, diff --git a/tests/responses/namespace-tool-compat.test.ts b/tests/responses/namespace-tool-compat.test.ts index c8917aac7a..629c3474a7 100644 --- a/tests/responses/namespace-tool-compat.test.ts +++ b/tests/responses/namespace-tool-compat.test.ts @@ -539,11 +539,11 @@ describe("dotted namespace restoration uses the declaration collision boundary", }); test.each(["mcp.run", "mcp__run"])("restores the declared custom tool after upstream function downgrade via %s", name => { - const namespaced = rewriteRoutedNamespaceToolsForUpstream({ + const downgraded = rewriteRoutedCustomToolsForUpstream({ tools: [{ type: "namespace", name: "mcp", tools: [{ type: "custom", name: "run", description: "Run raw input" }] }], - }); - const downgraded = rewriteRoutedCustomToolsForUpstream(namespaced.body, false); - expect(downgraded.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); + }, false); + const namespaced = rewriteRoutedNamespaceToolsForUpstream(downgraded.body, downgraded.names); + expect(namespaced.body).toMatchObject({ tools: [{ type: "function", name: "mcp__run" }] }); expect(downgraded.names.has("mcp__run")).toBe(true); expect(namespaced.aliases.get(name)?.kind).toBe("custom"); const call = { type: "function_call", name, id: "fc_run", call_id: "call_run", arguments: '{"input":"echo ready"}' }; diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 07256e83e7..9697ba6656 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -21,6 +21,8 @@ import { import { createTranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { restoreRoutedNamespaceCalls } from "../../src/responses/namespace-tool-compat"; +import { restoreRoutedCustomCalls } from "../../src/responses/custom-tool-compat"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); @@ -3714,6 +3716,59 @@ describe("routed namespace and custom-tool identity", () => { const frame = (event: string, payload: Record): string => `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; + test.each(["function_call", "custom_tool_call"])("adapter preserves original custom kind for upstream %s after actual lowering order", type => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const built = adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools: rawTools }, + }, { headers: new Headers() }); + const aliases = built.convertedRoutedNamespaceToolAliases; + const names = built.convertedRoutedCustomToolNames; + if (!aliases || !names) throw new Error("Missing adapter conversion provenance"); + expect([...names]).toEqual([`${customNamespace}__read`]); + expect(JSON.parse(built.body).tools).toMatchObject([ + { type: "function", name: `${customNamespace}__read` }, + { type: "function", name: `${functionNamespace}__read` }, + ]); + for (const separator of ["__", "."]) { + const name = `${customNamespace}${separator}read`; + expect(aliases.get(name)?.kind).toBe("custom"); + expect(aliases.get(`${functionNamespace}${separator}read`)?.kind).toBe("function"); + const call = type === "function_call" ? { ...customUpstreamItem, name } : { + type, name, id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }; + const restored = restoreRoutedNamespaceCalls({ output: [call] }, aliases); + expect(restored.changed).toBe(true); + expect(restoreRoutedCustomCalls(restored.value, names).value).toEqual({ output: [{ + type: "custom_tool_call", name: "read", namespace: customNamespace, + id: "ctc_custom_read", call_id: "call_custom_read", input: "freeform payload", status: "completed", + }] }); + const mismatched = { type: "custom_tool_call", name: `${functionNamespace}${separator}read`, input: "opaque payload" }; + expect(restoreRoutedNamespaceCalls(mismatched, aliases)).toEqual({ value: mismatched, changed: false }); + } + }); + + test("adapter custom provenance does not add excluded or colliding namespace aliases", () => { + const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); + const build = (tools: unknown[], tool_choice: unknown) => adapter.buildRequest({ + modelId: "routed-model", context: { messages: [] }, stream: false, options: {}, + _rawBody: { model: "routed-model", input: "read", tools, tool_choice }, + }, { headers: new Headers() }); + expect(build(rawTools, "none").convertedRoutedNamespaceToolAliases?.size).toBe(0); + const selected = build(rawTools, { type: "function", namespace: functionNamespace, name: "read" }); + expect([...selected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${functionNamespace}__read`, `${functionNamespace}.read`]); + const customSelected = build(rawTools, { type: "custom", namespace: customNamespace, name: "read" }); + expect([...customSelected.convertedRoutedNamespaceToolAliases!.keys()]) + .toEqual([`${customNamespace}__read`, `${customNamespace}.read`]); + expect(customSelected.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + const collision = build([...rawTools, { type: "function", name: `${customNamespace}.read`, parameters: {} }], "auto"); + expect(collision.convertedRoutedNamespaceToolAliases?.has(`${customNamespace}.read`)).toBe(false); + expect(collision.convertedRoutedNamespaceToolAliases?.get(`${customNamespace}__read`)?.kind).toBe("custom"); + expect(() => build([...rawTools, { type: "function", name: `${customNamespace}__read`, parameters: {} }], "auto")) + .toThrow("namespace tool wire-name collision"); + }); + test("round-trips same-named namespaced custom and function calls through JSON and SSE", async () => { const adapter = createResponsesPassthroughAdapter(config.providers.fixture!); const built = adapter.buildRequest({