/** * Contract: the anchored subagent HUD (rendered above the editor, next to the * Todos block) lists exactly the running *detached* subagents as * `Id: description` rows and yields no output once nothing qualifies, so the * block self-clears. Sync task spawns and eval `agent()` spawns are excluded: * their progress is already rendered inline (tool block / eval cell). */ import { beforeAll, describe, expect, it } from "bun:test"; import { renderSubagentHudLines } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { type ObservableSession, SessionObserverRegistry, } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { type AgentProgress, type SubagentLifecyclePayload, type SubagentProgressPayload, TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL, } from "@oh-my-pi/pi-coding-agent/task"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; function makeSession(overrides: Partial & { id: string }): ObservableSession { return { kind: "subagent", label: overrides.id, status: "active", detached: true, lastUpdate: Date.now(), ...overrides, }; } function makeProgress(overrides: Partial & { id: string }): AgentProgress { return { index: 0, agent: "task", agentSource: "bundled", status: "running", task: "", recentTools: [], recentOutput: [], toolCount: 0, requests: 0, tokens: 0, cost: 0, durationMs: 0, ...overrides, }; } function makeLifecycle(id: string, index: number, description: string, detached?: boolean): SubagentLifecyclePayload { return { id, index, agent: "task", agentSource: "bundled", description, status: "started", parentToolCallId: "tool-call", detached, }; } function makeProgressPayload( id: string, index: number, description: string, detached?: boolean, ): SubagentProgressPayload { return { index, agent: "task", agentSource: "bundled", task: description, parentToolCallId: "tool-call", detached, progress: makeProgress({ id, index, description, task: description }), }; } function render(sessions: ObservableSession[], columns = 120): string { return Bun.stripANSI(renderSubagentHudLines(sessions, columns).join("\n")); } describe("subagent HUD lines", () => { beforeAll(async () => { await initTheme(); }); it("renders running subagents as Id: description under a Subagents header", () => { const out = render([ makeSession({ id: "AuthLoader", description: "Refactoring the auth flow" }), makeSession({ id: "SchemaMigrator", description: "Migrating the users table" }), ]); expect(out).toContain("Subagents"); expect(out).toContain("AuthLoader: Refactoring the auth flow"); expect(out).toContain("SchemaMigrator: Migrating the users table"); }); it("only shows active subagents and clears once everything finished", () => { const finishedStates = ["completed", "failed", "aborted"] as const; const sessions: ObservableSession[] = [ { id: "main", kind: "main", label: "Main Session", status: "active", lastUpdate: Date.now() }, ...finishedStates.map(status => makeSession({ id: `Done-${status}`, status, description: "old work" })), ]; expect(renderSubagentHudLines(sessions, 120)).toEqual([]); const out = render([...sessions, makeSession({ id: "StillRunning", description: "live work" })]); expect(out).toContain("StillRunning: live work"); expect(out).not.toContain("Done-"); expect(out).not.toContain("Main Session"); }); it("falls back to the description and task carried by progress snapshots", () => { const fromProgressDesc = render([ makeSession({ id: "Worker", progress: makeProgress({ id: "Worker", description: "From progress" }) }), ]); expect(fromProgressDesc).toContain("Worker: From progress"); const fromTask = render([ makeSession({ id: "Worker", progress: makeProgress({ id: "Worker", task: "Investigate flaky CI on macOS" }) }), ]); expect(fromTask).toContain("Worker Investigate flaky CI on macOS"); }); it("hides non-detached spawns: sync task calls and eval agent() helpers", () => { // Sync task spawn (parent blocked on the call) and eval `agent()` spawn // (no detached flag at all) both stay off the HUD. const sessions = [ makeSession({ id: "SyncSpawn", description: "inline task work", detached: false }), makeSession({ id: "EvalSpawn", description: "eval cell work", detached: undefined }), ]; expect(renderSubagentHudLines(sessions, 120)).toEqual([]); const out = render([...sessions, makeSession({ id: "BackgroundSpawn", description: "detached work" })]); expect(out).toContain("BackgroundSpawn: detached work"); expect(out).not.toContain("SyncSpawn"); expect(out).not.toContain("EvalSpawn"); }); it("threads the detached flag from lifecycle and progress payloads", () => { const eventBus = new EventBus(); const registry = new SessionObserverRegistry(); registry.subscribeToEventBus(eventBus); eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, makeLifecycle("Detached", 0, "background work", true)); eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, makeLifecycle("Inline", 1, "sync work")); eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, makeProgressPayload("FromProgress", 2, "background work", true)); const out = render(registry.getSessions()); expect(out).toContain("Detached: background work"); expect(out).toContain("FromProgress: background work"); expect(out).not.toContain("Inline"); }); it("renders nested ids as a breadcrumb and truncates long descriptions to the viewport", () => { const out = render([makeSession({ id: "Anna.Bob", description: `start ${"x".repeat(300)} end` })], 60); expect(out).toContain("Anna>Bob:"); expect(out).not.toContain("end"); for (const line of out.split("\n")) { expect(Bun.stringWidth(line)).toBeLessThanOrEqual(60); } }); it("keeps subagent registry order stable while progress arrives out of order", () => { const eventBus = new EventBus(); const registry = new SessionObserverRegistry(); registry.subscribeToEventBus(eventBus); const activeIds = () => registry .getSessions() .filter(session => session.kind === "subagent" && session.status === "active") .map(session => session.id); eventBus.emit( TASK_SUBAGENT_LIFECYCLE_CHANNEL, makeLifecycle("BlastRadius", 1, "Survey id-keyed downstream consumers"), ); eventBus.emit( TASK_SUBAGENT_LIFECYCLE_CHANNEL, makeLifecycle("SelectorSurfaces", 0, "Map model-selector resolution surfaces"), ); eventBus.emit( TASK_SUBAGENT_LIFECYCLE_CHANNEL, makeLifecycle("VariantsSurvey", 2, "Survey tier-variant ids across catalog"), ); expect(activeIds()).toEqual(["SelectorSurfaces", "BlastRadius", "VariantsSurvey"]); eventBus.emit( TASK_SUBAGENT_PROGRESS_CHANNEL, makeProgressPayload("VariantsSurvey", 2, "Survey tier-variant ids across catalog"), ); eventBus.emit( TASK_SUBAGENT_PROGRESS_CHANNEL, makeProgressPayload("BlastRadius", 1, "Survey id-keyed downstream consumers"), ); expect(activeIds()).toEqual(["SelectorSurfaces", "BlastRadius", "VariantsSurvey"]); }); });