mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(gateway): preserve recovered chat streams
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user