refactor(gateway): trim responses terminal coverage

This commit is contained in:
Dallin Romney
2026-08-13 21:13:47 +08:00
parent 05e64b615c
commit 3ec0070a17
6 changed files with 369 additions and 1494 deletions
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
resolveOpenAiHttpAgentRunTerminalOutcome,
resolveOpenAiHttpResultText,
} from "./openai-http-terminal-outcome.js";
describe("OpenAI HTTP terminal outcome", () => {
it.each([
{
name: "accepts a visible fallback after a failed attempt",
result: {
payloads: [
{ text: "private provider failure", isError: true },
{ text: "fallback recovered" },
],
},
reason: "completed",
},
{
name: "accepts a media-only fallback after a failed attempt",
result: {
payloads: [
{ text: "private provider failure", isError: true },
{ mediaUrl: "https://example.invalid/recovered.png" },
],
},
reason: "completed",
},
{
name: "retains failure when only transient notices follow",
result: {
payloads: [
{ text: "private provider failure", isError: true },
{ text: "commentary", isCommentary: true },
{ text: "compaction", isCompactionNotice: true },
{ text: "fallback", isFallbackNotice: true },
{ text: "reasoning", isReasoningSnapshot: true },
{ text: "status", isStatusNotice: true },
{ text: "hidden", visible: false },
],
},
reason: "failed",
},
{
name: "retains failure when only whitespace follows",
result: {
payloads: [{ text: "private provider failure", isError: true }, { text: " \t\n " }],
},
reason: "failed",
},
{
name: "keeps replay-invalid success",
result: { meta: { replayInvalid: true } },
reason: "completed",
},
{
name: "classifies bare abort as cancellation",
result: { meta: { aborted: true } },
reason: "aborted",
},
{
name: "preserves hard timeout attribution",
result: { meta: { timeoutPhase: "provider" } },
reason: "hard_timeout",
},
])("$name", ({ result, reason }) => {
expect(resolveOpenAiHttpAgentRunTerminalOutcome(result)).toMatchObject({ reason });
});
it("filters historical error text from recovered output", () => {
expect(
resolveOpenAiHttpResultText({
payloads: [
{ text: "private provider failure", isError: true },
{ text: "fallback recovered" },
],
}),
).toBe("fallback recovered");
});
});
+44 -43
View File
@@ -5,30 +5,51 @@ import {
type AgentRunTerminalOutcome,
} from "../agents/agent-run-terminal-outcome.js";
import { hasVisibleAgentPayload } from "../agents/embedded-agent-runner/message-visibility.js";
import { isReplyPayloadStatusNotice, type ReplyPayload } from "../auto-reply/reply-payload.js";
type LifecycleData = NonNullable<
Parameters<typeof buildAgentRunTerminalOutcomeFromLifecycleEvent>[0]["data"]
>;
type OpenAiHttpAgentResult = {
payloads?: Array<{
isError?: boolean;
isCommentary?: boolean;
isCompactionNotice?: boolean;
isFallbackNotice?: boolean;
isReasoningSnapshot?: boolean;
isStatusNotice?: boolean;
text?: string;
visible?: boolean;
}>;
meta?: {
aborted?: boolean;
error?: unknown;
stopReason?: unknown;
livenessState?: unknown;
timeoutPhase?: unknown;
providerStarted?: unknown;
startedAt?: unknown;
endedAt?: unknown;
};
payloads?: ReplyPayload[];
meta?: LifecycleData;
};
function isTerminalPayload(payload: ReplyPayload): boolean {
if (payload.isError === true) {
return true;
}
if (
payload.isCommentary === true ||
payload.isReasoningSnapshot === true ||
isReplyPayloadStatusNotice(payload) ||
(payload as ReplyPayload & { visible?: unknown }).visible === false
) {
return false;
}
return hasVisibleAgentPayload(
{ payloads: [payload] },
{
includeErrorPayloads: false,
includeReasoningPayloads: false,
includeSilentReplyPayloads: false,
},
);
}
/** Return model-visible result text without leaking historical error payloads. */
export function resolveOpenAiHttpResultText(result: unknown): string {
const payloads = (result as OpenAiHttpAgentResult | null | undefined)?.payloads;
return Array.isArray(payloads)
? payloads
.filter((payload) => payload.isError !== true)
.map((payload) => (typeof payload.text === "string" ? payload.text : ""))
.filter(Boolean)
.join("\n\n")
: "";
}
/** Preserve real provider failures even when the agent resolves its result. */
export function resolveOpenAiHttpAgentRunTerminalOutcome(
result: unknown,
@@ -38,32 +59,12 @@ export function resolveOpenAiHttpAgentRunTerminalOutcome(
const meta = agentResult?.meta;
// Completed tool calls can intentionally make a successful turn unsafe to
// replay. Replay safety alone is not a provider or terminal-run failure.
// Recovery may retain a failed attempt before its final visible reply.
// Only the last visible/error payload owns the HTTP terminal result.
const terminalPayload = agentResult?.payloads?.findLast(
(payload) =>
payload.isError === true ||
(payload.isCommentary !== true &&
payload.isCompactionNotice !== true &&
payload.isFallbackNotice !== true &&
payload.isReasoningSnapshot !== true &&
payload.isStatusNotice !== true &&
payload.visible !== false &&
hasVisibleAgentPayload(
{ payloads: [payload] },
{
includeErrorPayloads: false,
includeReasoningPayloads: false,
includeSilentReplyPayloads: false,
},
)),
);
const resultFailed = meta?.error != null || terminalPayload?.isError === true;
// Only the last real visible/error payload owns recovered fallback state.
const terminalPayload = agentResult?.payloads?.findLast(isTerminalPayload);
return mergeAgentRunTerminalOutcome(
previous,
buildAgentRunTerminalOutcomeFromLifecycleEvent({
phase: resultFailed ? "error" : "end",
phase: meta?.error != null || terminalPayload?.isError === true ? "error" : "end",
data: meta,
}),
);
+14 -362
View File
@@ -24,7 +24,6 @@ import {
isGatewaySubordinateWorkAdmissionClosed,
} from "../process/gateway-work-admission.js";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveOpenAiHttpAgentRunTerminalOutcome } from "./openai-http-terminal-outcome.js";
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
import {
agentCommandMock,
@@ -37,8 +36,6 @@ import {
installGatewayTestHooks({ scope: "suite" });
const agentCommand = agentCommandMock;
let startGatewayServer: typeof import("./server.js").startGatewayServer;
let enabledServer: Awaited<ReturnType<typeof startServer>>;
let enabledPort: number;
@@ -137,36 +134,6 @@ function parseSseDataLines(text: string): string[] {
.map((line) => line.slice("data: ".length));
}
const PRESERVED_STREAM_FAILURE_CASES = [
{
name: "an exhausted fallback result",
text: "Terminal tool summary",
lifecycle: { fallbackExhaustedFailure: true },
error: {
kind: "incomplete_turn",
message: "raw exhausted provider detail should stay private",
fallbackSafe: true,
terminalPresentation: true,
},
},
{
name: "a non-replayable error result",
text: "Command may have changed state",
lifecycle: { replayInvalid: true },
error: {
kind: "incomplete_turn",
message: "raw non-replayable provider detail should stay private",
fallbackSafe: false,
},
},
] as const;
const PRESERVED_STREAM_LIFECYCLE_CASES = [
{ label: "after an error lifecycle", emitError: true, emitEnd: false },
{ label: "without an error lifecycle", emitError: false, emitEnd: false },
{ label: "after a superseded error lifecycle", emitError: true, emitEnd: true },
] as const;
type FirstAgentCommandOptions = {
clientTools?: Array<{
function?: {
@@ -2030,167 +1997,25 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(agentCommandMock).toHaveBeenCalledTimes(1);
});
it("classifies a bare aborted result as cancellation rather than timeout", () => {
expect(resolveOpenAiHttpAgentRunTerminalOutcome({ meta: { aborted: true } })).toMatchObject({
reason: "aborted",
status: "error",
stopReason: "aborted",
});
});
it("completes a successful non-replayable tool turn", async () => {
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "FAKE_PLUGIN_OK fake_plugin_tool_17" }],
meta: {
agentMeta: { usage: { input: 128, output: 40, total: 168 } },
livenessState: "working",
replayInvalid: true,
stopReason: "stop",
},
} as never);
const res = await postChatCompletions(enabledPort, {
model: "openclaw",
messages: [{ role: "user", content: "tool search qa check target=fake_plugin_tool_17" }],
});
const body = await res.text();
expect(res.status, body).toBe(200);
const response = JSON.parse(body) as {
choices?: Array<{ finish_reason?: string; message?: { content?: string } }>;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
};
expect(response.choices?.[0]?.message?.content).toBe("FAKE_PLUGIN_OK fake_plugin_tool_17");
expect(response.choices?.[0]?.finish_reason).toBe("stop");
expect(response.usage).toEqual({
prompt_tokens: 128,
completion_tokens: 40,
total_tokens: 168,
});
expect(agentCommand).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
"completes a recovered chat after an earlier error payload with stream=%s",
"fails a resolved terminal error without exposing provider details with stream=%s",
async (stream) => {
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [
{ text: "Historical failed provider attempt", isError: true },
{ text: "fallback recovered" },
{},
],
const privateFailure = "private terminal payload";
const privateError = "private provider detail";
agentCommandMock.mockClear();
agentCommandMock.mockResolvedValueOnce({
payloads: [{ text: privateFailure, isError: true }],
meta: {
error: { kind: "incomplete_turn", message: privateError },
agentMeta: { usage: { input: 7, output: 3, total: 10 } },
},
} as never);
const res = await postChatCompletions(enabledPort, {
stream,
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
const body = await res.text();
expect(res.status, body).toBe(200);
if (stream) {
const data = parseSseDataLines(body);
const chunks = data
.filter((line) => line !== "[DONE]")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(chunks.filter((chunk) => "error" in chunk)).toHaveLength(0);
expect(
chunks
.flatMap(
(chunk) =>
(chunk.choices as Array<{ finish_reason?: string | null }> | undefined) ?? [],
)
.filter((choice) => choice.finish_reason === "stop"),
).toHaveLength(1);
expect(data.at(-1)).toBe("[DONE]");
expect(body).toContain("fallback recovered");
} else {
const response = JSON.parse(body) as {
choices?: Array<{ finish_reason?: string; message?: { content?: string } }>;
};
expect(response.choices?.[0]?.message?.content).toContain("fallback recovered");
expect(response.choices?.[0]?.finish_reason).toBe("stop");
}
expect(agentCommand).toHaveBeenCalledTimes(1);
},
);
it.each([false, true])(
"completes a recovered media-only chat after an earlier error payload with stream=%s",
async (stream) => {
const privateFailure = "Historical private provider failure";
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [
{ text: privateFailure, isError: true },
{
mediaUrl: "https://example.invalid/recovered-image.png",
mediaUrls: ["https://example.invalid/recovered-document.pdf"],
},
],
} as never);
const res = await postChatCompletions(enabledPort, {
stream,
model: "openclaw",
messages: [{ role: "user", content: "recover the generated attachment" }],
});
const body = await res.text();
expect(res.status, body).toBe(200);
if (stream) {
const data = parseSseDataLines(body);
const chunks = data
.filter((line) => line !== "[DONE]")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(chunks.filter((chunk) => "error" in chunk)).toHaveLength(0);
expect(
chunks
.flatMap(
(chunk) =>
(chunk.choices as Array<{ finish_reason?: string | null }> | undefined) ?? [],
)
.filter((choice) => choice.finish_reason === "stop"),
).toHaveLength(1);
expect(data.filter((line) => line === "[DONE]")).toHaveLength(1);
expect(data.at(-1)).toBe("[DONE]");
} else {
const response = JSON.parse(body) as {
choices?: Array<{ finish_reason?: string; message?: { content?: string } }>;
};
expect(response.choices?.[0]?.finish_reason).toBe("stop");
}
expect(body).not.toContain("api_error");
expect(body).not.toContain(privateFailure);
expect(agentCommand).toHaveBeenCalledTimes(1);
},
);
it.each([false, true])(
"preserves a failed chat when only transient notices follow with stream=%s",
async (stream) => {
const privateFailure = "Historical private provider failure";
const notices = [
{ text: "Private commentary notice", isCommentary: true },
{ text: "Private compaction notice", isCompactionNotice: true },
{ text: "Private fallback notice", isFallbackNotice: true },
{ text: "Private reasoning snapshot", isReasoningSnapshot: true },
{ text: "Private status notice", isStatusNotice: true },
{ text: "Private hidden notice", visible: false },
];
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: privateFailure, isError: true }, ...notices],
} as never);
const res = await postChatCompletions(enabledPort, {
stream,
model: "openclaw",
messages: [{ role: "user", content: "finish the failed request" }],
...(stream ? { stream_options: { include_usage: true } } : {}),
});
const body = await res.text();
@@ -2203,15 +2028,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(chunks.filter((chunk) => "error" in chunk)).toEqual([
{ error: { message: "internal error", type: "api_error" } },
]);
expect(
chunks
.flatMap(
(chunk) =>
(chunk.choices as Array<{ finish_reason?: string | null }> | undefined) ?? [],
)
.filter((choice) => choice.finish_reason === "stop"),
).toHaveLength(0);
expect(data.filter((line) => line === "[DONE]")).toHaveLength(1);
expect(chunks.filter((chunk) => "usage" in chunk)).toHaveLength(1);
expect(data.at(-1)).toBe("[DONE]");
} else {
expect(res.status, body).toBe(502);
@@ -2219,176 +2036,11 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
error: { message: "internal error", type: "api_error" },
});
}
expect(body).not.toContain(privateFailure);
for (const notice of notices) {
expect(body).not.toContain(notice.text);
}
expect(agentCommand).toHaveBeenCalledTimes(1);
expect(body).not.toContain(privateError);
expect(agentCommandMock).toHaveBeenCalledTimes(1);
},
);
it.each([false, true])(
"preserves a failed chat when its final payload is whitespace with stream=%s",
async (stream) => {
const privateFailure = "Historical private provider failure";
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: privateFailure, isError: true }, { text: " \t\n " }],
} as never);
const res = await postChatCompletions(enabledPort, {
stream,
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
const body = await res.text();
if (stream) {
expect(res.status, body).toBe(200);
const data = parseSseDataLines(body);
const chunks = data
.filter((line) => line !== "[DONE]")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(chunks.filter((chunk) => "error" in chunk)).toEqual([
{ error: { message: "internal error", type: "api_error" } },
]);
expect(
chunks
.flatMap(
(chunk) =>
(chunk.choices as Array<{ finish_reason?: string | null }> | undefined) ?? [],
)
.filter((choice) => choice.finish_reason === "stop"),
).toHaveLength(0);
expect(data.filter((line) => line === "[DONE]")).toHaveLength(1);
expect(data.at(-1)).toBe("[DONE]");
} else {
expect(res.status, body).toBe(502);
expect(JSON.parse(body)).toEqual({
error: { message: "internal error", type: "api_error" },
});
}
expect(body).not.toContain(privateFailure);
expect(agentCommand).toHaveBeenCalledTimes(1);
},
);
it.each(PRESERVED_STREAM_FAILURE_CASES)(
"fails non-stream chat completions for $name without exposing provider details",
async ({ text, error }) => {
agentCommand.mockClear();
agentCommand.mockResolvedValueOnce({
payloads: [{ text, isError: true }],
meta: { error },
} as never);
const res = await postChatCompletions(enabledPort, {
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(502);
const body = await res.text();
expect(JSON.parse(body)).toEqual({
error: { message: "internal error", type: "api_error" },
});
expect(body).not.toContain(text);
expect(body).not.toContain(error.message);
expect(agentCommand).toHaveBeenCalledTimes(1);
},
);
it.each(
PRESERVED_STREAM_FAILURE_CASES.flatMap((failure) =>
PRESERVED_STREAM_LIFECYCLE_CASES.flatMap((lifecycleCase) =>
[false, true].map((includeUsage) => ({
name: failure.name,
text: failure.text,
lifecycle: failure.lifecycle,
error: failure.error,
includeUsage,
label: `${failure.name} ${includeUsage ? "with" : "without"} streamed usage`,
lifecycleLabel: lifecycleCase.label,
emitError: lifecycleCase.emitError,
emitEnd: lifecycleCase.emitEnd,
})),
),
),
)(
"fails the chat stream when $label resolves $lifecycleLabel",
async ({ text, lifecycle, error, includeUsage, emitError, emitEnd }) => {
const idleRootCount = getActiveGatewayRootWorkCount();
agentCommand.mockClear();
agentCommand.mockImplementationOnce((async (opts: unknown) => {
const runId = (opts as { runId?: string }).runId;
if (!runId) {
throw new Error("expected a streaming chat completion run ID");
}
if (emitError) {
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "error", error: text, ...lifecycle },
});
}
if (emitEnd) {
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } });
}
return {
payloads: [{ text, isError: true }],
meta: {
stopReason: "end_turn",
error,
agentMeta: { usage: { input: 7, output: 3, total: 10 } },
},
};
}) as never);
const res = await postChatCompletions(enabledPort, {
stream: true,
...(includeUsage ? { stream_options: { include_usage: true } } : {}),
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const body = await res.text();
const data = parseSseDataLines(body);
expect(data.filter((line) => line === "[DONE]")).toHaveLength(1);
expect(data.at(-1)).toBe("[DONE]");
const chunks = data
.filter((line) => line !== "[DONE]")
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(chunks.filter((chunk) => "error" in chunk)).toEqual([
{ error: { message: "internal error", type: "api_error" } },
]);
const finishReasons = chunks.flatMap(
(chunk) => (chunk.choices as Array<{ finish_reason?: string | null }> | undefined) ?? [],
);
expect(finishReasons.some((choice) => choice.finish_reason === "stop")).toBe(false);
const usageChunks = chunks.filter((chunk) => "usage" in chunk);
if (includeUsage) {
expect(usageChunks).toHaveLength(1);
expect(usageChunks[0]?.choices).toEqual([]);
expect(usageChunks[0]?.usage).toEqual({
prompt_tokens: 7,
completion_tokens: 3,
total_tokens: 10,
});
} else {
expect(usageChunks).toHaveLength(0);
}
expect(body).not.toContain(error.message);
expect(body).not.toContain(text);
expect(agentCommand).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(getActiveGatewayRootWorkCount()).toBe(idleRootCount));
},
);
it("forwards response_format into streamParams", async () => {
const port = enabledPort;
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
+30 -86
View File
@@ -72,7 +72,10 @@ import {
import { normalizeInputHostnameAllowlist } from "./input-allowlist.js";
import { resolveAgentRunUsage } from "./openai-agent-run-usage.js";
import { resolveOpenAiCompatError, validateOpenAiSamplingParams } from "./openai-compat-errors.js";
import { resolveOpenAiHttpAgentRunTerminalOutcome } from "./openai-http-terminal-outcome.js";
import {
resolveOpenAiHttpAgentRunTerminalOutcome,
resolveOpenAiHttpResultText,
} from "./openai-http-terminal-outcome.js";
import {
isToolChoiceConstraintSatisfied,
resolveUnsatisfiedToolChoiceMessage,
@@ -742,33 +745,6 @@ function coerceRequest(val: unknown): OpenAiChatCompletionRequest {
return val as OpenAiChatCompletionRequest;
}
function resolveAgentResponseText(result: unknown): string {
const payloads = (result as { payloads?: Array<{ isError?: boolean; text?: string }> } | null)
?.payloads;
if (!Array.isArray(payloads) || payloads.length === 0) {
return "No response from OpenClaw.";
}
const content = payloads
.filter((payload) => payload.isError !== true)
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n");
return content || "No response from OpenClaw.";
}
function resolveAgentResponseCommentary(result: unknown): string {
const payloads = (result as { payloads?: Array<{ isError?: boolean; text?: string }> } | null)
?.payloads;
if (!Array.isArray(payloads) || payloads.length === 0) {
return "";
}
return payloads
.filter((payload) => payload.isError !== true)
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n");
}
type PendingToolCall = {
id?: unknown;
name?: unknown;
@@ -1138,7 +1114,7 @@ export async function handleOpenAiHttpRequest(
}
if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) {
const commentary = resolveAgentResponseCommentary(result);
const commentary = resolveOpenAiHttpResultText(result);
sendJson(res, 200, {
id: runId,
object: "chat.completion",
@@ -1163,7 +1139,7 @@ export async function handleOpenAiHttpRequest(
});
return true;
}
const content = resolveAgentResponseText(result);
const content = resolveOpenAiHttpResultText(result) || "No response from OpenClaw.";
sendJson(res, 200, {
id: runId,
@@ -1213,15 +1189,9 @@ export async function handleOpenAiHttpRequest(
let bufferedAssistantContent = "";
let bufferedReplaceableAssistantContent = "";
let finalUsage: OpenAiChatCompletionsUsage | undefined;
type StreamFinalization =
| {
status: "completed";
finishReason: "stop" | "tool_calls";
outcome?: AgentRunTerminalOutcome;
}
| { status: "failed"; outcome: AgentRunTerminalOutcome };
let finalizeRequested: StreamFinalization | null = null;
const readFinalization = (): StreamFinalization | null => finalizeRequested;
let finalizeRequested = false;
let finalizeFinishReason: "stop" | "tool_calls" = "stop";
let terminalOutcome: AgentRunTerminalOutcome | undefined;
let finalizeScheduled = false;
let resultResolved = false;
let closed = false;
@@ -1231,14 +1201,17 @@ export async function handleOpenAiHttpRequest(
let unsubscribe = () => {};
let stopWatchingDisconnect = () => {};
const finalizeFailedStream = (error: { message: string; type: string; code?: string }) => {
const finishStreamWithError = (
error: { message: string; type: string; code?: string },
includeUsage = false,
) => {
if (closed) {
return;
}
closed = true;
stopWatchingDisconnect();
unsubscribe();
if (streamIncludeUsage && finalUsage) {
if (includeUsage && streamIncludeUsage && finalUsage) {
writeUsageChunk(res, { runId, model, usage: finalUsage });
}
writeSse(res, { error });
@@ -1251,8 +1224,8 @@ export async function handleOpenAiHttpRequest(
return;
}
// Resolved preserved errors are failures, not successful assistant stops.
if (finalizeRequested.status === "failed") {
finalizeFailedStream({ message: "internal error", type: "api_error" });
if (terminalOutcome?.reason && terminalOutcome.reason !== "completed") {
finishStreamWithError({ message: "internal error", type: "api_error" }, true);
return;
}
if (streamIncludeUsage && !finalUsage) {
@@ -1265,12 +1238,7 @@ export async function handleOpenAiHttpRequest(
if (closed) {
return;
}
const finalization = finalizeRequested;
if (!finalization) {
finalizeScheduled = false;
return;
}
if (finalization.status === "failed") {
if (terminalOutcome?.reason && terminalOutcome.reason !== "completed") {
finalizeScheduled = false;
maybeFinalize();
return;
@@ -1283,7 +1251,7 @@ export async function handleOpenAiHttpRequest(
stopWatchingDisconnect();
unsubscribe();
if (!wroteStopChunk) {
writeAssistantFinishChunk(res, { runId, model, finishReason: finalization.finishReason });
writeAssistantFinishChunk(res, { runId, model, finishReason: finalizeFinishReason });
wroteStopChunk = true;
}
if (streamIncludeUsage && finalUsage) {
@@ -1299,22 +1267,17 @@ export async function handleOpenAiHttpRequest(
outcome?: AgentRunTerminalOutcome,
) => {
// Failed attempts remain provisional until a recovered fallback settles.
const previous = readFinalization();
const preservedFinishReason =
previous?.status === "completed" && previous.finishReason === "tool_calls"
? "tool_calls"
: finishReason;
const preservedOutcome = outcome ?? previous?.outcome;
finalizeRequested = {
status: "completed",
finishReason: preservedFinishReason,
...(preservedOutcome ? { outcome: preservedOutcome } : {}),
};
if (finishReason === "tool_calls") {
finalizeFinishReason = finishReason;
}
terminalOutcome = outcome ?? terminalOutcome;
finalizeRequested = true;
maybeFinalize();
};
const requestFailedStream = (outcome: AgentRunTerminalOutcome) => {
finalizeRequested = { status: "failed", outcome };
terminalOutcome = outcome;
finalizeRequested = true;
maybeFinalize();
};
@@ -1405,7 +1368,7 @@ export async function handleOpenAiHttpRequest(
phase,
data: evt.data,
});
const outcome = mergeAgentRunTerminalOutcome(finalizeRequested?.outcome, incomingOutcome);
const outcome = mergeAgentRunTerminalOutcome(terminalOutcome, incomingOutcome);
if (outcome.reason === "completed") {
requestFinalize("stop", outcome);
} else {
@@ -1415,18 +1378,6 @@ export async function handleOpenAiHttpRequest(
}
});
const finishStreamWithError = (error: { message: string; type: string; code?: string }) => {
if (closed) {
return;
}
closed = true;
stopWatchingDisconnect();
unsubscribe();
writeSse(res, { error });
writeDone(res);
res.end();
};
// Agent cleanup and deferred SSE delivery have independent lifetimes;
// shutdown must wait until both have settled, whichever finishes last.
const releaseAgentRootWork = retainGatewayRootWorkAdmissionContinuation();
@@ -1458,18 +1409,12 @@ export async function handleOpenAiHttpRequest(
}
finalUsage = resolveChatCompletionUsage(result);
const resultOutcome = resolveOpenAiHttpAgentRunTerminalOutcome(result);
if (resultOutcome.reason !== "completed") {
requestFailedStream(
mergeAgentRunTerminalOutcome(readFinalization()?.outcome, resultOutcome),
);
return;
}
const outcome = resolveOpenAiHttpAgentRunTerminalOutcome(result, terminalOutcome);
terminalOutcome = outcome;
if (terminalStreamError) {
finishStreamWithError(terminalStreamError);
return;
}
const outcome = resolveOpenAiHttpAgentRunTerminalOutcome(result, readFinalization()?.outcome);
if (outcome.reason !== "completed") {
requestFailedStream(outcome);
return;
@@ -1503,7 +1448,7 @@ export async function handleOpenAiHttpRequest(
if (!sawAssistantDelta) {
const commentary =
bufferedAssistantContent ||
resolveAgentResponseCommentary(result) ||
resolveOpenAiHttpResultText(result) ||
bufferedReplaceableAssistantContent;
if (commentary) {
sawAssistantDelta = true;
@@ -1530,9 +1475,8 @@ export async function handleOpenAiHttpRequest(
}
const content =
resolveAgentResponseCommentary(result) ||
resolveOpenAiHttpResultText(result) ||
bufferedReplaceableAssistantContent ||
resolveAgentResponseText(result) ||
"No response from OpenClaw.";
sawAssistantDelta = true;
File diff suppressed because it is too large Load Diff
+29 -68
View File
@@ -84,7 +84,10 @@ import {
} from "./open-responses.schema.js";
import { resolveAgentRunUsage } from "./openai-agent-run-usage.js";
import { resolveOpenAiCompatError } from "./openai-compat-errors.js";
import { resolveOpenAiHttpAgentRunTerminalOutcome } from "./openai-http-terminal-outcome.js";
import {
resolveOpenAiHttpAgentRunTerminalOutcome,
resolveOpenAiHttpResultText,
} from "./openai-http-terminal-outcome.js";
import {
isToolChoiceConstraintSatisfied,
resolveUnsatisfiedToolChoiceMessage,
@@ -750,11 +753,6 @@ export async function handleOpenResponsesHttpRequest(
return true;
}
const payloads = (
result as {
payloads?: Array<{ isError?: boolean; text?: string }>;
} | null
)?.payloads;
const meta = (result as { meta?: unknown } | null)?.meta;
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
@@ -787,14 +785,7 @@ export async function handleOpenResponsesHttpRequest(
// pending call was emitted, so multi-tool turns lost every call but
// the leading one.
if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) {
const assistantText =
Array.isArray(payloads) && payloads.length > 0
? payloads
.filter((replyPayload) => replyPayload.isError !== true)
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n")
: "";
const assistantText = resolveOpenAiHttpResultText(result);
const output: OutputItem[] = [];
if (assistantText) {
@@ -833,14 +824,7 @@ export async function handleOpenResponsesHttpRequest(
return true;
}
const content =
Array.isArray(payloads) && payloads.length > 0
? payloads
.filter((replyPayload) => replyPayload.isError !== true)
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n")
: "No response from OpenClaw.";
const content = resolveOpenAiHttpResultText(result) || "No response from OpenClaw.";
const response = createResponseResource({
id: responseId,
@@ -921,11 +905,8 @@ export async function handleOpenResponsesHttpRequest(
let unsubscribe = () => {};
let stopWatchingDisconnect = () => {};
let finalUsage: Usage | undefined;
type StreamFinalization =
| { status: "completed"; text: string; outcome?: AgentRunTerminalOutcome }
| { status: "failed"; outcome: AgentRunTerminalOutcome };
let finalizeRequested: StreamFinalization | null = null;
const readFinalization = (): StreamFinalization | null => finalizeRequested;
let finalizeRequested: { status: "completed" | "failed"; text: string } | null = null;
let terminalOutcome: AgentRunTerminalOutcome | undefined;
let finalizeScheduled = false;
let terminalLifecyclePhase: "end" | "error" = "end";
let terminalStreamError: string | undefined;
@@ -960,15 +941,14 @@ export async function handleOpenResponsesHttpRequest(
finalizeUnrepresentableAssistantReplacement();
return;
}
const completedFinalization = finalizeRequested;
if (completedFinalization.status !== "completed") {
if (finalizeRequested.status !== "completed") {
finalizeScheduled = false;
maybeFinalize();
return;
}
const usage = finalUsage;
const finalText =
accumulatedText || bufferedReplaceableAssistantContent || completedFinalization.text;
accumulatedText || bufferedReplaceableAssistantContent || finalizeRequested.text;
closed = true;
stopWatchingDisconnect();
@@ -1018,9 +998,9 @@ export async function handleOpenResponsesHttpRequest(
});
};
const requestFinalize = (terminal: StreamFinalization) => {
const requestFinalize = (status: "completed" | "failed", text = "") => {
// Attempt errors stay provisional while a successful fallback can recover.
finalizeRequested = terminal;
finalizeRequested = { status, text };
maybeFinalize();
};
@@ -1176,18 +1156,15 @@ export async function handleOpenResponsesHttpRequest(
phase,
data: evt.data,
});
const outcome = mergeAgentRunTerminalOutcome(finalizeRequested?.outcome, incomingOutcome);
const outcome = mergeAgentRunTerminalOutcome(terminalOutcome, incomingOutcome);
terminalOutcome = outcome;
if (outcome.reason !== "completed") {
requestFinalize({ status: "failed", outcome });
requestFinalize("failed");
} else {
requestFinalize({
status: "completed",
text:
accumulatedText ||
bufferedReplaceableAssistantContent ||
"No response from OpenClaw.",
outcome,
});
requestFinalize(
"completed",
accumulatedText || bufferedReplaceableAssistantContent || "No response from OpenClaw.",
);
}
}
}
@@ -1232,15 +1209,14 @@ export async function handleOpenResponsesHttpRequest(
return;
}
finalUsage = extractUsageFromResult(result);
const resultOutcome = resolveOpenAiHttpAgentRunTerminalOutcome(result);
if (resultOutcome.reason !== "completed") {
requestFinalize({
status: "failed",
outcome: mergeAgentRunTerminalOutcome(readFinalization()?.outcome, resultOutcome),
});
const priorFinalization = finalizeRequested;
const outcome = resolveOpenAiHttpAgentRunTerminalOutcome(result, terminalOutcome);
terminalOutcome = outcome;
if (outcome.reason !== "completed") {
requestFinalize("failed");
return;
}
if (readFinalization()?.status === "failed" && terminalStreamError) {
if (priorFinalization?.status === "failed" && terminalStreamError) {
const failedResponse = createResponseResource({
id: responseId,
model,
@@ -1253,11 +1229,6 @@ export async function handleOpenResponsesHttpRequest(
finalizeFailedResponse(failedResponse);
return;
}
const outcome = resolveOpenAiHttpAgentRunTerminalOutcome(result, readFinalization()?.outcome);
if (outcome.reason !== "completed") {
requestFinalize({ status: "failed", outcome });
return;
}
if (unrepresentableAssistantReplacement) {
finalizeUnrepresentableAssistantReplacement();
@@ -1266,17 +1237,8 @@ export async function handleOpenResponsesHttpRequest(
// Check for pending client tool calls BEFORE maybeFinalize() because the
// lifecycle:end event may already have requested finalization.
const resultAny = result as {
payloads?: Array<{ isError?: boolean; text?: string }>;
meta?: unknown;
};
const resultPayloadText = Array.isArray(resultAny.payloads)
? resultAny.payloads
.filter((replyPayload) => replyPayload.isError !== true)
.map((p) => (typeof p.text === "string" ? p.text : ""))
.filter(Boolean)
.join("\n\n")
: "";
const resultAny = result as { meta?: unknown };
const resultPayloadText = resolveOpenAiHttpResultText(result);
const meta = resultAny.meta;
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
@@ -1413,9 +1375,8 @@ export async function handleOpenResponsesHttpRequest(
accumulatedText = content;
sawAssistantDelta = true;
const finalization = readFinalization();
if (finalization?.status === "completed") {
finalizeRequested = { ...finalization, text: content };
if (finalizeRequested?.status === "completed") {
finalizeRequested = { ...finalizeRequested, text: content };
}
writeSseEvent(res, {