/** * Pane-state acceptance tests for the Herdr busy projection. * * Every case wires the real TUI reporter (`herdr-agent-state.ts`) to the real * `registerHerdrStatusBridge` producer through the real event bus, and asserts * the lifecycle reports the reporter writes to a local fake Herdr socket. * These assertions observe outgoing pane state, not the intermediate * `herdr:busy` events. * * Isolation: every case gets a fresh module registry, Herdr env, socket, event * bus and factory instances. `afterEach` quits reporters, disposes bridges, * closes sockets, removes the temporary socket directory, restores the exact * pre-run Herdr env (values *and* absence) and drops the module cache so each * case re-reads the module-level Herdr env. * * The fake server treats socket chunks as a byte stream: it buffers per socket, * decodes split UTF-8 sequences and parses only complete newline-terminated * frames. On Windows the harness listens on a named pipe (Unix path length * limits are preserved); only macOS was executed for this suite. * * Deterministic drains: reports are serialized through the reporter's state * queue. Negative assertions either observe a queued barrier while no send is * in flight, or hold the server response so a report stays dequeued-but- * unanswered and a later barrier cannot coalesce it away. No fixed sleeps. */ import net from "node:net"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { afterEach, describe, expect, it, vi } from "vitest"; const SUBAGENT_ASYNC_STARTED_EVENT = "subagent:async-started"; const SUBAGENT_ASYNC_COMPLETE_EVENT = "subagent:async-complete"; const SUBAGENT_CONTROL_EVENT = "subagent:control-event"; const SESSION_FILE = "/tmp/herdr-agent-state-test-session.jsonl"; const SESSION_ID = "herdr-agent-state-test-session"; const ENV_KEYS = ["HERDR_ENV", "HERDR_SOCKET_PATH", "HERDR_PANE_ID"] as const; const isWindows = process.platform === "win32"; let pipeCounter = 0; type WireRecord = { method?: string; params?: Record }; type StateReport = { state?: string; message?: string; seq?: number }; type BusyEvent = { active?: boolean; label?: string }; type EnvSnapshot = Map; function saveHerdrEnv(): EnvSnapshot { const snapshot: EnvSnapshot = new Map(); for (const key of ENV_KEYS) snapshot.set(key, process.env[key]); return snapshot; } function restoreHerdrEnv(snapshot: EnvSnapshot): void { for (const key of ENV_KEYS) { const value = snapshot.get(key); if (value === undefined) delete process.env[key]; else process.env[key] = value; } } function hasHerdrEnv(key: (typeof ENV_KEYS)[number]): boolean { return Object.prototype.hasOwnProperty.call(process.env, key); } // Captured when this file loads; `afterEach` restores these exact values and // absences whether the case passed, failed or threw. const ORIGINAL_ENV = saveHerdrEnv(); type EventBusLike = { emit(channel: string, data: unknown): void; on(channel: string, handler: (data: unknown) => void): () => void; clear?(): void; }; type BridgeLike = { sessionStarted(input: { hasUI: boolean; runs: unknown[] }): void; flush(): Promise; dispose(): void; }; type FakePi = { pi: { events: EventBusLike; on(event: string, handler: (event: unknown, ctx: unknown) => unknown): void }; emit(event: string, payload: unknown, ctx: unknown): Promise; }; type Ctx = { mode: string; hasUI: boolean; isIdle: () => boolean; sessionManager: unknown }; type ManualTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval; tick(): void; active(): number; }; function createManualTimers(): ManualTimers { const intervals: Array<{ fn: () => void; handle: { unref(): void } }> = []; return { setInterval: ((fn: () => void) => { const entry = { fn, handle: { unref() {} } }; intervals.push(entry); return entry.handle as unknown as ReturnType; }) as unknown as typeof setInterval, clearInterval: ((handle: unknown) => { const index = intervals.findIndex((entry) => entry.handle === handle); if (index >= 0) intervals.splice(index, 1); }) as unknown as typeof clearInterval, tick: () => { for (const entry of [...intervals]) entry.fn(); }, active: () => intervals.length, }; } function createFakePi(bus: EventBusLike): FakePi { const handlers = new Map unknown>>(); const pi = { events: bus, on(event: string, handler: (event: unknown, ctx: unknown) => unknown) { handlers.set(event, [...(handlers.get(event) ?? []), handler]); }, }; return { pi, async emit(event, payload, ctx) { for (const handler of handlers.get(event) ?? []) { await handler(payload, ctx); } }, }; } async function waitFor(predicate: () => boolean, label: string, timeoutMs = 5000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error(`Timed out waiting for ${label}`); } function expectSeqMonotonic(wire: WireRecord[]): void { const seqs = wire.map((record) => Number(record.params?.seq)); expect(seqs.every((seq, index) => Number.isFinite(seq) && (index === 0 || seq > seqs[index - 1]!))).toBe(true); } type HarnessOptions = { enabled?: boolean; refreshMs?: number; getRuns?: () => Iterable; }; type Harness = { wire: WireRecord[]; busyEvents: BusyEvent[]; bus: EventBusLike; app: FakePi; bridge: BridgeLike; runningCtx: Ctx; idleCtx: Ctx; timers: ManualTimers; socketPathValue: string; socketEndpoint: string; requests: (method: string) => WireRecord[]; stateReports: () => StateReport[]; latestState: () => string | undefined; startRun: (data: Record) => void; completeRun: (runId: string) => void; newReporter: () => FakePi; newBridge: () => BridgeLike; blockBarrier: () => Promise; heldSliceFrom: (mark: number) => Promise; clearBlockAndWait: (expected: "working" | "idle") => Promise; holdResponses: () => void; releaseResponses: () => void; stopServer: () => Promise; cleanup: () => Promise; }; const activeHarnesses: Harness[] = []; afterEach(async () => { for (const harness of activeHarnesses.splice(0)) await harness.cleanup(); restoreHerdrEnv(ORIGINAL_ENV); vi.resetModules(); vi.unstubAllEnvs(); }); function createSocketDir(): string { const preferredRoot = process.env.TMPDIR ?? tmpdir(); const socketRoot = preferredRoot.length <= 60 ? preferredRoot : "/tmp"; const dir = mkdtempSync(path.join(socketRoot, "herdr-agent-state-")); const endpoint = path.join(dir, "herdr.sock"); if (endpoint.length > 100) { rmSync(dir, { recursive: true, force: true }); throw new Error(`Unix socket path too long (${endpoint.length}): ${endpoint}`); } return dir; } async function createHarness(options: HarnessOptions = {}): Promise { const enabled = options.enabled !== false; const socketDir = isWindows ? undefined : createSocketDir(); // `HERDR_SOCKET_PATH` holds the raw path value in both worlds; the reporter // prefixes the Windows pipe namespace itself. const socketPathValue = isWindows ? `herdr-agent-state-test-${process.pid}-${(pipeCounter += 1)}` : path.join(socketDir!, "herdr.sock"); const socketEndpoint = isWindows ? `\\\\.\\pipe\\${socketPathValue}` : socketPathValue; const wire: WireRecord[] = []; const sockets = new Set(); const held: net.Socket[] = []; const frameBuffers = new Map(); const decoders = new Map(); let holdResponses = false; const respond = (socket: net.Socket) => socket.write(`${JSON.stringify({ ok: true })}\n`); const server = net.createServer((socket) => { sockets.add(socket); socket.on("close", () => { sockets.delete(socket); frameBuffers.delete(socket); decoders.delete(socket); }); socket.on("error", () => {}); socket.on("data", (chunk: Buffer) => { let decoder = decoders.get(socket); if (!decoder) { decoder = new StringDecoder("utf8"); decoders.set(socket, decoder); } // Socket chunks are not frames: buffer per socket, let the decoder // hold split UTF-8 sequences, and parse only complete newline- // terminated requests. const combined = (frameBuffers.get(socket) ?? "") + decoder.write(chunk); const frames = combined.split("\n"); frameBuffers.set(socket, frames.pop() ?? ""); for (const frame of frames) { const trimmed = frame.trim(); if (!trimmed) continue; try { wire.push(JSON.parse(trimmed) as WireRecord); } catch { wire.push({}); } if (holdResponses) held.push(socket); else respond(socket); } }); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketEndpoint, () => resolve()); }); const releaseAll = (): void => { holdResponses = false; for (const socket of held.splice(0)) respond(socket); }; let serverOpen = true; const closeServer = async (): Promise => { if (!serverOpen) return; serverOpen = false; for (const socket of [...sockets]) socket.destroy(); await new Promise((resolve) => server.close(() => resolve())); if (socketDir) rmSync(socketDir, { recursive: true, force: true }); }; for (const key of ENV_KEYS) delete process.env[key]; if (enabled) { process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = socketPathValue; process.env.HERDR_PANE_ID = "w1:p1"; } vi.resetModules(); const { createEventBus } = (await import("../core/event-bus.ts")) as { createEventBus: () => EventBusLike }; const { registerHerdrStatusBridge } = (await import("./pi-subagents/src/integrations/herdr-status.ts")) as { registerHerdrStatusBridge: (bridgeOptions: Record) => BridgeLike; }; const reporterFactory = ((await import("./herdr-agent-state.ts")) as { default: (pi: unknown) => void }).default; const bus = createEventBus(); const busyEvents: BusyEvent[] = []; bus.on("herdr:busy", (data) => { busyEvents.push((data ?? {}) as BusyEvent); }); const sessionManager = { getSessionFile: () => SESSION_FILE, getSessionId: () => SESSION_ID }; const runningCtx: Ctx = { mode: "tui", hasUI: true, isIdle: () => false, sessionManager }; const idleCtx: Ctx = { mode: "tui", hasUI: true, isIdle: () => true, sessionManager }; const timers = createManualTimers(); const apps: FakePi[] = []; const bridges: BridgeLike[] = []; const newReporter = (): FakePi => { const app = createFakePi(bus); apps.push(app); reporterFactory(app.pi); return app; }; const newBridge = (): BridgeLike => { const bridge = registerHerdrStatusBridge({ events: bus, env: enabled ? { HERDR_ENV: "1", HERDR_PANE_ID: "w1:p1" } : {}, runHerdr: async () => {}, ttlMs: 0, refreshMs: options.refreshMs ?? 0, timers, ...(options.getRuns ? { getRuns: options.getRuns } : {}), }); bridges.push(bridge); return bridge; }; const app = newReporter(); const bridge = newBridge(); const requests = (method: string) => wire.filter((record) => record.method === method); const stateReports = () => requests("pane.report_agent").map((record) => ({ ...(record.params ?? {}) }) as StateReport); const latestState = () => stateReports().at(-1)?.state; const blockBarrier = async (): Promise => { const mark = stateReports().length; bus.emit("herdr:blocked", { active: true, label: "drain-barrier" }); await waitFor(() => latestState() === "blocked", "blocked drain barrier"); return stateReports().slice(mark, -1); }; // Use only when no report is in flight at the mark: the barrier must not // coalesce a queued intermediate state away. When a report may be in // flight, use `heldSliceFrom`. const clearBlockAndWait = async (expected: "working" | "idle"): Promise => { bus.emit("herdr:blocked", { active: false }); await waitFor(() => latestState() === expected, `restored ${expected} after the drain barrier`); }; // Holds responses, emits the blocked barrier, releases once the barrier (or // an earlier suspect report) reaches the server, and returns every state // report queued between `mark` and the barrier report. A suspect report // that was dequeued before the barrier is answered first, so the barrier // cannot coalesce it away. const heldSliceFrom = async (mark: number): Promise => { holdResponses = true; bus.emit("herdr:blocked", { active: true, label: "drain-barrier" }); await waitFor(() => requests("pane.report_agent").length > mark, "report reached the held socket"); releaseAll(); await waitFor(() => latestState() === "blocked", "blocked drain barrier"); return stateReports().slice(mark, -1); }; let closed = false; const cleanup = async (): Promise => { if (closed) return; closed = true; releaseAll(); for (const instance of [...apps].reverse()) { try { await instance.emit("session_shutdown", { type: "session_shutdown", reason: "quit" }, idleCtx); } catch { // Best effort: the case already asserted the behavior it cares about. } } for (const instance of [...bridges].reverse()) { instance.dispose(); await instance.flush(); } bus.clear?.(); await closeServer(); }; const harness: Harness = { wire, busyEvents, bus, app, bridge, runningCtx, idleCtx, timers, socketPathValue, socketEndpoint, requests, stateReports, latestState, startRun: (data) => bus.emit(SUBAGENT_ASYNC_STARTED_EVENT, data), completeRun: (runId) => bus.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, { runId }), newReporter, newBridge, blockBarrier, heldSliceFrom, clearBlockAndWait, holdResponses: () => { holdResponses = true; }, releaseResponses: releaseAll, stopServer: closeServer, cleanup, }; activeHarnesses.push(harness); return harness; } describe("herdr pane state includes active subagent work", () => { it("isolation: restores each Herdr env value and absence exactly, including after a case fails", async () => { const caseStart = saveHerdrEnv(); process.env.HERDR_ENV = "sentinel-env"; delete process.env.HERDR_SOCKET_PATH; process.env.HERDR_PANE_ID = "sentinel-pane"; const sentinel = saveHerdrEnv(); const h = await createHarness(); expect(process.env.HERDR_ENV).toBe("1"); expect(process.env.HERDR_SOCKET_PATH).toBe(h.socketPathValue); expect(process.env.HERDR_PANE_ID).toBe("w1:p1"); await h.cleanup(); try { process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = "/mutated.sock"; delete process.env.HERDR_PANE_ID; throw new Error("simulated case failure"); } catch { restoreHerdrEnv(sentinel); } expect(process.env.HERDR_ENV).toBe("sentinel-env"); expect(hasHerdrEnv("HERDR_SOCKET_PATH")).toBe(false); expect(process.env.HERDR_PANE_ID).toBe("sentinel-pane"); // Leave the exact pre-case env for `afterEach` to restore from ORIGINAL_ENV. restoreHerdrEnv(caseStart); }); it("fixture: buffers per-socket UTF-8 frames and parses only complete newline-terminated requests", async () => { const h = await createHarness(); const client = net.createConnection(h.socketEndpoint); await new Promise((resolve, reject) => { client.once("connect", () => resolve()); client.once("error", reject); }); try { const payload = JSON.stringify({ method: "pane.report_agent", params: { state: "working", note: "✓" } }); const frame = Buffer.from(`${payload}\n`, "utf8"); const check = Buffer.from("✓", "utf8"); const checkIndex = frame.indexOf(check); expect(checkIndex).toBeGreaterThan(0); // Split both the frame and the multibyte UTF-8 sequence across chunks. client.write(frame.subarray(0, checkIndex + 1)); client.write(frame.subarray(checkIndex + 1)); // Two complete frames in a single chunk. client.write( `${JSON.stringify({ method: "pane.report_agent", params: { state: "blocked" } })}\n` + `${JSON.stringify({ method: "pane.report_agent", params: { state: "idle" } })}\n`, ); await waitFor(() => h.requests("pane.report_agent").length === 3, "three framed requests parsed"); expect(h.wire.filter((record) => record.method === undefined)).toHaveLength(0); expect(h.requests("pane.report_agent").map((record) => record.params)).toEqual([ { state: "working", note: "✓" }, { state: "blocked" }, { state: "idle" }, ]); } finally { await new Promise((resolve) => client.end(() => resolve())); } }); it("A1/A2: stays working when the parent settles with projected work, overlap and partial completion; idles only after the last run", async () => { const h = await createHarness(); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.runningCtx); await waitFor(() => h.latestState() === "working", "startup working report"); h.bridge.sessionStarted({ hasUI: true, runs: [] }); h.startRun({ id: "run-1", agent: "builder", taskLabel: "RAW-PROMPT-LEAK-CANARY" }); const settleMark = h.stateReports().length; await h.app.emit("agent_settled", { type: "agent_settled" }, h.idleCtx); await h.app.emit("agent_settled", { type: "agent_settled" }, h.idleCtx); // The settle happens with no report in flight, so a buggy idle would be // dequeued immediately; the held response keeps it observable and the // barrier cannot coalesce it away. const afterSettle = await h.heldSliceFrom(settleMark); expect(afterSettle.map((report) => report.state)).toEqual([]); await h.clearBlockAndWait("working"); h.startRun({ id: "run-2", agent: "reviewer" }); expect(h.latestState()).toBe("working"); h.completeRun("run-1"); const partialMark = h.stateReports().length; const afterPartialCompletion = await h.heldSliceFrom(partialMark); expect(afterPartialCompletion.map((report) => report.state)).toEqual([]); await h.clearBlockAndWait("working"); h.completeRun("run-2"); await waitFor(() => h.latestState() === "idle", "idle after the last run completes"); expect(JSON.stringify(h.wire)).not.toContain("RAW-PROMPT-LEAK-CANARY"); expect(h.stateReports().every((report) => report.state !== "working" || report.message === undefined)).toBe(true); }); it("A2: repeated busy refreshes add no duplicate report while one is already in flight", async () => { const h = await createHarness(); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); h.bridge.sessionStarted({ hasUI: true, runs: [] }); const mark = h.stateReports().length; h.holdResponses(); h.startRun({ id: "run-1", agent: "builder" }); // Label/count churn and a duplicate aggregate refresh are the same // unfinished value: none of them may enqueue another report. h.startRun({ id: "run-2", agent: "reviewer" }); h.bus.emit("herdr:busy", { active: true, label: "⏳ 2 subagents (builder, reviewer)" }); const slice = await h.heldSliceFrom(mark); expect(slice.map((report) => report.state)).toEqual(["working"]); await h.clearBlockAndWait("working"); h.completeRun("run-1"); h.completeRun("run-2"); await waitFor(() => h.latestState() === "idle", "idle after the last run completes"); }); it("A2: an active parent stays working when the last child completes and idles only after it settles", async () => { const h = await createHarness(); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.runningCtx); await waitFor(() => h.latestState() === "working", "startup working report"); h.bridge.sessionStarted({ hasUI: true, runs: [] }); h.startRun({ id: "run-1", agent: "worker" }); h.completeRun("run-1"); const afterCompletion = await h.blockBarrier(); expect(afterCompletion.map((report) => report.state)).toEqual([]); await h.clearBlockAndWait("working"); expect(h.latestState()).toBe("working"); await h.app.emit("agent_settled", { type: "agent_settled" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle once the parent settles with no work"); expect(h.requests("pane.report_agent_session").map((report) => report.params?.session_start_source)).toEqual([ "startup", ]); expect(h.stateReports().every((report) => report.agent_session_path === SESSION_FILE || report.agent_session_id === SESSION_ID)).toBe(true); expectSeqMonotonic(h.wire); }); it("A2: coordinator-only workflow and simulated TTL label refresh never idle; final completion does", async () => { let projection: Array> = [{ id: "wf-1", coordinator: true }]; const h = await createHarness({ refreshMs: 20, getRuns: () => projection }); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); h.bridge.sessionStarted({ hasUI: true, runs: projection }); await waitFor(() => h.latestState() === "working", "coordinator-only workflow is unfinished work"); expect(h.timers.active()).toBe(1); projection = [{ id: "wf-1", coordinator: true, taskLabel: "phase two" }]; h.timers.tick(); const afterRefresh = await h.blockBarrier(); expect(afterRefresh.map((report) => report.state)).toEqual([]); await h.clearBlockAndWait("working"); h.completeRun("wf-1"); await waitFor(() => h.latestState() === "idle", "idle after the workflow completes"); expect(h.timers.active()).toBe(0); }); it("A3: blocked outranks busy, nested blocks persist, unblock resumes working, child attention is not blocked", async () => { const h = await createHarness(); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); h.bridge.sessionStarted({ hasUI: true, runs: [{ id: "run-3", agent: "worker" }] }); await waitFor(() => h.latestState() === "working", "restored child work reports working"); // Child attention is addressed to the parent: it must not raise the human block. h.bus.emit(SUBAGENT_CONTROL_EVENT, { source: "async", event: { type: "needs_attention", runId: "run-3" } }); expect(h.latestState()).toBe("working"); h.bus.emit("herdr:blocked", { active: true, label: "Waiting for input" }); await waitFor(() => h.latestState() === "blocked", "human block outranks busy work"); expect(h.stateReports().at(-1)?.message).toBe("Waiting for input"); h.bus.emit("herdr:blocked", { active: true, label: "Second request" }); await waitFor(() => h.stateReports().at(-1)?.message === "Second request", "nested block keeps blocked state"); h.bus.emit("herdr:blocked", { active: false }); expect(h.latestState()).toBe("blocked"); h.bus.emit("herdr:blocked", { active: false }); await waitFor(() => h.latestState() === "working", "clearing the last block resumes working"); expect(h.stateReports().at(-1)?.message).toBeUndefined(); }); it("A4: reload starts fresh, restores busy for the successor, and keeps sequence/session identity", async () => { const h = await createHarness(); const appA = h.app; const bridgeA = h.bridge; await appA.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); bridgeA.sessionStarted({ hasUI: true, runs: [{ id: "run-1", agent: "builder" }] }); await waitFor(() => h.latestState() === "working", "restored startup busy is working"); await appA.emit("session_shutdown", { type: "session_shutdown", reason: "reload" }, h.idleCtx); bridgeA.dispose(); const bridgeB = h.newBridge(); const appB = h.newReporter(); await appB.emit("session_start", { type: "session_start", reason: "reload" }, h.idleCtx); // A fresh instance must not inherit the predecessor's busy value; only // the producer's session_start resync may raise it again. await waitFor(() => h.latestState() === "idle", "successor reports the idle parent without stale busy"); bridgeB.sessionStarted({ hasUI: true, runs: [{ id: "run-2", agent: "worker" }] }); await waitFor(() => h.latestState() === "working", "successor consumes the restored busy resync"); expect(h.busyEvents.at(-1)?.active).toBe(true); // Leftover events against the silenced predecessor cannot alter successor state. await appA.emit("agent_settled", { type: "agent_settled" }, h.idleCtx); const afterLatePredecessor = await h.blockBarrier(); expect(afterLatePredecessor.map((report) => report.state)).toEqual([]); await h.clearBlockAndWait("working"); h.completeRun("run-2"); await waitFor(() => h.latestState() === "idle", "idle after the restored work completes"); expect(h.requests("pane.release_agent")).toHaveLength(0); const sessionReports = h.requests("pane.report_agent_session"); expect(sessionReports.map((report) => report.params?.session_start_source)).toEqual(["startup", "reload"]); expect( sessionReports.every((report) => report.params?.source === "herdr:selesai" && report.params?.agent === "selesai"), ).toBe(true); expect( h.stateReports().every((report) => report.agent_session_path === SESSION_FILE || report.agent_session_id === SESSION_ID), ).toBe(true); expectSeqMonotonic(h.wire); }); it("A4: resume starts fresh and restores busy without leaking the predecessor's", async () => { const h = await createHarness(); const appA = h.app; const bridgeA = h.bridge; await appA.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); bridgeA.sessionStarted({ hasUI: true, runs: [{ id: "run-1", agent: "builder" }] }); await waitFor(() => h.latestState() === "working", "predecessor is working from restored busy"); const mark = h.stateReports().length; await appA.emit("session_shutdown", { type: "session_shutdown", reason: "resume" }, h.idleCtx); bridgeA.dispose(); const bridgeB = h.newBridge(); const appB = h.newReporter(); await appB.emit("session_start", { type: "session_start", reason: "resume" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "successor resume reports the idle parent without leaked busy"); expect(h.stateReports().slice(mark).map((report) => report.state)).toEqual(["idle"]); bridgeB.sessionStarted({ hasUI: true, runs: [{ id: "run-2", agent: "worker" }] }); await waitFor(() => h.latestState() === "working", "resume restoration consumes the busy resync"); h.completeRun("run-2"); await waitFor(() => h.latestState() === "idle", "idle after restored resume work completes"); expect(h.requests("pane.release_agent")).toHaveLength(0); expect(h.requests("pane.report_agent_session").map((report) => report.params?.session_start_source)).toEqual([ "startup", "resume", ]); expect(h.stateReports().every((report) => report.agent_session_path === SESSION_FILE || report.agent_session_id === SESSION_ID)).toBe(true); expectSeqMonotonic(h.wire); }); it("A4: a new session with an empty projection stays idle and never inherits predecessor busy", async () => { const h = await createHarness(); const appA = h.app; const bridgeA = h.bridge; await appA.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); bridgeA.sessionStarted({ hasUI: true, runs: [{ id: "run-1", agent: "builder" }] }); await waitFor(() => h.latestState() === "working", "predecessor is working"); await appA.emit("session_shutdown", { type: "session_shutdown", reason: "new" }, h.idleCtx); bridgeA.dispose(); const busyMark = h.busyEvents.length; const bridgeB = h.newBridge(); const appB = h.newReporter(); await appB.emit("session_start", { type: "session_start", reason: "new" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "successor new session reports the idle parent"); const mark = h.stateReports().length; bridgeB.sessionStarted({ hasUI: true, runs: [] }); expect(h.busyEvents.slice(busyMark).filter((event) => event.active === true)).toEqual([]); const afterEmptyProjection = await h.heldSliceFrom(mark); expect(afterEmptyProjection.map((report) => report.state)).toEqual([]); await h.clearBlockAndWait("idle"); expect(h.requests("pane.release_agent")).toHaveLength(0); expect(h.requests("pane.report_agent_session").map((report) => report.params?.session_start_source)).toEqual([ "startup", "new", ]); expectSeqMonotonic(h.wire); }); it("A4: quit waits for the in-flight state report, releases last, and drops late events", async () => { const h = await createHarness(); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); h.holdResponses(); h.bus.emit("herdr:blocked", { active: true, label: "held state" }); await waitFor(() => h.requests("pane.report_agent").length === 2, "held blocked report reached the socket"); const quit = h.app.emit("session_shutdown", { type: "session_shutdown", reason: "quit" }, h.idleCtx); // The reporter is released synchronously; these late events must never // reach the pane, not even queued behind the held state report. h.bus.emit("herdr:blocked", { active: true, label: "late human block" }); h.bus.emit("herdr:busy", { active: true, label: "late busy" }); expect(h.requests("pane.release_agent")).toHaveLength(0); h.releaseResponses(); await quit; expect(h.requests("pane.release_agent")).toHaveLength(1); expect(h.wire.at(-1)?.method).toBe("pane.release_agent"); const methods = h.wire.map((record) => record.method); expect(methods.indexOf("pane.release_agent")).toBe(methods.length - 1); expect(h.requests("pane.report_agent").map((record) => record.params?.state)).toEqual(["idle", "blocked"]); }); it("A2: same-tick churn coalesces to the latest state and never sends idle while work remains", async () => { const h = await createHarness(); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); h.bridge.sessionStarted({ hasUI: true, runs: [] }); const mark = h.stateReports().length; h.startRun({ id: "run-1", agent: "builder" }); h.completeRun("run-1"); h.startRun({ id: "run-2", agent: "worker" }); await waitFor(() => h.latestState() === "working", "coalesced working report after churn"); const churn = h.stateReports().slice(mark); expect(churn.length).toBeGreaterThan(0); expect(churn.every((report) => report.state === "working")).toBe(true); h.completeRun("run-2"); await waitFor(() => h.latestState() === "idle", "idle after the last run completes"); }); it("A4: falls back to agent_session_id and re-reports the session ref on agent_start", async () => { const h = await createHarness(); const ctx: Ctx = { mode: "tui", hasUI: true, isIdle: () => false, sessionManager: { getSessionFile: () => undefined, getSessionId: () => SESSION_ID }, }; await h.app.emit("session_start", { type: "session_start", reason: "startup" }, ctx); await waitFor(() => h.latestState() === "working", "working report keyed by session id"); const sessionReports = h.requests("pane.report_agent_session"); expect(sessionReports).toHaveLength(1); expect(sessionReports[0]?.params?.agent_session_id).toBe(SESSION_ID); expect(sessionReports[0]?.params?.agent_session_path).toBeUndefined(); await h.app.emit("agent_start", { type: "agent_start" }, ctx); await waitFor(() => h.requests("pane.report_agent_session").length === 2, "agent_start re-reports the session"); expect(h.stateReports().every((report) => report.agent_session_id === SESSION_ID)).toBe(true); }); it("A4: skips session reports without a session reference and tolerates throwing session-manager getters", async () => { const h = await createHarness(); const noRefCtx: Ctx = { mode: "tui", hasUI: true, isIdle: () => true, sessionManager: {} }; await h.app.emit("session_start", { type: "session_start", reason: "startup" }, noRefCtx); await waitFor(() => h.latestState() === "idle", "idle report without a session reference"); expect(h.requests("pane.report_agent_session")).toHaveLength(0); expect( h.stateReports().every((report) => report.agent_session_path === undefined && report.agent_session_id === undefined), ).toBe(true); const throwingCtx: Ctx = { mode: "tui", hasUI: true, isIdle: () => false, sessionManager: { getSessionFile: () => { throw new Error("no session file"); }, getSessionId: () => { throw new Error("no session id"); }, }, }; await h.app.emit("agent_start", { type: "agent_start" }, throwingCtx); await waitFor(() => h.stateReports().at(-1)?.state === "working", "agent_start survives throwing getters"); expect(h.requests("pane.report_agent_session")).toHaveLength(0); }); it("A5: stays inert outside a Herdr pane", async () => { const h = await createHarness({ enabled: false }); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.runningCtx); h.bridge.sessionStarted({ hasUI: true, runs: [{ id: "run-1", agent: "builder" }] }); h.bus.emit("herdr:busy", { active: true, label: "ignored" }); h.bus.emit("herdr:blocked", { active: true, label: "ignored" }); await h.app.emit("agent_start", { type: "agent_start" }, h.runningCtx); await h.app.emit("agent_settled", { type: "agent_settled" }, h.idleCtx); expect(h.wire).toEqual([]); }); it("A5: keeps best-effort semantics when no Herdr socket is listening", async () => { const h = await createHarness(); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, h.idleCtx); await waitFor(() => h.latestState() === "idle", "idle startup report"); h.bridge.sessionStarted({ hasUI: true, runs: [] }); await h.stopServer(); const mark = h.stateReports().length; h.startRun({ id: "run-1", agent: "builder" }); h.completeRun("run-1"); await h.app.emit("session_shutdown", { type: "session_shutdown", reason: "quit" }, h.idleCtx); expect(h.stateReports().length).toBe(mark); expect(h.requests("pane.release_agent")).toHaveLength(0); }); it.each(["rpc", "json", "print"])("A5: %s mode with hasUI=true never claims pane lifecycle authority", async (mode) => { const h = await createHarness(); const ctx: Ctx = { mode, hasUI: true, isIdle: () => false, sessionManager: { getSessionFile: () => SESSION_FILE, getSessionId: () => SESSION_ID }, }; await h.app.emit("agent_start", { type: "agent_start" }, ctx); await h.app.emit("session_start", { type: "session_start", reason: "startup" }, ctx); h.bridge.sessionStarted({ hasUI: true, runs: [{ id: "run-1", agent: "worker" }] }); await waitFor(() => h.busyEvents.some((event) => event.active === true), "bridge raised busy for the headless session"); h.bus.emit("herdr:blocked", { active: true, label: "ignored" }); await h.app.emit("agent_settled", { type: "agent_settled" }, ctx); expect(h.wire).toEqual([]); }); it("isolation: cleanup restores the exact pre-run Herdr env after the cases that mutated it", () => { // Runs last: every earlier case called `createHarness`, so this observes // what `afterEach` restored for each key, values and absences alike. for (const key of ENV_KEYS) { const expected = ORIGINAL_ENV.get(key); if (expected === undefined) expect(hasHerdrEnv(key)).toBe(false); else expect(process.env[key]).toBe(expected); } }); });