@desplega.ai/agent-swarm 2.6.0 → 2.7.0

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 (213) hide show
  1. package/README.md +2 -0
  2. package/dist/{acp-adapter-dgfakfpw.js → acp-adapter-ma1js5n7.js} +6 -6
  3. package/dist/{actions-fbqma8aa.js → actions-1vhmtvg9.js} +18 -18
  4. package/dist/{amp-adapter-en0jy6hs.js → amp-adapter-sxqqvmdk.js} +3 -3
  5. package/dist/{app-k8a5cgpp.js → app-mwx1d5a9.js} +3 -3
  6. package/dist/{assistant-a16433gr.js → assistant-yx1p6g82.js} +20 -20
  7. package/dist/{boot-reembed-f4nn561n.js → boot-reembed-5h37a2c2.js} +1 -1
  8. package/dist/{boot-reembed-6c1sjfnr.js → boot-reembed-7h78vmvv.js} +2 -1
  9. package/dist/{boot-scrub-sweep-d4w9gajf.js → boot-scrub-sweep-8mawd4gw.js} +3 -3
  10. package/dist/{boot-seeds-9bmkspyw.js → boot-seeds-ca84dhnn.js} +4 -4
  11. package/dist/{claude-adapter-nys1kjxa.js → claude-adapter-wqt4hfkq.js} +7 -7
  12. package/dist/{claude-managed-adapter-55207973.js → claude-managed-adapter-6fjqtr2z.js} +2 -2
  13. package/dist/{claude-managed-setup-3cm35gey.js → claude-managed-setup-rqv98sea.js} +2 -2
  14. package/dist/{claude-sdk-session-nw5yd8cp.js → claude-sdk-session-f29f3b2x.js} +7 -7
  15. package/dist/{cli-sxgtf5fw.js → cli-07q03xm2.js} +1 -1
  16. package/dist/{cli-k21gx5ht.js → cli-1hhbzkbb.js} +6 -6
  17. package/dist/{cli-v49fkghp.js → cli-1m9x8dr5.js} +1 -1
  18. package/dist/{cli-24j1jeeb.js → cli-2pvzmas1.js} +3 -3
  19. package/dist/{cli-w2656ka1.js → cli-2y7v133v.js} +7 -7
  20. package/dist/{cli-g8ka5j4j.js → cli-48gxjmcz.js} +94 -36
  21. package/dist/{cli-2sjhpsns.js → cli-54sj64fc.js} +1 -1
  22. package/dist/{cli-aj8djyqt.js → cli-60sty27p.js} +2 -2
  23. package/dist/{cli-4hxrf0p4.js → cli-6a9qfa8b.js} +308 -47
  24. package/dist/{cli-n7asmce6.js → cli-6mgdba42.js} +3 -3
  25. package/dist/{cli-gf95q3bm.js → cli-6srv653c.js} +3 -3
  26. package/dist/{cli-9abdgn5t.js → cli-86ehn4s5.js} +3 -3
  27. package/dist/{cli-bqztfyve.js → cli-8mbc9za9.js} +1 -1
  28. package/dist/{cli-a2rr0fmg.js → cli-8wmj18cz.js} +1 -1
  29. package/dist/{cli-c2habvsf.js → cli-9gsm2rzp.js} +8 -8
  30. package/dist/{cli-4rf0f5vg.js → cli-9pshx6rd.js} +4 -2
  31. package/dist/{cli-abkm71vr.js → cli-a6ds2epe.js} +1 -1
  32. package/dist/{cli-esakngh8.js → cli-a89zdqz9.js} +1 -1
  33. package/dist/{cli-3ndmpxxv.js → cli-ay40dnj3.js} +2 -2
  34. package/dist/{cli-kf4jree9.js → cli-aypd09nf.js} +1 -1
  35. package/dist/{cli-6y51ryva.js → cli-b3yzyc57.js} +1 -1
  36. package/dist/{cli-ag2yrnt5.js → cli-c668vvcf.js} +2 -2
  37. package/dist/{cli-5sckpqcx.js → cli-d20qvpr2.js} +1 -1
  38. package/dist/{cli-a34pr3w1.js → cli-e1595bgx.js} +2 -2
  39. package/dist/{cli-m70bajzr.js → cli-f12mr20z.js} +1 -1
  40. package/dist/{cli-dqygeeh5.js → cli-f1e0ptjj.js} +10 -1
  41. package/dist/{cli-mvjz5an3.js → cli-f4dbh7a5.js} +7 -7
  42. package/dist/{cli-az25fn5f.js → cli-gsfk58nw.js} +2 -2
  43. package/dist/{cli-1tem6ns9.js → cli-gt4h8cxw.js} +3 -2
  44. package/dist/{cli-cz37jqss.js → cli-h5jeg46p.js} +2 -2
  45. package/dist/{cli-emhjcj2y.js → cli-he9gbdx0.js} +60 -10
  46. package/dist/{cli-3fwqy71j.js → cli-j0ppywdr.js} +38 -31
  47. package/dist/{cli-5rj83bam.js → cli-j3y0x2n4.js} +34 -33
  48. package/dist/{cli-3gfvmzht.js → cli-j9bpmykh.js} +73 -25
  49. package/dist/{cli-m5a9m0ak.js → cli-kv7sbc2c.js} +3 -3
  50. package/dist/{cli-g20jjwt4.js → cli-m30k9777.js} +12 -12
  51. package/dist/{cli-683y0b3a.js → cli-m5e802r7.js} +2 -2
  52. package/dist/{cli-nn6vx2mq.js → cli-ptfq202x.js} +5 -3
  53. package/dist/{cli-d2dpf95n.js → cli-qj442fws.js} +1 -1
  54. package/dist/{cli-03gg9pd1.js → cli-rd5m70gj.js} +4 -4
  55. package/dist/{cli-gnsb1zxa.js → cli-s09j9vp5.js} +1 -1
  56. package/dist/{cli-zqne1qcj.js → cli-s0mgx214.js} +2 -2
  57. package/dist/{cli-sqma6vw1.js → cli-sgqm2582.js} +5 -5
  58. package/dist/{cli-nq677csy.js → cli-sjctjr48.js} +1 -1
  59. package/dist/{cli-3aqd4jaa.js → cli-twctnkng.js} +3 -3
  60. package/dist/{cli-j6rktc91.js → cli-v6shg7na.js} +1 -1
  61. package/dist/{cli-7enjwya9.js → cli-vz78s5wm.js} +1 -1
  62. package/dist/{cli-art7c1zh.js → cli-wqr7cea4.js} +4 -4
  63. package/dist/{cli-hfeb68z4.js → cli-x37r7qwf.js} +11 -3
  64. package/dist/{cli-ffvsj2qw.js → cli-yd2nreqw.js} +2 -2
  65. package/dist/{cli-ca6banwz.js → cli-ymjpkxgn.js} +6 -6
  66. package/dist/{cli-mtnj4fwa.js → cli-yxbsmx56.js} +5 -5
  67. package/dist/cli.js +18 -16
  68. package/dist/{codex-adapter-d95q4qsn.js → codex-adapter-zk59x2zh.js} +5 -5
  69. package/dist/{codex-hook-brpnycn5.js → codex-hook-xyjhvxwd.js} +1 -1
  70. package/dist/{codex-session-runner-gdwd8h0c.js → codex-session-runner-kh6s4j3n.js} +5 -5
  71. package/dist/{commands-jqmgk54b.js → commands-68et1qv2.js} +2 -2
  72. package/dist/{controller-z3jq589f.js → controller-fyrbnzg4.js} +2 -2
  73. package/dist/{controllers-connect-r2za39ef.js → controllers-connect-nep9h2xv.js} +19 -19
  74. package/dist/{cursor-adapter-4mr4jgkw.js → cursor-adapter-yss3pg7t.js} +1 -1
  75. package/dist/{db-w36wwb5g.js → db-28gjyvxj.js} +2 -2
  76. package/dist/{db-ppv2tze7.js → db-8ftwvzba.js} +3 -3
  77. package/dist/{dispatcher-pv5h77vf.js → dispatcher-hayexmkr.js} +17 -17
  78. package/dist/{dsh-adapter-d4pdpb3r.js → dsh-adapter-46wawff9.js} +1 -1
  79. package/dist/{easter-egg-3d-4sp4dvmc.js → easter-egg-3d-8d1e0h4m.js} +1 -1
  80. package/dist/{execute-jev839y1.js → execute-cjrfxfnm.js} +1 -1
  81. package/dist/{external-channel-y6nkzhnn.js → external-channel-xbmxcg09.js} +2 -2
  82. package/dist/{grok-adapter-t08b9ntm.js → grok-adapter-ag4wks2a.js} +7 -7
  83. package/dist/handlers-tzmevqb5.js +41 -0
  84. package/dist/{heartbeat-vn31xwdk.js → heartbeat-psfvcgwp.js} +20 -20
  85. package/dist/{hook-6d0zbvvt.js → hook-8pz04697.js} +5 -5
  86. package/dist/{hook-cpjs8q51.js → hook-cyq4mryn.js} +6 -6
  87. package/dist/{http-9c6medda.js → http-haj1nd6k.js} +109 -114
  88. package/dist/{index-qhjf2htm.js → index-jwt2bj6f.js} +261 -96
  89. package/dist/{index-g6k9tctj.js → index-q52f47pe.js} +1 -1
  90. package/dist/{index-pkma1yf8.js → index-syvhnvtw.js} +1 -1
  91. package/dist/{index-yqgbfgaj.js → index-y1ajx04a.js} +20 -20
  92. package/dist/{index-k0yt1tvq.js → index-z5h22pes.js} +17 -17
  93. package/dist/{index-38t35h0n.js → index-zyq8ct52.js} +17 -17
  94. package/dist/{keepalive-em0117bp.js → keepalive-xdvsamvj.js} +3 -3
  95. package/dist/{lead-23e7z59t.js → lead-hqg57kdp.js} +35 -35
  96. package/dist/{maintenance-kbme3qy9.js → maintenance-4y2xzh3a.js} +1 -1
  97. package/dist/{oauth-refresh-sweep-xysj4yse.js → oauth-refresh-sweep-jd4r7zbs.js} +8 -3
  98. package/dist/{onboard-p6q0vz97.js → onboard-18447q9q.js} +5 -5
  99. package/dist/{opencode-adapter-esv3k9yn.js → opencode-adapter-26b2frdf.js} +5 -5
  100. package/dist/{otel-impl-54qhqh1z.js → otel-impl-f9tgr8yw.js} +1 -1
  101. package/dist/{pi-mono-adapter-mymbwhj0.js → pi-mono-adapter-qn6qbga1.js} +33 -12
  102. package/dist/{pricing-refresh-yb75wv33.js → pricing-refresh-dw2tgb53.js} +3 -3
  103. package/dist/{rbac-roles-1vp2mw4p.js → rbac-roles-aygm2760.js} +2 -2
  104. package/dist/{rbac-roles-dexhc9x8.js → rbac-roles-s4nwj2m3.js} +1 -1
  105. package/dist/{render-v2-tjay5hhf.js → render-v2-yfvdfr0x.js} +3 -3
  106. package/dist/{resume-8ayf37g5.js → resume-1bmvjbf3.js} +7 -7
  107. package/dist/{runtime-hqe5cqcd.js → runtime-f7tpzedy.js} +1 -1
  108. package/dist/{session-access-fdhp987s.js → session-access-r5garej6.js} +4 -4
  109. package/dist/{session-source-backfill-qgtc86z1.js → session-source-backfill-424wzv0r.js} +2 -2
  110. package/dist/{setup-4rrmqcq3.js → setup-8tygvxkq.js} +1 -1
  111. package/dist/{virtual-modules-52km00d6.js → virtual-modules-xsw323b3.js} +2 -2
  112. package/dist/{work-objects-p28fgd70.js → work-objects-ggnghpgf.js} +20 -20
  113. package/dist/{worker-e3w7vye1.js → worker-sqjd1nqf.js} +35 -35
  114. package/openapi.json +69 -1
  115. package/package.json +5 -3
  116. package/src/be/automation-preflight.ts +2 -0
  117. package/src/be/db/api-keys.ts +9 -0
  118. package/src/be/db/workflow-runs.ts +36 -5
  119. package/src/be/db.ts +80 -22
  120. package/src/be/memory/task-learning.ts +155 -0
  121. package/src/be/migrations/245_script_executor.sql +3 -0
  122. package/src/be/oauth-refresh-sweep.ts +2 -1
  123. package/src/be/scripts/boot-reembed.ts +2 -0
  124. package/src/be/scripts/db.ts +14 -4
  125. package/src/be/scripts/embeddings.ts +2 -0
  126. package/src/be/scripts/run-saved.ts +1 -0
  127. package/src/be/scripts/typecheck.ts +9 -3
  128. package/src/be/seed-skills/index.ts +7 -0
  129. package/src/commands/onboard/compose-generator.ts +5 -3
  130. package/src/commands/provider-credentials.ts +2 -1
  131. package/src/heartbeat/heartbeat.ts +14 -1
  132. package/src/heartbeat/script-runner-probe.ts +47 -0
  133. package/src/http/api-keys.ts +24 -9
  134. package/src/http/idempotency-key.ts +1 -1
  135. package/src/http/index.ts +3 -3
  136. package/src/http/pages-public.ts +38 -5
  137. package/src/http/scripts.ts +27 -0
  138. package/src/http/workflows.ts +10 -0
  139. package/src/http/x.ts +5 -0
  140. package/src/prompts/base-prompt.ts +21 -1
  141. package/src/prompts/session-templates.ts +17 -3
  142. package/src/providers/amp-adapter.ts +1 -1
  143. package/src/providers/dsh-adapter.ts +1 -1
  144. package/src/providers/pi-mono-adapter.ts +41 -11
  145. package/src/scheduler/scheduler.ts +2 -1
  146. package/src/scripts-runtime/eval-harness.ts +8 -1
  147. package/src/scripts-runtime/executors/native.ts +22 -16
  148. package/src/scripts-runtime/executors/quickjs-prelude.ts +103 -1
  149. package/src/scripts-runtime/executors/quickjs-runner.ts +2 -1
  150. package/src/scripts-runtime/executors/quickjs.ts +7 -0
  151. package/src/scripts-runtime/executors/registry.ts +32 -0
  152. package/src/scripts-runtime/executors/types.ts +7 -0
  153. package/src/scripts-runtime/host-validated-args.ts +133 -0
  154. package/src/scripts-runtime/loader.ts +30 -3
  155. package/src/scripts-runtime/swarm-sdk.ts +9 -2
  156. package/src/scripts-runtime/types/stdlib.d.ts +9 -1
  157. package/src/scripts-runtime/types/swarm-sdk.d.ts +9 -1
  158. package/src/tests/api-key-status-by-suffix.test.ts +242 -0
  159. package/src/tests/base-prompt.test.ts +70 -2
  160. package/src/tests/community-linear-delivery-loop.test.ts +2099 -0
  161. package/src/tests/credential-check.test.ts +4 -2
  162. package/src/tests/docs-index.test.ts +135 -0
  163. package/src/tests/dsh-adapter.test.ts +1 -1
  164. package/src/tests/fetch-resolved-env.test.ts +16 -2
  165. package/src/tests/heartbeat-checklist.test.ts +89 -0
  166. package/src/tests/heartbeat-script-runner-probe.test.ts +83 -0
  167. package/src/tests/oauth-refresh-sweep.test.ts +34 -1
  168. package/src/tests/onboard-compose.test.ts +20 -0
  169. package/src/tests/opencode-adapter.test.ts +2 -1
  170. package/src/tests/pages-frame-ancestors.test.ts +162 -0
  171. package/src/tests/pi-codemode.test.ts +2 -1
  172. package/src/tests/pi-mono-adapter.test.ts +54 -0
  173. package/src/tests/pi-native-mcp.test.ts +13 -1
  174. package/src/tests/pi-tool-exposure.test.ts +8 -8
  175. package/src/tests/probe-tool-schema-tokens.test.ts +144 -0
  176. package/src/tests/prompt-template-session.test.ts +4 -3
  177. package/src/tests/request-composition-derive.test.ts +329 -0
  178. package/src/tests/script-connections.test.ts +2 -0
  179. package/src/tests/script-executor-conformance.test.ts +36 -1
  180. package/src/tests/script-executor-quickjs-pool.test.ts +50 -0
  181. package/src/tests/script-executor-quickjs.test.ts +140 -0
  182. package/src/tests/script-host-validated-args-native.test.ts +196 -0
  183. package/src/tests/script-per-script-executor.test.ts +415 -0
  184. package/src/tests/scripts-external-api.test.ts +45 -0
  185. package/src/tests/sdk-allowlist.test.ts +3 -2
  186. package/src/tests/self-improvement.test.ts +4 -4
  187. package/src/tests/store-progress-learning.test.ts +288 -0
  188. package/src/tests/ui-logs-parser.test.ts +180 -0
  189. package/src/tests/workflow-engine-v2.test.ts +116 -1
  190. package/src/tests/workflow-mcp-trigger-schema.test.ts +209 -0
  191. package/src/tests/workflow-run-finished-event.test.ts +263 -0
  192. package/src/tests/workflow-swarm-script.test.ts +34 -0
  193. package/src/tools/script-common.ts +1 -0
  194. package/src/tools/script-run.ts +7 -0
  195. package/src/tools/script-upsert.ts +7 -0
  196. package/src/tools/store-progress.ts +67 -1
  197. package/src/tools/workflows/trigger-workflow.ts +104 -10
  198. package/src/types.ts +12 -1
  199. package/src/utils/credentials.ts +5 -2
  200. package/src/workflows/engine.ts +18 -16
  201. package/src/workflows/executors/swarm-script.ts +5 -0
  202. package/src/workflows/foreach-driver.ts +5 -11
  203. package/src/workflows/node-timeout.ts +30 -0
  204. package/templates/community/linear-delivery-loop/PLAYBOOK.md +323 -0
  205. package/templates/community/linear-delivery-loop/README.md +38 -0
  206. package/templates/community/linear-delivery-loop/config.example.json +31 -0
  207. package/templates/community/linear-delivery-loop/lead-prompt.md +34 -0
  208. package/templates/community/linear-delivery-loop/schedule.json +10 -0
  209. package/templates/community/linear-delivery-loop/scripts/linear-delivery-setup.ts +148 -0
  210. package/templates/community/linear-delivery-loop/scripts/linear-delivery-tick.ts +1781 -0
  211. package/templates/skills/swarm-docs/config.json +14 -0
  212. package/templates/skills/swarm-docs/content.md +144 -0
  213. package/dist/handlers-eqsnmv95.js +0 -41
@@ -1859,3 +1859,57 @@ describe("PiMonoSession context-bound note", () => {
1859
1859
  expect(await lateSteers([assistantTurn(usage(5000))])).toEqual([HELD_NOTE_TEXT]);
1860
1860
  });
1861
1861
  });
1862
+
1863
+ describe("PiMonoSession — per-call usage line", () => {
1864
+ function assistantEnd(content: unknown[], usage?: unknown): AgentSessionEvent {
1865
+ return {
1866
+ type: "message_end",
1867
+ message: { role: "assistant", content, stopReason: "stop", ...(usage ? { usage } : {}) },
1868
+ } as unknown as AgentSessionEvent;
1869
+ }
1870
+
1871
+ test("one call_usage raw_log line per assistant message_end with usage", async () => {
1872
+ const { events } = await runWithEvents([
1873
+ assistantEnd([{ type: "toolCall", id: "call-1", name: "bash", arguments: {} }], {
1874
+ input: 120,
1875
+ cacheRead: 9_000,
1876
+ cacheWrite: 400,
1877
+ output: 60,
1878
+ reasoning: 12,
1879
+ totalTokens: 9_580,
1880
+ }),
1881
+ {
1882
+ type: "message_end",
1883
+ message: { role: "user", content: [{ type: "text", text: "tool output" }] },
1884
+ } as unknown as AgentSessionEvent,
1885
+ // No usage (some providers omit it): no line.
1886
+ assistantEnd([{ type: "text", text: "thinking out loud" }]),
1887
+ assistantEnd([{ type: "text", text: "Done" }], { input: 5, cacheRead: 9_500, output: 3 }),
1888
+ ]);
1889
+
1890
+ const raw = events.flatMap((e) =>
1891
+ e.type === "raw_log" ? [JSON.parse(e.content) as Record<string, unknown>] : [],
1892
+ );
1893
+ const usageLines = raw.filter((line) => line.type === "call_usage");
1894
+ expect(usageLines).toEqual([
1895
+ {
1896
+ type: "call_usage",
1897
+ harness: "pi",
1898
+ usage: { input: 120, cacheRead: 9_000, cacheWrite: 400, output: 60, reasoning: 12 },
1899
+ },
1900
+ {
1901
+ type: "call_usage",
1902
+ harness: "pi",
1903
+ usage: { input: 5, cacheRead: 9_500, cacheWrite: 0, output: 3, reasoning: 0 },
1904
+ },
1905
+ ]);
1906
+ // The usage line precedes the call's own text line.
1907
+ const done = raw.findIndex(
1908
+ (line) => line.type === "assistant" && JSON.stringify(line).includes('"Done"'),
1909
+ );
1910
+ expect(done).toBeGreaterThan(0);
1911
+ expect(raw[done - 1]).toEqual(usageLines[1]);
1912
+ // Token counts only: no message content rides on the line.
1913
+ expect(JSON.stringify(usageLines)).not.toContain("tool output");
1914
+ });
1915
+ });
@@ -30,6 +30,17 @@ describe("toPiMcpServers", () => {
30
30
  });
31
31
  });
32
32
 
33
+ test("deferred maps every installed server to deferred exposure", () => {
34
+ const servers = toPiMcpServers(
35
+ {
36
+ local: { command: "context-mode" },
37
+ remote: { url: "https://mcp.example.com/mcp" },
38
+ },
39
+ { deferred: true },
40
+ );
41
+ expect(Object.values(servers).map((s) => s.exposure)).toEqual(["deferred", "deferred"]);
42
+ });
43
+
33
44
  test("no installed servers maps to an empty set", () => {
34
45
  expect(toPiMcpServers(null)).toEqual({});
35
46
  });
@@ -70,7 +81,8 @@ describe("PiMonoAdapter.createSession — native MCP for installed servers", ()
70
81
  process.env.OPENROUTER_API_KEY = "example-test-key";
71
82
  // The session prompts on creation; keep that request off the network.
72
83
  process.env.OPENROUTER_BASE_URL = "http://127.0.0.1:9/api/v1";
73
- delete process.env.PI_TOOL_DEFERRAL;
84
+ // Direct exposure, so installed tools show up as active tool names.
85
+ process.env.PI_TOOL_DEFERRAL = "false";
74
86
  mkdirSync(join(dir, "agent"), { recursive: true });
75
87
  });
76
88
  afterEach(() => {
@@ -176,8 +176,8 @@ describe("pi resource loader trust", () => {
176
176
  });
177
177
 
178
178
  describe("PI_TOOL_DEFERRAL flag", () => {
179
- test("defaults off and parses boolean literals", () => {
180
- expect(isPiToolDeferralEnabled({})).toBe(false);
179
+ test("defaults on and parses boolean literals", () => {
180
+ expect(isPiToolDeferralEnabled({})).toBe(true);
181
181
  expect(isPiToolDeferralEnabled({ PI_TOOL_DEFERRAL: "true" })).toBe(true);
182
182
  expect(isPiToolDeferralEnabled({ PI_TOOL_DEFERRAL: "1" })).toBe(true);
183
183
  expect(isPiToolDeferralEnabled({ PI_TOOL_DEFERRAL: "false" })).toBe(false);
@@ -187,9 +187,9 @@ describe("PI_TOOL_DEFERRAL flag", () => {
187
187
  "traits.hasToolSearch follows the flag live",
188
188
  withEnv("PI_TOOL_DEFERRAL", undefined, () => {
189
189
  const adapter = new PiMonoAdapter();
190
- expect(adapter.traits.hasToolSearch).toBe(false);
191
- process.env.PI_TOOL_DEFERRAL = "true";
192
190
  expect(adapter.traits.hasToolSearch).toBe(true);
191
+ process.env.PI_TOOL_DEFERRAL = "false";
192
+ expect(adapter.traits.hasToolSearch).toBe(false);
193
193
  }),
194
194
  );
195
195
  });
@@ -259,8 +259,8 @@ describe("PiMonoAdapter.createSession — tool deferral wiring", () => {
259
259
  }
260
260
 
261
261
  test(
262
- "flag on: non-core tools deferred and tool_search enabled by default",
263
- withEnv("PI_TOOL_DEFERRAL", "true", async () => {
262
+ "flag unset: non-core tools deferred and tool_search enabled by default",
263
+ withEnv("PI_TOOL_DEFERRAL", undefined, async () => {
264
264
  const opts = await captureSessionOptions();
265
265
  const tools = opts.customTools as ToolDefinition[];
266
266
  expect(Object.fromEntries(tools.map((t) => [t.name, t.exposure]))).toEqual({
@@ -274,8 +274,8 @@ describe("PiMonoAdapter.createSession — tool deferral wiring", () => {
274
274
  );
275
275
 
276
276
  test(
277
- "flag off: every tool declared and no tool_search",
278
- withEnv("PI_TOOL_DEFERRAL", undefined, async () => {
277
+ "flag false: every tool declared and no tool_search",
278
+ withEnv("PI_TOOL_DEFERRAL", "false", async () => {
279
279
  const opts = await captureSessionOptions();
280
280
  const tools = opts.customTools as ToolDefinition[];
281
281
  expect(tools.map((t) => t.exposure)).toEqual([undefined, undefined, undefined]);
@@ -0,0 +1,144 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ bareToolName,
7
+ controlReached,
8
+ countSwarmTools,
9
+ hasControlResult,
10
+ inputTokens,
11
+ toolNames,
12
+ turnOne,
13
+ withSessionDirs,
14
+ } from "../../scripts/probe-tool-schema-tokens";
15
+
16
+ describe("probe-tool-schema-tokens", () => {
17
+ let dir = "";
18
+ afterEach(() => {
19
+ if (dir) rmSync(dir, { recursive: true, force: true });
20
+ });
21
+
22
+ test("matches swarm tools under every harness naming", () => {
23
+ const swarm = new Set(["get_swarm", "store_progress"]);
24
+ expect(bareToolName("mcp__agent_swarm__get_swarm")).toBe("get_swarm");
25
+ expect(bareToolName("agent-swarm_store-progress")).toBe("store_progress");
26
+ expect(
27
+ countSwarmTools(["get-swarm", "mcp__agent_swarm__store_progress", "bash", "read"], swarm),
28
+ ).toBe(2);
29
+ });
30
+
31
+ test("lists tools in chat-completions and namespaced responses bodies", () => {
32
+ expect(toolNames({ tools: [{ type: "function", function: { name: "get-swarm" } }] })).toEqual([
33
+ "get-swarm",
34
+ ]);
35
+ const responses = {
36
+ input: [
37
+ {
38
+ type: "additional_tools",
39
+ tools: [{ type: "namespace", name: "functions", tools: [{ name: "exec" }] }],
40
+ },
41
+ ],
42
+ tools: [{ name: "wait" }],
43
+ };
44
+ expect(toolNames(responses).sort()).toEqual(["exec", "wait"]);
45
+ });
46
+
47
+ test("reads the last reported input token count from a stream", () => {
48
+ const sse = 'data: {"usage":null}\n\ndata: {"usage":{"prompt_tokens":90776}}\n';
49
+ expect(inputTokens(sse)).toBe(90776);
50
+ expect(inputTokens('{"response":{"usage":{"input_tokens":16629}}}')).toBe(16629);
51
+ expect(inputTokens("data: [DONE]")).toBeNull();
52
+ });
53
+
54
+ test("turn one skips side requests for other models", () => {
55
+ dir = mkdtempSync(join(tmpdir(), "probe-tool-schema-"));
56
+ writeFileSync(join(dir, "req-1.json"), JSON.stringify({ model: "openai/gpt-6-luna" }));
57
+ writeFileSync(join(dir, "res-1.txt"), '{"usage":{"prompt_tokens":453}}');
58
+ writeFileSync(
59
+ join(dir, "req-2.json"),
60
+ JSON.stringify({ model: "deepseek/deepseek-v4.1-flash", tools: [{ name: "bash" }] }),
61
+ );
62
+ writeFileSync(join(dir, "res-2.txt"), '{"usage":{"prompt_tokens":33115}}');
63
+ expect(turnOne(dir, "openrouter/deepseek/deepseek-v4.1-flash")).toEqual({
64
+ tokens: 33115,
65
+ names: ["bash"],
66
+ });
67
+ });
68
+
69
+ const found = "Found 5 agent(s) in the swarm. Requested by session: s1";
70
+
71
+ test("control passes only on a get-swarm tool output", () => {
72
+ dir = mkdtempSync(join(tmpdir(), "probe-tool-schema-"));
73
+ // The schema alone carries "in the swarm" in get-swarm's description.
74
+ const schemaOnly = {
75
+ model: "m",
76
+ tools: [
77
+ {
78
+ type: "function",
79
+ function: { name: "agent-swarm_get-swarm", description: "Lists agents in the swarm." },
80
+ },
81
+ ],
82
+ messages: [{ role: "user", content: "Call get-swarm. Found 5 agent(s) in the swarm." }],
83
+ };
84
+ expect(hasControlResult(schemaOnly)).toBe(false);
85
+ writeFileSync(join(dir, "req-1.json"), JSON.stringify(schemaOnly));
86
+ expect(controlReached(dir)).toBe(false);
87
+
88
+ const chat = {
89
+ ...schemaOnly,
90
+ messages: [
91
+ {
92
+ role: "assistant",
93
+ tool_calls: [{ id: "c1", type: "function", function: { name: "agent-swarm_get-swarm" } }],
94
+ },
95
+ { role: "tool", tool_call_id: "c1", content: [{ type: "text", text: found }] },
96
+ ],
97
+ };
98
+ expect(hasControlResult(chat)).toBe(true);
99
+ writeFileSync(join(dir, "req-2.json"), JSON.stringify(chat));
100
+ expect(controlReached(dir)).toBe(true);
101
+ });
102
+
103
+ test("control reads Responses items and rejects other tools and failed calls", () => {
104
+ const responses = (name: string, output: string) => ({
105
+ input: [
106
+ { type: "function_call", name, call_id: "call_1", arguments: "{}" },
107
+ { type: "function_call_output", call_id: "call_1", output },
108
+ ],
109
+ });
110
+ expect(hasControlResult(responses("mcp__agent_swarm__get_swarm", found))).toBe(true);
111
+ expect(hasControlResult(responses("mcp__agent_swarm__get_tasks", found))).toBe(false);
112
+ expect(hasControlResult(responses("mcp__agent_swarm__get_swarm", "Agent ID required."))).toBe(
113
+ false,
114
+ );
115
+ // An output for a different call id does not count.
116
+ const mismatched = responses("mcp__agent_swarm__get_swarm", found);
117
+ mismatched.input[1] = { type: "function_call_output", call_id: "call_2", output: found };
118
+ expect(hasControlResult(mismatched)).toBe(false);
119
+ });
120
+
121
+ test("session dirs: home is removed on success and on error, captures stay", async () => {
122
+ dir = mkdtempSync(join(tmpdir(), "probe-tool-schema-"));
123
+ let home = "";
124
+ const capDir = await withSessionDirs(dir, "ok", async (cap, h) => {
125
+ home = h;
126
+ writeFileSync(join(h, "config.toml"), "x");
127
+ writeFileSync(join(cap, "req-1.json"), "{}");
128
+ return cap;
129
+ });
130
+ expect(existsSync(home)).toBe(false);
131
+ expect(existsSync(join(capDir, "req-1.json"))).toBe(true);
132
+
133
+ await expect(
134
+ withSessionDirs(dir, "boom", async (cap, h) => {
135
+ home = h;
136
+ writeFileSync(join(h, "config.toml"), "x");
137
+ writeFileSync(join(cap, "req-1.json"), "{}");
138
+ throw new Error("codex exec exited 1");
139
+ }),
140
+ ).rejects.toThrow("codex exec exited 1");
141
+ expect(existsSync(home)).toBe(false);
142
+ expect(existsSync(join(dir, "boom-cap", "req-1.json"))).toBe(true);
143
+ });
144
+ });
@@ -25,6 +25,7 @@ const SYSTEM_TEMPLATES = [
25
25
  "system.agent.context.bound_note",
26
26
  "system.agent.cursor.first_message",
27
27
  "system.agent.lead",
28
+ "system.agent.lead.docs",
28
29
  "system.agent.memory",
29
30
  "system.agent.memory.remote",
30
31
  "system.agent.outputs",
@@ -118,11 +119,11 @@ describe("Session templates: registration", () => {
118
119
  expect(sessionSystemEventTypes("session")).toEqual([...SESSION_TEMPLATES].sort());
119
120
  });
120
121
 
121
- test("registers 32 system and session templates in total", () => {
122
+ test("registers 33 system and session templates in total", () => {
122
123
  const all = getAllTemplateDefinitions();
123
124
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
124
- // 27 system blocks + 5 session composites.
125
- expect(sessionSystem.length).toBe(32);
125
+ // 28 system blocks + 5 session composites.
126
+ expect(sessionSystem.length).toBe(33);
126
127
  });
127
128
 
128
129
  test("drops the v1 blocks that prompt v2 deleted", () => {
@@ -0,0 +1,329 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type CompositionCall,
4
+ type CompositionManifest,
5
+ compositionCallsOf,
6
+ deriveCallCompositions,
7
+ staticTokens,
8
+ } from "../../apps/ui/src/lib/request-composition";
9
+ import type { CallUsage, NormalizedItem } from "../../apps/ui/src/logs-parser/types";
10
+
11
+ const T0 = Date.parse("2026-10-26T10:00:00.000Z");
12
+
13
+ function manifest(overrides: Partial<CompositionManifest> = {}): CompositionManifest {
14
+ return {
15
+ id: "m-0",
16
+ taskId: "task-1",
17
+ agentId: "agent-1",
18
+ sessionId: "s-1",
19
+ seq: 0,
20
+ provider: "claude",
21
+ model: null,
22
+ source: "worker",
23
+ parts: [
24
+ {
25
+ kind: "system_section",
26
+ id: "system.agent.role",
27
+ label: "role",
28
+ chars: 4000,
29
+ estTokens: 1000,
30
+ fidelity: "exact",
31
+ },
32
+ {
33
+ kind: "persona",
34
+ id: "persona",
35
+ label: "persona",
36
+ chars: 8000,
37
+ estTokens: 2000,
38
+ fidelity: "exact",
39
+ },
40
+ {
41
+ kind: "tool_schemas",
42
+ id: "mcp:agent-swarm",
43
+ label: "tools",
44
+ chars: 40000,
45
+ estTokens: 10000,
46
+ fidelity: "estimate",
47
+ },
48
+ {
49
+ kind: "skills_listing",
50
+ id: "skills_listing",
51
+ label: "skills",
52
+ chars: 4000,
53
+ estTokens: 1000,
54
+ fidelity: "estimate",
55
+ },
56
+ {
57
+ kind: "task_prompt",
58
+ id: "task_prompt",
59
+ label: "task",
60
+ chars: 2000,
61
+ estTokens: 500,
62
+ fidelity: "exact",
63
+ },
64
+ {
65
+ kind: "memories",
66
+ id: "memories",
67
+ label: "memories",
68
+ chars: 2000,
69
+ estTokens: 500,
70
+ fidelity: "exact",
71
+ },
72
+ ],
73
+ staticEstTokens: 15000,
74
+ tokenRatio: 1,
75
+ createdAt: new Date(T0 - 1000).toISOString(),
76
+ ...overrides,
77
+ };
78
+ }
79
+
80
+ function call(
81
+ callId: string,
82
+ promptTokens: number,
83
+ offsetMs: number,
84
+ extra: Partial<CallUsage> = {},
85
+ sessionId = "s-1",
86
+ ): CompositionCall {
87
+ return {
88
+ sessionId,
89
+ t: T0 + offsetMs,
90
+ usage: {
91
+ callId,
92
+ promptTokens,
93
+ cacheReadTokens: 0,
94
+ cacheWriteTokens: 0,
95
+ uncachedInputTokens: promptTokens,
96
+ outputTokens: 10,
97
+ sidechain: false,
98
+ harness: "claude",
99
+ reportedAt: "start",
100
+ ...extra,
101
+ },
102
+ };
103
+ }
104
+
105
+ function sum(tokens: { tokens: number }[]): number {
106
+ return tokens.reduce((acc, s) => acc + s.tokens, 0);
107
+ }
108
+
109
+ function tokensOf(segments: { group: string; tokens: number }[], group: string): number {
110
+ return segments.find((s) => s.group === group)?.tokens ?? 0;
111
+ }
112
+
113
+ describe("deriveCallCompositions", () => {
114
+ test("segments sum to each call's prompt tokens", () => {
115
+ const { byCall } = deriveCallCompositions(
116
+ [call("c1", 40_000, 0), call("c2", 52_000, 1000), call("c3", 90_000, 2000)],
117
+ [manifest()],
118
+ );
119
+ for (const [id, prompt] of [
120
+ ["c1", 40_000],
121
+ ["c2", 52_000],
122
+ ["c3", 90_000],
123
+ ] as const) {
124
+ const comp = byCall.get(id);
125
+ expect(comp).toBeDefined();
126
+ expect(sum(comp!.segments)).toBeCloseTo(prompt, 6);
127
+ expect(comp!.scaled).toBe(false);
128
+ }
129
+ });
130
+
131
+ test("the first main call anchors the harness base; later calls add history", () => {
132
+ const { byCall, anchors } = deriveCallCompositions(
133
+ [call("c1", 40_000, 0), call("c2", 52_000, 1000)],
134
+ [manifest()],
135
+ );
136
+ const first = byCall.get("c1")!;
137
+ expect(tokensOf(first.segments, "harness")).toBe(25_000);
138
+ expect(tokensOf(first.segments, "history")).toBe(0);
139
+ expect(tokensOf(first.segments, "task")).toBe(1000);
140
+ const second = byCall.get("c2")!;
141
+ expect(tokensOf(second.segments, "harness")).toBe(25_000);
142
+ expect(tokensOf(second.segments, "history")).toBe(12_000);
143
+ expect(anchors.get("m-0")).toEqual({
144
+ callId: "c1",
145
+ promptTokens: 40_000,
146
+ residualTokens: 25_000,
147
+ });
148
+ });
149
+
150
+ test("sub-agent calls are never the anchor and get no composition", () => {
151
+ const { byCall, anchors } = deriveCallCompositions(
152
+ [call("sub", 5_000, 0, { sidechain: true }), call("c1", 40_000, 1000)],
153
+ [manifest()],
154
+ );
155
+ expect(byCall.has("sub")).toBe(false);
156
+ expect(anchors.get("m-0")?.callId).toBe("c1");
157
+ expect(tokensOf(byCall.get("c1")!.segments, "harness")).toBe(25_000);
158
+ });
159
+
160
+ test("a compaction starts a new anchor whose harness base never grows", () => {
161
+ const compactions = [{ sessionId: "s-1", t: T0 + 1500 }];
162
+ // Post-compaction prompt carries a 6k summary: it is history, not harness.
163
+ const grown = deriveCallCompositions(
164
+ [call("c1", 40_000, 0), call("c2", 120_000, 1000), call("c3", 46_000, 2000)],
165
+ [manifest()],
166
+ compactions,
167
+ );
168
+ const post = grown.byCall.get("c3")!;
169
+ expect(tokensOf(post.segments, "harness")).toBe(25_000);
170
+ expect(tokensOf(post.segments, "history")).toBe(6_000);
171
+ // The rail's "first call" stays the session's first call.
172
+ expect(grown.anchors.get("m-0")?.callId).toBe("c1");
173
+
174
+ // The harness dropped part of its base after compacting: the base shrinks.
175
+ const shrunk = deriveCallCompositions(
176
+ [call("c1", 40_000, 0), call("c3", 30_000, 2000), call("c4", 33_000, 3000)],
177
+ [manifest()],
178
+ compactions,
179
+ );
180
+ expect(tokensOf(shrunk.byCall.get("c3")!.segments, "harness")).toBe(15_000);
181
+ expect(tokensOf(shrunk.byCall.get("c4")!.segments, "history")).toBe(3_000);
182
+ // Control: without the compaction, the base stays at the first call's.
183
+ const control = deriveCallCompositions(
184
+ [call("c1", 40_000, 0), call("c3", 30_000, 2000)],
185
+ [manifest()],
186
+ );
187
+ expect(tokensOf(control.byCall.get("c3")!.segments, "harness")).toBe(
188
+ 25_000 * (30_000 / 40_000),
189
+ );
190
+ });
191
+
192
+ test("nothing goes negative when the estimates overshoot the prompt", () => {
193
+ const { byCall } = deriveCallCompositions(
194
+ [call("c1", 9_000, 0), call("c2", 7_500, 1000)],
195
+ [manifest()],
196
+ );
197
+ for (const id of ["c1", "c2"]) {
198
+ const comp = byCall.get(id)!;
199
+ expect(comp.scaled).toBe(true);
200
+ for (const seg of comp.segments) expect(seg.tokens).toBeGreaterThan(0);
201
+ expect(tokensOf(comp.segments, "harness")).toBe(0);
202
+ expect(tokensOf(comp.segments, "history")).toBe(0);
203
+ }
204
+ expect(sum(byCall.get("c1")!.segments)).toBeCloseTo(9_000, 6);
205
+ expect(sum(byCall.get("c2")!.segments)).toBeCloseTo(7_500, 6);
206
+ });
207
+
208
+ test("the token ratio scales the static parts", () => {
209
+ const m = manifest({ tokenRatio: 1.2 });
210
+ expect(staticTokens(m)).toBeCloseTo(18_000, 6);
211
+ const { byCall } = deriveCallCompositions([call("c1", 40_000, 0)], [m]);
212
+ expect(tokensOf(byCall.get("c1")!.segments, "tools")).toBeCloseTo(12_000, 6);
213
+ expect(tokensOf(byCall.get("c1")!.segments, "harness")).toBeCloseTo(22_000, 6);
214
+ });
215
+
216
+ test("each session uses its own manifest; a later seq applies from its write time", () => {
217
+ const s2 = manifest({ id: "m-s2", sessionId: "s-2", parts: manifest().parts.slice(0, 1) });
218
+ const seq1 = manifest({
219
+ id: "m-1",
220
+ seq: 1,
221
+ createdAt: new Date(T0 + 1500).toISOString(),
222
+ parts: manifest().parts.slice(0, 2),
223
+ });
224
+ const { byCall } = deriveCallCompositions(
225
+ [
226
+ call("c1", 40_000, 0),
227
+ call("c2", 41_000, 2000),
228
+ call("d1", 20_000, 0, {}, "s-2"),
229
+ call("x1", 20_000, 0, {}, "s-unknown"),
230
+ ],
231
+ [manifest(), seq1, s2],
232
+ );
233
+ expect(byCall.get("c1")!.manifest.id).toBe("m-0");
234
+ expect(byCall.get("c2")!.manifest.id).toBe("m-1");
235
+ expect(byCall.get("d1")!.manifest.id).toBe("m-s2");
236
+ expect(tokensOf(byCall.get("d1")!.segments, "harness")).toBe(19_000);
237
+ expect(byCall.has("x1")).toBe(false);
238
+ for (const comp of byCall.values())
239
+ expect(sum(comp.segments)).toBeCloseTo(comp.promptTokens, 6);
240
+ });
241
+
242
+ test("a hook manifest after the first call keeps later growth as history", () => {
243
+ // The worker manifest (15k static) anchors c1. The harness hook then posts
244
+ // exact parts worth 40k, the whole first prompt. c2's extra 12k is history.
245
+ const hook = manifest({
246
+ id: "m-hook",
247
+ seq: 1,
248
+ source: "harness-hook",
249
+ createdAt: new Date(T0 + 500).toISOString(),
250
+ parts: [{ ...manifest().parts[0]!, estTokens: 40_000, fidelity: "exact" }],
251
+ });
252
+ const { byCall, anchors } = deriveCallCompositions(
253
+ [call("c1", 40_000, 0), call("c2", 52_000, 1000)],
254
+ [manifest(), hook],
255
+ );
256
+ const second = byCall.get("c2")!;
257
+ expect(second.manifest.id).toBe("m-hook");
258
+ expect(tokensOf(second.segments, "harness")).toBe(0);
259
+ expect(tokensOf(second.segments, "history")).toBe(12_000);
260
+ expect(sum(second.segments)).toBeCloseTo(52_000, 6);
261
+ expect(anchors.get("m-hook")).toEqual({
262
+ callId: "c1",
263
+ promptTokens: 40_000,
264
+ residualTokens: 0,
265
+ });
266
+ // After a compaction the re-split base still caps the harness base: the
267
+ // 6k summary is history.
268
+ const compacted = deriveCallCompositions(
269
+ [call("c1", 40_000, 0), call("c2", 52_000, 1000), call("c3", 46_000, 2000)],
270
+ [manifest(), hook],
271
+ [{ sessionId: "s-1", t: T0 + 1500 }],
272
+ );
273
+ expect(tokensOf(compacted.byCall.get("c3")!.segments, "harness")).toBe(0);
274
+ expect(tokensOf(compacted.byCall.get("c3")!.segments, "history")).toBe(6_000);
275
+ // A hook manifest smaller than the first prompt leaves the rest as harness.
276
+ const partial = deriveCallCompositions(
277
+ [call("c1", 40_000, 0), call("c2", 52_000, 1000)],
278
+ [manifest(), { ...hook, parts: [{ ...hook.parts[0]!, estTokens: 30_000 }] }],
279
+ );
280
+ expect(tokensOf(partial.byCall.get("c2")!.segments, "harness")).toBe(10_000);
281
+ expect(tokensOf(partial.byCall.get("c2")!.segments, "history")).toBe(12_000);
282
+ });
283
+
284
+ test("a later worker manifest keeps the harness base and swaps only the static parts", () => {
285
+ // m-0 has 15k static, so c1 (40k) measures a 25k harness base. A worker
286
+ // manifest then re-estimates the static parts as 16k. c2's history is
287
+ // 55k − 16k − 25k = 14k.
288
+ const part = manifest().parts[0]!;
289
+ const first = manifest({ parts: [{ ...part, estTokens: 15_000 }] });
290
+ const second = manifest({
291
+ id: "m-1",
292
+ seq: 1,
293
+ createdAt: new Date(T0 + 500).toISOString(),
294
+ parts: [{ ...part, estTokens: 16_000 }],
295
+ });
296
+ const { byCall, anchors } = deriveCallCompositions(
297
+ [call("c1", 40_000, 0), call("c2", 55_000, 1000)],
298
+ [first, second],
299
+ );
300
+ const c2 = byCall.get("c2")!;
301
+ expect(c2.manifest.id).toBe("m-1");
302
+ expect(tokensOf(c2.segments, "harness")).toBe(25_000);
303
+ expect(tokensOf(c2.segments, "history")).toBe(14_000);
304
+ expect(sum(c2.segments)).toBeCloseTo(55_000, 6);
305
+ expect(anchors.get("m-1")).toEqual({
306
+ callId: "c1",
307
+ promptTokens: 40_000,
308
+ residualTokens: 25_000,
309
+ });
310
+ });
311
+
312
+ test("no manifests means no compositions", () => {
313
+ expect(deriveCallCompositions([call("c1", 40_000, 0)], []).byCall.size).toBe(0);
314
+ });
315
+ });
316
+
317
+ describe("compositionCallsOf", () => {
318
+ test("keeps call_usage items whose record has a session", () => {
319
+ const base = { fileIndex: 0, iteration: 1, lineNumber: 1, createdAt: "", t: T0, cli: "claude" };
320
+ const usage = call("c1", 100, 0).usage;
321
+ const items: NormalizedItem[] = [
322
+ { ...base, recId: "r1", kind: "call_usage", usage },
323
+ { ...base, recId: "r2", kind: "call_usage", usage: { ...usage, callId: "c2" } },
324
+ { ...base, recId: "r1", kind: "text", text: "hi" },
325
+ ];
326
+ const calls = compositionCallsOf(items, (recId) => (recId === "r1" ? "s-1" : undefined));
327
+ expect(calls).toEqual([{ usage, sessionId: "s-1", t: T0 }]);
328
+ });
329
+ });
@@ -653,6 +653,8 @@ describe("script connections", () => {
653
653
  markMigrationApplied(database, "241_skills_meta_fts.sql");
654
654
  // 244 alters api_key_status, which this migration-112-only fixture does not create.
655
655
  markMigrationApplied(database, "244_api_key_validation.sql");
656
+ // 245 alters scripts, which this migration-112-only fixture does not create.
657
+ markMigrationApplied(database, "245_script_executor.sql");
656
658
 
657
659
  const id = crypto.randomUUID();
658
660
  const now = new Date().toISOString();
@@ -1,4 +1,4 @@
1
- import { afterAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, describe, expect, spyOn, test } from "bun:test";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -402,6 +402,41 @@ describe("native-only executor behavior", () => {
402
402
  expect(output.error).toBe("killed");
403
403
  });
404
404
 
405
+ // Before the fix the abort listener was added after the file writes, so an
406
+ // abort that landed during them was missed and the script ran to the end.
407
+ test.skipIf(SKIP_SANDBOX_SPAWN_TESTS)(
408
+ "an abort while args.json is written kills the run before the script starts",
409
+ async () => {
410
+ const dir = await mkdtemp(join(tmpdir(), "native-abort-"));
411
+ const marker = join(dir, "side-effect.marker");
412
+ const controller = new AbortController();
413
+ const write = Bun.write;
414
+ const spy = spyOn(Bun, "write").mockImplementation((async (
415
+ ...writeArgs: Parameters<typeof Bun.write>
416
+ ) => {
417
+ const written = await write(...writeArgs);
418
+ if (String(writeArgs[0]).endsWith("/args.json")) controller.abort();
419
+ return written;
420
+ }) as typeof Bun.write);
421
+ try {
422
+ const output = await new NativeScriptExecutor().run(
423
+ input({
424
+ source: `export default async () => { await Bun.write(${JSON.stringify(marker)}, "ran"); return "ran"; };`,
425
+ signal: controller.signal,
426
+ resources: { ...DEFAULT_SCRIPT_RESOURCES, memoryMb: 2048, wallClockMs: 10_000 },
427
+ }),
428
+ );
429
+ expect(output.error).toBe("killed");
430
+ expect(output.result).toBeUndefined();
431
+ expect(await Bun.file(marker).exists()).toBe(false);
432
+ } finally {
433
+ spy.mockRestore();
434
+ await rm(dir, { recursive: true, force: true });
435
+ }
436
+ },
437
+ CHILD_PROCESS_TEST_BUDGET_MS,
438
+ );
439
+
405
440
  // PR #1326 review finding: exit 134 (SIGABRT) was classified `capacity_exceeded`
406
441
  // — and therefore retried by `runScript` — purely from the exit code, with no
407
442
  // evidence about *when* the abort happened. A script that causes a side effect