9d99ae1af0
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.
124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { TaskTool } from "@oh-my-pi/pi-coding-agent/task";
|
|
import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
|
|
import type { TaskParams } from "@oh-my-pi/pi-coding-agent/task/types";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
|
|
const TEST_AGENTS = [
|
|
{
|
|
name: "task",
|
|
description: "General-purpose task agent",
|
|
systemPrompt: "You are a task agent.",
|
|
source: "bundled" as const,
|
|
},
|
|
];
|
|
|
|
const ALL_MODES = ["default", "schema-free", "independent"] as const;
|
|
|
|
function createSession(overrides: Partial<Record<string, unknown>> = {}): ToolSession {
|
|
return {
|
|
cwd: "/tmp",
|
|
hasUI: false,
|
|
settings: Settings.isolated(overrides),
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
} as unknown as ToolSession;
|
|
}
|
|
|
|
function getSchemaProperties(tool: TaskTool): Record<string, unknown> {
|
|
const wire = toolWireSchema(tool) as { properties?: Record<string, unknown> };
|
|
return wire.properties ?? {};
|
|
}
|
|
|
|
function getFirstText(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
const content = result.content.find(part => part.type === "text");
|
|
return content?.type === "text" ? (content.text ?? "") : "";
|
|
}
|
|
|
|
function mockDiscovery(): void {
|
|
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
|
|
agents: TEST_AGENTS,
|
|
projectAgentsDir: null,
|
|
});
|
|
}
|
|
|
|
describe("task.simple", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("exposes the custom schema input only in default mode", async () => {
|
|
mockDiscovery();
|
|
|
|
const defaultTool = await TaskTool.create(createSession({ "task.simple": "default" }));
|
|
expect(getSchemaProperties(defaultTool).schema).toBeDefined();
|
|
expect(defaultTool.description).toContain("- `schema`:");
|
|
|
|
for (const mode of ["schema-free", "independent"] as const) {
|
|
const tool = await TaskTool.create(createSession({ "task.simple": mode }));
|
|
expect(getSchemaProperties(tool).schema).toBeUndefined();
|
|
expect(tool.description).not.toContain("- `schema`:");
|
|
}
|
|
});
|
|
|
|
it("never exposes batch tasks or shared context inputs in any mode", async () => {
|
|
mockDiscovery();
|
|
|
|
for (const mode of ALL_MODES) {
|
|
const tool = await TaskTool.create(createSession({ "task.simple": mode }));
|
|
const properties = getSchemaProperties(tool);
|
|
expect(properties.tasks).toBeUndefined();
|
|
expect(properties.context).toBeUndefined();
|
|
// The flat single-spawn contract is what replaced them.
|
|
expect(properties.assignment).toBeDefined();
|
|
expect(properties.resume).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("describes the non-blocking spawn and resume contract", async () => {
|
|
mockDiscovery();
|
|
|
|
const tool = await TaskTool.create(createSession({ "task.simple": "default" }));
|
|
expect(tool.description).toContain("Spawning is non-blocking");
|
|
expect(tool.description).toContain("revives an idle/parked agent");
|
|
});
|
|
|
|
it("rejects a direct schema input when the mode disables it", async () => {
|
|
mockDiscovery();
|
|
|
|
for (const mode of ["schema-free", "independent"] as const) {
|
|
const tool = await TaskTool.create(createSession({ "task.simple": mode }));
|
|
|
|
// Execution-time guard: raw params carrying `schema` are refused.
|
|
const result = await tool.execute(`tool-${mode}`, {
|
|
agent: "task",
|
|
id: "One",
|
|
description: "label",
|
|
assignment: "Do the thing.",
|
|
schema: '{"properties":{"ok":{"type":"boolean"}}}',
|
|
} as TaskParams);
|
|
expect(getFirstText(result)).toContain("does not accept `schema`");
|
|
|
|
// Round-trip guard: wire validation passes the extraneous `schema`
|
|
// through, so the execution-time check must still refuse it.
|
|
const validated = validateToolArguments(tool, {
|
|
type: "toolCall",
|
|
id: `tool-${mode}-validated`,
|
|
name: tool.name,
|
|
arguments: {
|
|
agent: "task",
|
|
id: "One",
|
|
description: "label",
|
|
assignment: "Do the thing.",
|
|
schema: '{"properties":{"ok":{"type":"boolean"}}}',
|
|
},
|
|
}) as TaskParams;
|
|
const validatedResult = await tool.execute(`tool-${mode}-validated`, validated);
|
|
expect(getFirstText(validatedResult)).toContain("does not accept `schema`");
|
|
}
|
|
});
|
|
});
|