@selesai/code 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/dist/core/diagnostics.d.ts +1 -0
  2. package/dist/core/diagnostics.d.ts.map +1 -1
  3. package/dist/core/diagnostics.js.map +1 -1
  4. package/dist/core/package-manager.d.ts +10 -0
  5. package/dist/core/package-manager.d.ts.map +1 -1
  6. package/dist/core/package-manager.js +93 -8
  7. package/dist/core/package-manager.js.map +1 -1
  8. package/dist/core/package-manager.test.d.ts +2 -0
  9. package/dist/core/package-manager.test.d.ts.map +1 -0
  10. package/dist/core/package-manager.test.js +20 -0
  11. package/dist/core/package-manager.test.js.map +1 -0
  12. package/dist/core/resource-loader.d.ts +3 -0
  13. package/dist/core/resource-loader.d.ts.map +1 -1
  14. package/dist/core/resource-loader.js +10 -0
  15. package/dist/core/resource-loader.js.map +1 -1
  16. package/dist/core/settings-manager.d.ts +1 -0
  17. package/dist/core/settings-manager.d.ts.map +1 -1
  18. package/dist/core/settings-manager.js.map +1 -1
  19. package/dist/extensions/caveman/caveman-instructions.cjs +39 -0
  20. package/dist/extensions/caveman/index.js +116 -0
  21. package/dist/extensions/caveman/package.json +8 -0
  22. package/dist/extensions/caveman/test/extension.test.js +185 -0
  23. package/dist/extensions/caveman/test/helpers.test.js +67 -0
  24. package/dist/extensions/pi-intercom/broker/broker.ts +2 -3
  25. package/dist/extensions/pi-intercom/broker/paths.ts +10 -5
  26. package/dist/extensions/pi-intercom/broker/spawn.ts +2 -3
  27. package/dist/extensions/pi-intercom/config.ts +2 -2
  28. package/dist/extensions/pi-intercom/index.ts +2 -2
  29. package/dist/extensions/pi-intercom/package.json +2 -2
  30. package/dist/extensions/pi-intercom/ui/compose.ts +3 -3
  31. package/dist/extensions/pi-intercom/ui/inline-message.ts +3 -3
  32. package/dist/extensions/pi-intercom/ui/session-list.ts +3 -3
  33. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +2 -1
  34. package/dist/extensions/pi-powerline-footer/index.ts +9 -16
  35. package/dist/extensions/pi-powerline-footer/welcome.ts +23 -16
  36. package/dist/extensions/pi-powerline-footer/working-vibes.ts +2 -9
  37. package/dist/extensions/pi-rewind-hook/index.ts +1 -1
  38. package/dist/extensions/pi-rewind-hook/package.json +1 -1
  39. package/dist/extensions/pi-subagents/README.md +224 -52
  40. package/dist/extensions/pi-subagents/install.mjs +93 -0
  41. package/dist/extensions/pi-subagents/package.json +4 -5
  42. package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
  43. package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
  44. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +76 -13
  45. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +179 -2
  46. package/dist/extensions/pi-subagents/src/agents/agent-memory.ts +254 -0
  47. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +11 -0
  48. package/dist/extensions/pi-subagents/src/agents/agents.ts +193 -19
  49. package/dist/extensions/pi-subagents/src/agents/chain-serializer.ts +27 -2
  50. package/dist/extensions/pi-subagents/src/extension/config.ts +27 -4
  51. package/dist/extensions/pi-subagents/src/extension/doctor.ts +1 -7
  52. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -2
  53. package/dist/extensions/pi-subagents/src/extension/index.ts +73 -71
  54. package/dist/extensions/pi-subagents/src/extension/rpc.ts +369 -0
  55. package/dist/extensions/pi-subagents/src/extension/schemas.ts +54 -10
  56. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +200 -0
  57. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +21 -253
  58. package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +519 -0
  59. package/dist/extensions/pi-subagents/src/profiles/profiles.ts +1 -1
  60. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +195 -105
  61. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +88 -2
  62. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +67 -10
  63. package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +34 -4
  64. package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +166 -0
  65. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +156 -1
  66. package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +515 -0
  67. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +161 -44
  68. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +1 -2
  69. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +3 -2
  70. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +167 -6
  71. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +514 -0
  72. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +28 -1
  73. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +873 -204
  74. package/dist/extensions/pi-subagents/src/runs/background/wait.ts +353 -0
  75. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +123 -27
  76. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +174 -70
  77. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +569 -81
  78. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +45 -22
  79. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +3 -1
  80. package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +2 -2
  81. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +172 -21
  82. package/dist/extensions/pi-subagents/src/runs/shared/model-scope.ts +128 -0
  83. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +89 -0
  84. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +50 -1
  85. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +35 -4
  86. package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +63 -45
  87. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +2 -0
  88. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +125 -5
  89. package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +74 -0
  90. package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +52 -0
  91. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +28 -5
  92. package/dist/extensions/pi-subagents/src/shared/artifacts.ts +16 -1
  93. package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +15 -2
  94. package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +212 -0
  95. package/dist/extensions/pi-subagents/src/shared/fork-context.ts +133 -22
  96. package/dist/extensions/pi-subagents/src/shared/settings.ts +3 -1
  97. package/dist/extensions/pi-subagents/src/shared/types.ts +197 -4
  98. package/dist/extensions/pi-subagents/src/shared/utils.ts +100 -15
  99. package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +330 -0
  100. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +135 -4
  101. package/dist/extensions/pi-subagents/src/tui/render.ts +32 -12
  102. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +94 -0
  103. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +2772 -0
  104. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +1107 -0
  105. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +463 -0
  106. package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +274 -0
  107. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +1535 -0
  108. package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +242 -0
  109. package/dist/extensions/pi-subagents/test/integration/doctor-executor.test.ts +128 -0
  110. package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +252 -0
  111. package/dist/extensions/pi-subagents/test/integration/foreground-result-size.test.ts +188 -0
  112. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +1306 -0
  113. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +1033 -0
  114. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +397 -0
  115. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +583 -0
  116. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +691 -0
  117. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +892 -0
  118. package/dist/extensions/pi-subagents/test/integration/session-tokens.test.ts +62 -0
  119. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1824 -0
  120. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1284 -0
  121. package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +127 -0
  122. package/dist/extensions/pi-subagents/test/integration/template-resolution.test.ts +299 -0
  123. package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +37 -0
  124. package/dist/extensions/pi-subagents/test/support/helpers.ts +189 -0
  125. package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +335 -0
  126. package/dist/extensions/pi-subagents/test/support/mock-pi.ts +129 -0
  127. package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +184 -0
  128. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +285 -0
  129. package/dist/extensions/pi-subagents/test/support/register-loader.mjs +15 -0
  130. package/dist/extensions/pi-subagents/test/support/ts-loader.mjs +108 -0
  131. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +421 -0
  132. package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +213 -0
  133. package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +343 -0
  134. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +1199 -0
  135. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +556 -0
  136. package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +420 -0
  137. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +579 -0
  138. package/dist/extensions/pi-subagents/test/unit/agent-scope.test.ts +20 -0
  139. package/dist/extensions/pi-subagents/test/unit/agent-selection.test.ts +88 -0
  140. package/dist/extensions/pi-subagents/test/unit/artifacts.test.ts +24 -0
  141. package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +86 -0
  142. package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +221 -0
  143. package/dist/extensions/pi-subagents/test/unit/async-permission-session.test.ts +98 -0
  144. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +285 -0
  145. package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +112 -0
  146. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +206 -0
  147. package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +159 -0
  148. package/dist/extensions/pi-subagents/test/unit/chain-serializer.test.ts +229 -0
  149. package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +225 -0
  150. package/dist/extensions/pi-subagents/test/unit/close-grace-timer.test.ts +101 -0
  151. package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +228 -0
  152. package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +36 -0
  153. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +152 -0
  154. package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +72 -0
  155. package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +327 -0
  156. package/dist/extensions/pi-subagents/test/unit/control-notices.test.ts +161 -0
  157. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +159 -0
  158. package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +217 -0
  159. package/dist/extensions/pi-subagents/test/unit/extra-agent-dirs.test.ts +88 -0
  160. package/dist/extensions/pi-subagents/test/unit/file-coalescer.test.ts +68 -0
  161. package/dist/extensions/pi-subagents/test/unit/foreground-tool-call-compaction.test.ts +78 -0
  162. package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +415 -0
  163. package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +126 -0
  164. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +271 -0
  165. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +173 -0
  166. package/dist/extensions/pi-subagents/test/unit/jsonl-writer.test.ts +119 -0
  167. package/dist/extensions/pi-subagents/test/unit/live-async-resume.test.ts +170 -0
  168. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +346 -0
  169. package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +62 -0
  170. package/dist/extensions/pi-subagents/test/unit/model-scope-settings.test.ts +87 -0
  171. package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +145 -0
  172. package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +162 -0
  173. package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +470 -0
  174. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +329 -0
  175. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +319 -0
  176. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +70 -0
  177. package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +247 -0
  178. package/dist/extensions/pi-subagents/test/unit/path-handling.test.ts +113 -0
  179. package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +94 -0
  180. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +803 -0
  181. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +209 -0
  182. package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +238 -0
  183. package/dist/extensions/pi-subagents/test/unit/proactive-skills.test.ts +118 -0
  184. package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +299 -0
  185. package/dist/extensions/pi-subagents/test/unit/prompt-template-bridge.test.ts +408 -0
  186. package/dist/extensions/pi-subagents/test/unit/prompt-workflows.test.ts +134 -0
  187. package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +257 -0
  188. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +209 -0
  189. package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +226 -0
  190. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +294 -0
  191. package/dist/extensions/pi-subagents/test/unit/run-id-resolver.test.ts +147 -0
  192. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +997 -0
  193. package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +478 -0
  194. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +562 -0
  195. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +213 -0
  196. package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +438 -0
  197. package/dist/extensions/pi-subagents/test/unit/slash-bridge.test.ts +54 -0
  198. package/dist/extensions/pi-subagents/test/unit/slash-chain-groups.test.ts +464 -0
  199. package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +255 -0
  200. package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +19 -0
  201. package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +245 -0
  202. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +517 -0
  203. package/dist/extensions/pi-subagents/test/unit/temp-paths.test.ts +72 -0
  204. package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +52 -0
  205. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +237 -0
  206. package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +60 -0
  207. package/dist/extensions/pi-subagents/test/unit/ts-loader.test.ts +33 -0
  208. package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +202 -0
  209. package/dist/extensions/pi-subagents/test/unit/types-fork-preamble.test.ts +23 -0
  210. package/dist/extensions/pi-subagents/test/unit/wait.test.ts +398 -0
  211. package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +88 -0
  212. package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +26 -0
  213. package/dist/extensions/pi-subagents/test/unit/workflow-graph.test.ts +134 -0
  214. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +479 -0
  215. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +5 -2
  216. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +6 -4
  217. package/dist/main.d.ts.map +1 -1
  218. package/dist/main.js +4 -0
  219. package/dist/main.js.map +1 -1
  220. package/dist/modes/interactive/components/first-time-setup.d.ts +1 -1
  221. package/dist/modes/interactive/components/first-time-setup.d.ts.map +1 -1
  222. package/dist/modes/interactive/components/first-time-setup.js +19 -5
  223. package/dist/modes/interactive/components/first-time-setup.js.map +1 -1
  224. package/dist/skills/caveman/SKILL.md +49 -0
  225. package/dist/skills/handoff-text/SKILL.md +13 -0
  226. package/docs/docs.json +4 -0
  227. package/docs/extensions.md +2 -0
  228. package/docs/settings.md +15 -0
  229. package/docs/shared-host-extensions.md +109 -0
  230. package/package.json +1 -1
@@ -0,0 +1,2772 @@
1
+ /**
2
+ * Integration tests for async (background) agent execution.
3
+ *
4
+ * Tests the async support utilities: jiti availability check,
5
+ * status file reading/caching.
6
+ *
7
+ * Requires pi packages to be importable. Skips gracefully if unavailable.
8
+ */
9
+
10
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { spawnSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { createEventBus, createMockPi, createTempDir, events, makeAgent, makeMinimalCtx, removeTempDir, tryImport } from "../support/helpers.ts";
17
+ import type { MockPi } from "../support/helpers.ts";
18
+ import { deliverInterruptRequest } from "../../src/runs/background/control-channel.ts";
19
+
20
+ interface AsyncExecutionResult {
21
+ content: Array<{ text?: string }>;
22
+ isError?: boolean;
23
+ details: { asyncId?: string };
24
+ }
25
+
26
+ interface AsyncResultPayload {
27
+ lifecycleArtifactVersion?: number;
28
+ success: boolean;
29
+ state?: string;
30
+ exitCode?: number;
31
+ sessionId?: string;
32
+ mode?: string;
33
+ summary?: string;
34
+ error?: string;
35
+ timeoutMs?: number;
36
+ deadlineAt?: number;
37
+ timedOut?: boolean;
38
+ turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
39
+ turnBudgetExceeded?: boolean;
40
+ wrapUpRequested?: boolean;
41
+ totalTokens?: { input: number; output: number; total: number };
42
+ totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
43
+ 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 } }>;
44
+ outputs?: Record<string, { text?: string; structured?: unknown }>;
45
+ 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 }> }> };
46
+ }
47
+
48
+ interface AsyncStatusPayload {
49
+ lifecycleArtifactVersion?: number;
50
+ sessionId?: string;
51
+ activityState?: string;
52
+ currentTool?: string;
53
+ currentPath?: string;
54
+ state?: string;
55
+ error?: string;
56
+ timeoutMs?: number;
57
+ deadlineAt?: number;
58
+ timedOut?: boolean;
59
+ turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
60
+ turnBudgetExceeded?: boolean;
61
+ wrapUpRequested?: boolean;
62
+ totalTokens?: { total: number };
63
+ totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
64
+ parallelGroups?: Array<{ start: number; count: number; stepIndex: number }>;
65
+ steps?: Array<{
66
+ label?: string;
67
+ phase?: string;
68
+ outputName?: string;
69
+ structured?: boolean;
70
+ skills?: string[];
71
+ activityState?: string;
72
+ currentTool?: string;
73
+ status?: string;
74
+ exitCode?: number;
75
+ timedOut?: boolean;
76
+ error?: string;
77
+ model?: string;
78
+ thinking?: string;
79
+ tokens?: { total: number };
80
+ totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
81
+ acceptance?: { status?: string };
82
+ turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; exceededAtTurn?: number };
83
+ turnBudgetExceeded?: boolean;
84
+ wrapUpRequested?: boolean;
85
+ }>;
86
+ }
87
+
88
+ interface MockPiCallRecord {
89
+ args?: string[];
90
+ systemPrompts?: Array<{ mode?: string; path?: string; text?: string; error?: string }>;
91
+ }
92
+
93
+ function mockAssistantMessage(text: string, stopReason: "stop" | "tool_use" = "stop") {
94
+ return {
95
+ type: "message_end",
96
+ message: {
97
+ role: "assistant",
98
+ content: stopReason === "tool_use"
99
+ ? [{ type: "text", text }, { type: "toolCall", name: "bash", arguments: { command: "echo test" } }]
100
+ : [{ type: "text", text }],
101
+ model: "mock/test-model",
102
+ stopReason,
103
+ usage: {
104
+ input: 10,
105
+ output: 5,
106
+ cacheRead: 0,
107
+ cacheWrite: 0,
108
+ cost: { total: 0.001 },
109
+ },
110
+ },
111
+ };
112
+ }
113
+
114
+ interface AsyncExecutionModule {
115
+ isAsyncAvailable(): boolean;
116
+ executeAsyncSingle(id: string, params: Record<string, unknown>): AsyncExecutionResult;
117
+ executeAsyncChain(id: string, params: Record<string, unknown>): AsyncExecutionResult;
118
+ }
119
+
120
+ interface UtilsModule {
121
+ readStatus(dir: string): { runId: string; state: string; mode: string } | null;
122
+ }
123
+
124
+ interface TypesModule {
125
+ ASYNC_DIR: string;
126
+ RESULTS_DIR: string;
127
+ TEMP_ROOT_DIR: string;
128
+ }
129
+
130
+ interface ExecutorModule {
131
+ createSubagentExecutor?: (...args: unknown[]) => {
132
+ execute: (...args: unknown[]) => Promise<{ content: Array<{ text?: string }>; isError?: boolean; details?: { asyncId?: string } }>;
133
+ };
134
+ }
135
+
136
+ const asyncMod = await tryImport<AsyncExecutionModule>("./src/runs/background/async-execution.ts");
137
+ const utils = await tryImport<UtilsModule>("./src/shared/utils.ts");
138
+ const typesMod = await tryImport<TypesModule>("./src/shared/types.ts");
139
+ const executorMod = await tryImport<ExecutorModule>("./src/runs/foreground/subagent-executor.ts");
140
+ const available = !!(asyncMod && utils && typesMod);
141
+
142
+ const isAsyncAvailable = asyncMod?.isAsyncAvailable;
143
+ const executeAsyncSingle = asyncMod?.executeAsyncSingle;
144
+ const executeAsyncChain = asyncMod?.executeAsyncChain;
145
+ const readStatus = utils?.readStatus;
146
+ const ASYNC_DIR = typesMod?.ASYNC_DIR;
147
+ const RESULTS_DIR = typesMod?.RESULTS_DIR;
148
+ const TEMP_ROOT_DIR = typesMod?.TEMP_ROOT_DIR;
149
+ const createSubagentExecutor = executorMod?.createSubagentExecutor;
150
+
151
+ function escapeRegExp(value: string): string {
152
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
153
+ }
154
+
155
+ function git(cwd: string, args: string[]): string {
156
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
157
+ if (result.status !== 0) {
158
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `git ${args.join(" ")} failed`);
159
+ }
160
+ return result.stdout.trim();
161
+ }
162
+
163
+ function createRepo(prefix: string): string {
164
+ const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
165
+ git(repoDir, ["init"]);
166
+ git(repoDir, ["config", "user.email", "tests@example.com"]);
167
+ git(repoDir, ["config", "user.name", "Async Tests"]);
168
+ fs.writeFileSync(path.join(repoDir, "input.md"), "input\n", "utf-8");
169
+ git(repoDir, ["add", "-A"]);
170
+ git(repoDir, ["commit", "-m", "initial commit"]);
171
+ return repoDir;
172
+ }
173
+
174
+ function writePackageSkill(packageRoot: string, skillName: string): void {
175
+ const skillDir = path.join(packageRoot, "skills", skillName);
176
+ fs.mkdirSync(skillDir, { recursive: true });
177
+ fs.writeFileSync(
178
+ path.join(packageRoot, "package.json"),
179
+ JSON.stringify({ name: `${skillName}-pkg`, version: "1.0.0", pi: { skills: [`./skills/${skillName}`] } }, null, 2),
180
+ "utf-8",
181
+ );
182
+ fs.writeFileSync(
183
+ path.join(skillDir, "SKILL.md"),
184
+ `---\nname: ${skillName}\ndescription: test skill\n---\nbody\n`,
185
+ "utf-8",
186
+ );
187
+ }
188
+
189
+ async function waitForAsyncResultFile(id: string, timeoutMs = 15_000): Promise<string> {
190
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
191
+ const deadline = Date.now() + timeoutMs;
192
+ while (!fs.existsSync(resultPath)) {
193
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
194
+ await new Promise((resolve) => setTimeout(resolve, 100));
195
+ }
196
+ return resultPath;
197
+ }
198
+
199
+ async function waitForMockPiCall(mockPi: MockPi, index: number, timeoutMs = 30_000): Promise<{ args: string[]; systemPrompts: NonNullable<MockPiCallRecord["systemPrompts"]> }> {
200
+ const deadline = Date.now() + timeoutMs;
201
+ for (;;) {
202
+ const callFile = fs.readdirSync(mockPi.dir)
203
+ .filter((name) => name.startsWith("call-") && name.endsWith(".json"))
204
+ .sort()
205
+ .at(index);
206
+ if (callFile) {
207
+ const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as MockPiCallRecord;
208
+ assert.ok(Array.isArray(payload.args), "expected recorded args");
209
+ return { args: payload.args, systemPrompts: payload.systemPrompts ?? [] };
210
+ }
211
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for recorded mock pi call ${index}`);
212
+ await new Promise((resolve) => setTimeout(resolve, 100));
213
+ }
214
+ }
215
+
216
+ async function waitForMockPiArgs(mockPi: MockPi, index: number, timeoutMs = 30_000): Promise<string[]> {
217
+ return (await waitForMockPiCall(mockPi, index, timeoutMs)).args;
218
+ }
219
+
220
+ function readLastMockPiArgs(mockPi: MockPi): string[] {
221
+ const callFile = fs.readdirSync(mockPi.dir)
222
+ .filter((name) => name.startsWith("call-") && name.endsWith(".json"))
223
+ .sort()
224
+ .at(-1);
225
+ assert.ok(callFile, "expected a recorded mock pi call");
226
+ const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as MockPiCallRecord;
227
+ assert.ok(Array.isArray(payload.args), "expected recorded args");
228
+ return payload.args;
229
+ }
230
+
231
+ function readMockPiArgs(mockPi: MockPi, index: number): string[] {
232
+ const callFile = fs.readdirSync(mockPi.dir)
233
+ .filter((name) => name.startsWith("call-") && name.endsWith(".json"))
234
+ .sort()
235
+ .at(index);
236
+ assert.ok(callFile, `expected recorded call ${index}`);
237
+ const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as MockPiCallRecord;
238
+ assert.ok(Array.isArray(payload.args), "expected recorded args");
239
+ return payload.args;
240
+ }
241
+
242
+ function readMockPiArgsMatching(mockPi: MockPi, text: string): string[] {
243
+ const callFiles = fs.readdirSync(mockPi.dir)
244
+ .filter((name) => name.startsWith("call-") && name.endsWith(".json"))
245
+ .sort();
246
+ for (const callFile of callFiles) {
247
+ const payload = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as { args?: string[] };
248
+ assert.ok(Array.isArray(payload.args), "expected recorded args");
249
+ if (payload.args.join("\n").includes(text)) return payload.args;
250
+ }
251
+ assert.fail(`expected recorded call containing ${text}`);
252
+ }
253
+
254
+ describe("async execution utilities", { skip: !available ? "pi packages not available" : undefined }, () => {
255
+ let tempDir: string;
256
+ let mockPi: MockPi;
257
+
258
+ before(() => {
259
+ mockPi = createMockPi();
260
+ mockPi.install();
261
+ });
262
+
263
+ after(() => {
264
+ mockPi.uninstall();
265
+ });
266
+
267
+ beforeEach(() => {
268
+ tempDir = createTempDir();
269
+ mockPi.reset();
270
+ });
271
+
272
+ afterEach(() => {
273
+ removeTempDir(tempDir);
274
+ });
275
+ it("reports jiti availability as boolean", () => {
276
+ const result = isAsyncAvailable();
277
+ assert.equal(typeof result, "boolean");
278
+ });
279
+
280
+ it("spawns the async runner with node when process.execPath is not node", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
281
+ const originalExecPath = process.execPath;
282
+ process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi");
283
+ try {
284
+ mockPi.onCall({ output: "non-node exec async done" });
285
+ const id = `async-non-node-exec-${Date.now().toString(36)}`;
286
+ const result = executeAsyncSingle(id, {
287
+ agent: "worker",
288
+ task: "Say non-node exec async done. Do not edit files.",
289
+ agentConfig: makeAgent("worker"),
290
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
291
+ artifactConfig: {
292
+ enabled: false,
293
+ includeInput: false,
294
+ includeOutput: false,
295
+ includeJsonl: false,
296
+ includeMetadata: false,
297
+ cleanupDays: 7,
298
+ },
299
+ shareEnabled: false,
300
+ sessionRoot: path.join(tempDir, "sessions"),
301
+ maxSubagentDepth: 2,
302
+ });
303
+
304
+ assert.equal(result.isError, undefined);
305
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
306
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
307
+ assert.equal(payload.success, true);
308
+ assert.equal(payload.results[0]?.output, "non-node exec async done");
309
+ } finally {
310
+ process.execPath = originalExecPath;
311
+ }
312
+ });
313
+
314
+ it("falls back to PATH node when node-like process.execPath is stale", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
315
+ const originalExecPath = process.execPath;
316
+ process.execPath = path.join(tempDir, "deleted-node-install", "bin", process.platform === "win32" ? "node.exe" : "node");
317
+ try {
318
+ mockPi.onCall({ output: "stale node exec async done" });
319
+ const id = `async-stale-node-exec-${Date.now().toString(36)}`;
320
+ const result = executeAsyncSingle(id, {
321
+ agent: "worker",
322
+ task: "Say stale node exec async done. Do not edit files.",
323
+ agentConfig: makeAgent("worker"),
324
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
325
+ artifactConfig: {
326
+ enabled: false,
327
+ includeInput: false,
328
+ includeOutput: false,
329
+ includeJsonl: false,
330
+ includeMetadata: false,
331
+ cleanupDays: 7,
332
+ },
333
+ shareEnabled: false,
334
+ sessionRoot: path.join(tempDir, "sessions"),
335
+ maxSubagentDepth: 2,
336
+ });
337
+
338
+ assert.equal(result.isError, undefined);
339
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
340
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
341
+ assert.equal(payload.success, true);
342
+ assert.equal(payload.results[0]?.output, "stale node exec async done");
343
+ } finally {
344
+ process.execPath = originalExecPath;
345
+ }
346
+ });
347
+
348
+ it("readStatus returns null for missing directory", () => {
349
+ const status = readStatus("/nonexistent/path/abc123");
350
+ assert.equal(status, null);
351
+ });
352
+
353
+ it("readStatus parses valid status file", () => {
354
+ const dir = createTempDir();
355
+ try {
356
+ const statusData = {
357
+ runId: "test-123",
358
+ state: "running",
359
+ mode: "single",
360
+ startedAt: Date.now(),
361
+ lastUpdate: Date.now(),
362
+ steps: [{ agent: "test", status: "running" }],
363
+ };
364
+ fs.writeFileSync(path.join(dir, "status.json"), JSON.stringify(statusData));
365
+
366
+ const status = readStatus(dir);
367
+ assert.ok(status, "should parse status");
368
+ assert.equal(status.runId, "test-123");
369
+ assert.equal(status.state, "running");
370
+ assert.equal(status.mode, "single");
371
+ } finally {
372
+ removeTempDir(dir);
373
+ }
374
+ });
375
+
376
+ 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 () => {
377
+ mockPi.onCall({ delay: 5_000, output: "one done" });
378
+ mockPi.onCall({ delay: 5_000, output: "two done" });
379
+ mockPi.onCall({ delay: 5_000, output: "three done" });
380
+ const id = `async-interrupt-parallel-${Date.now().toString(36)}`;
381
+ executeAsyncChain(id, {
382
+ chain: [{
383
+ parallel: [
384
+ { agent: "one", task: "Wait" },
385
+ { agent: "two", task: "Wait" },
386
+ { agent: "three", task: "Wait" },
387
+ ],
388
+ concurrency: 3,
389
+ }],
390
+ resultMode: "parallel",
391
+ agents: [makeAgent("one"), makeAgent("two"), makeAgent("three")],
392
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
393
+ artifactConfig: {
394
+ enabled: false,
395
+ includeInput: false,
396
+ includeOutput: false,
397
+ includeJsonl: false,
398
+ includeMetadata: false,
399
+ cleanupDays: 7,
400
+ },
401
+ shareEnabled: false,
402
+ maxSubagentDepth: 2,
403
+ });
404
+
405
+ await waitForMockPiCall(mockPi, 2, 10_000);
406
+ const asyncDir = path.join(ASYNC_DIR, id);
407
+ const statusPath = path.join(asyncDir, "status.json");
408
+ const statusBeforeInterrupt = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload & { pid?: number };
409
+ deliverInterruptRequest({ asyncDir, pid: statusBeforeInterrupt.pid, source: "test" });
410
+
411
+ const resultPath = await waitForAsyncResultFile(id, 30_000);
412
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
413
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
414
+ assert.equal(payload.state, "paused");
415
+ assert.equal(payload.success, false);
416
+ assert.deepEqual(status.steps?.map((step) => step.status), ["paused", "paused", "paused"]);
417
+ assert.equal(mockPi.callCount(), 3);
418
+ });
419
+
420
+ 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 () => {
421
+ mockPi.onCall({ delay: 5_000, output: "one done" });
422
+ mockPi.onCall({ delay: 5_000, output: "two done" });
423
+ const id = `async-timeout-parallel-${Date.now().toString(36)}`;
424
+ executeAsyncChain(id, {
425
+ chain: [{
426
+ parallel: [
427
+ { agent: "one", task: "Wait" },
428
+ { agent: "two", task: "Wait" },
429
+ ],
430
+ concurrency: 2,
431
+ }],
432
+ resultMode: "parallel",
433
+ agents: [makeAgent("one"), makeAgent("two")],
434
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
435
+ artifactConfig: {
436
+ enabled: false,
437
+ includeInput: false,
438
+ includeOutput: false,
439
+ includeJsonl: false,
440
+ includeMetadata: false,
441
+ cleanupDays: 7,
442
+ },
443
+ shareEnabled: false,
444
+ maxSubagentDepth: 2,
445
+ timeoutMs: 1_500,
446
+ });
447
+
448
+ await waitForMockPiCall(mockPi, 1, 10_000);
449
+ const resultPath = await waitForAsyncResultFile(id, 8_000);
450
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
451
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
452
+ assert.equal(payload.state, "failed");
453
+ assert.equal(payload.success, false);
454
+ assert.equal(payload.exitCode, 1);
455
+ assert.equal(payload.timeoutMs, 1_500);
456
+ assert.equal(payload.timedOut, true);
457
+ assert.match(payload.summary ?? "", /Subagent timed out after 1500ms\./);
458
+ assert.equal(status.state, "failed");
459
+ assert.equal(status.timeoutMs, 1_500);
460
+ assert.equal(status.timedOut, true);
461
+ assert.match(status.error ?? "", /Subagent timed out after 1500ms\./);
462
+ assert.deepEqual(status.steps?.map((step) => step.status), ["failed", "failed"]);
463
+ assert.deepEqual(status.steps?.map((step) => step.timedOut), [true, true]);
464
+ assert.deepEqual(status.steps?.map((step) => step.error), ["Subagent timed out after 1500ms.", "Subagent timed out after 1500ms."]);
465
+ assert.deepEqual(payload.results.map((result) => result.timedOut), [true, true]);
466
+ assert.equal(mockPi.callCount(), 2);
467
+ });
468
+
469
+ it("hard-kills async children that ignore timeout SIGTERM", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
470
+ mockPi.onCall({ delay: 60_000, ignoreSigterm: true, output: "too late" });
471
+ const id = `async-timeout-hard-kill-${Date.now().toString(36)}`;
472
+ const timeoutMs = 1_500;
473
+ const startedAt = Date.now();
474
+ executeAsyncSingle(id, {
475
+ agent: "stubborn",
476
+ task: "Ignore soft termination",
477
+ agentConfig: makeAgent("stubborn", { model: "primary-model", fallbackModels: ["fallback-model"] }),
478
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
479
+ artifactConfig: {
480
+ enabled: false,
481
+ includeInput: false,
482
+ includeOutput: false,
483
+ includeJsonl: false,
484
+ includeMetadata: false,
485
+ cleanupDays: 7,
486
+ },
487
+ shareEnabled: false,
488
+ maxSubagentDepth: 2,
489
+ timeoutMs,
490
+ });
491
+
492
+ await waitForMockPiCall(mockPi, 0, 10_000);
493
+ const resultPath = await waitForAsyncResultFile(id, 8_000);
494
+ const elapsedMs = Date.now() - startedAt;
495
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
496
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
497
+ assert.equal(payload.state, "failed");
498
+ assert.equal(payload.timedOut, true);
499
+ assert.equal(payload.results[0]?.timedOut, true);
500
+ assert.equal(payload.results[0]?.error, `Subagent timed out after ${timeoutMs}ms.`);
501
+ assert.equal(status.timedOut, true);
502
+ assert.equal(status.steps?.[0]?.timedOut, true);
503
+ assert.ok(elapsedMs < 7_000, `timeout result should settle after hard kill, elapsed ${elapsedMs}ms`);
504
+ assert.equal(mockPi.callCount(), 1);
505
+ });
506
+
507
+ it("cancels async acceptance verification when the run times out", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
508
+ mockPi.onCall({ output: "implementation complete" });
509
+ const id = `async-timeout-acceptance-${Date.now().toString(36)}`;
510
+ const startedAt = Date.now();
511
+ executeAsyncSingle(id, {
512
+ agent: "worker",
513
+ task: "Implement with verified acceptance",
514
+ agentConfig: makeAgent("worker"),
515
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
516
+ artifactConfig: {
517
+ enabled: false,
518
+ includeInput: false,
519
+ includeOutput: false,
520
+ includeJsonl: false,
521
+ includeMetadata: false,
522
+ cleanupDays: 7,
523
+ },
524
+ shareEnabled: false,
525
+ maxSubagentDepth: 2,
526
+ timeoutMs: 1_000,
527
+ acceptance: {
528
+ level: "verified",
529
+ verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 5000)"`, timeoutMs: 10_000 }],
530
+ },
531
+ });
532
+
533
+ const resultPath = await waitForAsyncResultFile(id, 5_000);
534
+ const elapsedMs = Date.now() - startedAt;
535
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
536
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
537
+ assert.equal(payload.state, "failed");
538
+ assert.equal(payload.timedOut, true);
539
+ assert.equal(payload.results[0]?.timedOut, true);
540
+ assert.equal(payload.results[0]?.acceptance, undefined);
541
+ assert.equal(status.steps?.[0]?.timedOut, true);
542
+ assert.ok(elapsedMs < 3_000, `timeout should cancel acceptance verification promptly, elapsed ${elapsedMs}ms`);
543
+ });
544
+
545
+ it("async turn budget allows a terminal final grace turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
546
+ mockPi.onCall({
547
+ jsonl: [
548
+ mockAssistantMessage("working before wrap-up", "tool_use"),
549
+ mockAssistantMessage("final wrapped output", "stop"),
550
+ ],
551
+ });
552
+ const id = `async-turn-budget-soft-${Date.now().toString(36)}`;
553
+ executeAsyncSingle(id, {
554
+ agent: "worker",
555
+ task: "Use the final grace turn to wrap up.",
556
+ agentConfig: makeAgent("worker"),
557
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
558
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
559
+ shareEnabled: false,
560
+ maxSubagentDepth: 2,
561
+ turnBudget: { maxTurns: 1, graceTurns: 1 },
562
+ });
563
+
564
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
565
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
566
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
567
+ assert.equal(payload.success, true);
568
+ assert.equal(payload.state, "complete");
569
+ assert.equal(payload.turnBudgetExceeded, undefined);
570
+ assert.equal(payload.wrapUpRequested, true);
571
+ assert.equal(payload.turnBudget?.outcome, "wrap-up-requested");
572
+ assert.equal(payload.turnBudget?.turnCount, 2);
573
+ assert.equal(payload.results[0]?.wrapUpRequested, true);
574
+ assert.equal(payload.results[0]?.turnBudget?.turnCount, 2);
575
+ assert.match(payload.results[0]?.output ?? "", /Turn budget wrap-up was requested after 1 assistant turn/);
576
+ assert.match(payload.results[0]?.output ?? "", /final wrapped output/);
577
+ assert.equal(status.wrapUpRequested, true);
578
+ assert.equal(status.turnBudgetExceeded, undefined);
579
+ assert.equal(status.steps?.[0]?.wrapUpRequested, true);
580
+ assert.equal(status.steps?.[0]?.turnBudget?.turnCount, 2);
581
+ });
582
+
583
+ it("async turn budget hard-aborts a non-terminal final grace turn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
584
+ mockPi.onCall({
585
+ jsonl: [
586
+ mockAssistantMessage("working before wrap-up", "tool_use"),
587
+ mockAssistantMessage("still starting more tool work", "tool_use"),
588
+ ],
589
+ });
590
+ const id = `async-turn-budget-hard-${Date.now().toString(36)}`;
591
+ executeAsyncSingle(id, {
592
+ agent: "worker",
593
+ task: "Exceed the turn budget.",
594
+ agentConfig: makeAgent("worker"),
595
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
596
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
597
+ shareEnabled: false,
598
+ maxSubagentDepth: 2,
599
+ turnBudget: { maxTurns: 1, graceTurns: 1 },
600
+ });
601
+
602
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
603
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
604
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
605
+ assert.equal(payload.success, false);
606
+ assert.equal(payload.state, "failed");
607
+ assert.equal(payload.exitCode, 1);
608
+ assert.equal(payload.turnBudgetExceeded, true);
609
+ assert.equal(payload.wrapUpRequested, true);
610
+ assert.equal(payload.turnBudget?.outcome, "exceeded");
611
+ assert.equal(payload.turnBudget?.turnCount, 2);
612
+ assert.equal(payload.turnBudget?.exceededAtTurn, 2);
613
+ assert.equal(payload.results[0]?.turnBudgetExceeded, true);
614
+ assert.match(payload.results[0]?.output ?? "", /Partial output before turn-budget abort:/);
615
+ assert.match(payload.results[0]?.output ?? "", /still starting more tool work/);
616
+ assert.equal(status.state, "failed");
617
+ assert.equal(status.turnBudgetExceeded, true);
618
+ assert.equal(status.steps?.[0]?.turnBudgetExceeded, true);
619
+ assert.equal(status.steps?.[0]?.turnBudget?.outcome, "exceeded");
620
+ });
621
+
622
+ it("async launch messages tell the parent not to sleep-poll", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
623
+ const artifactConfig = {
624
+ enabled: false,
625
+ includeInput: false,
626
+ includeOutput: false,
627
+ includeJsonl: false,
628
+ includeMetadata: false,
629
+ cleanupDays: 7,
630
+ };
631
+ const commonParams = {
632
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
633
+ artifactConfig,
634
+ shareEnabled: false,
635
+ maxSubagentDepth: 2,
636
+ };
637
+ mockPi.onCall({ output: "single done" });
638
+ const singleId = `async-handoff-single-${Date.now().toString(36)}`;
639
+ const singleResult = executeAsyncSingle(singleId, {
640
+ agent: "worker",
641
+ task: "Do work",
642
+ agentConfig: makeAgent("worker"),
643
+ ...commonParams,
644
+ });
645
+ assert.match(singleResult.content[0]?.text ?? "", /Async: worker \[/);
646
+ assert.match(singleResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
647
+ assert.match(singleResult.content[0]?.text ?? "", /call wait\(\)/);
648
+ assert.match(singleResult.content[0]?.text ?? "", /there is no next turn, so use wait\(\)/);
649
+ await waitForAsyncResultFile(singleId, 30_000);
650
+
651
+ mockPi.onCall({ output: "parallel one done" });
652
+ mockPi.onCall({ output: "parallel two done" });
653
+ const parallelId = `async-handoff-parallel-${Date.now().toString(36)}`;
654
+ const parallelResult = executeAsyncChain(parallelId, {
655
+ chain: [{ parallel: [{ agent: "worker", task: "Do one" }, { agent: "reviewer", task: "Do two" }] }],
656
+ resultMode: "parallel",
657
+ agents: [makeAgent("worker"), makeAgent("reviewer")],
658
+ ...commonParams,
659
+ });
660
+ assert.match(parallelResult.content[0]?.text ?? "", /Async parallel:/);
661
+ assert.match(parallelResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
662
+ assert.match(parallelResult.content[0]?.text ?? "", /call wait\(\)/);
663
+ const parallelResultPath = await waitForAsyncResultFile(parallelId, 10_000);
664
+ const parallelPayload = JSON.parse(fs.readFileSync(parallelResultPath, "utf-8")) as { agent?: string; mode?: string };
665
+ assert.equal(parallelPayload.mode, "parallel");
666
+ assert.equal(parallelPayload.agent, "parallel:worker+reviewer");
667
+
668
+ mockPi.onCall({ output: "chain done" });
669
+ const chainId = `async-handoff-chain-${Date.now().toString(36)}`;
670
+ const chainResult = executeAsyncChain(chainId, {
671
+ chain: [{ agent: "worker", task: "Do chained work" }],
672
+ agents: [makeAgent("worker")],
673
+ ...commonParams,
674
+ });
675
+ assert.match(chainResult.content[0]?.text ?? "", /Async chain:/);
676
+ assert.match(chainResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
677
+ await waitForAsyncResultFile(chainId, 10_000);
678
+ });
679
+
680
+ it("top-level async parallel conversion preserves output, reads, and progress", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
681
+ mockPi.onCall({ output: "Async top-level report" });
682
+ const executor = createSubagentExecutor!({
683
+ pi: { events: createEventBus(), getSessionName: () => undefined },
684
+ state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
685
+ config: {},
686
+ asyncByDefault: false,
687
+ tempArtifactsDir: tempDir,
688
+ getSubagentSessionRoot: () => tempDir,
689
+ expandTilde: (p: string) => p,
690
+ discoverAgents: () => ({ agents: [makeAgent("worker", { defaultProgress: true })] }),
691
+ });
692
+
693
+ const result = await executor.execute(
694
+ "async-parallel-fields",
695
+ {
696
+ tasks: [{ agent: "worker", task: "Do async work", output: "async-top-output.md", reads: ["input.md"] }],
697
+ async: true,
698
+ clarify: false,
699
+ },
700
+ new AbortController().signal,
701
+ undefined,
702
+ makeMinimalCtx(tempDir),
703
+ );
704
+
705
+ const asyncId = result.details?.asyncId;
706
+ assert.ok(asyncId, "expected asyncId");
707
+ const resultPath = path.join(RESULTS_DIR, `${asyncId}.json`);
708
+ const statusPath = path.join(ASYNC_DIR, asyncId, "status.json");
709
+ const deadline = Date.now() + 10_000;
710
+ while (!fs.existsSync(resultPath)) {
711
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
712
+ await new Promise((resolve) => setTimeout(resolve, 100));
713
+ }
714
+
715
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
716
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
717
+ assert.equal(payload.mode, "parallel");
718
+ assert.equal(payload.sessionId, "session-123");
719
+ assert.equal(payload.results[0]?.acceptance?.status, "checked");
720
+ assert.equal(status.sessionId, "session-123");
721
+ assert.equal(status.steps?.[0]?.acceptance?.status, "checked");
722
+ const outputPath = path.join(tempDir, ".pi-subagents", "artifacts", "outputs", asyncId, "async-top-output.md");
723
+ const outputDeadline = Date.now() + 5_000;
724
+ while (!fs.existsSync(outputPath)) {
725
+ if (Date.now() > outputDeadline) {
726
+ assert.fail(`Timed out waiting for saved output file: ${outputPath}`);
727
+ }
728
+ await new Promise((resolve) => setTimeout(resolve, 50));
729
+ }
730
+ assert.equal(fs.readFileSync(outputPath, "utf-8"), "Async top-level report");
731
+ const callFile = fs.readdirSync(mockPi.dir).find((name) => name.startsWith("call-"));
732
+ assert.ok(callFile, "expected a recorded mock pi call");
733
+ const args = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")).args as string[];
734
+ const taskArg = args.at(-1) ?? "";
735
+ const progressPath = path.join(tempDir, ".pi-subagents", "artifacts", "progress", asyncId, "progress.md");
736
+ assert.ok(taskArg.includes(`[Read from: ${path.join(tempDir, "input.md")}]`));
737
+ assert.ok(taskArg.includes(`Update progress at: ${progressPath}`));
738
+ assert.ok(taskArg.includes(`Write your findings to exactly this path: ${outputPath}`));
739
+ assert.equal(fs.existsSync(progressPath), true);
740
+ assert.equal(fs.existsSync(path.join(tempDir, "progress.md")), false);
741
+ });
742
+
743
+ it("async single rejects explicit reviewed acceptance without a reviewer result", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
744
+ mockPi.onCall({
745
+ output: [
746
+ "implemented",
747
+ "```acceptance-report",
748
+ JSON.stringify({
749
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patched" }],
750
+ changedFiles: ["src/file.ts"],
751
+ testsAddedOrUpdated: ["test/file.test.ts"],
752
+ commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
753
+ validationOutput: ["passed"],
754
+ residualRisks: [],
755
+ noStagedFiles: true,
756
+ notes: "done",
757
+ }),
758
+ "```",
759
+ ].join("\n"),
760
+ });
761
+ const artifactConfig = {
762
+ enabled: false,
763
+ includeInput: false,
764
+ includeOutput: false,
765
+ includeJsonl: false,
766
+ includeMetadata: false,
767
+ cleanupDays: 7,
768
+ };
769
+ const id = `async-acceptance-${Date.now().toString(36)}`;
770
+ executeAsyncSingle(id, {
771
+ agent: "worker",
772
+ task: "Implement acceptance-covered fix",
773
+ agentConfig: makeAgent("worker", { completionGuard: false }),
774
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-acceptance" },
775
+ artifactConfig,
776
+ shareEnabled: false,
777
+ maxSubagentDepth: 2,
778
+ acceptance: { level: "reviewed", criteria: ["Patch bug"], review: false },
779
+ });
780
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
781
+ const result = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
782
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
783
+
784
+ assert.equal(result.success, false);
785
+ assert.equal(result.results[0]?.acceptance?.status, "rejected");
786
+ assert.ok(result.results[0]?.acceptance?.childReport);
787
+ assert.equal(result.results[0]?.acceptance?.reviewResult?.status, "needs-parent-decision");
788
+ assert.equal(status.steps?.[0]?.acceptance?.status, "rejected");
789
+ });
790
+
791
+ it("top-level async chain suppresses progress for {task} review-only tasks", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
792
+ mockPi.onCall({ output: "Async review" });
793
+ const executor = createSubagentExecutor!({
794
+ pi: { events: createEventBus(), getSessionName: () => undefined },
795
+ state: { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
796
+ config: {},
797
+ asyncByDefault: false,
798
+ tempArtifactsDir: tempDir,
799
+ getSubagentSessionRoot: () => tempDir,
800
+ expandTilde: (p: string) => p,
801
+ discoverAgents: () => ({ agents: [makeAgent("reviewer", { defaultProgress: true })] }),
802
+ });
803
+
804
+ const result = await executor.execute(
805
+ "async-chain-read-only-progress",
806
+ {
807
+ chain: [{ agent: "reviewer" }],
808
+ task: "Review-only. Do not edit files. Return findings.",
809
+ async: true,
810
+ clarify: false,
811
+ },
812
+ new AbortController().signal,
813
+ undefined,
814
+ makeMinimalCtx(tempDir),
815
+ );
816
+
817
+ const asyncId = result.details?.asyncId;
818
+ assert.ok(asyncId, "expected asyncId");
819
+ const resultPath = path.join(RESULTS_DIR, `${asyncId}.json`);
820
+ const deadline = Date.now() + 10_000;
821
+ while (!fs.existsSync(resultPath)) {
822
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
823
+ await new Promise((resolve) => setTimeout(resolve, 100));
824
+ }
825
+ const callFile = fs.readdirSync(mockPi.dir).find((name) => name.startsWith("call-"));
826
+ assert.ok(callFile, "expected a recorded mock pi call");
827
+ const args = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")).args as string[];
828
+ assert.doesNotMatch(args.at(-1) ?? "", /progress\.md/);
829
+ assert.equal(fs.existsSync(path.join(tempDir, "progress.md")), false);
830
+ });
831
+
832
+ it("async chains reject malformed named output references before spawning", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
833
+ const id = `async-malformed-output-ref-${Date.now().toString(36)}`;
834
+ const result = executeAsyncChain(id, {
835
+ chain: [{ agent: "consumer", task: "Use {outputs.bad-name}" }],
836
+ agents: [makeAgent("consumer")],
837
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-malformed" },
838
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
839
+ shareEnabled: false,
840
+ maxSubagentDepth: 2,
841
+ });
842
+
843
+ assert.equal(result.isError, true);
844
+ assert.match(result.content[0]?.text ?? "", /Invalid chain output reference '\{outputs\.bad-name\}'/);
845
+ assert.equal(mockPi.callCount(), 0);
846
+ });
847
+
848
+ it("async chains persist structured outputs, named outputs, and graph labels", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
849
+ const schema = {
850
+ type: "object",
851
+ required: ["value"],
852
+ properties: { value: { type: "string" } },
853
+ };
854
+ mockPi.onCall({ structuredOutput: { value: "Alpha structured" } });
855
+ mockPi.onCall({ output: "used named output" });
856
+ const id = `async-structured-chain-${Date.now().toString(36)}`;
857
+ const result = executeAsyncChain(id, {
858
+ chain: [
859
+ {
860
+ agent: "producer",
861
+ task: "Produce data",
862
+ phase: "Collect",
863
+ label: "Produce structured data",
864
+ as: "data",
865
+ outputSchema: schema,
866
+ },
867
+ { agent: "consumer", task: "Use {outputs.data}", phase: "Use", label: "Consume data" },
868
+ ],
869
+ agents: [makeAgent("producer"), makeAgent("consumer")],
870
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-structured" },
871
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
872
+ shareEnabled: false,
873
+ maxSubagentDepth: 2,
874
+ });
875
+
876
+ assert.ok(!result.isError);
877
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
878
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
879
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
880
+ assert.deepEqual(payload.results[0]?.structuredOutput, { value: "Alpha structured" });
881
+ assert.deepEqual(payload.outputs?.data?.structured, { value: "Alpha structured" });
882
+ assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", /Alpha structured/);
883
+ assert.equal(status.steps?.[0]?.label, "Produce structured data");
884
+ assert.equal(status.steps?.[0]?.phase, "Collect");
885
+ assert.equal(status.steps?.[0]?.outputName, "data");
886
+ assert.equal(status.steps?.[0]?.structured, true);
887
+ assert.equal(payload.workflowGraph?.nodes?.[0]?.label, "Produce structured data");
888
+ assert.equal(payload.workflowGraph?.nodes?.[0]?.outputName, "data");
889
+ assert.equal(payload.workflowGraph?.nodes?.[0]?.status, "completed");
890
+ assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "completed");
891
+ });
892
+
893
+ it("async chains can start parallel, funnel into one step, then fan back out", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
894
+ mockPi.onCall({ matchArgIncludes: "Scout API", output: "Scout A async findings" });
895
+ mockPi.onCall({ matchArgIncludes: "Scout UI", output: "Scout B async findings" });
896
+ mockPi.onCall({ matchArgIncludes: "Synthesize:", output: "Async funnel synthesis" });
897
+ mockPi.onCall({ matchArgIncludes: "Review funnel A:", output: "Async reviewer A done" });
898
+ mockPi.onCall({ matchArgIncludes: "Review funnel B:", output: "Async reviewer B done" });
899
+ const id = `async-parallel-funnel-fanout-${Date.now().toString(36)}`;
900
+ const result = executeAsyncChain(id, {
901
+ chain: [
902
+ {
903
+ parallel: [
904
+ { agent: "scout-a", task: "Scout API" },
905
+ { agent: "scout-b", task: "Scout UI" },
906
+ ],
907
+ concurrency: 2,
908
+ },
909
+ { agent: "synthesizer", task: "Synthesize:\n{previous}" },
910
+ {
911
+ parallel: [
912
+ { agent: "review-a", task: "Review funnel A:\n{previous}" },
913
+ { agent: "review-b", task: "Review funnel B:\n{previous}" },
914
+ ],
915
+ concurrency: 2,
916
+ },
917
+ ],
918
+ agents: [makeAgent("scout-a"), makeAgent("scout-b"), makeAgent("synthesizer"), makeAgent("review-a"), makeAgent("review-b")],
919
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-parallel-funnel-fanout" },
920
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
921
+ shareEnabled: false,
922
+ maxSubagentDepth: 2,
923
+ });
924
+
925
+ assert.ok(!result.isError, `should launch: ${JSON.stringify(result.content)}`);
926
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
927
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
928
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
929
+ assert.equal(payload.success, true);
930
+ assert.deepEqual(payload.results.map((entry) => entry.output), [
931
+ "Scout A async findings",
932
+ "Scout B async findings",
933
+ "Async funnel synthesis",
934
+ "Async reviewer A done",
935
+ "Async reviewer B done",
936
+ ]);
937
+ assert.deepEqual(status.steps?.map((step) => step.status), ["complete", "complete", "complete", "complete", "complete"]);
938
+ assert.deepEqual(status.parallelGroups, [
939
+ { start: 0, count: 2, stepIndex: 0 },
940
+ { start: 3, count: 2, stepIndex: 2 },
941
+ ]);
942
+ const funnelTask = readMockPiArgsMatching(mockPi, "Synthesize:").at(-1) ?? "";
943
+ assert.match(funnelTask, /=== Parallel Task 1 \(scout-a\) ===/);
944
+ assert.match(funnelTask, /Scout A async findings/);
945
+ assert.match(funnelTask, /=== Parallel Task 2 \(scout-b\) ===/);
946
+ assert.match(funnelTask, /Scout B async findings/);
947
+ assert.match(readMockPiArgsMatching(mockPi, "Review funnel A:").at(-1) ?? "", /Review funnel A:\nAsync funnel synthesis/);
948
+ assert.match(readMockPiArgsMatching(mockPi, "Review funnel B:").at(-1) ?? "", /Review funnel B:\nAsync funnel synthesis/);
949
+ assert.equal(payload.workflowGraph?.nodes?.[0]?.kind, "parallel-group");
950
+ assert.equal(payload.workflowGraph?.nodes?.[0]?.status, "completed");
951
+ assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "step");
952
+ assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "completed");
953
+ assert.equal(payload.workflowGraph?.nodes?.[2]?.kind, "parallel-group");
954
+ assert.equal(payload.workflowGraph?.nodes?.[2]?.status, "completed");
955
+ });
956
+
957
+ it("async dynamic status shows a placeholder before materialization", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
958
+ mockPi.onCall({ delay: 800, output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
959
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
960
+ mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
961
+ mockPi.onCall({ output: "used reviews" });
962
+ const id = `async-dynamic-placeholder-${Date.now().toString(36)}`;
963
+ const result = executeAsyncChain(id, {
964
+ chain: [
965
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
966
+ {
967
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
968
+ parallel: { agent: "reviewer", task: "Review {target.path}", label: "Review {target.path}", outputSchema: { type: "object" } },
969
+ collect: { as: "reviews" },
970
+ concurrency: 1,
971
+ },
972
+ { agent: "consumer", task: "Use {outputs.reviews}" },
973
+ ],
974
+ agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")],
975
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-placeholder" },
976
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
977
+ shareEnabled: false,
978
+ maxSubagentDepth: 2,
979
+ });
980
+
981
+ assert.ok(!result.isError);
982
+ const statusPath = path.join(ASYNC_DIR, id, "status.json");
983
+ const deadline = Date.now() + 5_000;
984
+ let status: AsyncStatusPayload | undefined;
985
+ while (!status) {
986
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for async status file: ${statusPath}`);
987
+ if (fs.existsSync(statusPath)) status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
988
+ else await new Promise((resolve) => setTimeout(resolve, 50));
989
+ }
990
+ assert.deepEqual(status.steps?.map((step) => step.agent), ["producer", "expand:reviewer", "consumer"]);
991
+ assert.equal(status.steps?.[1]?.label, "Review {target.path}");
992
+ assert.equal(status.steps?.[1]?.outputName, "reviews");
993
+ assert.deepEqual(status.parallelGroups, [{ start: 1, count: 1, stepIndex: 1 }]);
994
+
995
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
996
+ const finalStatus = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
997
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
998
+ assert.equal(payload.success, true);
999
+ assert.deepEqual(finalStatus.steps?.map((step) => step.agent), ["producer", "reviewer", "reviewer", "consumer"]);
1000
+ assert.deepEqual(finalStatus.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]);
1001
+ });
1002
+
1003
+ it("async chains expand dynamic fanout and persist collected output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1004
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1005
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
1006
+ mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
1007
+ mockPi.onCall({ output: "used reviews" });
1008
+ const id = `async-dynamic-chain-${Date.now().toString(36)}`;
1009
+ const result = executeAsyncChain(id, {
1010
+ chain: [
1011
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1012
+ {
1013
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
1014
+ parallel: {
1015
+ agent: "reviewer",
1016
+ task: "Review {target.path}",
1017
+ label: "Review {target.path}",
1018
+ outputSchema: { type: "object" },
1019
+ },
1020
+ collect: { as: "reviews" },
1021
+ concurrency: 1,
1022
+ },
1023
+ { agent: "consumer", task: "Use {outputs.reviews}" },
1024
+ ],
1025
+ agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")],
1026
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" },
1027
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1028
+ shareEnabled: false,
1029
+ maxSubagentDepth: 2,
1030
+ });
1031
+
1032
+ assert.ok(!result.isError);
1033
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1034
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1035
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
1036
+ assert.equal(payload.success, true);
1037
+ assert.equal(mockPi.callCount(), 4);
1038
+ assert.match(readMockPiArgs(mockPi, 1).at(-1) ?? "", /Review src\/a\.ts/);
1039
+ assert.match(readMockPiArgs(mockPi, 2).at(-1) ?? "", /Review src\/b\.ts/);
1040
+ assert.match(readMockPiArgs(mockPi, 3).at(-1) ?? "", /"key":"src\/a\.ts"/);
1041
+ const collected = payload.outputs?.reviews?.structured as Array<{ key: string; structured: unknown }>;
1042
+ assert.deepEqual(collected.map((item) => item.key), ["src/a.ts", "src/b.ts"]);
1043
+ assert.deepEqual(collected.map((item) => item.structured), [{ ok: "a" }, { ok: "b" }]);
1044
+ assert.equal(status.steps?.length, 4);
1045
+ assert.deepEqual(status.parallelGroups, [{ start: 1, count: 2, stepIndex: 1 }]);
1046
+ assert.equal(payload.workflowGraph?.nodes?.[1]?.kind, "dynamic-parallel-group");
1047
+ assert.deepEqual(payload.workflowGraph?.nodes?.[1]?.children?.map((child) => child.itemKey), ["src/a.ts", "src/b.ts"]);
1048
+ assert.equal(payload.workflowGraph?.nodes?.[2]?.flatIndex, 3);
1049
+ });
1050
+
1051
+ it("async dynamic fanout applies fork session files and thinking overrides to materialized children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1052
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1053
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
1054
+ mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
1055
+ const id = `async-dynamic-fork-thinking-${Date.now().toString(36)}`;
1056
+ const sessionA = path.join(tempDir, "dynamic-a.jsonl");
1057
+ const sessionB = path.join(tempDir, "dynamic-b.jsonl");
1058
+ const result = executeAsyncChain(id, {
1059
+ chain: [
1060
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1061
+ {
1062
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 2 },
1063
+ parallel: {
1064
+ agent: "reviewer",
1065
+ task: "Review {target.path}",
1066
+ label: "Review {target.path}",
1067
+ outputSchema: { type: "object" },
1068
+ },
1069
+ collect: { as: "reviews" },
1070
+ concurrency: 1,
1071
+ },
1072
+ ],
1073
+ agents: [makeAgent("producer"), makeAgent("reviewer", { model: "anthropic/claude-sonnet-4-5:high", thinking: "high" })],
1074
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic" },
1075
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1076
+ shareEnabled: false,
1077
+ sessionFilesByFlatIndex: [undefined, sessionA, sessionB],
1078
+ thinkingOverridesByFlatIndex: [undefined, "off", "off"],
1079
+ maxSubagentDepth: 2,
1080
+ });
1081
+
1082
+ assert.ok(!result.isError);
1083
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1084
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1085
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
1086
+ const firstDynamicArgs = readMockPiArgs(mockPi, 1);
1087
+ const secondDynamicArgs = readMockPiArgs(mockPi, 2);
1088
+ assert.equal(payload.success, true);
1089
+ assert.equal(firstDynamicArgs[firstDynamicArgs.indexOf("--session") + 1], sessionA);
1090
+ assert.equal(secondDynamicArgs[secondDynamicArgs.indexOf("--session") + 1], sessionB);
1091
+ assert.equal(firstDynamicArgs[firstDynamicArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4-5:off");
1092
+ assert.equal(secondDynamicArgs[secondDynamicArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4-5:off");
1093
+ assert.deepEqual(status.steps?.slice(1).map((step) => step.sessionFile), [sessionA, sessionB]);
1094
+ assert.deepEqual(status.steps?.slice(1).map((step) => step.thinking), ["off", "off"]);
1095
+ });
1096
+
1097
+ 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 () => {
1098
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
1099
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
1100
+ const id = `async-dynamic-acceptance-timeout-${Date.now().toString(36)}`;
1101
+ const startedAt = Date.now();
1102
+ executeAsyncChain(id, {
1103
+ chain: [
1104
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1105
+ {
1106
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
1107
+ parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" }, acceptance: { level: "checked" } },
1108
+ collect: { as: "reviews" },
1109
+ acceptance: {
1110
+ level: "verified",
1111
+ verify: [{ id: "slow", command: `${process.execPath} -e "setTimeout(()=>process.exit(0), 5000)"`, timeoutMs: 10_000 }],
1112
+ },
1113
+ },
1114
+ ],
1115
+ agents: [makeAgent("producer"), makeAgent("reviewer")],
1116
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-acceptance-timeout" },
1117
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1118
+ shareEnabled: false,
1119
+ maxSubagentDepth: 2,
1120
+ timeoutMs: 1_000,
1121
+ });
1122
+
1123
+ const resultPath = await waitForAsyncResultFile(id, 5_000);
1124
+ const elapsedMs = Date.now() - startedAt;
1125
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1126
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload;
1127
+ const dynamicNode = payload.workflowGraph?.nodes?.[1] as { status?: string; error?: string; acceptanceStatus?: string } | undefined;
1128
+ assert.equal(payload.state, "failed");
1129
+ assert.equal(payload.timedOut, true);
1130
+ assert.equal(payload.results.at(-1)?.timedOut, true);
1131
+ assert.equal(payload.results.at(-1)?.acceptance, undefined);
1132
+ assert.equal(dynamicNode?.status, "failed");
1133
+ assert.match(dynamicNode?.error ?? "", /Subagent timed out after 1000ms\./);
1134
+ assert.notEqual(dynamicNode?.acceptanceStatus, "verified");
1135
+ assert.equal(status.timedOut, true);
1136
+ assert.ok(elapsedMs < 3_000, `timeout should cancel dynamic aggregate acceptance promptly, elapsed ${elapsedMs}ms`);
1137
+ });
1138
+
1139
+ it("async dynamic fanout recomputes later child intercom targets by final flat index", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1140
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1141
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
1142
+ mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
1143
+ mockPi.onCall({ echoEnv: ["SELESAI_SUBAGENT_INTERCOM_SESSION_NAME"] });
1144
+ const id = `async-dynamic-targets-${Date.now().toString(36)}`;
1145
+ const result = executeAsyncChain(id, {
1146
+ chain: [
1147
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1148
+ {
1149
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
1150
+ parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" } },
1151
+ collect: { as: "reviews" },
1152
+ concurrency: 1,
1153
+ },
1154
+ { agent: "consumer", task: "Use {outputs.reviews}" },
1155
+ ],
1156
+ agents: [makeAgent("producer"), makeAgent("reviewer"), makeAgent("consumer")],
1157
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-targets" },
1158
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1159
+ shareEnabled: false,
1160
+ maxSubagentDepth: 2,
1161
+ controlIntercomTarget: "subagent-orchestrator-test",
1162
+ childIntercomTarget: (agent: string, index: number) => `subagent-${agent}-${id}-${index + 1}`,
1163
+ });
1164
+
1165
+ assert.ok(!result.isError);
1166
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1167
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1168
+ const expectedConsumerTarget = `subagent-consumer-${id}-4`;
1169
+ assert.equal(payload.success, true);
1170
+ assert.equal(payload.results[3]?.intercomTarget, expectedConsumerTarget);
1171
+ assert.deepEqual(JSON.parse(payload.results[3]?.output ?? "{}"), { SELESAI_SUBAGENT_INTERCOM_SESSION_NAME: expectedConsumerTarget });
1172
+ });
1173
+
1174
+ it("async dynamic pre-spawn failures persist failed graph status and error", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1175
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
1176
+ const id = `async-dynamic-prespawn-fail-${Date.now().toString(36)}`;
1177
+ const result = executeAsyncChain(id, {
1178
+ chain: [
1179
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1180
+ {
1181
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 1 },
1182
+ parallel: { agent: "reviewer", task: "Review {target.path}" },
1183
+ collect: { as: "reviews" },
1184
+ },
1185
+ ],
1186
+ agents: [makeAgent("producer"), makeAgent("reviewer")],
1187
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-fail" },
1188
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1189
+ shareEnabled: false,
1190
+ maxSubagentDepth: 2,
1191
+ });
1192
+
1193
+ assert.ok(!result.isError);
1194
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1195
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1196
+ const status = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "status.json"), "utf-8")) as AsyncStatusPayload & { workflowGraph?: AsyncResultPayload["workflowGraph"]; error?: string };
1197
+ assert.equal(payload.success, false);
1198
+ assert.match(payload.results.at(-1)?.error ?? "", /exceeding maxItems 1/);
1199
+ assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "failed");
1200
+ assert.match(payload.workflowGraph?.nodes?.[1]?.error ?? "", /exceeding maxItems 1/);
1201
+ assert.equal(status.state, "failed");
1202
+ assert.match(status.error ?? "", /exceeding maxItems 1/);
1203
+ assert.equal(status.workflowGraph?.nodes?.[1]?.status, "failed");
1204
+ });
1205
+
1206
+ it("async dynamic collect schema failures persist failed graph status and details", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1207
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
1208
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
1209
+ const id = `async-dynamic-collect-fail-${Date.now().toString(36)}`;
1210
+ const result = executeAsyncChain(id, {
1211
+ chain: [
1212
+ { agent: "producer", task: "Produce targets", as: "targets", outputSchema: { type: "object" } },
1213
+ {
1214
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
1215
+ parallel: { agent: "reviewer", task: "Review {target.path}", outputSchema: { type: "object" } },
1216
+ collect: { as: "reviews", outputSchema: { type: "object" } },
1217
+ },
1218
+ ],
1219
+ agents: [makeAgent("producer"), makeAgent("reviewer")],
1220
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-dynamic-collect-fail" },
1221
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1222
+ shareEnabled: false,
1223
+ maxSubagentDepth: 2,
1224
+ });
1225
+
1226
+ assert.ok(!result.isError);
1227
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1228
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1229
+ assert.equal(payload.success, false);
1230
+ assert.match(payload.results.at(-1)?.error ?? "", /Collected output validation failed/);
1231
+ assert.ok(Array.isArray(payload.results.at(-1)?.structuredOutput), "failed collect result should preserve ordered collection details");
1232
+ assert.equal(payload.workflowGraph?.nodes?.[1]?.status, "failed");
1233
+ assert.match(payload.workflowGraph?.nodes?.[1]?.error ?? "", /Collected output validation failed/);
1234
+ });
1235
+
1236
+ 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 () => {
1237
+ const repoDir = createRepo("pi-subagent-async-worktree-");
1238
+ try {
1239
+ mockPi.onCall({ output: "Worktree report" });
1240
+ const executor = createSubagentExecutor!({
1241
+ pi: { events: createEventBus(), getSessionName: () => undefined },
1242
+ state: { baseCwd: repoDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null },
1243
+ config: {},
1244
+ asyncByDefault: false,
1245
+ tempArtifactsDir: repoDir,
1246
+ getSubagentSessionRoot: () => repoDir,
1247
+ expandTilde: (p: string) => p,
1248
+ discoverAgents: () => ({ agents: [makeAgent("worker")] }),
1249
+ });
1250
+
1251
+ const result = await executor.execute(
1252
+ "async-parallel-worktree-fields",
1253
+ {
1254
+ tasks: [{ agent: "worker", task: "Do worktree work", output: "report.md", reads: ["input.md"] }],
1255
+ async: true,
1256
+ clarify: false,
1257
+ worktree: true,
1258
+ },
1259
+ new AbortController().signal,
1260
+ undefined,
1261
+ makeMinimalCtx(repoDir),
1262
+ );
1263
+
1264
+ const asyncId = result.details?.asyncId;
1265
+ assert.ok(asyncId, "expected asyncId");
1266
+
1267
+ const worktreeCwd = path.join(os.tmpdir(), `pi-worktree-${asyncId}-s0-0`);
1268
+ const args = await waitForMockPiArgs(mockPi, 0);
1269
+ const taskArg = args.at(-1) ?? "";
1270
+ assert.ok(taskArg.includes(`[Read from: ${path.join(worktreeCwd, "input.md")}]`));
1271
+ assert.ok(taskArg.includes(`Write your findings to exactly this path: ${path.join(repoDir, ".pi-subagents", "artifacts", "outputs", asyncId, "report.md")}`));
1272
+ await waitForAsyncResultFile(asyncId, 90_000);
1273
+ } finally {
1274
+ removeTempDir(repoDir);
1275
+ }
1276
+ });
1277
+
1278
+ it("readStatus caches by mtime (second call uses cache)", () => {
1279
+ const dir = createTempDir();
1280
+ try {
1281
+ const statusData = {
1282
+ runId: "cache-test",
1283
+ state: "running",
1284
+ mode: "single",
1285
+ startedAt: Date.now(),
1286
+ };
1287
+ fs.writeFileSync(path.join(dir, "status.json"), JSON.stringify(statusData));
1288
+
1289
+ const s1 = readStatus(dir);
1290
+ const s2 = readStatus(dir);
1291
+ assert.ok(s1);
1292
+ assert.ok(s2);
1293
+ assert.equal(s1.runId, s2.runId);
1294
+ } finally {
1295
+ removeTempDir(dir);
1296
+ }
1297
+ });
1298
+
1299
+ it("readStatus throws for malformed status files", () => {
1300
+ const dir = createTempDir();
1301
+ try {
1302
+ fs.writeFileSync(path.join(dir, "status.json"), "{bad-json", "utf-8");
1303
+ assert.throws(() => readStatus(dir), /Failed to parse async status file/);
1304
+ } finally {
1305
+ removeTempDir(dir);
1306
+ }
1307
+ });
1308
+
1309
+ it("background runs record fallback attempts and final model", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1310
+ mockPi.onCall({
1311
+ jsonl: [{
1312
+ type: "message_end",
1313
+ message: {
1314
+ role: "assistant",
1315
+ content: [{ type: "text", text: "primary failed" }],
1316
+ model: "openai/gpt-5-mini",
1317
+ errorMessage: "rate limit exceeded",
1318
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
1319
+ },
1320
+ }],
1321
+ exitCode: 1,
1322
+ });
1323
+ mockPi.onCall({ output: "Recovered asynchronously" });
1324
+ const id = `async-fallback-${Date.now().toString(36)}`;
1325
+ const sessionRoot = path.join(tempDir, "sessions");
1326
+ const asyncDir = path.join(ASYNC_DIR, id);
1327
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
1328
+ const run = executeAsyncSingle(id, {
1329
+ agent: "worker",
1330
+ task: "Do work",
1331
+ agentConfig: makeAgent("worker", {
1332
+ model: "openai/gpt-5-mini:high",
1333
+ fallbackModels: ["anthropic/claude-sonnet-4:low"],
1334
+ }),
1335
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1336
+ availableModels: [
1337
+ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
1338
+ { provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
1339
+ ],
1340
+ artifactConfig: {
1341
+ enabled: false,
1342
+ includeInput: false,
1343
+ includeOutput: false,
1344
+ includeJsonl: false,
1345
+ includeMetadata: false,
1346
+ cleanupDays: 7,
1347
+ },
1348
+ shareEnabled: false,
1349
+ sessionRoot,
1350
+ maxSubagentDepth: 2,
1351
+ });
1352
+
1353
+ assert.equal(run.details.asyncId, id);
1354
+
1355
+ const started = Date.now();
1356
+ while (!fs.existsSync(resultPath)) {
1357
+ if (Date.now() - started > 15000) {
1358
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
1359
+ }
1360
+ await new Promise((resolve) => setTimeout(resolve, 100));
1361
+ }
1362
+
1363
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
1364
+ assert.equal(payload.lifecycleArtifactVersion, 1);
1365
+ assert.equal(payload.success, true);
1366
+ assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:low");
1367
+ assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:high", "anthropic/claude-sonnet-4:low"]);
1368
+ assert.equal(payload.results[0].modelAttempts.length, 2);
1369
+ assert.deepEqual(payload.results[0].totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1370
+ assert.deepEqual(payload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1371
+ const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
1372
+ assert.equal(statusPayload.lifecycleArtifactVersion, 1);
1373
+ assert.equal(statusPayload.steps[0]?.model, "anthropic/claude-sonnet-4:low");
1374
+ assert.equal(statusPayload.steps[0]?.thinking, "low");
1375
+ assert.ok(statusPayload.totalTokens!.total > 0);
1376
+ assert.ok(statusPayload.steps[0]?.tokens!.total > 0);
1377
+ assert.deepEqual(statusPayload.steps[0]?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1378
+ assert.deepEqual(statusPayload.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1379
+ const events = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8").trim().split("\n").map((line) => JSON.parse(line));
1380
+ assert.equal(events.find((event) => event.type === "subagent.run.started")?.lifecycleArtifactVersion, 1);
1381
+ const completed = events.find((event) => event.type === "subagent.run.completed");
1382
+ assert.equal(completed?.lifecycleArtifactVersion, 1);
1383
+ assert.deepEqual(completed?.totalCost, { inputTokens: 110, outputTokens: 55, costUsd: 0.011 });
1384
+ assert.match(fs.readFileSync(path.join(asyncDir, "output-0.log"), "utf-8"), /Recovered asynchronously/);
1385
+ assert.equal(mockPi.callCount(), 2);
1386
+ });
1387
+
1388
+ it("background single thinking override replaces primary and fallback suffixes", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1389
+ mockPi.onCall({
1390
+ jsonl: [{
1391
+ type: "message_end",
1392
+ message: {
1393
+ role: "assistant",
1394
+ content: [{ type: "text", text: "primary failed" }],
1395
+ model: "openai/gpt-5-mini",
1396
+ errorMessage: "rate limit exceeded",
1397
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
1398
+ },
1399
+ }],
1400
+ exitCode: 1,
1401
+ });
1402
+ mockPi.onCall({ output: "Recovered asynchronously" });
1403
+ const id = `async-fallback-thinking-off-${Date.now().toString(36)}`;
1404
+ const run = executeAsyncSingle(id, {
1405
+ agent: "worker",
1406
+ task: "Do work",
1407
+ agentConfig: makeAgent("worker", {
1408
+ model: "openai/gpt-5-mini:high",
1409
+ fallbackModels: ["anthropic/claude-sonnet-4:low"],
1410
+ thinking: "high",
1411
+ }),
1412
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1413
+ availableModels: [
1414
+ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
1415
+ { provider: "anthropic", id: "claude-sonnet-4", fullId: "anthropic/claude-sonnet-4" },
1416
+ ],
1417
+ artifactConfig: {
1418
+ enabled: false,
1419
+ includeInput: false,
1420
+ includeOutput: false,
1421
+ includeJsonl: false,
1422
+ includeMetadata: false,
1423
+ cleanupDays: 7,
1424
+ },
1425
+ shareEnabled: false,
1426
+ sessionRoot: path.join(tempDir, "sessions"),
1427
+ thinkingOverride: "off",
1428
+ maxSubagentDepth: 2,
1429
+ });
1430
+
1431
+ assert.equal(run.details.asyncId, id);
1432
+ const resultPath = await waitForAsyncResultFile(id);
1433
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1434
+ const firstArgs = readMockPiArgs(mockPi, 0);
1435
+ const secondArgs = readMockPiArgs(mockPi, 1);
1436
+ assert.equal(payload.success, true);
1437
+ assert.equal(payload.results[0].model, "anthropic/claude-sonnet-4:off");
1438
+ assert.deepEqual(payload.results[0].attemptedModels, ["openai/gpt-5-mini:off", "anthropic/claude-sonnet-4:off"]);
1439
+ assert.equal(firstArgs[firstArgs.indexOf("--model") + 1], "openai/gpt-5-mini:off");
1440
+ assert.equal(secondArgs[secondArgs.indexOf("--model") + 1], "anthropic/claude-sonnet-4:off");
1441
+ });
1442
+
1443
+ it("background runs retry fallback models when a zero-exit attempt has empty output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1444
+ mockPi.onCall({
1445
+ jsonl: [{
1446
+ type: "message_end",
1447
+ message: {
1448
+ role: "assistant",
1449
+ content: [{ type: "text", text: "" }],
1450
+ model: "openai/gpt-5-mini",
1451
+ stopReason: "error",
1452
+ usage: { input: 10, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
1453
+ },
1454
+ }],
1455
+ exitCode: 0,
1456
+ });
1457
+ mockPi.onCall({ output: "Recovered asynchronously from empty output" });
1458
+ const id = `async-empty-output-fallback-${Date.now().toString(36)}`;
1459
+ executeAsyncSingle(id, {
1460
+ agent: "worker",
1461
+ task: "Do work",
1462
+ agentConfig: makeAgent("worker", {
1463
+ model: "openai/gpt-5-mini",
1464
+ fallbackModels: ["anthropic/claude-sonnet-4"],
1465
+ }),
1466
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1467
+ artifactConfig: {
1468
+ enabled: false,
1469
+ includeInput: false,
1470
+ includeOutput: false,
1471
+ includeJsonl: false,
1472
+ includeMetadata: false,
1473
+ cleanupDays: 7,
1474
+ },
1475
+ shareEnabled: false,
1476
+ maxSubagentDepth: 2,
1477
+ });
1478
+
1479
+ const resultPath = await waitForAsyncResultFile(id);
1480
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1481
+ assert.equal(payload.success, true);
1482
+ assert.equal(payload.results[0]?.model, "anthropic/claude-sonnet-4");
1483
+ assert.match(payload.results[0]?.output ?? "", /Recovered asynchronously from empty output/);
1484
+ assert.match(payload.results[0]?.modelAttempts?.[0]?.error ?? "", /no output/i);
1485
+ assert.deepEqual(payload.results[0]?.modelAttempts?.map((attempt) => attempt.success), [false, true]);
1486
+ assert.equal(mockPi.callCount(), 2);
1487
+ });
1488
+
1489
+ it("background runs fail zero-exit provider errors when no fallback succeeds", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1490
+ mockPi.onCall({
1491
+ jsonl: [{
1492
+ type: "message_end",
1493
+ message: {
1494
+ role: "assistant",
1495
+ content: [{ type: "text", text: "quota hit" }],
1496
+ model: "openai/gpt-5-mini",
1497
+ errorMessage: "429 quota exceeded",
1498
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
1499
+ },
1500
+ }],
1501
+ exitCode: 0,
1502
+ });
1503
+ const id = `async-zero-exit-provider-error-${Date.now().toString(36)}`;
1504
+ const asyncDir = path.join(ASYNC_DIR, id);
1505
+ executeAsyncSingle(id, {
1506
+ agent: "worker",
1507
+ task: "Do work",
1508
+ agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
1509
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1510
+ artifactConfig: {
1511
+ enabled: false,
1512
+ includeInput: false,
1513
+ includeOutput: false,
1514
+ includeJsonl: false,
1515
+ includeMetadata: false,
1516
+ cleanupDays: 7,
1517
+ },
1518
+ shareEnabled: false,
1519
+ maxSubagentDepth: 2,
1520
+ });
1521
+
1522
+ const resultPath = await waitForAsyncResultFile(id);
1523
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1524
+ assert.equal(payload.success, false);
1525
+ assert.match(payload.results[0]?.error ?? "", /429 quota exceeded/);
1526
+ const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
1527
+ assert.equal(statusPayload.state, "failed");
1528
+ assert.match(statusPayload.steps?.[0]?.error ?? "", /429 quota exceeded/);
1529
+ });
1530
+
1531
+ it("background runs treat recovered child errors as successful", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1532
+ mockPi.onCall({
1533
+ jsonl: [
1534
+ events.toolResult("read", "EISDIR: illegal operation on a directory", true),
1535
+ {
1536
+ type: "message_end",
1537
+ message: {
1538
+ role: "assistant",
1539
+ content: [{ type: "text", text: "temporary provider failure" }],
1540
+ model: "openai/gpt-5-mini",
1541
+ stopReason: "error",
1542
+ errorMessage: "provider transport failed",
1543
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
1544
+ },
1545
+ },
1546
+ events.assistantMessage("Recovered asynchronously"),
1547
+ ],
1548
+ });
1549
+ const id = `async-recovered-child-error-${Date.now().toString(36)}`;
1550
+ const asyncDir = path.join(ASYNC_DIR, id);
1551
+ executeAsyncSingle(id, {
1552
+ agent: "worker",
1553
+ task: "Do work",
1554
+ agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
1555
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1556
+ artifactConfig: {
1557
+ enabled: false,
1558
+ includeInput: false,
1559
+ includeOutput: false,
1560
+ includeJsonl: false,
1561
+ includeMetadata: false,
1562
+ cleanupDays: 7,
1563
+ },
1564
+ shareEnabled: false,
1565
+ maxSubagentDepth: 2,
1566
+ });
1567
+
1568
+ const resultPath = await waitForAsyncResultFile(id);
1569
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1570
+ assert.equal(payload.success, true);
1571
+ assert.equal(payload.state, "complete");
1572
+ assert.equal(payload.exitCode, 0);
1573
+ assert.equal(payload.results[0]?.success, true);
1574
+ assert.equal(payload.results[0]?.error, undefined);
1575
+ assert.equal(payload.results[0]?.output, "Recovered asynchronously");
1576
+ const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
1577
+ assert.equal(statusPayload.state, "complete");
1578
+ assert.equal(statusPayload.steps?.[0]?.status, "complete");
1579
+ assert.equal(statusPayload.steps?.[0]?.exitCode, 0);
1580
+ });
1581
+
1582
+ it("background runs keep provider errors failed when followed only by empty assistant output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1583
+ mockPi.onCall({
1584
+ jsonl: [
1585
+ {
1586
+ type: "message_end",
1587
+ message: {
1588
+ role: "assistant",
1589
+ content: [{ type: "text", text: "temporary provider failure" }],
1590
+ model: "openai/gpt-5-mini",
1591
+ stopReason: "error",
1592
+ errorMessage: "provider transport failed",
1593
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
1594
+ },
1595
+ },
1596
+ events.assistantMessage(""),
1597
+ ],
1598
+ });
1599
+ const id = `async-provider-error-empty-stop-${Date.now().toString(36)}`;
1600
+ const asyncDir = path.join(ASYNC_DIR, id);
1601
+ executeAsyncSingle(id, {
1602
+ agent: "worker",
1603
+ task: "Do work",
1604
+ agentConfig: makeAgent("worker", { model: "openai/gpt-5-mini" }),
1605
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1606
+ artifactConfig: {
1607
+ enabled: false,
1608
+ includeInput: false,
1609
+ includeOutput: false,
1610
+ includeJsonl: false,
1611
+ includeMetadata: false,
1612
+ cleanupDays: 7,
1613
+ },
1614
+ shareEnabled: false,
1615
+ maxSubagentDepth: 2,
1616
+ });
1617
+
1618
+ const resultPath = await waitForAsyncResultFile(id);
1619
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1620
+ assert.equal(payload.success, false);
1621
+ assert.equal(payload.state, "failed");
1622
+ assert.equal(payload.exitCode, 1);
1623
+ assert.equal(payload.results[0]?.success, false);
1624
+ assert.match(payload.results[0]?.error ?? "", /provider transport failed/);
1625
+ assert.equal(payload.results[0]?.output, "");
1626
+ const statusPayload = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatusPayload;
1627
+ assert.equal(statusPayload.state, "failed");
1628
+ assert.equal(statusPayload.steps?.[0]?.status, "failed");
1629
+ assert.equal(statusPayload.steps?.[0]?.exitCode, 1);
1630
+ });
1631
+
1632
+ it("background file-only runs write full output but return only a file reference", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1633
+ mockPi.onCall({ output: "async full output\nwith details" });
1634
+ const id = `async-file-only-${Date.now().toString(36)}`;
1635
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
1636
+ const outputPath = path.join(tempDir, "async-file-only.md");
1637
+ const run = executeAsyncSingle(id, {
1638
+ agent: "worker",
1639
+ task: "Do work",
1640
+ agentConfig: makeAgent("worker"),
1641
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1642
+ artifactConfig: {
1643
+ enabled: false,
1644
+ includeInput: false,
1645
+ includeOutput: false,
1646
+ includeJsonl: false,
1647
+ includeMetadata: false,
1648
+ cleanupDays: 7,
1649
+ },
1650
+ shareEnabled: false,
1651
+ sessionRoot: path.join(tempDir, "sessions"),
1652
+ output: outputPath,
1653
+ outputMode: "file-only",
1654
+ maxSubagentDepth: 2,
1655
+ });
1656
+
1657
+ assert.equal(run.details.asyncId, id);
1658
+ const deadline = Date.now() + 10_000;
1659
+ while (!fs.existsSync(resultPath)) {
1660
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
1661
+ await new Promise((resolve) => setTimeout(resolve, 100));
1662
+ }
1663
+
1664
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1665
+ assert.equal(payload.success, true);
1666
+ assert.match(payload.summary ?? "", /Output saved to:/);
1667
+ assert.match(payload.summary ?? "", /2 lines/);
1668
+ assert.doesNotMatch(payload.summary ?? "", /async full output/);
1669
+ assert.match(payload.results[0]?.output ?? "", /Output saved to:/);
1670
+ assert.doesNotMatch(payload.results[0]?.output ?? "", /async full output/);
1671
+ assert.equal(fs.readFileSync(outputPath, "utf-8"), "async full output\nwith details");
1672
+ });
1673
+
1674
+ it("background single runs route relative outputs to outputBaseDir", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1675
+ mockPi.onCall({ output: "async configured report" });
1676
+ const id = `async-configured-output-base-${Date.now().toString(36)}`;
1677
+ const outputBaseDir = path.join(tempDir, "async-configured-outputs");
1678
+ const run = executeAsyncSingle(id, {
1679
+ agent: "researcher",
1680
+ task: "Write report",
1681
+ agentConfig: makeAgent("researcher", { output: "context.md" }),
1682
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1683
+ artifactConfig: {
1684
+ enabled: false,
1685
+ includeInput: false,
1686
+ includeOutput: false,
1687
+ includeJsonl: false,
1688
+ includeMetadata: false,
1689
+ cleanupDays: 7,
1690
+ },
1691
+ shareEnabled: false,
1692
+ sessionRoot: path.join(tempDir, "sessions"),
1693
+ output: "context.md",
1694
+ outputBaseDir,
1695
+ maxSubagentDepth: 2,
1696
+ });
1697
+
1698
+ assert.equal(run.details.asyncId, id);
1699
+ const outputPath = path.join(outputBaseDir, "context.md");
1700
+ const call = await waitForMockPiCall(mockPi, 0);
1701
+ const taskArg = call.args.at(-1) ?? "";
1702
+ assert.match(taskArg, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`));
1703
+ const resultPath = await waitForAsyncResultFile(id);
1704
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1705
+ assert.equal(payload.success, true);
1706
+ assert.equal(fs.readFileSync(outputPath, "utf-8"), "async configured report");
1707
+ assert.equal(fs.existsSync(path.join(tempDir, "context.md")), false);
1708
+ });
1709
+
1710
+ it("background single runs make output overrides authoritative in the child system prompt", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1711
+ mockPi.onCall({ output: "async override report" });
1712
+ const id = `async-output-override-system-prompt-${Date.now().toString(36)}`;
1713
+ const outputPath = path.join(tempDir, "async-custom-report.md");
1714
+ const run = executeAsyncSingle(id, {
1715
+ agent: "researcher",
1716
+ task: "Write report",
1717
+ agentConfig: makeAgent("researcher", {
1718
+ output: "default-report.md",
1719
+ systemPrompt: "Output format (`default-report.md`):\n\nWrite the full report to default-report.md.",
1720
+ }),
1721
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1722
+ artifactConfig: {
1723
+ enabled: false,
1724
+ includeInput: false,
1725
+ includeOutput: false,
1726
+ includeJsonl: false,
1727
+ includeMetadata: false,
1728
+ cleanupDays: 7,
1729
+ },
1730
+ shareEnabled: false,
1731
+ sessionRoot: path.join(tempDir, "sessions"),
1732
+ output: outputPath,
1733
+ maxSubagentDepth: 2,
1734
+ });
1735
+
1736
+ assert.equal(run.details.asyncId, id);
1737
+ const call = await waitForMockPiCall(mockPi, 0);
1738
+ const taskArg = call.args.at(-1) ?? "";
1739
+ const systemPrompt = call.systemPrompts[0]?.text ?? "";
1740
+ assert.match(taskArg, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`));
1741
+ assert.match(systemPrompt, /Output format \(`default-report\.md`\):/);
1742
+ assert.match(systemPrompt, /Runtime output path override:/);
1743
+ assert.match(systemPrompt, new RegExp(`Write your findings to exactly this path: ${escapeRegExp(outputPath)}`));
1744
+ assert.match(systemPrompt, /Ignore any other output filename or output path mentioned elsewhere/);
1745
+ await waitForAsyncResultFile(id);
1746
+ });
1747
+
1748
+ it("background single runs treat string false as disabled output", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1749
+ mockPi.onCall({ output: "async inline report" });
1750
+ const id = `async-string-false-output-${Date.now().toString(36)}`;
1751
+ const run = executeAsyncSingle(id, {
1752
+ agent: "worker",
1753
+ task: "Do work",
1754
+ agentConfig: makeAgent("worker", { output: "default-report.md" }),
1755
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1756
+ artifactConfig: {
1757
+ enabled: false,
1758
+ includeInput: false,
1759
+ includeOutput: false,
1760
+ includeJsonl: false,
1761
+ includeMetadata: false,
1762
+ cleanupDays: 7,
1763
+ },
1764
+ shareEnabled: false,
1765
+ sessionRoot: path.join(tempDir, "sessions"),
1766
+ output: "false",
1767
+ maxSubagentDepth: 2,
1768
+ });
1769
+
1770
+ assert.equal(run.details.asyncId, id);
1771
+ const resultPath = await waitForAsyncResultFile(id);
1772
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1773
+ assert.equal(payload.success, true);
1774
+ assert.equal(payload.results[0]?.output, "async inline report");
1775
+ assert.doesNotMatch(payload.summary ?? "", /Output saved to:/);
1776
+ assert.equal(fs.existsSync(path.join(tempDir, "false")), false);
1777
+ assert.equal(fs.existsSync(path.join(tempDir, "default-report.md")), false);
1778
+ assert.doesNotMatch(readLastMockPiArgs(mockPi).at(-1) ?? "", /Write your findings to(?: exactly this path)?:/);
1779
+ });
1780
+
1781
+ it("background runs detect hidden tool failures even when the child exits 0", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1782
+ mockPi.onCall({
1783
+ jsonl: [events.toolResult("bash", "connection refused")],
1784
+ });
1785
+
1786
+ const id = `async-hidden-failure-${Date.now().toString(36)}`;
1787
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
1788
+ const sessionRoot = path.join(tempDir, "sessions");
1789
+
1790
+ executeAsyncSingle(id, {
1791
+ agent: "worker",
1792
+ task: "Deploy app",
1793
+ agentConfig: makeAgent("worker"),
1794
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1795
+ artifactConfig: {
1796
+ enabled: false,
1797
+ includeInput: false,
1798
+ includeOutput: false,
1799
+ includeJsonl: false,
1800
+ includeMetadata: false,
1801
+ cleanupDays: 7,
1802
+ },
1803
+ shareEnabled: false,
1804
+ sessionRoot,
1805
+ maxSubagentDepth: 2,
1806
+ });
1807
+
1808
+ const deadline = Date.now() + 10_000;
1809
+ while (!fs.existsSync(resultPath)) {
1810
+ if (Date.now() > deadline) {
1811
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
1812
+ }
1813
+ await new Promise((resolve) => setTimeout(resolve, 100));
1814
+ }
1815
+
1816
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
1817
+ assert.equal(payload.success, false);
1818
+ assert.equal(payload.exitCode, 1);
1819
+ assert.equal(payload.results[0].success, false);
1820
+ });
1821
+
1822
+ it("background implementation runs fail when no mutation attempt occurred", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1823
+ mockPi.onCall({ output: "I’ll do that now and report back after implementing." });
1824
+
1825
+ const id = `async-no-mutation-${Date.now().toString(36)}`;
1826
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
1827
+ const sessionRoot = path.join(tempDir, "sessions");
1828
+
1829
+ executeAsyncSingle(id, {
1830
+ agent: "worker",
1831
+ task: "Implement the approved fixes",
1832
+ agentConfig: makeAgent("worker"),
1833
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1834
+ artifactConfig: {
1835
+ enabled: false,
1836
+ includeInput: false,
1837
+ includeOutput: false,
1838
+ includeJsonl: false,
1839
+ includeMetadata: false,
1840
+ cleanupDays: 7,
1841
+ },
1842
+ shareEnabled: false,
1843
+ sessionRoot,
1844
+ maxSubagentDepth: 2,
1845
+ });
1846
+
1847
+ const deadline = Date.now() + 10_000;
1848
+ while (!fs.existsSync(resultPath)) {
1849
+ if (Date.now() > deadline) {
1850
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
1851
+ }
1852
+ await new Promise((resolve) => setTimeout(resolve, 100));
1853
+ }
1854
+
1855
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
1856
+ assert.equal(payload.success, false);
1857
+ assert.equal(payload.exitCode, 1);
1858
+ assert.equal(payload.results[0].success, false);
1859
+ assert.match(String(payload.results[0].error ?? ""), /completed without making edits/);
1860
+ assert.match(String(payload.results[0].modelAttempts?.[0]?.error ?? ""), /completed without making edits/);
1861
+
1862
+ const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl");
1863
+ const eventsText = fs.readFileSync(eventsPath, "utf-8");
1864
+ assert.match(eventsText, /"reason":"completion_guard"/);
1865
+ assert.match(eventsText, /Subagent failed: worker/);
1866
+ assert.doesNotMatch(eventsText, /Status:/);
1867
+ assert.doesNotMatch(eventsText, /Interrupt:/);
1868
+ });
1869
+
1870
+ it("background bash-enabled non-implementation agents can opt out of the completion guard", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1871
+ mockPi.onCall({ output: "cold start test after patch" });
1872
+
1873
+ const id = `async-completion-guard-optout-${Date.now().toString(36)}`;
1874
+ const sessionRoot = path.join(tempDir, "sessions");
1875
+
1876
+ executeAsyncSingle(id, {
1877
+ agent: "test-runner",
1878
+ task: "Run cold start test after patch",
1879
+ agentConfig: makeAgent("test-runner", { tools: ["read", "grep", "bash", "ls"], completionGuard: false }),
1880
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1881
+ artifactConfig: {
1882
+ enabled: false,
1883
+ includeInput: false,
1884
+ includeOutput: false,
1885
+ includeJsonl: false,
1886
+ includeMetadata: false,
1887
+ cleanupDays: 7,
1888
+ },
1889
+ shareEnabled: false,
1890
+ sessionRoot,
1891
+ maxSubagentDepth: 2,
1892
+ });
1893
+
1894
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1895
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
1896
+ assert.equal(payload.success, true);
1897
+ assert.equal(payload.exitCode, 0);
1898
+ assert.equal(payload.results[0].success, true);
1899
+ assert.equal(payload.results[0].output, "cold start test after patch");
1900
+
1901
+ const eventsPath = path.join(ASYNC_DIR, id, "events.jsonl");
1902
+ const eventsText = fs.readFileSync(eventsPath, "utf-8");
1903
+ assert.doesNotMatch(eventsText, /"reason":"completion_guard"/);
1904
+ });
1905
+
1906
+ it("background runs prefer the parent session provider for ambiguous bare model ids", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1907
+ mockPi.onCall({ output: "Done asynchronously" });
1908
+
1909
+ const id = `async-provider-${Date.now().toString(36)}`;
1910
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
1911
+ const sessionRoot = path.join(tempDir, "sessions");
1912
+
1913
+ executeAsyncSingle(id, {
1914
+ agent: "worker",
1915
+ task: "Do work",
1916
+ agentConfig: makeAgent("worker", { model: "gpt-5-mini" }),
1917
+ ctx: {
1918
+ pi: { events: { emit() {} } },
1919
+ cwd: tempDir,
1920
+ currentSessionId: "session-1",
1921
+ currentModelProvider: "github-copilot",
1922
+ },
1923
+ availableModels: [
1924
+ { provider: "openai", id: "gpt-5-mini", fullId: "openai/gpt-5-mini" },
1925
+ { provider: "github-copilot", id: "gpt-5-mini", fullId: "github-copilot/gpt-5-mini" },
1926
+ ],
1927
+ artifactConfig: {
1928
+ enabled: false,
1929
+ includeInput: false,
1930
+ includeOutput: false,
1931
+ includeJsonl: false,
1932
+ includeMetadata: false,
1933
+ cleanupDays: 7,
1934
+ },
1935
+ shareEnabled: false,
1936
+ sessionRoot,
1937
+ maxSubagentDepth: 2,
1938
+ });
1939
+
1940
+ const deadline = Date.now() + 10_000;
1941
+ while (!fs.existsSync(resultPath)) {
1942
+ if (Date.now() > deadline) {
1943
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
1944
+ }
1945
+ await new Promise((resolve) => setTimeout(resolve, 100));
1946
+ }
1947
+
1948
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
1949
+ assert.equal(payload.success, true);
1950
+ assert.equal(payload.results[0].model, "github-copilot/gpt-5-mini");
1951
+ assert.deepEqual(payload.results[0].attemptedModels, ["github-copilot/gpt-5-mini"]);
1952
+ });
1953
+
1954
+ it("background single runs inherit the parent session model when no model is set", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1955
+ mockPi.onCall({ output: "Done asynchronously" });
1956
+
1957
+ const id = `async-single-parent-model-${Date.now().toString(36)}`;
1958
+ executeAsyncSingle(id, {
1959
+ agent: "worker",
1960
+ task: "Do work",
1961
+ agentConfig: makeAgent("worker"),
1962
+ ctx: {
1963
+ pi: { events: { emit() {} } },
1964
+ cwd: tempDir,
1965
+ currentSessionId: "session-1",
1966
+ currentModelProvider: "deepseek",
1967
+ currentModel: { provider: "deepseek", id: "deepseek-v4-flash" },
1968
+ },
1969
+ artifactConfig: {
1970
+ enabled: false,
1971
+ includeInput: false,
1972
+ includeOutput: false,
1973
+ includeJsonl: false,
1974
+ includeMetadata: false,
1975
+ cleanupDays: 7,
1976
+ },
1977
+ shareEnabled: false,
1978
+ sessionRoot: path.join(tempDir, "sessions"),
1979
+ maxSubagentDepth: 2,
1980
+ });
1981
+
1982
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1983
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
1984
+ assert.equal(payload.success, true);
1985
+ assert.equal(payload.results[0].model, "deepseek/deepseek-v4-flash");
1986
+ assert.deepEqual(payload.results[0].attemptedModels, ["deepseek/deepseek-v4-flash"]);
1987
+ const args = readMockPiArgs(mockPi, 0);
1988
+ assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
1989
+ });
1990
+
1991
+ it("background chains inherit the parent session model when no step or agent model is set", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1992
+ mockPi.onCall({ output: "Done asynchronously" });
1993
+
1994
+ const id = `async-chain-parent-model-${Date.now().toString(36)}`;
1995
+ executeAsyncChain(id, {
1996
+ chain: [{ agent: "worker", task: "Do work" }],
1997
+ agents: [makeAgent("worker")],
1998
+ ctx: {
1999
+ pi: { events: { emit() {} } },
2000
+ cwd: tempDir,
2001
+ currentSessionId: "session-1",
2002
+ currentModelProvider: "deepseek",
2003
+ currentModel: { provider: "deepseek", id: "deepseek-v4-flash" },
2004
+ },
2005
+ artifactConfig: {
2006
+ enabled: false,
2007
+ includeInput: false,
2008
+ includeOutput: false,
2009
+ includeJsonl: false,
2010
+ includeMetadata: false,
2011
+ cleanupDays: 7,
2012
+ },
2013
+ shareEnabled: false,
2014
+ sessionRoot: path.join(tempDir, "sessions"),
2015
+ maxSubagentDepth: 2,
2016
+ });
2017
+
2018
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
2019
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
2020
+ assert.equal(payload.success, true);
2021
+ assert.equal(payload.results[0].model, "deepseek/deepseek-v4-flash");
2022
+ assert.deepEqual(payload.results[0].attemptedModels, ["deepseek/deepseek-v4-flash"]);
2023
+ const args = readMockPiArgs(mockPi, 0);
2024
+ assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
2025
+ });
2026
+
2027
+ it("background runs resolve skills from the effective task cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2028
+ mockPi.onCall({ output: "Done asynchronously" });
2029
+ const taskCwd = createTempDir("pi-subagent-async-task-cwd-");
2030
+ const id = `async-skill-cwd-${Date.now().toString(36)}`;
2031
+ const asyncDir = path.join(ASYNC_DIR, id);
2032
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2033
+ const statusPath = path.join(asyncDir, "status.json");
2034
+
2035
+ try {
2036
+ writePackageSkill(taskCwd, "async-task-cwd-skill");
2037
+ executeAsyncSingle(id, {
2038
+ agent: "worker",
2039
+ task: "Do work",
2040
+ agentConfig: makeAgent("worker", { skills: ["async-task-cwd-skill"] }),
2041
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2042
+ cwd: taskCwd,
2043
+ artifactConfig: {
2044
+ enabled: false,
2045
+ includeInput: false,
2046
+ includeOutput: false,
2047
+ includeJsonl: false,
2048
+ includeMetadata: false,
2049
+ cleanupDays: 7,
2050
+ },
2051
+ shareEnabled: false,
2052
+ sessionRoot: path.join(tempDir, "sessions"),
2053
+ maxSubagentDepth: 2,
2054
+ });
2055
+
2056
+ const deadline = Date.now() + 10_000;
2057
+ while (!fs.existsSync(resultPath)) {
2058
+ if (Date.now() > deadline) {
2059
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2060
+ }
2061
+ await new Promise((resolve) => setTimeout(resolve, 100));
2062
+ }
2063
+
2064
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
2065
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
2066
+ assert.equal(payload.success, true);
2067
+ assert.deepEqual(status.steps?.[0]?.skills, ["async-task-cwd-skill"]);
2068
+ } finally {
2069
+ removeTempDir(taskCwd);
2070
+ }
2071
+ });
2072
+
2073
+ it("background single runs report unavailable pi-subagents skill requests", () => {
2074
+ const id = `async-pi-subagents-skill-${Date.now().toString(36)}`;
2075
+ const result = executeAsyncSingle(id, {
2076
+ agent: "worker",
2077
+ task: "Do work",
2078
+ agentConfig: makeAgent("worker"),
2079
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2080
+ cwd: tempDir,
2081
+ artifactConfig: {
2082
+ enabled: false,
2083
+ includeInput: false,
2084
+ includeOutput: false,
2085
+ includeJsonl: false,
2086
+ includeMetadata: false,
2087
+ cleanupDays: 7,
2088
+ },
2089
+ shareEnabled: false,
2090
+ sessionRoot: path.join(tempDir, "sessions"),
2091
+ skills: ["pi-subagents"],
2092
+ maxSubagentDepth: 2,
2093
+ });
2094
+
2095
+ assert.equal(result.isError, true);
2096
+ assert.match(result.content[0]?.text ?? "", /Skills not found: pi-subagents/);
2097
+ });
2098
+
2099
+ it("background chains report unavailable pi-subagents skill requests", () => {
2100
+ const id = `async-chain-pi-subagents-skill-${Date.now().toString(36)}`;
2101
+ const result = executeAsyncChain(id, {
2102
+ chain: [{ agent: "worker", task: "Do work", skill: ["pi-subagents"] }],
2103
+ agents: [makeAgent("worker")],
2104
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2105
+ cwd: tempDir,
2106
+ artifactConfig: {
2107
+ enabled: false,
2108
+ includeInput: false,
2109
+ includeOutput: false,
2110
+ includeJsonl: false,
2111
+ includeMetadata: false,
2112
+ cleanupDays: 7,
2113
+ },
2114
+ shareEnabled: false,
2115
+ sessionRoot: path.join(tempDir, "sessions"),
2116
+ maxSubagentDepth: 2,
2117
+ });
2118
+
2119
+ assert.equal(result.isError, true);
2120
+ assert.match(result.content[0]?.text ?? "", /Skills not found: pi-subagents/);
2121
+ });
2122
+
2123
+ it("background chains resolve relative step cwd values against the shared cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2124
+ mockPi.onCall({ output: "Done asynchronously" });
2125
+ const chainCwd = createTempDir("pi-subagent-async-chain-cwd-");
2126
+ const id = `async-chain-skill-cwd-${Date.now().toString(36)}`;
2127
+ const asyncDir = path.join(ASYNC_DIR, id);
2128
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2129
+ const statusPath = path.join(asyncDir, "status.json");
2130
+
2131
+ try {
2132
+ writePackageSkill(path.join(chainCwd, "packages", "app"), "async-chain-step-skill");
2133
+ executeAsyncChain(id, {
2134
+ chain: [{ agent: "worker", task: "Do work", cwd: "packages/app", skill: ["async-chain-step-skill"] }],
2135
+ agents: [makeAgent("worker")],
2136
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2137
+ cwd: chainCwd,
2138
+ artifactConfig: {
2139
+ enabled: false,
2140
+ includeInput: false,
2141
+ includeOutput: false,
2142
+ includeJsonl: false,
2143
+ includeMetadata: false,
2144
+ cleanupDays: 7,
2145
+ },
2146
+ shareEnabled: false,
2147
+ sessionRoot: path.join(tempDir, "sessions"),
2148
+ maxSubagentDepth: 2,
2149
+ });
2150
+
2151
+ const deadline = Date.now() + 10_000;
2152
+ while (!fs.existsSync(resultPath)) {
2153
+ if (Date.now() > deadline) {
2154
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2155
+ }
2156
+ await new Promise((resolve) => setTimeout(resolve, 100));
2157
+ }
2158
+
2159
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
2160
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
2161
+ assert.equal(payload.success, true);
2162
+ assert.equal(payload.sessionId, "session-1");
2163
+ assert.equal(status.sessionId, "session-1");
2164
+ assert.deepEqual(status.steps?.[0]?.skills, ["async-chain-step-skill"]);
2165
+ } finally {
2166
+ removeTempDir(chainCwd);
2167
+ }
2168
+ });
2169
+
2170
+ it("keeps top-level current tool/path aligned with still-running parallel children", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2171
+ mockPi.onCall({
2172
+ steps: [
2173
+ { jsonl: [events.toolStart("read", { path: "README.md" })] },
2174
+ { delay: 900, jsonl: [events.toolEnd("read"), events.toolResult("read", "done"), events.assistantMessage("reader done")] },
2175
+ ],
2176
+ });
2177
+ mockPi.onCall({
2178
+ steps: [
2179
+ { delay: 100, jsonl: [events.toolStart("edit", { path: "docs.md" })] },
2180
+ { delay: 100, jsonl: [events.toolEnd("edit"), events.toolResult("edit", "ok")] },
2181
+ { delay: 700, jsonl: [events.assistantMessage("editor done")] },
2182
+ ],
2183
+ });
2184
+
2185
+ const id = `async-parallel-tool-sync-${Date.now().toString(36)}`;
2186
+ const asyncDir = path.join(ASYNC_DIR, id);
2187
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2188
+
2189
+ executeAsyncChain(id, {
2190
+ chain: [{ parallel: [{ agent: "reader", task: "Read" }, { agent: "editor", task: "Edit" }] }],
2191
+ agents: [makeAgent("reader"), makeAgent("editor")],
2192
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2193
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
2194
+ shareEnabled: false,
2195
+ sessionRoot: path.join(tempDir, "sessions"),
2196
+ maxSubagentDepth: 2,
2197
+ });
2198
+
2199
+ const statusPath = path.join(asyncDir, "status.json");
2200
+ const doneDeadline = Date.now() + 10_000;
2201
+ let sawRunningTool = false;
2202
+ let invariantViolated = false;
2203
+ while (!fs.existsSync(resultPath) && Date.now() < doneDeadline) {
2204
+ if (fs.existsSync(statusPath)) {
2205
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
2206
+ const runningTools = (status.steps ?? [])
2207
+ .filter((step) => step.status === "running" && typeof step.currentTool === "string")
2208
+ .map((step) => step.currentTool as string);
2209
+ if (runningTools.length > 0) {
2210
+ sawRunningTool = true;
2211
+ if (!status.currentTool || !runningTools.includes(status.currentTool)) {
2212
+ invariantViolated = true;
2213
+ break;
2214
+ }
2215
+ }
2216
+ }
2217
+ await new Promise((resolve) => setTimeout(resolve, 50));
2218
+ }
2219
+ if (!fs.existsSync(resultPath)) {
2220
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2221
+ }
2222
+ assert.equal(sawRunningTool, true, "expected at least one polling interval with a running step tool");
2223
+ assert.equal(invariantViolated, false, "top-level currentTool drifted from running step tools");
2224
+ });
2225
+
2226
+ it("returns a tool error when the detached runner config cannot be written", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => {
2227
+ const id = `async-write-fail-${Date.now().toString(36)}`;
2228
+ assert.ok(TEMP_ROOT_DIR, "TEMP_ROOT_DIR should be available for async tests");
2229
+ fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true });
2230
+ fs.mkdirSync(path.join(TEMP_ROOT_DIR, `async-cfg-${id}.json`), { recursive: true });
2231
+
2232
+ const result = executeAsyncSingle(id, {
2233
+ agent: "worker",
2234
+ task: "Do work",
2235
+ agentConfig: makeAgent("worker"),
2236
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2237
+ artifactConfig: {
2238
+ enabled: false,
2239
+ includeInput: false,
2240
+ includeOutput: false,
2241
+ includeJsonl: false,
2242
+ includeMetadata: false,
2243
+ cleanupDays: 7,
2244
+ },
2245
+ shareEnabled: false,
2246
+ sessionRoot: path.join(tempDir, "sessions"),
2247
+ maxSubagentDepth: 2,
2248
+ });
2249
+
2250
+ assert.equal(result.isError, true);
2251
+ assert.match(result.content[0]?.text ?? "", /Failed to start async run/);
2252
+ assert.match(result.content[0]?.text ?? "", /async-cfg-/);
2253
+ });
2254
+
2255
+ it("returns a tool error when an async run uses a missing cwd", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => {
2256
+ const id = `async-missing-cwd-${Date.now().toString(36)}`;
2257
+ const missingCwd = path.join(tempDir, "missing-cwd");
2258
+
2259
+ const singleResult = executeAsyncSingle(id, {
2260
+ agent: "worker",
2261
+ task: "Do work",
2262
+ agentConfig: makeAgent("worker"),
2263
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2264
+ cwd: missingCwd,
2265
+ artifactConfig: {
2266
+ enabled: false,
2267
+ includeInput: false,
2268
+ includeOutput: false,
2269
+ includeJsonl: false,
2270
+ includeMetadata: false,
2271
+ cleanupDays: 7,
2272
+ },
2273
+ shareEnabled: false,
2274
+ sessionRoot: path.join(tempDir, "sessions"),
2275
+ maxSubagentDepth: 2,
2276
+ });
2277
+
2278
+ assert.equal(singleResult.isError, true);
2279
+ assert.match(singleResult.content[0]?.text ?? "", /Failed to start async run/);
2280
+ assert.match(singleResult.content[0]?.text ?? "", /cwd does not exist/);
2281
+
2282
+ const chainId = `async-missing-cwd-chain-${Date.now().toString(36)}`;
2283
+ const chainResult = executeAsyncChain(chainId, {
2284
+ chain: [{ agent: "worker", task: "Do work" }],
2285
+ agents: [makeAgent("worker")],
2286
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2287
+ cwd: missingCwd,
2288
+ artifactConfig: {
2289
+ enabled: false,
2290
+ includeInput: false,
2291
+ includeOutput: false,
2292
+ includeJsonl: false,
2293
+ includeMetadata: false,
2294
+ cleanupDays: 7,
2295
+ },
2296
+ shareEnabled: false,
2297
+ sessionRoot: path.join(tempDir, "sessions"),
2298
+ maxSubagentDepth: 2,
2299
+ });
2300
+
2301
+ assert.equal(chainResult.isError, true);
2302
+ assert.match(chainResult.content[0]?.text ?? "", /Failed to start async chain/);
2303
+ assert.match(chainResult.content[0]?.text ?? "", /cwd does not exist/);
2304
+ });
2305
+
2306
+ it("returns a tool error when the async runner process cannot spawn", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => {
2307
+ const originalExecPath = process.execPath;
2308
+ const pathKey = process.platform === "win32" ? "Path" : "PATH";
2309
+ const originalPath = process.env[pathKey];
2310
+ process.execPath = path.join(tempDir, process.platform === "win32" ? "pi.exe" : "pi");
2311
+ process.env[pathKey] = tempDir;
2312
+ try {
2313
+ const id = `async-spawn-fail-${Date.now().toString(36)}`;
2314
+ const result = executeAsyncSingle(id, {
2315
+ agent: "worker",
2316
+ task: "Do work",
2317
+ agentConfig: makeAgent("worker"),
2318
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2319
+ artifactConfig: {
2320
+ enabled: false,
2321
+ includeInput: false,
2322
+ includeOutput: false,
2323
+ includeJsonl: false,
2324
+ includeMetadata: false,
2325
+ cleanupDays: 7,
2326
+ },
2327
+ shareEnabled: false,
2328
+ sessionRoot: path.join(tempDir, "sessions"),
2329
+ maxSubagentDepth: 2,
2330
+ });
2331
+
2332
+ assert.equal(result.isError, true);
2333
+ assert.match(result.content[0]?.text ?? "", /Failed to start async run/);
2334
+ assert.match(result.content[0]?.text ?? "", /async runner did not produce a pid/);
2335
+ } finally {
2336
+ process.execPath = originalExecPath;
2337
+ if (originalPath === undefined) {
2338
+ delete process.env[pathKey];
2339
+ } else {
2340
+ process.env[pathKey] = originalPath;
2341
+ }
2342
+ }
2343
+ });
2344
+
2345
+ it("returns a tool error when an async chain cannot write its detached runner config", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, () => {
2346
+ const id = `async-chain-write-fail-${Date.now().toString(36)}`;
2347
+ assert.ok(TEMP_ROOT_DIR, "TEMP_ROOT_DIR should be available for async tests");
2348
+ fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true });
2349
+ fs.mkdirSync(path.join(TEMP_ROOT_DIR, `async-cfg-${id}.json`), { recursive: true });
2350
+
2351
+ const result = executeAsyncChain(id, {
2352
+ chain: [{ agent: "worker", task: "Do work" }],
2353
+ agents: [makeAgent("worker")],
2354
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2355
+ artifactConfig: {
2356
+ enabled: false,
2357
+ includeInput: false,
2358
+ includeOutput: false,
2359
+ includeJsonl: false,
2360
+ includeMetadata: false,
2361
+ cleanupDays: 7,
2362
+ },
2363
+ shareEnabled: false,
2364
+ sessionRoot: path.join(tempDir, "sessions"),
2365
+ maxSubagentDepth: 2,
2366
+ });
2367
+
2368
+ assert.equal(result.isError, true);
2369
+ assert.match(result.content[0]?.text ?? "", /Failed to start async chain/);
2370
+ assert.match(result.content[0]?.text ?? "", /async-cfg-/);
2371
+ });
2372
+
2373
+ it("background forced drain after final assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2374
+ mockPi.onCall({
2375
+ jsonl: [events.assistantMessage("async-done-before-drain")],
2376
+ stderr: "Done after 1 turn(s). Ready for input.\n",
2377
+ keepAliveAfterFinalMessageMs: 10000,
2378
+ });
2379
+
2380
+ const id = `async-final-drain-${Date.now().toString(36)}`;
2381
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2382
+ const sessionRoot = path.join(tempDir, "sessions");
2383
+
2384
+ const start = Date.now();
2385
+ executeAsyncSingle(id, {
2386
+ agent: "worker",
2387
+ task: "Do work",
2388
+ agentConfig: makeAgent("worker"),
2389
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2390
+ artifactConfig: {
2391
+ enabled: false,
2392
+ includeInput: false,
2393
+ includeOutput: false,
2394
+ includeJsonl: false,
2395
+ includeMetadata: false,
2396
+ cleanupDays: 7,
2397
+ },
2398
+ shareEnabled: false,
2399
+ sessionRoot,
2400
+ maxSubagentDepth: 2,
2401
+ });
2402
+
2403
+ const deadline = Date.now() + 10_000;
2404
+ while (!fs.existsSync(resultPath)) {
2405
+ if (Date.now() > deadline) {
2406
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2407
+ }
2408
+ await new Promise((resolve) => setTimeout(resolve, 100));
2409
+ }
2410
+
2411
+ const elapsed = Date.now() - start;
2412
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
2413
+ assert.ok(elapsed < 9000, `should clean up async child before the mock's natural keepalive exit, took ${elapsed}ms`);
2414
+ assert.equal(payload.success, true);
2415
+ assert.equal(payload.exitCode, 0);
2416
+ assert.equal(payload.results[0].success, true);
2417
+ assert.equal(payload.results[0].output, "async-done-before-drain");
2418
+ });
2419
+
2420
+ it("background forced drain after empty terminal assistant output is cleanup success", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2421
+ mockPi.onCall({
2422
+ jsonl: [events.assistantMessage("")],
2423
+ keepAliveAfterFinalMessageMs: 10000,
2424
+ });
2425
+
2426
+ const id = `async-final-drain-empty-${Date.now().toString(36)}`;
2427
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2428
+
2429
+ const start = Date.now();
2430
+ executeAsyncSingle(id, {
2431
+ agent: "scout",
2432
+ task: "Inspect something",
2433
+ agentConfig: makeAgent("scout"),
2434
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2435
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
2436
+ shareEnabled: false,
2437
+ sessionRoot: path.join(tempDir, "sessions"),
2438
+ maxSubagentDepth: 2,
2439
+ });
2440
+
2441
+ const deadline = Date.now() + 10_000;
2442
+ while (!fs.existsSync(resultPath)) {
2443
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2444
+ await new Promise((resolve) => setTimeout(resolve, 100));
2445
+ }
2446
+
2447
+ const elapsed = Date.now() - start;
2448
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
2449
+ assert.ok(elapsed < 9000, `should clean up async child before the mock's natural keepalive exit, took ${elapsed}ms`);
2450
+ assert.equal(payload.success, true);
2451
+ assert.equal(payload.exitCode, 0);
2452
+ assert.equal(payload.results[0].success, true);
2453
+ assert.equal(payload.results[0].output, "");
2454
+ });
2455
+
2456
+ it("background final-drain cleanup preserves explicit assistant errors", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2457
+ mockPi.onCall({
2458
+ jsonl: [{
2459
+ type: "message_end",
2460
+ message: {
2461
+ role: "assistant",
2462
+ content: [{ type: "text", text: "failed" }],
2463
+ model: "mock/test-model",
2464
+ stopReason: "stop",
2465
+ errorMessage: "provider exploded",
2466
+ usage: { input: 100, output: 0, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } },
2467
+ },
2468
+ }],
2469
+ keepAliveAfterFinalMessageMs: 10000,
2470
+ });
2471
+
2472
+ const id = `async-final-drain-error-${Date.now().toString(36)}`;
2473
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2474
+
2475
+ executeAsyncSingle(id, {
2476
+ agent: "worker",
2477
+ task: "Do work",
2478
+ agentConfig: makeAgent("worker"),
2479
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2480
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
2481
+ shareEnabled: false,
2482
+ sessionRoot: path.join(tempDir, "sessions"),
2483
+ maxSubagentDepth: 2,
2484
+ });
2485
+
2486
+ const deadline = Date.now() + 10_000;
2487
+ while (!fs.existsSync(resultPath)) {
2488
+ if (Date.now() > deadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2489
+ await new Promise((resolve) => setTimeout(resolve, 100));
2490
+ }
2491
+
2492
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
2493
+ assert.equal(payload.success, false);
2494
+ assert.equal(payload.exitCode, 1);
2495
+ assert.equal(payload.results[0].success, false);
2496
+ assert.equal(payload.results[0].error, "provider exploded");
2497
+ });
2498
+
2499
+ it("background runs emit active-long-running control events from child turns", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2500
+ mockPi.onCall({
2501
+ steps: [
2502
+ { jsonl: [events.assistantMessage("still working")] },
2503
+ { delay: 2_000, jsonl: [events.assistantMessage("done")] },
2504
+ ],
2505
+ });
2506
+
2507
+ const id = `async-active-long-${Date.now().toString(36)}`;
2508
+ const asyncDir = path.join(ASYNC_DIR, id);
2509
+ const eventsPath = path.join(asyncDir, "events.jsonl");
2510
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2511
+
2512
+ executeAsyncSingle(id, {
2513
+ agent: "scout",
2514
+ task: "Investigate behavior",
2515
+ agentConfig: makeAgent("scout"),
2516
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2517
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
2518
+ shareEnabled: false,
2519
+ sessionRoot: path.join(tempDir, "sessions"),
2520
+ maxSubagentDepth: 2,
2521
+ controlConfig: {
2522
+ enabled: true,
2523
+ needsAttentionAfterMs: 999_999,
2524
+ activeNoticeAfterTurns: 1,
2525
+ activeNoticeAfterMs: 999_999,
2526
+ activeNoticeAfterTokens: 999_999,
2527
+ failedToolAttemptsBeforeAttention: 3,
2528
+ notifyOn: ["active_long_running", "needs_attention"],
2529
+ notifyChannels: ["event", "async", "intercom"],
2530
+ },
2531
+ });
2532
+
2533
+ const statusPath = path.join(asyncDir, "status.json");
2534
+ const deadline = Date.now() + 10_000;
2535
+ let eventText = "";
2536
+ let statusDuringEvent: AsyncStatusPayload | undefined;
2537
+ while (Date.now() < deadline) {
2538
+ if (fs.existsSync(eventsPath)) {
2539
+ eventText = fs.readFileSync(eventsPath, "utf-8");
2540
+ }
2541
+ if (eventText.includes('"type":"active_long_running"') && fs.existsSync(statusPath)) {
2542
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
2543
+ if (status.activityState === "active_long_running" && status.steps?.[0]?.activityState === "active_long_running") {
2544
+ statusDuringEvent = status;
2545
+ break;
2546
+ }
2547
+ }
2548
+ if (eventText.includes('"type":"active_long_running"') && fs.existsSync(resultPath)) {
2549
+ assert.fail("run completed before status.json exposed active_long_running");
2550
+ }
2551
+ await new Promise((resolve) => setTimeout(resolve, 100));
2552
+ }
2553
+
2554
+ assert.match(eventText, /"type":"active_long_running"/);
2555
+ assert.match(eventText, /"reason":"turn_threshold"/);
2556
+ assert.ok(statusDuringEvent, "expected status.json to expose active_long_running while the run is still active");
2557
+ assert.equal(statusDuringEvent.activityState, "active_long_running");
2558
+ assert.equal(statusDuringEvent.steps?.[0]?.activityState, "active_long_running");
2559
+
2560
+ const doneDeadline = Date.now() + 10_000;
2561
+ while (!fs.existsSync(resultPath)) {
2562
+ if (Date.now() > doneDeadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2563
+ await new Promise((resolve) => setTimeout(resolve, 100));
2564
+ }
2565
+ });
2566
+
2567
+ it("background runs escalate repeated mutating tool failures", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2568
+ mockPi.onCall({
2569
+ steps: [
2570
+ { 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)] },
2571
+ { 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)] },
2572
+ { 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)] },
2573
+ { delay: 2_000, jsonl: [events.assistantMessage("I need another attempt.")] },
2574
+ ],
2575
+ });
2576
+
2577
+ const id = `async-tool-failures-${Date.now().toString(36)}`;
2578
+ const asyncDir = path.join(ASYNC_DIR, id);
2579
+ const eventsPath = path.join(asyncDir, "events.jsonl");
2580
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2581
+
2582
+ executeAsyncSingle(id, {
2583
+ agent: "worker",
2584
+ task: "Implement the approved fixes",
2585
+ agentConfig: makeAgent("worker"),
2586
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2587
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
2588
+ shareEnabled: false,
2589
+ sessionRoot: path.join(tempDir, "sessions"),
2590
+ maxSubagentDepth: 2,
2591
+ controlConfig: {
2592
+ enabled: true,
2593
+ needsAttentionAfterMs: 999_999,
2594
+ activeNoticeAfterTurns: 999_999,
2595
+ activeNoticeAfterMs: 999_999,
2596
+ activeNoticeAfterTokens: 999_999,
2597
+ failedToolAttemptsBeforeAttention: 3,
2598
+ notifyOn: ["active_long_running", "needs_attention"],
2599
+ notifyChannels: ["event", "async", "intercom"],
2600
+ },
2601
+ });
2602
+
2603
+ const statusPath = path.join(asyncDir, "status.json");
2604
+ const deadline = Date.now() + 10_000;
2605
+ let eventText = "";
2606
+ let statusDuringEvent: AsyncStatusPayload | undefined;
2607
+ while (Date.now() < deadline) {
2608
+ if (fs.existsSync(eventsPath)) {
2609
+ eventText = fs.readFileSync(eventsPath, "utf-8");
2610
+ }
2611
+ if (eventText.includes('"reason":"tool_failures"') && fs.existsSync(statusPath)) {
2612
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatusPayload;
2613
+ if (status.activityState === "needs_attention" && status.steps?.[0]?.activityState === "needs_attention") {
2614
+ statusDuringEvent = status;
2615
+ break;
2616
+ }
2617
+ }
2618
+ if (eventText.includes('"reason":"tool_failures"') && fs.existsSync(resultPath)) {
2619
+ assert.fail("run completed before status.json exposed needs_attention");
2620
+ }
2621
+ await new Promise((resolve) => setTimeout(resolve, 100));
2622
+ }
2623
+
2624
+ assert.match(eventText, /"type":"needs_attention"/);
2625
+ assert.match(eventText, /"reason":"tool_failures"/);
2626
+ assert.match(eventText, /subagent-runner\.ts/);
2627
+ assert.ok(statusDuringEvent, "expected status.json to expose needs_attention while the run is still active");
2628
+ assert.equal(statusDuringEvent.activityState, "needs_attention");
2629
+ assert.equal(statusDuringEvent.steps?.[0]?.activityState, "needs_attention");
2630
+
2631
+ const doneDeadline = Date.now() + 10_000;
2632
+ while (!fs.existsSync(resultPath)) {
2633
+ if (Date.now() > doneDeadline) assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2634
+ await new Promise((resolve) => setTimeout(resolve, 100));
2635
+ }
2636
+ });
2637
+
2638
+ it("background event logs drop noisy message updates and cap child diagnostics", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2639
+ const previousMaxBytes = process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES;
2640
+ process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES = "900";
2641
+ try {
2642
+ mockPi.onCall({
2643
+ steps: [
2644
+ {
2645
+ jsonl: [
2646
+ {
2647
+ type: "message_update",
2648
+ assistantMessageEvent: {
2649
+ type: "thinking_delta",
2650
+ delta: "NOISY_PARTIAL_DELTA",
2651
+ partial: { role: "assistant", content: [{ type: "text", text: "NOISY_PARTIAL_SNAPSHOT".repeat(200) }] },
2652
+ },
2653
+ message: { role: "assistant", content: [{ type: "text", text: "NOISY_PARTIAL_MESSAGE".repeat(200) }] },
2654
+ },
2655
+ events.toolStart("bash", { command: `echo ${"BIG_COMMAND_PAYLOAD".repeat(200)}` }),
2656
+ events.assistantMessage("Done after noisy stream"),
2657
+ ],
2658
+ },
2659
+ ],
2660
+ });
2661
+
2662
+ const id = `async-noisy-events-${Date.now().toString(36)}`;
2663
+ const asyncDir = path.join(ASYNC_DIR, id);
2664
+ const sessionRoot = path.join(tempDir, "sessions");
2665
+
2666
+ executeAsyncSingle(id, {
2667
+ agent: "worker",
2668
+ task: "Stream noisy diagnostics",
2669
+ agentConfig: makeAgent("worker"),
2670
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2671
+ artifactConfig: {
2672
+ enabled: false,
2673
+ includeInput: false,
2674
+ includeOutput: false,
2675
+ includeJsonl: false,
2676
+ includeMetadata: false,
2677
+ cleanupDays: 7,
2678
+ },
2679
+ shareEnabled: false,
2680
+ sessionRoot,
2681
+ maxSubagentDepth: 2,
2682
+ });
2683
+
2684
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
2685
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultPayload;
2686
+ assert.equal(payload.success, true);
2687
+ assert.equal(payload.results[0]?.output, "Done after noisy stream");
2688
+
2689
+ const eventsText = fs.readFileSync(path.join(asyncDir, "events.jsonl"), "utf-8");
2690
+ assert.doesNotMatch(eventsText, /"type":"message_update"/);
2691
+ assert.doesNotMatch(eventsText, /NOISY_PARTIAL_/);
2692
+ assert.doesNotMatch(eventsText, /BIG_COMMAND_PAYLOAD/);
2693
+ assert.match(eventsText, /"type":"subagent\.events\.truncated"/);
2694
+ assert.match(eventsText, /"droppedEventType":"tool_execution_start"/);
2695
+ } finally {
2696
+ if (previousMaxBytes === undefined) delete process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES;
2697
+ else process.env.SELESAI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES = previousMaxBytes;
2698
+ }
2699
+ });
2700
+
2701
+ it("background runs stream child events and live output while active", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
2702
+ mockPi.onCall({
2703
+ steps: [
2704
+ { delay: 200, jsonl: [events.toolStart("bash", { command: "ls" })] },
2705
+ { delay: 600, jsonl: [events.toolEnd("bash"), events.toolResult("bash", "file-a\nfile-b")] },
2706
+ { delay: 600, jsonl: [events.assistantMessage("Done streaming")], stderr: "warning: mock stderr\n" },
2707
+ ],
2708
+ });
2709
+
2710
+ const id = `async-stream-${Date.now().toString(36)}`;
2711
+ const asyncDir = path.join(ASYNC_DIR, id);
2712
+ const eventsPath = path.join(asyncDir, "events.jsonl");
2713
+ const outputPath = path.join(asyncDir, "output-0.log");
2714
+ const resultPath = path.join(RESULTS_DIR, `${id}.json`);
2715
+ const sessionRoot = path.join(tempDir, "sessions");
2716
+
2717
+ executeAsyncSingle(id, {
2718
+ agent: "worker",
2719
+ task: "Stream detailed progress",
2720
+ agentConfig: makeAgent("worker"),
2721
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
2722
+ artifactConfig: {
2723
+ enabled: false,
2724
+ includeInput: false,
2725
+ includeOutput: false,
2726
+ includeJsonl: false,
2727
+ includeMetadata: false,
2728
+ cleanupDays: 7,
2729
+ },
2730
+ shareEnabled: false,
2731
+ sessionRoot,
2732
+ maxSubagentDepth: 2,
2733
+ });
2734
+
2735
+ const liveDeadline = Date.now() + 10_000;
2736
+ let sawChildEvent = false;
2737
+ let sawLiveOutput = false;
2738
+ while (Date.now() < liveDeadline && (!sawChildEvent || !sawLiveOutput)) {
2739
+ if (fs.existsSync(eventsPath)) {
2740
+ const content = fs.readFileSync(eventsPath, "utf-8");
2741
+ sawChildEvent = content.includes('"type":"tool_execution_start"')
2742
+ && content.includes('"subagentSource":"child"');
2743
+ }
2744
+ if (fs.existsSync(outputPath)) {
2745
+ const content = fs.readFileSync(outputPath, "utf-8");
2746
+ sawLiveOutput = content.includes("bash: ls") || content.includes("file-a") || content.includes("warning: mock stderr");
2747
+ }
2748
+ if (sawChildEvent && sawLiveOutput) break;
2749
+ assert.equal(fs.existsSync(resultPath), false, "run finished before live observability was written");
2750
+ await new Promise((resolve) => setTimeout(resolve, 100));
2751
+ }
2752
+
2753
+ assert.equal(sawChildEvent, true, "expected child JSON events to be streamed into events.jsonl");
2754
+ assert.equal(sawLiveOutput, true, "expected output-0.log to receive live child output");
2755
+
2756
+ const doneDeadline = Date.now() + 10_000;
2757
+ while (!fs.existsSync(resultPath)) {
2758
+ if (Date.now() > doneDeadline) {
2759
+ assert.fail(`Timed out waiting for async result file: ${resultPath}`);
2760
+ }
2761
+ await new Promise((resolve) => setTimeout(resolve, 100));
2762
+ }
2763
+
2764
+ const payload = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
2765
+ assert.equal(payload.success, true);
2766
+ assert.equal(payload.results[0].output, "Done streaming");
2767
+
2768
+ const status = JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8"));
2769
+ assert.deepEqual(status.steps[0].recentTools.map((tool: { tool: string; args: string }) => ({ tool: tool.tool, args: tool.args })), [{ tool: "bash", args: "ls" }]);
2770
+ assert.deepEqual(status.steps[0].recentOutput, ["file-a", "file-b", "Done streaming"]);
2771
+ });
2772
+ });