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.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { taskToolRenderer } from "@oh-my-pi/pi-coding-agent/task/render";
|
||||
import type { AgentProgress, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types";
|
||||
import type { AgentProgress, SingleResult, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types";
|
||||
|
||||
function runningProgress(overrides: Partial<AgentProgress> = {}): AgentProgress {
|
||||
return {
|
||||
@@ -16,6 +16,7 @@ function runningProgress(overrides: Partial<AgentProgress> = {}): AgentProgress
|
||||
recentTools: [],
|
||||
recentOutput: [],
|
||||
toolCount: 0,
|
||||
requests: 0,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
durationMs: 0,
|
||||
@@ -23,6 +24,24 @@ function runningProgress(overrides: Partial<AgentProgress> = {}): AgentProgress
|
||||
};
|
||||
}
|
||||
|
||||
function finishedResult(overrides: Partial<SingleResult> = {}): SingleResult {
|
||||
return {
|
||||
index: 0,
|
||||
id: "Agent",
|
||||
agent: "task",
|
||||
agentSource: "bundled",
|
||||
task: "investigate hot paths",
|
||||
exitCode: 0,
|
||||
output: "done",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
durationMs: 0,
|
||||
tokens: 0,
|
||||
requests: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function detailsFor(progress: AgentProgress): TaskToolDetails {
|
||||
return { projectAgentsDir: null, results: [], totalDurationMs: 0, progress: [progress] };
|
||||
}
|
||||
@@ -102,7 +121,64 @@ describe("task progress rendering", () => {
|
||||
expect(strippedRow).not.toContain(theme.getSpinnerFrames("status")[0]);
|
||||
});
|
||||
|
||||
it("pins unfinished tasks below finished ones in the live view", async () => {
|
||||
it("shimmers the pending description like a running one (frozen async spawn snapshot)", async () => {
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 };
|
||||
const progress = runningProgress({
|
||||
id: "BestGpt",
|
||||
status: "pending",
|
||||
description: "Combine winners for gpt",
|
||||
});
|
||||
|
||||
const renderRow = (timeMs: number): string => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(timeMs);
|
||||
return findRow(
|
||||
taskToolRenderer.renderResult(
|
||||
{ content: [{ type: "text", text: "" }], details: detailsFor(progress) },
|
||||
options,
|
||||
theme,
|
||||
),
|
||||
"BestGpt",
|
||||
);
|
||||
};
|
||||
|
||||
const rawRow0 = renderRow(0);
|
||||
const rawRow1 = renderRow(700);
|
||||
|
||||
expect(Bun.stripANSI(rawRow0)).toContain("BestGpt: Combine winners for gpt");
|
||||
// The label stays one solid bold-accent run; the description shimmers,
|
||||
// so the row animates across frames exactly like a running agent's.
|
||||
const label = theme.fg("accent", theme.bold("BestGpt"));
|
||||
expect(rawRow0).toContain(label);
|
||||
expect(rawRow1).toContain(label);
|
||||
expect(rawRow0).not.toBe(rawRow1);
|
||||
});
|
||||
|
||||
it("renders the assignment markdown inside the result frame", async () => {
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
setThemeInstance(theme);
|
||||
const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 };
|
||||
const progress = runningProgress({ id: "BestGpt", status: "pending", description: "Combine winners" });
|
||||
|
||||
const rendered = Bun.stripANSI(
|
||||
taskToolRenderer
|
||||
.renderResult(
|
||||
{ content: [{ type: "text", text: "Spawned agent BestGpt..." }], details: detailsFor(progress) },
|
||||
options,
|
||||
theme,
|
||||
{ agent: "task", id: "BestGpt", assignment: "# Target\nCombine the winning patches." },
|
||||
)
|
||||
.render(120)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
// The brief stays visible for the whole task lifecycle, not just while
|
||||
// the call args stream in.
|
||||
expect(rendered).toContain("Target");
|
||||
expect(rendered).toContain("Combine the winning patches.");
|
||||
});
|
||||
|
||||
it("pins unfinished tasks below finished ones, finished sorted by runtime asc", async () => {
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
const options: RenderResultOptions = { expanded: false, isPartial: true, spinnerFrame: 0 };
|
||||
const details: TaskToolDetails = {
|
||||
@@ -110,10 +186,10 @@ describe("task progress rendering", () => {
|
||||
results: [],
|
||||
totalDurationMs: 0,
|
||||
progress: [
|
||||
runningProgress({ index: 0, id: "FirstRunning", status: "running" }),
|
||||
runningProgress({ index: 1, id: "DoneEarly", status: "completed" }),
|
||||
runningProgress({ index: 0, id: "FirstRunning", status: "running", durationMs: 9000 }),
|
||||
runningProgress({ index: 1, id: "DoneSlow", status: "completed", durationMs: 5000 }),
|
||||
runningProgress({ index: 2, id: "StillPending", status: "pending" }),
|
||||
runningProgress({ index: 3, id: "FailedFast", status: "failed" }),
|
||||
runningProgress({ index: 3, id: "FailedFast", status: "failed", durationMs: 1000 }),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -124,8 +200,34 @@ describe("task progress rendering", () => {
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
// Finished agents (in dispatch order) come first; pending/running stay at the bottom.
|
||||
const positions = ["DoneEarly", "FailedFast", "FirstRunning", "StillPending"].map(id => rendered.indexOf(id));
|
||||
// Finished agents sorted by runtime ascending; pending/running stay at the
|
||||
// bottom in dispatch order.
|
||||
const positions = ["FailedFast", "DoneSlow", "FirstRunning", "StillPending"].map(id => rendered.indexOf(id));
|
||||
expect(positions.every(p => p >= 0)).toBe(true);
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it("orders finalized results by runtime asc, matching the live view", async () => {
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
const options: RenderResultOptions = { expanded: false, isPartial: false };
|
||||
const details: TaskToolDetails = {
|
||||
projectAgentsDir: null,
|
||||
results: [
|
||||
finishedResult({ index: 0, id: "SlowFinish", durationMs: 9000 }),
|
||||
finishedResult({ index: 1, id: "FastFinish", durationMs: 1000 }),
|
||||
finishedResult({ index: 2, id: "MidFinish", durationMs: 4000 }),
|
||||
],
|
||||
totalDurationMs: 9000,
|
||||
};
|
||||
|
||||
const rendered = Bun.stripANSI(
|
||||
taskToolRenderer
|
||||
.renderResult({ content: [{ type: "text", text: "" }], details }, options, theme)
|
||||
.render(120)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
const positions = ["FastFinish", "MidFinish", "SlowFinish"].map(id => rendered.indexOf(id));
|
||||
expect(positions.every(p => p >= 0)).toBe(true);
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
||||
});
|
||||
@@ -144,15 +246,17 @@ describe("task result detail-less state", () => {
|
||||
|
||||
it("renders a validation failure with the error glyph, not a success bullet", async () => {
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
// The assignment section renders markdown, which reads the active theme.
|
||||
setThemeInstance(theme);
|
||||
const options: RenderResultOptions = { expanded: false, isPartial: false };
|
||||
const component = taskToolRenderer.renderResult(
|
||||
{
|
||||
content: [{ type: "text", text: 'Validation failed for tool "task": tasks: Invalid input' }],
|
||||
content: [{ type: "text", text: 'Validation failed for tool "task": assignment: Invalid input' }],
|
||||
isError: true,
|
||||
},
|
||||
options,
|
||||
theme,
|
||||
{ agent: "explore", tasks: [] },
|
||||
{ agent: "explore", assignment: "Look around." },
|
||||
);
|
||||
const stripped = Bun.stripANSI(component.render(120).join("\n"));
|
||||
|
||||
@@ -166,10 +270,11 @@ describe("task result detail-less state", () => {
|
||||
|
||||
it("renders a detail-less success with the accent bullet, not an error glyph", async () => {
|
||||
const theme = (await getThemeByName("dark"))!;
|
||||
setThemeInstance(theme);
|
||||
const options: RenderResultOptions = { expanded: false, isPartial: false };
|
||||
const component = taskToolRenderer.renderResult({ content: [{ type: "text", text: "done" }] }, options, theme, {
|
||||
agent: "explore",
|
||||
tasks: [],
|
||||
assignment: "Look around.",
|
||||
});
|
||||
const stripped = Bun.stripANSI(component.render(120).join("\n"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user