From f04685876d942aef0fa1d251207232dba50e4741 Mon Sep 17 00:00:00 2001 From: AK Date: Wed, 7 Oct 2026 17:52:03 +0900 Subject: [PATCH 1/4] fix(bridge): end the turn on a terminal upstream refusal instead of a retryable incomplete When an upstream refuses a turn outright, the Anthropic adapter (stop_reason "refusal" / "content_filter") and the Kiro adapter (CONTENT_FILTERED / GUARDRAIL_INTERVENED) emit an `incomplete` event with reason "content_filter" and `retryable: false` (#4312), and the Responses bridge relayed it as `response.incomplete` with `incomplete_details.retryable: false`. codex-rs never reads that field: it maps every `response.incomplete` to a retryable stream error, so the same refused request was sent once and then re-sent through the whole stream retry budget (five more times). It only ends a turn on `response.failed` whose `error.code` is a terminal-refusal code. - `bridgeToResponsesSSE` gains `terminalRefusalAsFailed`. With it set, an `incomplete` that is a content filter with an explicit `retryable: false` is delivered as `response.failed` carrying `invalid_request_error` / `invalid_prompt`, `retryable: false`, the turn's usage and the adapter's `end_turn`. Open message, reasoning, tool-call and web-search items are closed exactly as on the incomplete path. The payload lives in `src/bridge/terminal-refusal.ts`. - Every client on the Responses inbound wire gets this, not only Codex: the Grok surface, direct SDK callers and the WebSocket transport share the same bridge call. The opt-in uses the same test as `enforceDeclaredToolNames` in adapter delivery, runTurn delivery, and the web-search and image sidecar loops. The chat and Anthropic inbound wires still receive `response.incomplete`, which their encoders translate into `finish_reason: "content_filter"` and `stop_reason: "refusal"`, and the buffered `buildResponseJSON` path is unchanged. - Inside a combo, a refusal with no output never reaches the client as a stream: the combo preflight converts the zero-output `response.failed` into an HTTP 400 JSON error with the same `invalid_prompt` code, and `comboFailureDecision` stops the chain instead of sending the refused prompt to the next target. The preflight itself is unchanged; what is new is that a refusal now reaches it as a failure, and a test pins the result. - A `response.failed` built from an adapter error now carries `retryable: false` for every terminal-refusal code, not only cyber policy. Not covered here: refusals that reach the bridge as a `done` without a verdict stay `response.incomplete`. That includes the proxy's own policy refusal for an xAI 403 (`src/server/responses/policy-refusal.ts`), an OpenAI-chat upstream ending with `finish_reason: "content_filter"`, and a Google `SAFETY` finish. Each needs its adapter to decide whether the stop is permanent before the bridge can treat it as terminal. Refs #4312 Co-Authored-By: Claude Opus 5.5 --- scripts/test-layout/layout.json | 6 +- src/adapters/anthropic.ts | 17 +- src/bridge/sse.ts | 26 +- src/bridge/terminal-refusal.ts | 49 ++++ src/images/loop.ts | 3 + 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 | 3 + structure/adapters/registry.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/transports/responses.md | 2 +- .../adapters/bridge-terminal-refusal.test.ts | 231 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 6 +- tests/routing/combo-terminal-refusal.test.ts | 71 ++++++ tests/server/server-kiro-refusal-e2e.test.ts | 81 ++++++ .../server-terminal-refusal-wiring.test.ts | 190 ++++++++++++++ 17 files changed, 674 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/routing/combo-terminal-refusal.test.ts create mode 100644 tests/server/server-terminal-refusal-wiring.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..cda42d4f837 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -259,7 +259,7 @@ "bridge-raw-reasoning-hidden.test.ts": "adapters", "bridge-reasoning-replay-batch.test.ts": "adapters", "bridge-stall.test.ts": "adapters", - "bridge-terminal-singleness.test.ts": "adapters", + "bridge-terminal-refusal.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", "build-desktop-icon-set.test.ts": "ci-workflows", @@ -698,7 +698,7 @@ "combo-child-headers.test.ts": "routing", "combo-last-resort.test.ts": "codex-integration", "combo-management-api.test.ts": "routing", - "combo-stream-preflight.test.ts": "routing", + "combo-stream-preflight.test.ts": "routing", "combo-terminal-refusal.test.ts": "routing", "combo-workspace-data.test.ts": "gui", "combo-workspace-jev-decision.test.ts": "gui", "combos.test.ts": "codex-integration", @@ -1592,7 +1592,7 @@ "server-jev-combo-e2e.test.ts": "server", "combo-requested-effort.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", - "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", + "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", "server-terminal-refusal-wiring.test.ts": "server", "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 0211aef03fc..187a400d1fb 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -312,11 +312,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( @@ -1222,8 +1223,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; @@ -1418,7 +1419,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 e34c3bebdf6..5f4659e8f8c 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -12,7 +12,7 @@ import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, - isCyberPolicyCode, + isTerminalRefusalCode, type OcxErrorPayload, } from "../lib/errors"; import { redactSecretString } from "../lib/redact"; @@ -50,6 +50,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`; @@ -120,6 +121,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>; /** @@ -1287,6 +1294,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), @@ -1325,7 +1345,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 } : {}), }, @@ -1366,7 +1386,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..66e50cc4e28 --- /dev/null +++ b/src/bridge/terminal-refusal.ts @@ -0,0 +1,49 @@ +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."; +} + +/** + * `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 (event.reason !== "content_filter" || event.retryable !== false) 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 516b3cbc6a1..136f609eb10 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -353,6 +353,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; } /** @@ -1086,6 +1088,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { const served = transportState.replayOAuthCredentialSnapshot; if (route.providerName === "kiro" && response.status === "completed" && served @@ -633,6 +634,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 8720e657411..8b20081d642 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -297,6 +297,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; @@ -1005,6 +1007,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 0a065aec8ba..c84c3422c61 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -587,7 +587,7 @@ keeps only allowlisted transport verdict codes and the explicit local `spend_led `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. +deserialize them as required fields. 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`). 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. Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or 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 210221f4d50..07369b3829c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -223,7 +223,7 @@ "bridge-legacy-shell-normalization.test.ts": "adapters", "bridge-lifecycle.test.ts": "adapters", "bridge-live-delivery.test.ts": "server", "bridge-nonstreaming-terminal.test.ts": "adapters", "bridge-raw-reasoning-hidden.test.ts": "adapters", "bridge-reasoning-replay-batch.test.ts": "adapters", - "bridge-stall.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", + "bridge-stall.test.ts": "adapters", "bridge-terminal-refusal.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "ci-gui-typecheck-gate.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", @@ -507,7 +507,7 @@ "combo-permanent-failure-cooldown.test.ts": "codex-integration", "combo-reset-window-model-scope.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", "combo-last-resort.test.ts": "codex-integration", "combo-management-api.test.ts": "routing", - "combo-stream-preflight.test.ts": "routing", "combo-workspace-data.test.ts": "gui", + "combo-stream-preflight.test.ts": "routing", "combo-terminal-refusal.test.ts": "routing", "combo-workspace-data.test.ts": "gui", "combo-workspace-jev-decision.test.ts": "gui", "combos.test.ts": "codex-integration", "command-code-error-finish.test.ts": "providers", "command-code-efforts.test.ts": "providers", "command-code-project-context.test.ts": "providers", "command-code-provider.test.ts": "providers", @@ -1044,7 +1044,7 @@ "server-jev-combo-e2e.test.ts": "server", "server-kiro-completion-e2e.test.ts": "server", "combo-requested-effort.test.ts": "server", "server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server", - "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", + "server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server", "server-terminal-refusal-wiring.test.ts": "server", "server-live-frame-log.test.ts": "server", "server-live-realtime-fixtures.test.ts": "server", "server-live.test.ts": "server", "server-loopback-host-gate.test.ts": "server", "server-management-auth.test.ts": "server", 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"); + }); +}); From 4c23fea23f58db873913d57170988d9a658632cb Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 18:22:21 +0900 Subject: [PATCH 2/4] fix(sidecars): end the turn when a refused iteration carries a sidecar call The web-search and image sidecar loops decided to loop from the sidecar calls alone. A complete web_search or image_gen call followed by a non-retryable content-filter refusal ran the call and asked the model again with the refused context, which re-sent the refusal several times per turn (#7148). Both loops now finalize on such an iteration, matching run-turn-loop, which already stops on any incomplete. --- scripts/test-layout/layout.json | 1 + src/bridge/terminal-refusal.ts | 12 ++- src/images/loop.ts | 6 +- src/web-search/loop.ts | 6 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/images/loop-terminal-refusal.test.ts | 73 +++++++++++++++++++ .../web-search-terminal-refusal.test.ts | 60 +++++++++++++++ 8 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 tests/images/loop-terminal-refusal.test.ts create mode 100644 tests/web-search/web-search-terminal-refusal.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cda42d4f837..af204da59b8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -260,6 +260,7 @@ "bridge-reasoning-replay-batch.test.ts": "adapters", "bridge-stall.test.ts": "adapters", "bridge-terminal-refusal.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", + "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", "build-desktop-icon-set.test.ts": "ci-workflows", diff --git a/src/bridge/terminal-refusal.ts b/src/bridge/terminal-refusal.ts index 66e50cc4e28..e7586e26cd2 100644 --- a/src/bridge/terminal-refusal.ts +++ b/src/bridge/terminal-refusal.ts @@ -25,6 +25,16 @@ function terminalRefusalMessage(detail: string | undefined): string { + "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 @@ -39,7 +49,7 @@ function terminalRefusalMessage(detail: string | undefined): string { export function terminalRefusalFailure( event: Extract, ): TerminalRefusalFailure | undefined { - if (event.reason !== "content_filter" || event.retryable !== false) return undefined; + if (!isTerminalRefusalIncomplete(event)) return undefined; const error: OcxErrorPayload = { type: "invalid_request_error", code: TERMINAL_REFUSAL_FAILED_CODE, diff --git a/src/images/loop.ts b/src/images/loop.ts index 136f609eb10..c1676945d94 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"; @@ -865,7 +866,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--) { diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 8b20081d642..368f33edd9c 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 { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; import { runAnthropicWebSearch } from "./anthropic-executor"; import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor"; @@ -916,7 +917,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. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index c84c3422c61..81bfb0f9172 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -587,7 +587,7 @@ keeps only allowlisted transport verdict codes and the explicit local `spend_led `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. 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`). 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. +deserialize them as required fields. 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. Active-turn admission owns workflow admission, so both remain held until a streaming body finishes or diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 07369b3829c..838bb7cb0c9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -224,6 +224,7 @@ "bridge-live-delivery.test.ts": "server", "bridge-nonstreaming-terminal.test.ts": "adapters", "bridge-raw-reasoning-hidden.test.ts": "adapters", "bridge-reasoning-replay-batch.test.ts": "adapters", "bridge-stall.test.ts": "adapters", "bridge-terminal-refusal.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", + "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "ci-gui-typecheck-gate.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", diff --git a/tests/images/loop-terminal-refusal.test.ts b/tests/images/loop-terminal-refusal.test.ts new file mode 100644 index 00000000000..d693a406f54 --- /dev/null +++ b/tests/images/loop-terminal-refusal.test.ts @@ -0,0 +1,73 @@ +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("a refused iteration with an image_gen call neither fulfills nor re-asks", async () => { + 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: true, + }); + const text = await response.text(); + + expect(routedRequests).toBe(1); + expect(mediaFetches).toBe(0); + expect(text).toContain("event: response.failed"); + expect(text).toContain('"code":"invalid_prompt"'); + expect(text).not.toContain("event: response.completed"); +}); + 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..2ec9a23e575 --- /dev/null +++ b/tests/web-search/web-search-terminal-refusal.test.ts @@ -0,0 +1,60 @@ +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("a refused iteration with a web_search call neither searches nor re-asks", async () => { + 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: true, + }); + const text = await response.text(); + + expect(routedSends).toBe(1); + expect(searchFetches).toBe(0); + expect(text).toContain("event: response.failed"); + expect(text).toContain('"code":"invalid_prompt"'); + expect(text).not.toContain("event: response.completed"); +}); + From cd769525f79dfc71c69d8325b69807536d20d3c0 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 18:35:09 +0900 Subject: [PATCH 3/4] test(sidecars): cover refusal stops on every inbound wire Exercise the refusal loop guards with Responses failure projection enabled, disabled, and omitted. Document the streaming terminal refusal contract and the preserved buffered and alternate-wire shapes. --- .../src/content/docs/reference/proxy-formats.md | 15 ++++++++++++++- tests/images/loop-terminal-refusal.test.ts | 9 +++++---- .../web-search-terminal-refusal.test.ts | 9 +++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 0168480b8e6..030c76874ea 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -294,7 +294,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. 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 diff --git a/tests/images/loop-terminal-refusal.test.ts b/tests/images/loop-terminal-refusal.test.ts index d693a406f54..b43a94f4ea7 100644 --- a/tests/images/loop-terminal-refusal.test.ts +++ b/tests/images/loop-terminal-refusal.test.ts @@ -23,7 +23,7 @@ afterEach(() => { if (home) removeTreeWithRetry(home); }); -test("a refused iteration with an image_gen call neither fulfills nor re-asks", async () => { +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; @@ -60,14 +60,15 @@ test("a refused iteration with an image_gen call neither fulfills nor re-asks", adapter, plan, incomingMeta: { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, - terminalRefusalAsFailed: true, + terminalRefusalAsFailed, }); const text = await response.text(); expect(routedRequests).toBe(1); expect(mediaFetches).toBe(0); - expect(text).toContain("event: response.failed"); - expect(text).toContain('"code":"invalid_prompt"'); + 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/web-search/web-search-terminal-refusal.test.ts b/tests/web-search/web-search-terminal-refusal.test.ts index 2ec9a23e575..8f39c61a714 100644 --- a/tests/web-search/web-search-terminal-refusal.test.ts +++ b/tests/web-search/web-search-terminal-refusal.test.ts @@ -13,7 +13,7 @@ import { createTestTranslatorBudget } from "../helpers/translator-budget"; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); -test("a refused iteration with a web_search call neither searches nor re-asks", async () => { +test.each([true, false, undefined])("a refused web_search iteration stops with terminalRefusalAsFailed=%s", async terminalRefusalAsFailed => { let searchFetches = 0; globalThis.fetch = (async () => { searchFetches++; @@ -47,14 +47,15 @@ test("a refused iteration with a web_search call neither searches nor re-asks", selectedForwardHeaders: new Headers(), settings: { model: "exa-fixture-model", reasoning: "low", timeoutMs: 30_000 }, maxSearches: 2, - terminalRefusalAsFailed: true, + terminalRefusalAsFailed, }); const text = await response.text(); expect(routedSends).toBe(1); expect(searchFetches).toBe(0); - expect(text).toContain("event: response.failed"); - expect(text).toContain('"code":"invalid_prompt"'); + 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 480e29f861fd021ee4c1a37af996fd79292b3c64 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 18:37:09 +0900 Subject: [PATCH 4/4] docs(bridge): isolate the terminal refusal contract from reason normalization --- structure/transports/responses.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 81bfb0f9172..574bea4a71b 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -587,14 +587,14 @@ keeps only allowlisted transport verdict codes and the explicit local `spend_led `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. 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. +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.