From 002b3e0c16403bf2e1b6b647c6c003db4e899331 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 16 Aug 2026 02:12:11 -0700 Subject: [PATCH] fix(gateway): preserve recovered chat streams --- src/gateway/openai-http.test.ts | 36 +++++++++++++++++++++++++++++++++ src/gateway/openai-http.ts | 31 +++++++++++++++++++--------- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index d22e3e03e008..1dc52ca92314 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -2279,6 +2279,42 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { expect(finishChoice?.finish_reason).toBe("stop"); }); + it("completes a stream when a failed attempt is superseded by fallback success", async () => { + agentCommandMock.mockClear(); + agentCommandMock.mockImplementationOnce((async (opts: unknown) => { + const runId = (opts as { runId?: string }).runId; + if (!runId) { + throw new Error("expected a streaming chat-completion run ID"); + } + emitAgentEvent({ + runId, + stream: "lifecycle", + data: { phase: "error", error: "raw primary provider failure" }, + }); + emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); + return { payloads: [{ text: "fallback recovered" }] }; + }) as never); + + const stream = await createOpenAiChatClient(enabledPort).chat.completions.create({ + model: "openclaw", + messages: [{ role: "user", content: "Recover with the fallback." }], + stream: true, + }); + const content: string[] = []; + const finishReasons: Array = []; + for await (const chunk of stream) { + for (const choice of chunk.choices) { + if (typeof choice.delta.content === "string") { + content.push(choice.delta.content); + } + finishReasons.push(choice.finish_reason); + } + } + + expect(content.join("")).toBe("fallback recovered"); + expect(finishReasons.at(-1)).toBe("stop"); + }); + it.each([ { name: "resolved", reject: false }, { name: "rejected", reject: true }, diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index f558189a7ef1..bca307eac038 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -1196,10 +1196,16 @@ export async function handleOpenAiHttpRequest( let resultResolved = false; let closed = false; let observedTerminalLifecycle = false; - let terminalStreamError: { message: string; type: string; code?: string } | undefined; + let terminalProtocolError: { message: string; type: string; code?: string } | undefined; + let terminalLifecycleError: { message: string; type: string; code?: string } | undefined; + // A lifecycle error can describe one failed fallback attempt; only a later + // lifecycle end recovers it. Append-only protocol errors remain terminal. + let lifecycleErrorRecovered = false; let terminalLifecyclePhase: "end" | "error" = "end"; let unsubscribe = () => {}; let stopWatchingDisconnect = () => {}; + const readActiveStreamError = () => + terminalProtocolError ?? (lifecycleErrorRecovered ? undefined : terminalLifecycleError); const finishStreamWithError = ( error: { message: string; type: string; code?: string }, @@ -1243,8 +1249,9 @@ export async function handleOpenAiHttpRequest( maybeFinalize(); return; } - if (terminalStreamError) { - finishStreamWithError(terminalStreamError); + const streamError = readActiveStreamError(); + if (streamError) { + finishStreamWithError(streamError); return; } closed = true; @@ -1311,7 +1318,7 @@ export async function handleOpenAiHttpRequest( !toolChoiceConstraint && !text.startsWith(streamedAssistantText) ) { - terminalStreamError ??= { + terminalProtocolError ??= { message: "Assistant output cannot be represented as an append-only response stream.", type: "api_error", }; @@ -1359,7 +1366,8 @@ export async function handleOpenAiHttpRequest( if (phase === "end" || phase === "error") { observedTerminalLifecycle = true; if (phase === "error" && terminalLifecyclePhase !== "error") { - terminalStreamError ??= { + lifecycleErrorRecovered = false; + terminalLifecycleError ??= { message: normalizeOptionalString(evt.data?.error) ?? "Agent run failed", type: "api_error", }; @@ -1369,6 +1377,9 @@ export async function handleOpenAiHttpRequest( data: evt.data, }); const outcome = mergeAgentRunTerminalOutcome(terminalOutcome, incomingOutcome); + if (phase === "end" && terminalLifecycleError && outcome.reason === "completed") { + lifecycleErrorRecovered = true; + } if (outcome.reason === "completed") { requestFinalize("stop", outcome); } else { @@ -1411,8 +1422,9 @@ export async function handleOpenAiHttpRequest( finalUsage = resolveChatCompletionUsage(result); const outcome = resolveOpenAiHttpAgentRunTerminalOutcome(result, terminalOutcome); terminalOutcome = outcome; - if (terminalStreamError) { - finishStreamWithError(terminalStreamError); + const streamError = readActiveStreamError(); + if (streamError) { + finishStreamWithError(streamError); return; } if (outcome.reason !== "completed") { @@ -1506,8 +1518,9 @@ export async function handleOpenAiHttpRequest( finishStreamWithError(mapped.error); return; } - if (terminalStreamError) { - finishStreamWithError(terminalStreamError); + const streamError = terminalProtocolError ?? terminalLifecycleError; + if (streamError) { + finishStreamWithError(streamError); return; } // Runs without a producer-owned terminal retain the visible-error fallback.