Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions tests/adapters/bridge-nonstreaming-terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
]) {
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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" });
});
}
});
Loading