/** * Contracts: per-item blocking split in `task` spawns. * * An item whose agent type declares `blocking: true` runs inline (the call * waits on its result); non-blocking items in the same call still spawn as * background jobs. Previously blocking was all-or-nothing per call: one scout * in a batch silently dragged every sibling down the sync path — no job ids * despite the tool description promising them, the whole turn blocked on the * slowest worker, and every spawn died with the turn signal. * * 1. A mixed batch splits: the blocking item's result returns inline, the * non-blocking item registers a job that keeps running past the return. * 2. Returned details reflect settled jobs (async.state converges), and * post-return job updates keep the inline results — they never regress to * an empty-results skeleton. * 3. An all-blocking batch stays fully synchronous (no jobs). * 4. Async schedule failure in a mixed call still returns the inline results * and reports the failed spawn. */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; import { TaskTool } from "@oh-my-pi/pi-coding-agent/task"; import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery"; import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; import type { AgentDefinition, SingleResult, TaskParams, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; const taskAgent: AgentDefinition = { name: "task", description: "General-purpose task agent", systemPrompt: "You are a task agent.", source: "bundled", }; const scoutAgent: AgentDefinition = { name: "scout", description: "Read-only scout", systemPrompt: "You are a scout.", source: "bundled", blocking: true, }; function createSession(options: { manager?: AsyncJobManager; settings?: Record } = {}): ToolSession { return { cwd: "/tmp", hasUI: false, settings: Settings.isolated(options.settings ?? { "async.enabled": true, "task.batch": true }), getSessionFile: () => null, getSessionSpawns: () => "*", getAgentId: () => null, asyncJobManager: options.manager, } as unknown as ToolSession; } function makeResult(id: string, agent: string, overrides: Partial = {}): SingleResult { return { index: 0, id, agent, agentSource: "bundled", task: "task prompt", assignment: "Do the thing.", exitCode: 0, output: `${id} output.`, stderr: "", truncated: false, durationMs: 5, tokens: 0, requests: 1, ...overrides, }; } function mockDiscovery(): void { vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [taskAgent, scoutAgent], projectAgentsDir: null, }); } function firstText(result: { content: Array<{ type: string; text?: string }> }): string { const content = result.content.find(part => part.type === "text"); return content?.type === "text" ? (content.text ?? "") : ""; } describe("task per-item blocking split", () => { const managers: AsyncJobManager[] = []; function createManager(): AsyncJobManager { const manager = new AsyncJobManager({ onJobComplete: () => {} }); managers.push(manager); return manager; } beforeEach(() => { AgentRegistry.resetGlobalForTests(); AgentLifecycleManager.resetGlobalForTests(); }); afterEach(async () => { vi.restoreAllMocks(); for (const manager of managers.splice(0)) { await manager.dispose({ timeoutMs: 1000 }); } AgentLifecycleManager.resetGlobalForTests(); AgentRegistry.resetGlobalForTests(); }); it("runs blocking items inline while non-blocking siblings spawn as jobs", async () => { mockDiscovery(); const gates = new Map>(); const started: string[] = []; vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { const id = options.id ?? "?"; started.push(id); const gate = Promise.withResolvers(); gates.set(id, gate); await gate.promise; return makeResult(id, options.agent.name); }); const manager = createManager(); const tool = await TaskTool.create(createSession({ manager })); const executePromise = tool.execute("tc-mixed", { context: "ctx", tasks: [ { name: "ScoutOne", agent: "scout", task: "Research A." }, { name: "WorkerOne", agent: "task", task: "Build B." }, ], } as TaskParams); // Both spawns run concurrently: the async job is registered immediately, // the blocking scout gates the call's return. const deadline = Date.now() + 2_000; while (started.length < 2) { if (Date.now() > deadline) throw new Error(`spawns never started: ${JSON.stringify(started)}`); await Bun.sleep(5); } const workerJob = manager.getJob("WorkerOne"); expect(workerJob).toBeDefined(); expect(workerJob!.status).toBe("running"); // Releasing only the scout settles the call; the worker keeps running. gates.get("ScoutOne")!.resolve(); const result = await executePromise; const text = firstText(result); expect(text).toContain('id="ScoutOne"'); expect(text).toContain("ScoutOne output."); expect(text).toContain("Spawned 1 background agent"); expect(text).toContain("- `WorkerOne` (job `WorkerOne`)"); expect(text).not.toContain("WorkerOne output."); expect(result.details?.results.map(r => r.id)).toEqual(["ScoutOne"]); expect(result.details?.async?.state).toBe("running"); const progressById = new Map(result.details?.progress?.map(p => [p.id, p.status])); expect(progressById.get("ScoutOne")).toBe("completed"); expect(progressById.get("WorkerOne")).toBe("running"); expect(manager.getJob("WorkerOne")!.status).toBe("running"); gates.get("WorkerOne")!.resolve(); await manager.getJob("WorkerOne")!.promise; expect(manager.getJob("WorkerOne")!.status).toBe("completed"); }); it("keeps inline results in post-return job updates and converges async state", async () => { mockDiscovery(); const gates = new Map>(); const started: string[] = []; vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { const id = options.id ?? "?"; started.push(id); const gate = Promise.withResolvers(); gates.set(id, gate); await gate.promise; return makeResult(id, options.agent.name); }); const manager = createManager(); const tool = await TaskTool.create(createSession({ manager })); const updates: Array<{ text: string; details: TaskToolDetails }> = []; const executePromise = tool.execute( "tc-mixed-settle", { context: "ctx", tasks: [ { name: "ScoutTwo", agent: "scout", task: "Research." }, { name: "WorkerTwo", agent: "task", task: "Build." }, ], } as TaskParams, undefined, update => { if (update.details) updates.push({ text: firstText(update), details: update.details }); }, ); const deadline = Date.now() + 2_000; while (started.length < 2) { if (Date.now() > deadline) throw new Error(`spawns never started: ${JSON.stringify(started)}`); await Bun.sleep(5); } // The async job settles BEFORE the blocking subset: the returned result // must already report the converged async state, not a stale "running". gates.get("WorkerTwo")!.resolve(); await manager.getJob("WorkerTwo")!.promise; gates.get("ScoutTwo")!.resolve(); const result = await executePromise; expect(result.details?.async?.state).toBe("completed"); expect(result.details?.results.map(r => r.id)).toEqual(["ScoutTwo"]); // The job's completion update carries the shared aggregate — inline // results included — never an empty-results skeleton, and exactly once. const completionUpdates = updates.filter(u => u.text.includes("Background task WorkerTwo complete.")); expect(completionUpdates).toHaveLength(1); }); it("keeps an all-blocking batch fully synchronous", async () => { mockDiscovery(); const executed: string[] = []; vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { executed.push(options.id ?? "?"); return makeResult(options.id ?? "?", options.agent.name); }); const manager = createManager(); const tool = await TaskTool.create(createSession({ manager })); const result = await tool.execute("tc-all-blocking", { context: "ctx", tasks: [ { name: "ScoutA", agent: "scout", task: "Research A." }, { name: "ScoutB", agent: "scout", task: "Research B." }, ], } as TaskParams); expect(executed.sort()).toEqual(["ScoutA", "ScoutB"]); expect(result.details?.async).toBeUndefined(); expect(result.details?.results.map(r => r.id).sort()).toEqual(["ScoutA", "ScoutB"]); expect(manager.getAllJobs()).toHaveLength(0); }); it("returns inline results and reports the failure when async scheduling fails", async () => { mockDiscovery(); vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => makeResult(options.id ?? "?", options.agent.name), ); // A running non-queued filler job exhausts maxRunningJobs, so the // worker spawn's registration throws while the scout still runs inline. const filler = Promise.withResolvers(); const manager = new AsyncJobManager({ maxRunningJobs: 1, onJobComplete: () => {} }); managers.push(manager); manager.register("bash", "filler", () => filler.promise, { id: "filler" }); const tool = await TaskTool.create(createSession({ manager })); const result = await tool.execute("tc-mixed-schedfail", { context: "ctx", tasks: [ { name: "ScoutThree", agent: "scout", task: "Research." }, { name: "WorkerThree", agent: "task", task: "Build." }, ], } as TaskParams); filler.resolve("done"); const text = firstText(result); expect(text).toContain('id="ScoutThree"'); expect(text).toContain("Failed to schedule 1 spawn"); expect(text).toContain("WorkerThree"); expect(result.details?.results.map(r => r.id)).toEqual(["ScoutThree"]); expect(result.details?.async?.state).toBe("failed"); expect(manager.getJob("WorkerThree")).toBeUndefined(); }); });