Files
openclaw/extensions/qa-lab/src/suite-runtime-agent-process.test.ts

946 lines
31 KiB
TypeScript

// Qa Lab tests cover suite runtime agent process plugin behavior.
import { EventEmitter } from "node:events";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
const runQaWindowsTaskkillMock = vi.hoisted(() => vi.fn());
const resolveQaNodeExecPathMock = vi.hoisted(() => vi.fn(async () => "/usr/bin/node"));
const waitForGatewayHealthyMock = vi.hoisted(() => vi.fn(async () => undefined));
const waitForTransportReadyMock = vi.hoisted(() => vi.fn(async () => undefined));
const readSessionTranscriptSummaryMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
vi.mock("./windows-system-tools.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./windows-system-tools.js")>()),
runQaWindowsTaskkill: runQaWindowsTaskkillMock,
}));
vi.mock("./node-exec.js", () => ({
resolveQaNodeExecPath: resolveQaNodeExecPathMock,
}));
vi.mock("./suite-runtime-gateway.js", () => ({
waitForGatewayHealthy: waitForGatewayHealthyMock,
waitForTransportReady: waitForTransportReadyMock,
}));
vi.mock("./suite-runtime-agent-session.js", () => ({
readSessionTranscriptSummary: readSessionTranscriptSummaryMock,
}));
import { QA_CHILD_STDERR_TAIL_BYTES, QA_CHILD_STDOUT_MAX_BYTES } from "./child-output.js";
import { runQaCli } from "./qa-cli-process.js";
import {
findManagedDreamingCronJob,
listCronJobs,
readDoctorMemoryStatus,
runAgentPrompt,
startAgentRun,
waitForAgentRun,
waitForAgentHistoryReply,
} from "./suite-runtime-agent-process.js";
type MockEmitter = {
emit: (eventName: string | symbol, ...args: unknown[]) => boolean;
on: (eventName: string | symbol, listener: (...args: unknown[]) => void) => MockEmitter;
once: (eventName: string | symbol, listener: (...args: unknown[]) => void) => MockEmitter;
};
type MockChildProcess = MockEmitter & {
pid?: number;
stdout: MockEmitter;
stderr: MockEmitter;
kill: ReturnType<typeof vi.fn>;
};
function createMockEmitter() {
return new EventEmitter() as unknown as MockEmitter;
}
function createSpawnedProcess(params: { pid?: number } = {}) {
const child = createMockEmitter() as MockChildProcess;
const emit = child.emit.bind(child);
let exited = false;
child.emit = (eventName, ...args) => {
if (eventName === "exit") {
exited = true;
} else if (eventName === "close" && !exited) {
exited = true;
emit("exit", ...args);
}
return emit(eventName, ...args);
};
child.pid = params.pid;
child.stdout = createMockEmitter();
child.stderr = createMockEmitter();
child.kill = vi.fn();
return child;
}
async function waitForSpawnCount(count: number) {
await vi.waitFor(() => {
expect(spawnMock).toHaveBeenCalledTimes(count);
});
await Promise.resolve();
}
function firstSpawnCall(): unknown[] | undefined {
return spawnMock.mock.calls[0];
}
function firstGatewayCall(
gatewayCall: ReturnType<typeof vi.fn>,
): [string, unknown, unknown] | undefined {
return gatewayCall.mock.calls[0] as [string, unknown, unknown] | undefined;
}
const QA_CLI_ENV = {
repoRoot: "/repo",
gateway: {
tempRoot: "/tmp/runtime",
runtimeEnv: { PATH: "/usr/bin" },
},
primaryModel: "openai/gpt-5.6-luna",
alternateModel: "openai/gpt-5.6-luna-mini",
providerMode: "mock-openai",
} as unknown as Parameters<typeof runQaCli>[0];
const QA_CLI_JSON_ENV = {
...QA_CLI_ENV,
gateway: { tempRoot: "/tmp/runtime", runtimeEnv: {} },
} as unknown as Parameters<typeof runQaCli>[0];
function startMockQaCli(params: {
args: string[];
child?: MockChildProcess;
env?: Parameters<typeof runQaCli>[0];
options?: Parameters<typeof runQaCli>[2];
}) {
const child = params.child ?? createSpawnedProcess();
spawnMock.mockReturnValue(child);
return {
child,
pending: runQaCli(params.env ?? QA_CLI_ENV, params.args, params.options),
};
}
function createAgentPromptEnv(gatewayCall: ReturnType<typeof vi.fn>) {
return {
gateway: { call: gatewayCall },
transport: {
buildAgentDelivery: vi.fn(() => ({
channel: "qa-channel",
replyChannel: "reply-channel",
replyTo: "reply-target",
})),
},
} as never;
}
describe("qa suite runtime agent process helpers", () => {
beforeEach(() => {
spawnMock.mockReset();
runQaWindowsTaskkillMock.mockReset();
resolveQaNodeExecPathMock.mockClear();
waitForGatewayHealthyMock.mockClear();
waitForTransportReadyMock.mockClear();
readSessionTranscriptSummaryMock.mockReset();
});
it("runs the qa cli through the resolved node executable", async () => {
const { child, pending } = startMockQaCli({ args: ["qa", "suite"] });
await waitForSpawnCount(1);
child.stdout.emit("data", Buffer.from("ok\n"));
child.emit("close", 0);
await expect(pending).resolves.toBe("ok");
const spawnCall = firstSpawnCall();
expect(spawnCall?.[0]).toBe("/usr/bin/node");
expect(spawnCall?.[1]).toEqual([path.join("/repo", "dist", "index.js"), "qa", "suite"]);
expect((spawnCall?.[2] as { cwd?: string; env?: unknown } | undefined)?.cwd).toBe(
"/tmp/runtime",
);
expect((spawnCall?.[2] as { env?: unknown } | undefined)?.env).toEqual({ PATH: "/usr/bin" });
});
it("caps oversized qa cli timeout timers", async () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const { child, pending } = startMockQaCli({
args: ["qa", "suite"],
options: { timeoutMs: Number.MAX_SAFE_INTEGER },
});
await waitForSpawnCount(1);
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
child.stdout.emit("data", Buffer.from("ok\n"));
child.emit("close", 0);
await expect(pending).resolves.toBe("ok");
} finally {
timeoutSpy.mockRestore();
}
});
it.runIf(process.platform !== "win32")("kills timed-out qa cli process groups", async () => {
let processGroupAlive = true;
const killSpy = vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
if (pid === -12345 && signal === "SIGKILL") {
processGroupAlive = false;
}
if (pid === -12345 && signal === 0 && !processGroupAlive) {
throw Object.assign(new Error("gone"), { code: "ESRCH" });
}
return true;
});
vi.useFakeTimers();
try {
const child = createSpawnedProcess({ pid: 12345 });
const { pending } = startMockQaCli({
args: ["qa", "suite"],
child,
options: { timeoutMs: 1 },
});
const errorPromise = pending.catch((value: unknown) => value);
await Promise.resolve();
expect(spawnMock).toHaveBeenCalledTimes(1);
child.stdout.emit(
"data",
Buffer.from(
`stdout-head-marker\n${"x".repeat(QA_CHILD_STDOUT_MAX_BYTES)}\nstdout-tail-marker`,
),
);
child.stderr.emit(
"data",
Buffer.from(
`stderr-head-marker\n${"x".repeat(QA_CHILD_STDERR_TAIL_BYTES)}\nstderr-tail-marker`,
),
);
await vi.advanceTimersByTimeAsync(1);
child.emit("exit", null, "SIGKILL");
child.emit("close", null, "SIGKILL");
const error = await errorPromise;
expect(error).toMatchObject({ code: "qa_cli_timeout" });
const message = error instanceof Error ? error.message : String(error);
expect(message).toContain("qa cli timed out: openclaw qa suite");
expect(message).toContain("stdout:\n[qa cli stdout truncated to last");
expect(message).toContain("stdout-tail-marker");
expect(message).not.toContain("stdout-head-marker");
expect(message).toContain("stderr:\n[qa cli stderr truncated to last");
expect(message).toContain("stderr-tail-marker");
expect(message).not.toContain("stderr-head-marker");
expect(killSpy).toHaveBeenCalledWith(-12345, "SIGKILL");
expect(child.kill).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
killSpy.mockRestore();
}
});
it.runIf(process.platform !== "win32")(
"preserves a nonzero qa cli failure when process-group cleanup also fails",
async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
if (pid === -12345 && signal === "SIGKILL") {
throw Object.assign(new Error("cleanup denied"), { code: "EPERM" });
}
return true;
});
vi.useFakeTimers();
try {
const child = createSpawnedProcess({ pid: 12345 });
const { pending } = startMockQaCli({ args: ["qa", "suite"], child });
const errorPromise = pending.catch((value: unknown) => value);
await Promise.resolve();
child.stderr.emit("data", Buffer.from("suite failed\n"));
child.emit("exit", 7, null);
child.emit("close", 7, null);
await vi.advanceTimersByTimeAsync(500);
const error = await errorPromise;
expect(error).toBeInstanceOf(AggregateError);
expect(error).toMatchObject({ message: "qa cli command and settlement failed" });
const failures = error instanceof AggregateError ? error.errors : [];
expect(failures).toEqual([
expect.objectContaining({ message: "qa cli failed (7): suite failed" }),
expect.any(Error),
]);
} finally {
vi.useRealTimers();
killSpy.mockRestore();
}
},
);
it.each([
{ label: "succeeds", taskkillSucceeded: true },
{ label: "falls back", taskkillSucceeded: false },
])(
"preserves the Windows timeout result when canonical cleanup $label",
async ({ taskkillSucceeded }) => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
try {
const child = createSpawnedProcess({ pid: 12345 });
runQaWindowsTaskkillMock.mockReturnValue(taskkillSucceeded);
const { pending } = startMockQaCli({
args: ["qa", "suite"],
child,
options: { timeoutMs: 1 },
});
const timeoutAssertion = expect(pending).rejects.toThrow(
"qa cli timed out: openclaw qa suite",
);
await waitForSpawnCount(1);
await timeoutAssertion;
expect(runQaWindowsTaskkillMock).toHaveBeenCalledWith({
pid: 12345,
signal: "SIGKILL",
});
if (taskkillSucceeded) {
expect(child.kill).not.toHaveBeenCalled();
} else {
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
}
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
},
);
it("merges isolated env overrides into qa cli runs", async () => {
const { child, pending } = startMockQaCli({
env: {
repoRoot: "/repo",
gateway: {
tempRoot: "/tmp/runtime",
runtimeEnv: { PATH: "/usr/bin", OPENCLAW_STATE_DIR: "/tmp/default-state" },
},
primaryModel: "openai/gpt-5.6-luna",
alternateModel: "openai/gpt-5.6-luna-mini",
providerMode: "mock-openai",
} as never,
args: ["openclaw", "-m", "overview"],
options: {
env: {
OPENCLAW_STATE_DIR: "/tmp/isolated-state",
OPENCLAW_CONFIG_PATH: "/tmp/isolated-state/openclaw.json",
},
},
});
await waitForSpawnCount(1);
child.stdout.emit("data", Buffer.from("ok\n"));
child.emit("close", 0);
await expect(pending).resolves.toBe("ok");
const spawnCall = firstSpawnCall();
expect(spawnCall?.[0]).toBe("/usr/bin/node");
expect(spawnCall?.[1]).toEqual([
path.join("/repo", "dist", "index.js"),
"openclaw",
"-m",
"overview",
]);
const spawnEnv = (spawnCall?.[2] as { env?: Record<string, string> } | undefined)?.env;
expect(spawnEnv?.PATH).toBe("/usr/bin");
expect(spawnEnv?.OPENCLAW_STATE_DIR).toBe("/tmp/isolated-state");
expect(spawnEnv?.OPENCLAW_CONFIG_PATH).toBe("/tmp/isolated-state/openclaw.json");
});
it("parses json qa cli output when requested", async () => {
const { child, pending } = startMockQaCli({
env: QA_CLI_JSON_ENV,
args: ["memory", "search"],
options: { json: true },
});
await waitForSpawnCount(1);
child.stdout.emit("data", Buffer.from('{"ok":true}\n'));
child.emit("close", 0);
await expect(pending).resolves.toEqual({ ok: true });
});
it.each([
{
title: "parses json qa cli output after colored startup logs",
stdout:
'\u001b[35m[plugins]\u001b[39m \u001b[36mcodex loaded plugin package metadata\u001b[39m\n{"results":[{"text":"ORBIT-10"}]}\n',
},
{
title: "parses pretty json qa cli output after startup logs",
stdout:
'[plugins] memory-core loaded plugin package metadata\n{\n "results": [\n {\n "text": "ORBIT-10"\n }\n ]\n}\n',
},
{
title: "parses pretty json qa cli output before trailing stdout logs",
stdout:
'[plugins] memory-core loaded plugin package metadata\n{\n "results": [\n {\n "text": "ORBIT-10"\n }\n ]\n}\n[plugins] trailing diagnostic\n',
},
{
title: "ignores diagnostic json fragments before the qa cli payload",
stdout:
'[plugins] diagnostic context {"ok":true}\n{"results":[{"text":"ORBIT-10"}]}\n[plugins] trailing diagnostic\n',
},
{
title: "ignores leading json diagnostic records before the qa cli payload",
stdout:
'{"event":"startup-repair"}\n{"results":[{"text":"ORBIT-10"}]}\n[plugins] trailing diagnostic\n',
},
{
title: "ignores trailing json diagnostic records after the qa cli payload",
stdout:
'[plugins] memory-core loaded plugin package metadata\n{\n "results": [\n {\n "text": "ORBIT-10"\n }\n ]\n}\n{"event":"cleanup"}\n',
},
])("$title", async ({ stdout }) => {
const { child, pending } = startMockQaCli({
env: QA_CLI_JSON_ENV,
args: ["memory", "search", "--json"],
options: { json: true },
});
await waitForSpawnCount(1);
child.stdout.emit("data", Buffer.from(stdout));
child.emit("close", 0);
await expect(pending).resolves.toEqual({ results: [{ text: "ORBIT-10" }] });
});
it("waits for stdio close before parsing qa cli stdout", async () => {
const { child, pending } = startMockQaCli({
env: QA_CLI_JSON_ENV,
args: ["memory", "search", "--json"],
options: { json: true },
});
await waitForSpawnCount(1);
child.emit("exit", 0);
child.stdout.emit("data", Buffer.from('{"results":[{"text":"LATE-STDOUT"}]}\n'));
child.emit("close", 0);
await expect(pending).resolves.toEqual({ results: [{ text: "LATE-STDOUT" }] });
});
it("rejects oversized qa cli stdout instead of parsing truncated output", async () => {
const { child, pending } = startMockQaCli({
env: QA_CLI_JSON_ENV,
args: ["memory", "search", "--json"],
options: { json: true },
});
await waitForSpawnCount(1);
child.stdout.emit("data", Buffer.alloc(QA_CHILD_STDOUT_MAX_BYTES + 1, "x"));
child.emit("close", 0);
await expect(pending).rejects.toThrow(
`qa cli stdout exceeded ${QA_CHILD_STDOUT_MAX_BYTES} bytes; refusing to parse truncated output`,
);
});
it("keeps only a bounded qa cli stderr tail for failure diagnostics", async () => {
const { child, pending } = startMockQaCli({
env: QA_CLI_JSON_ENV,
args: ["memory", "search", "--json"],
options: { json: true },
});
await waitForSpawnCount(1);
child.stderr.emit(
"data",
Buffer.from(`head-marker\n${"x".repeat(QA_CHILD_STDERR_TAIL_BYTES)}\ntail-marker`),
);
child.emit("close", 1);
const error = await pending.catch((value: unknown) => value);
expect(error).toBeInstanceOf(Error);
const message = error instanceof Error ? error.message : String(error);
expect(message).toContain("qa cli failed (1):");
expect(message).toContain("qa cli stderr truncated to last");
expect(message).toContain("tail-marker");
expect(message).not.toContain("head-marker");
});
it("starts an agent run with transport-derived delivery metadata", async () => {
const gatewayCall = vi.fn(async () => ({ runId: "run-1" }));
const env = {
gateway: { call: gatewayCall },
transport: {
buildAgentDelivery: vi.fn(() => ({
channel: "qa-channel",
to: "transport-target",
replyChannel: "reply-channel",
replyTo: "reply-target",
})),
},
} as never;
await expect(
startAgentRun(env, {
sessionKey: "session-1",
message: "hello",
}),
).resolves.toEqual({ runId: "run-1" });
const gatewayArgs = firstGatewayCall(gatewayCall);
expect(gatewayArgs?.[0]).toBe("agent");
const agentPayload = gatewayArgs?.[1] as
| {
channel?: string;
message?: string;
replyChannel?: string;
replyTo?: string;
sessionKey?: string;
to?: string;
}
| undefined;
expect(agentPayload?.sessionKey).toBe("session-1");
expect(agentPayload?.message).toBe("hello");
expect(agentPayload?.channel).toBe("qa-channel");
expect(agentPayload?.to).toBe("transport-target");
expect(agentPayload?.replyChannel).toBe("reply-channel");
expect(agentPayload?.replyTo).toBe("reply-target");
expect(gatewayArgs?.[2]).toBeTypeOf("object");
});
it("starts an interactive run without CLI task tracking", async () => {
const gatewayCall = vi.fn(async () => ({ runId: "run-chat", status: "started" }));
const buildAgentDelivery = vi.fn(() => ({
channel: "qa-channel",
replyChannel: "qa-channel",
replyTo: "dm:qa-operator",
}));
const env = {
gateway: { call: gatewayCall },
transport: {
buildAgentDelivery,
},
} as never;
await expect(
startAgentRun(env, {
sessionKey: "agent:qa:main",
message: "hello",
taskTracking: false,
}),
).resolves.toEqual({ runId: "run-chat", status: "started" });
expect(gatewayCall).toHaveBeenCalledWith(
"chat.send",
{
idempotencyKey: expect.any(String),
sessionKey: "agent:qa:main",
message: "hello",
deliver: true,
originatingChannel: "qa-channel",
originatingTo: "dm:qa-operator",
},
{ timeoutMs: 30_000 },
);
expect(buildAgentDelivery).toHaveBeenCalledWith({ target: "dm:qa-operator" });
});
it("finds managed dreaming cron jobs across legacy and current payload contracts", () => {
const legacy = {
id: "legacy",
name: "Memory Dreaming Promotion",
payload: {
kind: "systemEvent",
text: "__openclaw_memory_core_short_term_promotion_dream__",
},
};
const current = {
id: "current",
name: "Memory Dreaming Promotion",
payload: {
kind: "agentTurn",
message: "__openclaw_memory_core_short_term_promotion_dream__",
lightContext: true,
},
sessionTarget: "isolated",
delivery: { mode: "none" },
};
expect(findManagedDreamingCronJob([{ id: "other", name: "Other" }, legacy])).toBe(legacy);
expect(findManagedDreamingCronJob([{ id: "other", name: "Other" }, current])).toBe(current);
});
it("waits for an agent run and fails when the run does not finish ok", async () => {
const gatewayCall = vi
.fn()
.mockResolvedValueOnce({ runId: "run-2" })
.mockResolvedValueOnce({ status: "error", error: "boom" });
const env = createAgentPromptEnv(gatewayCall);
await expect(
runAgentPrompt(env, {
sessionKey: "session-2",
message: "hello",
}),
).rejects.toThrow("agent.wait returned error: boom");
});
it("accepts completed agent wait status as a successful terminal run", async () => {
const terminalReply = { disposition: "visible" as const, text: "completed reply" };
const terminalDelivery = { status: "sent" as const, resultCount: 1 };
const gatewayCall = vi
.fn()
.mockResolvedValueOnce({ runId: "run-completed" })
.mockResolvedValueOnce({ status: "completed", terminalDelivery, terminalReply });
const env = createAgentPromptEnv(gatewayCall);
await expect(
runAgentPrompt(env, {
sessionKey: "session-completed",
message: "hello",
}),
).resolves.toEqual({
started: { runId: "run-completed" },
waited: { status: "completed", terminalDelivery, terminalReply },
});
});
it("accepts malformed completed wait errors as successful terminal runs", async () => {
const gatewayCall = vi
.fn()
.mockResolvedValueOnce({ runId: "run-error-completed" })
.mockResolvedValueOnce({ status: "error", error: "completed" });
const env = createAgentPromptEnv(gatewayCall);
await expect(
runAgentPrompt(env, {
sessionKey: "session-error-completed",
message: "hello",
}),
).resolves.toEqual({
started: { runId: "run-error-completed" },
waited: { status: "error", error: "completed" },
});
});
it("waits for persisted transcript tool evidence after agent completion", async () => {
vi.useFakeTimers();
try {
const gatewayCall = vi
.fn()
.mockResolvedValueOnce({ runId: "run-transcript-evidence" })
.mockResolvedValueOnce({ status: "completed" });
readSessionTranscriptSummaryMock
.mockResolvedValueOnce({
assistantToolCallCounts: {},
completedToolCallCounts: {},
successfulToolCallCounts: {},
finalText: "",
})
.mockResolvedValueOnce({
assistantToolCallCounts: { web_fetch: 1 },
completedToolCallCounts: { web_fetch: 1 },
successfulToolCallCounts: { web_fetch: 1 },
finalText: "",
});
const env = createAgentPromptEnv(gatewayCall);
const pending = runAgentPrompt(env, {
sessionKey: "session-transcript-evidence",
message: "call web_fetch",
transcriptToolName: "web_fetch",
requireSuccessfulTranscriptToolResult: true,
});
await vi.advanceTimersByTimeAsync(50);
await expect(pending).resolves.toEqual({
started: { runId: "run-transcript-evidence" },
waited: { status: "completed" },
});
expect(readSessionTranscriptSummaryMock).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("waits for a persisted failed tool result after the call is visible", async () => {
vi.useFakeTimers();
try {
const gatewayCall = vi
.fn()
.mockResolvedValueOnce({ runId: "run-failed-tool-evidence" })
.mockResolvedValueOnce({ status: "completed" });
readSessionTranscriptSummaryMock
.mockResolvedValueOnce({
assistantToolCallCounts: { session_status: 1 },
completedToolCallCounts: {},
successfulToolCallCounts: {},
finalText: "",
})
.mockResolvedValueOnce({
assistantToolCallCounts: { session_status: 1 },
completedToolCallCounts: { session_status: 1 },
successfulToolCallCounts: {},
finalText: "",
});
const env = createAgentPromptEnv(gatewayCall);
const pending = runAgentPrompt(env, {
sessionKey: "session-failed-tool-evidence",
message: "call session_status with invalid input",
transcriptToolName: "session_status",
});
await vi.advanceTimersByTimeAsync(50);
await expect(pending).resolves.toEqual({
started: { runId: "run-failed-tool-evidence" },
waited: { status: "completed" },
});
expect(readSessionTranscriptSummaryMock).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("waits for the latest assistant history reply", async () => {
const gatewayCall = vi
.fn()
.mockResolvedValueOnce({ messages: [{ role: "assistant", content: "still working" }] })
.mockResolvedValueOnce({
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: [{ type: "output_text", text: "HISTORY-REPLY-OK" }],
},
],
});
await expect(
waitForAgentHistoryReply(
{ gateway: { call: gatewayCall } } as never,
"session-history",
(text) => text === "HISTORY-REPLY-OK",
1_000,
1,
),
).resolves.toMatchObject({
text: "HISTORY-REPLY-OK",
});
expect(gatewayCall).toHaveBeenLastCalledWith(
"chat.history",
{ sessionKey: "session-history", limit: 12 },
{ timeoutMs: 10_000 },
);
});
it("retries structured transient history failures through gateway log wrappers", async () => {
vi.useFakeTimers();
try {
const gatewayError = Object.assign(new Error("session history is rebuilding"), {
gatewayCode: "UNAVAILABLE",
retryable: true,
retryAfterMs: 250,
details: { method: "chat.history" },
});
const wrappedError = new Error("gateway call failed", {
cause: new Error("gateway rpc failed", { cause: gatewayError }),
});
const gatewayCall = vi
.fn()
.mockRejectedValueOnce(wrappedError)
.mockResolvedValueOnce({
messages: [{ role: "assistant", content: "HISTORY-RETRY-OK" }],
});
const pending = waitForAgentHistoryReply(
{ gateway: { call: gatewayCall } } as never,
"session-history-retry",
(text) => text === "HISTORY-RETRY-OK",
1_000,
1,
);
await vi.advanceTimersByTimeAsync(250);
await expect(pending).resolves.toEqual({ text: "HISTORY-RETRY-OK" });
expect(gatewayCall).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("preserves the final retryable history failure when the poll deadline expires", async () => {
const gatewayError = Object.assign(new Error("session history is rebuilding"), {
gatewayCode: "UNAVAILABLE",
retryable: true,
retryAfterMs: 1,
details: { method: "chat.history" },
});
const wrappedError = new Error("gateway call failed", { cause: gatewayError });
const gatewayCall = vi.fn().mockRejectedValue(wrappedError);
await expect(
waitForAgentHistoryReply(
{ gateway: { call: gatewayCall } } as never,
"session-history-retry-timeout",
() => false,
220,
50,
),
).rejects.toMatchObject({
message: "timed out after 220ms",
cause: wrappedError,
});
expect(gatewayCall.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("does not attach a recovered history failure to a later predicate timeout", async () => {
const gatewayError = Object.assign(new Error("session history is rebuilding"), {
gatewayCode: "UNAVAILABLE",
retryable: true,
retryAfterMs: 1,
details: { method: "chat.history" },
});
const gatewayCall = vi
.fn()
.mockRejectedValueOnce(gatewayError)
.mockResolvedValue({ messages: [{ role: "assistant", content: "still working" }] });
const timeoutError = await waitForAgentHistoryReply(
{ gateway: { call: gatewayCall } } as never,
"session-history-recovered-timeout",
() => false,
220,
50,
).catch((error: unknown) => error);
expect(timeoutError).toBeInstanceOf(Error);
expect(timeoutError).not.toHaveProperty("cause");
expect(gatewayCall.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("does not retry transient gateway errors for a different method", async () => {
const gatewayError = Object.assign(new Error("gateway method is rebuilding"), {
gatewayCode: "UNAVAILABLE",
retryable: true,
retryAfterMs: 250,
details: { method: "chat.startup" },
});
const gatewayCall = vi.fn().mockRejectedValueOnce(gatewayError);
await expect(
waitForAgentHistoryReply(
{ gateway: { call: gatewayCall } } as never,
"session-history-wrong-method",
() => false,
1_000,
1,
),
).rejects.toBe(gatewayError);
expect(gatewayCall).toHaveBeenCalledOnce();
});
it("does not retry unavailable gateway errors without the retryable contract", async () => {
const gatewayError = Object.assign(new Error("history unavailable"), {
gatewayCode: "UNAVAILABLE",
retryable: false,
retryAfterMs: 250,
details: { method: "chat.history" },
});
const gatewayCall = vi.fn().mockRejectedValueOnce(gatewayError);
await expect(
waitForAgentHistoryReply(
{ gateway: { call: gatewayCall } } as never,
"session-history-not-retryable",
() => false,
1_000,
1,
),
).rejects.toBe(gatewayError);
expect(gatewayCall).toHaveBeenCalledOnce();
});
it("does not retry retry-shaped predicate failures", async () => {
const predicateError = Object.assign(new Error("predicate unavailable"), {
gatewayCode: "UNAVAILABLE",
retryable: true,
retryAfterMs: 250,
details: { method: "chat.history" },
});
const gatewayCall = vi.fn().mockResolvedValueOnce({
messages: [{ role: "assistant", content: "candidate reply" }],
});
await expect(
waitForAgentHistoryReply(
{ gateway: { call: gatewayCall } } as never,
"session-history-predicate-error",
async () => {
throw predicateError;
},
1_000,
1,
),
).rejects.toBe(predicateError);
expect(gatewayCall).toHaveBeenCalledOnce();
});
it("waits for a specific agent run id", async () => {
const gatewayCall = vi.fn(async () => ({ status: "ok" }));
await expect(
waitForAgentRun({ gateway: { call: gatewayCall } } as never, "run-3"),
).resolves.toEqual({ status: "ok" });
expect(gatewayCall).toHaveBeenCalledWith(
"agent.wait",
{ runId: "run-3", timeoutMs: 30_000 },
{ timeoutMs: 35_000 },
);
});
it.each(["restart", "aborted"])(
"preserves the %s stop reason from agent.wait",
async (stopReason) => {
const result = { status: "error", stopReason };
const gatewayCall = vi.fn(async () => result);
await expect(
waitForAgentRun({ gateway: { call: gatewayCall } } as never, "run-interrupted"),
).resolves.toEqual(result);
},
);
it("caps the gateway client timeout when waiting for oversized agent runs", async () => {
const gatewayCall = vi.fn(async () => ({ status: "ok" }));
await expect(
waitForAgentRun({ gateway: { call: gatewayCall } } as never, "run-oversized", 9e15),
).resolves.toEqual({ status: "ok" });
expect(gatewayCall).toHaveBeenCalledWith(
"agent.wait",
{ runId: "run-oversized", timeoutMs: MAX_TIMER_TIMEOUT_MS },
{ timeoutMs: MAX_TIMER_TIMEOUT_MS },
);
});
it("lists cron jobs and doctor memory status through the gateway", async () => {
const gatewayCall = vi
.fn()
.mockResolvedValueOnce({
jobs: [{ id: "job-1", name: "dreaming" }],
})
.mockResolvedValueOnce({
dreaming: { enabled: true, shortTermCount: 3 },
});
const env = { gateway: { call: gatewayCall } } as never;
await expect(listCronJobs(env)).resolves.toEqual([{ id: "job-1", name: "dreaming" }]);
await expect(readDoctorMemoryStatus(env)).resolves.toEqual({
dreaming: { enabled: true, shortTermCount: 3 },
});
});
});