fix(gateway): preserve recovered chat streams

This commit is contained in:
Dallin Romney
2026-08-16 02:12:11 -07:00
parent 40e64971db
commit 002b3e0c16
2 changed files with 58 additions and 9 deletions
+36
View File
@@ -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<string | null> = [];
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 },
+22 -9
View File
@@ -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.