From 551e7d79d48d3f987217215aaada63af885dd214 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:58:11 +0900 Subject: [PATCH 1/7] fix(responses): normalize explicit incomplete reasons (carry #7122) Carried from #7122. Co-authored-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 6 + scripts/test-layout/layout.json | 2 +- src/adapters/openai-responses/passthrough.ts | 10 +- src/bridge/response-json.ts | 2 +- src/bridge/sse.ts | 2 +- src/protocols/encoders/adapter-events.ts | 2 +- src/responses/incomplete-reason.ts | 31 +++++ src/server/responses/codex-ws-wire.ts | 5 +- src/server/responses/passthrough-delivery.ts | 4 +- structure/data-planes/protocol-paths.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/transports/responses-wire-shapes.md | 5 + structure/transports/responses.md | 6 +- structure/transports/streaming-health.md | 2 + .../bridge-nonstreaming-terminal.test.ts | 73 +++++++++++ tests/fixtures/test-layout-expected.json | 2 +- .../incomplete-reason-normalization.test.ts | 124 ++++++++++++++++++ .../protocol-direct-encoders-chat.test.ts | 16 +++ .../protocol-direct-encoders-messages.test.ts | 16 +++ 19 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 src/responses/incomplete-reason.ts create mode 100644 tests/responses/incomplete-reason-normalization.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index cce9d5a92e4..002d794841d 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -304,6 +304,12 @@ in both JSON and SSE. Partial output is preserved, and the bridge does not emit completion event for that open call. Calls already completed keep their status. This preserves the provider outcome; client retry behavior for incomplete responses is unchanged. +Explicit incomplete reasons use the same vocabulary in adapter-backed responses and native +Responses JSON, SSE, and WebSocket delivery: +output-limit aliases such as `length` and `max_tokens` become `max_output_tokens`, and refusal/filter +aliases such as `refusal` become `content_filter`. Other reasons remain unchanged, along with the +provider's message, retryability, and usage. Direct Chat and Messages encoders apply the same mapping. + On the pending `dev` implementation for #4112, a final upstream HTTP 413 on this surface is classified as `invalid_request_error` / `context_length_exceeded`. Non-streaming callers retain HTTP 413 with a JSON `error`; streaming callers retain the terminal SSE failure. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9925e111815..53d444afb8c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "incomplete-reason-normalization.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 976222d7ee3..74e3bb48751 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -1,3 +1,4 @@ +import { truncationReasonFor } from "../../responses/truncated-stop-reason"; import { normalizeRoutedAgentMessages } from "../routed-agent-messages"; import { frameCompactionHistory } from "../../responses/compaction-history-framing"; import { nameRoutedIdentity, repairIdentityInResponsesBody, stripRoutedIdentity } from "../identity"; @@ -662,9 +663,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): case "error": yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) }; return; - case "response.incomplete": - yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) }; + case "response.incomplete": { + const reason = responsesErrorMessage(payload.response ?? payload); + yield { type: "incomplete", reason: truncationReasonFor(reason) ?? reason }; return; + } case "response.completed": { completedSeen = true; @@ -757,7 +760,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): return [{ type: "error", message: responsesErrorMessage(payload) }]; } if (payload.status === "incomplete") { - return [{ type: "incomplete", reason: responsesErrorMessage(payload) }]; + const reason = responsesErrorMessage(payload); + return [{ type: "incomplete", reason: truncationReasonFor(reason) ?? reason }]; } const usage = usageFromResponsesPayload(payload); const output = Array.isArray(payload.output) ? payload.output : []; diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index f5840a1e457..fb2cc73ae04 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -657,7 +657,7 @@ function buildResponseJSONWithBudget( : errorEvent?.retryable !== undefined ? { retryable: errorEvent.retryable } : {}), ...(incompleteEvent ? { incomplete_details: { - reason: incompleteEvent.reason, + reason: truncationReasonFor(incompleteEvent.reason) ?? incompleteEvent.reason, ...(incompleteEvent.message ? { message: incompleteEvent.message } : {}), ...(incompleteEvent.retryable !== undefined ? { retryable: incompleteEvent.retryable } : {}), }, diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 0a8340a503a..282038460a5 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1317,7 +1317,7 @@ export function bridgeToResponsesSSE( ...responseSnapshot("incomplete", finishedItems, event.endTurn), usage: responsesUsage(event.usage), incomplete_details: { - reason: event.reason, + reason: truncationReasonFor(event.reason) ?? event.reason, ...(event.message ? { message: event.message } : {}), ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), }, diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index 8c10eb2db49..fc4b466f177 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -729,7 +729,7 @@ export function encodeAdapterEventStream( terminal = { status: "incomplete", incomplete: { - reason: event.reason, + reason: truncationReasonFor(event.reason) ?? event.reason, ...(event.message ? { message: event.message } : {}), ...(event.retryable !== undefined ? { retryable: event.retryable } : {}), }, diff --git a/src/responses/incomplete-reason.ts b/src/responses/incomplete-reason.ts new file mode 100644 index 00000000000..c8230ad3322 --- /dev/null +++ b/src/responses/incomplete-reason.ts @@ -0,0 +1,31 @@ +import { truncationReasonFor } from "./truncated-stop-reason"; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function normalizeResponse(value: unknown): unknown { + if (!record(value) || !record(value.incomplete_details)) return value; + const details = value.incomplete_details; + if (typeof details.reason !== "string") return value; + const reason = truncationReasonFor(details.reason); + return reason !== undefined && reason !== details.reason + ? { ...value, incomplete_details: { ...details, reason } } + : value; +} + +/** Normalize only Responses metadata, never output content or unknown provider reasons. */ +export function normalizeResponsesIncompleteReason(payload: T): T { + if (!record(payload)) return payload; + const root = normalizeResponse(payload) as Record; + const response = normalizeResponse(root.response); + return (response === root.response ? root : { ...root, response }) as T; +} + +/** Identity for malformed JSON and unchanged metadata, preserving upstream framing on SSE. */ +export function rewriteResponsesIncompleteReasonJson(json: string): string { + let payload: unknown; + try { payload = JSON.parse(json); } catch { return json; } + const normalized = normalizeResponsesIncompleteReason(payload); + return normalized === payload ? json : JSON.stringify(normalized); +} diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index 59fe27f2699..22a749492ba 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -1,3 +1,4 @@ +import { normalizeResponsesIncompleteReason } from "../../responses/incomplete-reason"; import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer"; import { isTransientUpstreamStatus, @@ -517,12 +518,12 @@ export function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEv return null; } if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - const record = payload as Record; + const record = normalizeResponsesIncompleteReason(payload as Record); if (typeof record.type !== "string") return null; // A native error may be pretty-printed JSON. SSE prefixes one data line; // embedded physical newlines would otherwise truncate the JSON for readers. if (record.type !== "response.done") return { - type: record.type, text: /[\r\n]/.test(text) ? JSON.stringify(record) : text, payload: record, + type: record.type, text: record !== payload || /[\r\n]/.test(text) ? JSON.stringify(record) : text, payload: record, }; const response = record.response; diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 25d4531c1ac..9d5dbbf9dbb 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -58,6 +58,7 @@ import { hasResponsesSnapshotRepair, createResponsesSnapshotBlockRewrite, } from "../responses-snapshot-repair"; +import { rewriteResponsesIncompleteReasonJson } from "../../responses/incomplete-reason"; import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite"; import { createImageGenCallRestoreRewrite, restoreImageGenCallsInJson } from "../responses-image-gen-repair"; import { @@ -768,6 +769,7 @@ export async function deliverPassthroughResponse( : undefined; // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first). const payloadRewrites = [ + rewriteResponsesIncompleteReasonJson, createImageGenCallRestoreRewrite(imageGenCallAliases), // #3217: a call whose namespace repeats its own name is unroutable in codex-rs. createSelfNamedToolCallNamespaceScrubRewrite(selfNamedNamespaceScrubAuthorization), @@ -1317,7 +1319,7 @@ export async function deliverPassthroughResponse( const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(repaired, parsed._responseModelId) : repaired; - return modelRewritten; + return rewriteResponsesIncompleteReasonJson(modelRewritten); })(); if (plaintextV2RestoreFailed) { return formatErrorResponse(502, "upstream_error", PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 1e2dd83a989..a7b5436635e 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -229,7 +229,7 @@ converters' own helpers, exported from `src/chat/outbound.ts` and `src/claude/ou `chatCompletionsFailedResponse`, `chatCompletionsIncompleteOutcome`, `anthropicIncompleteOutcome`, `anthropicFailedStatus`, the message snapshot, the web-search pair), so the frames a client receives are the converters' frames. A fold is the encoded stream read by -the existing collector. +the existing collector. Explicit incomplete reasons use the [shared normalization contract](../transports/responses-wire-shapes.md#adapter-incomplete-reasons) before the writers classify the outcome. Deliberate equivalences, pinned by the parity tests: a Chat function call is delivered as one complete tool-call chunk when it completes, as the converter always did; Messages streams diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a8c63a4cdc2..87859fb8547 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -199,7 +199,7 @@ rewrite rules and the routed-id settlement. | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. Because an organization profile ARN is shared by its users, `src/oauth/kiro-account-identity.ts` keys Kiro rows by ARN plus the same-session `whoami` email (#6641): a login without a user keeps the legacy ARN identity but never replaces an identified user of the same profile unless the refresh token proves the same CLI session; reauth of a row with a user and rotated-CLI recovery require that same user (a row without a user recovers only while no other row shares its profile, and explicit reauth may record its user unless that user owns another row of the profile), an unmatched login claims an identity-less active slot only as the same session while the profile has other rows, and Add account records the current CLI user on ARN-only rows through a fenced same-session bind. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second per-fetch deadline covering headers and body. Error bodies are capped at 16 KiB bytes and success JSON at 64 KiB; each settled call detaches its caller listener and clears its timer. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals require an explicit allowlisted terminal OAuth code and mark only the matching generation `needsReauth`; free-text descriptions never establish terminality; transient failures remain retryable. HTTP-derived errors expose only status and allowlisted code. Cancellation and deadline errors use fixed AbortError/TimeoutError messages; transport and parsing failures also use fixed messages, without raw reasons or causes, and never mark reauthentication. The registry preserves its own stale-flight error. `tests/oauth/chatgpt-token-expiry.test.ts`, `tests/oauth/chatgpt-token-boundaries.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. | | `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | -| `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | +| `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. Compaction parsing normalizes known incomplete aliases under the [incomplete reason contract](transports/responses-wire-shapes.md#adapter-incomplete-reasons). The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 8709593b69a..ca1b9e1998f 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -9,6 +9,11 @@ lower bound first; competing original hints are retained under an explicit provi Other explicit verdicts, proxy replay budgets and event ordering are unchanged. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). This creates no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. +## Adapter incomplete reasons + +`src/bridge/sse.ts`, `src/bridge/response-json.ts`, and `src/protocols/encoders/adapter-events.ts` normalize explicit incomplete reasons with `truncationReasonFor(reason) ?? reason`: known output-limit aliases become `max_output_tokens`, and refusal/filter aliases become `content_filter`. `src/responses/incomplete-reason.ts` applies the same classifier to native Responses JSON and SSE delivery in `src/server/responses/passthrough-delivery.ts` and to WebSocket frames in `src/server/responses/codex-ws-wire.ts`, including native control chains. It copies only changed `incomplete_details.reason` metadata, leaving output content and all other response fields intact. The compaction parser in `src/adapters/openai-responses/passthrough.ts` also normalizes its internal incomplete event. `tests/responses/incomplete-reason-normalization.test.ts` covers native HTTP delivery, WS terminal normalization and both adapter parser formats. +Unknown reasons remain verbatim, including `adapter_eof`, `max_messages`, and `steered`; message, retryability, usage, and incomplete status are preserved. `tests/adapters/bridge-nonstreaming-terminal.test.ts` covers both Responses projections and real passthrough parsing; the direct Chat and Messages parity suites cover the client-wire outcomes. + ## Compaction image input For translated routed compaction, `src/responses/compaction-images.ts` replaces earlier user and diff --git a/structure/transports/responses.md b/structure/transports/responses.md index d0d1fcd17c2..8e5de3b94a4 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -585,9 +585,9 @@ machine for those encoders, so a change to item boundaries, tool naming or termi `sse.ts` has to be made there too; the parity tests fail when the two diverge. `src/bridge/errors.ts` (`formatErrorResponse`) formats error responses and keeps only allowlisted transport verdict codes and the explicit local `spend_ledger_storage_unavailable` identity ([storage refusal contract](responses-spend.md#durable-spend-reservations)). Adapter error events take a different path: `src/bridge/internal.ts` preserves explicit verdicts except cyber-policy and known rate-limit mappings -([client retry advice](responses-wire-shapes.md#client-rate-limit-retry-advice)). The shared usage shaping's `input_tokens_details` and -`output_tokens_details` are always emitted, with zero defaults, because strict Responses clients -deserialize them as required fields. +([client retry advice](responses-wire-shapes.md#client-rate-limit-retry-advice)). Explicit incomplete reasons follow the [reason contract](responses-wire-shapes.md#adapter-incomplete-reasons). +The shared usage shaping's `input_tokens_details` and `output_tokens_details` are always emitted, +with zero defaults, because strict Responses clients deserialize them as required fields. Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 3d4f57ab9ac..a3a8becb95a 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -17,6 +17,8 @@ terminal on drift; see [continuation binding contract](../providers-and-adapters Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses-wire-shapes.md#passthrough-sse-stream-shapes-314). +Responses WebSocket terminal frames normalize known `incomplete_details.reason` aliases under the [incomplete reason contract](responses-wire-shapes.md#adapter-incomplete-reasons), including native control chains. + ## Heartbeat and stall deadline RunTurn search interception forwards adapter heartbeats immediately and emits progress for buffered semantic events. Its bridge uses the search plan's stall deadline so a bounded sidecar search fits the watchdog budget. diff --git a/tests/adapters/bridge-nonstreaming-terminal.test.ts b/tests/adapters/bridge-nonstreaming-terminal.test.ts index 23679505861..284c9a9955c 100644 --- a/tests/adapters/bridge-nonstreaming-terminal.test.ts +++ b/tests/adapters/bridge-nonstreaming-terminal.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { isTruncatedStopReason, truncationReasonFor } from "../../src/responses/truncated-stop-reason"; import type { AdapterEvent } from "../../src/types"; @@ -95,6 +97,77 @@ describe("buffered turns without an adapter terminal", () => { }); }); +describe("explicit incomplete reasons use the Responses vocabulary", () => { + const cases = [ + ["length", "max_output_tokens"], + ["max_tokens", "max_output_tokens"], + ["model_context_window_exceeded", "max_output_tokens"], + ["refusal", "content_filter"], + ["content-filter", "content_filter"], + [" SAFETY ", "content_filter"], + ["max_output_tokens", "max_output_tokens"], + ["content_filter", "content_filter"], + ["max_messages", "max_messages"], + ["steered", "steered"], + ["adapter_eof", "adapter_eof"], + [" provider_specific_stop ", " provider_specific_stop "], + ] as const; + + async function project(events: AdapterEvent[], streaming: boolean) { + if (!streaming) return buildResponseJSON(events, "routed/model"); + const text = await sseText(events); + expect(terminalEventNames(text)).toEqual(["response.incomplete"]); + const terminal = text.split("\n\n").find(frame => frame.startsWith("event: response.incomplete\n")); + const data = terminal?.split("\n").find(line => line.startsWith("data: "))?.slice(6); + expect(data).toBeDefined(); + return JSON.parse(data!).response; + } + + for (const streaming of [true, false]) { + test.each(cases)(`stream=${streaming}: %s becomes %s without losing metadata`, async (reason, expected) => { + const response = await project([ + { type: "text_delta", text: "partial answer" }, + { + type: "incomplete", reason, message: "provider stopped this turn", retryable: false, + usage: { inputTokens: 10, outputTokens: 2 }, + }, + ], streaming); + expect(response).toMatchObject({ + status: "incomplete", + incomplete_details: { reason: expected, message: "provider stopped this turn", retryable: false }, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }); + expect(response.output.find((item: { type: string }) => item.type === "message").content[0].text) + .toBe("partial answer"); + }); + } + + for (const upstreamStream of [true, false]) { + test(`passthrough stream=${upstreamStream} length reaches both projections as max_output_tokens`, async () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", baseUrl: "https://provider.example/v1", authMode: "forward", + }); + const budget = createTranslatorBudget(); + try { + const payload = { status: "incomplete", incomplete_details: { reason: "length" } }; + const events: AdapterEvent[] = []; + if (upstreamStream) { + const wire = `event: response.incomplete\ndata: ${JSON.stringify({ type: "response.incomplete", response: payload })}\n\n`; + for await (const event of adapter.parseStream(new Response(wire), budget)) events.push(event); + } else { + events.push(...await adapter.parseResponse!(Response.json(payload), budget)); + } + expect(events).toEqual([{ type: "incomplete", reason: "max_output_tokens" }]); + for (const streaming of [true, false]) { + expect(await project(events, streaming)).toMatchObject({ + status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, + }); + } + } finally { budget.dispose(); } + }); + } +}); + describe("compaction is never installed from a truncated turn", () => { // #422: a compaction item becomes REPLACEMENT HISTORY. The original guard could only see // explicit error/incomplete events, so a stream that stopped without any terminal slipped diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5047535934..25616205e47 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,5 @@ { - "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", + "incomplete-reason-normalization.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", diff --git a/tests/responses/incomplete-reason-normalization.test.ts b/tests/responses/incomplete-reason-normalization.test.ts new file mode 100644 index 00000000000..0c0bcc3a40c --- /dev/null +++ b/tests/responses/incomplete-reason-normalization.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { rewriteResponsesIncompleteReasonJson } from "../../src/responses/incomplete-reason"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { normalizeResponsesWsRelayEvent } from "../../src/server/responses/codex-ws-wire"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const aliases = [ + ["length", "max_output_tokens"], [" MAX_TOKENS ", "max_output_tokens"], + ["model_context_window_exceeded", "max_output_tokens"], ["safety", "content_filter"], + ["refusal", "content_filter"], ["content-filter", "content_filter"], + ["max_output_tokens", "max_output_tokens"], ["content_filter", "content_filter"], + ["max_messages", "max_messages"], ["steered", "steered"], + ["adapter_eof", "adapter_eof"], ["vendor-limit", "vendor-limit"], +] as const; +const snapshot = (reason: string) => ({ + id: "resp_incomplete", object: "response", model: "test-model", status: "incomplete", + incomplete_details: { reason, message: "partial turn", retryable: false, vendor: 7 }, + output: [{ id: "msg_partial", type: "message", role: "assistant", status: "incomplete", + content: [{ type: "output_text", text: "partial answer", annotations: [] }] }], + usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 }, +}); +const frame = (response: unknown) => `event: response.incomplete\ndata: ${JSON.stringify({ type: "response.incomplete", response })}\n\n`; + +describe("Responses incomplete reasons at transport boundaries", () => { + test.each([ + "not json", "[DONE]", "null", "[]", '{ "status": "completed" }', + '{ "status": "incomplete", "incomplete_details": { "reason": 7 } }', + '{ "status": "incomplete", "incomplete_details": { "reason": "steered" } }', + '{ "output": [{ "incomplete_details": { "reason": "length" } }] }', + ])("unchanged or malformed metadata remains byte-identical: %s", json => { + expect(rewriteResponsesIncompleteReasonJson(json)).toBe(json); + }); + test("nested user output stays opaque and the input object stays unchanged", () => { + const response = { ...snapshot("length"), output: [{ incomplete_details: { reason: "length" } }] }; + const json = JSON.stringify(response); + expect(JSON.parse(rewriteResponsesIncompleteReasonJson(json))).toEqual({ ...response, + incomplete_details: { ...response.incomplete_details, reason: "max_output_tokens" } }); + expect(response.incomplete_details.reason).toBe("length"); + }); + for (const [reason, canonical] of aliases) { + for (const type of ["response.incomplete", "response.done"]) { + test(`WebSocket ${type}: ${reason}`, () => { + const response = snapshot(reason); + const result = normalizeResponsesWsRelayEvent(JSON.stringify({ type, response, sequence_number: 9 })); + expect(result?.type).toBe("response.incomplete"); + expect(JSON.parse(result!.text)).toEqual({ type: "response.incomplete", sequence_number: 9, + response: { ...response, incomplete_details: { ...response.incomplete_details, reason: canonical } } }); + expect(result?.payload).toEqual(JSON.parse(result!.text)); + }); + } + for (const stream of [false, true]) { + test(`compaction adapter ${stream ? "SSE" : "JSON"}: ${reason}`, async () => { + const adapter = createResponsesPassthroughAdapter({ adapter: "openai-responses", baseUrl: "https://example.invalid/v1", authMode: "key" }); + const budget = createTranslatorBudget(); + try { + const response = snapshot(reason); + const upstream = stream ? new Response(frame(response)) : Response.json(response); + const events = stream ? await Array.fromAsync(adapter.parseStream(upstream, budget)) + : await adapter.parseResponse!(upstream, budget); + expect(events.find(event => event.type === "incomplete")).toMatchObject({ type: "incomplete", reason: canonical }); + } finally { budget.dispose(); } + }); + } + } +}); + +let testDir = ""; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + codexHome = installIsolatedCodexHome("ocx-incomplete-reason-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-incomplete-reason-")); + process.env.OPENCODEX_HOME = testDir; +}); +afterEach(() => { + resetProviderRequestPacingForTest(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome?.restore(); + removeTreeWithRetry(testDir); +}); + +describe("Responses native passthrough delivery", () => { + for (const [reason, canonical] of aliases) { + for (const stream of [false, true]) { + test(`${stream ? "SSE" : "JSON"}: ${reason}`, async () => { + const response = snapshot(reason); + let sends = 0; + const upstream = Bun.serve({ port: 0, fetch() { + sends++; + return stream ? new Response(frame(response), { headers: { "content-type": "text/event-stream" } }) + : Response.json(response); + } }); + saveConfig({ port: 0, defaultProvider: "mock", providers: { mock: { + adapter: "openai-responses", apiKey: "mock-key", authMode: "key", allowPrivateNetwork: true, + baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, + } } }); + const proxy = startServer(0); + try { + const result = await fetch(new URL("/v1/responses", proxy.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", input: "hello", stream }), + }); + expect(result.status).toBe(200); + const text = await result.text(); + const terminal = stream ? text.split("\n").filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))).find(event => event.type === "response.incomplete").response + : JSON.parse(text); + expect(terminal).toEqual({ ...response, incomplete_details: { ...response.incomplete_details, reason: canonical } }); + expect(sends).toBe(1); + } finally { await proxy.stop(true); await upstream.stop(true); } + }); + } + } +}); diff --git a/tests/responses/protocol-direct-encoders-chat.test.ts b/tests/responses/protocol-direct-encoders-chat.test.ts index 3625ca9010f..029ae974e03 100644 --- a/tests/responses/protocol-direct-encoders-chat.test.ts +++ b/tests/responses/protocol-direct-encoders-chat.test.ts @@ -280,6 +280,22 @@ const SCENARIOS: Record { + test.each([["length", "length"], ["refusal", "content_filter"]] as const)( + "explicit %s incomplete finishes with %s on both paths", async (reason, finishReason) => { + const events: AdapterEvent[] = [ + { type: "text_delta", text: "partial" }, { type: "incomplete", reason, usage }, + ]; + const frames = await expectStreamParity(events); + expect(frames.at(-1)).toBe("[DONE]"); + expect(frames.at(-2)).toMatchObject({ + choices: [{ finish_reason: finishReason }], usage: { prompt_tokens: 120, completion_tokens: 30 }, + }); + const folded = await expectFoldParity(events); + expect(folded.status).toBe(200); + expect(folded.body).toMatchObject({ choices: [{ finish_reason: finishReason }] }); + }, + ); + for (const [name, scenario] of Object.entries(SCENARIOS)) { test(name, async () => { await expectStreamParity(scenario.events, scenario.options); diff --git a/tests/responses/protocol-direct-encoders-messages.test.ts b/tests/responses/protocol-direct-encoders-messages.test.ts index 6df602c0b5d..666e8f4f7cc 100644 --- a/tests/responses/protocol-direct-encoders-messages.test.ts +++ b/tests/responses/protocol-direct-encoders-messages.test.ts @@ -301,6 +301,22 @@ const SCENARIOS: Record { + test.each([["length", "max_tokens"], ["refusal", "refusal"]] as const)( + "explicit %s incomplete stops with %s on both paths", async (reason, stopReason) => { + const events: AdapterEvent[] = [ + { type: "text_delta", text: "partial" }, { type: "incomplete", reason, usage }, + ]; + const frames = await expectStreamParity(events); + expect(frames.at(-1)!.event).toBe("message_stop"); + expect(frames.at(-2)!.data).toMatchObject({ + delta: { stop_reason: stopReason }, usage: { output_tokens: 30 }, + }); + const folded = await expectFoldParity(events); + expect(folded.status).toBe(200); + expect(folded.body.stop_reason).toBe(stopReason); + }, + ); + for (const [name, scenario] of Object.entries(SCENARIOS)) { test(name, async () => { await expectStreamParity(scenario.events, scenario.options); From e9c1e81d497a4dac5168b9c8e34b8bcdf088efba Mon Sep 17 00:00:00 2001 From: AK Date: Fri, 16 Oct 2026 09:58:42 +0900 Subject: [PATCH 2/7] fix(bridge): end the turn on a terminal upstream refusal instead of a retryable incomplete (carry #7152) Carried from #7152. Co-authored-by: AK Co-authored-by: Claude Opus 5.5 --- .../content/docs/reference/proxy-formats.md | 15 +- scripts/test-layout/layout.json | 8 +- src/adapters/anthropic.ts | 17 +- src/bridge/sse.ts | 26 +- src/bridge/terminal-refusal.ts | 59 +++++ src/images/loop.ts | 9 +- src/server/responses/adapter-delivery.ts | 1 + src/server/responses/run-turn-execution.ts | 2 + src/server/responses/sidecar-execution.ts | 2 + src/web-search/loop.ts | 9 +- structure/adapters/registry.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/transports/responses.md | 6 +- .../adapters/bridge-terminal-refusal.test.ts | 231 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 6 + tests/images/loop-terminal-refusal.test.ts | 74 ++++++ tests/routing/combo-terminal-refusal.test.ts | 71 ++++++ tests/server/server-kiro-refusal-e2e.test.ts | 81 ++++++ .../server-terminal-refusal-wiring.test.ts | 190 ++++++++++++++ .../web-search-terminal-refusal.test.ts | 61 +++++ 20 files changed, 852 insertions(+), 20 deletions(-) create mode 100644 src/bridge/terminal-refusal.ts create mode 100644 tests/adapters/bridge-terminal-refusal.test.ts create mode 100644 tests/images/loop-terminal-refusal.test.ts create mode 100644 tests/routing/combo-terminal-refusal.test.ts create mode 100644 tests/server/server-terminal-refusal-wiring.test.ts create mode 100644 tests/web-search/web-search-terminal-refusal.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 002d794841d..73463369645 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -302,7 +302,20 @@ returns an error instead of partial JSON with HTTP 200. Streaming callers are un When a provider filters or truncates a response, an unfinished tool call remains `incomplete` in both JSON and SSE. Partial output is preserved, and the bridge does not emit an argument completion event for that open call. Calls already completed keep their status. This preserves -the provider outcome; client retry behavior for incomplete responses is unchanged. +the provider outcome. + +On the Responses streaming wire, an explicit non-retryable content-filter stop from +Anthropic (`refusal` / `content_filter`) or Kiro (`CONTENT_FILTERED` / +`GUARDRAIL_INTERVENED`) ends with `response.failed`, `error.code: "invalid_prompt"`, +and `retryable: false`. This prevents clients that retry incomplete streams from +resending the refused context. Usage and partial output are preserved. Web-search +and image sidecars stop without executing calls from that refused iteration or +asking the model again, on every inbound wire. + +Buffered Responses JSON retains `status: "incomplete"`; Chat Completions retains +`finish_reason: "content_filter"`, and Messages retains `stop_reason: "refusal"`. +A content filter without an explicit non-retryable verdict, token limits, stalls, +and EOFs keep their existing incomplete behavior. Explicit incomplete reasons use the same vocabulary in adapter-backed responses and native Responses JSON, SSE, and WebSocket delivery: diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 53d444afb8c..0ff3c35d048 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,13 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "incomplete-reason-normalization.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { + "bridge-terminal-refusal.test.ts": "adapters", + "loop-terminal-refusal.test.ts": "images", + "web-search-terminal-refusal.test.ts": "web-search", + "combo-terminal-refusal.test.ts": "routing", + "server-terminal-refusal-wiring.test.ts": "server", + "incomplete-reason-normalization.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 2e98267362b..c9a18603a1d 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -313,11 +313,12 @@ function isAnthropicContentFilterStopReason( } /** - * Anthropic `refusal` / `content_filter` is a permanent sampling decision, not a disconnect. - * Emitting `done` with that stopReason used to surface as `response.incomplete` without - * `retryable`, which Codex treats as a dropped stream and retries five times (#4312). - * The explicit incomplete event is what the bridge already forwards into - * `incomplete_details.retryable`. Usage is preserved: a filtered turn still consumed tokens. + * Anthropic `refusal` / `content_filter` is a permanent sampling decision, not a disconnect + * (#4312). Codex does not read `incomplete_details.retryable`: it retries every + * `response.incomplete`. The explicit `retryable: false` verdict here is the marker the bridge + * uses to deliver the turn to a Responses client as a terminal `response.failed` instead + * (`src/bridge/terminal-refusal.ts`); a `done` with that stopReason carries no verdict and + * stays a plain incomplete. Usage is preserved: a filtered turn still consumed tokens. * `max_tokens` stays a `done` so the client can continue from a legitimate truncation. */ function anthropicContentFilterIncomplete( @@ -1237,8 +1238,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti return; } // Refusal / content_filter must not look like a dropped stream. `done` with that - // stopReason becomes `response.incomplete` without `retryable`, and Codex retries - // the same refusal five times (#4312). + // stopReason carries no verdict, so the bridge could not tell it from a retryable + // incomplete and Codex would re-send the same refusal (#4312). if (isAnthropicContentFilterStopReason(pendingStopReason)) { yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage)); return; @@ -1433,7 +1434,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } // Same rule as emitDone: refusal / content_filter is a permanent decision, not a // disconnect. This branch bypasses emitDone, so the check has to be repeated here - // or the EOF route still emits `done` and Codex retries the refusal (#4312). + // or the EOF route emits a verdict-less `done` the bridge cannot make terminal (#4312). if (isAnthropicContentFilterStopReason(pendingStopReason)) { emittedDone = true; yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage)); diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 282038460a5..765065b9310 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -13,7 +13,7 @@ import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, - isCyberPolicyCode, + isTerminalRefusalCode, type OcxErrorPayload, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; @@ -51,6 +51,7 @@ import { } from "../lib/translator-budget"; import { adapterFailureFromEvent, emptyChunks, joinChunks, ownedBudgetAbandonedMs, responsesUsage, toolCallArgumentsCouldBeJson, toolCallArgumentsUsable, uuid, webSearchAction } from "./internal"; import type { OutputItem, StringChunks } from "./internal"; +import { terminalRefusalFailure } from "./terminal-refusal"; function sseEvent(name: string, data: Record): string { return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`; @@ -121,6 +122,12 @@ export function bridgeToResponsesSSE( * would reach the client under the invented name instead of the normalized one. */ enforceDeclaredToolNames?: boolean; + /** + * Deliver a non-retryable upstream refusal as `response.failed` with a terminal-refusal code + * instead of `response.incomplete` (see `./terminal-refusal`). Set only for the Responses + * inbound wire: the chat and Anthropic encoders read the incomplete to report a filter stop. + */ + terminalRefusalAsFailed?: boolean; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ toolParameterSchemas?: ReadonlyMap>; /** @@ -1312,6 +1319,19 @@ export function bridgeToResponsesSSE( flushHiddenReasoningEnvelope(); options?.onUsage?.(event.usage); await awaitThoughtSignatureDurability(); + const refusal = options?.terminalRefusalAsFailed ? terminalRefusalFailure(event) : undefined; + if (refusal) { + emit("response.failed", { + response: { + ...responseSnapshot("failed", finishedItems, event.endTurn), + usage: responsesUsage(event.usage), + ...refusal, + }, + }); + reportTerminal("failed"); + terminalEvent = true; + break; + } emit("response.incomplete", { response: { ...responseSnapshot("incomplete", finishedItems, event.endTurn), @@ -1350,7 +1370,7 @@ export function bridgeToResponsesSSE( ...(event.usage ? { usage: responsesUsage(event.usage) } : {}), error: failure.error, last_error: failure.error, - ...(isCyberPolicyCode(failure.error.code) + ...(isTerminalRefusalCode(failure.error.code) ? { retryable: false } : event.retryable !== undefined ? { retryable: event.retryable } : {}), }, @@ -1391,7 +1411,7 @@ export function bridgeToResponsesSSE( ...responseSnapshot("failed", finishedItems), error: failure, last_error: failure, - ...(isCyberPolicyCode(failure.code) ? { retryable: false } : {}), + ...(isTerminalRefusalCode(failure.code) ? { retryable: false } : {}), }, }); reportTerminal("failed"); diff --git a/src/bridge/terminal-refusal.ts b/src/bridge/terminal-refusal.ts new file mode 100644 index 00000000000..e7586e26cd2 --- /dev/null +++ b/src/bridge/terminal-refusal.ts @@ -0,0 +1,59 @@ +import type { AdapterEvent } from "../types"; +import type { OcxErrorPayload } from "../lib/errors"; +import { redactSecretString } from "../lib/redact"; + +/** + * The terminal-refusal code Codex ends a turn on. It is a member of `TERMINAL_REFUSAL_CODES` + * in `src/lib/errors.ts`, and the one that says "this prompt, as sent, is refused" without + * claiming a specific policy family the upstream never named. + */ +const TERMINAL_REFUSAL_FAILED_CODE = "invalid_prompt"; + +interface TerminalRefusalFailure { + error: OcxErrorPayload; + last_error: OcxErrorPayload; + retryable: false; +} + +/** + * Client-visible copy: what happened, and that resending the same context cannot help. It points + * at a new conversation rather than a new turn, because a later turn replays the refused context. + */ +function terminalRefusalMessage(detail: string | undefined): string { + const reason = detail?.trim() ? ` (${redactSecretString(detail.trim())})` : ""; + return `The upstream model refused this request${reason}. ` + + "Retrying with the same conversation context will not succeed; change the request or start a new conversation."; +} + +/** + * An adapter `incomplete` that is a non-retryable upstream refusal: a content filter carrying an + * explicit `retryable: false` verdict. A filter without the verdict, an output cap, a stall and an + * adapter EOF do not qualify. The sidecar loops read this too: a refused iteration ends the turn, + * so a sidecar call it carried is neither run nor answered with another model request. + */ +export function isTerminalRefusalIncomplete(event: AdapterEvent | undefined): boolean { + return event?.type === "incomplete" && event.reason === "content_filter" && event.retryable === false; +} + +/** + * `response.failed` fields for an adapter `incomplete` that is a non-retryable upstream refusal + * (#4312), or `undefined` when the event is anything else. The Anthropic and Kiro adapters are + * the ones that emit it today. + * + * codex-rs maps every `response.incomplete` to a retryable stream error and never reads + * `incomplete_details.retryable`, so a refusal delivered that way is re-sent through the whole + * stream retry budget. It only ends the turn on `response.failed` carrying a terminal-refusal + * `error.code`. Only an explicit `retryable: false` content filter qualifies: a filter without + * the verdict, an output cap, a stall and an adapter EOF stay `response.incomplete`. + */ +export function terminalRefusalFailure( + event: Extract, +): TerminalRefusalFailure | undefined { + if (!isTerminalRefusalIncomplete(event)) return undefined; + const error: OcxErrorPayload = { + type: "invalid_request_error", + code: TERMINAL_REFUSAL_FAILED_CODE, + message: terminalRefusalMessage(event.message), + }; + return { error, last_error: error, retryable: false }; +} diff --git a/src/images/loop.ts b/src/images/loop.ts index cc33a556bae..21cbf3d349d 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -20,6 +20,7 @@ import { isAnthropicInstanceId } from "../providers/anthropic-instance-id"; import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; +import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal"; import { clearableDeadline, idleDeadline } from "../lib/abort"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry"; @@ -359,6 +360,8 @@ export interface ImageBridgeDeps { onCompletedResponse?: (response: Record, providerState?: OcxProviderContinuationState) => void; /** WebSocket Responses path only — leave response id empty for protocol compatibility. */ forceEmptyResponseId?: boolean; + /** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */ + terminalRefusalAsFailed?: boolean; } /** @@ -869,7 +872,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise 0 && !split.hasRealToolCall && !forceFinal; + // A refused iteration also finalizes: fulfilling its media call would re-ask the model + // with the refused context (#7148). + const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall && !forceFinal + && !split.passthrough.some(isTerminalRefusalIncomplete); if (!shouldLoop) { if (hiddenUsage) { for (let i = split.passthrough.length - 1; i >= 0; i--) { @@ -1097,6 +1103,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { const served = transportState.replayOAuthCredentialSnapshot; if (route.providerName === "kiro" && response.status === "completed" && served @@ -636,6 +637,7 @@ export async function executeResponsesSidecars( settings: wsPlan.settings, maxSearches: wsPlan.maxSearches, forceEmptyResponseId: true, + terminalRefusalAsFailed: inboundWire !== "chat" && inboundWire !== "anthropic", abortSignal: options.abortSignal, onFirstOutput: noteSidecarOutput, onRequestBuilt: request => { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 79d6c291f7c..004ac013967 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -6,6 +6,7 @@ import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaqu import type { AttemptRecoveryKind } from "../usage/log"; import { isTruncatedStopReason } from "../responses/truncated-stop-reason"; import { bridgeToResponsesSSE } from "../bridge"; +import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal"; import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; @@ -303,6 +304,8 @@ export interface WebSearchLoopDeps { settings: SidecarSettings; maxSearches: number; forceEmptyResponseId?: boolean; + /** Responses inbound only: deliver a non-retryable upstream refusal as `response.failed`. */ + terminalRefusalAsFailed?: boolean; abortSignal?: AbortSignal; recordSidecarOutcome?: SidecarOutcomeRecorder; beforeSidecarDispatch?: () => void; @@ -920,7 +923,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise 0 && !split.hasRealToolCall; + // A refused iteration ends the turn even when it carried a search call: running the + // search would send the refused context back to the model (#7148). + const shouldLoop = split.calls.length > 0 && !split.hasRealToolCall + && !split.passthrough.some(isTerminalRefusalIncomplete); if (!shouldLoop) { // #1001: a forced-answer pass that ends `done` must have produced // usable output — never a malformed tool call, and never silence. @@ -1015,6 +1021,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise.fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 8e5de3b94a4..42b891a5eee 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -589,12 +589,12 @@ keeps only allowlisted transport verdict codes and the explicit local `spend_led The shared usage shaping's `input_tokens_details` and `output_tokens_details` are always emitted, with zero defaults, because strict Responses clients deserialize them as required fields. - -Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or -is cancelled. +Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or is cancelled. The shared endpoint path also preserves the cyber-policy stop when a malformed UTF-8 5xx body is replacement-decoded; other malformed-body usage, quota, reset evidence and classification retain the status-only fallback. Rebuilt failures remain non-replayable and cyber-policy failures carry neither `Retry-After` nor quota-reset metadata. The [Responses failover contract](responses-failover.md) owns the bounded recovery and replay decisions. + +On the `responses` inbound wire only (`terminalRefusalAsFailed`, set by the same test as `enforceDeclaredToolNames` in `src/server/responses/adapter-delivery.ts`, `src/server/responses/run-turn-execution.ts` and the sidecar loops started from `src/server/responses/sidecar-execution.ts`), an adapter `incomplete` with `reason: "content_filter"` and `retryable: false` is delivered as `response.failed` whose `error` and `last_error` carry `invalid_request_error` / `invalid_prompt` with `retryable: false` and the turn's usage, built by `src/bridge/terminal-refusal.ts`: Codex retries every `response.incomplete` without reading `incomplete_details.retryable` and ends a turn only on a terminal-refusal `error.code` (#4312). The Anthropic and Kiro adapters emit that event today. Inside a combo a refusal with no output never reaches the client as a stream: `src/server/responses/combo-stream-preflight.ts` converts the zero-output `response.failed` into an HTTP 400 JSON error with the same `invalid_prompt` code, and `comboFailureDecision` stops the chain on it instead of sending the refused prompt to the next target (`tests/routing/combo-terminal-refusal.test.ts`). The web-search and image sidecar loops (`src/web-search/loop.ts`, `src/images/loop.ts`) check `isTerminalRefusalIncomplete` before looping, on every inbound wire: a refused iteration that carried a sidecar call ends the turn instead of running the call and re-asking the model with the refused context (`tests/web-search/web-search-terminal-refusal.test.ts`, `tests/images/loop-terminal-refusal.test.ts`); `src/web-search/run-turn-loop.ts` already stops on any incomplete. Every other incomplete, the chat and Anthropic inbound wires, and `buildResponseJSON` keep the incomplete shape. A `response.failed` built from an adapter error carries `retryable: false` for every code in `isTerminalRefusalCode` (`src/lib/errors.ts`), not only cyber policy. `tests/adapters/bridge-terminal-refusal.test.ts` pins both, `tests/server/server-terminal-refusal-wiring.test.ts` pins which inbound wire opts in, and `tests/server/server-kiro-refusal-e2e.test.ts` pins the adapter, runTurn and both sidecar-loop deliveries. diff --git a/tests/adapters/bridge-terminal-refusal.test.ts b/tests/adapters/bridge-terminal-refusal.test.ts new file mode 100644 index 00000000000..6ad059dcb89 --- /dev/null +++ b/tests/adapters/bridge-terminal-refusal.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { isTerminalRefusalCode } from "../../src/lib/errors"; +import type { AdapterEvent, OcxUsage } from "../../src/types"; + +/** + * #4312: codex-rs maps every `response.incomplete` to a retryable stream error and never reads + * `incomplete_details.retryable`, so an upstream refusal delivered that way is re-sent through the + * whole stream retry budget. Only `response.failed` with a terminal-refusal `error.code` ends the + * turn, and only the Responses inbound wire opts into that delivery. + */ +type BridgeOptions = NonNullable[7]>; +type Frame = { name: string; data: Record }; + +const TERMINALS = new Set(["response.completed", "response.incomplete", "response.failed"]); +const USAGE: OcxUsage = { inputTokens: 120, outputTokens: 7, totalTokens: 127 }; +const REFUSAL: AdapterEvent = { + type: "incomplete", + reason: "content_filter", + retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: USAGE, +}; + +async function frames(events: AdapterEvent[], options?: BridgeOptions): Promise { + async function* source(): AsyncGenerator { + for (const event of events) yield event; + } + const text = await new Response( + bridgeToResponsesSSE(source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, options), + ).text(); + return text.split("\n\n").flatMap(frame => { + const lines = frame.trim().split("\n"); + const name = lines.find(line => line.startsWith("event: "))?.slice(7); + const data = lines.find(line => line.startsWith("data: "))?.slice(6); + return name && data ? [{ name, data: JSON.parse(data) as Record }] : []; + }); +} + +async function terminal(events: AdapterEvent[], options?: BridgeOptions): Promise { + const terminals = (await frames(events, options)).filter(frame => TERMINALS.has(frame.name)); + expect(terminals).toHaveLength(1); + return terminals[0]!; +} + +describe("Responses bridge: terminal upstream refusal", () => { + test("a non-retryable content filter becomes response.failed with a terminal-refusal code", async () => { + const statuses: string[] = []; + const usages: Array = []; + const frame = await terminal([REFUSAL], { + terminalRefusalAsFailed: true, + onTerminal: status => { statuses.push(status); }, + onUsage: usage => { usages.push(usage); }, + }); + + expect(frame.name).toBe("response.failed"); + const response = frame.data.response; + expect(response.status).toBe("failed"); + expect(response.error).toEqual(response.last_error); + expect(response.error.type).toBe("invalid_request_error"); + expect(response.error.code).toBe("invalid_prompt"); + // The code is what ends the Codex turn, so it must stay inside the set Codex treats as final. + expect(isTerminalRefusalCode(response.error.code)).toBe(true); + expect(response.error.message).toContain("refused"); + expect(response.error.message).toContain("will not succeed"); + expect(response.error.message).toContain('stop_reason "refusal"'); + expect(response.error.message).toContain("start a new conversation"); + expect(response.retryable).toBe(false); + expect(response.incomplete_details).toBeUndefined(); + // A refused turn still consumed tokens. + expect(response.usage.input_tokens).toBe(120); + expect(response.usage.output_tokens).toBe(7); + expect(usages).toEqual([USAGE]); + expect(statuses).toEqual(["failed"]); + }); + + test("output opened before the refusal is closed the same way as on the incomplete path", async () => { + const events: AdapterEvent[] = [ + { type: "text_delta", text: "I can" }, + { type: "tool_call_start", id: "call_1", name: "lookup" }, + { type: "tool_call_delta", arguments: '{"q":"par' }, + REFUSAL, + ]; + const done = (all: Frame[]) => all + .filter(frame => frame.name === "response.output_item.done") + .map(frame => [frame.data.item.type, frame.data.item.status]); + + const failed = await frames(events, { terminalRefusalAsFailed: true }); + const incomplete = await frames(events); + + expect(failed.at(-1)?.name).toBe("response.failed"); + expect(incomplete.at(-1)?.name).toBe("response.incomplete"); + expect(done(failed)).toEqual(done(incomplete)); + expect(done(failed)).toEqual([["message", "completed"], ["function_call", "incomplete"]]); + expect(failed.at(-1)!.data.response.output.map((item: { type: string }) => item.type)) + .toEqual(incomplete.at(-1)!.data.response.output.map((item: { type: string }) => item.type)); + }); + + test("a Kiro-shaped refusal is promoted and keeps its end_turn verdict", async () => { + // src/adapters/kiro/stream.ts: CONTENT_FILTERED / GUARDRAIL_INTERVENED, endTurn false, with + // continuation state. A failed turn is never remembered, so the state is not handed out. + const completed: unknown[] = []; + const frame = await terminal([{ + type: "incomplete", + reason: "content_filter", + retryable: false, + message: "Kiro stopped with CONTENT_FILTERED before an explicit final answer", + usage: USAGE, + endTurn: false, + providerState: { kiro: { conversationId: "conv_1" } }, + }], { terminalRefusalAsFailed: true, onCompletedResponse: response => { completed.push(response); } }); + + expect(frame.name).toBe("response.failed"); + expect(frame.data.response.error.code).toBe("invalid_prompt"); + expect(frame.data.response.error.message).toContain("CONTENT_FILTERED"); + expect(frame.data.response.retryable).toBe(false); + expect(frame.data.response.end_turn).toBe(false); + expect(completed).toEqual([]); + }); + + test("end_turn is carried only when the adapter set it", async () => { + const options = { terminalRefusalAsFailed: true }; + + expect((await terminal([{ ...REFUSAL, endTurn: true }], options)).data.response.end_turn).toBe(true); + expect((await terminal([{ ...REFUSAL, endTurn: false }], options)).data.response.end_turn).toBe(false); + expect("end_turn" in (await terminal([REFUSAL], options)).data.response).toBe(false); + }); + + test("a refusal without an adapter message still says what happened", async () => { + for (const message of [undefined, "", " "]) { + const frame = await terminal( + [{ type: "incomplete", reason: "content_filter", retryable: false, ...(message !== undefined ? { message } : {}) }], + { terminalRefusalAsFailed: true }, + ); + + expect(frame.name).toBe("response.failed"); + expect(frame.data.response.error.message).toBe( + "The upstream model refused this request. Retrying with the same conversation context will not succeed; " + + "change the request or start a new conversation.", + ); + } + }); + + test("a credential echoed in the adapter message is masked before it reaches the client", async () => { + // Assembled at runtime so the fixture is not itself a credential-shaped literal. + const bearer = ["tok", "en", "0123456789", "abcdef"].join(""); + const apiKey = ["sk", "refusal0123456789"].join("-"); + const frame = await terminal([{ + type: "incomplete", reason: "content_filter", retryable: false, + message: `upstream refused; request carried Authorization: Bearer ${bearer} and api_key=${apiKey}`, + }], { terminalRefusalAsFailed: true }); + + const message: string = frame.data.response.error.message; + expect(frame.name).toBe("response.failed"); + expect(message).toContain("upstream refused; request carried"); + expect(message).not.toContain(bearer); + expect(message).not.toContain(apiKey); + expect(frame.data.response.last_error.message).toBe(message); + }); + + test("without the option the refusal stays response.incomplete", async () => { + for (const options of [undefined, {}, { terminalRefusalAsFailed: false }] as const) { + const statuses: string[] = []; + const frame = await terminal([REFUSAL], { ...options, onTerminal: status => { statuses.push(status); } }); + + expect(frame.name).toBe("response.incomplete"); + expect(frame.data.response.status).toBe("incomplete"); + expect(frame.data.response.error).toBeUndefined(); + expect(frame.data.response.incomplete_details).toEqual({ + reason: "content_filter", + message: 'upstream ended the turn with stop_reason "refusal"', + retryable: false, + }); + expect(frame.data.response.usage.input_tokens).toBe(120); + expect(statuses).toEqual(["incomplete"]); + } + }); + + test("only an explicit non-retryable content filter is promoted", async () => { + const stillIncomplete: AdapterEvent[] = [ + { type: "incomplete", reason: "content_filter", usage: USAGE }, + { type: "incomplete", reason: "content_filter", retryable: true, usage: USAGE }, + { type: "incomplete", reason: "max_output_tokens", usage: USAGE }, + // A cap is a legitimate truncation the client may continue, whatever the verdict says. + { type: "incomplete", reason: "max_output_tokens", retryable: false, usage: USAGE }, + { type: "incomplete", reason: "upstream_stall_timeout", retryable: false }, + ]; + for (const event of stillIncomplete) { + const frame = await terminal([event], { terminalRefusalAsFailed: true }); + + expect(frame.name).toBe("response.incomplete"); + expect(frame.data.response.incomplete_details.reason).toBe((event as { reason: string }).reason); + expect(frame.data.response.error).toBeUndefined(); + } + }); + + test("a done carrying a filter stop reason is not promoted", async () => { + const frame = await terminal( + [{ type: "done", stopReason: "content_filter", usage: USAGE }], + { terminalRefusalAsFailed: true }, + ); + + expect(frame.name).toBe("response.incomplete"); + expect(frame.data.response.incomplete_details.reason).toBe("content_filter"); + }); + + test("an adapter error with a terminal-refusal code is marked non-retryable", async () => { + for (const code of ["invalid_prompt", "misalignment_policy_violation", "bio_policy", "cyber_policy"]) { + // The verdict belongs to the code: an adapter that claims the refusal is retryable is wrong. + for (const retryable of [undefined, true]) { + const frame = await terminal([{ + type: "error", message: "upstream refused the request", status: 400, + errorType: "invalid_request_error", code, + ...(retryable !== undefined ? { retryable } : {}), + }]); + + expect(frame.name).toBe("response.failed"); + expect(frame.data.response.error.code).toBe(code); + expect(frame.data.response.retryable).toBe(false); + } + } + }); + + test("an adapter error with an ordinary code keeps the adapter's own verdict", async () => { + const base = { type: "error", message: "upstream overloaded", status: 503, errorType: "server_error", code: "server_is_overloaded" } as const; + + expect((await terminal([base])).data.response.retryable).toBeUndefined(); + expect((await terminal([{ ...base, retryable: true }])).data.response.retryable).toBe(true); + expect((await terminal([{ ...base, retryable: false }])).data.response.retryable).toBe(false); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 25616205e47..b652c06bb73 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,10 @@ { + "bridge-terminal-refusal.test.ts": "adapters", + "loop-terminal-refusal.test.ts": "images", + "web-search-terminal-refusal.test.ts": "web-search", + "combo-terminal-refusal.test.ts": "routing", + "server-terminal-refusal-wiring.test.ts": "server", + "incomplete-reason-normalization.test.ts": "responses", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "catalog-native-max-context.test.ts": "codex-integration", diff --git a/tests/images/loop-terminal-refusal.test.ts b/tests/images/loop-terminal-refusal.test.ts new file mode 100644 index 00000000000..b43a94f4ea7 --- /dev/null +++ b/tests/images/loop-terminal-refusal.test.ts @@ -0,0 +1,74 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import type { AdapterEvent, OcxParsedRequest } from "../../src/types"; +import type { ImageBridgePlan } from "../../src/images/types"; +import { runWithImageBridge } from "../../src/images/loop"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #7148: a refusal that ends an iteration carrying a complete image_gen call must end the turn. + * Fulfilling the call and re-asking would send the refused context back to the model. + */ +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +afterEach(() => { + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (home) removeTreeWithRetry(home); +}); + +test.each([true, false, undefined])("a refused image_gen iteration stops with terminalRefusalAsFailed=%s", async terminalRefusalAsFailed => { + home = mkdtempSync(join(tmpdir(), "ocx-image-refusal-")); + process.env.OPENCODEX_HOME = home; + let mediaFetches = 0; + globalThis.fetch = (async () => { mediaFetches++; return new Response("unavailable", { status: 503 }); }) as typeof fetch; + let routedRequests = 0; + const adapter: ProviderAdapter = { + name: "mock-refusal", + buildRequest: async () => { + routedRequests++; + return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }; + }, + fetchResponse: async () => new Response("ok", { status: 200 }), + async *parseStream() { + yield { type: "tool_call_start", id: "call_1", name: "image_gen" } satisfies AdapterEvent; + yield { type: "tool_call_delta", arguments: '{"prompt":"a cat"}' } satisfies AdapterEvent; + yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { + type: "incomplete", reason: "content_filter", retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 }, + } satisfies AdapterEvent; + }, + async parseResponse() { throw new Error("parseResponse must be unreachable"); }, + }; + const plan = { + provider: {} as never, + auth: { baseUrl: "https://api.x.ai", token: "test-token" }, + model: "grok-imagine-image-quality", + toolNames: new Set(["image_gen"]), + } as ImageBridgePlan; + + const response = await runWithImageBridge({ + parsed: { modelId: "test-model", context: { messages: [], tools: [] }, stream: true, options: {} } as OcxParsedRequest, + adapter, + plan, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + terminalRefusalAsFailed, + }); + const text = await response.text(); + + expect(routedRequests).toBe(1); + expect(mediaFetches).toBe(0); + expect(text).toContain(`event: response.${terminalRefusalAsFailed ? "failed" : "incomplete"}`); + if (terminalRefusalAsFailed) expect(text).toContain('"code":"invalid_prompt"'); + else expect(text).toContain('"reason":"content_filter"'); + expect(text).not.toContain("event: response.completed"); +}); + diff --git a/tests/routing/combo-terminal-refusal.test.ts b/tests/routing/combo-terminal-refusal.test.ts new file mode 100644 index 00000000000..068b9044b12 --- /dev/null +++ b/tests/routing/combo-terminal-refusal.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { comboFailureDecision } from "../../src/combos"; +import type { RequestLogContext } from "../../src/server/request-log"; +import { preflightComboStreamResponse } from "../../src/server/responses/combo-stream-preflight"; +import { consumeComboFailure } from "../../src/server/responses/core-combo-failure"; +import type { AdapterEvent } from "../../src/types"; + +/** + * #4312 inside a combo. A refusal with no output is a zero-output `response.failed`, which the + * combo preflight converts into an HTTP error before any byte reaches the client. That must end + * the chain rather than send the refused prompt to the next target, and once output has been + * relayed the same terminal has to stay a streamed `response.failed`. + */ +const REFUSAL: AdapterEvent = { + type: "incomplete", + reason: "content_filter", + retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: { inputTokens: 10, outputTokens: 0, totalTokens: 10 }, +}; + +function bridged(events: AdapterEvent[]): Response { + async function* source(): AsyncGenerator { + for (const event of events) yield event; + } + return new Response( + bridgeToResponsesSSE(source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { + terminalRefusalAsFailed: true, + }), + { headers: { "content-type": "text/event-stream" } }, + ); +} + +describe("combo: terminal upstream refusal", () => { + test("a zero-output refusal becomes an HTTP 400 that stops the chain", async () => { + const logCtx = {} as RequestLogContext; + const preflight = await preflightComboStreamResponse(bridged([REFUSAL]), logCtx); + + if (preflight.kind !== "failed") throw new Error(`expected a failed preflight, got ${preflight.kind}`); + expect(preflight.response.status).toBe(400); + expect(preflight.response.headers.get("content-type")).toContain("application/json"); + const body = await preflight.response.clone().json() as { error: { type: string; code: string; message: string } }; + expect(body.error.type).toBe("invalid_request_error"); + expect(body.error.code).toBe("invalid_prompt"); + expect(body.error.message).toContain("refused"); + // The steps `src/server/responses/core-combo.ts` takes with a failed preflight: consume the + // failure body, carry the preflight's model-refusal evidence over, then decide. The loop's + // other stop conditions (non-replayable marker, spent replacement) are not in play here. + const failure = await consumeComboFailure(preflight.response); + expect(failure.response.status).toBe(400); + expect(failure.upstreamCode).toBe("invalid_prompt"); + expect(comboFailureDecision(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + codexModelRefusal: preflight.codexModelRefusal ?? failure.codexModelRefusal, + })).toBe("stop"); + }); + + test("a refusal after output is relayed as the streamed response.failed", async () => { + const preflight = await preflightComboStreamResponse( + bridged([{ type: "text_delta", text: "I can" }, REFUSAL]), + {} as RequestLogContext, + ); + + expect(preflight.kind).toBe("accepted"); + const text = await preflight.response.text(); + expect(text).toContain("event: response.failed"); + expect(text).toContain('"code":"invalid_prompt"'); + expect(text).not.toContain("event: response.incomplete"); + }); +}); diff --git a/tests/server/server-kiro-refusal-e2e.test.ts b/tests/server/server-kiro-refusal-e2e.test.ts index 74d17cc9514..eb0b42b5450 100644 --- a/tests/server/server-kiro-refusal-e2e.test.ts +++ b/tests/server/server-kiro-refusal-e2e.test.ts @@ -522,3 +522,84 @@ describe("Kiro refusal recovery through Responses", () => { }); } }); + +/** + * #4312: Kiro's CONTENT_FILTERED / GUARDRAIL_INTERVENED stop is an `incomplete` with + * `retryable: false`. A Responses client must see it as a terminal `response.failed` on every + * delivery that owns a bridge call: the adapter stream, both sidecar loops, and runTurn. No + * runTurn adapter emits this verdict today, so that case drives the same event through a + * `runTurn` stand-in on the Kiro registry entry. + */ +describe("Kiro content-filter stop through Responses streaming", () => { + const frame = (eventType: string, payload: Record) => encodeMessage( + { ":message-type": "event", ":event-type": eventType }, + new TextEncoder().encode(JSON.stringify(payload))); + const filtered = (stopReason: string) => new Response(Buffer.concat([ + frame("assistantResponseEvent", { content: "Partial." }), + frame("metadataEvent", { stopReason }), + ]), { headers: { "content-type": "application/vnd.amazon.eventstream" } }); + const lookupTool = { type: "function", name: "lookup", description: "Look something up.", + parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] } }; + const xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", + apiKey: "synthetic", models: ["grok-4"] }; + + for (const [label, stopReason, extra, tools] of [ + ["adapter delivery", "CONTENT_FILTERED", {}, [lookupTool]], + ["adapter delivery", "GUARDRAIL_INTERVENED", {}, [lookupTool]], + ["web-search sidecar loop", "CONTENT_FILTERED", + { webSearchSidecar: { backend: "exa", exaApiKey: "synthetic" } }, + [{ type: "web_search" }]], + ["image sidecar loop", "CONTENT_FILTERED", + { images: { bridgeEnabled: true }, providers: { ...config().providers, xai } }, [{ type: "image_generation" }]], + ["runTurn delivery", "CONTENT_FILTERED", {}, [lookupTool]], + ] as const) { + test(`${label}: ${stopReason} ends with one response.failed carrying invalid_prompt`, async () => { + await seed(); + saveConfig({ ...config(), ...extra } as OcxConfig); + let sends = 0; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://runtime.") && url.endsWith(".kiro.dev/")) { + sends++; + return filtered(stopReason); + } + return realFetch(input, init); + }) as typeof fetch; + const originalCreate = ADAPTER_REGISTRY.kiro.create; + if (label === "runTurn delivery") { + ADAPTER_REGISTRY.kiro.create = (provider, context) => ({ + ...originalCreate(provider, context), + async runTurn(_parsed, _incoming, emit) { + sends++; + emit({ type: "text_delta", text: "Partial." }); + emit({ type: "incomplete", reason: "content_filter", retryable: false, endTurn: false, + message: `Kiro stopped with ${stopReason} before an explicit final answer` }); + }, + }); + } + const server = startServer(0); + try { + const response = await realFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "kiro/claude-sonnet-4.5", input: "hello", stream: true, tools }), + }); + expect(response.status).toBe(200); + const text = await response.text(); + const terminals = text.split("\n\n").flatMap(block => { + const name = block.split("\n").find(line => line.startsWith("event: "))?.slice(7) ?? ""; + const data = block.split("\n").find(line => line.startsWith("data: "))?.slice(6); + return /^response\.(completed|incomplete|failed)$/.test(name) && data + ? [{ name, response: JSON.parse(data).response as Record }] : []; + }); + expect(terminals.map(terminal => terminal.name)).toEqual(["response.failed"]); + const failed = terminals[0]!.response; + expect(failed.error.type).toBe("invalid_request_error"); + expect(failed.error.code).toBe("invalid_prompt"); + expect(failed.error.message).toContain(stopReason); + expect(failed.retryable).toBe(false); + // The refusal is neither replayed on the same account nor rotated to the other one. + expect(sends).toBe(1); + } finally { ADAPTER_REGISTRY.kiro.create = originalCreate; await server.stop(true); } + }); + } +}); diff --git a/tests/server/server-terminal-refusal-wiring.test.ts b/tests/server/server-terminal-refusal-wiring.test.ts new file mode 100644 index 00000000000..8aaf07d0755 --- /dev/null +++ b/tests/server/server-terminal-refusal-wiring.test.ts @@ -0,0 +1,190 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #4312, end to end: which inbound wire sees an upstream refusal as what. + * + * `tests/adapters/bridge-terminal-refusal.test.ts` pins the bridge; this pins the wiring — that + * only the Responses inbound wire asks the bridge for `response.failed`, through the real server + * and a real Anthropic-shaped upstream that ends the turn with `stop_reason: "refusal"`. + */ +type Frame = { name?: string; data: Record }; + +const MODEL = "claude-refusal/claude-test"; +const RESPONSES_TERMINALS = new Set(["response.completed", "response.incomplete", "response.failed"]); + +function anthropicSse(stopReason: string): string { + const frame = (name: string, data: Record) => + `event: ${name}\ndata: ${JSON.stringify({ type: name, ...data })}\n\n`; + return [ + frame("message_start", { + message: { id: "msg_1", model: "claude-test", usage: { input_tokens: 10, output_tokens: 1 } }, + }), + frame("content_block_start", { index: 0, content_block: { type: "text", text: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "text_delta", text: "I can" } }), + frame("content_block_stop", { index: 0 }), + frame("message_delta", { delta: { stop_reason: stopReason }, usage: { output_tokens: 3 } }), + frame("message_stop", {}), + ].join(""); +} + +function frames(text: string): Frame[] { + return text.split("\n\n").flatMap(block => { + const lines = block.trim().split("\n"); + const name = lines.find(line => line.startsWith("event: "))?.slice(7); + const data = lines.find(line => line.startsWith("data: "))?.slice(6); + if (!data || data === "[DONE]") return []; + return [{ name, data: JSON.parse(data) as Record }]; + }); +} + +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; +let isolated: IsolatedCodexHome | undefined; +let upstream: ReturnType | undefined; +let server: ReturnType | undefined; +let stopReason = "refusal"; +let upstreamRequests = 0; + +beforeAll(async () => { + upstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + upstreamRequests += 1; + const body = await request.json() as { stream?: boolean }; + if (body.stream !== true) { + return Response.json({ + id: "msg_1", type: "message", role: "assistant", model: "claude-test", + content: [{ type: "text", text: "I can" }], + stop_reason: stopReason, stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 3 }, + }); + } + return new Response(anthropicSse(stopReason), { headers: { "content-type": "text/event-stream" } }); + }, + }); + home = mkdtempSync(join(tmpdir(), "ocx-refusal-wiring-")); + isolated = installIsolatedCodexHome("ocx-refusal-wiring-codex-"); + process.env.OPENCODEX_HOME = home; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "claude-refusal", + providers: { + "claude-refusal": { + adapter: "anthropic", + baseUrl: `http://127.0.0.1:${upstream.port}`, + apiKey: "sk-test", + // The upstream is a loopback fixture, and nothing here may reach a real catalog. + allowPrivateNetwork: true, + liveModels: false, + defaultModel: "claude-test", + models: ["claude-test"], + }, + }, + } as OcxConfig); + server = startServer(0); + // Requests admitted before the native-main startup gate settles are held, not served. + await waitForNativeMainStartupGate(); +}); + +afterAll(async () => { + await server?.stop(true); + upstream?.stop(true); + // Assigning `undefined` to an env var stores the string "undefined", which then becomes a + // directory name under the working tree. Delete the key when there was nothing to restore. + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolated?.restore(); + if (home) removeTreeWithRetry(home); +}); + +async function post(path: string, body: Record): Promise { + return fetch(new URL(path, server!.url), { + method: "POST", + headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify(body), + }); +} + +describe("upstream refusal, per inbound wire", () => { + test("/v1/responses streaming ends with exactly one response.failed carrying invalid_prompt", async () => { + stopReason = "refusal"; + const before = upstreamRequests; + const response = await post("/v1/responses", { model: MODEL, input: "hi", stream: true }); + const all = frames(await response.text()); + + expect(response.status).toBe(200); + expect(all.map(frame => frame.name).filter(name => RESPONSES_TERMINALS.has(name ?? ""))) + .toEqual(["response.failed"]); + const failed = all.find(frame => frame.name === "response.failed")!.data.response; + expect(failed.status).toBe("failed"); + expect(failed.error).toEqual(failed.last_error); + expect(failed.error.type).toBe("invalid_request_error"); + expect(failed.error.code).toBe("invalid_prompt"); + expect(failed.error.message).toContain('stop_reason "refusal"'); + expect(failed.retryable).toBe(false); + expect(failed.usage.input_tokens).toBe(10); + // The proxy itself must not replay a refusal either. + expect(upstreamRequests - before).toBe(1); + }); + + test("/v1/responses streaming still completes an ordinary turn", async () => { + stopReason = "end_turn"; + const response = await post("/v1/responses", { model: MODEL, input: "hi", stream: true }); + const names = frames(await response.text()).map(frame => frame.name); + + expect(names.filter(name => RESPONSES_TERMINALS.has(name ?? ""))).toEqual(["response.completed"]); + }); + + test("/v1/responses non-streaming keeps the incomplete body", async () => { + stopReason = "refusal"; + const response = await post("/v1/responses", { model: MODEL, input: "hi", stream: false }); + const body = await response.json() as Record; + + expect(response.status).toBe(200); + expect(body.status).toBe("incomplete"); + expect(body.incomplete_details.reason).toBe("content_filter"); + expect(body.error ?? null).toBeNull(); + expect(JSON.stringify(body)).not.toContain("invalid_prompt"); + }); + + test("/v1/chat/completions streaming reports finish_reason content_filter", async () => { + stopReason = "refusal"; + const response = await post("/v1/chat/completions", { + model: MODEL, messages: [{ role: "user", content: "hi" }], stream: true, + }); + const text = await response.text(); + const finishes = frames(text) + .flatMap(frame => (frame.data.choices ?? []) as Array<{ finish_reason?: string | null }>) + .map(choice => choice.finish_reason) + .filter(Boolean); + + expect(response.status).toBe(200); + expect(finishes).toEqual(["content_filter"]); + expect(text).not.toContain("invalid_prompt"); + }); + + test("/v1/messages streaming reports stop_reason refusal", async () => { + stopReason = "refusal"; + const response = await post("/v1/messages", { + model: MODEL, max_tokens: 64, messages: [{ role: "user", content: "hi" }], stream: true, + }); + const text = await response.text(); + const delta = frames(text).find(frame => frame.name === "message_delta"); + + expect(response.status).toBe(200); + expect(delta?.data.delta.stop_reason).toBe("refusal"); + expect(text).not.toContain("invalid_prompt"); + expect(text).not.toContain("event: error"); + }); +}); diff --git a/tests/web-search/web-search-terminal-refusal.test.ts b/tests/web-search/web-search-terminal-refusal.test.ts new file mode 100644 index 00000000000..8f39c61a714 --- /dev/null +++ b/tests/web-search/web-search-terminal-refusal.test.ts @@ -0,0 +1,61 @@ +import { afterEach, expect, test } from "bun:test"; +import type { AdapterEvent } from "../../src/types"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import { parseRequest } from "../../src/responses/parser"; +import { runWithWebSearch } from "../../src/web-search/loop"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +/** + * #7148: a refusal that ends an iteration carrying a complete web_search call must end the turn. + * Running the search and re-asking would send the refused context back to the model, the same + * re-send the Responses bridge's terminal `response.failed` exists to prevent. + */ +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +test.each([true, false, undefined])("a refused web_search iteration stops with terminalRefusalAsFailed=%s", async terminalRefusalAsFailed => { + let searchFetches = 0; + globalThis.fetch = (async () => { + searchFetches++; + return Response.json({ results: [{ title: "Fixture", url: "https://example.test/r", content: "result" }] }); + }) as typeof fetch; + let routedSends = 0; + const adapter: ProviderAdapter = { + name: "mock-refusal", + buildRequest: () => ({ url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" }), + fetchResponse: async () => { routedSends++; return new Response("ok", { status: 200 }); }, + async *parseStream() { + yield { type: "tool_call_start", id: "search-1", name: "web_search" } satisfies AdapterEvent; + yield { type: "tool_call_delta", arguments: JSON.stringify({ query: "fixture query" }) } satisfies AdapterEvent; + yield { type: "tool_call_end" } satisfies AdapterEvent; + yield { + type: "incomplete", reason: "content_filter", retryable: false, + message: 'upstream ended the turn with stop_reason "refusal"', + usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 }, + } satisfies AdapterEvent; + }, + async parseResponse() { throw new Error("parseResponse must be unreachable"); }, + }; + + const response = await runWithWebSearch({ + parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }] }), + adapter, + incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, + backend: "exa", + exaApiKey: "synthetic-exa-key", + hostedTool: { type: "web_search" }, + selectedForwardHeaders: new Headers(), + settings: { model: "exa-fixture-model", reasoning: "low", timeoutMs: 30_000 }, + maxSearches: 2, + terminalRefusalAsFailed, + }); + const text = await response.text(); + + expect(routedSends).toBe(1); + expect(searchFetches).toBe(0); + expect(text).toContain(`event: response.${terminalRefusalAsFailed ? "failed" : "incomplete"}`); + if (terminalRefusalAsFailed) expect(text).toContain('"code":"invalid_prompt"'); + else expect(text).toContain('"reason":"content_filter"'); + expect(text).not.toContain("event: response.completed"); +}); + From cae32766699d1a807ed02355ada5d3ed28a3a7ae Mon Sep 17 00:00:00 2001 From: Aditya Rawat Date: Fri, 16 Oct 2026 09:59:05 +0900 Subject: [PATCH 3/7] test: add offline Responses continuation regression coverage (carry #7068) Carried from #7068. Co-authored-by: Aditya Rawat --- scripts/test-layout/layout.json | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../responses-call-id-linkage.test.ts | 245 ++++++++++++++++++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 tests/responses/responses-call-id-linkage.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0ff3c35d048..0494a7918c7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { + "explicit": { "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b652c06bb73..434437e2b2a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,4 @@ -{ +{ "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", diff --git a/tests/responses/responses-call-id-linkage.test.ts b/tests/responses/responses-call-id-linkage.test.ts new file mode 100644 index 00000000000..78bd6d72642 --- /dev/null +++ b/tests/responses/responses-call-id-linkage.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from "bun:test"; +import { parseRequest } from "../../src/responses/parser"; +import { buildResponseJSON } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; +describe("Issue #6469: Responses Request Parser & Serializer Offline Regression", () => { + describe("B. Call ID preservation and linkage", () => { + test("preserves call_id across serialization and links toolResult correctly", () => { + const callId = "call_custom_unique_id_xyz"; + const events: AdapterEvent[] = [ + { + type: "tool_call_start", + id: callId, + name: "fetch_record", + }, + { + type: "tool_call_delta", + arguments: JSON.stringify({ record_id: "REC-42" }), + }, + { + type: "tool_call_end", + }, + { type: "done" }, + ]; + + const serialized = buildResponseJSON(events, "claude-3-7-sonnet"); + const output = serialized.output as Array>; + expect(output[0].call_id).toBe(callId); + expect(output[0].type).toBe("function_call"); + expect(output[0].name).toBe("fetch_record"); + + // Inbound continuation with explicit input items + const rawInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Fetch record 42" }, + { + type: "function_call", + call_id: callId, + name: "fetch_record", + arguments: JSON.stringify({ record_id: "REC-42" }), + }, + { + type: "function_call_output", + call_id: callId, + output: JSON.stringify({ title: "Annual Report" }), + }, + ], + }; + + const parsed = parseRequest(rawInbound); + const messages = parsed.context.messages; + expect(messages.length).toBe(3); + + const assistantMsg = messages[1]; + expect(assistantMsg.role).toBe("assistant"); + const toolCallPart = (assistantMsg.content as Array>).find(p => p.type === "toolCall"); + expect(toolCallPart?.id).toBe(callId); + expect(toolCallPart?.name).toBe("fetch_record"); + + const toolResultMsg = messages[2] as { role: string; toolCallId: string; toolName: string; content: string }; + expect(toolResultMsg.role).toBe("toolResult"); + expect(toolResultMsg.toolCallId).toBe(callId); + expect(toolResultMsg.toolName).toBe("fetch_record"); + }); + }); + + describe("C. Invalid Call ID cases", () => { + test("handles unknown call_id without throwing, defaulting toolName to empty", () => { + const inbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Previous question" }, + { + type: "function_call_output", + call_id: "call_non_existent_999", + output: "some result", + }, + ], + }; + + const parsed = parseRequest(inbound); + const toolResult = parsed.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + content: string; + }; + expect(toolResult).toBeDefined(); + expect(toolResult.toolCallId).toBe("call_non_existent_999"); + expect(toolResult.toolName).toBe(""); // Unlinked call produces empty toolName + }); + + test("handles duplicate call IDs accurately across single-turn and multi-turn histories", () => { + const duplicateId = "call_dup_777"; + + // 1. Single assistant turn (consecutive function_calls): + // findToolById walks backward over messages, but iterates forward across m.content parts, + // so within the same assistant turn it binds to the first tool_call with that ID. + const sameTurnInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Start" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v1", + arguments: "{}", + }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v2", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: duplicateId, + output: "output for tool", + }, + ], + }; + + const parsedSameTurn = parseRequest(sameTurnInbound); + const sameTurnResult = parsedSameTurn.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + }; + expect(sameTurnResult).toBeDefined(); + expect(sameTurnResult.toolCallId).toBe(duplicateId); + expect(sameTurnResult.toolName).toBe("tool_v1"); // First part in m.content + + // 2. Multi-turn history (distinct assistant turns separated by user message): + // findToolById walks backward over messages, so it binds to the most recent assistant turn's call. + const multiTurnInbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Start" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v1", + arguments: "{}", + }, + { role: "user", content: "Retry with newer tool" }, + { + type: "function_call", + call_id: duplicateId, + name: "tool_v2", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: duplicateId, + output: "output for tool", + }, + ], + }; + + const parsedMultiTurn = parseRequest(multiTurnInbound); + const multiTurnResult = parsedMultiTurn.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId: string; + toolName: string; + }; + expect(multiTurnResult).toBeDefined(); + expect(multiTurnResult.toolCallId).toBe(duplicateId); + expect(multiTurnResult.toolName).toBe("tool_v2"); // Most recent assistant turn + }); + + test("tolerates missing call_id in function_call and function_call_output with undefined id", () => { + // schema catch-all (z.object({ type: z.string() }).loose()) matches items with missing call_id, + // so parseRequest does NOT throw; call_id simply resolves to undefined on the parsed part. + const missingCallIdItem = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Test" }, + { + type: "function_call", + name: "lookup_tool", + arguments: "{}", + // call_id omitted + }, + ], + }; + + const parsed = parseRequest(missingCallIdItem); + const assistantMsg = parsed.context.messages.find(m => m.role === "assistant"); + expect(assistantMsg).toBeDefined(); + const toolCallPart = (assistantMsg?.content as Array>).find(p => p.type === "toolCall"); + expect(toolCallPart?.id).toBeUndefined(); + + // function_call_output without call_id likewise resolves to an undefined toolCallId. + const missingCallIdOutput = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Test" }, + { + type: "function_call_output", + output: "output without call_id", + }, + ], + }; + const parsedOutput = parseRequest(missingCallIdOutput); + const toolResult = parsedOutput.context.messages.find(m => m.role === "toolResult") as { + role: string; + toolCallId?: string; + toolName: string; + }; + expect(toolResult).toBeDefined(); + expect(toolResult.toolCallId).toBeUndefined(); + expect(toolResult.toolName).toBe(""); + }); + + test("handles mismatched call_id without corrupting sibling calls", () => { + const inbound = { + model: "claude-3-7-sonnet", + input: [ + { role: "user", content: "Fetch" }, + { + type: "function_call", + call_id: "call_alpha", + name: "alpha_tool", + arguments: "{}", + }, + { + type: "function_call_output", + call_id: "call_beta", // Mismatched + output: "output for beta", + }, + ], + }; + + const parsed = parseRequest(inbound); + const messages = parsed.context.messages; + const alphaPart = (messages[1].content as Array>).find(p => p.type === "toolCall"); + expect(alphaPart?.id).toBe("call_alpha"); + expect(alphaPart?.name).toBe("alpha_tool"); + + const betaResult = messages[2] as { role: string; toolCallId: string; toolName: string }; + expect(betaResult.toolCallId).toBe("call_beta"); + expect(betaResult.toolName).toBe(""); + }); + }); +}); From f595c5c52f8a70d3246716704eefc3985033412e Mon Sep 17 00:00:00 2001 From: shirruy Date: Fri, 16 Oct 2026 09:59:13 +0900 Subject: [PATCH 4/7] fix(responses): repair MiMo tool-call envelope corruption causing infinite retry loops (carry #7214) Carried from #7214; source targets main, carried onto dev. Co-authored-by: shirruy --- src/responses/parser.ts | 47 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 6979da5bb4b..37f518cef62 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -68,6 +68,39 @@ function repairJsonObjectEnvelope(text: string): Record | undef return undefined; } +/** + * Repair the observed MiMo tool-call corruption: the model emits its proprietary + * `` closing tags INSIDE the JSON string value of the + * function arguments, and drops the JSON object's closing brace. Strip the + * suffix, restore the brace, and only accept the repair when the result parses + * into an object. + */ +function repairMimoToolCallEnvelope(text: string): Record | undefined { + const suffix = ""; + if (!text.includes(suffix)) return undefined; + const stripped = text.replace(/\s*<\/function><\/tool_call>\s*$/u, "").trimEnd(); + if (!stripped.startsWith("{")) return undefined; + // The observed corruption replaces the JSON string's closing quote and the + // object's closing brace with the tags. Restore what is missing: an object + // that already closes keeps nothing, a closed string value needs only `}`, + // and an unterminated string value needs `"}`. + let candidate: string; + if (stripped.endsWith("}")) { + candidate = stripped; + } else if (stripped.endsWith('"')) { + candidate = `${stripped}}`; + } else { + candidate = `${stripped}"}`; + } + try { + const parsed: unknown = JSON.parse(candidate); + if (isObj(parsed)) return parsed; + } catch { + /* fall through to the tolerated-{} path */ + } + return undefined; +} + /** * Decode a function call's serialized arguments the way history replay tolerates them: empty * or non-JSON text becomes `{}` instead of failing every later turn, and the one bounded @@ -82,12 +115,24 @@ export function functionCallArgumentsFromText(callId: string, rawArguments: stri const parsed: unknown = JSON.parse(rawArgs); if (isObj(parsed)) args = parsed; } catch { + // Corruptions observed on Xiaomi MiMo routed requests, each with a bounded + // repair so a serialized call the model can still see is restored instead + // of replaying {} forever. + const mimoRepaired = repairMimoToolCallEnvelope(rawArgs); + if (mimoRepaired !== undefined) { + args = mimoRepaired; + console.warn(`[parser] function_call ${callId} repaired MiMo tool-call envelope (tags inside string)`); + return args; + } // One observed serialization corruption loses exactly the JSON object's opening // brace; the closed envelope is tight enough to repair back into a call the // routed model can still see and retry, instead of replaying {} forever. const repaired = repairJsonObjectEnvelope(rawArgs); if (repaired === undefined) { - console.warn(`[parser] function_call ${callId} has non-JSON arguments; defaulting to {}`); + console.warn( + `[parser] function_call ${callId} has non-JSON arguments; defaulting to {}. ` + + `len=${rawArgs.length} head=${JSON.stringify(rawArgs.slice(0, 200))} tail=${JSON.stringify(rawArgs.slice(-100))}` + ); } else { args = repaired; console.warn(`[parser] function_call ${callId} arguments lost the JSON opening brace; repaired from history`); From a704902fe7369510e6d88a69c5b7a10a90eb89ee Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 09:59:55 +0900 Subject: [PATCH 5/7] fix(responses): bound MiMo repairs and keep tool arguments out of logs Cover envelope endings, opaque valid JSON, rejected shapes, replay, and content-free diagnostics. Co-authored-by: shirruy --- .../content/docs/reference/proxy-formats.md | 7 +++ scripts/test-layout/layout.json | 2 +- src/responses/parser.ts | 39 +++++--------- structure/transports/responses-wire-shapes.md | 6 ++- tests/fixtures/test-layout-expected.json | 2 +- tests/images/loop-terminal-refusal.test.ts | 1 - .../responses/responses-mimo-envelope.test.ts | 54 +++++++++++++++++++ .../web-search-terminal-refusal.test.ts | 1 - 8 files changed, 82 insertions(+), 30 deletions(-) create mode 100644 tests/responses/responses-mimo-envelope.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 73463369645..631a9355191 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -878,3 +878,10 @@ An ordinary 400 and a 429 are never retried this way, and neither is a 5xx, with exception: a 502 whose body is the exact encrypted tool-output decrypt rejection, sent for a request that carries encrypted tool output, gets the same single resend. A second rejection reaches the client unchanged. If that happens, start a new conversation on the destination provider. + +### Malformed tool-call history + +Routed requests tolerate malformed serialized function arguments as an empty object. +Two bounded history repairs preserve usable arguments: a missing JSON object prefix, +and MiMo's trailing `` envelope with a missing closing quote or brace. +A repair must decode to a JSON object. Valid JSON values containing those tags are preserved. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0494a7918c7..54742cfc6d1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "responses-call-id-linkage.test.ts": "responses", + "explicit": { "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 37f518cef62..e193cdb900e 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -77,34 +77,26 @@ function repairJsonObjectEnvelope(text: string): Record | undef */ function repairMimoToolCallEnvelope(text: string): Record | undefined { const suffix = ""; - if (!text.includes(suffix)) return undefined; - const stripped = text.replace(/\s*<\/function><\/tool_call>\s*$/u, "").trimEnd(); + if (!text.endsWith(suffix)) return undefined; + const stripped = text.slice(0, -suffix.length); if (!stripped.startsWith("{")) return undefined; - // The observed corruption replaces the JSON string's closing quote and the - // object's closing brace with the tags. Restore what is missing: an object - // that already closes keeps nothing, a closed string value needs only `}`, - // and an unterminated string value needs `"}`. - let candidate: string; - if (stripped.endsWith("}")) { - candidate = stripped; - } else if (stripped.endsWith('"')) { - candidate = `${stripped}}`; - } else { - candidate = `${stripped}"}`; - } - try { - const parsed: unknown = JSON.parse(candidate); - if (isObj(parsed)) return parsed; - } catch { - /* fall through to the tolerated-{} path */ + // A trailing brace or quote can itself belong to the unterminated string. + // Try only the three observed envelope endings, accepting an object parse. + for (const candidate of [stripped, `${stripped}}`, `${stripped}"}`]) { + try { + const parsed: unknown = JSON.parse(candidate); + if (isObj(parsed)) return parsed; + } catch { + /* try the next bounded envelope ending */ + } } return undefined; } /** * Decode a function call's serialized arguments the way history replay tolerates them: empty - * or non-JSON text becomes `{}` instead of failing every later turn, and the one bounded - * lost-brace corruption above is repaired. Shared with the direct Chat decoder + * or non-JSON text becomes `{}` instead of failing every later turn, and the bounded + * lost-brace and MiMo closing-tag corruptions above are repaired. Shared with the direct Chat decoder * (`src/protocols/codecs/chat-ir.ts`) so both decode the same call to the same object. */ export function functionCallArgumentsFromText(callId: string, rawArguments: string | undefined): Record { @@ -129,10 +121,7 @@ export function functionCallArgumentsFromText(callId: string, rawArguments: stri // routed model can still see and retry, instead of replaying {} forever. const repaired = repairJsonObjectEnvelope(rawArgs); if (repaired === undefined) { - console.warn( - `[parser] function_call ${callId} has non-JSON arguments; defaulting to {}. ` + - `len=${rawArgs.length} head=${JSON.stringify(rawArgs.slice(0, 200))} tail=${JSON.stringify(rawArgs.slice(-100))}` - ); + console.warn(`[parser] function_call ${callId} has non-JSON arguments; defaulting to {}`); } else { args = repaired; console.warn(`[parser] function_call ${callId} arguments lost the JSON opening brace; repaired from history`); diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index ca1b9e1998f..bfa00dedf98 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -535,7 +535,11 @@ request-log accounting without promoting a truncated repair candidate. Terminal ## Inbound history and code-mode shell wire repairs Inbound function-call history with a missing JSON object prefix is repaired for every provider when -restoring it produces an object; other malformed argument strings replay as `{}` (`src/responses/parser.ts`). +restoring it produces an object. A terminal MiMo `` suffix is removed only +when the remaining text parses as an object unchanged or after restoring a closing brace or quote +and brace. Valid JSON containing these tags stays opaque; unrelated malformed arguments replay +as `{}` without logging their content (`src/responses/parser.ts`). Regression coverage lives in +`tests/responses/responses-mimo-envelope.test.ts`. Function-wrapper restoration supports `default.`-prefixed `exec` and `apply_patch` aliases while retaining the existing ambiguity and foreign-grammar boundaries. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 434437e2b2a..938123fd6f7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,4 @@ -{ "responses-call-id-linkage.test.ts": "responses", +{ "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", diff --git a/tests/images/loop-terminal-refusal.test.ts b/tests/images/loop-terminal-refusal.test.ts index b43a94f4ea7..ad05475a4c1 100644 --- a/tests/images/loop-terminal-refusal.test.ts +++ b/tests/images/loop-terminal-refusal.test.ts @@ -71,4 +71,3 @@ test.each([true, false, undefined])("a refused image_gen iteration stops with te else expect(text).toContain('"reason":"content_filter"'); expect(text).not.toContain("event: response.completed"); }); - diff --git a/tests/responses/responses-mimo-envelope.test.ts b/tests/responses/responses-mimo-envelope.test.ts new file mode 100644 index 00000000000..9f4ccf0971b --- /dev/null +++ b/tests/responses/responses-mimo-envelope.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { functionCallArgumentsFromText, parseRequest } from "../../src/responses/parser"; + +const suffix = ""; + +describe("MiMo function-call history envelope", () => { + test.each([ + ['{"code":"run()' + suffix, { code: "run()" }], + ['{"code":"run()"' + suffix, { code: "run()" }], + ['{"code":"run()"}' + suffix, { code: "run()" }], + ['{"code":"if (ready) {}' + suffix, { code: "if (ready) {}" }], + ['{"code":"run() ' + suffix, { code: "run() " }], + ] as const)("restores a bounded object envelope: %s", (text, expected) => { + expect(functionCallArgumentsFromText("call_mimo", text)).toEqual(expected); + }); + + test("valid JSON preserves embedded tags and escaped quotes", () => { + const expected = { code: `print("${suffix}")` }; + expect(functionCallArgumentsFromText("call_valid", JSON.stringify(expected))).toEqual(expected); + }); + + test.each([ + '{"code":"run()', + '{"code":"run()' + suffix + ' trailing', + 'plain text' + suffix, + '["run()"]' + suffix, + '{"nested":{"code":"run()' + suffix, + ])("unrelated malformed text retains the empty-object fallback: %s", text => { + expect(functionCallArgumentsFromText("call_bad", text)).toEqual({}); + }); + + test("parseRequest restores a call before its tool output", () => { + const parsed = parseRequest({ + model: "mimo-test", + input: [ + { type: "function_call", call_id: "call_history", name: "js", arguments: '{"code":"run()' + suffix }, + { type: "function_call_output", call_id: "call_history", output: "done" }, + ], + }); + const assistant = parsed.context.messages.find(message => message.role === "assistant"); + expect(assistant?.content.find(part => part.type === "toolCall")).toMatchObject({ + type: "toolCall", id: "call_history", name: "js", arguments: { code: "run()" }, + }); + }); + + test("fallback diagnostics never include raw argument content", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + functionCallArgumentsFromText("call_private", '{"private":"argument-content-marker'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls.flat().join(" ")).not.toContain("argument-content-marker"); + } finally { warn.mockRestore(); } + }); +}); diff --git a/tests/web-search/web-search-terminal-refusal.test.ts b/tests/web-search/web-search-terminal-refusal.test.ts index 8f39c61a714..af88991b997 100644 --- a/tests/web-search/web-search-terminal-refusal.test.ts +++ b/tests/web-search/web-search-terminal-refusal.test.ts @@ -58,4 +58,3 @@ test.each([true, false, undefined])("a refused web_search iteration stops with t else expect(text).toContain('"reason":"content_filter"'); expect(text).not.toContain("event: response.completed"); }); - From 7dbb40c90965cc369d41a229d837d7e3a79ad582 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 10:00:48 +0900 Subject: [PATCH 6/7] test(responses): cover MiMo escaped terminal quote repair Covers the outstanding source review case with the bounded parse alternatives. Co-authored-by: shirruy --- tests/responses/responses-mimo-envelope.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/responses/responses-mimo-envelope.test.ts b/tests/responses/responses-mimo-envelope.test.ts index 9f4ccf0971b..1d4646473a9 100644 --- a/tests/responses/responses-mimo-envelope.test.ts +++ b/tests/responses/responses-mimo-envelope.test.ts @@ -14,6 +14,12 @@ describe("MiMo function-call history envelope", () => { expect(functionCallArgumentsFromText("call_mimo", text)).toEqual(expected); }); + test("an escaped terminal quote remains inside the repaired string", () => { + const expected = { code: 'print("ready")"' }; + const text = JSON.stringify(expected).slice(0, -2) + suffix; + expect(functionCallArgumentsFromText("call_escaped", text)).toEqual(expected); + }); + test("valid JSON preserves embedded tags and escaped quotes", () => { const expected = { code: `print("${suffix}")` }; expect(functionCallArgumentsFromText("call_valid", JSON.stringify(expected))).toEqual(expected); From 0f00e6d4b6704c441eedeb0351a3315567fdf026 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 10:02:47 +0900 Subject: [PATCH 7/7] docs(responses): keep combined wire contract within its line budget Consolidate the affected retry and history repair paragraphs without dropping contract detail. Co-authored-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Co-authored-by: shirruy --- structure/transports/responses-wire-shapes.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index bfa00dedf98..fed514833c0 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -2,12 +2,7 @@ ## Client rate-limit retry advice -With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes -`resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. -`src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest -lower bound first; competing original hints are retained under an explicit provider-detail label. -Other explicit verdicts, proxy replay budgets and event ordering are unchanged. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). This creates -no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. +With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes `resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. `src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest lower bound first; competing original hints are retained under an explicit provider-detail label. Other explicit verdicts, proxy replay budgets and event ordering are unchanged. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). This creates no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. ## Adapter incomplete reasons @@ -534,12 +529,7 @@ request-log accounting without promoting a truncated repair candidate. Terminal ## Inbound history and code-mode shell wire repairs -Inbound function-call history with a missing JSON object prefix is repaired for every provider when -restoring it produces an object. A terminal MiMo `` suffix is removed only -when the remaining text parses as an object unchanged or after restoring a closing brace or quote -and brace. Valid JSON containing these tags stays opaque; unrelated malformed arguments replay -as `{}` without logging their content (`src/responses/parser.ts`). Regression coverage lives in -`tests/responses/responses-mimo-envelope.test.ts`. +Inbound function-call history with a missing JSON object prefix is repaired for every provider when restoring it produces an object. A terminal MiMo `` suffix is removed only when the remaining text parses as an object unchanged or after restoring a closing brace or quote and brace. Valid JSON containing these tags stays opaque; unrelated malformed arguments replay as `{}` without logging their content (`src/responses/parser.ts`). Regression coverage lives in `tests/responses/responses-mimo-envelope.test.ts`. Function-wrapper restoration supports `default.`-prefixed `exec` and `apply_patch` aliases while retaining the existing ambiguity and foreign-grammar boundaries.