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" }); + }); } });