Files
oh-my-pi/packages/coding-agent/test/task/render-yield-shape.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

137 lines
4.7 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } 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";
// Regression for #1987: when a subagent stores a non-array value in
// `extractedToolData.yield`, the renderer cast it to `Array<{ data }>` and
// then called `?.map`. Optional chaining only short-circuits on null/undefined,
// so a plain object made `.map` undefined and crashed the TUI with
// `TypeError: completeData?.map is not a function`. The renderer must tolerate
// both shapes (array and single object) without throwing, on both the live
// progress branch (`renderAgentProgress`) and the final result branch
// (`renderAgentResult`).
describe("task renderer: malformed yield slot (#1987)", () => {
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: process.cwd() });
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
setThemeInstance(theme!);
});
afterAll(() => {
resetSettingsForTest();
});
const reviewVerdict = {
overall_correctness: "correct",
confidence: 0.92,
explanation: "Looks good.",
};
function makeCompletedResult(extractedToolData: Record<string, unknown>): SingleResult {
return {
index: 0,
id: "reviewer",
agent: "reviewer",
agentSource: "bundled",
task: "review the patch",
assignment: "review the patch",
description: "review the patch",
exitCode: 0,
output: "",
stderr: "",
truncated: false,
durationMs: 250,
tokens: 100,
requests: 0,
// Cast deliberately: production typings declare `unknown[]`, but the
// renderer must defend against a stray non-array value — that's
// exactly what this regression test exercises.
extractedToolData: extractedToolData as Record<string, unknown[]>,
};
}
function makeCompletedProgress(extractedToolData: Record<string, unknown>): AgentProgress {
return {
index: 0,
id: "reviewer",
agent: "reviewer",
agentSource: "bundled",
status: "completed",
task: "review the patch",
assignment: "review the patch",
description: "review the patch",
recentTools: [],
recentOutput: [],
toolCount: 1,
requests: 0,
tokens: 100,
cost: 0,
durationMs: 250,
extractedToolData: extractedToolData as Record<string, unknown[]>,
};
}
async function renderResultText(extractedToolData: Record<string, unknown>): Promise<string> {
const theme = (await getThemeByName("dark"))!;
const details: TaskToolDetails = {
projectAgentsDir: null,
results: [makeCompletedResult(extractedToolData)],
totalDurationMs: 250,
};
const component = taskToolRenderer.renderResult(
{ content: [{ type: "text", text: "" }], details },
{ expanded: false, isPartial: false, spinnerFrame: 0 },
theme,
);
return Bun.stripANSI(component.render(160).join("\n"));
}
async function renderProgressText(extractedToolData: Record<string, unknown>): Promise<string> {
const theme = (await getThemeByName("dark"))!;
const details: TaskToolDetails = {
projectAgentsDir: null,
results: [],
totalDurationMs: 250,
progress: [makeCompletedProgress(extractedToolData)],
};
const component = taskToolRenderer.renderResult(
{ content: [{ type: "text", text: "" }], details },
{ expanded: false, isPartial: true, spinnerFrame: 0 },
theme,
);
return Bun.stripANSI(component.render(160).join("\n"));
}
it("does not throw and still surfaces the verdict when yield is a single object (result branch)", async () => {
const text = await renderResultText({
yield: { data: reviewVerdict, status: "success" },
});
expect(text).toContain("correct");
});
it("does not throw and still surfaces the verdict when yield is a single object (progress branch)", async () => {
const text = await renderProgressText({
yield: { data: reviewVerdict, status: "success" },
});
expect(text).toContain("correct");
});
it("does not throw when yield is a non-object primitive (both branches)", async () => {
// Primitives can't carry a verdict — renderer must drop them silently
// instead of crashing.
await expect(renderResultText({ yield: "not-an-array" })).resolves.toBeString();
await expect(renderProgressText({ yield: 42 })).resolves.toBeString();
});
it("still renders the canonical array shape unchanged", async () => {
const text = await renderResultText({
yield: [{ data: reviewVerdict, status: "success" }],
});
expect(text).toContain("correct");
});
});