Files
oh-my-pi/packages/coding-agent/test/task/render-nested-live.test.ts
T
can1357 9d99ae1af0 feat(coding-agent): rewrote the task tool to spawn one persistent subagent per call
The task tool now takes a single { agent, assignment, description, ... } and always runs the subagent in the background — the batch tasks[] array and shared context parameter are gone. Fan-out is parallel task calls; shared background flows through a '/Users/can/.omp/agent/sessions/-Projects-.tree-pi-commit/2026-06-10T15-36-32-782Z_019eb22d-970e-7000-8964-72c98becf3e8/local' file referenced in each assignment.\n\nIntroduces a persistent subagent lifecycle: finished subagents stay live as idle, the lifecycle manager parks them to disk after task.agentIdleTtlMs (default 7 minutes; 0 keeps them live until exit), and they revive automatically when prompted from the Agent Hub, messaged on IRC, or resumed via task. New task(resume: "<id>") revives an idle or parked subagent and runs a follow-up assignment in its existing session.\n\nAdds soft request budgets (explore/quick_task 40, others 90, configurable via task.softRequestBudget, 0 disables): crossing the budget injects a one-time wrap-up steer into the child; crossing 1.5× aborts the run gracefully. Cancelled/aborted subagent salvage replaces the old (no output) with the child's last activity snippet plus request/token stats; SingleResult tracks a per-child requests counter (assistant message_end events) used to sort agent lists in runtime-ascending order in both the live progress view (finished agents above pending/running) and the finalized result view, so rows no longer reshuffle on finalize. Adds a task gallery fixture variant for the resume path (renderer key separated from fixture key).\n\nAll task tests are reshaped around the single-call contract; tests for the discarded shared-context flow are removed, and new task-guards/task-resume/task-schema tests pin the new contract surface.
2026-06-10 17:54:47 +02:00

269 lines
8.7 KiB
TypeScript

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { AgentProgress, SingleResult, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task";
import { taskToolRenderer } from "@oh-my-pi/pi-coding-agent/task/render";
import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
describe("task renderer: nested live rendering", () => {
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
setThemeInstance(theme!);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
resetSettingsForTest();
});
// Defends the live-rendering contract for the `task` tool: while a Level-1
// subagent is still mid-flight, any nested `task` activity it has produced
// (already-completed sub-calls in `extractedToolData.task`, plus the in-flight
// snapshot in `inflightTaskDetails`) MUST surface in the parent's streaming
// output — same way it surfaces in the finished result.
function makeRunningProgress(overrides: Partial<AgentProgress>): AgentProgress {
return {
index: 0,
id: "parent",
agent: "task",
agentSource: "bundled",
status: "running",
task: "parent assignment",
assignment: "parent assignment",
description: "Parent Level 1 work",
recentTools: [],
recentOutput: [],
toolCount: 1,
requests: 0,
tokens: 1000,
cost: 0,
durationMs: 1234,
...overrides,
};
}
function makeCompletedSubResult(id: string, description: string): SingleResult {
return {
index: 0,
id,
agent: "task",
agentSource: "bundled",
task: "sub assignment",
assignment: "sub assignment",
description,
exitCode: 0,
output: "sub-final-output",
stderr: "",
truncated: false,
durationMs: 500,
tokens: 200,
requests: 0,
};
}
function makeRunningSubProgress(id: string, description: string): AgentProgress {
return {
index: 0,
id,
agent: "task",
agentSource: "bundled",
status: "running",
task: "sub assignment",
assignment: "sub assignment",
description,
recentTools: [],
recentOutput: [],
toolCount: 0,
requests: 0,
tokens: 0,
cost: 0,
durationMs: 0,
};
}
async function render(progress: AgentProgress): Promise<string> {
const theme = (await getThemeByName("dark"))!;
const details: TaskToolDetails = {
projectAgentsDir: null,
results: [],
totalDurationMs: 1234,
progress: [progress],
};
const component = taskToolRenderer.renderResult(
{ content: [{ type: "text", text: "Running 1 agents..." }], details },
{ expanded: false, isPartial: true, spinnerFrame: 0 },
theme,
);
return Bun.stripANSI(component.render(160).join("\n"));
}
it("renders completed nested task results stored in extractedToolData.task while parent is in-progress", async () => {
const parent = makeRunningProgress({
id: "Parent",
recentTools: [{ tool: "task", args: "", endMs: Date.now() }],
extractedToolData: {
task: [
{
projectAgentsDir: null,
results: [
makeCompletedSubResult("Parent.AlphaSub", "Alpha child"),
makeCompletedSubResult("Parent.BetaSub", "Beta child"),
],
totalDurationMs: 1000,
} satisfies TaskToolDetails,
],
},
});
const text = await render(parent);
// Parent label is intact.
expect(text).toContain("Parent Level 1 work");
// Both nested completed children labels surface (formatTaskId renders the
// dotted hierarchy as a "Parent>AlphaSub" breadcrumb).
expect(text).toContain("Alpha child");
expect(text).toContain("Beta child");
expect(text).toContain("Parent>AlphaSub");
expect(text).toContain("Parent>BetaSub");
});
it("renders the in-flight nested task snapshot (progress[]) before the call ends", async () => {
const inflight: TaskToolDetails = {
projectAgentsDir: null,
results: [],
totalDurationMs: 0,
progress: [
makeRunningSubProgress("Parent.GammaSub", "Gamma child running"),
makeRunningSubProgress("Parent.DeltaSub", "Delta child running"),
],
};
const parent = makeRunningProgress({
id: "Parent",
currentTool: "task",
currentToolStartMs: Date.now(),
inflightTaskDetails: inflight,
});
const text = await render(parent);
expect(text).toContain("Parent Level 1 work");
expect(text).toContain("Gamma child running");
expect(text).toContain("Delta child running");
expect(text).toContain("Parent>GammaSub");
expect(text).toContain("Parent>DeltaSub");
});
it("combines completed and in-flight nested snapshots in one tree", async () => {
const parent = makeRunningProgress({
currentTool: "task",
extractedToolData: {
task: [
{
projectAgentsDir: null,
results: [makeCompletedSubResult("Parent.EpsilonSub", "Epsilon done")],
totalDurationMs: 1000,
} satisfies TaskToolDetails,
],
},
inflightTaskDetails: {
projectAgentsDir: null,
results: [],
totalDurationMs: 0,
progress: [makeRunningSubProgress("Parent.ZetaSub", "Zeta running")],
},
});
const text = await render(parent);
expect(text).toContain("Epsilon done");
expect(text).toContain("Zeta running");
// Completed entry shows "done" badge, in-flight does not.
const epsilonIdx = text.indexOf("Epsilon done");
const zetaIdx = text.indexOf("Zeta running");
// Completed entries are emitted before the in-flight snapshot.
expect(epsilonIdx).toBeLessThan(zetaIdx);
});
it("formats running progress stats with tool icon, context window, and cost", async () => {
const theme = (await getThemeByName("dark"))!;
const text = await render(
makeRunningProgress({
toolCount: 19,
contextTokens: 58_000,
contextWindow: 272_000,
cost: 2.1,
durationMs: 0,
}),
);
// Context now matches the status line gauge: 58000/272000 → 21.3%/272K.
// Cost is separated by the theme dot separator, not a literal ".".
const expectedStats = `${formatNumber(19)} ${theme.icon.extensionTool}${theme.sep.dot}21.3%/272K${theme.sep.dot}$2.10`;
expect(text).toContain(expectedStats);
expect(text).not.toContain("tools");
expect(text).not.toContain("ctx");
expect(text).not.toContain("Σ");
});
it("renders a static result header while the body shimmers the running task name", async () => {
const theme = (await getThemeByName("dark"))!;
const details: TaskToolDetails = {
projectAgentsDir: null,
results: [],
totalDurationMs: 0,
progress: [makeRunningProgress({ id: "Probe", description: "Investigate padding" })],
};
let now = 0;
vi.spyOn(Date, "now").mockImplementation(() => now);
const renderAt = (timeMs: number): string[] => {
now = timeMs;
const component = taskToolRenderer.renderResult(
{ content: [{ type: "text", text: "Running 1 agents..." }], details },
{ expanded: false, isPartial: true, spinnerFrame: 0 },
theme,
);
return component.render(160).join("\n").split("\n");
};
const f0 = renderAt(0);
const f1 = renderAt(700);
const header0 = f0.find(l => Bun.stripANSI(l).includes("Task"))!;
const header1 = f1.find(l => Bun.stripANSI(l).includes("Task"))!;
const body0 = f0.find(l => Bun.stripANSI(l).includes("Probe"))!;
const body1 = f1.find(l => Bun.stripANSI(l).includes("Probe"))!;
// Header is static — no clock ticking beside "Task".
expect(header0).toBe(header1);
// The per-agent body line still animates via the shimmered task name.
expect(body0).not.toBe(body1);
expect(Bun.stripANSI(body1)).toContain("• Probe: Investigate padding");
});
it("wraps the completed run summary in bracket glyphs, dropping the Total: label", async () => {
const theme = (await getThemeByName("dark"))!;
const ok = makeCompletedSubResult("Alpha", "did the thing");
const bad: SingleResult = { ...makeCompletedSubResult("Beta", "blew up"), exitCode: 1, error: "boom" };
const details: TaskToolDetails = {
projectAgentsDir: null,
results: [ok, bad],
totalDurationMs: 39_400,
};
const component = taskToolRenderer.renderResult(
{ content: [{ type: "text", text: "2 agents completed" }], details },
{ expanded: false, isPartial: false },
theme,
);
const text = Bun.stripANSI(component.render(160).join("\n"));
// Bash-style bracketed footer: dim bracket chrome wrapping colored counts,
// with no "Total:" prefix between the bracket and the first count.
const expected = `${theme.format.bracketLeft}1 succeeded${theme.sep.dot}1 failed${theme.sep.dot}${formatDuration(39_400)}${theme.format.bracketRight}`;
expect(text).toContain(expected);
expect(text).not.toContain("Total:");
});
});