openclaw/apps/web/lib/active-runs.test.ts

1226 lines
34 KiB
TypeScript

import { type ChildProcess } from "node:child_process";
import { PassThrough } from "node:stream";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock agent-runner to control spawnAgentProcess
vi.mock("./agent-runner", () => ({
spawnAgentProcess: vi.fn(),
spawnAgentSubscribeProcess: vi.fn(),
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;
}),
}));
// Mock fs operations used for persistence so tests don't hit disk
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return {
...original,
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
};
});
import type { SseEvent } from "./active-runs.js";
/**
* Create a mock child process with a real PassThrough stream for stdout,
* so the readline interface inside wireChildProcess actually receives data.
*/
function createMockChild() {
const events: Record<string, ((...args: unknown[]) => void)[]> = {};
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
const child = {
exitCode: null as number | null,
killed: false,
pid: 12345,
stdout: stdoutStream,
stderr: stderrStream,
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
events[event] = events[event] || [];
events[event].push(cb);
return child;
}),
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
events[event] = events[event] || [];
events[event].push(cb);
return child;
}),
kill: vi.fn(),
/** Emit an event to all registered listeners. */
_emit(event: string, ...args: unknown[]) {
for (const cb of events[event] || []) {
cb(...args);
}
},
/** Write a JSON line to stdout (simulating agent output). */
_writeLine(jsonObj: Record<string, unknown>) {
stdoutStream.write(JSON.stringify(jsonObj) + "\n");
},
/** Write raw text to stderr. */
_writeStderr(text: string) {
stderrStream.write(Buffer.from(text));
},
};
return child;
}
describe("active-runs", () => {
beforeEach(() => {
vi.resetModules();
// Re-wire mocks after resetModules
vi.mock("./agent-runner", () => ({
spawnAgentProcess: vi.fn(),
spawnAgentSubscribeProcess: vi.fn(),
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;
}),
}));
vi.mock("node:fs", async (importOriginal) => {
const original =
await importOriginal<typeof import("node:fs")>();
return {
...original,
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ""),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
};
});
});
afterEach(() => {
vi.restoreAllMocks();
});
/** Helper: set up a mock child and import the active-runs module fresh. */
async function setup() {
const child = createMockChild();
const { spawnAgentProcess } = await import("./agent-runner.js");
vi.mocked(spawnAgentProcess).mockReturnValue(
child as unknown as ChildProcess,
);
const mod = await import("./active-runs.js");
return { child, ...mod };
}
// ── startRun + subscribeToRun ──────────────────────────────────────
describe("startRun + subscribeToRun", () => {
it("creates a run and emits fallback text when process exits without output", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({
sessionId: "s1",
message: "hello",
agentSessionId: "s1",
});
subscribeToRun(
"s1",
(event) => {
if (event) {events.push(event);}
},
{ replay: false },
);
// Close stdout before emitting close, so readline finishes
child.stdout.end();
// Small delay to let readline drain
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
// Should have emitted fallback "[error] No response from agent."
expect(
events.some(
(e) =>
e.type === "text-delta" &&
typeof e.delta === "string" &&
(e.delta).includes("No response"),
),
).toBe(true);
});
it("streams assistant text events for agent assistant output", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({
sessionId: "s-text",
message: "say hi",
agentSessionId: "s-text",
});
subscribeToRun(
"s-text",
(event) => {
if (event) {events.push(event);}
},
{ replay: false },
);
// Emit an assistant text delta via stdout JSON
child._writeLine({
event: "agent",
stream: "assistant",
data: { delta: "Hello world!" },
});
// Give readline a tick to process
await new Promise((r) => setTimeout(r, 50));
// Should have text-start + text-delta
expect(events.some((e) => e.type === "text-start")).toBe(true);
expect(
events.some(
(e) => e.type === "text-delta" && e.delta === "Hello world!",
),
).toBe(true);
// Clean up
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
});
it("streams reasoning events for thinking output", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({
sessionId: "s-think",
message: "think about it",
agentSessionId: "s-think",
});
subscribeToRun(
"s-think",
(event) => {
if (event) {events.push(event);}
},
{ replay: false },
);
child._writeLine({
event: "agent",
stream: "thinking",
data: { delta: "Let me think..." },
});
await new Promise((r) => setTimeout(r, 50));
expect(events.some((e) => e.type === "reasoning-start")).toBe(
true,
);
expect(
events.some(
(e) =>
e.type === "reasoning-delta" &&
e.delta === "Let me think...",
),
).toBe(true);
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
});
it("streams tool-input-start and tool-input-available for tool calls", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({
sessionId: "s-tool",
message: "use a tool",
agentSessionId: "s-tool",
});
subscribeToRun(
"s-tool",
(event) => {
if (event) {events.push(event);}
},
{ replay: false },
);
child._writeLine({
event: "agent",
stream: "tool",
data: {
phase: "start",
toolCallId: "tc-1",
name: "search",
args: { query: "test" },
},
});
await new Promise((r) => setTimeout(r, 50));
expect(
events.some(
(e) =>
e.type === "tool-input-start" &&
e.toolCallId === "tc-1",
),
).toBe(true);
expect(
events.some(
(e) =>
e.type === "tool-input-available" &&
e.toolCallId === "tc-1" &&
e.toolName === "search",
),
).toBe(true);
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
});
it("emits error text for non-zero exit code", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({
sessionId: "s-fail",
message: "fail",
agentSessionId: "s-fail",
});
subscribeToRun(
"s-fail",
(event) => {
if (event) {events.push(event);}
},
{ replay: false },
);
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 1);
expect(
events.some(
(e) =>
e.type === "text-delta" &&
typeof e.delta === "string" &&
(e.delta).includes("exited with code 1"),
),
).toBe(true);
});
it("signals completion (null) to subscribers when run finishes", async () => {
const { child, startRun, subscribeToRun } = await setup();
const completed: boolean[] = [];
startRun({
sessionId: "s-complete",
message: "hi",
agentSessionId: "s-complete",
});
subscribeToRun(
"s-complete",
(event) => {
if (event === null) {completed.push(true);}
},
{ replay: false },
);
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
expect(completed).toHaveLength(1);
});
});
// ── child process error handling ────────────────────────────────────
describe("child process error handling", () => {
it("emits 'Failed to start agent' on spawn error (ENOENT)", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
const completions: boolean[] = [];
startRun({
sessionId: "s-enoent",
message: "hello",
agentSessionId: "s-enoent",
});
subscribeToRun(
"s-enoent",
(event) => {
if (event) {
events.push(event);
} else {
completions.push(true);
}
},
{ replay: false },
);
const err = new Error("spawn node ENOENT");
(err as NodeJS.ErrnoException).code = "ENOENT";
child._emit("error", err);
expect(
events.some(
(e) =>
e.type === "text-delta" &&
typeof e.delta === "string" &&
(e.delta).includes("Failed to start agent"),
),
).toBe(true);
expect(completions).toHaveLength(1);
});
it("does not crash on readline error (the root cause of 'Unhandled error event')", async () => {
const { child, startRun } = await setup();
startRun({
sessionId: "s-rl-err",
message: "hello",
agentSessionId: "s-rl-err",
});
// Simulate what happens when a child process fails to start:
// stdout stream is destroyed with an error, which readline re-emits.
// Before the fix, this would throw "Unhandled 'error' event".
// After the fix, the rl.on("error") handler swallows it.
expect(() => {
child.stdout.destroy(new Error("stream destroyed"));
}).not.toThrow();
// Give a tick for the error to propagate
await new Promise((r) => setTimeout(r, 50));
// The run should still be tracked (error handler on child takes care of cleanup)
});
});
// ── subscribeToRun replay ──────────────────────────────────────────
describe("subscribeToRun replay", () => {
it("replays buffered events to new subscribers", async () => {
const { child, startRun, subscribeToRun } = await setup();
startRun({
sessionId: "s-replay",
message: "hi",
agentSessionId: "s-replay",
});
// Generate some events
child._writeLine({
event: "agent",
stream: "assistant",
data: { delta: "Hello" },
});
await new Promise((r) => setTimeout(r, 50));
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
// New subscriber with replay=true
const replayed: (SseEvent | null)[] = [];
subscribeToRun(
"s-replay",
(event) => {
replayed.push(event);
},
{ replay: true },
);
// Should include the text events + null (completion)
expect(replayed.length).toBeGreaterThan(0);
expect(replayed[replayed.length - 1]).toBeNull();
expect(
replayed.some(
(e) =>
e !== null &&
e.type === "text-delta" &&
e.delta === "Hello",
),
).toBe(true);
});
it("returns null for unsubscribe when no run exists", async () => {
const { subscribeToRun } = await setup();
const unsub = subscribeToRun(
"nonexistent",
() => {},
{ replay: true },
);
expect(unsub).toBeNull();
});
});
// ── hasActiveRun / getActiveRun ────────────────────────────────────
describe("hasActiveRun / getActiveRun", () => {
it("returns true for a running process", async () => {
const { child: _child, startRun, hasActiveRun, getActiveRun } =
await setup();
startRun({
sessionId: "s-active",
message: "hi",
agentSessionId: "s-active",
});
expect(hasActiveRun("s-active")).toBe(true);
expect(getActiveRun("s-active")).toBeDefined();
expect(getActiveRun("s-active")?.status).toBe("running");
});
it("marks status as completed after clean exit", async () => {
const { child, startRun, hasActiveRun, getActiveRun } =
await setup();
startRun({
sessionId: "s-done",
message: "hi",
agentSessionId: "s-done",
});
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
expect(hasActiveRun("s-done")).toBe(false);
expect(getActiveRun("s-done")?.status).toBe("completed");
});
it("marks status as error after non-zero exit", async () => {
const { child, startRun, getActiveRun } = await setup();
startRun({
sessionId: "s-err-exit",
message: "hi",
agentSessionId: "s-err-exit",
});
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 1);
expect(getActiveRun("s-err-exit")?.status).toBe("error");
});
it("returns false for unknown sessions", async () => {
const { hasActiveRun, getActiveRun } = await setup();
expect(hasActiveRun("nonexistent")).toBe(false);
expect(getActiveRun("nonexistent")).toBeUndefined();
});
});
// ── abortRun ──────────────────────────────────────────────────────
describe("abortRun", () => {
it("kills a running child process", async () => {
const { child, startRun, abortRun } = await setup();
const { callGatewayRpc } = await import("./agent-runner.js");
startRun({
sessionId: "s-abort",
message: "hi",
agentSessionId: "s-abort",
});
expect(abortRun("s-abort")).toBe(true);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(vi.mocked(callGatewayRpc)).toHaveBeenCalledWith(
"chat.abort",
{ sessionKey: "agent:main:web:s-abort" },
{ timeoutMs: 4_000 },
);
});
it("returns false for non-running sessions", async () => {
const { abortRun } = await setup();
expect(abortRun("nonexistent")).toBe(false);
});
it("immediately marks the run as non-active so new messages are not blocked", async () => {
const { startRun, abortRun, hasActiveRun, getActiveRun } = await setup();
startRun({
sessionId: "s-abort-status",
message: "hi",
agentSessionId: "s-abort-status",
});
expect(hasActiveRun("s-abort-status")).toBe(true);
abortRun("s-abort-status");
// hasActiveRun must return false immediately after abort
// (before the child process exits), otherwise the next
// user message is rejected with 409.
expect(hasActiveRun("s-abort-status")).toBe(false);
expect(getActiveRun("s-abort-status")?.status).toBe("error");
});
it("allows starting a new run after abort (no 409 race)", async () => {
const { startRun, abortRun, hasActiveRun } = await setup();
startRun({
sessionId: "s-abort-new",
message: "first",
agentSessionId: "s-abort-new",
});
abortRun("s-abort-new");
// Starting a new run for the same session should succeed.
expect(() =>
startRun({
sessionId: "s-abort-new",
message: "second",
agentSessionId: "s-abort-new",
}),
).not.toThrow();
expect(hasActiveRun("s-abort-new")).toBe(true);
});
it("signals subscribers with null on abort", async () => {
const { startRun, abortRun, subscribeToRun } = await setup();
const completed: boolean[] = [];
startRun({
sessionId: "s-abort-sub",
message: "hi",
agentSessionId: "s-abort-sub",
});
subscribeToRun(
"s-abort-sub",
(event) => {
if (event === null) {completed.push(true);}
},
{ replay: false },
);
abortRun("s-abort-sub");
expect(completed).toHaveLength(1);
});
it("aborts runs while waiting for subagents", async () => {
const { startRun, startSubscribeRun, abortRun, getActiveRun } = await setup();
const { spawnAgentProcess, spawnAgentSubscribeProcess } = await import(
"./agent-runner.js"
);
const mockRunSpawn = vi.mocked(spawnAgentProcess);
const mockSubscribeSpawn = vi.mocked(spawnAgentSubscribeProcess);
mockRunSpawn.mockReset();
mockSubscribeSpawn.mockReset();
const parentChild = createMockChild();
const subagentStream = createMockChild();
const parentSubscribe = createMockChild();
mockRunSpawn.mockReturnValue(parentChild as unknown as ChildProcess);
mockSubscribeSpawn
.mockReturnValueOnce(subagentStream as unknown as ChildProcess)
.mockReturnValueOnce(parentSubscribe as unknown as ChildProcess);
startSubscribeRun({
sessionKey: "sub:waiting:abort",
parentSessionId: "parent-waiting-abort",
task: "child task",
});
startRun({
sessionId: "parent-waiting-abort",
message: "run parent",
agentSessionId: "parent-waiting-abort",
});
parentChild.stdout.end();
await new Promise((r) => setTimeout(r, 0));
parentChild._emit("close", 0);
expect(getActiveRun("parent-waiting-abort")?.status).toBe(
"waiting-for-subagents",
);
expect(abortRun("parent-waiting-abort")).toBe(true);
expect(parentSubscribe.kill).toHaveBeenCalledWith("SIGTERM");
expect(getActiveRun("parent-waiting-abort")?.status).toBe("error");
});
});
describe("sendSubagentFollowUp", () => {
it("sends follow-up over gateway RPC", async () => {
const { sendSubagentFollowUp } = await setup();
const { callGatewayRpc } = await import("./agent-runner.js");
expect(sendSubagentFollowUp("session-1", "continue")).toBe(true);
expect(vi.mocked(callGatewayRpc)).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
sessionKey: "session-1",
message: "continue",
channel: "webchat",
lane: "subagent",
deliver: false,
timeout: 0,
}),
{ timeoutMs: 10_000 },
);
});
});
describe("subscribe stream restart stability", () => {
it("uses bounded exponential backoff for subscribe-only restarts and resets after first event", async () => {
vi.useFakeTimers();
try {
const { startSubscribeRun, abortRun } = await setup();
const { spawnAgentSubscribeProcess } = await import("./agent-runner.js");
const mockSubscribeSpawn = vi.mocked(spawnAgentSubscribeProcess);
mockSubscribeSpawn.mockReset();
const first = createMockChild();
const second = createMockChild();
const third = createMockChild();
const fourth = createMockChild();
mockSubscribeSpawn
.mockReturnValueOnce(first as unknown as ChildProcess)
.mockReturnValueOnce(second as unknown as ChildProcess)
.mockReturnValueOnce(third as unknown as ChildProcess)
.mockReturnValueOnce(fourth as unknown as ChildProcess);
startSubscribeRun({
sessionKey: "sub:retry:one",
parentSessionId: "parent-retry",
task: "retry task",
});
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);
third._writeLine({
event: "agent",
sessionKey: "sub:retry:one",
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);
expect(abortRun("sub:retry:one")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("retries parent waiting streams with backoff instead of tight loops", async () => {
vi.useFakeTimers();
try {
const { startRun, startSubscribeRun, getActiveRun, abortRun } =
await setup();
const { spawnAgentProcess, spawnAgentSubscribeProcess } = await import(
"./agent-runner.js"
);
const mockRunSpawn = vi.mocked(spawnAgentProcess);
const mockSubscribeSpawn = vi.mocked(spawnAgentSubscribeProcess);
mockRunSpawn.mockReset();
mockSubscribeSpawn.mockReset();
const parentChild = createMockChild();
const subagentStream = createMockChild();
const parentSubscribeFirst = createMockChild();
const parentSubscribeSecond = createMockChild();
mockRunSpawn.mockReturnValue(parentChild as unknown as ChildProcess);
mockSubscribeSpawn
.mockReturnValueOnce(subagentStream as unknown as ChildProcess)
.mockReturnValueOnce(parentSubscribeFirst as unknown as ChildProcess)
.mockReturnValueOnce(parentSubscribeSecond as unknown as ChildProcess);
startSubscribeRun({
sessionKey: "sub:parent:retry",
parentSessionId: "parent-retry-2",
task: "child task",
});
startRun({
sessionId: "parent-retry-2",
message: "run parent",
agentSessionId: "parent-retry-2",
});
parentChild.stdout.end();
await vi.advanceTimersByTimeAsync(0);
parentChild._emit("close", 0);
expect(getActiveRun("parent-retry-2")?.status).toBe(
"waiting-for-subagents",
);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(2);
parentSubscribeFirst._emit("close", 1);
await vi.advanceTimersByTimeAsync(299);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1);
expect(mockSubscribeSpawn).toHaveBeenCalledTimes(3);
expect(abortRun("parent-retry-2")).toBe(true);
expect(abortRun("sub:parent:retry")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("streams multiple announce turns while waiting and finalizes after idle reconciliation", async () => {
vi.useFakeTimers();
try {
const { startRun, startSubscribeRun, subscribeToRun, getActiveRun } =
await setup();
const { spawnAgentProcess, spawnAgentSubscribeProcess } = await import(
"./agent-runner.js"
);
const mockRunSpawn = vi.mocked(spawnAgentProcess);
const mockSubscribeSpawn = vi.mocked(spawnAgentSubscribeProcess);
mockRunSpawn.mockReset();
mockSubscribeSpawn.mockReset();
const parentChild = createMockChild();
const subagentStream = createMockChild();
const parentSubscribe = createMockChild();
mockRunSpawn.mockReturnValue(parentChild as unknown as ChildProcess);
mockSubscribeSpawn
.mockReturnValueOnce(subagentStream as unknown as ChildProcess)
.mockReturnValueOnce(parentSubscribe as unknown as ChildProcess);
startSubscribeRun({
sessionKey: "sub:announce:one",
parentSessionId: "parent-announce",
task: "child task",
});
startRun({
sessionId: "parent-announce",
message: "run parent",
agentSessionId: "parent-announce",
});
const events: SseEvent[] = [];
const completed: boolean[] = [];
subscribeToRun(
"parent-announce",
(event) => {
if (event) {
events.push(event);
} else {
completed.push(true);
}
},
{ replay: false },
);
parentChild.stdout.end();
await vi.advanceTimersByTimeAsync(0);
parentChild._emit("close", 0);
expect(getActiveRun("parent-announce")?.status).toBe(
"waiting-for-subagents",
);
subagentStream._writeLine({
event: "agent",
sessionKey: "sub:announce:one",
stream: "lifecycle",
data: { phase: "end" },
globalSeq: 1,
});
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(750);
expect(getActiveRun("sub:announce:one")?.status).toBe("completed");
parentSubscribe._writeLine({
event: "chat",
sessionKey: "agent:main:web:parent-announce",
globalSeq: 2,
data: {
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Subagent finished and reported back." }],
},
},
});
await vi.advanceTimersByTimeAsync(0);
expect(
events.some(
(e) =>
e.type === "text-delta" &&
typeof e.delta === "string" &&
e.delta.includes("Subagent finished and reported back."),
),
).toBe(true);
// A subsequent announce turn should keep the waiting run alive
// by resetting the finalize reconciliation timer.
parentSubscribe._writeLine({
event: "agent",
sessionKey: "agent:main:web:parent-announce",
stream: "lifecycle",
data: { phase: "start" },
globalSeq: 3,
});
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(4_900);
expect(completed).toHaveLength(0);
expect(getActiveRun("parent-announce")?.status).toBe(
"waiting-for-subagents",
);
parentSubscribe._writeLine({
event: "chat",
sessionKey: "agent:main:web:parent-announce",
globalSeq: 4,
data: {
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Another subagent result delivered." }],
},
},
});
await vi.advanceTimersByTimeAsync(0);
expect(
events.some(
(e) =>
e.type === "text-delta" &&
typeof e.delta === "string" &&
e.delta.includes("Another subagent result delivered."),
),
).toBe(true);
await vi.advanceTimersByTimeAsync(5_000);
expect(completed).toHaveLength(1);
expect(getActiveRun("parent-announce")?.status).toBe("completed");
} finally {
vi.useRealTimers();
}
});
it("does not spam duplicate waiting-status deltas while already waiting", async () => {
vi.useFakeTimers();
try {
const { startRun, startSubscribeRun, subscribeToRun, abortRun } =
await setup();
const { spawnAgentProcess, spawnAgentSubscribeProcess } = await import(
"./agent-runner.js"
);
const mockRunSpawn = vi.mocked(spawnAgentProcess);
const mockSubscribeSpawn = vi.mocked(spawnAgentSubscribeProcess);
mockRunSpawn.mockReset();
mockSubscribeSpawn.mockReset();
const parentChild = createMockChild();
const subagentStream = createMockChild();
const parentSubscribe = createMockChild();
mockRunSpawn.mockReturnValue(parentChild as unknown as ChildProcess);
mockSubscribeSpawn
.mockReturnValueOnce(subagentStream as unknown as ChildProcess)
.mockReturnValueOnce(parentSubscribe as unknown as ChildProcess);
startSubscribeRun({
sessionKey: "sub:waiting:dedupe",
parentSessionId: "parent-waiting-dedupe",
task: "child task",
});
startRun({
sessionId: "parent-waiting-dedupe",
message: "run parent",
agentSessionId: "parent-waiting-dedupe",
});
const events: SseEvent[] = [];
subscribeToRun(
"parent-waiting-dedupe",
(event) => {
if (event) {
events.push(event);
}
},
{ replay: false },
);
parentChild.stdout.end();
await vi.advanceTimersByTimeAsync(0);
parentChild._emit("close", 0);
await vi.advanceTimersByTimeAsync(0);
const waitingText = "Waiting for subagent results...";
const waitingCountAfterEnter = events.filter(
(e) => e.type === "reasoning-delta" && e.delta === waitingText,
).length;
expect(waitingCountAfterEnter).toBe(1);
parentSubscribe._writeLine({
event: "agent",
sessionKey: "agent:main:web:parent-waiting-dedupe",
stream: "lifecycle",
data: { phase: "end" },
globalSeq: 2,
});
parentSubscribe._writeLine({
event: "agent",
sessionKey: "agent:main:web:parent-waiting-dedupe",
stream: "lifecycle",
data: { phase: "end" },
globalSeq: 3,
});
await vi.advanceTimersByTimeAsync(0);
const waitingCountFinal = events.filter(
(e) => e.type === "reasoning-delta" && e.delta === waitingText,
).length;
expect(waitingCountFinal).toBe(1);
expect(abortRun("parent-waiting-dedupe")).toBe(true);
expect(abortRun("sub:waiting:dedupe")).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
// ── duplicate run prevention ──────────────────────────────────────
describe("duplicate run prevention", () => {
it("throws when starting a run for an already-active session", async () => {
const { startRun } = await setup();
startRun({
sessionId: "s-dup",
message: "first",
agentSessionId: "s-dup",
});
expect(() =>
startRun({
sessionId: "s-dup",
message: "second",
agentSessionId: "s-dup",
}),
).toThrow("Active run already exists");
});
});
// ── multiple concurrent runs ─────────────────────────────────────
describe("multiple concurrent runs", () => {
it("tracks multiple sessions independently", async () => {
const { startRun, hasActiveRun, getActiveRun } = await setup();
startRun({ sessionId: "s-a", message: "first", agentSessionId: "s-a" });
startRun({ sessionId: "s-b", message: "second", agentSessionId: "s-b" });
expect(hasActiveRun("s-a")).toBe(true);
expect(hasActiveRun("s-b")).toBe(true);
expect(getActiveRun("s-a")?.status).toBe("running");
expect(getActiveRun("s-b")?.status).toBe("running");
});
});
// ── tool result events ───────────────────────────────────────────
describe("tool result events", () => {
it("emits tool-result events for completed tool calls", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({ sessionId: "s-tr", message: "use tool", agentSessionId: "s-tr" });
subscribeToRun("s-tr", (event) => {
if (event) {events.push(event);}
}, { replay: false });
// Emit tool start
child._writeLine({
event: "agent",
stream: "tool",
data: { phase: "start", toolCallId: "tc-1", name: "search", args: { q: "test" } },
});
// Emit tool result
child._writeLine({
event: "agent",
stream: "tool",
data: { phase: "result", toolCallId: "tc-1", result: "found 3 results" },
});
await new Promise((r) => setTimeout(r, 50));
expect(events.some((e) => e.type === "tool-input-start" && e.toolCallId === "tc-1")).toBe(true);
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
});
});
// ── stderr handling ──────────────────────────────────────────────
describe("stderr handling", () => {
it("captures stderr output for error reporting", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({ sessionId: "s-stderr", message: "fail", agentSessionId: "s-stderr" });
subscribeToRun("s-stderr", (event) => {
if (event) {events.push(event);}
}, { replay: false });
child._writeStderr("Error: something went wrong\n");
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 1);
// Should have an error message mentioning stderr content
expect(events.some((e) =>
e.type === "text-delta" && typeof e.delta === "string",
)).toBe(true);
});
});
// ── lifecycle events ──────────────────────────────────────────────
describe("lifecycle events", () => {
it("emits reasoning status on lifecycle start", async () => {
const { child, startRun, subscribeToRun } = await setup();
const events: SseEvent[] = [];
startRun({
sessionId: "s-lifecycle",
message: "hi",
agentSessionId: "s-lifecycle",
});
subscribeToRun(
"s-lifecycle",
(event) => {
if (event) {events.push(event);}
},
{ replay: false },
);
child._writeLine({
event: "agent",
stream: "lifecycle",
data: { phase: "start" },
});
await new Promise((r) => setTimeout(r, 50));
expect(events.some((e) => e.type === "reasoning-start")).toBe(
true,
);
expect(
events.some(
(e) =>
e.type === "reasoning-delta" &&
e.delta === "Preparing response...",
),
).toBe(true);
child.stdout.end();
await new Promise((r) => setTimeout(r, 50));
child._emit("close", 0);
});
});
});