From df16e0a78dd655af355d9bed0367ab69b1c95605 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 22:44:45 -0700 Subject: [PATCH 1/8] fix(responses): lower apply_patch for upstreams that reject custom tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ROUTED_CUSTOM_TOOL_PASSTHROUGH` exempted `apply_patch` from routed custom-tool lowering unconditionally, so it reached every routed destination as a `type: "custom"` tool with `custom_tool_call` items. xAI's Responses endpoint rejects that item type: 422 Failed to deserialize the JSON body into the target type: input[5]: invalid "custom_tool_call" item: missing field `id` The message is misleading — the id is present. Instrumenting the adapter showed the item leaving as `{"type":"custom_tool_call","id":"ctc_abc123","call_id":"c1",...}`; xAI reports the first field its own parser cannot satisfy rather than the real problem, which is that it does not accept the item type. Same class as its "Could not decode the compaction blob" message for a reasoning field, so the fix is not to generate or preserve ids. Live A/B against the endpoint — identical body, identical id, only the tool name differs: apply_patch (exempt from lowering) -> 422 my_custom_thing (lowered to a function) -> 200 Lowering is what makes it work; the exemption is what breaks it. It surfaces on Codex's compact turn because a real session always contains apply_patch calls, but a plain replay reproduces it too. The exemption is not wrong everywhere — the canonical ChatGPT surface speaks custom_tool_call natively and lowering there would regress it. The defect is that one unconditional rule about "routed providers" encoded a claim about a single destination's capability. Add `supportsResponsesCustomTools`, following the existing `supportsOpenAiWebSearchToolFields` shape: declared on the registry row and the provider config, filled only when unset, and consumed as an explicit denial. Absent or true keeps today's behaviour byte-identical; only xAI declares false. The response path needed no special case: it is name-generic, so once apply_patch joins the converted set the existing repair restores the function_call and its streaming argument events to a custom_tool_call with the original call id. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 5 +- src/providers/derive.ts | 3 + src/providers/registry.ts | 5 ++ src/responses/custom-tool-compat.ts | 32 +++++-- src/router.ts | 3 + src/types/provider.ts | 6 ++ structure/04_transports-and-sidecars.md | 2 +- tests/custom-tool-compat.test.ts | 61 ++++++++++++++ tests/openai-responses-passthrough.test.ts | 32 +++++++ tests/responses-custom-tool-repair.test.ts | 98 ++++++++++++++++++++++ 10 files changed, 237 insertions(+), 10 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 323f9fbf40..d31d7abc56 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1702,7 +1702,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = promoteClientLoadedTools(outBody); } if (!isCanonicalOpenAiForwardProvider(provider)) { - const rewritten = rewriteRoutedCustomToolsForUpstream(outBody); + const rewritten = rewriteRoutedCustomToolsForUpstream( + outBody, + provider.supportsResponsesCustomTools, + ); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; } diff --git a/src/providers/derive.ts b/src/providers/derive.ts index c00df10bee..63cd1c9388 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -483,6 +483,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsOpenAiWebSearchToolFields === undefined && entry.supportsOpenAiWebSearchToolFields !== undefined) { prov.supportsOpenAiWebSearchToolFields = entry.supportsOpenAiWebSearchToolFields; } + if (prov.supportsResponsesCustomTools === undefined && entry.supportsResponsesCustomTools !== undefined) { + prov.supportsResponsesCustomTools = entry.supportsResponsesCustomTools; + } if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov)); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 24e46c476f..896c8d92c9 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -224,6 +224,8 @@ export interface ProviderRegistryEntry { supportsServiceTier?: boolean; /** Registry default for OpenAI extended hosted web_search field support. */ supportsOpenAiWebSearchToolFields?: boolean; + /** Registry default for native Responses custom-tool support. */ + supportsResponsesCustomTools?: boolean; /** Registry default for exact model service-tier capability; explicit config keys win. */ modelSupportsServiceTier?: Record; /** @@ -1011,6 +1013,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ oauthId: "xai", jawcodeBundle: "xai", supportsOpenAiWebSearchToolFields: false, + // Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting + // the otherwise-identical request after the custom tool is lowered to a function. + supportsResponsesCustomTools: false, note: "Log in with your Grok account", // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index e7db3c32a6..d5d4e93b30 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -4,6 +4,13 @@ import { collectResponsesToolGroups } from "./tool-groups"; const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; +function routedCustomToolPassesThrough( + name: string, + supportsResponsesCustomTools: boolean | undefined, +): boolean { + return supportsResponsesCustomTools !== false && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name); +} + function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -34,7 +41,10 @@ export function routedCustomToolWireName(value: unknown): string | undefined { * Names of converted custom declarations after namespace lowering. Restoration uses these exact * wire identities so same-named function and custom children in different namespaces stay distinct. */ -function collectRoutedCustomToolWireNames(body: unknown): Set { +function collectRoutedCustomToolWireNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const groups = collectResponsesToolGroups(body); const bareWireNames = new Set(); @@ -54,7 +64,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { if ( tool.type === "custom" && typeof tool.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(tool.name) + && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) ) { names.add(tool.name); continue; @@ -67,7 +77,7 @@ function collectRoutedCustomToolWireNames(body: unknown): Set { isPlainObject(child) && child.type === "custom" && typeof child.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(child.name) + && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) ) names.add(customToolWireName(tool.name, child.name)); } @@ -81,7 +91,10 @@ export function customToolItemId(id: unknown): unknown { return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; } -export function collectRoutedCustomToolNames(body: unknown): Set { +export function collectRoutedCustomToolNames( + body: unknown, + supportsResponsesCustomTools?: boolean, +): Set { const names = new Set(); const visit = (value: unknown): void => { if (Array.isArray(value)) { @@ -92,7 +105,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set { if ( value.type === "custom" && typeof value.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name) + && !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools) ) { names.add(value.name); } @@ -184,12 +197,15 @@ function rewriteForUpstream( return changed ? next : value; } -export function rewriteRoutedCustomToolsForUpstream(body: unknown): { +export function rewriteRoutedCustomToolsForUpstream( + body: unknown, + supportsResponsesCustomTools?: boolean, +): { body: unknown; names: Set; } { - const conversionNames = collectRoutedCustomToolNames(body); - const names = collectRoutedCustomToolWireNames(body); + const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools); + const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools); if (conversionNames.size === 0) return { body, names }; const callIds = new Set(); collectConvertedCallIds(body, conversionNames, callIds); diff --git a/src/router.ts b/src/router.ts index 35e34d75ca..47a604d77c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -366,6 +366,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.supportsOpenAiWebSearchToolFields !== undefined ? { supportsOpenAiWebSearchToolFields: registryEntry.supportsOpenAiWebSearchToolFields } : {}), + ...(provider.supportsResponsesCustomTools === undefined && registryEntry.supportsResponsesCustomTools !== undefined + ? { supportsResponsesCustomTools: registryEntry.supportsResponsesCustomTools } + : {}), ...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined ? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent } : {}), diff --git a/src/types/provider.ts b/src/types/provider.ts index b4044050d5..3dfca58ddc 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -352,6 +352,12 @@ export interface OcxProviderConfig { * passthrough compatibility for OpenAI and unclassified gateways. */ supportsOpenAiWebSearchToolFields?: boolean; + /** + * Whether the Responses upstream accepts native custom tools and custom_tool_call items. + * Set false only for a provider whose native contract rejects them; absence preserves + * apply_patch passthrough compatibility for OpenAI and unclassified gateways. + */ + supportsResponsesCustomTools?: boolean; /** * Provider-local repair for Responses gateways whose lifecycle snapshots omit canonical * fields or closing events (#893). Disabled by default and applied only to client-facing diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 75410d38b3..3f0466b0b0 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -27,7 +27,7 @@ Responses-compatible streaming output. - 기존 구현 및 제약 조건: The request catalog already controlled custom-tool restoration and the non-OpenAI prompt nudge, but an undeclared upstream name still fell through as an ordinary `function_call`; Codex then reduced the mismatch to a bare `aborted` result. - 검토한 주요 대안: Rely only on prompt guidance; automatically translate undeclared `apply_patch` into Code Mode; validate returned names against the request-visible catalog at the final bridge. - 선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item. -- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` and tools replaced by hosted-provider policy stay in their upstream function-call form. +- 보완된 경계: Key-auth Responses passthrough restores a routed custom call only when the adapter actually lowered that name after request normalization and the caller's `tool_choice` still authorizes it. Native `apply_patch` stays in its upstream function-call form unless the destination explicitly denies Responses custom tools; tools replaced by hosted-provider policy also stay in their upstream function-call form. - 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. - 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 4d2a500857..d04535581d 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -14,6 +14,67 @@ function convertedInputDescription(name: string): string | undefined { } describe("routed custom-tool compatibility", () => { + test.each([ + ["absent", undefined], + ["true", true], + ] as const)("keeps apply_patch byte-identical when custom-tool support is %s", (_label, support) => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + const before = JSON.stringify(raw); + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, support); + + expect(rewritten.body).toBe(raw); + expect(JSON.stringify(rewritten.body)).toBe(before); + expect(rewritten.names).toEqual(new Set()); + }); + + test("lowers apply_patch declarations and replay items on an explicit capability denial", () => { + const raw = { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, false); + const body = rewritten.body as typeof raw; + + expect(rewritten.names).toEqual(new Set(["apply_patch"])); + expect(body.tools[0]).toMatchObject({ + type: "function", + name: "apply_patch", + parameters: { required: ["input"] }, + }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(body.input[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch", + output: "done", + }); + }); + + test.each([undefined, true, false])("keeps lowering other custom tools when support is %p", support => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [{ type: "custom", name: "review_patch", description: "Review", format: { type: "text" } }], + }, support); + const body = rewritten.body as { tools: Array> }; + + expect(body.tools[0]).toMatchObject({ type: "function", name: "review_patch" }); + expect(rewritten.names).toEqual(new Set(["review_patch"])); + }); + test("converted exec preserves the JavaScript input contract", () => { const description = convertedInputDescription("exec"); expect(description).toContain("JavaScript"); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 3da353dac0..c1f9f74e39 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -3,6 +3,7 @@ import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterP import { openaiResponsesUrl } from "../src/adapters/openai-responses-url"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; +import { routeModel } from "../src/router"; import { handleResponses, sanitizeEncryptedContentInPlace } from "../src/server/responses"; import { encodeCompactionSummary, @@ -248,6 +249,37 @@ describe("DeepSeek Responses endpoint contract", () => { }); }); +describe("Responses custom-tool destination capability", () => { + test("xAI explicitly denies native custom tools and registry enrichment preserves an override", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.supportsResponsesCustomTools).toBe(false); + + const inherited = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + } as Parameters[1]; + enrichProviderFromRegistry("xai", inherited); + expect(inherited.supportsResponsesCustomTools).toBe(false); + + const explicit = { + adapter: entry.adapter, + baseUrl: entry.baseUrl, + supportsResponsesCustomTools: true, + } as Parameters[1]; + enrichProviderFromRegistry("xai", explicit); + expect(explicit.supportsResponsesCustomTools).toBe(true); + + const routed = routeModel({ + port: 0, + defaultProvider: "xai", + providers: { + xai: { adapter: entry.adapter, baseUrl: entry.baseUrl, authMode: "oauth" }, + }, + } as OcxConfig, "xai/grok-4.6"); + expect(routed.provider.supportsResponsesCustomTools).toBe(false); + }); +}); + describe("OpenAI Responses passthrough sanitization", () => { const deferredToolBody = { model: "routed-model", diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index a5fdafabee..9cd7ee7dc1 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -528,6 +528,104 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses lowers and restores apply_patch when the destination denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses continuation rewrites custom_tool_call_output and keeps call_id ordered", async () => { const savedFetch = globalThis.fetch; const outboundBodies: Array> = []; From 88ffe32725ce82700ce5e1fa37f5d36309c7598a Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 22:58:51 -0700 Subject: [PATCH 2/8] fix(responses): build the routed compaction body last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every routed lowering step derives its plan from the tool declarations, and the compaction body build deletes them. It ran first, so on a compaction turn the plan was empty and replayed call items reached the wire in their private shapes. Against xAI: 422 Failed to deserialize the JSON body into the target type: input[5]: invalid "custom_tool_call" item: missing field `id` The id is present; xAI reports the first field its own parser cannot satisfy rather than the real problem, which is that it does not accept the item type. Instrumented the adapter to pin the mechanism: with declarations present the call item is converted; with them absent, or on a compaction turn, it goes out raw. Reordering locally produced `function_call` / `function_call_output` with `tools` still absent and the compact prompt still appended. This is the second time this exact shape has been fixed here — a replayed namespace key survived for the same reason. That fix taught one lowering step to cope; this one fixes the pipeline, so the next private field added does not need its own workaround. The invariant is now stated at the call site: the compaction body build removes the tool surface and must be the last routed transform. Two effects beyond the call items, both improvements: `promoteClientLoadedTools` could previously reintroduce top-level `tools` after compaction had removed them, which running compaction last now prevents; and namespace-collision validation runs before the declarations are deleted. Non-compaction output is byte-identical, pinned by an exact comparison test. Co-Authored-By: Claude Fable 5 (cherry picked from commit 59d0cde7f75f0e645a12ec44a388609dfba50ce6) --- src/adapters/openai-responses.ts | 14 +- src/responses/namespace-tool-compat.ts | 5 +- structure/04_transports-and-sidecars.md | 9 +- tests/namespace-tool-compat.test.ts | 5 +- tests/openai-responses-passthrough.test.ts | 255 +++++++++++++++++++++ 5 files changed, 272 insertions(+), 16 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d31d7abc56..949a090457 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1692,12 +1692,6 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // that already recorded a single-query web_search_call replays it every turn, and // a strict parser rejects the whole request over it (#930). outBody = backfillWebSearchQueries(outBody); - // Same predicate as the routedCompaction gate in handleResponses(): an - // authMode check would let a noncanonical custom forward provider skip this - // rewrite while the server still routes it as a summarizer turn (#422). - if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { - outBody = buildRoutedCompactionBody(outBody); - } if (!isCanonicalOpenAiForwardProvider(provider)) { outBody = promoteClientLoadedTools(outBody); } @@ -1732,6 +1726,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } + // Same predicate as the routedCompaction gate in handleResponses(): an authMode check would + // let a noncanonical custom forward provider skip this rewrite while the server still routes + // it as a summarizer turn (#422). The compaction body build removes the tool surface and must + // therefore be the last routed transform: anything before it may depend on the declarations; + // anything after it cannot. + if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { + outBody = buildRoutedCompactionBody(outBody); + } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( outBody, diff --git a/src/responses/namespace-tool-compat.ts b/src/responses/namespace-tool-compat.ts index 3f6cd42ea2..cbc90db605 100644 --- a/src/responses/namespace-tool-compat.ts +++ b/src/responses/namespace-tool-compat.ts @@ -268,9 +268,8 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): { const groups = collectResponsesToolGroups(body); const plan = buildRewritePlan(groups); - // Deliberately not gated on the plan being non-empty: a turn whose catalog is gone still replays - // call items carrying a private `namespace`, and the routed compaction turn strips the whole tool - // surface before this runs. + // Deliberately not gated on the plan being non-empty: a turn whose catalog is absent can still + // replay call items carrying a private `namespace`. const emitted = new Set(); const tools = Array.isArray(body.tools) ? rewriteToolList(body.tools, plan, emitted) : body.tools; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 3f0466b0b0..0f24948199 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -52,10 +52,11 @@ Two coordinates that lower to the same wire name are treated as one tool when th a `functions` child of the same name are the duplicate the parser already tolerates — and the one `promoteClientLoadedTools` produces. The declaration is emitted once instead of failing the request. -Replayed call items are lowered whether or not this turn declares the group they name. A routed -compaction turn strips the whole tool surface before the boundary runs, and a catalog can change -mid-session, but the client is still replaying items this layer's own response restoration stamped -with a private `namespace`. Only `tool_choice` resolves a bare name through the catalog: a history +Replayed call items are lowered whether or not this turn declares the group they name. A catalog can +be absent or change mid-session, but the client is still replaying items this layer's own response +restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing +the tool surface so request-local aliases remain available for response restoration. Only +`tool_choice` resolves a bare name through the catalog: a history item records which tool actually ran, so re-pointing it at a same-named namespace child would rewrite that record on a coincidence rather than translate it. diff --git a/tests/namespace-tool-compat.test.ts b/tests/namespace-tool-compat.test.ts index 45a4157808..83367a8ed8 100644 --- a/tests/namespace-tool-compat.test.ts +++ b/tests/namespace-tool-compat.test.ts @@ -198,9 +198,8 @@ describe("Responses namespace tool compatibility", () => { expect(flatten([functionsGroup], [bare])).toEqual([bare]); }); - // The routed compaction turn strips the whole tool surface before this runs, and a catalog can - // change mid-session — but the client is still replaying items this layer's own restoration - // stamped with a private `namespace`. + // A catalog can be absent or change mid-session, but the client can still replay items this + // layer's own restoration stamped with a private `namespace`. test("lowers replayed calls even when this turn declares no namespace", () => { const body = rewriteRoutedNamespaceToolsForUpstream({ input: [ diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index c1f9f74e39..d551c81612 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -280,6 +280,261 @@ describe("Responses custom-tool destination capability", () => { }); }); +describe("routed compaction lowering order", () => { + const baseInput = [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + { type: "custom_tool_call", call_id: "c1", name: "apply_patch", input: "noop" }, + { type: "custom_tool_call_output", call_id: "c1", output: "ok" }, + { + type: "tool_search_call", + call_id: "c2", + execution: "client", + arguments: { query: "database" }, + }, + { + type: "tool_search_output", + call_id: "c2", + execution: "client", + status: "completed", + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + { + type: "function_call", + call_id: "c3", + namespace: "collaboration", + name: "spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + { + type: "additional_tools", + role: "developer", + tools: [{ + type: "function", + name: "extra", + defer_loading: true, + parameters: { type: "object" }, + }], + }, + ]; + const rawBody = (compaction: boolean) => ({ + model: "routed-model", + stream: false, + input: [ + ...baseInput, + ...(compaction ? [{ type: "compaction_trigger" }] : []), + ], + tools: [ + { + type: "custom", + name: "apply_patch", + description: "Apply patch", + format: { type: "text" }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "tool_search", + execution: "client", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", parameters: { type: "object" } }], + }, + ], + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + }); + const loweredReplay = [ + { + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + { + type: "function_call", + call_id: "c2", + name: "opencodex_tool_search", + arguments: JSON.stringify({ query: "database" }), + }, + { + type: "function_call_output", + call_id: "c2", + output: JSON.stringify({ + tools: [{ + type: "function", + name: "loaded_tool", + defer_loading: true, + parameters: { type: "object" }, + }], + status: "completed", + }), + }, + { + type: "function_call", + call_id: "c3", + name: "collaboration__spawn_agent", + arguments: "{}", + }, + { type: "function_call_output", call_id: "c3", output: "done" }, + ]; + const loweredTools = [ + { + type: "function", + name: "apply_patch", + description: "Apply patch", + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Raw input for this client-executed custom tool.", + }, + }, + required: ["input"], + additionalProperties: false, + }, + }, + { + type: "function", + name: "tool_search", + description: "Ordinary collision", + parameters: { type: "object" }, + }, + { + type: "function", + description: "Find tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + name: "opencodex_tool_search", + }, + { + type: "function", + name: "collaboration__spawn_agent", + parameters: { type: "object" }, + }, + { type: "function", name: "loaded_tool", parameters: { type: "object" } }, + ]; + + function build(compaction: boolean) { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://gateway.example/v1", + authMode: "key", + apiKey: "test-key", + supportsResponsesCustomTools: false, + }); + return adapter.buildRequest({ + modelId: "routed-model", + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: rawBody(compaction), + ...(compaction ? { _compactionRequest: true } : {}), + }, { headers: new Headers() }); + } + + test("lowers replayed calls before removing the compaction tool surface", () => { + const built = build(true); + const body = JSON.parse(built.body) as Record & { + input: Array>; + }; + + expect(body.input.slice(0, -1)).toEqual([ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_text", text: "[image omitted for compaction]" }, + ], + }, + ...loweredReplay, + ]); + expect(body.input.at(-1)).toEqual({ + type: "message", + role: "user", + content: [{ + type: "input_text", + text: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION"), + }], + }); + + expect(body).not.toHaveProperty("tools"); + expect(body).not.toHaveProperty("tool_choice"); + expect(body).not.toHaveProperty("parallel_tool_calls"); + expect(body).not.toHaveProperty("text"); + expect(body.input.some(item => item.type === "compaction_trigger")).toBe(false); + expect(body.input.some(item => item.type === "additional_tools")).toBe(false); + expect(JSON.stringify(body)).not.toContain("input_image"); + expect(JSON.stringify(body)).not.toContain("data:image/png"); + expect(body.input.find(item => item.call_id === "c3")).not.toHaveProperty("namespace"); + + expect([...(built.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + expect([...(built.convertedRoutedToolSearchNames ?? [])]).toEqual(["opencodex_tool_search"]); + expect([...(built.convertedRoutedNamespaceToolAliases ?? new Map()).entries()]).toEqual([ + ["collaboration__spawn_agent", { namespace: "collaboration", name: "spawn_agent" }], + ]); + }); + + test("leaves the non-compaction serialized body byte-identical", () => { + const built = build(false); + expect(built.body).toBe(JSON.stringify({ + model: "routed-model", + stream: false, + input: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "earlier turn" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA" }, + ], + }, + ...loweredReplay, + { + type: "additional_tools", + role: "developer", + tools: [{ type: "function", name: "extra", parameters: { type: "object" } }], + }, + ], + tools: loweredTools, + tool_choice: "auto", + parallel_tool_calls: true, + text: { format: { type: "json_object" } }, + })); + }); +}); + describe("OpenAI Responses passthrough sanitization", () => { const deferredToolBody = { model: "routed-model", From 2785aa29dad0ff5afe05448522285d530b21e47a Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 06:24:21 +0000 Subject: [PATCH 3/8] test(responses): assert the terminal SSE marker on namespace replay The namespace-replay restore test verified the restored custom_tool_call events but never checked that the stream still ends with data: [DONE], so a regression that drops the terminal marker would have passed. The sibling lowering test already asserts it; match that. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017zpLCh4eEms6un3VjapRgL --- tests/responses-custom-tool-repair.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 9cd7ee7dc1..2a5e39a78e 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -619,6 +619,7 @@ describe("routed Responses custom-tool compatibility", () => { expect(clientSse).toContain('"call_id":"call_patch_next"'); expect(clientSse).toContain('"name":"apply_patch"'); expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); expect(clientSse).not.toContain('"type":"function_call"'); expect(clientSse).not.toContain("response.function_call_arguments.done"); } finally { From 398b7ade4c05816052d82c690b5d7f682cc7c90f Mon Sep 17 00:00:00 2001 From: olddonkey Date: Fri, 21 Aug 2026 01:34:24 -0700 Subject: [PATCH 4/8] test(responses): lower apply_patch on noncanonical forward destinations Forward auth is not an OpenAI-destination identity. A noncanonical forward provider that denies native custom tools must still convert apply_patch. Pin the adapter serialization and the handleResponses path. --- tests/openai-responses-passthrough.test.ts | 40 ++++++++ tests/responses-custom-tool-repair.test.ts | 105 +++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index d551c81612..f42d8f6938 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -278,6 +278,46 @@ describe("Responses custom-tool destination capability", () => { } as OcxConfig, "xai/grok-4.6"); expect(routed.provider.supportsResponsesCustomTools).toBe(false); }); + + test("noncanonical forward destinations that deny custom tools lower apply_patch", () => { + const rawBody = { + model: "routed-model", + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "routed-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(request.headers.authorization).toBe("Bearer provider-static"); + expect(body.tools[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "c1", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect([...(request.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); + }); }); describe("routed compaction lowering order", () => { diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 2a5e39a78e..923d52af44 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -627,6 +627,111 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses lowers apply_patch for a noncanonical forward destination that denies custom tools", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + let outboundAuthorization: string | null = null; + let outboundUrl = ""; + const upstreamItem = { + type: "function_call", + id: "fc_patch_next", + call_id: "call_patch_next", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + }), + frame("response.function_call_arguments.done", { + output_index: 0, + item_id: upstreamItem.id, + arguments: upstreamItem.arguments, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (input, init) => { + outboundUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + outboundBody = JSON.parse(String(init?.body)) as Record; + outboundAuthorization = new Headers(init?.headers).get("authorization"); + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer caller-secret" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [ + { + type: "custom_tool_call", + id: "ctc_patch_prior", + call_id: "call_patch_prior", + name: "apply_patch", + input: "noop", + }, + { type: "custom_tool_call_output", call_id: "call_patch_prior", output: "done" }, + ], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + const outboundInput = outboundBody?.input as Array> | undefined; + + expect(outboundUrl).toBe("https://provider.example/v1/responses"); + expect(outboundAuthorization).toBe("Bearer provider-static"); + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "apply_patch" }); + expect(outboundInput?.[0]).toMatchObject({ + type: "function_call", + call_id: "call_patch_prior", + name: "apply_patch", + arguments: JSON.stringify({ input: "noop" }), + }); + expect(outboundInput?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_patch_prior", + output: "done", + }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"id":"ctc_patch_next"'); + expect(clientSse).toContain('"call_id":"call_patch_next"'); + expect(clientSse).toContain('"name":"apply_patch"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain("data: [DONE]"); + expect(clientSse).not.toContain('"type":"function_call"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses continuation rewrites custom_tool_call_output and keeps call_id ordered", async () => { const savedFetch = globalThis.fetch; const outboundBodies: Array> = []; From 3bbe4e411d0c41d6d6cffb2c09937ca3da56854e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 09:40:44 +0900 Subject: [PATCH 5/8] =?UTF-8?q?devlog:=202270=20cycle=20plan=20=E2=80=94?= =?UTF-8?q?=20PR-ref=20merge=20strategy=20for=20fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_plan/260821_bug_merge_train/060_merge_2270.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md index 26fa79e3db..d5903737f3 100644 --- a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md +++ b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md @@ -2,3 +2,17 @@ 48 behind; single rebase onto now-stable dev. Preserve the !isCanonicalOpenAiForwardProvider boundary (already on head 398b7ade4; maintainer review r3 found no remaining technical blocker). Review: supportsResponsesCustomTools capability plumbing (registry/derive/types), compaction-body-last reorder invariant, byte-identical non-compaction pin test. Fork head (olddonkey/opencodex, maintainerCanModify=true): stacked commits push to the fork remote. Pre-merge: dismiss stale CHANGES_REQUESTED (converged per reviewer's own head-398b7ade4 comment) or record fresh APPROVE. Verify on REBASED head BEFORE merge: bun test tests/custom-tool-compat.test.ts tests/namespace-tool-compat.test.ts tests/openai-responses-passthrough.test.ts tests/responses-custom-tool-repair.test.ts, bun run typecheck, FULL SUITE (shared routing/adapter surface; ssh lidge if local env-limited). grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live PR head 398b7ade4 — 4 commits over base 7881319e, ~50 behind dev) + +The fork branch is not directly fetchable as a remote ref (fork: olddonkey); +use the PR ref. The branch carries its own rebase history — do NOT rebase the +fork branch; merge the PR ref into the TRAIN and let the train carry it. +Fork push only needed if we stack new commits on the PR itself. Steps: +1. Merge pr/2270 into train, resolve conflicts there. +2. Adversarial review (inherited model): supportsResponsesCustomTools + plumbing, compaction-body-last reorder invariant, byte-identical + non-compaction pin, !isCanonicalOpenAiForwardProvider boundary. +3. Focused custom-tool tests + typecheck + privacy locally at merged head; + lidge full suite; land via train PR to dev; dismiss stale review state via + merge admin path. From ec32a8d526f68b747445ec06a9bd08cdcaf293c3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 09:47:24 +0900 Subject: [PATCH 6/8] test(responses): pin canonical forward custom-tool passthrough against explicit denial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review P2: the lowering boundary rested on code reading only — add a negative pin proving the exact canonical Codex forward surface ignores supportsResponsesCustomTools: false and keeps custom tools verbatim. --- tests/openai-responses-passthrough.test.ts | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index d384050ce5..27254db95f 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -318,6 +318,43 @@ describe("Responses custom-tool destination capability", () => { }); expect([...(request.convertedRoutedCustomToolNames ?? [])]).toEqual(["apply_patch"]); }); + + test("the canonical Codex forward surface never lowers custom tools, even with an explicit denial", () => { + const rawBody = { + model: "gpt-5.6-sol", + stream: true, + input: [ + { type: "custom_tool_call", id: "ctc_patch", call_id: "c1", name: "apply_patch", input: "noop" }, + ], + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "gpt-5.6-sol", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const request = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + // Exact canonical Codex forward base URL: isCanonicalOpenAiForwardProvider is true, + // so the lowering gate must be unreachable regardless of the capability flag. + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + supportsResponsesCustomTools: false, + }).buildRequest(parsed, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + }; + + expect(body.tools[0]).toMatchObject({ type: "custom", name: "apply_patch" }); + expect(body.input[0]).toMatchObject({ type: "custom_tool_call", call_id: "c1", name: "apply_patch" }); + expect(request.convertedRoutedCustomToolNames ?? []).toEqual([]); + }); }); describe("routed compaction lowering order", () => { From 65c0fd362a9adde11f0db5746def39bb065d5d72 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 09:50:01 +0900 Subject: [PATCH 7/8] =?UTF-8?q?devlog:=202270=20review=20round=20=E2=80=94?= =?UTF-8?q?=20boundary=20pin=20added,=20re-verdict=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260821_bug_merge_train/060_merge_2270.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md index d5903737f3..aaab6ea8bf 100644 --- a/devlog/_plan/260821_bug_merge_train/060_merge_2270.md +++ b/devlog/_plan/260821_bug_merge_train/060_merge_2270.md @@ -16,3 +16,21 @@ Fork push only needed if we stack new commits on the PR itself. Steps: 3. Focused custom-tool tests + typecheck + privacy locally at merged head; lidge full suite; land via train PR to dev; dismiss stale review state via merge admin path. + +## Review (Bohr, inherited model) — GO-WITH-FIXES (blockers=0) → P2 fixed → re-verdict PASS + +Clean: capability plumbing consistent (undefined/true = passthrough, false = +lowering, explicit-override precedence tested); all consumption sites behind +the exact-base-URL canonical gate; reorder fixes the real latent bug +(compaction replayed custom_tool_call reached strict upstreams unlowered) +with byte-identical non-compaction pin intact; response restoration +fail-closed via buildToolBridgeMaps; no privacy/logging regressions. + +P2 fixed (commit ec32a8d52): negative pin proving the canonical Codex forward +surface ignores supportsResponsesCustomTools:false. Re-verdict: PASS. +Accepted residuals (P3): composed registry-to-handleResponses e2e, +namespace-child deny dedup coverage, tool_choice + lowered apply_patch case. + +Gates at train head ec32a8d52: focused tests 138/138 (+ pin 100/100), +typecheck pass, privacy:scan pass, lidge r9 full suite 14233 pass / 0 fail +exit 0 at 668512a58 + pin-only delta after. From c7f341a8031c1e5821c9ec4d4647711a4ccd6ee4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 09:50:23 +0900 Subject: [PATCH 8/8] =?UTF-8?q?devlog:=20triage=20matrix=20=E2=80=94=20mar?= =?UTF-8?q?k=20#2270=20hardened=20and=20merged=20on=20the=20train?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260821_bug_merge_train/000_triage_matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md index 88c4fd83f7..0152933d69 100644 --- a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md +++ b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md @@ -11,7 +11,7 @@ adversarial xai/grok-4.6 subagent verdicts, and a final green dev CI gate. | #2294 | fix(release): reject credential-bearing SSH remotes | 71598fa45 + hardening 2cdfba24d (train-stacked) | 3 | yes | green | MERGED to train; grok blocker fixed; re-verdict PASS; landing on dev | | #2289 | fix(service): restart existing installs w/o re-register | 2df92a270 + locale sync 174f03b60 (train-stacked) | 2 | yes | green incl. Service lifecycle | MERGED to train; grok P2 fixed; re-verdict PASS; Closes #2287 | | #2295 | fix(codex): recover zero-byte coordinator remnants | 6d5f0cf2c (ingw/fix-zero-byte-coordinator-2291) | 0 | yes | green | MERGED to train 728ca1e8b; suite green; landing on dev | -| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 (fix/apply-patch-routed-lowering) | 48 | yes | Ingwannu: two CHANGES_REQUESTED resolved on this head; third review says no remaining technical blocker | Linux shards green | +| #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 + pin ec32a8d52 (train-stacked) | merged into train | yes | MERGED to train; grok P2 fixed; re-verdict PASS | Linux shards green; lidge full suite green | | #2281 | fix: call_id thought-signature replay for Claude Code | b31f3dbed (fix/claude-code-thought-signature-replay) | 50 | no (review-ready + hygiene-blocked label) | BLOCKED state | CodeRabbit minor: normalize promptCacheKey via anthropicSessionKeyFromParts before storing as clientThreadId (core.ts ~1888-1896); lidge-jun review priority 63/80 confirms repro | | #2296 | fix(codex): bind Desktop reconnects to one pool account | e672b0fd0 + scope fix 698228e40 (train-stacked) | 2 | yes | green | MERGED to train; grok major fixed; re-verdict PASS; landing on dev |