mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
refactor(gateway): trim responses terminal coverage
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user