Files
oh-my-pi/packages/coding-agent/test/subagent-hud-render.test.ts
T
can1357 0ebe1dfe5e feat(coding-agent): filtered subagent HUD to detached background spawns only
- Added detached spawn metadata to lifecycle, progress, and executor session payloads so task and evaluator runs can mark background jobs.
- Updated subagent session tracking and HUD rendering to only show active detached spawns.
- Extended HUD tests to verify non-detached sync and eval spawns are excluded while detached flags propagate through events.
2026-06-12 14:21:17 +02:00

204 lines
7.0 KiB
TypeScript

/**
* 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<ObservableSession> & { id: string }): ObservableSession {
return {
kind: "subagent",
label: overrides.id,
status: "active",
detached: true,
lastUpdate: Date.now(),
...overrides,
};
}
function makeProgress(overrides: Partial<AgentProgress> & { 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"]);
});
});