From 8da8dbae401d7d23599449f111815f200ce200e2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 02:00:13 +0000 Subject: [PATCH 1/3] fix(responses): scope tool authority to current turn --- src/server/responses/request-prepare.ts | 15 +++++-------- .../transports/response-turn-membership.md | 4 ++++ ...xplicit-empty-tool-catalog-history.test.ts | 22 ++++++++++++++++--- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 637c3a74262..c53a32881f1 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -150,7 +150,6 @@ import { nativeContextLimits } from "../../codex/catalog"; import type { ClientToolCatalogRestriction } from "../../lib/empty-tool-catalog"; import { currentTurnWireToolCatalogBody, - hasExplicitEmptyToolCatalog, collectDeclaredWireToolNames, collectDeclaredBareCustomWireToolNames, } from "../responses-undeclared-tool-guard"; @@ -483,16 +482,14 @@ export async function prepareResponsesRequest( parsed._nativeReasoningRetention = resolveNativeReasoningRetention(config); // Capture before route rewrites or hosted sidecars can add tools; replayed catalogs are history. // The direct settings body omits tools, including current legacy Chat functions. Recover - // their wire names only when this restriction needs a non-empty decoded catalog. - const catalogBody = options.directRequest && options.clientDeclaredEmptyToolCatalog === true && parsed.context.tools?.length + // their wire names from the decoded conversation before restricting delivery authority. + const catalogBody = options.directRequest && parsed.context.tools?.length ? options.directRequest.bridgeConversation() : parsed._rawBody; const currentCatalog = currentTurnWireToolCatalogBody(catalogBody, parsed._replayPrefixLen); - if (options.clientDeclaredEmptyToolCatalog === true || hasExplicitEmptyToolCatalog(currentCatalog)) { - clientToolCatalogRestriction = { - declaredToolNames: collectDeclaredWireToolNames(currentCatalog), - bareCustomToolNames: collectDeclaredBareCustomWireToolNames(currentCatalog), - }; - } + clientToolCatalogRestriction = { + declaredToolNames: collectDeclaredWireToolNames(currentCatalog), + bareCustomToolNames: collectDeclaredBareCustomWireToolNames(currentCatalog), + }; if (options.inboundWire === "anthropic") { parsed._nativeReasoningReplay = options.nativeReasoningReplay; parsed._nativeReasoningMint = options.nativeReasoningMint; diff --git a/structure/transports/response-turn-membership.md b/structure/transports/response-turn-membership.md index 894a20b21e6..e7e2f76fdb3 100644 --- a/structure/transports/response-turn-membership.md +++ b/structure/transports/response-turn-membership.md @@ -19,6 +19,10 @@ so clearing a caller-owned Buffer does not mutate the signing key. No key is gen The absent/malformed-key default changes no input. The pass uses bounded linear indexing with at most 65,536 input items and retains existing item references. +Independently of structural normalization, request preparation derives downstream +tool-call authority from the current-turn wire catalog. Declarations prepended while +expanding `previous_response_id` remain history and cannot authorize a new call. + `tests/responses/turn-membership.test.ts` covers token and scope controls; `tests/responses/turn-membership-ingress.test.ts` proves actual ingress-to-supplier request equality against the buffered reference and refusal of keyless ownership. diff --git a/tests/responses/explicit-empty-tool-catalog-history.test.ts b/tests/responses/explicit-empty-tool-catalog-history.test.ts index 49cb2acde7f..0ca8c355334 100644 --- a/tests/responses/explicit-empty-tool-catalog-history.test.ts +++ b/tests/responses/explicit-empty-tool-catalog-history.test.ts @@ -16,7 +16,7 @@ import { createTempHome } from "../helpers/temp-home"; type Rec = Record; type Lane = "adapter" | "runTurn"; -type Wire = "chat" | "anthropic"; +type Wire = "chat" | "anthropic" | undefined; const REFUSAL_CODE = "undeclared_tool_call"; const userInput = { role: "user", content: [{ type: "input_text", text: "fixture request" }] }; const tool = (name: string) => ({ type: "function", name, parameters: { type: "object", properties: {} } }); @@ -143,6 +143,22 @@ function assertAllowed(response: Rec, name: string): void { } describe("explicit empty catalog current-turn and continuation boundaries", () => { + test("Responses continuation rejects a tool declared only in replayed history", async () => { + const config = setup("adapter"); + const prior = await post(config, undefined, false, { input: [additional(["shell"]), userInput] }); + expect(prior.status).toBe("completed"); + + nextCall = "shell"; + const response = await post(config, undefined, false, { + previous_response_id: prior.id, + input: [additional(["benign_tool"]), userInput], + }); + + expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); + expect(dispatched[1]!.tools).toEqual(expect.arrayContaining(["shell", "benign_tool"])); + assertRefusedAndUncached(response); + }); + for (const lane of ["adapter", "runTurn"] as const) for (const wire of ["chat", "anthropic"] as const) { for (const stream of [false, true]) { const label = `${lane}, ${wire}, ${stream ? "SSE" : "JSON"}`; @@ -164,7 +180,7 @@ describe("explicit empty catalog current-turn and continuation boundaries", () = }); } - test(`${label}: historical additional_tools:[] does not restrict a current partial catalog`, async () => { + test(`${label}: current partial catalog rejects an undeclared tool`, async () => { const config = setup(lane); const prior = await post(config, wire, stream, { input: [additional([]), userInput] }); expect(prior.status).toBe("completed"); @@ -173,7 +189,7 @@ describe("explicit empty catalog current-turn and continuation boundaries", () = expect(dispatched).toHaveLength(2); expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); expect(dispatched[1]!.tools).toContain("Read"); - assertAllowed(response, "Write"); + assertRefusedAndUncached(response); }); test(`${label}: current declaration controls output normalization after historical default.Read`, async () => { From ec4bc25901fae21f715f57cd2b5e338d9081a0f5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 05:33:45 +0000 Subject: [PATCH 2/3] fix(responses): preserve deferred tools and current-turn identity Separate current-turn normalization from explicit-empty and replay enforcement. Keep tool-choice filtering and canonical namespace provenance at delivery. Read direct decoded tool names without bridging signed history a second time. Restore the 70 existing regressions and add direct-budget and namespace continuation coverage while retaining historical-tool refusal. --- scripts/test-layout/layout-responses.json | 2 + src/lib/empty-tool-catalog.ts | 28 ++- src/server/responses-undeclared-tool-guard.ts | 11 +- src/server/responses/adapter-delivery.ts | 5 +- src/server/responses/request-prepare.ts | 12 +- src/server/responses/run-turn-execution.ts | 5 +- .../transports/response-turn-membership.md | 18 +- structure/transports/responses-wire-shapes.md | 6 +- tests/fixtures/test-layout-expected.json | 2 + .../current-turn-tool-catalog-direct.test.ts | 140 +++++++++++++++ ...urrent-turn-tool-catalog-namespace.test.ts | 163 ++++++++++++++++++ ...xplicit-empty-tool-catalog-history.test.ts | 41 +++-- 12 files changed, 392 insertions(+), 41 deletions(-) create mode 100644 tests/responses/current-turn-tool-catalog-direct.test.ts create mode 100644 tests/responses/current-turn-tool-catalog-namespace.test.ts diff --git a/scripts/test-layout/layout-responses.json b/scripts/test-layout/layout-responses.json index 9561e2eefbb..b455b7d332f 100644 --- a/scripts/test-layout/layout-responses.json +++ b/scripts/test-layout/layout-responses.json @@ -1,4 +1,6 @@ { + "current-turn-tool-catalog-direct.test.ts": "responses", + "current-turn-tool-catalog-namespace.test.ts": "responses", "advisor-web-search.test.ts": "advisor", "passthrough-media-type.test.ts": "responses", "passthrough-override.test.ts": "responses", diff --git a/src/lib/empty-tool-catalog.ts b/src/lib/empty-tool-catalog.ts index 54bb800eefd..67fa0eab770 100644 --- a/src/lib/empty-tool-catalog.ts +++ b/src/lib/empty-tool-catalog.ts @@ -1,19 +1,41 @@ -import { normalizeDeclaredToolName, type AdapterEvent } from "../types"; +import { namespacedToolName, normalizeDeclaredToolName, type AdapterEvent } from "../types"; import { UNDECLARED_TOOL_CALL_ERROR_CODE } from "./errors"; import { releaseTranslatedEvent, type TranslatorBudget } from "./translator-budget"; import { releaseCompactionCiphertextLease } from "../responses/compaction"; -/** Snapshot of current-turn declarations when an explicit empty catalog activates enforcement. */ +/** Current-turn names for normalization, with wire-specific membership enforcement. */ export interface ClientToolCatalogRestriction { declaredToolNames: ReadonlySet; bareCustomToolNames: ReadonlySet; + canonicalToolNames?: ReadonlySet; + enforceDeclaredToolNames?: boolean; +} + +/** Current declarations may narrow the selection-filtered bridge names, never widen them. */ +export function currentTurnToolBridgeMaps & { + toolNsMap: ReadonlyMap; +}>( + maps: T, + catalog: ClientToolCatalogRestriction | undefined, +): T { + if (!catalog) return maps; + const currentIdentity = (name: string): boolean => { + const mapped = maps.toolNsMap.get(name); + // An ambiguous historical bare alias may lack a mapping. Keep only current identities. + return catalog.canonicalToolNames?.has(mapped ? namespacedToolName(mapped.namespace, mapped.name) : name) ?? true; + }; + return { + ...maps, + declaredToolNames: new Set([...catalog.declaredToolNames].filter(name => maps.declaredToolNames.has(name) && currentIdentity(name))), + bareCustomToolNames: new Set([...catalog.bareCustomToolNames].filter(name => maps.bareCustomToolNames.has(name) && currentIdentity(name))), + }; } export function emptyToolCatalogRefusal( event: AdapterEvent, catalog: ClientToolCatalogRestriction | undefined, ): Extract | undefined { - if (!catalog) return; + if (!catalog || catalog.enforceDeclaredToolNames === false) return; if (event.type === "tool_call_start") { const name = normalizeDeclaredToolName(event.name, catalog.declaredToolNames, undefined, catalog.bareCustomToolNames); if (catalog.declaredToolNames.has(name)) return; diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index d64a802b2ab..3698b76da9d 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -90,6 +90,7 @@ function addWireToolName( tool: unknown, namespace?: string, ambiguousDottedAliases?: ReadonlySet, + canonicalOnly = false, ): void { if (!isPlainObject(tool)) return; const name = wireToolInnerName(tool); @@ -102,6 +103,7 @@ function addWireToolName( return; } names.add(namespacedToolName(namespace, name)); + if (canonicalOnly) return; // Some routed providers echo the flattened wire name with a dot (`ns.name`, observed with // muse-spark via opencode-go) instead of `ns__name`. It is the same tool identity, so register // the dotted spelling too, mirroring `toolChoiceAliases` (#3402) -- but only while that @@ -146,16 +148,17 @@ function addWireToolSpecs( names: Set, specs: unknown, ambiguousDottedAliases?: ReadonlySet, + canonicalOnly = false, ): void { if (!Array.isArray(specs)) return; 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; - for (const inner of spec.tools) addWireToolName(names, inner, namespace, ambiguousDottedAliases); + for (const inner of spec.tools) addWireToolName(names, inner, namespace, ambiguousDottedAliases, canonicalOnly); continue; } - addWireToolName(names, spec, undefined, ambiguousDottedAliases); + addWireToolName(names, spec, undefined, ambiguousDottedAliases, canonicalOnly); } } @@ -167,7 +170,7 @@ function addWireToolSpecs( * inside `input`, routed custom-tool rewrites) that the internal list flattens or renames, and * only the wire names can be compared against what the provider echoes back. */ -export function collectDeclaredWireToolNames(body: unknown): Set { +export function collectDeclaredWireToolNames(body: unknown, options?: { canonicalOnly?: boolean }): Set { const names = new Set(); if (!isPlainObject(body)) return names; const specGroups: unknown[] = [body.tools]; @@ -180,7 +183,7 @@ export function collectDeclaredWireToolNames(body: unknown): Set { } } const ambiguousDottedAliases = collectAmbiguousDottedAliases(specGroups); - for (const specs of specGroups) addWireToolSpecs(names, specs, ambiguousDottedAliases); + for (const specs of specGroups) addWireToolSpecs(names, specs, ambiguousDottedAliases, options?.canonicalOnly); return names; } diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 986bd4c0a45..839e4893c28 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -27,7 +27,7 @@ import { readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-err import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog"; +import { currentTurnToolBridgeMaps, guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog"; import { guardCompactionSummary } from "../../responses/compaction-summary"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ @@ -123,8 +123,7 @@ export async function deliverAdapterResponse( const clientToolCatalog = requestState.clientToolCatalogRestriction && route.provider.authMode !== "forward" ? requestState.clientToolCatalogRestriction : undefined; - const deliveryToolBridgeMaps = clientToolCatalog - ? { ...toolBridgeMaps, ...clientToolCatalog } : toolBridgeMaps; + const deliveryToolBridgeMaps = currentTurnToolBridgeMaps(toolBridgeMaps, clientToolCatalog); if (parsed.stream) { diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index c53a32881f1..e2073dbc026 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -150,6 +150,7 @@ import { nativeContextLimits } from "../../codex/catalog"; import type { ClientToolCatalogRestriction } from "../../lib/empty-tool-catalog"; import { currentTurnWireToolCatalogBody, + hasExplicitEmptyToolCatalog, collectDeclaredWireToolNames, collectDeclaredBareCustomWireToolNames, } from "../responses-undeclared-tool-guard"; @@ -481,14 +482,19 @@ export async function prepareResponsesRequest( if (options.directRequest) parsed = options.directRequest.decode(parsed); parsed._nativeReasoningRetention = resolveNativeReasoningRetention(config); // Capture before route rewrites or hosted sidecars can add tools; replayed catalogs are history. - // The direct settings body omits tools, including current legacy Chat functions. Recover - // their wire names from the decoded conversation before restricting delivery authority. + // Direct decoders already own current function names (including legacy Chat functions). + // Project only those names; bridging the conversation would retain its reasoning twice. const catalogBody = options.directRequest && parsed.context.tools?.length - ? options.directRequest.bridgeConversation() : parsed._rawBody; + ? { tools: parsed.context.tools.map(tool => ({ type: "function", name: tool.name })) } : parsed._rawBody; const currentCatalog = currentTurnWireToolCatalogBody(catalogBody, parsed._replayPrefixLen); clientToolCatalogRestriction = { declaredToolNames: collectDeclaredWireToolNames(currentCatalog), bareCustomToolNames: collectDeclaredBareCustomWireToolNames(currentCatalog), + canonicalToolNames: collectDeclaredWireToolNames(currentCatalog, { canonicalOnly: true }), + // Ordinary Responses validation stays with its delivery owner; replay cannot add authority. + enforceDeclaredToolNames: (inboundWire === "responses" && (parsed._replayPrefixLen ?? 0) > 0) + || options.clientDeclaredEmptyToolCatalog === true + || hasExplicitEmptyToolCatalog(currentCatalog), }; if (options.inboundWire === "anthropic") { parsed._nativeReasoningReplay = options.nativeReasoningReplay; diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index e2f6277004f..b802d26cf5c 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -49,7 +49,7 @@ import { rememberResponseState } from "../../responses/state"; import { trackStreamLifetime } from "../lifecycle"; import { awaitThoughtSignatureDurability } from "../../responses/thought-signature-replay"; import { undeclaredToolCallMessage } from "../responses-undeclared-tool-guard"; -import { emptyToolCatalogRefusal, guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog"; +import { currentTurnToolBridgeMaps, emptyToolCatalogRefusal, guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog"; // LOCAL PATCH (runturn-websearch) import { planWebSearch } from "../../web-search"; import { runTurnWebSearchInitialParsed, runTurnWebSearchLoop } from "../../web-search/run-turn-loop"; @@ -755,8 +755,7 @@ export async function executeResponsesRunTurn( const clientToolCatalog = requestState.clientToolCatalogRestriction && route.provider.authMode !== "forward" ? requestState.clientToolCatalogRestriction : undefined; - const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = clientToolCatalog - ? { ...toolBridgeMaps, ...clientToolCatalog } : toolBridgeMaps; + const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = currentTurnToolBridgeMaps(toolBridgeMaps, clientToolCatalog); const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic"; const classifyUndeclaredFirstTool = ( event: AdapterEvent, diff --git a/structure/transports/response-turn-membership.md b/structure/transports/response-turn-membership.md index e7e2f76fdb3..2b96e8fd953 100644 --- a/structure/transports/response-turn-membership.md +++ b/structure/transports/response-turn-membership.md @@ -19,9 +19,21 @@ so clearing a caller-owned Buffer does not mutate the signing key. No key is gen The absent/malformed-key default changes no input. The pass uses bounded linear indexing with at most 65,536 input items and retains existing item references. -Independently of structural normalization, request preparation derives downstream -tool-call authority from the current-turn wire catalog. Declarations prepended while -expanding `previous_response_id` remain history and cannot authorize a new call. +Independently of structural normalization, request preparation snapshots current-turn +tool names for output normalization. Responses enforces membership in that catalog; +Chat and Messages enforce it when the caller explicitly empties a catalog, while +omitted and partial catalogs retain deferred delivery. Declarations prepended while +expanding `previous_response_id` remain history and cannot grant current-turn membership. +Delivery intersects current names with the existing selection-filtered bridge names, so +`tool_choice` cannot be widened by the snapshot. Ordinary Responses validation retains its +delivery owner and diagnostics; the additional guard enforces explicit-empty catalogs and +Responses replay boundaries. Initial hosted-sidecar deferred delivery retains its existing contract. +`tests/responses/explicit-empty-tool-catalog-history.test.ts` covers these continuation boundaries +and normalization across adapter/run-turn JSON/SSE. The real direct Messages planner and delivery +share one signed-history translation budget in `tests/responses/current-turn-tool-catalog-direct.test.ts`. +Namespace restoration must also name a current canonical identity; an ambiguous historical bare +alias is refused while the current canonical spelling stays callable. The two identity directions +are covered by `tests/responses/current-turn-tool-catalog-namespace.test.ts` on both delivery lanes. `tests/responses/turn-membership.test.ts` covers token and scope controls; `tests/responses/turn-membership-ingress.test.ts` proves actual ingress-to-supplier diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 2a5808baac7..286d997bf12 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -316,9 +316,9 @@ input deltas are tracked separately from client-executed `tool_use` blocks. Ingress `clientDeclaredEmptyToolCatalog` (`src/server/chat-completions.ts`, `src/server/claude-messages.ts`) survives omission during translation. Preparation snapshots declarations after direct decoding, before hosted-tool rewrites. -Direct Chat with `tools: []` and legacy functions reads their names from the bridge projection; -empty and omitted catalogs need no extra projection. Explicit `tools: []` or empty `additional_tools` -activates the restriction; replayed declarations neither activate it nor grant names. Delivery +Direct decoders expose current function names, including legacy Chat functions, without translating +the conversation again or duplicating retained reasoning-budget charges. Explicit `tools: []` or empty +`additional_tools` activates the restriction; replayed declarations neither activate it nor grant names. Delivery normalization uses those names; direct custom `exec` helpers use the bridge compiler. Refusals keep `undeclared_tool_call` in buffered envelopes and endpoint projections; Messages sees `api_error`, never `overloaded_error`. Caller-forward routes retain their exemption; `default.` normalization remains separate. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0c9e9e966e0..510a429a12d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -81,6 +81,8 @@ "messages-native-empty-inline.test.ts": "claude-integration", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", + "current-turn-tool-catalog-direct.test.ts": "responses", + "current-turn-tool-catalog-namespace.test.ts": "responses", "gentle-shell-path.test.ts": "clients", "gentle-shell-client.test.ts": "clients", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "compaction-history-framing.test.ts": "responses", diff --git a/tests/responses/current-turn-tool-catalog-direct.test.ts b/tests/responses/current-turn-tool-catalog-direct.test.ts new file mode 100644 index 00000000000..b8c18e0ec68 --- /dev/null +++ b/tests/responses/current-turn-tool-catalog-direct.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { saveConfig } from "../../src/config"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { saveCredential } from "../../src/oauth/store"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import { clearResponseStateForTests, flushResponseState } from "../../src/responses/state"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import { clientWireOf } from "../../src/server/inference/client-wire"; +import { messagesDirectRequestOption, planMessagesAntigravityDirect } from "../../src/server/messages-antigravity-direct"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { createTempHome } from "../helpers/temp-home"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const MODEL = "google-antigravity/gemini-3.8-flash"; +const API_BASE = "https://daily-cloudcode-pa.googleapis.com"; +const SIGNATURE = "c2ln".repeat(8192); +const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "google-antigravity", + protocols: { rollout: { antigravityDirect: true } }, + providers: { + "google-antigravity": { + adapter: "google", baseUrl: API_BASE, authMode: "oauth", googleMode: "cloud-code-assist", + project: "fixture-project", models: ["gemini-3.8-flash"], liveModels: false, + }, + }, +}; + +let home: ReturnType; +let releaseHome: (() => void) | undefined; +let originalFetch: typeof fetch; +let sent: Array>; + +beforeEach(async () => { + home = createTempHome("ocx-current-catalog-direct-"); + releaseHome = acquireOwnedSpendHome(); + clearResponseStateForTests(); + clearGenericFailoverHealth(); + sent = []; + originalFetch = globalThis.fetch; + saveConfig(config); + await saveCredential("google-antigravity", { + access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3_600_000, + accountId: "fixture-account", projectId: "fixture-project", source: "oauth", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin !== API_BASE || !["/v1internal:streamGenerateContent", "/v1internal:generateContent"].includes(url.pathname)) { + throw new Error("the direct catalog fixture must not send another upstream request"); + } + sent.push(JSON.parse(String(init?.body)) as Record); + const payload = { response: { + candidates: [{ content: { role: "model", parts: [{ functionCall: { name: "default.lookup", args: { q: "fixture" } } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 11, candidatesTokenCount: 3, totalTokenCount: 14 }, + } }; + return url.searchParams.get("alt") === "sse" + ? new Response(`data: ${JSON.stringify(payload)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + }) as typeof fetch; +}); + +afterEach(async () => { + globalThis.fetch = originalFetch; + releaseHome?.(); + releaseHome = undefined; + closeRequestHistoryIndex(); + await flushResponseState(); + clearResponseStateForTests(); + clearGenericFailoverHealth(); + resetProviderRequestPacingForTest(); + home.remove(); +}); + +function messagesBody(): Record { + return { + model: MODEL, max_tokens: 200, + tools: [{ name: "lookup", input_schema: { type: "object", properties: { q: { type: "string" } } } }], + messages: [ + { role: "user", content: "look up the fixture" }, + { role: "assistant", content: [ + { type: "thinking", thinking: "Use the lookup tool.", signature: SIGNATURE }, + { type: "text", text: "Ready." }, + ] }, + { role: "user", content: "continue" }, + ], + }; +} + +for (const stream of [false, true]) { + test(`direct Messages catalog capture preserves signed-history budget and normalizes tool names (${stream ? "SSE" : "JSON"})`, async () => { + const body = messagesBody(); + // Leave room for normal delivery, but not another full translation on top of the + // direct decoder's retained reasoning. Measure the real decoder, not its formula. + const measured = createTestTranslatorBudget(); + expect(planMessagesAntigravityDirect(body, undefined, config, measured)).toBeDefined(); + const decoded = measured.snapshot(); + expect(decoded.currentBytes).toBeGreaterThan(0); + const maxTurnBytes = decoded.highWaterBytes + Math.floor(decoded.currentBytes / 2); + measured.dispose(); + + const budget = createTestTranslatorBudget({ maxTurnBytes }); + const plan = planMessagesAntigravityDirect(body, undefined, config, budget)!; + expect(plan).toBeDefined(); + expect(budget.snapshot().currentBytes).toBe(decoded.currentBytes); + expect(plan.conversation.context.messages.some(message => message.role === "assistant" + && message.content.some(part => part.type === "thinking" && part.signature === SIGNATURE))).toBe(true); + const direct = messagesDirectRequestOption(plan, body, undefined, budget); + const bridge = spyOn(direct, "bridgeConversation"); + const releaseDecode = spyOn(plan, "releaseDecodeCharges"); + try { + const response = await handleResponses(new Request("http://127.0.0.1/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...plan.settingsBody, stream: true }), + }), config, { model: "", provider: "", surface: "claude" }, { + inboundWire: "anthropic", translatorBudget: budget, directRequest: direct, + nativeReasoningReplay: plan.conversation.nativeReasoningReplay, + clientEncoder: { protocol: "messages", stream, model: MODEL, requestLane: "direct" }, + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(clientWireOf(response)).toBe("messages"); + expect(sent).toHaveLength(1); + // The final route owns signature forwarding; catalog capture keeps the conversation + // and current declaration without translating the signed history a second time. + expect(JSON.stringify(sent[0])).toContain("Ready."); + expect(JSON.stringify(sent[0])).toContain('"name":"lookup"'); + expect(text).toContain('"name":"lookup"'); + expect(text).not.toContain("default.lookup"); + expect(text).not.toContain("translation_buffer_limit"); + expect(text).toContain(stream ? "event: content_block_start" : '"type":"message"'); + expect(bridge).not.toHaveBeenCalled(); + expect(releaseDecode).not.toHaveBeenCalled(); + expect(budget.snapshot().overflows).toBe(0); + } finally { + bridge.mockRestore(); + releaseDecode.mockRestore(); + } + }); +} diff --git a/tests/responses/current-turn-tool-catalog-namespace.test.ts b/tests/responses/current-turn-tool-catalog-namespace.test.ts new file mode 100644 index 00000000000..243882360ca --- /dev/null +++ b/tests/responses/current-turn-tool-catalog-namespace.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import { clearResponseStateForTests, expandPreviousResponseInput, flushResponseState } from "../../src/responses/state"; +import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; +import * as adapterResolver from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses"; +import { namespacedToolName, type AdapterEvent, type OcxConfig, type OcxParsedRequest } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { createTempHome } from "../helpers/temp-home"; + +type Rec = Record; +type Lane = "adapter" | "runTurn"; +type Reply = { response: Rec; items: Rec[] }; +const userInput = { role: "user", content: [{ type: "input_text", text: "fixture request" }] }; +const readTool = { type: "function", name: "Read", parameters: { type: "object", properties: {} } }; +const additional = (namespace: string | undefined) => ({ + type: "additional_tools", role: "developer", + tools: namespace ? [{ type: "namespace", name: namespace, tools: [readTool] }] : [readTool], +}); + +let home: ReturnType; +let releaseHome: (() => void) | undefined; +let adapterSpy: ReturnType> | undefined; +let nextCall: string | undefined; +let dispatched: Array<{ replayPrefix: number; tools: string[] }>; + +beforeEach(() => { + home = createTempHome("ocx-current-tool-namespace-"); + releaseHome = acquireOwnedSpendHome(); + clearResponseStateForTests(); + clearKeyCooldowns(); + nextCall = undefined; + dispatched = []; +}); + +afterEach(async () => { + adapterSpy?.mockRestore(); + adapterSpy = undefined; + releaseHome?.(); + releaseHome = undefined; + closeRequestHistoryIndex(); + await flushResponseState(); + clearResponseStateForTests(); + clearKeyCooldowns(); + resetProviderRequestPacingForTest(); + home.remove(); +}); + +function replyEvents(): AdapterEvent[] { + return [ + ...(nextCall ? [ + { type: "tool_call_start" as const, id: "call_fixture", name: nextCall }, + { type: "tool_call_delta" as const, arguments: "{}" }, + { type: "tool_call_end" as const }, + ] : [{ type: "text_delta" as const, text: "fixture answer" }]), + { type: "done", usage: { inputTokens: 2, outputTokens: 1 } }, + ]; +} + +function setup(lane: Lane): OcxConfig { + const adapterName = `current-namespace-${lane}`; + const record = (parsed: OcxParsedRequest) => dispatched.push({ + replayPrefix: parsed._replayPrefixLen ?? 0, + tools: (parsed.context.tools ?? []).map(tool => namespacedToolName(tool.namespace, tool.name)), + }); + const originalResolve = adapterResolver.resolveAdapter; + adapterSpy = spyOn(adapterResolver, "resolveAdapter").mockImplementation((provider, cache) => { + if (provider.adapter !== adapterName) return originalResolve(provider, cache); + return { + name: adapterName, + buildRequest(parsed) { + record(parsed); + return { url: provider.baseUrl, method: "POST", headers: {}, body: "{}" }; + }, + // Exercise the real handler and continuation store without a physical network send. + async fetchResponse() { return Response.json({ fixture: true }); }, + async *parseStream() { yield* replyEvents(); }, + async parseResponse() { return replyEvents(); }, + ...(lane === "runTurn" ? { + async runTurn(parsed: OcxParsedRequest, _incoming: unknown, emit: (event: AdapterEvent) => void) { + record(parsed); + for (const event of replyEvents()) emit(event); + }, + } : {}), + } satisfies ProviderAdapter; + }); + return { + port: 0, defaultProvider: "fixture", + providers: { fixture: { + adapter: adapterName, baseUrl: "http://127.0.0.1:9/v1", allowPrivateNetwork: true, + authMode: "key", apiKey: "fixture-key", models: ["model"], liveModels: false, + } }, + }; +} + +async function post(config: OcxConfig, stream: boolean, body: Rec): Promise { + const response = await handleResponses(new Request("http://127.0.0.1/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", stream, ...body }), + }), config, { model: "", provider: "" }); + const text = await response.text(); + expect(response.status).toBe(200); + if (!stream) return { response: JSON.parse(text) as Rec, items: [] }; + const frames = text.split(/\r?\n\r?\n/).flatMap(block => { + const data = block.split(/\r?\n/).filter(line => line.startsWith("data:")) + .map(line => line.slice(5).trimStart()).join("\n"); + return data && data !== "[DONE]" ? [JSON.parse(data) as Rec] : []; + }); + const terminal = frames.filter(frame => frame.type === "response.completed" || frame.type === "response.failed"); + expect(terminal).toHaveLength(1); + return { + response: terminal[0]!.response as Rec, + items: frames.filter(frame => frame.type === "response.output_item.added" || frame.type === "response.output_item.done") + .map(frame => frame.item as Rec), + }; +} + +function assertCurrentIdentity(reply: Reply, namespace: string | undefined, allowRefusal: boolean): void { + const calls = (reply.response.output as Rec[]).filter(item => item.type === "function_call"); + const streamedCalls = reply.items.filter(item => item.type === "function_call"); + if (allowRefusal && reply.response.status === "failed") { + expect(calls).toHaveLength(0); + expect(streamedCalls).toHaveLength(0); + expect((reply.response.error as Rec).message).toContain("undeclared client tool"); + const continuation = { previous_response_id: reply.response.id, input: [userInput] }; + expect(expandPreviousResponseInput(continuation)).toBe(continuation); + return; + } + expect(reply.response.status).toBe("completed"); + expect(reply.response.error).toBeUndefined(); + expect(calls).toHaveLength(1); + for (const call of [...calls, ...streamedCalls]) { + expect(call.name).toBe("Read"); + expect(call.namespace).toBe(namespace); + } +} + +for (const lane of ["adapter", "runTurn"] as const) for (const stream of [false, true]) { + for (const currentNamespace of ["remote", undefined]) { + const previousNamespace = currentNamespace ? undefined : "remote"; + test(`${lane}, ${stream ? "SSE" : "JSON"}: prior ${previousNamespace ?? "bare"}/Read cannot own current ${currentNamespace ?? "bare"}/Read echo`, async () => { + const config = setup(lane); + const prior = await post(config, stream, { input: [additional(previousNamespace), userInput] }); + expect(prior.response.status).toBe("completed"); + const body = { previous_response_id: prior.response.id, input: [additional(currentNamespace), userInput] }; + nextCall = "Read"; + const echo = await post(config, stream, body); + expect(dispatched).toHaveLength(2); + expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); + expect(dispatched[1]!.tools).toEqual(expect.arrayContaining(["Read", "remote__Read"])); + // An ambiguous bare echo may be refused, but must never execute under the older identity. + assertCurrentIdentity(echo, currentNamespace, true); + // The current canonical spelling remains executable; denying every call is not a fix. + nextCall = namespacedToolName(currentNamespace, "Read"); + const canonical = await post(config, stream, body); + expect(dispatched).toHaveLength(3); + expect(dispatched[2]!.replayPrefix).toBeGreaterThan(0); + assertCurrentIdentity(canonical, currentNamespace, false); + }); + } +} diff --git a/tests/responses/explicit-empty-tool-catalog-history.test.ts b/tests/responses/explicit-empty-tool-catalog-history.test.ts index 0ca8c355334..efc8e17444d 100644 --- a/tests/responses/explicit-empty-tool-catalog-history.test.ts +++ b/tests/responses/explicit-empty-tool-catalog-history.test.ts @@ -143,21 +143,24 @@ function assertAllowed(response: Rec, name: string): void { } describe("explicit empty catalog current-turn and continuation boundaries", () => { - test("Responses continuation rejects a tool declared only in replayed history", async () => { - const config = setup("adapter"); - const prior = await post(config, undefined, false, { input: [additional(["shell"]), userInput] }); - expect(prior.status).toBe("completed"); - - nextCall = "shell"; - const response = await post(config, undefined, false, { - previous_response_id: prior.id, - input: [additional(["benign_tool"]), userInput], - }); - - expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); - expect(dispatched[1]!.tools).toEqual(expect.arrayContaining(["shell", "benign_tool"])); - assertRefusedAndUncached(response); - }); + for (const lane of ["adapter", "runTurn"] as const) for (const stream of [false, true]) { + for (const partial of [false, true]) { + test(`Responses ${lane}, ${stream ? "SSE" : "JSON"}, ${partial ? "partial" : "omitted"} catalog: history cannot authorize a call`, async () => { + const config = setup(lane); + const prior = await post(config, undefined, stream, { input: [additional(["shell"]), userInput] }); + expect(prior.status).toBe("completed"); + nextCall = "shell"; + const response = await post(config, undefined, stream, { + previous_response_id: prior.id, + input: [...(partial ? [additional(["benign_tool"])] : []), userInput], + }); + expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); + expect(dispatched[1]!.tools).toContain("shell"); + if (partial) expect(dispatched[1]!.tools).toContain("benign_tool"); + assertRefusedAndUncached(response); + }); + } + } for (const lane of ["adapter", "runTurn"] as const) for (const wire of ["chat", "anthropic"] as const) { for (const stream of [false, true]) { @@ -180,7 +183,7 @@ describe("explicit empty catalog current-turn and continuation boundaries", () = }); } - test(`${label}: current partial catalog rejects an undeclared tool`, async () => { + test(`${label}: current partial catalog preserves a deferred tool`, async () => { const config = setup(lane); const prior = await post(config, wire, stream, { input: [additional([]), userInput] }); expect(prior.status).toBe("completed"); @@ -189,16 +192,16 @@ describe("explicit empty catalog current-turn and continuation boundaries", () = expect(dispatched).toHaveLength(2); expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); expect(dispatched[1]!.tools).toContain("Read"); - assertRefusedAndUncached(response); + assertAllowed(response, "Write"); }); - test(`${label}: current declaration controls output normalization after historical default.Read`, async () => { + for (const explicitEmpty of [false, true]) test(`${label}: current ${explicitEmpty ? "restricted" : "deferred"} declaration normalizes historical default.Read`, async () => { const config = setup(lane); const prior = await post(config, wire, stream, { input: [additional(["default.Read"]), userInput] }); expect(prior.status).toBe("completed"); nextCall = "default.Read"; const response = await post(config, wire, stream, { - previous_response_id: prior.id, tools: [], input: [additional(["Read"]), userInput], + previous_response_id: prior.id, ...(explicitEmpty ? { tools: [] } : {}), input: [additional(["Read"]), userInput], }); expect(dispatched).toHaveLength(2); expect(dispatched[1]!.replayPrefix).toBeGreaterThan(0); From 5910d062eb02ff72fab9fd416a47f207b6a297f3 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 05:41:27 +0000 Subject: [PATCH 3/3] test(responses): avoid overlapping dev registration insertions Place the two current-turn test registrations after the existing entries. This preserves the same mapping while allowing dev's independent graceful output registrations to integrate without a textual conflict. The explicit map and independent golden previews retain all four names exactly once. --- scripts/test-layout/layout-responses.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/test-layout/layout-responses.json b/scripts/test-layout/layout-responses.json index b455b7d332f..32998a8961f 100644 --- a/scripts/test-layout/layout-responses.json +++ b/scripts/test-layout/layout-responses.json @@ -1,9 +1,9 @@ { - "current-turn-tool-catalog-direct.test.ts": "responses", - "current-turn-tool-catalog-namespace.test.ts": "responses", "advisor-web-search.test.ts": "advisor", "passthrough-media-type.test.ts": "responses", "passthrough-override.test.ts": "responses", "passthrough-headers.test.ts": "responses", - "passthrough-abort.test.ts": "responses" + "passthrough-abort.test.ts": "responses", + "current-turn-tool-catalog-direct.test.ts": "responses", + "current-turn-tool-catalog-namespace.test.ts": "responses" }