/** * Integration tests for async (background) agent execution. * * Tests the async support utilities: jiti availability check, * status file reading/caching. * * Requires pi packages to be importable. Skips gracefully if unavailable. */ import { after, afterEach, before, beforeEach, describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { createEventBus, createMockPi, createTempDir, events, makeAgent, makeMinimalCtx, removeTempDir, tryImport } from "../support/helpers.ts"; import type { MockPi } from "../support/helpers.ts"; import { deliverInterruptRequest } from "../../src/runs/background/control-channel.ts"; interface AsyncExecutionResult { content: Array<{ text?: string }>; isError?: boolean; details: { asyncId?: string }; } interface AsyncResultPayload { lifecycleArtifactVersion?: number; success: boolean; state?: string; exitCode?: number; sessionId?: string; mode?: string; summary?: string; error?: string; timeoutMs?: number; deadlineAt?: number; timedOut?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; totalTokens?: { input: number; output: number; total: number }; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; results: Array<{ output?: string; success?: boolean; error?: string; timedOut?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; model?: string; attemptedModels?: string[]; modelAttempts?: Array<{ success?: boolean; error?: string }>; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; structuredOutput?: unknown; intercomTarget?: string; acceptance?: { status?: string; childReport?: unknown } }>; outputs?: Record; workflowGraph?: { nodes?: Array<{ kind?: string; label?: string; phase?: string; status?: string; error?: string; outputName?: string; structured?: boolean; children?: Array<{ label?: string; outputName?: string; itemKey?: string; status?: string; error?: string }> }> }; } interface AsyncStatusPayload { lifecycleArtifactVersion?: number; sessionId?: string; activityState?: string; currentTool?: string; currentPath?: string; state?: string; error?: string; timeoutMs?: number; deadlineAt?: number; timedOut?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; totalTokens?: { total: number }; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; parallelGroups?: Array<{ start: number; count: number; stepIndex: number }>; steps?: Array<{ label?: string; phase?: string; outputName?: string; structured?: boolean; skills?: string[]; activityState?: string; currentTool?: string; status?: string; exitCode?: number; timedOut?: boolean; error?: string; model?: string; thinking?: string; tokens?: { total: number }; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; acceptance?: { status?: string }; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; }>; } interface MockPiCallRecord { args?: string[]; systemPrompts?: Array<{ mode?: string; path?: string; text?: string; error?: string }>; } function mockAssistantMessage(text: string, stopReason: "stop" | "tool_use" = "stop") { return { type: "message_end", message: { role: "assistant", content: stopReason === "tool_use" ? [{ type: "text", text }, { type: "toolCall", name: "bash", arguments: { command: "echo test" } }] : [{ type: "text", text }], model: "mock/test-model", stopReason, usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 }, }, }, }; } interface AsyncExecutionModule { isAsyncAvailable(): boolean; executeAsyncSingle(id: string, params: Record): AsyncExecutionResult; executeAsyncChain(id: string, params: Record): AsyncExecutionResult; } interface UtilsModule { readStatus(dir: string): { runId: string; state: string; mode: string } | null; } interface TypesModule { ASYNC_DIR: string; RESULTS_DIR: string; TEMP_ROOT_DIR: string; } interface ExecutorModule { createSubagentExecutor?: (...args: unknown[]) => { execute: (...args: unknown[]) => Promise<{ content: Array<{ text?: string }>; isError?: boolean; details?: { asyncId?: string } }>; }; } const asyncMod = await tryImport("./src/runs/background/async-execution.ts"); const utils = await tryImport("./src/shared/utils.ts"); const typesMod = await tryImport("./src/shared/types.ts"); const executorMod = await tryImport("./src/runs/foreground/subagent-executor.ts"); const available = !!(asyncMod && utils && typesMod); const isAsyncAvailable = asyncMod?.isAsyncAvailable; const executeAsyncSingle = asyncMod?.executeAsyncSingle; const executeAsyncChain = asyncMod?.executeAsyncChain; const readStatus = utils?.readStatus; const ASYNC_DIR = typesMod?.ASYNC_DIR; const RESULTS_DIR = typesMod?.RESULTS_DIR; const TEMP_ROOT_DIR = typesMod?.TEMP_ROOT_DIR; const createSubagentExecutor = executorMod?.createSubagentExecutor; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function git(cwd: string, args: string[]): string { const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" }); if (result.status !== 0) { throw new Error(result.stderr.trim() || result.stdout.trim() || `git ${args.join(" ")} failed`); } return result.stdout.trim(); } function createRepo(prefix: string): string { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); git(repoDir, ["init"]); git(repoDir, ["config", "user.email", "tests@example.com"]); git(repoDir, ["config", "user.name", "Async Tests"]); fs.writeFileSync(path.join(repoDir, "input.md"), "input\n", "utf-8"); git(repoDir, ["add", "-A"]); git(repoDir, ["commit", "-m", "initial commit"]); return repoDir; } function writePackageSkill(packageRoot: string, skillName: string): void { const skillDir = path.join(packageRoot, "skills", skillName); fs.mkdirSync(skillDir, { recursive: true }); fs.writeFileSync( path.join(packageRoot, "package.json"), JSON.stringify({ name: `${skillName}-pkg`, version: "1.0.0", pi: { skills: [`./skills/${skillName}`] } }, null, 2), "utf-8", ); fs.writeFileSync( path.join(skillDir, "SKILL.md"), `---\nname: ${skillName}\ndescription: test skill\n---\nbody\n`, "utf-8", ); } async function waitForAsyncResultFile(id: string, timeoutMs = 15_000): Promise { const resultPath = path.join(RESULTS_DIR, `${id}.json`); const deadline = Date.now() + timeoutMs; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } return resultPath; } async function waitForMockPiCall(mockPi: MockPi, index: number, timeoutMs = 30_000): Promise<{ args: string[]; systemPrompts: NonNullable }> { const deadline = Date.now() + timeoutMs; for (;;) { const callFile = fs.readdirSync(mockPi.dir) .filter((name) => name.startsWith("call-") && name.endsWith(".json")) .sort() .at(index); if (callFile) { const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as MockPiCallRecord; assert.ok(Array.isArray(payload.args), "expected recorded args"); return { args: payload.args, systemPrompts: payload.systemPrompts ?? [] }; } if (Date.now() > deadline) assert.fail(`Timed out waiting for recorded mock pi call ${index}`); await new Promise((resolve) => setTimeout(resolve, 100)); } } async function waitForMockPiArgs(mockPi: MockPi, index: number, timeoutMs = 30_000): Promise { return (await waitForMockPiCall(mockPi, index, timeoutMs)).args; } function readLastMockPiArgs(mockPi: MockPi): string[] { const callFile = fs.readdirSync(mockPi.dir) .filter((name) => name.startsWith("call-") && name.endsWith(".json")) .sort() .at(-1); assert.ok(callFile, "expected a recorded mock pi call"); const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as MockPiCallRecord; assert.ok(Array.isArray(payload.args), "expected recorded args"); return payload.args; } function readMockPiArgs(mockPi: MockPi, index: number): string[] { const callFile = fs.readdirSync(mockPi.dir) .filter((name) => name.startsWith("call-") && name.endsWith(".json")) .sort() .at(index); assert.ok(callFile, `expected recorded call ${index}`); const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as MockPiCallRecord; assert.ok(Array.isArray(payload.args), "expected recorded args"); return payload.args; } function readMockPiArgsMatching(mockPi: MockPi, text: string): string[] { const callFiles = fs.readdirSync(mockPi.dir) .filter((name) => name.startsWith("call-") && name.endsWith(".json")) .sort(); for (const callFile of callFiles) { const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as { args?: string[] }; assert.ok(Array.isArray(payload.args), "expected recorded args"); if (payload.args.join("\n").includes(text)) return payload.args; } assert.fail(`expected recorded call containing ${text}`); } describe("async execution utilities", { skip: !available ? "pi packages not available" : undefined }, () => { let tempDir: string; let mockPi: MockPi; before(() => { mockPi = createMockPi(); mockPi.install(); }); after(() => { mockPi.uninstall(); }); beforeEach(() => { tempDir = createTempDir(); mockPi.reset(); }); afterEach(() => { removeTempDir(tempDir); }); it("reports jiti availability as boolean", () => { const result = isAsyncAvailable(); assert.equal(typeof result, "boolean"); }); it("spawns the async runner with node when process.execPath is not node", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const originalExecPath = process.execPath; process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi"); try { mockPi.onCall({ output: "non-node exec async done" }); const id = `async-non-node-exec-${Date.now().toString(36)}`; const result = executeAsyncSingle(id, { agent: "worker", task: "Say non-node exec async done. Do not edit files.", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, undefined); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "non-node exec async done"); } finally { process.execPath = originalExecPath; } }); it("falls back to PATH node when node-like process.execPath is stale", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const originalExecPath = process.execPath; process.execPath = path.join(tempDir, "deleted-node-install", "bin", process.platform === "win32" ? "node.exe" : "node"); try { mockPi.onCall({ output: "stale node exec async done" }); const id = `async-stale-node-exec-${Date.now().toString(36)}`; const result = executeAsyncSingle(id, { agent: "worker", task: "Say stale node exec async done. Do not edit files.", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, undefined); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "stale node exec async done"); } finally { process.execPath = originalExecPath; } }); it("readStatus returns null for missing directory", () => { const status = readStatus("/nonexistent/path/abc123"); assert.equal(status, null); }); it("readStatus parses valid status file", () => { const dir = createTempDir(); try { const statusData = { runId: "test-123", state: "running", mode: "single", startedAt: Date.now(), lastUpdate: Date.now(), steps: [{ agent: "test", status: "running" }], }; fs.writeFileSync(path.join(dir, "status.json"), JSON.stringify(statusData)); const status = readStatus(dir); assert.ok(status, "should parse status"); assert.equal(status.runId, "test-123"); assert.equal(status.state, "running"); assert.equal(status.mode, "single"); } finally { removeTempDir(dir); } }); it("interrupts every active async parallel child", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "cross-process interrupt delivery unreliable on Windows CI" : undefined }, async () => { mockPi.onCall({ delay: 5_000, output: "one done" }); mockPi.onCall({ delay: 5_000, output: "two done" }); mockPi.onCall({ delay: 5_000, output: "three done" }); const id = `async-interrupt-parallel-${Date.now().toString(36)}`; executeAsyncChain(id, { chain: [{ parallel: [ { agent: "one", task: "Wait" }, { agent: "two", task: "Wait" }, { agent: "three", task: "Wait" }, ], concurrency: 3, }], resultMode: "parallel", agents: [makeAgent("one"), makeAgent("two"), makeAgent("three")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, }); await waitForMockPiCall(mockPi, 2, 10_000); const asyncDir = path.join(ASYNC_DIR, id); const statusPath = path.join(asyncDir, "status.json"); const statusBeforeInterrupt = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload & { pid?: number }; deliverInterruptRequest({ asyncDir, pid: statusBeforeInterrupt.pid, source: "test" }); const resultPath = await waitForAsyncResultFile(id, 30_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; assert.equal(payload.state, "paused"); assert.equal(payload.success, false); assert.deepEqual(status.steps?.map((step) => step.status), ["paused", "paused", "paused"]); assert.equal(mockPi.callCount(), 3); }); it("marks async parallel runs that exceed timeoutMs as timed out", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => { mockPi.onCall({ delay: 5_000, output: "one done" }); mockPi.onCall({ delay: 5_000, output: "two done" }); const id = `async-timeout-parallel-${Date.now().toString(36)}`; executeAsyncChain(id, { chain: [{ parallel: [ { agent: "one", task: "Wait" }, { agent: "two", task: "Wait" }, ], concurrency: 2, }], resultMode: "parallel", agents: [makeAgent("one"), makeAgent("two")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, timeoutMs: 1_500, }); await waitForMockPiCall(mockPi, 1, 10_000); const resultPath = await waitForAsyncResultFile(id, 8_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.state, "failed"); assert.equal(payload.success, false); assert.equal(payload.exitCode, 1); assert.equal(payload.timeoutMs, 1_500); assert.equal(payload.timedOut, true); assert.match(payload.summary ?? "", /Subagent timed out after 1500ms\./); assert.equal(status.state, "failed"); assert.equal(status.timeoutMs, 1_500); assert.equal(status.timedOut, true); assert.match(status.error ?? "", /Subagent timed out after 1500ms\./); assert.deepEqual(status.steps?.map((step) => step.status), ["failed", "failed"]); assert.deepEqual(status.steps?.map((step) => step.timedOut), [true, true]); assert.deepEqual(status.steps?.map((step) => step.error), ["Subagent timed out after 1500ms.", "Subagent timed out after 1500ms."]); assert.deepEqual(payload.results.map((result) => result.timedOut), [true, true]); assert.equal(mockPi.callCount(), 2); }); it("hard-kills async children that ignore timeout SIGTERM", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ delay: 60_000, ignoreSigterm: true, output: "too late" }); const id = `async-timeout-hard-kill-${Date.now().toString(36)}`; const timeoutMs = 1_500; const startedAt = Date.now(); executeAsyncSingle(id, { agent: "stubborn", task: "Ignore soft termination", agentConfig: makeAgent("stubborn", { model: "primary-model", fallbackModels: ["fallback-model"] }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, timeoutMs, }); await waitForMockPiCall(mockPi, 0, 10_000); const resultPath = await waitForAsyncResultFile(id, 8_000); const elapsedMs = Date.now() - startedAt; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.state, "failed"); assert.equal(payload.timedOut, true); assert.equal(payload.results[0]?.timedOut, true); assert.equal(payload.results[0]?.error, `Subagent timed out after ${timeoutMs}ms.`); assert.equal(status.timedOut, true); assert.equal(status.steps?.[0]?.timedOut, true); assert.ok(elapsedMs < 7_000, `timeout result should settle after hard kill, elapsed ${elapsedMs}ms`); assert.equal(mockPi.callCount(), 1); }); it("cancels async acceptance verification when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "implementation complete" }); const id = `async-timeout-acceptance-${Date.now().toString(36)}`; const startedAt = Date.now(); executeAsyncSingle(id, { agent: "worker", task: "Implement with verified acceptance", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, timeoutMs: 1_000, acceptance: { level: "verified", verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 5000)"`, timeoutMs: 10_000 }], }, }); const resultPath = await waitForAsyncResultFile(id, 5_000); const elapsedMs = Date.now() - startedAt; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.state, "failed"); assert.equal(payload.timedOut, true); assert.equal(payload.results[0]?.timedOut, true); assert.equal(payload.results[0]?.acceptance, undefined); assert.equal(status.steps?.[0]?.timedOut, true); assert.ok(elapsedMs < 3_000, `timeout should cancel acceptance verification promptly, elapsed ${elapsedMs}ms`); }); it("async turn budget allows a terminal final grace turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [ mockAssistantMessage("working before wrap-up", "tool_use"), mockAssistantMessage("final wrapped output", "stop"), ], }); const id = `async-turn-budget-soft-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Use the final grace turn to wrap up.", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, turnBudget: { maxTurns: 1, graceTurns: 1 }, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.success, true); assert.equal(payload.state, "complete"); assert.equal(payload.turnBudgetExceeded, undefined); assert.equal(payload.wrapUpRequested, true); assert.equal(payload.turnBudget?.outcome, "wrap-up-requested"); assert.equal(payload.turnBudget?.turnCount, 2); assert.equal(payload.results[0]?.wrapUpRequested, true); assert.equal(payload.results[0]?.turnBudget?.turnCount, 2); assert.match(payload.results[0]?.output ?? "", /Turn budget wrap-up was requested after 1 assistant turn/); assert.match(payload.results[0]?.output ?? "", /final wrapped output/); assert.equal(status.wrapUpRequested, true); assert.equal(status.turnBudgetExceeded, undefined); assert.equal(status.steps?.[0]?.wrapUpRequested, true); assert.equal(status.steps?.[0]?.turnBudget?.turnCount, 2); }); it("async turn budget hard-aborts a non-terminal final grace turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [ mockAssistantMessage("working before wrap-up", "tool_use"), mockAssistantMessage("still starting more tool work", "tool_use"), ], }); const id = `async-turn-budget-hard-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Exceed the turn budget.", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, turnBudget: { maxTurns: 1, graceTurns: 1 }, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.success, false); assert.equal(payload.state, "failed"); assert.equal(payload.exitCode, 1); assert.equal(payload.turnBudgetExceeded, true); assert.equal(payload.wrapUpRequested, true); assert.equal(payload.turnBudget?.outcome, "exceeded"); assert.equal(payload.turnBudget?.turnCount, 2); assert.equal(payload.turnBudget?.exceededAtTurn, 2); assert.equal(payload.results[0]?.turnBudgetExceeded, true); assert.match(payload.results[0]?.output ?? "", /Partial output before turn-budget abort:/); assert.match(payload.results[0]?.output ?? "", /still starting more tool work/); assert.equal(status.state, "failed"); assert.equal(status.turnBudgetExceeded, true); assert.equal(status.steps?.[0]?.turnBudgetExceeded, true); assert.equal(status.steps?.[0]?.turnBudget?.outcome, "exceeded"); }); it("async launch messages tell the parent not to sleep-poll", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const artifactConfig = { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }; const commonParams = { ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig, shareEnabled: false, maxSubagentDepth: 2, }; mockPi.onCall({ output: "single done" }); const singleId = `async-handoff-single-${Date.now().toString(36)}`; const singleResult = executeAsyncSingle(singleId, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ...commonParams, }); assert.match(singleResult.content[0]?.text ?? "", /Async: worker \[/); assert.match(singleResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/); assert.match(singleResult.content[0]?.text ?? "", /call wait\(\)/); assert.match(singleResult.content[0]?.text ?? "", /there is no next turn, so use wait\(\)/); await waitForAsyncResultFile(singleId, 30_000); mockPi.onCall({ output: "parallel one done" }); mockPi.onCall({ output: "parallel two done" }); const parallelId = `async-handoff-parallel-${Date.now().toString(36)}`; const parallelResult = executeAsyncChain(parallelId, { chain: [{ parallel: [{ agent: "worker", task: "Do one" }, { agent: "reviewer", task: "Do two" }] }], resultMode: "parallel", agents: [makeAgent("worker"), makeAgent("reviewer")], ...commonParams, }); assert.match(parallelResult.content[0]?.text ?? "", /Async parallel:/); assert.match(parallelResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/); assert.match(parallelResult.content[0]?.text ?? "", /call wait\(\)/); const parallelResultPath = await waitForAsyncResultFile(parallelId, 10_000); const parallelPayload = JSON.parse(fs.readFileSync(parallelResultPath, "utf-8")) as { agent?: string; mode?: string }; assert.equal(parallelPayload.mode, "parallel"); assert.equal(parallelPayload.agent, "parallel:worker+reviewer"); mockPi.onCall({ output: "chain done" }); const chainId = `async-handoff-chain-${Date.now().toString(36)}`; const chainResult = executeAsyncChain(chainId, { chain: [{ agent: "worker", task: "Do chained work" }], agents: [makeAgent("worker")], ...commonParams, }); assert.match(chainResult.content[0]?.text ?? "", /Async chain:/); assert.match(chainResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/); await waitForAsyncResultFile(chainId, 10_000); }); it("top-level async parallel conversion preserves output, reads, and progress", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => { mockPi.onCall({ output: "Async top-level report" }); const executor = createSubagentExecutor!({ pi: { events: createEventBus(), getSessionName: () => undefined }, state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null }, config: {}, asyncByDefault: false, tempArtifactsDir: tempDir, getSubagentSessionRoot: () => tempDir, expandTilde: (p: string) => p, discoverAgents: () => ({ agents: [makeAgent("worker", { defaultProgress: true })] }), }); const result = await executor.execute( "async-parallel-fields", { tasks: [{ agent: "worker", task: "Do async work", output: "async-top-output.md", reads: ["input.md"] }], async: true, clarify: false, }, new AbortController().signal, undefined, makeMinimalCtx(tempDir), ); const asyncId = result.details?.asyncId; assert.ok(asyncId, "expected asyncId"); const resultPath = path.join(RESULTS_DIR, `${asyncId}.json`); const statusPath = path.join(ASYNC_DIR, asyncId, "status.json"); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; assert.equal(payload.mode, "parallel"); assert.equal(payload.sessionId, "session-123"); assert.equal(payload.results[0]?.acceptance?.status, "checked"); assert.equal(status.sessionId, "session-123"); assert.equal(status.steps?.[0]?.acceptance?.status, "checked"); const outputPath = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", asyncId, "async-top-output.md"); const outputDeadline = Date.now() + 5_000; while (!fs.existsSync(outputPath)) { if (Date.now() > outputDeadline) { assert.fail(`Timed out waiting for saved output file: ${outputPath}`); } await new Promise((resolve) => setTimeout(resolve, 50)); } assert.equal(fs.readFileSync(outputPath, "utf-8"), "Async top-level report"); const callFile = fs.readdirSync(mockPi.dir).find((name) => name.startsWith("call-")); assert.ok(callFile, "expected a recorded mock pi call"); const args = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")).args as string[]; const taskArg = args.at(-1) ?? ""; const progressPath = path.join(tempDir, ".pi-subagents", "artifacts", "progress", asyncId, "progress.md"); assert.ok(taskArg.includes(`[Read from: ${path.join(tempDir, "input.md")}]`)); assert.ok(taskArg.includes(`Update progress at: ${progressPath}`)); assert.ok(taskArg.includes(`Write your findings to exactly this path: ${outputPath}`)); assert.equal(fs.existsSync(progressPath), true); assert.equal(fs.existsSync(path.join(tempDir, "progress.md")), false); }); it("async single rejects explicit reviewed acceptance without a reviewer result", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: [ "implemented", "```acceptance-report", JSON.stringify({ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patched" }], changedFiles: ["src/file.ts"], testsAddedOrUpdated: ["test/file.test.ts"], commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }], validationOutput: ["passed"], residualRisks: [], noStagedFiles: true, notes: "done", }), "```", ].join("\n"), }); const artifactConfig = { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }; const id = `async-acceptance-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Implement acceptance-covered fix", agentConfig: makeAgent("worker", { completionGuard: false }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-acceptance" }, artifactConfig, shareEnabled: false, maxSubagentDepth: 2, acceptance: { level: "reviewed", criteria: ["Patch bug"], review: false }, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const result = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(result.success, false); assert.equal(result.results[0]?.acceptance?.status, "rejected"); assert.ok(result.results[0]?.acceptance?.childReport); assert.equal(result.results[0]?.acceptance?.reviewResult?.status, "needs-parent-decision"); assert.equal(status.steps?.[0]?.acceptance?.status, "rejected"); }); it("top-level async chain suppresses progress for {task} review-only tasks", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => { mockPi.onCall({ output: "Async review" }); const executor = createSubagentExecutor!({ pi: { events: createEventBus(), getSessionName: () => undefined }, state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null }, config: {}, asyncByDefault: false, tempArtifactsDir: tempDir, getSubagentSessionRoot: () => tempDir, expandTilde: (p: string) => p, discoverAgents: () => ({ agents: [makeAgent("reviewer", { defaultProgress: true })] }), }); const result = await executor.execute( "async-chain-read-only-progress", { chain: [{ agent: "reviewer" }], task: "Review-only. Do not edit files. Return findings.", async: true, clarify: false, }, new AbortController().signal, undefined, makeMinimalCtx(tempDir), ); const asyncId = result.details?.asyncId; assert.ok(asyncId, "expected asyncId"); const resultPath = path.join(RESULTS_DIR, `${asyncId}.json`); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const callFile = fs.readdirSync(mockPi.dir).find((name) => name.startsWith("call-")); assert.ok(callFile, "expected a recorded mock pi call"); const args = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")).args as string[]; assert.doesNotMatch(args.at(-1) ?? "", /progress\.md/); assert.equal(fs.existsSync(path.join(tempDir, "progress.md")), false); }); it("async chains reject malformed named output references before spawning", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const id = `async-malformed-output-ref-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [{ agent: "consumer", task: "Use {outputs.bad-name}" }], agents: [makeAgent("consumer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-malformed" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Invalid chain output reference '\{outputs\.bad-name\}'/); assert.equal(mockPi.callCount(), 0); }); it("async chains persist structured outputs, named outputs, and graph labels", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const schema = { type: "object", required: ["value"], properties: { value: { type: "string" } }, }; mockPi.onCall({ structuredOutput: { value: "Alpha structured" } }); mockPi.onCall({ output: "used named output" }); const id = `async-structured-chain-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce data", phase: "Collect", label: "Produce structured data", as: "data", outputSchema: schema, }, { agent: "consumer", task: "Use {outputs.data}", phase: "Use", label: "Consume data" }, ], agents: [makeAgent("producer"), makeAgent("consumer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-structured" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); assert.ok(!result.isError); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.deepEqual(payload.results[0]?.structuredOutput, { value: "Alpha structured" }); assert.deepEqual(payload.outputs?.data?.structured, { value: "Alpha structured" }); assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", /Alpha structured/); assert.equal(status.steps?.[0]?.label, "Produce structured data"); assert.equal(status.steps?.[0]?.phase, "Collect"); assert.equal(status.steps?.[0]?.outputName, "data"); assert.equal(status.steps?.[0]?.structured, true); assert.equal(payload.workflowGraph?.nodes?.[0]?.label, "Produce structured data"); assert.equal(payload.workflowGraph?.nodes?.[0]?.outputName, "data"); assert.equal(payload.workflowGraph?.nodes?.[0]?.status, "completed"); assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "completed"); }); it("async chains can start parallel, funnel into one step, then fan back out", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ matchArgIncludes: "Scout API", output: "Scout A async findings" }); mockPi.onCall({ matchArgIncludes: "Scout UI", output: "Scout B async findings" }); mockPi.onCall({ matchArgIncludes: "Synthesize:", output: "Async funnel synthesis" }); mockPi.onCall({ matchArgIncludes: "Review funnel A:", output: "Async reviewer A done" }); mockPi.onCall({ matchArgIncludes: "Review funnel B:", output: "Async reviewer B done" }); const id = `async-parallel-funnel-fanout-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [ { parallel: [ { agent: "scout-a", task: "Scout API" }, { agent: "scout-b", task: "Scout UI" }, ], concurrency: 2, }, { agent: "synthesizer", task: "Synthesize:\n{previous}" }, { parallel: [ { agent: "review-a", task: "Review funnel A:\n{previous}" }, { agent: "review-b", task: "Review funnel B:\n{previous}" }, ], concurrency: 2, }, ], agents: [makeAgent("scout-a"), makeAgent("scout-b"), makeAgent("synthesizer"), makeAgent("review-a"), makeAgent("review-b")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-parallel-funnel-fanout" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); assert.ok(!result.isError, `should launch: ${JSON.stringify(result.content)}`); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.success, true); assert.deepEqual(payload.results.map((entry) => entry.output), [ "Scout A async findings", "Scout B async findings", "Async funnel synthesis", "Async reviewer A done", "Async reviewer B done", ]); assert.deepEqual(status.steps?.map((step) => step.status), ["complete", "complete", "complete", "complete", "complete"]); assert.deepEqual(status.parallelGroups, [ { start: 0, count: 2, stepIndex: 0 }, { start: 3, count: 2, stepIndex: 2 }, ]); const funnelTask = readMockPiArgsMatching(mockPi, "Synthesize:").at(-1) ?? ""; assert.match(funnelTask, /=== Parallel Task 1 \(scout-a\) ===/); assert.match(funnelTask, /Scout A async findings/); assert.match(funnelTask, /=== Parallel Task 2 \(scout-b\) ===/); assert.match(funnelTask, /Scout B async findings/); assert.match(readMockPiArgsMatching(mockPi, "Review funnel A:").at(-1) ?? "", /Review funnel A:\nAsync funnel synthesis/); assert.match(readMockPiArgsMatching(mockPi, "Review funnel B:").at(-1) ?? "", /Review funnel B:\nAsync funnel synthesis/); assert.equal(payload.workflowGraph?.nodes?.[0]?.kind, "parallel-group"); assert.equal(payload.workflowGraph?.nodes?.[0]?.status, "completed"); assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "step"); assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "completed"); assert.equal(payload.workflowGraph?.nodes?.[2]?.kind, "parallel-group"); assert.equal(payload.workflowGraph?.nodes?.[2]?.status, "completed"); }); it("async dynamic status shows a placeholder before materialization", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ delay: 800, output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } }); mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } }); mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } }); mockPi.onCall({ output: "used reviews" }); const id = `async-dynamic-placeholder-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } }, { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 }, parallel: { agent: "reviewer", task: "Review {target.path}", label: "Review {target.path}", outputSchema: { type: "object" } }, collect: { as: "reviews" }, concurrency: 1, }, { agent: "consumer", task: "Use {outputs.reviews}" }, ], agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-placeholder" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); assert.ok(!result.isError); const statusPath = path.join(ASYNC_DIR, id, "status.json"); const deadline = Date.now() + 5_000; let status: AsyncStatusPayload | undefined; while (!status) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async status file: ${statusPath}`); if (fs.existsSync(statusPath)) status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; else await new Promise((resolve) => setTimeout(resolve, 50)); } assert.deepEqual(status.steps?.map((step) => step.agent), ["producer", "expand:reviewer", "consumer"]); assert.equal(status.steps?.[1]?.label, "Review {target.path}"); assert.equal(status.steps?.[1]?.outputName, "reviews"); assert.deepEqual(status.parallelGroups, [{ start: 1, count: 1, stepIndex: 1 }]); const resultPath = await waitForAsyncResultFile(id, 10_000); const finalStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.deepEqual(finalStatus.steps?.map((step) => step.agent), ["producer", "reviewer", "reviewer", "consumer"]); assert.deepEqual(finalStatus.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]); }); it("async chains expand dynamic fanout and persist collected output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } }); mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } }); mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } }); mockPi.onCall({ output: "used reviews" }); const id = `async-dynamic-chain-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } }, { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 }, parallel: { agent: "reviewer", task: "Review {target.path}", label: "Review {target.path}", outputSchema: { type: "object" }, }, collect: { as: "reviews" }, concurrency: 1, }, { agent: "consumer", task: "Use {outputs.reviews}" }, ], agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); assert.ok(!result.isError); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(payload.success, true); assert.equal(mockPi.callCount(), 4); assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", /Review src\/a\.ts/); assert.match(readMockPiArgs(mockPi, 2).at(-1) ?? "", /Review src\/b\.ts/); assert.match(readMockPiArgs(mockPi, 3).at(-1) ?? "", /"key":"src\/a\.ts"/); const collected = payload.outputs?.reviews?.structured as Array<{ key: string; structured: unknown }>; assert.deepEqual(collected.map((item) => item.key), ["src/a.ts", "src/b.ts"]); assert.deepEqual(collected.map((item) => item.structured), [{ ok: "a" }, { ok: "b" }]); assert.equal(status.steps?.length, 4); assert.deepEqual(status.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]); assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "dynamic-parallel-group"); assert.deepEqual(payload.workflowGraph?.nodes?.[1]?.children?.map((child) => child.itemKey), ["src/a.ts", "src/b.ts"]); assert.equal(payload.workflowGraph?.nodes?.[2]?.flatIndex, 3); }); it("async dynamic fanout applies fork session files and thinking overrides to materialized children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } }); mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } }); mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } }); const id = `async-dynamic-fork-thinking-${Date.now().toString(36)}`; const sessionA = path.join(tempDir, "dynamic-a.jsonl"); const sessionB = path.join(tempDir, "dynamic-b.jsonl"); const result = executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } }, { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 }, parallel: { agent: "reviewer", task: "Review {target.path}", label: "Review {target.path}", outputSchema: { type: "object" }, }, collect: { as: "reviews" }, concurrency: 1, }, ], agents: [makeAgent("producer"), makeAgent("reviewer", { model: "anthropic/claude-sonnet-4-5:high", thinking: "high" })], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionFilesByFlatIndex: [undefined, sessionA, sessionB], thinkingOverridesByFlatIndex: [undefined, "off", "off"], maxSubagentDepth: 2, }); assert.ok(!result.isError); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; const firstDynamicArgs = readMockPiArgs(mockPi, 1); const secondDynamicArgs = readMockPiArgs(mockPi, 2); assert.equal(payload.success, true); assert.equal(firstDynamicArgs[firstDynamicArgs.indexOf("--session") + 1], sessionA); assert.equal(secondDynamicArgs[secondDynamicArgs.indexOf("--session") + 1], sessionB); assert.equal(firstDynamicArgs[firstDynamicArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4-5:off"); assert.equal(secondDynamicArgs[secondDynamicArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4-5:off"); assert.deepEqual(status.steps?.slice(1).map((step) => step.sessionFile), [sessionA, sessionB]); assert.deepEqual(status.steps?.slice(1).map((step) => step.thinking), ["off", "off"]); }); it("cancels dynamic fanout aggregate acceptance when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : process.platform === "win32" ? "timeout signal delivery intermittent on Windows CI" : undefined }, async () => { mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } }); mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } }); const id = `async-dynamic-acceptance-timeout-${Date.now().toString(36)}`; const startedAt = Date.now(); executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } }, { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 }, parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" }, acceptance: { level: "checked" } }, collect: { as: "reviews" }, acceptance: { level: "verified", verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 5000)"`, timeoutMs: 10_000 }], }, }, ], agents: [makeAgent("producer"), makeAgent("reviewer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-acceptance-timeout" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, timeoutMs: 1_000, }); const resultPath = await waitForAsyncResultFile(id, 5_000); const elapsedMs = Date.now() - startedAt; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload; const dynamicNode = payload.workflowGraph?.nodes?.[1] as { status?: string; error?: string; acceptanceStatus?: string } | undefined; assert.equal(payload.state, "failed"); assert.equal(payload.timedOut, true); assert.equal(payload.results.at(-1)?.timedOut, true); assert.equal(payload.results.at(-1)?.acceptance, undefined); assert.equal(dynamicNode?.status, "failed"); assert.match(dynamicNode?.error ?? "", /Subagent timed out after 1000ms\./); assert.notEqual(dynamicNode?.acceptanceStatus, "verified"); assert.equal(status.timedOut, true); assert.ok(elapsedMs < 3_000, `timeout should cancel dynamic aggregate acceptance promptly, elapsed ${elapsedMs}ms`); }); it("async dynamic fanout recomputes later child intercom targets by final flat index", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } }); mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } }); mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } }); mockPi.onCall({ echoEnv: ["SELESAI_SUBAGENT_INTERCOM_SESSION_NAME"] }); const id = `async-dynamic-targets-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } }, { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 }, parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" } }, collect: { as: "reviews" }, concurrency: 1, }, { agent: "consumer", task: "Use {outputs.reviews}" }, ], agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-targets" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, controlIntercomTarget: "subagent-orchestrator-test", childIntercomTarget: (agent: string, index: number) => `subagent-${agent}-${id}-${index + 1}`, }); assert.ok(!result.isError); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const expectedConsumerTarget = `subagent-consumer-${id}-4`; assert.equal(payload.success, true); assert.equal(payload.results[3]?.intercomTarget, expectedConsumerTarget); assert.deepEqual(JSON.parse(payload.results[3]?.output ?? "{}"), { SELESAI_SUBAGENT_INTERCOM_SESSION_NAME: expectedConsumerTarget }); }); it("async dynamic pre-spawn failures persist failed graph status and error", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } }); const id = `async-dynamic-prespawn-fail-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } }, { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 1 }, parallel: { agent: "reviewer", task: "Review {target.path}" }, collect: { as: "reviews" }, }, ], agents: [makeAgent("producer"), makeAgent("reviewer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-fail" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); assert.ok(!result.isError); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload & { workflowGraph?: AsyncResultPayload["workflowGraph"]; error?: string }; assert.equal(payload.success, false); assert.match(payload.results.at(-1)?.error ?? "", /exceeding maxItems 1/); assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "failed"); assert.match(payload.workflowGraph?.nodes?.[1]?.error ?? "", /exceeding maxItems 1/); assert.equal(status.state, "failed"); assert.match(status.error ?? "", /exceeding maxItems 1/); assert.equal(status.workflowGraph?.nodes?.[1]?.status, "failed"); }); it("async dynamic collect schema failures persist failed graph status and details", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } }); mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } }); const id = `async-dynamic-collect-fail-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } }, { expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 }, parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" } }, collect: { as: "reviews", outputSchema: { type: "object" } }, }, ], agents: [makeAgent("producer"), makeAgent("reviewer")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-collect-fail" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, maxSubagentDepth: 2, }); assert.ok(!result.isError); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, false); assert.match(payload.results.at(-1)?.error ?? "", /Collected output validation failed/); assert.ok(Array.isArray(payload.results.at(-1)?.structuredOutput), "failed collect result should preserve ordered collection details"); assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "failed"); assert.match(payload.workflowGraph?.nodes?.[1]?.error ?? "", /Collected output validation failed/); }); it("top-level async worktree parallel resolves reads against the worktree and output under project artifacts", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : process.platform === "win32" ? "worktree path separators unreliable on Windows CI" : undefined }, async () => { const repoDir = createRepo("pi-subagent-async-worktree-"); try { mockPi.onCall({ output: "Worktree report" }); const executor = createSubagentExecutor!({ pi: { events: createEventBus(), getSessionName: () => undefined }, state: { baseCwd: repoDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null }, config: {}, asyncByDefault: false, tempArtifactsDir: repoDir, getSubagentSessionRoot: () => repoDir, expandTilde: (p: string) => p, discoverAgents: () => ({ agents: [makeAgent("worker")] }), }); const result = await executor.execute( "async-parallel-worktree-fields", { tasks: [{ agent: "worker", task: "Do worktree work", output: "report.md", reads: ["input.md"] }], async: true, clarify: false, worktree: true, }, new AbortController().signal, undefined, makeMinimalCtx(repoDir), ); const asyncId = result.details?.asyncId; assert.ok(asyncId, "expected asyncId"); const worktreeCwd = path.join(os.tmpdir(), `pi-worktree-${asyncId}-s0-0`); const args = await waitForMockPiArgs(mockPi, 0); const taskArg = args.at(-1) ?? ""; assert.ok(taskArg.includes(`[Read from: ${path.join(worktreeCwd, "input.md")}]`)); assert.ok(taskArg.includes(`Write your findings to exactly this path: ${path.join(repoDir, ".pi-subagents", "artifacts", "outputs", asyncId, "report.md")}`)); await waitForAsyncResultFile(asyncId, 90_000); } finally { removeTempDir(repoDir); } }); it("readStatus caches by mtime (second call uses cache)", () => { const dir = createTempDir(); try { const statusData = { runId: "cache-test", state: "running", mode: "single", startedAt: Date.now(), }; fs.writeFileSync(path.join(dir, "status.json"), JSON.stringify(statusData)); const s1 = readStatus(dir); const s2 = readStatus(dir); assert.ok(s1); assert.ok(s2); assert.equal(s1.runId, s2.runId); } finally { removeTempDir(dir); } }); it("readStatus throws for malformed status files", () => { const dir = createTempDir(); try { fs.writeFileSync(path.join(dir, "status.json"), "{bad-json", "utf-8"); assert.throws(() => readStatus(dir), /Failed to parse async status file/); } finally { removeTempDir(dir); } }); it("background runs record fallback attempts and final model", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "primary failed" }], model: "openai/gpt-5-mini", errorMessage: "rate limit exceeded", usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, }, }], exitCode: 1, }); mockPi.onCall({ output: "Recovered asynchronously" }); const id = `async-fallback-${Date.now().toString(36)}`; const sessionRoot = path.join(tempDir, "sessions"); const asyncDir = path.join(ASYNC_DIR, id); const resultPath = path.join(RESULTS_DIR, `${id}.json`); const run = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini:high", fallbackModels: ["anthropic/claude-sonnet-4:low"], }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, availableModels: [ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" }, { provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" }, ], artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); assert.equal(run.details.asyncId, id); const started = Date.now(); while (!fs.existsSync(resultPath)) { if (Date.now() - started > 15000) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.lifecycleArtifactVersion, 1); assert.equal(payload.success, true); assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:low"); assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]); assert.equal(payload.results[0].modelAttempts.length, 2); assert.deepEqual(payload.results[0].totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 }); assert.deepEqual(payload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 }); const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(statusPayload.lifecycleArtifactVersion, 1); assert.equal(statusPayload.steps[0]?.model, "anthropic/claude-sonnet-4:low"); assert.equal(statusPayload.steps[0]?.thinking, "low"); assert.ok(statusPayload.totalTokens!.total > 0); assert.ok(statusPayload.steps[0]?.tokens!.total > 0); assert.deepEqual(statusPayload.steps[0]?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 }); assert.deepEqual(statusPayload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 }); const events = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line)); assert.equal(events.find((event) => event.type === "subagent.run.started")?.lifecycleArtifactVersion, 1); const completed = events.find((event) => event.type === "subagent.run.completed"); assert.equal(completed?.lifecycleArtifactVersion, 1); assert.deepEqual(completed?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 }); assert.match(fs.readFileSync(path.join(asyncDir, "output-0.log"), "utf-8"), /Recovered asynchronously/); assert.equal(mockPi.callCount(), 2); }); it("background single thinking override replaces primary and fallback suffixes", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "primary failed" }], model: "openai/gpt-5-mini", errorMessage: "rate limit exceeded", usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, }, }], exitCode: 1, }); mockPi.onCall({ output: "Recovered asynchronously" }); const id = `async-fallback-thinking-off-${Date.now().toString(36)}`; const run = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini:high", fallbackModels: ["anthropic/claude-sonnet-4:low"], thinking: "high", }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, availableModels: [ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" }, { provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" }, ], artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), thinkingOverride: "off", maxSubagentDepth: 2, }); assert.equal(run.details.asyncId, id); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const firstArgs = readMockPiArgs(mockPi, 0); const secondArgs = readMockPiArgs(mockPi, 1); assert.equal(payload.success, true); assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:off"); assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:off", "anthropic/claude-sonnet-4:off"]); assert.equal(firstArgs[firstArgs.indexOf("--model") + 1], "openai/gpt-5-mini:off"); assert.equal(secondArgs[secondArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4:off"); }); it("background runs retry fallback models when a zero-exit attempt has empty output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "" }], model: "openai/gpt-5-mini", stopReason: "error", usage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, }, }], exitCode: 0, }); mockPi.onCall({ output: "Recovered asynchronously from empty output" }); const id = `async-empty-output-fallback-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini", fallbackModels: ["anthropic/claude-sonnet-4"], }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0]?.model, "anthropic/claude-sonnet-4"); assert.match(payload.results[0]?.output ?? "", /Recovered asynchronously from empty output/); assert.match(payload.results[0]?.modelAttempts?.[0]?.error ?? "", /no output/i); assert.deepEqual(payload.results[0]?.modelAttempts?.map((attempt) => attempt.success), [false, true]); assert.equal(mockPi.callCount(), 2); }); it("background runs fail zero-exit provider errors when no fallback succeeds", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "quota hit" }], model: "openai/gpt-5-mini", errorMessage: "429 quota exceeded", usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, }, }], exitCode: 0, }); const id = `async-zero-exit-provider-error-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, false); assert.match(payload.results[0]?.error ?? "", /429 quota exceeded/); const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(statusPayload.state, "failed"); assert.match(statusPayload.steps?.[0]?.error ?? "", /429 quota exceeded/); }); it("background runs treat recovered child errors as successful", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [ events.toolResult("read", "EISDIR: illegal operation on a directory", true), { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "temporary provider failure" }], model: "openai/gpt-5-mini", stopReason: "error", errorMessage: "provider transport failed", usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, }, }, events.assistantMessage("Recovered asynchronously"), ], }); const id = `async-recovered-child-error-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.state, "complete"); assert.equal(payload.exitCode, 0); assert.equal(payload.results[0]?.success, true); assert.equal(payload.results[0]?.error, undefined); assert.equal(payload.results[0]?.output, "Recovered asynchronously"); const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(statusPayload.state, "complete"); assert.equal(statusPayload.steps?.[0]?.status, "complete"); assert.equal(statusPayload.steps?.[0]?.exitCode, 0); }); it("background runs keep provider errors failed when followed only by empty assistant output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [ { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "temporary provider failure" }], model: "openai/gpt-5-mini", stopReason: "error", errorMessage: "provider transport failed", usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }, }, }, events.assistantMessage(""), ], }); const id = `async-provider-error-empty-stop-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, false); assert.equal(payload.state, "failed"); assert.equal(payload.exitCode, 1); assert.equal(payload.results[0]?.success, false); assert.match(payload.results[0]?.error ?? "", /provider transport failed/); assert.equal(payload.results[0]?.output, ""); const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload; assert.equal(statusPayload.state, "failed"); assert.equal(statusPayload.steps?.[0]?.status, "failed"); assert.equal(statusPayload.steps?.[0]?.exitCode, 1); }); it("background file-only runs write full output but return only a file reference", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "async full output\nwith details" }); const id = `async-file-only-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const outputPath = path.join(tempDir, "async-file-only.md"); const run = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), output: outputPath, outputMode: "file-only", maxSubagentDepth: 2, }); assert.equal(run.details.asyncId, id); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.match(payload.summary ?? "", /Output saved to:/); assert.match(payload.summary ?? "", /2 lines/); assert.doesNotMatch(payload.summary ?? "", /async full output/); assert.match(payload.results[0]?.output ?? "", /Output saved to:/); assert.doesNotMatch(payload.results[0]?.output ?? "", /async full output/); assert.equal(fs.readFileSync(outputPath, "utf-8"), "async full output\nwith details"); }); it("background single runs route relative outputs to outputBaseDir", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "async configured report" }); const id = `async-configured-output-base-${Date.now().toString(36)}`; const outputBaseDir = path.join(tempDir, "async-configured-outputs"); const run = executeAsyncSingle(id, { agent: "researcher", task: "Write report", agentConfig: makeAgent("researcher", { output: "context.md" }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), output: "context.md", outputBaseDir, maxSubagentDepth: 2, }); assert.equal(run.details.asyncId, id); const outputPath = path.join(outputBaseDir, "context.md"); const call = await waitForMockPiCall(mockPi, 0); const taskArg = call.args.at(-1) ?? ""; assert.match(taskArg, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`)); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(fs.readFileSync(outputPath, "utf-8"), "async configured report"); assert.equal(fs.existsSync(path.join(tempDir, "context.md")), false); }); it("background single runs make output overrides authoritative in the child system prompt", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "async override report" }); const id = `async-output-override-system-prompt-${Date.now().toString(36)}`; const outputPath = path.join(tempDir, "async-custom-report.md"); const run = executeAsyncSingle(id, { agent: "researcher", task: "Write report", agentConfig: makeAgent("researcher", { output: "default-report.md", systemPrompt: "Output format (`default-report.md`):\n\nWrite the full report to default-report.md.", }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), output: outputPath, maxSubagentDepth: 2, }); assert.equal(run.details.asyncId, id); const call = await waitForMockPiCall(mockPi, 0); const taskArg = call.args.at(-1) ?? ""; const systemPrompt = call.systemPrompts[0]?.text ?? ""; assert.match(taskArg, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`)); assert.match(systemPrompt, /Output format \(`default-report\.md`\):/); assert.match(systemPrompt, /Runtime output path override:/); assert.match(systemPrompt, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`)); assert.match(systemPrompt, /Ignore any other output filename or output path mentioned elsewhere/); await waitForAsyncResultFile(id); }); it("background single runs treat string false as disabled output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "async inline report" }); const id = `async-string-false-output-${Date.now().toString(36)}`; const run = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { output: "default-report.md" }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), output: "false", maxSubagentDepth: 2, }); assert.equal(run.details.asyncId, id); const resultPath = await waitForAsyncResultFile(id); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "async inline report"); assert.doesNotMatch(payload.summary ?? "", /Output saved to:/); assert.equal(fs.existsSync(path.join(tempDir, "false")), false); assert.equal(fs.existsSync(path.join(tempDir, "default-report.md")), false); assert.doesNotMatch(readLastMockPiArgs(mockPi).at(-1) ?? "", /Write your findings to(?: exactly this path)?:/); }); it("background runs detect hidden tool failures even when the child exits 0", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [events.toolResult("bash", "connection refused")], }); const id = `async-hidden-failure-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "worker", task: "Deploy app", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, false); assert.equal(payload.exitCode, 1); assert.equal(payload.results[0].success, false); }); it("background implementation runs fail when no mutation attempt occurred", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "I’ll do that now and report back after implementing." }); const id = `async-no-mutation-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "worker", task: "Implement the approved fixes", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, false); assert.equal(payload.exitCode, 1); assert.equal(payload.results[0].success, false); assert.match(String(payload.results[0].error ?? ""), /completed without making edits/); assert.match(String(payload.results[0].modelAttempts?.[0]?.error ?? ""), /completed without making edits/); const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl"); const eventsText = fs.readFileSync(eventsPath, "utf-8"); assert.match(eventsText, /"reason":"completion_guard"/); assert.match(eventsText, /Subagent failed: worker/); assert.doesNotMatch(eventsText, /Status:/); assert.doesNotMatch(eventsText, /Interrupt:/); }); it("background bash-enabled non-implementation agents can opt out of the completion guard", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "cold start test after patch" }); const id = `async-completion-guard-optout-${Date.now().toString(36)}`; const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "test-runner", task: "Run cold start test after patch", agentConfig: makeAgent("test-runner", { tools: ["read", "grep", "bash", "ls"], completionGuard: false }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, true); assert.equal(payload.exitCode, 0); assert.equal(payload.results[0].success, true); assert.equal(payload.results[0].output, "cold start test after patch"); const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl"); const eventsText = fs.readFileSync(eventsPath, "utf-8"); assert.doesNotMatch(eventsText, /"reason":"completion_guard"/); }); it("background runs prefer the parent session provider for ambiguous bare model ids", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "Done asynchronously" }); const id = `async-provider-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { model: "gpt-5-mini" }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1", currentModelProvider: "github-copilot", }, availableModels: [ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" }, { provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini" }, ], artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, true); assert.equal(payload.results[0].model, "github-copilot/gpt-5-mini"); assert.deepEqual(payload.results[0].attemptedModels, ["github-copilot/gpt-5-mini"]); }); it("background single runs inherit the parent session model when no model is set", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "Done asynchronously" }); const id = `async-single-parent-model-${Date.now().toString(36)}`; executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1", currentModelProvider: "deepseek", currentModel: { provider: "deepseek", id: "deepseek-v4-flash" }, }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0].model, "deepseek/deepseek-v4-flash"); assert.deepEqual(payload.results[0].attemptedModels, ["deepseek/deepseek-v4-flash"]); const args = readMockPiArgs(mockPi, 0); assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash"); }); it("background chains inherit the parent session model when no step or agent model is set", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "Done asynchronously" }); const id = `async-chain-parent-model-${Date.now().toString(36)}`; executeAsyncChain(id, { chain: [{ agent: "worker", task: "Do work" }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1", currentModelProvider: "deepseek", currentModel: { provider: "deepseek", id: "deepseek-v4-flash" }, }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0].model, "deepseek/deepseek-v4-flash"); assert.deepEqual(payload.results[0].attemptedModels, ["deepseek/deepseek-v4-flash"]); const args = readMockPiArgs(mockPi, 0); assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash"); }); it("background runs resolve skills from the effective task cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "Done asynchronously" }); const taskCwd = createTempDir("pi-subagent-async-task-cwd-"); const id = `async-skill-cwd-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const resultPath = path.join(RESULTS_DIR, `${id}.json`); const statusPath = path.join(asyncDir, "status.json"); try { writePackageSkill(taskCwd, "async-task-cwd-skill"); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker", { skills: ["async-task-cwd-skill"] }), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: taskCwd, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; assert.equal(payload.success, true); assert.deepEqual(status.steps?.[0]?.skills, ["async-task-cwd-skill"]); } finally { removeTempDir(taskCwd); } }); it("background single runs report unavailable pi-subagents skill requests", () => { const id = `async-pi-subagents-skill-${Date.now().toString(36)}`; const result = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: tempDir, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), skills: ["pi-subagents"], maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Skills not found: pi-subagents/); }); it("background chains report unavailable pi-subagents skill requests", () => { const id = `async-chain-pi-subagents-skill-${Date.now().toString(36)}`; const result = executeAsyncChain(id, { chain: [{ agent: "worker", task: "Do work", skill: ["pi-subagents"] }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: tempDir, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Skills not found: pi-subagents/); }); it("background chains resolve relative step cwd values against the shared cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ output: "Done asynchronously" }); const chainCwd = createTempDir("pi-subagent-async-chain-cwd-"); const id = `async-chain-skill-cwd-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const resultPath = path.join(RESULTS_DIR, `${id}.json`); const statusPath = path.join(asyncDir, "status.json"); try { writePackageSkill(path.join(chainCwd, "packages", "app"), "async-chain-step-skill"); executeAsyncChain(id, { chain: [{ agent: "worker", task: "Do work", cwd: "packages/app", skill: ["async-chain-step-skill"] }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: chainCwd, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; assert.equal(payload.success, true); assert.equal(payload.sessionId, "session-1"); assert.equal(status.sessionId, "session-1"); assert.deepEqual(status.steps?.[0]?.skills, ["async-chain-step-skill"]); } finally { removeTempDir(chainCwd); } }); it("keeps top-level current tool/path aligned with still-running parallel children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [events.toolStart("read", { path: "README.md" })] }, { delay: 900, jsonl: [events.toolEnd("read"), events.toolResult("read", "done"), events.assistantMessage("reader done")] }, ], }); mockPi.onCall({ steps: [ { delay: 100, jsonl: [events.toolStart("edit", { path: "docs.md" })] }, { delay: 100, jsonl: [events.toolEnd("edit"), events.toolResult("edit", "ok")] }, { delay: 700, jsonl: [events.assistantMessage("editor done")] }, ], }); const id = `async-parallel-tool-sync-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncChain(id, { chain: [{ parallel: [{ agent: "reader", task: "Read" }, { agent: "editor", task: "Edit" }] }], agents: [makeAgent("reader"), makeAgent("editor")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const statusPath = path.join(asyncDir, "status.json"); const doneDeadline = Date.now() + 10_000; let sawRunningTool = false; let invariantViolated = false; while (!fs.existsSync(resultPath) && Date.now() < doneDeadline) { if (fs.existsSync(statusPath)) { const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; const runningTools = (status.steps ?? []) .filter((step) => step.status === "running" && typeof step.currentTool === "string") .map((step) => step.currentTool as string); if (runningTools.length > 0) { sawRunningTool = true; if (!status.currentTool || !runningTools.includes(status.currentTool)) { invariantViolated = true; break; } } } await new Promise((resolve) => setTimeout(resolve, 50)); } if (!fs.existsSync(resultPath)) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } assert.equal(sawRunningTool, true, "expected at least one polling interval with a running step tool"); assert.equal(invariantViolated, false, "top-level currentTool drifted from running step tools"); }); it("returns a tool error when the detached runner config cannot be written", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => { const id = `async-write-fail-${Date.now().toString(36)}`; assert.ok(TEMP_ROOT_DIR, "TEMP_ROOT_DIR should be available for async tests"); fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true }); fs.mkdirSync(path.join(TEMP_ROOT_DIR, `async-cfg-${id}.json`), { recursive: true }); const result = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Failed to start async run/); assert.match(result.content[0]?.text ?? "", /async-cfg-/); }); it("returns a tool error when an async run uses a missing cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => { const id = `async-missing-cwd-${Date.now().toString(36)}`; const missingCwd = path.join(tempDir, "missing-cwd"); const singleResult = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: missingCwd, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(singleResult.isError, true); assert.match(singleResult.content[0]?.text ?? "", /Failed to start async run/); assert.match(singleResult.content[0]?.text ?? "", /cwd does not exist/); const chainId = `async-missing-cwd-chain-${Date.now().toString(36)}`; const chainResult = executeAsyncChain(chainId, { chain: [{ agent: "worker", task: "Do work" }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, cwd: missingCwd, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(chainResult.isError, true); assert.match(chainResult.content[0]?.text ?? "", /Failed to start async chain/); assert.match(chainResult.content[0]?.text ?? "", /cwd does not exist/); }); it("returns a tool error when the async runner process cannot spawn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => { const originalExecPath = process.execPath; const pathKey = process.platform === "win32" ? "Path" : "PATH"; const originalPath = process.env[pathKey]; process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi"); process.env[pathKey] = tempDir; try { const id = `async-spawn-fail-${Date.now().toString(36)}`; const result = executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Failed to start async run/); assert.match(result.content[0]?.text ?? "", /async runner did not produce a pid/); } finally { process.execPath = originalExecPath; if (originalPath === undefined) { delete process.env[pathKey]; } else { process.env[pathKey] = originalPath; } } }); it("returns a tool error when an async chain cannot write its detached runner config", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => { const id = `async-chain-write-fail-${Date.now().toString(36)}`; assert.ok(TEMP_ROOT_DIR, "TEMP_ROOT_DIR should be available for async tests"); fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true }); fs.mkdirSync(path.join(TEMP_ROOT_DIR, `async-cfg-${id}.json`), { recursive: true }); const result = executeAsyncChain(id, { chain: [{ agent: "worker", task: "Do work" }], agents: [makeAgent("worker")], ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); assert.equal(result.isError, true); assert.match(result.content[0]?.text ?? "", /Failed to start async chain/); assert.match(result.content[0]?.text ?? "", /async-cfg-/); }); it("background forced drain after final assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [events.assistantMessage("async-done-before-drain")], stderr: "Done after 1 turn(s). Ready for input.\n", keepAliveAfterFinalMessageMs: 10000, }); const id = `async-final-drain-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const sessionRoot = path.join(tempDir, "sessions"); const start = Date.now(); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const elapsed = Date.now() - start; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.ok(elapsed < 9000, `should clean up async child before the mock's natural keepalive exit, took ${elapsed}ms`); assert.equal(payload.success, true); assert.equal(payload.exitCode, 0); assert.equal(payload.results[0].success, true); assert.equal(payload.results[0].output, "async-done-before-drain"); }); it("background forced drain after empty terminal assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [events.assistantMessage("")], keepAliveAfterFinalMessageMs: 10000, }); const id = `async-final-drain-empty-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); const start = Date.now(); executeAsyncSingle(id, { agent: "scout", task: "Inspect something", agentConfig: makeAgent("scout"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const elapsed = Date.now() - start; const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.ok(elapsed < 9000, `should clean up async child before the mock's natural keepalive exit, took ${elapsed}ms`); assert.equal(payload.success, true); assert.equal(payload.exitCode, 0); assert.equal(payload.results[0].success, true); assert.equal(payload.results[0].output, ""); }); it("background final-drain cleanup preserves explicit assistant errors", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ jsonl: [{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "failed" }], model: "mock/test-model", stopReason: "stop", errorMessage: "provider exploded", usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } }, }, }], keepAliveAfterFinalMessageMs: 10000, }); const id = `async-final-drain-error-${Date.now().toString(36)}`; const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Do work", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, }); const deadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, false); assert.equal(payload.exitCode, 1); assert.equal(payload.results[0].success, false); assert.equal(payload.results[0].error, "provider exploded"); }); it("background runs emit active-long-running control events from child turns", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [events.assistantMessage("still working")] }, { delay: 2_000, jsonl: [events.assistantMessage("done")] }, ], }); const id = `async-active-long-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "scout", task: "Investigate behavior", agentConfig: makeAgent("scout"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterTurns: 1, activeNoticeAfterMs: 999_999, activeNoticeAfterTokens: 999_999, failedToolAttemptsBeforeAttention: 3, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const statusPath = path.join(asyncDir, "status.json"); const deadline = Date.now() + 10_000; let eventText = ""; let statusDuringEvent: AsyncStatusPayload | undefined; while (Date.now() < deadline) { if (fs.existsSync(eventsPath)) { eventText = fs.readFileSync(eventsPath, "utf-8"); } if (eventText.includes('"type":"active_long_running"') && fs.existsSync(statusPath)) { const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; if (status.activityState === "active_long_running" && status.steps?.[0]?.activityState === "active_long_running") { statusDuringEvent = status; break; } } if (eventText.includes('"type":"active_long_running"') && fs.existsSync(resultPath)) { assert.fail("run completed before status.json exposed active_long_running"); } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.match(eventText, /"type":"active_long_running"/); assert.match(eventText, /"reason":"turn_threshold"/); assert.ok(statusDuringEvent, "expected status.json to expose active_long_running while the run is still active"); assert.equal(statusDuringEvent.activityState, "active_long_running"); assert.equal(statusDuringEvent.steps?.[0]?.activityState, "active_long_running"); const doneDeadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > doneDeadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } }); it("background runs escalate repeated mutating tool failures", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { jsonl: [events.toolStart("edit", { path: "src/runs/background/subagent-runner.ts" }), events.toolEnd("edit"), events.toolResult("edit", "No exact match found for subagent-runner.ts", true)] }, { jsonl: [events.toolStart("edit", { path: "src/runs/background/subagent-runner.ts" }), events.toolEnd("edit"), events.toolResult("edit", "No exact match found for subagent-runner.ts", true)] }, { jsonl: [events.toolStart("edit", { path: "src/runs/background/subagent-runner.ts" }), events.toolEnd("edit"), events.toolResult("edit", "No exact match found for subagent-runner.ts", true)] }, { delay: 2_000, jsonl: [events.assistantMessage("I need another attempt.")] }, ], }); const id = `async-tool-failures-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); executeAsyncSingle(id, { agent: "worker", task: "Implement the approved fixes", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 }, shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2, controlConfig: { enabled: true, needsAttentionAfterMs: 999_999, activeNoticeAfterTurns: 999_999, activeNoticeAfterMs: 999_999, activeNoticeAfterTokens: 999_999, failedToolAttemptsBeforeAttention: 3, notifyOn: ["active_long_running", "needs_attention"], notifyChannels: ["event", "async", "intercom"], }, }); const statusPath = path.join(asyncDir, "status.json"); const deadline = Date.now() + 10_000; let eventText = ""; let statusDuringEvent: AsyncStatusPayload | undefined; while (Date.now() < deadline) { if (fs.existsSync(eventsPath)) { eventText = fs.readFileSync(eventsPath, "utf-8"); } if (eventText.includes('"reason":"tool_failures"') && fs.existsSync(statusPath)) { const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload; if (status.activityState === "needs_attention" && status.steps?.[0]?.activityState === "needs_attention") { statusDuringEvent = status; break; } } if (eventText.includes('"reason":"tool_failures"') && fs.existsSync(resultPath)) { assert.fail("run completed before status.json exposed needs_attention"); } await new Promise((resolve) => setTimeout(resolve, 100)); } assert.match(eventText, /"type":"needs_attention"/); assert.match(eventText, /"reason":"tool_failures"/); assert.match(eventText, /subagent-runner\.ts/); assert.ok(statusDuringEvent, "expected status.json to expose needs_attention while the run is still active"); assert.equal(statusDuringEvent.activityState, "needs_attention"); assert.equal(statusDuringEvent.steps?.[0]?.activityState, "needs_attention"); const doneDeadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > doneDeadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`); await new Promise((resolve) => setTimeout(resolve, 100)); } }); it("background event logs drop noisy message updates and cap child diagnostics", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { const previousMaxBytes = process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES; process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES = "900"; try { mockPi.onCall({ steps: [ { jsonl: [ { type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "NOISY_PARTIAL_DELTA", partial: { role: "assistant", content: [{ type: "text", text: "NOISY_PARTIAL_SNAPSHOT".repeat(200) }] }, }, message: { role: "assistant", content: [{ type: "text", text: "NOISY_PARTIAL_MESSAGE".repeat(200) }] }, }, events.toolStart("bash", { command: `echo ${"BIG_COMMAND_PAYLOAD".repeat(200)}` }), events.assistantMessage("Done after noisy stream"), ], }, ], }); const id = `async-noisy-events-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "worker", task: "Stream noisy diagnostics", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const resultPath = await waitForAsyncResultFile(id, 10_000); const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload; assert.equal(payload.success, true); assert.equal(payload.results[0]?.output, "Done after noisy stream"); const eventsText = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8"); assert.doesNotMatch(eventsText, /"type":"message_update"/); assert.doesNotMatch(eventsText, /NOISY_PARTIAL_/); assert.doesNotMatch(eventsText, /BIG_COMMAND_PAYLOAD/); assert.match(eventsText, /"type":"subagent\.events\.truncated"/); assert.match(eventsText, /"droppedEventType":"tool_execution_start"/); } finally { if (previousMaxBytes === undefined) delete process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES; else process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES = previousMaxBytes; } }); it("background runs stream child events and live output while active", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => { mockPi.onCall({ steps: [ { delay: 200, jsonl: [events.toolStart("bash", { command: "ls" })] }, { delay: 600, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "file-a\nfile-b")] }, { delay: 600, jsonl: [events.assistantMessage("Done streaming")], stderr: "warning: mock stderr\n" }, ], }); const id = `async-stream-${Date.now().toString(36)}`; const asyncDir = path.join(ASYNC_DIR, id); const eventsPath = path.join(asyncDir, "events.jsonl"); const outputPath = path.join(asyncDir, "output-0.log"); const resultPath = path.join(RESULTS_DIR, `${id}.json`); const sessionRoot = path.join(tempDir, "sessions"); executeAsyncSingle(id, { agent: "worker", task: "Stream detailed progress", agentConfig: makeAgent("worker"), ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" }, artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7, }, shareEnabled: false, sessionRoot, maxSubagentDepth: 2, }); const liveDeadline = Date.now() + 10_000; let sawChildEvent = false; let sawLiveOutput = false; while (Date.now() < liveDeadline && (!sawChildEvent || !sawLiveOutput)) { if (fs.existsSync(eventsPath)) { const content = fs.readFileSync(eventsPath, "utf-8"); sawChildEvent = content.includes('"type":"tool_execution_start"') && content.includes('"subagentSource":"child"'); } if (fs.existsSync(outputPath)) { const content = fs.readFileSync(outputPath, "utf-8"); sawLiveOutput = content.includes("bash: ls") || content.includes("file-a") || content.includes("warning: mock stderr"); } if (sawChildEvent && sawLiveOutput) break; assert.equal(fs.existsSync(resultPath), false, "run finished before live observability was written"); await new Promise((resolve) => setTimeout(resolve, 100)); } assert.equal(sawChildEvent, true, "expected child JSON events to be streamed into events.jsonl"); assert.equal(sawLiveOutput, true, "expected output-0.log to receive live child output"); const doneDeadline = Date.now() + 10_000; while (!fs.existsSync(resultPath)) { if (Date.now() > doneDeadline) { assert.fail(`Timed out waiting for async result file: ${resultPath}`); } await new Promise((resolve) => setTimeout(resolve, 100)); } const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")); assert.equal(payload.success, true); assert.equal(payload.results[0].output, "Done streaming"); const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")); assert.deepEqual(status.steps[0].recentTools.map((tool: { tool: string; args: string }) => ({ tool: tool.tool, args: tool.args })), [{ tool: "bash", args: "ls" }]); assert.deepEqual(status.steps[0].recentOutput, ["file-a", "file-b", "Done streaming"]); }); });