From aaed68a815420cd05c37e1338ee1dfbdf2d9597f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 15:54:50 +0900 Subject: [PATCH] fix(bridge): fail an in-flight search at a truncated terminal [skip ci] Carry #4381 from aad1d75bcfe8c80efdff5e8e6d9ce3dff16b79c0 onto the current dev tip. When an adapter ends a turn with a recognized truncated stop reason, the streaming path already keeps an open function, custom, or tool-search call incomplete, and the error and explicit-incomplete terminals already close an in-flight provider web search as failed. The plain truncated `done` path did not: it closed the search as `completed`, so a client showed a finished search for a turn the provider cut short before any result arrived. That search takes the same failed status now. The classifier and bridge terminal tests gain the exact `max_output_tokens` stop-reason value, which the vocabulary and mapping cases previously omitted even though `src/responses/truncated-stop-reason.ts` classifies it. Without that case, dropping it from the classifier would silently let the bridge emit `response.completed` with an incomplete call or search attached. This is the remainder of #4312's tool-finalization scope after #4341 landed the open function, custom, and tool-search call handling upstream; nothing from that PR is duplicated here. Verification on this carry: bun test tests/adapters/bridge-nonstreaming-terminal.test.ts, bun run typecheck, bun run structure:check, bun run privacy:scan. Local full suite: NOT RUN. Hosted CI on the lane tip is the suite proof. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- src/bridge.ts | 4 +++- structure/adapters/registry.md | 2 ++ .../bridge-nonstreaming-terminal.test.ts | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/bridge.ts b/src/bridge.ts index 612b60ba36..f0cf2f0a2c 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1293,7 +1293,9 @@ export function bridgeToResponsesSSE( if (isTruncatedStopReason(event.stopReason)) failCurrentToolCall(); else closeCurrentToolCall(); } - if (currentWebSearch) closeCurrentWebSearch("completed", []); + // A search still in flight when upstream truncates never returned results, so it + // takes the same "failed" status as the error/incomplete terminals below. + if (currentWebSearch) closeCurrentWebSearch(isTruncatedStopReason(event.stopReason) ? "failed" : "completed", []); releasePendingWebSources(); // Redacted-only turns (or hidden thinking without a trailing signature event) still // need their envelope-only reasoning item so the blocks replay next turn. diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 8c23e38eef..38ae9d79bb 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -81,6 +81,8 @@ Listener startup diagnostics follow [the runtime lifecycle contract](../runtime. The bridge keeps an open function, custom, or tool-search call incomplete when an adapter ends with a recognized truncated stop reason. Streaming emits no argument/input completion frame for that open call, and buffered JSON applies the same status. A call already closed by its own tool-call end retains its completed state. The response remains incomplete, partial output is preserved, and truncated compaction never replaces history. +A provider web search still in flight at that truncated terminal is finalized as `failed`, the same status it already receives from the error and explicit-incomplete terminals. It never returned results, so reporting it as `completed` would leave the client showing a finished search for a turn the provider cut short. + Chat helper admission in `src/server/responses/core.ts` follows the [deferred stored-main contract](../providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. diff --git a/tests/adapters/bridge-nonstreaming-terminal.test.ts b/tests/adapters/bridge-nonstreaming-terminal.test.ts index de6bfb37c9..2367950586 100644 --- a/tests/adapters/bridge-nonstreaming-terminal.test.ts +++ b/tests/adapters/bridge-nonstreaming-terminal.test.ts @@ -268,6 +268,7 @@ describe("truncated-stop-reason classifier", () => { "length", "content-filter", // Command Code / AI SDK "pause_turn", // Anthropic: turn needs continuation "refusal", "model_context_window_exceeded", // Anthropic + "max_output_tokens", // Anthropic: same spelling as the mapped reason "MAX_TOKENS", "SAFETY", "MALFORMED_FUNCTION_CALL", "IMAGE_SAFETY", "LANGUAGE", // Gemini "Safety", "safety", // mixed case must not slip through ]) { @@ -285,6 +286,7 @@ describe("truncated-stop-reason classifier", () => { test("truncation maps to the right incomplete_details reason", () => { expect(truncationReasonFor("length")).toBe("max_output_tokens"); expect(truncationReasonFor("model_context_window_exceeded")).toBe("max_output_tokens"); + expect(truncationReasonFor("max_output_tokens")).toBe("max_output_tokens"); expect(truncationReasonFor("refusal")).toBe("content_filter"); expect(truncationReasonFor("SAFETY")).toBe("content_filter"); expect(truncationReasonFor("end_turn")).toBeUndefined(); @@ -318,6 +320,7 @@ describe("truncated done preserves open tool integrity (#4312)", () => { ["content_filter", "content_filter"], ["max_tokens", "max_output_tokens"], ["length", "max_output_tokens"], + ["max_output_tokens", "max_output_tokens"], ] as const; for (const [stopReason, reason] of cases) { for (const kind of ["function_call", "custom_tool_call", "tool_search_call"] as const) { @@ -376,5 +379,24 @@ describe("truncated done preserves open tool integrity (#4312)", () => { expect(text).toContain("event: response.function_call_arguments.done"); expect(text).toContain('"arguments":"{\\"arg\\":\\"complete\\"}","status":"completed"'); }); + + test(`${stopReason}: a search still in flight is failed, not completed`, async () => { + // The provider cut the turn short, so the search never returned results. Reporting it as + // completed would leave the client showing a finished search for a truncated turn. + const text = await sseText([ + { type: "web_search_call_begin", id: "search_in_flight" }, + { type: "done", stopReason }, + ]); + const item = text.split("\n\n") + .flatMap(frame => { + const data = frame.split("\n").find(line => line.startsWith("data: {"))?.slice(6); + return data ? [JSON.parse(data)] : []; + }) + .find(frame => frame.type === "response.output_item.done" + && frame.item?.type === "web_search_call")?.item; + + expect(terminalEventNames(text)).toEqual(["response.incomplete"]); + expect(item).toMatchObject({ type: "web_search_call", status: "failed" }); + }); } });