diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 67b30c86e..8017a2d3d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -77,7 +77,7 @@ import { import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; -import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; +import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, CodexAccountCooldownError, @@ -233,6 +233,10 @@ import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-t import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; import { responsesJsonToSseStream } from "../responses-json-events"; import { guardTerminalEventStream } from "./terminal-guard"; +import { + emptyCompletionRetryEnabled, + guardEmptyCompletionEventStream, +} from "./empty-completion-guard"; /** * Adapters whose continuation state must survive Codex's store:false requests. @@ -3055,22 +3059,37 @@ async function handleResponsesInner( return wsResponse; } + // Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output + // text and no tool call is a failure the client cannot see — it silently records the turn as + // done. The guard holds pre-content adapter events, suppresses the terminal of an empty + // turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is + // empty or fails. Kill switch: OCX_EMPTY_COMPLETION_RETRY=0. Compaction turns and combo + // attempts keep their own machinery (the combo preflight already handles empty streams). + const emptyCompletionGuardEnabled = + emptyCompletionRetryEnabled() + && !options.comboAttempt + && !routedCompaction; + if (adapter.runTurn) { const runTurnAbort = new AbortController(); linkAbortSignal(runTurnAbort, options.abortSignal); const queue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); - const runTurn = async (): Promise => { + // One attempt of the runTurn transport, against an explicit queue. The + // empty-completion guard re-invokes the IDENTICAL turn (same parsed request, + // same forwarded headers, same abort signal) through a fresh queue, so the + // attempt body must not capture the first queue. + const runTurnAttempt = async (targetQueue: AdapterEventQueue): Promise => { try { noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); await adapter.runTurn?.( parsed, { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal, translatorBudget }, - queue.push, + targetQueue.push, ); } catch (err) { - queue.push({ + targetQueue.push({ type: "error", message: err instanceof Error ? err.message : String(err), }); @@ -3080,9 +3099,20 @@ async function handleResponsesInner( if (!logCtx.conversationId && parsed._cursorConversationId) { logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); } - queue.close(); + targetQueue.close(); } }; + const runTurn = async (): Promise => runTurnAttempt(queue); + // The empty-completion retry re-runs the turn against a fresh queue: the + // first queue is closed once its attempt settles, and pushing into it after + // close is a silent no-op. + const runTurnRetrySource = (): AsyncIterable => { + const retryQueue = createAdapterEventQueue({ + onBacklogExceeded: () => runTurnAbort.abort(), + }); + void runTurnAttempt(retryQueue); + return retryQueue.stream(); + }; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; if (parsed.stream) { @@ -3098,8 +3128,16 @@ async function handleResponsesInner( } eventSource = preflight.stream; } + const guardedSource = emptyCompletionGuardEnabled + ? guardEmptyCompletionEventStream({ + firstEvents: eventSource, + // Identical-turn retry: same parsed request, same headers, same + // signal — run the adapter transport again against a fresh queue. + continuation: runTurnRetrySource, + }) + : eventSource; const sseStream = bridgeToResponsesSSE( - eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { runTurnAbort.abort(); queue.close(); @@ -3142,7 +3180,17 @@ async function handleResponsesInner( } await runTurn(); - const events = await queue.collect(); + const firstAttemptEvents = await queue.collect(); + let events: AdapterEvent[]; + if (emptyCompletionGuardEnabled) { + events = []; + for await (const event of guardEmptyCompletionEventStream({ + firstEvents: (async function* () { yield* firstAttemptEvents; })(), + continuation: runTurnRetrySource, + })) events.push(event); + } else { + events = firstAttemptEvents; + } if (options.comboAttempt) { const firstMeaningful = events.find(event => event.type !== "heartbeat"); if (!firstMeaningful || firstMeaningful.type === "error") { @@ -3848,9 +3896,19 @@ async function handleResponsesInner( continuation: fetchTerminalGuardContinuation, }) : initialEventStream; + // The empty-completion guard sits OUTSIDE the terminal guard: a completed + // turn with no text and no tool call is retried with the IDENTICAL request + // (fetchTerminalGuardContinuation(parsed) replays the cached byte-identical + // request — same body, same headers, same signal). + const guardedEventStream = emptyCompletionGuardEnabled + ? guardEmptyCompletionEventStream({ + firstEvents: eventStream, + continuation: () => fetchTerminalGuardContinuation(parsed), + }) + : eventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; const sseStream = bridgeToResponsesSSE( - eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, { translatorBudget, @@ -3895,17 +3953,27 @@ async function handleResponsesInner( let events: AdapterEvent[]; try { const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget); + let guardedEvents: AdapterEvent[]; if (terminalGuardEnabled) { - events = []; + guardedEvents = []; for await (const event of guardTerminalEventStream({ parsed, firstEvents: (async function* () { yield* initialEvents; })(), adapterName: activeAdapter.name, maxAutoContinuations: 1, continuation: fetchTerminalGuardContinuation, + })) guardedEvents.push(event); + } else { + guardedEvents = initialEvents; + } + if (emptyCompletionGuardEnabled) { + events = []; + for await (const event of guardEmptyCompletionEventStream({ + firstEvents: (async function* () { yield* guardedEvents; })(), + continuation: () => fetchTerminalGuardContinuation(parsed), })) events.push(event); } else { - events = initialEvents; + events = guardedEvents; } } finally { cleanupUpstreamAbort(); diff --git a/src/server/responses/empty-completion-guard.ts b/src/server/responses/empty-completion-guard.ts new file mode 100644 index 000000000..d113a3af1 --- /dev/null +++ b/src/server/responses/empty-completion-guard.ts @@ -0,0 +1,239 @@ +import type { AdapterEvent, OcxUsage } from "../../types"; + +/** + * Empty-completion guard for Responses turns (port of codex-router's + * empty-completion-guard + single retry, PR #145). + * + * Failure mode: the upstream answers 200 and completes the turn but never + * produced output text or a tool call (a reasoning-only stream that ends with + * nothing is the canonical shape). The client has no code path for "the model + * said nothing", so it silently records the turn as done — the "random stop" + * nobody can explain. The guard holds pre-content events (reasoning deltas + * are deliberately NOT content), and when a terminal event arrives with no + * content it suppresses the terminal and retries the IDENTICAL turn once + * (same request bytes, same headers). If the retry is also empty — or fails + * upstream — the client sees a stated failure instead of a second silent + * success. + * + * Kill switch on the pattern of the router's CODEX_ROUTER_EMPTY_COMPLETION_RETRY: + * setting OCX_EMPTY_COMPLETION_RETRY=0 turns the guard off entirely and + * restores the previous relay behavior exactly. + */ +export const EMPTY_COMPLETION_RETRY_ENV = "OCX_EMPTY_COMPLETION_RETRY"; + +export function emptyCompletionRetryEnabled( + env: Record = process.env, +): boolean { + // "0" disables; anything else (including unset) enables — same contract as + // the router's CODEX_ROUTER_EMPTY_COMPLETION_RETRY. + const configured = env[EMPTY_COMPLETION_RETRY_ENV]; + return configured !== "0"; +} + +/** Surfaced when the single retry was also empty or failed upstream. */ +export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed"; + +/** + * Terminal stop reasons the bridge renders as a visible `response.incomplete` + * (max_tokens / content_filter). Those are already a stated failure, not the + * silent empty success this guard exists to catch, and retrying the identical + * request would burn tokens for the same truncated result. + */ +const VISIBLE_INCOMPLETE_STOP_REASONS: Record = { + max_tokens: true, + content_filter: true, +}; + +/** + * Content means something the client can act on: output text or a tool call + * (web-search cells included). Reasoning deltas are deliberately not content — + * a turn that streams only reasoning and then completes with nothing is + * exactly the empty completion this guard exists to catch. Empty text deltas + * (some batch adapters always carry `""`) are not content either. + */ +export function isContentEvent(event: AdapterEvent): boolean { + switch (event.type) { + case "text_delta": + return event.text.length > 0; + case "tool_call_start": + case "tool_call_delta": + case "tool_call_end": + case "web_search_call_begin": + case "web_search_call_end": + return true; + default: + return false; + } +} + +export function emptyCompletionRetryFailedEvent( + usage?: OcxUsage, + retryFailedUpstream = false, +): Extract { + return { + type: "error", + status: 502, + errorType: "upstream_error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + message: retryFailedUpstream + ? "The model returned an empty completion and the retry failed upstream." + : "The model returned an empty completion. opencodex retried once and the completion was empty again.", + ...(usage ? { usage } : {}), + }; +} + +/** + * Sum two usage snapshots. Same semantics as terminal-guard's mergeUsage and + * request-log's aggregateAttemptUsage: token totals add across the attempts; + * `estimated` wins when either attempt only estimated. + */ +export function mergeUsage( + first: OcxUsage | undefined, + second: OcxUsage | undefined, +): OcxUsage | undefined { + if (!first) return second; + if (!second) return first; + const sumOptional = (key: keyof OcxUsage): number | undefined => { + const left = first[key]; + const right = second[key]; + return typeof left === "number" || typeof right === "number" + ? (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0) + : undefined; + }; + const cachedInputTokens = sumOptional("cachedInputTokens"); + const cacheReadInputTokens = sumOptional("cacheReadInputTokens"); + const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens"); + const reasoningOutputTokens = sumOptional("reasoningOutputTokens"); + const inputTokens = first.inputTokens + second.inputTokens; + const outputTokens = first.outputTokens + second.outputTokens; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}), + ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), + ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(first.estimated || second.estimated ? { estimated: true } : {}), + }; +} + +export interface EmptyCompletionGuardOptions { + firstEvents: AsyncIterable; + /** + * Re-run the IDENTICAL turn: same request body, same headers, same signal. + * Receives no arguments — the request must not be modified between attempts. + */ + continuation: () => AsyncIterable | Promise>; + /** How many times an empty completion is retried; default 1 (the router's single retry). */ + maxRetries?: number; +} + +/** + * Watch an adapter event stream for the empty-completion failure mode. Events + * are held until the turn produces content or ends: reasoning and other + * pre-content events stay buffered (released in order on first content), the + * terminal is withheld, and an empty terminal triggers one identical-turn + * retry through `continuation`. Usage is merged across attempts so the bridge + * and request log meter the whole turn, not just the attempt that succeeded. + * + * Heartbeats always pass through untouched: they feed the bridge's stall + * watchdog, so holding them behind the content gate would trip false + * upstream_stall_timeout failures on slow reasoning-only turns. + */ +export async function* guardEmptyCompletionEventStream( + options: EmptyCompletionGuardOptions, +): AsyncGenerator { + const maxRetries = Math.max(0, Math.floor(options.maxRetries ?? 1)); + let source = options.firstEvents; + let held: AdapterEvent[] = []; + let sawContent = false; + let retries = 0; + let usage: OcxUsage | undefined; + + const withUsage = (event: AdapterEvent & { usage?: OcxUsage }): AdapterEvent => { + const merged = mergeUsage(usage, event.usage); + return merged ? { ...event, ...(merged ? { usage: merged } : {}) } : event; + }; + const releaseHeld = (): AdapterEvent[] => { + const released = held; + held = []; + return released; + }; + + while (true) { + let terminalSeen = false; + for await (const event of source) { + if (event.type === "heartbeat") { + yield event; + continue; + } + if (sawContent) { + // Buffered content is already flowing; everything downstream passes + // through. The final done carries usage merged across every attempt. + yield event.type === "done" ? withUsage(event) : event; + continue; + } + if (isContentEvent(event)) { + sawContent = true; + yield* releaseHeld(); + yield event; + continue; + } + if (event.type === "done") { + usage = mergeUsage(usage, event.usage); + if (VISIBLE_INCOMPLETE_STOP_REASONS[event.stopReason ?? ""]) { + // Rendered as response.incomplete: a stated failure, not the silent + // empty success this guard exists to catch. + yield* releaseHeld(); + yield { ...event, ...(usage ? { usage } : {}) }; + return; + } + if (retries < maxRetries) { + // Suppress the terminal: the client must never see a completed event + // for a turn that produced nothing. Retry the identical turn. + retries += 1; + try { + source = await options.continuation(); + } catch { + yield emptyCompletionRetryFailedEvent(usage); + return; + } + terminalSeen = true; + break; + } + // The retry was also empty: a stated failure, not a second silent + // success. + yield emptyCompletionRetryFailedEvent(usage); + return; + } + if (event.type === "error") { + if (retries > 0 && event.status !== 499) { + // The retry failed upstream. Its body cannot reach the client (the + // 200 head went out with the first attempt), so state the failure in + // the stream's own error framing — same move as the router's + // empty_completion_retry_failed. Client cancels (499) pass through. + yield emptyCompletionRetryFailedEvent(mergeUsage(usage, event.usage), true); + return; + } + yield* releaseHeld(); + yield withUsage(event); + return; + } + if (event.type === "incomplete") { + // A structured incomplete is already a visible failure; never convert + // it into an empty completion. + yield* releaseHeld(); + yield withUsage(event); + return; + } + held.push(event); + } + if (!terminalSeen) { + // The source ended without a terminal event (truncated stream). Release + // what was held so the bridge can mark the stream incomplete. + yield* releaseHeld(); + return; + } + } +} diff --git a/tests/empty-completion-guard.test.ts b/tests/empty-completion-guard.test.ts new file mode 100644 index 000000000..26a709c59 --- /dev/null +++ b/tests/empty-completion-guard.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, test } from "bun:test"; +import { + EMPTY_COMPLETION_RETRY_ENV, + EMPTY_COMPLETION_RETRY_FAILED_CODE, + emptyCompletionRetryEnabled, + guardEmptyCompletionEventStream, + isContentEvent, +} from "../src/server/responses/empty-completion-guard"; +import type { AdapterEvent } from "../src/types"; + +function collect(source: AsyncIterable): Promise { + const events: AdapterEvent[] = []; + return (async () => { + for await (const event of source) events.push(event); + return events; + })(); +} + +function eventsOf(...items: AdapterEvent[]): AsyncIterable { + return (async function* () { yield* items; })(); +} + +describe("empty-completion guard content classification", () => { + test("output text is content", () => { + expect(isContentEvent({ type: "text_delta", text: "hello" })).toBe(true); + }); + + test("tool calls are content", () => { + expect(isContentEvent({ type: "tool_call_start", id: "c1", name: "run" })).toBe(true); + expect(isContentEvent({ type: "tool_call_delta", arguments: "{}" })).toBe(true); + expect(isContentEvent({ type: "tool_call_end" })).toBe(true); + expect(isContentEvent({ type: "web_search_call_begin", id: "w1" })).toBe(true); + expect(isContentEvent({ type: "web_search_call_end", id: "w1", queries: [] })).toBe(true); + }); + + test("reasoning alone is NOT content", () => { + expect(isContentEvent({ type: "thinking_delta", thinking: "let me think" })).toBe(false); + expect(isContentEvent({ type: "thinking_signature", signature: "sig" })).toBe(false); + expect(isContentEvent({ type: "redacted_thinking", data: "blob" })).toBe(false); + expect(isContentEvent({ type: "reasoning_raw_delta", text: "raw" })).toBe(false); + }); + + test("empty text deltas are not content", () => { + expect(isContentEvent({ type: "text_delta", text: "" })).toBe(false); + }); + + test("heartbeats and internal boundaries are not content", () => { + expect(isContentEvent({ type: "heartbeat" })).toBe(false); + expect(isContentEvent({ type: "assistant_boundary" })).toBe(false); + }); +}); + +describe("empty-completion guard kill switch", () => { + test("enabled by default, disabled by OCX_EMPTY_COMPLETION_RETRY=0", () => { + expect(emptyCompletionRetryEnabled({})).toBe(true); + expect(emptyCompletionRetryEnabled({ [EMPTY_COMPLETION_RETRY_ENV]: "1" })).toBe(true); + expect(emptyCompletionRetryEnabled({ [EMPTY_COMPLETION_RETRY_ENV]: "0" })).toBe(false); + }); +}); + +describe("empty-completion guard retry", () => { + test("buffers pre-content events and releases them on first content", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "thinking..." }, + { type: "text_delta", text: "answer" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 2 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toEqual([ + { type: "thinking_delta", thinking: "thinking..." }, + { type: "text_delta", text: "answer" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 2 } }, + ]); + }); + + test("a reasoning-only terminal turn is retried once and the identical-turn retry succeeds", async () => { + let continuations = 0; + const retryParsedSeen: string[] = []; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "first attempt" }, + { type: "reasoning_raw_delta", text: "raw" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 0 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf( + { type: "thinking_delta", thinking: "second attempt" }, + { type: "text_delta", text: "finally an answer" }, + { type: "done", usage: { inputTokens: 20, outputTokens: 5 } }, + ); + }, + })); + + expect(continuations).toBe(1); + expect(retryParsedSeen).toEqual([]); + // The first attempt's buffered reasoning is released in order, then the + // retry's reasoning, then the content, then the merged-usage terminal. + expect(events).toEqual([ + { type: "thinking_delta", thinking: "first attempt" }, + { type: "reasoning_raw_delta", text: "raw" }, + { type: "thinking_delta", thinking: "second attempt" }, + { type: "text_delta", text: "finally an answer" }, + { type: "done", usage: { inputTokens: 30, outputTokens: 5, totalTokens: 35 } }, + ]); + }); + + test("usage is merged across attempts", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "done", usage: { inputTokens: 100, outputTokens: 0, cachedInputTokens: 40 } }, + ), + continuation: () => eventsOf( + { type: "tool_call_start", id: "c1", name: "run" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 200, outputTokens: 30, reasoningOutputTokens: 12 } }, + ), + })); + + const done = events.at(-1) as Extract; + expect(done.usage).toEqual({ + inputTokens: 300, + outputTokens: 30, + totalTokens: 330, + cachedInputTokens: 40, + reasoningOutputTokens: 12, + }); + }); + + test("both attempts empty surfaces empty_completion_retry_failed", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "first" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 0 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf( + { type: "thinking_delta", thinking: "second" }, + { type: "done", usage: { inputTokens: 12, outputTokens: 0 } }, + ); + }, + })); + + expect(continuations).toBe(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + status: 502, + errorType: "upstream_error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + usage: { inputTokens: 22, outputTokens: 0, totalTokens: 22 }, + }); + // The silent completed event never reaches the client. + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test("a failed retry surfaces empty_completion_retry_failed", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "first" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 0 } }, + ), + continuation: () => eventsOf( + { type: "error", status: 502, message: "upstream died", usage: { inputTokens: 8, outputTokens: 0 } }, + ), + })); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + status: 502, + errorType: "upstream_error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + usage: { inputTokens: 18, outputTokens: 0, totalTokens: 18 }, + }); + }); + + test("a continuation that throws surfaces empty_completion_retry_failed", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf({ type: "done" }), + continuation: () => { + throw new Error("continuation exploded"); + }, + })); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + }); + }); + + test("max_tokens completions pass through without retrying", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "done", stopReason: "max_tokens", usage: { inputTokens: 5, outputTokens: 1 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toEqual([ + { type: "thinking_delta", thinking: "..." }, + { type: "done", stopReason: "max_tokens", usage: { inputTokens: 5, outputTokens: 1 } }, + ]); + }); + + test("a tool-call-only turn is content and is not retried", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "tool_call_start", id: "c1", name: "exec_command" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events.map(event => event.type)).toEqual([ + "tool_call_start", "tool_call_delta", "tool_call_end", "done", + ]); + }); + + test("first-attempt errors and incompletes pass through untouched", async () => { + let continuations = 0; + const incomplete = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "incomplete", reason: "content_filter", retryable: false }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + expect(continuations).toBe(0); + expect(incomplete).toEqual([ + { type: "thinking_delta", thinking: "..." }, + { type: "incomplete", reason: "content_filter", retryable: false }, + ]); + + const error = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "error", status: 401, message: "bad key" }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + expect(continuations).toBe(0); + expect(error).toEqual([{ type: "error", status: 401, message: "bad key" }]); + }); + + test("maxRetries 0 (kill-switch behavior) surfaces the failure immediately", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "done" }, + ), + maxRetries: 0, + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: EMPTY_COMPLETION_RETRY_FAILED_CODE }); + }); + + test("heartbeats pass through immediately even before content", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "heartbeat" }, + { type: "thinking_delta", thinking: "..." }, + { type: "done" }, + ), + continuation: () => { + continuations += 1; + return eventsOf({ type: "text_delta", text: "ok" }, { type: "done" }); + }, + })); + + expect(continuations).toBe(1); + expect(events.map(event => event.type)).toEqual([ + "heartbeat", "thinking_delta", "text_delta", "done", + ]); + }); + + test("a truncated first source (no terminal) releases held events and ends", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf({ type: "thinking_delta", thinking: "..." }), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toEqual([{ type: "thinking_delta", thinking: "..." }]); + }); +});