openclaw/apps/web/lib/subagent-runs.test.ts
2026-03-02 18:33:38 -08:00

793 lines
25 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
readdirSync: vi.fn(() => []),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
appendFileSync: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: vi.fn(() => {
const proc = {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
kill: vi.fn(),
unref: vi.fn(),
pid: 12345,
};
return proc;
}),
execSync: vi.fn(() => ""),
exec: vi.fn(
(
_cmd: string,
_opts: unknown,
cb: (err: Error | null, result: { stdout: string }) => void,
) => {
cb(null, { stdout: "" });
},
),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
vi.mock("node:readline", () => ({
createInterface: vi.fn(() => {
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
const iface = {
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
handlers[event] = handlers[event] || [];
handlers[event].push(cb);
return iface;
}),
close: vi.fn(),
_emit: (event: string, ...args: unknown[]) => {
for (const cb of handlers[event] || []) {
cb(...args);
}
},
};
return iface;
}),
}));
vi.mock("./agent-runner", () => ({
spawnAgentSubscribeProcess: vi.fn(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
once: vi.fn(),
kill: vi.fn(),
pid: 12345,
})),
callGatewayRpc: vi.fn(() => Promise.resolve({ ok: true })),
extractToolResult: vi.fn((raw: unknown) => {
if (!raw) {return undefined;}
if (typeof raw === "string") {return { text: raw };}
return { text: undefined, details: raw as Record<string, unknown> };
}),
buildToolOutput: vi.fn((result?: { text?: string }) =>
result ? { text: result.text } : {},
),
parseAgentErrorMessage: vi.fn((data?: Record<string, unknown>) => {
if (data?.error && typeof data.error === "string") {return data.error;}
if (data?.message && typeof data.message === "string") {return data.message;}
return undefined;
}),
parseErrorBody: vi.fn((raw: string) => raw),
parseErrorFromStderr: vi.fn((stderr: string) => {
if (!stderr) {return undefined;}
if (/error/i.test(stderr)) {return stderr.trim();}
return undefined;
}),
}));
import { appendFileSync } from "node:fs";
function createMockSubscribeProcess() {
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
const proc = {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
handlers[event] = handlers[event] || [];
handlers[event].push(cb);
return proc;
}),
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
handlers[event] = handlers[event] || [];
handlers[event].push(cb);
return proc;
}),
kill: vi.fn(),
pid: 12345,
_emit(event: string, ...args: unknown[]) {
for (const cb of handlers[event] || []) {
cb(...args);
}
},
};
return proc;
}
// Shared global key used by subagent-runs.ts for its singleton registry
const GLOBAL_KEY = "__openclaw_subagentRuns";
describe("subagent runs", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
process.env = { ...originalEnv };
delete process.env.OPENCLAW_PROFILE;
delete process.env.OPENCLAW_HOME;
delete process.env.OPENCLAW_WORKSPACE;
delete process.env.OPENCLAW_STATE_DIR;
// Reset the global singleton between tests
delete (globalThis as Record<string, unknown>)[GLOBAL_KEY];
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
readdirSync: vi.fn(() => []),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
appendFileSync: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: vi.fn(() => {
const proc = {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
kill: vi.fn(),
unref: vi.fn(),
pid: 12345,
};
return proc;
}),
execSync: vi.fn(() => ""),
exec: vi.fn(
(
_cmd: string,
_opts: unknown,
cb: (err: Error | null, result: { stdout: string }) => void,
) => {
cb(null, { stdout: "" });
},
),
}));
vi.mock("node:os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}));
vi.mock("node:readline", () => ({
createInterface: vi.fn(() => {
const handlers: Record<string, ((...args: unknown[]) => void)[]> = {};
const iface = {
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
handlers[event] = handlers[event] || [];
handlers[event].push(cb);
return iface;
}),
close: vi.fn(),
_emit: (event: string, ...args: unknown[]) => {
for (const cb of handlers[event] || []) {
cb(...args);
}
},
};
return iface;
}),
}));
vi.mock("./agent-runner", () => ({
spawnAgentSubscribeProcess: vi.fn(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
once: vi.fn(),
kill: vi.fn(),
pid: 12345,
})),
callGatewayRpc: vi.fn(() => Promise.resolve({ ok: true })),
extractToolResult: vi.fn((raw: unknown) => {
if (!raw) {return undefined;}
if (typeof raw === "string") {return { text: raw };}
return { text: undefined, details: raw as Record<string, unknown> };
}),
buildToolOutput: vi.fn((result?: { text?: string }) =>
result ? { text: result.text } : {},
),
parseAgentErrorMessage: vi.fn((data?: Record<string, unknown>) => {
if (data?.error && typeof data.error === "string") {return data.error;}
if (data?.message && typeof data.message === "string") {return data.message;}
return undefined;
}),
parseErrorBody: vi.fn((raw: string) => raw),
parseErrorFromStderr: vi.fn((stderr: string) => {
if (!stderr) {return undefined;}
if (/error/i.test(stderr)) {return stderr.trim();}
return undefined;
}),
}));
});
afterEach(() => {
process.env = originalEnv;
delete (globalThis as Record<string, unknown>)[GLOBAL_KEY];
});
async function importSubagentRuns() {
return import("./subagent-runs.js");
}
// ─── registerSubagent ─────────────────────────────────────────────
describe("registerSubagent", () => {
it("registers a new subagent run", async () => {
const { registerSubagent, hasActiveSubagent } =
await importSubagentRuns();
registerSubagent("parent-session-1", {
sessionKey: "sub:parent:child1",
runId: "run-123",
task: "test task",
});
expect(hasActiveSubagent("sub:parent:child1")).toBe(true);
});
it("prevents duplicate registration", async () => {
const { registerSubagent, getSubagentsForSession } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "run-1",
task: "task 1",
});
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "run-2",
task: "task 2",
});
const subs = getSubagentsForSession("parent-1");
expect(subs).toHaveLength(1);
expect(subs[0].runId).toBe("run-1");
});
it("sets initial status to running", async () => {
const { registerSubagent, getSubagentsForSession } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "run-1",
task: "task",
});
const subs = getSubagentsForSession("parent-1");
expect(subs[0].status).toBe("running");
});
it("persists subagent info to index file", async () => {
const { writeFileSync: wfs } = await import("node:fs");
const mockWrite = vi.mocked(wfs);
const { registerSubagent } = await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "run-1",
task: "my task",
label: "my label",
});
const indexWrites = mockWrite.mock.calls.filter((c) =>
(c[0] as string).includes("subagent-index.json"),
);
expect(indexWrites.length).toBeGreaterThan(0);
const written = JSON.parse(indexWrites[indexWrites.length - 1][1] as string);
expect(written["sub:p:c1"]).toBeDefined();
expect(written["sub:p:c1"].task).toBe("my task");
});
it("stores label when provided", async () => {
const { registerSubagent, getSubagentsForSession } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "run-1",
task: "task",
label: "custom label",
});
const subs = getSubagentsForSession("parent-1");
expect(subs[0].label).toBe("custom label");
});
});
describe("subscribe restart stability", () => {
it("applies exponential backoff and resets after a recovered stream event", async () => {
vi.useFakeTimers();
try {
const { spawnAgentSubscribeProcess } = await import("./agent-runner.js");
const { createInterface } = await import("node:readline");
const mockSubscribeSpawn = vi.mocked(spawnAgentSubscribeProcess);
mockSubscribeSpawn.mockReset();
const first = createMockSubscribeProcess();
const second = createMockSubscribeProcess();
const third = createMockSubscribeProcess();
const fourth = createMockSubscribeProcess();
mockSubscribeSpawn
.mockReturnValueOnce(first as never)
.mockReturnValueOnce(second as never)
.mockReturnValueOnce(third as never)
.mockReturnValueOnce(fourth as never);
const { registerSubagent } = await importSubagentRuns();
registerSubagent("parent-stable", {
sessionKey: "sub:stable:c1",
runId: "run-stable",
task: "retry stream",
});
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(1);
first._emit("close", 1);
await vi.advanceTimersByTimeAsync(299);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(2);
second._emit("close", 1);
await vi.advanceTimersByTimeAsync(599);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(3);
const createInterfaceMock = vi.mocked(createInterface);
const thirdInterface = createInterfaceMock.mock.results.at(-1)
?.value as { _emit: (event: string, payload: string) => void };
thirdInterface?._emit("line", JSON.stringify({
event: "agent",
sessionKey: "sub:stable:c1",
stream: "assistant",
data: { delta: "recovered" },
globalSeq: 1,
}));
await vi.advanceTimersByTimeAsync(0);
third._emit("close", 1);
await vi.advanceTimersByTimeAsync(299);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(1);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(4);
} finally {
vi.useRealTimers();
}
});
});
describe("event parity safeguards", () => {
it("emits tool input/output events from subscribed lines", async () => {
const { createInterface } = await import("node:readline");
const { registerSubagent, subscribeToSubagent } = await importSubagentRuns();
registerSubagent("parent-tools", {
sessionKey: "sub:tools:c1",
runId: "run-tools",
task: "tool parity",
});
const received: Array<Record<string, unknown>> = [];
subscribeToSubagent(
"sub:tools:c1",
(event) => {
if (event) {
received.push(event as Record<string, unknown>);
}
},
{ replay: false },
);
const createInterfaceMock = vi.mocked(createInterface);
const iface = createInterfaceMock.mock.results.at(-1)
?.value as { _emit: (event: string, payload: string) => void };
iface._emit("line", JSON.stringify({
event: "agent",
sessionKey: "sub:tools:c1",
stream: "tool",
data: {
phase: "start",
toolCallId: "tc-tools-1",
name: "bash",
args: { command: "echo hello" },
},
globalSeq: 1,
}));
iface._emit("line", JSON.stringify({
event: "agent",
sessionKey: "sub:tools:c1",
stream: "tool",
data: {
phase: "result",
toolCallId: "tc-tools-1",
result: { text: "hello" },
},
globalSeq: 2,
}));
expect(
received.some(
(e) =>
e.type === "tool-input-start" &&
e.toolCallId === "tc-tools-1",
),
).toBe(true);
expect(
received.some(
(e) =>
e.type === "tool-output-available" &&
e.toolCallId === "tc-tools-1",
),
).toBe(true);
});
});
// ─── getSubagentsForSession ───────────────────────────────────────
describe("getSubagentsForSession", () => {
it("returns empty array for unknown parent", async () => {
const { getSubagentsForSession } = await importSubagentRuns();
expect(getSubagentsForSession("unknown")).toEqual([]);
});
it("returns all subagents for a parent session", async () => {
const { registerSubagent, getSubagentsForSession } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t1",
});
registerSubagent("parent-1", {
sessionKey: "sub:p:c2",
runId: "r2",
task: "t2",
});
const subs = getSubagentsForSession("parent-1");
expect(subs).toHaveLength(2);
});
it("does not return subagents from other parents", async () => {
const { registerSubagent, getSubagentsForSession } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p1:c1",
runId: "r1",
task: "t1",
});
registerSubagent("parent-2", {
sessionKey: "sub:p2:c1",
runId: "r2",
task: "t2",
});
const subs1 = getSubagentsForSession("parent-1");
const subs2 = getSubagentsForSession("parent-2");
expect(subs1).toHaveLength(1);
expect(subs1[0].sessionKey).toBe("sub:p1:c1");
expect(subs2).toHaveLength(1);
expect(subs2[0].sessionKey).toBe("sub:p2:c1");
});
});
// ─── subscribeToSubagent ──────────────────────────────────────────
describe("subscribeToSubagent", () => {
it("returns null for unknown subagent", async () => {
const { subscribeToSubagent } = await importSubagentRuns();
const unsub = subscribeToSubagent("unknown-key", () => {});
expect(unsub).toBeNull();
});
it("replays buffered events by default", async () => {
const { registerSubagent, subscribeToSubagent } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t",
});
// Manually push events into the buffer by using persistUserMessage
const { persistUserMessage } = await importSubagentRuns();
persistUserMessage("sub:p:c1", { text: "hello" });
const received: unknown[] = [];
subscribeToSubagent("sub:p:c1", (event) => {
if (event) {received.push(event);}
});
expect(received.length).toBeGreaterThanOrEqual(1);
const userMsg = received.find(
(e) => (e as Record<string, unknown>).type === "user-message",
);
expect(userMsg).toBeDefined();
});
it("skips replay when replay=false", async () => {
const { registerSubagent, persistUserMessage, subscribeToSubagent } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t",
});
persistUserMessage("sub:p:c1", { text: "hello" });
const received: unknown[] = [];
subscribeToSubagent(
"sub:p:c1",
(event) => {
if (event) {received.push(event);}
},
{ replay: false },
);
expect(received).toHaveLength(0);
});
it("returns unsubscribe function", async () => {
const { registerSubagent, subscribeToSubagent } =
await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t",
});
const unsub = subscribeToSubagent("sub:p:c1", () => {});
expect(typeof unsub).toBe("function");
});
});
// ─── isSubagentRunning / hasActiveSubagent ────────────────────────
describe("isSubagentRunning / hasActiveSubagent", () => {
it("reports running after registration", async () => {
const { registerSubagent, isSubagentRunning, hasActiveSubagent } =
await importSubagentRuns();
registerSubagent("p-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t",
});
expect(isSubagentRunning("sub:p:c1")).toBe(true);
expect(hasActiveSubagent("sub:p:c1")).toBe(true);
});
it("reports not running for unknown keys", async () => {
const { isSubagentRunning, hasActiveSubagent } =
await importSubagentRuns();
expect(isSubagentRunning("unknown")).toBe(false);
expect(hasActiveSubagent("unknown")).toBe(false);
});
});
// ─── persistUserMessage ───────────────────────────────────────────
describe("persistUserMessage", () => {
it("appends user message event to buffer and disk", async () => {
const mockAppend = vi.mocked(appendFileSync);
const { registerSubagent, persistUserMessage } =
await importSubagentRuns();
registerSubagent("p-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t",
});
const result = persistUserMessage("sub:p:c1", { text: "hello" });
expect(result).toBe(true);
const appendCalls = mockAppend.mock.calls.filter((c) =>
(c[0] as string).includes("subagent-events"),
);
expect(appendCalls.length).toBeGreaterThan(0);
});
it("returns false for unknown subagent", async () => {
const { persistUserMessage } = await importSubagentRuns();
expect(persistUserMessage("unknown", { text: "hello" })).toBe(false);
});
it("fans out to subscribers", async () => {
const {
registerSubagent,
subscribeToSubagent,
persistUserMessage,
} = await importSubagentRuns();
registerSubagent("p-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t",
});
const received: unknown[] = [];
subscribeToSubagent(
"sub:p:c1",
(event) => {
if (event) {received.push(event);}
},
{ replay: false },
);
persistUserMessage("sub:p:c1", { text: "live msg" });
const userMsg = received.find(
(e) => (e as Record<string, unknown>).type === "user-message",
);
expect(userMsg).toBeDefined();
});
});
// ─── getRunningSubagentKeys ───────────────────────────────────────
describe("getRunningSubagentKeys", () => {
it("returns keys of running subagents", async () => {
const { registerSubagent, getRunningSubagentKeys } =
await importSubagentRuns();
registerSubagent("p-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t1",
});
registerSubagent("p-1", {
sessionKey: "sub:p:c2",
runId: "r2",
task: "t2",
});
const keys = getRunningSubagentKeys();
expect(keys).toContain("sub:p:c1");
expect(keys).toContain("sub:p:c2");
});
it("returns empty when no subagents registered", async () => {
const { getRunningSubagentKeys } = await importSubagentRuns();
expect(getRunningSubagentKeys()).toEqual([]);
});
});
// ─── ensureRegisteredFromDisk ─────────────────────────────────────
describe("ensureRegisteredFromDisk", () => {
it("returns true if already registered in memory", async () => {
const { registerSubagent, ensureRegisteredFromDisk } =
await importSubagentRuns();
registerSubagent("p-1", {
sessionKey: "sub:p:c1",
runId: "r1",
task: "t",
});
expect(ensureRegisteredFromDisk("sub:p:c1", "p-1")).toBe(true);
});
it("registers from profile-scoped index file", async () => {
const { readFileSync: rfs, existsSync: es } = await import("node:fs");
vi.mocked(es).mockImplementation((p) => {
const s = String(p);
return s.includes("subagent-index.json");
});
vi.mocked(rfs).mockImplementation((p) => {
const s = String(p);
if (s.includes("subagent-index.json")) {
return JSON.stringify({
"sub:p:disk1": {
runId: "r-disk",
parentWebSessionId: "p-disk",
task: "disk task",
status: "completed",
startedAt: 1000,
},
}) as never;
}
return "" as never;
});
const { ensureRegisteredFromDisk, hasActiveSubagent } =
await importSubagentRuns();
const result = ensureRegisteredFromDisk("sub:p:disk1", "p-disk");
expect(result).toBe(true);
expect(hasActiveSubagent("sub:p:disk1")).toBe(true);
});
it("returns false when not found anywhere", async () => {
const { ensureRegisteredFromDisk } = await importSubagentRuns();
expect(ensureRegisteredFromDisk("sub:nonexistent", "p-1")).toBe(false);
});
it("registers from shared gateway registry as fallback", async () => {
const { readFileSync: rfs, existsSync: es } = await import("node:fs");
vi.mocked(es).mockImplementation((p) => {
const s = String(p);
return s.includes("subagents/runs.json") || s.includes(".openclaw");
});
vi.mocked(rfs).mockImplementation((p) => {
const s = String(p);
if (s.includes("runs.json")) {
return JSON.stringify({
runs: {
"run-gw": {
childSessionKey: "sub:gw:c1",
runId: "r-gw",
task: "gateway task",
},
},
}) as never;
}
if (s.includes("subagent-index.json")) {
return "{}" as never;
}
return "" as never;
});
const { ensureRegisteredFromDisk, hasActiveSubagent } =
await importSubagentRuns();
const result = ensureRegisteredFromDisk("sub:gw:c1", "p-gw");
expect(result).toBe(true);
expect(hasActiveSubagent("sub:gw:c1")).toBe(true);
});
});
// ─── abortSubagent ────────────────────────────────────────────────
describe("abortSubagent", () => {
it("returns false for unknown subagent", async () => {
const { abortSubagent } = await importSubagentRuns();
expect(abortSubagent("unknown")).toBe(false);
});
it("aborts known subagent via gateway RPC", async () => {
const { callGatewayRpc } = await import("./agent-runner.js");
const { registerSubagent, abortSubagent } = await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "run-1",
task: "task",
});
expect(abortSubagent("sub:p:c1")).toBe(true);
expect(vi.mocked(callGatewayRpc)).toHaveBeenCalledWith(
"chat.abort",
{ sessionKey: "sub:p:c1" },
{ timeoutMs: 4_000 },
);
});
});
describe("spawnSubagentMessage", () => {
it("sends follow-up messages via gateway RPC", async () => {
const { callGatewayRpc } = await import("./agent-runner.js");
const { registerSubagent, spawnSubagentMessage } = await importSubagentRuns();
registerSubagent("parent-1", {
sessionKey: "sub:p:c1",
runId: "run-1",
task: "task",
});
expect(spawnSubagentMessage("sub:p:c1", "continue")).toBe(true);
expect(vi.mocked(callGatewayRpc)).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
sessionKey: "sub:p:c1",
message: "continue",
channel: "webchat",
lane: "subagent",
deliver: false,
timeout: 0,
}),
{ timeoutMs: 10_000 },
);
});
});
});