feat(pi): render subagent monitor views
This commit is contained in:
@@ -165,7 +165,7 @@ test("ad hoc fallback labels are prompt-derived and reused by widget and result
|
|||||||
result: label,
|
result: label,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.ok(inspectorLines.some((line) => line.includes(`${accepted.id} ${label} independent completed`)), inspectorLines.join("\n"));
|
assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n"));
|
||||||
assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u);
|
assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
68
modules/agents/pi/extensions/subagents/ui.test.ts
Normal file
68
modules/agents/pi/extensions/subagents/ui.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
import { renderInspector, renderSummary, widget } from "./ui.ts";
|
||||||
|
|
||||||
|
function status(overrides: Partial<SubagentStatus> & { id: string; label: string; state: SubagentState }): SubagentStatus {
|
||||||
|
return {
|
||||||
|
adHoc: true,
|
||||||
|
context: "independent",
|
||||||
|
cwd: "/tmp",
|
||||||
|
elapsedMs: 0,
|
||||||
|
resultAvailable: false,
|
||||||
|
startedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
tools: "inherit",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compact monitor aggregates visible children by actionable lifecycle group", () => {
|
||||||
|
assert.deepEqual(renderSummary([]), []);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
renderSummary([
|
||||||
|
status({ id: "queued", label: "Queued", state: "queued" }),
|
||||||
|
status({ id: "starting", label: "Starting", state: "starting" }),
|
||||||
|
status({ id: "running", label: "Running", state: "running" }),
|
||||||
|
status({ id: "settling", label: "Settling", state: "settling" }),
|
||||||
|
status({ id: "completed", label: "Completed", state: "completed", resultAvailable: true }),
|
||||||
|
status({ id: "failed", label: "Failed", state: "failed", error: "boom" }),
|
||||||
|
status({ id: "timed-out", label: "Timed out", state: "timed_out" }),
|
||||||
|
status({ id: "cancelled", label: "Cancelled", state: "cancelled" }),
|
||||||
|
]),
|
||||||
|
["subagents: queued 1 · running 2 · settling 1 · completed 1 · failed 1 · timed out 1 · cancelled 1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => {
|
||||||
|
const lines = renderInspector([
|
||||||
|
status({
|
||||||
|
id: "sg-running",
|
||||||
|
label: "Audit unusually verbose guest enablement migration plan",
|
||||||
|
state: "running",
|
||||||
|
elapsedMs: 65_000,
|
||||||
|
lastEvent: "message_update",
|
||||||
|
}),
|
||||||
|
status({
|
||||||
|
id: "sg-completed",
|
||||||
|
label: "Summarize review",
|
||||||
|
state: "completed",
|
||||||
|
elapsedMs: 3_600_000,
|
||||||
|
lastEvent: "completed",
|
||||||
|
resultAvailable: true,
|
||||||
|
}),
|
||||||
|
status({ id: "sg-failed", label: "Run risky test", state: "failed", elapsedMs: 2_000, error: "exit 1" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(lines.length, 3);
|
||||||
|
assert.match(lines[0], /^▶ running +1m05s +Audit unusually verbose guest enablement migration plan +last: message_update$/u);
|
||||||
|
assert.equal(lines[1], "✓ completed 1h00m00s Summarize review result: available");
|
||||||
|
assert.equal(lines[2], "✗ failed 2s Run risky test error: exit 1");
|
||||||
|
|
||||||
|
const rendered = widget([
|
||||||
|
status({ id: "sg-running", label: "Audit unusually verbose guest enablement migration plan", state: "running", elapsedMs: 65_000, lastEvent: "message_update" }),
|
||||||
|
], true)().render(32);
|
||||||
|
|
||||||
|
assert.deepEqual(rendered, ["▶ running 1m05s Audit unusual…"]);
|
||||||
|
assert.ok(rendered.every((line) => line.length <= 32));
|
||||||
|
});
|
||||||
@@ -1,29 +1,80 @@
|
|||||||
import { isTerminalState } from "./status.ts";
|
import type { SubagentState, SubagentStatus } from "./types.ts";
|
||||||
import type { SubagentStatus } from "./types.ts";
|
|
||||||
|
const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [
|
||||||
|
{ label: "queued", states: ["queued"] },
|
||||||
|
{ label: "running", states: ["starting", "running"] },
|
||||||
|
{ label: "settling", states: ["settling"] },
|
||||||
|
{ label: "completed", states: ["completed"] },
|
||||||
|
{ label: "failed", states: ["failed"] },
|
||||||
|
{ label: "timed out", states: ["timed_out"] },
|
||||||
|
{ label: "cancelled", states: ["cancelled"] },
|
||||||
|
{ label: "orphaned", states: ["orphaned"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATE_PRESENTATION: Record<SubagentState, { icon: string; label: string }> = {
|
||||||
|
queued: { icon: "…", label: "queued" },
|
||||||
|
starting: { icon: "◌", label: "starting" },
|
||||||
|
running: { icon: "▶", label: "running" },
|
||||||
|
settling: { icon: "◒", label: "settling" },
|
||||||
|
completed: { icon: "✓", label: "completed" },
|
||||||
|
failed: { icon: "✗", label: "failed" },
|
||||||
|
cancelled: { icon: "■", label: "cancelled" },
|
||||||
|
timed_out: { icon: "⏱", label: "timed out" },
|
||||||
|
orphaned: { icon: "?", label: "orphaned" },
|
||||||
|
};
|
||||||
|
|
||||||
export function renderSummary(statuses: SubagentStatus[]): string[] {
|
export function renderSummary(statuses: SubagentStatus[]): string[] {
|
||||||
const running = statuses.filter((status) => ["starting", "running", "settling"].includes(status.state)).length;
|
const groups = COMPACT_GROUPS.map((group) => ({
|
||||||
const queued = statuses.filter((status) => status.state === "queued").length;
|
label: group.label,
|
||||||
const terminal = statuses.filter((status) => isTerminalState(status.state)).length;
|
count: statuses.filter((status) => group.states.includes(status.state)).length,
|
||||||
if (running === 0 && queued === 0 && terminal === 0) return [];
|
})).filter((group) => group.count > 0);
|
||||||
return [`subagents: ${running} running · ${queued} queued · ${terminal} terminal`];
|
|
||||||
|
if (groups.length === 0) return [];
|
||||||
|
return [`subagents: ${groups.map((group) => `${group.label} ${group.count}`).join(" · ")}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderInspector(statuses: SubagentStatus[]): string[] {
|
export function renderInspector(statuses: SubagentStatus[]): string[] {
|
||||||
const lines = renderSummary(statuses);
|
return statuses.map((status) => renderStatusRow(status));
|
||||||
for (const status of statuses) {
|
|
||||||
lines.push(
|
|
||||||
`${status.id} ${status.label} ${status.context} ${status.state} ${Math.round(status.elapsedMs / 1000)}s ${status.model ?? "inherit"} ${status.tools} ${status.lastEvent ?? "none"} result:${status.resultAvailable ? "yes" : "no"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function widget(statuses: SubagentStatus[], expanded: boolean) {
|
export function widget(statuses: SubagentStatus[], expanded: boolean) {
|
||||||
return () => ({
|
return () => ({
|
||||||
invalidate() {},
|
invalidate() {},
|
||||||
render(width: number) {
|
render(width: number) {
|
||||||
return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => (line.length > width ? line.slice(0, Math.max(0, width - 1)) : line));
|
return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => truncateLine(line, width));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStatusRow(status: SubagentStatus): string {
|
||||||
|
const presentation = STATE_PRESENTATION[status.state];
|
||||||
|
const marker = statusMarker(status);
|
||||||
|
return `${presentation.icon} ${presentation.label.padEnd(9)} ${formatDuration(status.elapsedMs)} ${status.label}${marker ? ` ${marker}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMarker(status: SubagentStatus): string | undefined {
|
||||||
|
if (status.error) return `error: ${status.error}`;
|
||||||
|
if (status.resultAvailable) return "result: available";
|
||||||
|
if (status.lastEvent) return `last: ${status.lastEvent}`;
|
||||||
|
if (status.state === "queued") return "waiting";
|
||||||
|
if (status.state === "settling") return "settling";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`;
|
||||||
|
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLine(line: string, width: number): string {
|
||||||
|
if (width <= 0) return "";
|
||||||
|
if (line.length <= width) return line;
|
||||||
|
if (width === 1) return "…";
|
||||||
|
return `${line.slice(0, width - 1)}…`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user