@desplega.ai/agent-swarm 1.166.0 → 1.168.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 (221) hide show
  1. package/dist/acp-adapter-zhejfecx.js +34 -0
  2. package/dist/{actions-qz3m692w.js → actions-7jg840nr.js} +25 -25
  3. package/dist/{amp-adapter-j1xbmsqz.js → amp-adapter-ye6rnetm.js} +4 -4
  4. package/dist/{anthropic-messages-hst62ws9.js → anthropic-messages-13yn502s.js} +4 -4
  5. package/dist/{app-43vrdmv8.js → app-ffes2kyf.js} +10 -9
  6. package/dist/{assistant-ertffmka.js → assistant-fhm5smyz.js} +26 -26
  7. package/dist/{azure-openai-responses-mh8tvf78.js → azure-openai-responses-9st34pfz.js} +5 -5
  8. package/dist/{boot-reembed-998jtpdg.js → boot-reembed-20q3nzvg.js} +9 -8
  9. package/dist/{boot-reembed-s7k7c6v2.js → boot-reembed-jvfadhsk.js} +9 -8
  10. package/dist/{boot-scrub-sweep-nepsk4t1.js → boot-scrub-sweep-jtfajzve.js} +10 -9
  11. package/dist/{boot-seeds-vvzs1zhk.js → boot-seeds-9qt0j8j2.js} +12 -11
  12. package/dist/{claude-adapter-2zvmekrp.js → claude-adapter-a8znhthz.js} +24 -22
  13. package/dist/{claude-managed-adapter-s49wbk8t.js → claude-managed-adapter-tzwxy803.js} +4 -4
  14. package/dist/{claude-managed-setup-cvhqq8qn.js → claude-managed-setup-nrhfx5b0.js} +3 -3
  15. package/dist/{claude-sdk-session-kkbxtw5q.js → claude-sdk-session-7x7chdrs.js} +7608 -7596
  16. package/dist/{cli-zsh093hk.js → cli-014vns2y.js} +63 -1
  17. package/dist/{cli-8kgvmkak.js → cli-0jngabtb.js} +1 -1
  18. package/dist/{cli-qvfk83fy.js → cli-13xwcqvj.js} +2 -2
  19. package/dist/{cli-0nhdwxd5.js → cli-43jg9xdw.js} +3 -3
  20. package/dist/{cli-cpfmqz3n.js → cli-47zfzshm.js} +5 -5
  21. package/dist/{cli-zy7vwsne.js → cli-5qkcdv8c.js} +16 -9
  22. package/dist/{cli-cwxhbf43.js → cli-5t5my0h8.js} +85 -2
  23. package/dist/{cli-y59h2pk9.js → cli-69egj654.js} +1 -1
  24. package/dist/{cli-68r1j4sg.js → cli-6bpvv8h4.js} +3 -3
  25. package/dist/{cli-yshaazs1.js → cli-74ez9b66.js} +63 -20
  26. package/dist/{cli-32bca7nr.js → cli-7avy4v8w.js} +1 -1
  27. package/dist/{cli-f2n17zhp.js → cli-7mhy3ths.js} +21 -18
  28. package/dist/{cli-q38n4bq0.js → cli-95zs84qw.js} +3 -3
  29. package/dist/{cli-fn697bnt.js → cli-9q0bh411.js} +21 -8
  30. package/dist/{cli-rggdq8wq.js → cli-9q24yyvs.js} +3 -3
  31. package/dist/cli-a35hzdve.js +33 -0
  32. package/dist/{cli-3qn4r334.js → cli-aj1e0h0d.js} +20 -14
  33. package/dist/{cli-rsy933yz.js → cli-bcspch6h.js} +30 -19
  34. package/dist/{cli-x3x0qwky.js → cli-bxqez1p0.js} +51 -23
  35. package/dist/{cli-ng97fjq7.js → cli-cmxkbzzs.js} +33 -7
  36. package/dist/{cli-df56tqdw.js → cli-ee0402n1.js} +198 -70
  37. package/dist/{cli-mwegs4af.js → cli-ew0c3a1j.js} +3 -3
  38. package/dist/{cli-ysnyhws9.js → cli-ewhwpyrb.js} +54 -51
  39. package/dist/{cli-j2jbr8af.js → cli-fhj4y16f.js} +1 -1
  40. package/dist/{cli-8t229cmr.js → cli-fqq06kkb.js} +30 -17
  41. package/dist/{cli-jvj6a0cv.js → cli-g6gx2dn1.js} +54 -19
  42. package/dist/{cli-nexwh8zg.js → cli-g7k7zbcz.js} +12 -5
  43. package/dist/{cli-ecknp7p1.js → cli-j05x7pnb.js} +10 -10
  44. package/dist/{cli-gt8azyet.js → cli-jb931ba2.js} +1 -1
  45. package/dist/{cli-nvvfd8bq.js → cli-jbzbr36n.js} +4091 -3793
  46. package/dist/{cli-3ddybbbx.js → cli-jvmtxqtc.js} +26 -14
  47. package/dist/{cli-pkke6geq.js → cli-km4x66j0.js} +83 -65
  48. package/dist/{cli-b0f70z24.js → cli-mn8qkd8v.js} +1 -1
  49. package/dist/{cli-0qjcsxgz.js → cli-nctfv2yy.js} +99 -9
  50. package/dist/{cli-4r1sjfc4.js → cli-ng1pmdx6.js} +10 -10
  51. package/dist/cli-nkqjj42q.js +104 -0
  52. package/dist/{cli-aqcey741.js → cli-p5jqk8fr.js} +1 -1
  53. package/dist/{cli-66zh26sp.js → cli-pgehz8e1.js} +57 -17
  54. package/dist/cli-q1vnz8wv.js +96 -0
  55. package/dist/{cli-fj86xr3c.js → cli-qd97v6z2.js} +2 -2
  56. package/dist/{cli-p4ztb80h.js → cli-r7eeddkq.js} +13 -8
  57. package/dist/{cli-8ssjez7e.js → cli-rhy7z1rp.js} +22 -11
  58. package/dist/{cli-vzc73wet.js → cli-rr5z1j4p.js} +87 -69
  59. package/dist/{cli-4mevd1hf.js → cli-s9y81s7j.js} +5 -5
  60. package/dist/{cli-dw2q9f7g.js → cli-svah6b6w.js} +4 -4
  61. package/dist/{cli-h3x613ze.js → cli-tc5zec7v.js} +6 -6
  62. package/dist/{cli-ttbsse3f.js → cli-tmhsxhb8.js} +1 -1
  63. package/dist/{cli-feh3dkah.js → cli-v1w7pbd8.js} +2 -1
  64. package/dist/{cli-10zmp1p7.js → cli-vr5avem2.js} +27 -32
  65. package/dist/{cli-bzr0ftht.js → cli-wnb9b2g3.js} +19 -0
  66. package/dist/{cli-krqm5f11.js → cli-xezn9nkh.js} +16 -13
  67. package/dist/{cli-8kt0bmgx.js → cli-xf713zz0.js} +2 -2
  68. package/dist/{cli-6zcfw0x7.js → cli-y195hv1a.js} +1 -1
  69. package/dist/{cli-x7kpr72a.js → cli-ytmmyj7f.js} +2 -2
  70. package/dist/{cli-v2a8x45d.js → cli-z48ggfyq.js} +11 -6
  71. package/dist/{cli-qbksc292.js → cli-zy39d433.js} +2 -0
  72. package/dist/cli.js +28 -28
  73. package/dist/{cloudflare-workers-ai-system-one-tt484t61.js → cloudflare-workers-ai-system-one-sta45jf9.js} +7 -5
  74. package/dist/{codex-adapter-dw1wxqt8.js → codex-adapter-n63k33d3.js} +18 -18
  75. package/dist/{codex-login-terbdsqq.js → codex-login-g1cyt5zm.js} +1 -1
  76. package/dist/{codex-session-runner-3cgb8y1g.js → codex-session-runner-xkxfd81a.js} +18 -18
  77. package/dist/{commands-mhxam6z2.js → commands-q4mfhn51.js} +9 -8
  78. package/dist/{cursor-adapter-2qda3st7.js → cursor-adapter-v8mm6bp5.js} +5 -5
  79. package/dist/{db-sjks418k.js → db-1t0bj3pc.js} +9 -8
  80. package/dist/{dispatcher-1es759rn.js → dispatcher-k1fhavcf.js} +24 -24
  81. package/dist/{dsh-adapter-bek4cyw9.js → dsh-adapter-5423p70m.js} +5 -5
  82. package/dist/{easter-egg-3d-mtpnw06s.js → easter-egg-3d-66zxervv.js} +2 -2
  83. package/dist/{execute-s4ccjq3n.js → execute-h4jgevyw.js} +61 -11
  84. package/dist/{google-generative-ai-d6nphg3q.js → google-generative-ai-b8zxrpax.js} +5 -5
  85. package/dist/{google-vertex-cpshkref.js → google-vertex-nrdd697t.js} +5 -5
  86. package/dist/{grok-adapter-ar9cw9jg.js → grok-adapter-phrndeb8.js} +18 -8
  87. package/dist/{handlers-1gkbhktr.js → handlers-59ywqwzh.js} +27 -27
  88. package/dist/{hook-vxa3tcsy.js → hook-atj0swc2.js} +14 -14
  89. package/dist/{hook-ds2zxq9v.js → hook-mew1chq9.js} +42 -31
  90. package/dist/{http-3h21z9md.js → http-ef2fnn9v.js} +953 -98
  91. package/dist/{index-n99fycq7.js → index-48pb9erw.js} +24 -24
  92. package/dist/{index-bn6cy8ps.js → index-9fc2h7ww.js} +24 -24
  93. package/dist/{index-9b65j1m5.js → index-gr69c43q.js} +9 -8
  94. package/dist/{index-f8xnqrye.js → index-h26wjcfy.js} +43 -24
  95. package/dist/{index-dkfxpkmm.js → index-w2yhye23.js} +25 -25
  96. package/dist/{keepalive-3xspa9jw.js → keepalive-qhcqz56v.js} +11 -10
  97. package/dist/{lead-6qphc124.js → lead-hpk5r3b1.js} +47 -47
  98. package/dist/{llama-cpp-classify-tgj35k8d.js → llama-cpp-classify-6erb7wwv.js} +3 -1
  99. package/dist/{maintenance-0teem1bb.js → maintenance-nvkyp2xe.js} +9 -8
  100. package/dist/{mistral-conversations-8445ddjn.js → mistral-conversations-059y51zt.js} +4 -4
  101. package/dist/{node-9sg7ajby.js → node-qm9eb6z0.js} +1 -1
  102. package/dist/{oauth-refresh-sweep-sh9q25xp.js → oauth-refresh-sweep-ngskthwh.js} +10 -9
  103. package/dist/{onboard-54qmnqxe.js → onboard-yfnkqhxc.js} +2 -2
  104. package/dist/{openai-codex-responses-9ynm88zy.js → openai-codex-responses-jzxkta9b.js} +8 -7
  105. package/dist/{openai-completions-nhqwjmaw.js → openai-completions-663yncfq.js} +4 -4
  106. package/dist/openai-decisions-vj9nkpf8.js +162 -0
  107. package/dist/{openai-responses-xx1c4kgm.js → openai-responses-m3g43rrt.js} +5 -5
  108. package/dist/{opencode-adapter-wjfa2wvd.js → opencode-adapter-yjyqynzf.js} +7 -7
  109. package/dist/{openrouter-images-8yx3487g.js → openrouter-images-kr60n6b9.js} +1 -1
  110. package/dist/{otel-impl-ycqzp2a1.js → otel-impl-hhm64r6m.js} +1 -1
  111. package/dist/{pi-messages-vxdt8v7j.js → pi-messages-xszk613n.js} +1 -1
  112. package/dist/{pi-mono-adapter-e77e9zst.js → pi-mono-adapter-0eg8kw2c.js} +68 -51
  113. package/dist/{pricing-refresh-atnkngqs.js → pricing-refresh-35f91nx2.js} +11 -10
  114. package/dist/{rbac-roles-j78wdksm.js → rbac-roles-97w370qj.js} +10 -9
  115. package/dist/{rbac-roles-9pjphh9q.js → rbac-roles-z6sje3gy.js} +9 -8
  116. package/dist/{render-v2-pxjs49fx.js → render-v2-n4v66bj3.js} +10 -9
  117. package/dist/{runtime-v2t1r79e.js → runtime-h9ha1mq6.js} +4 -4
  118. package/dist/{setup-ymej0xtx.js → setup-m3hhyv5f.js} +2 -2
  119. package/dist/{typesafe-system-one-datfr82p.js → typesafe-system-one-e988vty7.js} +7 -5
  120. package/dist/{virtual-modules-wm8mxdxx.js → virtual-modules-ftr9kwe8.js} +11 -11
  121. package/dist/{work-objects-3nh4ymzb.js → work-objects-mxf2c842.js} +27 -27
  122. package/dist/{worker-qa9fkz5s.js → worker-9t07z1gq.js} +47 -47
  123. package/openapi.json +72 -1
  124. package/package.json +10 -10
  125. package/src/be/memory/providers/openai-embedding.ts +7 -2
  126. package/src/be/memory/raters/llm-summarizer.ts +7 -2
  127. package/src/be/modelsdev-cache.json +61 -0
  128. package/src/be/script-connections.ts +40 -6
  129. package/src/be/seed-pricing.ts +84 -53
  130. package/src/be/swarm-config-guard.ts +14 -0
  131. package/src/commands/provider-credentials.ts +28 -5
  132. package/src/commands/runner.ts +7 -12
  133. package/src/extensions/catalog.generated.json +17 -0
  134. package/src/heartbeat/heartbeat.ts +91 -19
  135. package/src/hooks/hook.ts +72 -27
  136. package/src/http/mcp.ts +5 -0
  137. package/src/http/onboarding.ts +2 -0
  138. package/src/http/script-connections.ts +35 -0
  139. package/src/providers/acp-targets.ts +10 -5
  140. package/src/providers/amp-adapter.ts +1 -1
  141. package/src/providers/claude-adapter.ts +32 -0
  142. package/src/providers/claude-background-keepalive.ts +100 -0
  143. package/src/providers/claude-sdk-session.ts +6 -0
  144. package/src/providers/codex-adapter.ts +30 -3
  145. package/src/providers/codex-oauth/auth-json.ts +21 -4
  146. package/src/providers/codex-oauth/types.ts +2 -0
  147. package/src/providers/dsh-adapter.ts +11 -2
  148. package/src/providers/grok-adapter.ts +18 -2
  149. package/src/providers/opencode-adapter.ts +21 -5
  150. package/src/providers/pi-mono-extension.ts +56 -25
  151. package/src/providers/pricing-sources.md +2 -0
  152. package/src/scheduler/scheduler.ts +82 -13
  153. package/src/scripts-runtime/executors/registry.ts +1 -1
  154. package/src/server.ts +63 -3
  155. package/src/tests/acp-adapter.test.ts +152 -12
  156. package/src/tests/ci-unit-test-report.test.ts +73 -0
  157. package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
  158. package/src/tests/claude-background-keepalive.fixture.ts +162 -0
  159. package/src/tests/claude-background-keepalive.test.ts +139 -0
  160. package/src/tests/claude-default-route.test.ts +75 -0
  161. package/src/tests/claude-hook-output.test.ts +233 -0
  162. package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +4 -2
  163. package/src/tests/codex-adapter.test.ts +166 -26
  164. package/src/tests/codex-oauth-auth-timestamps.test.ts +76 -0
  165. package/src/tests/codex-oauth.test.ts +4 -1
  166. package/src/tests/connection-embedded-auth.test.ts +51 -0
  167. package/src/tests/context-window.test.ts +2 -2
  168. package/src/tests/credential-check.test.ts +51 -1
  169. package/src/tests/docs-index-footprint.test.js +3 -3
  170. package/src/tests/dsh-adapter.test.ts +24 -2
  171. package/src/tests/enabled-tools.test.ts +269 -0
  172. package/src/tests/entrypoint-codex-oauth-seed.test.ts +12 -0
  173. package/src/tests/extensions-catalog.test.ts +1 -0
  174. package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
  175. package/src/tests/grok-adapter.test.ts +13 -0
  176. package/src/tests/haiku-5-5-pricing.test.ts +38 -0
  177. package/src/tests/heartbeat-checklist.test.ts +158 -1
  178. package/src/tests/onboarding.test.ts +44 -0
  179. package/src/tests/opencode-adapter.test.ts +66 -3
  180. package/src/tests/opencode-plugin.test.ts +37 -0
  181. package/src/tests/openrouter-attribution-call-sites.test.ts +204 -0
  182. package/src/tests/openrouter-base-url.test.ts +30 -39
  183. package/src/tests/pi-mono-extension.test.ts +161 -2
  184. package/src/tests/pricing-refresh.test.ts +70 -1
  185. package/src/tests/reasoning-effort.test.ts +3 -1
  186. package/src/tests/schedule-target-type.test.ts +136 -0
  187. package/src/tests/script-connections-http.test.ts +45 -0
  188. package/src/tests/script-connections-mcp.test.ts +41 -0
  189. package/src/tests/script-executor-registry.test.ts +18 -0
  190. package/src/tests/send-task-self-send.test.ts +107 -0
  191. package/src/tests/telemetry-trigger.test.ts +12 -1
  192. package/src/tests/workflow-llm.test.ts +14 -0
  193. package/src/tests/workflow-system-one-decision-providers.test.ts +33 -0
  194. package/src/tools/request-human-input.ts +7 -2
  195. package/src/tools/script-connections/tool.ts +35 -3
  196. package/src/tools/send-task.ts +9 -1
  197. package/src/utils/enabled-tools.ts +27 -0
  198. package/src/utils/internal-ai/complete-structured.ts +11 -3
  199. package/src/utils/openrouter-base-url.ts +23 -0
  200. package/src/workflows/executors/raw-llm.ts +1 -0
  201. package/src/workflows/executors/system-one-decision.ts +2 -0
  202. package/src/workflows/executors/validate.ts +1 -0
  203. package/src/workflows/executors/workflow-llm.ts +9 -2
  204. package/templates/extensions/README.md +1 -0
  205. package/templates/extensions/slack-reply-gate/README.md +98 -0
  206. package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
  207. package/templates/extensions/slack-reply-gate/manifest.json +8 -0
  208. package/templates/skills/agent-browser/SKILL.md +2 -0
  209. package/templates/skills/agent-browser/content.md +2 -0
  210. package/templates/skills/code-quality/content.md +1 -0
  211. package/templates/skills/heartbeat-runbook/content.md +1 -1
  212. package/templates/skills/swarm-scripts/SKILL.md +10 -0
  213. package/templates/skills/swarm-scripts/content.md +10 -0
  214. package/templates/skills/work-on-task/SKILL.md +4 -0
  215. package/templates/skills/work-on-task/content.md +4 -0
  216. package/templates/skills/workflow-iterate/content.md +9 -0
  217. package/dist/acp-adapter-yzfxn8vj.js +0 -23
  218. package/dist/cli-3dpj7atn.js +0 -11
  219. package/dist/cli-gvcgmx69.js +0 -183
  220. package/dist/{cli-jhm6hzjn.js → cli-mxvnnv5q.js} +7 -7
  221. package/dist/{codex-hook-d2rkvf39.js → codex-hook-15dgc4cb.js} +1 -1
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import type { Usage } from "@agentclientprotocol/sdk";
@@ -64,20 +64,29 @@ function baseConfig(overrides: Partial<ProviderSessionConfig> = {}): ProviderSes
64
64
 
65
65
  /**
66
66
  * Start a minimal swarm API stub that handles POST /api/sessions/tokens and
67
- * DELETE /api/sessions/tokens/:id. Returns the server and its base URL.
68
- * The caller is responsible for closing the server.
67
+ * DELETE /api/sessions/tokens/:id. Returns its base URL and `close()`, which
68
+ * the caller must await. The adapter revokes a minted token fire-and-forget,
69
+ * so closing before that DELETE lands fails it and leaks a revoke warning
70
+ * into a later test.
69
71
  */
70
72
  async function startTokenStubServer(
71
73
  tokenId: string,
72
74
  plaintext: string,
73
- ): Promise<{ server: Server; apiUrl: string }> {
75
+ ): Promise<{ apiUrl: string; close: () => Promise<void> }> {
76
+ let minted = false;
77
+ let resolveRevoked!: () => void;
78
+ const revoked = new Promise<void>((resolve) => {
79
+ resolveRevoked = resolve;
80
+ });
74
81
  const stub = createServer(async (req: IncomingMessage, res: ServerResponse) => {
75
82
  if (req.method === "POST" && req.url === "/api/sessions/tokens") {
83
+ minted = true;
76
84
  res.writeHead(200, { "Content-Type": "application/json" });
77
85
  res.end(JSON.stringify({ tokenId, plaintext }));
78
86
  } else if (req.method === "DELETE" && req.url?.startsWith("/api/sessions/tokens/")) {
79
87
  res.writeHead(204);
80
88
  res.end();
89
+ resolveRevoked();
81
90
  } else {
82
91
  res.writeHead(404);
83
92
  res.end();
@@ -85,7 +94,13 @@ async function startTokenStubServer(
85
94
  });
86
95
  await new Promise<void>((resolve) => stub.listen(0, "127.0.0.1", resolve));
87
96
  const addr = stub.address() as import("net").AddressInfo;
88
- return { server: stub, apiUrl: `http://127.0.0.1:${addr.port}` };
97
+ const close = async () => {
98
+ // A minted token is always revoked; wait for it, bounded so a failed test
99
+ // still tears down.
100
+ if (minted) await Promise.race([revoked, Bun.sleep(5_000)]);
101
+ await new Promise<void>((resolve) => stub.close(() => resolve()));
102
+ };
103
+ return { apiUrl: `http://127.0.0.1:${addr.port}`, close };
89
104
  }
90
105
 
91
106
  describe("ACPAdapter", () => {
@@ -305,7 +320,7 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
305
320
  `,
306
321
  );
307
322
 
308
- const { server: tokenStub, apiUrl } = await startTokenStubServer(
323
+ const { close: closeTokenStub, apiUrl } = await startTokenStubServer(
309
324
  "stub-token-id",
310
325
  "aseph_stubtokenfortest1234567890",
311
326
  );
@@ -503,7 +518,7 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
503
518
  closeDb();
504
519
  }
505
520
  } finally {
506
- await new Promise<void>((resolve) => tokenStub.close(() => resolve()));
521
+ await closeTokenStub();
507
522
  }
508
523
  });
509
524
 
@@ -537,10 +552,132 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
537
552
  ),
538
553
  ).toMatchObject({
539
554
  OPENAI_API_KEY: "example-test-key",
540
- OPENCODE_CONFIG_CONTENT: JSON.stringify({ theme: "dark", model: "opencode/model" }),
555
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
556
+ theme: "dark",
557
+ model: "opencode/model",
558
+ provider: {
559
+ openrouter: {
560
+ options: {
561
+ headers: {
562
+ "HTTP-Referer": "https://agent-swarm.dev",
563
+ "X-OpenRouter-Title": "Agent Swarm",
564
+ "X-OpenRouter-Categories": "personal-agent,cloud-agent",
565
+ "X-Title": "Agent Swarm",
566
+ },
567
+ },
568
+ },
569
+ },
570
+ }),
541
571
  });
542
572
  });
543
573
 
574
+ test("OpenCode preset merges the OpenRouter gateway with model and provider config", () => {
575
+ const config = baseConfig({
576
+ model: " openrouter/openai/gpt-4o-mini ",
577
+ env: {
578
+ ACP_TARGET: "opencode",
579
+ OPENROUTER_BASE_URL: " http://127.0.0.1:8080/api/v1/ ",
580
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
581
+ theme: "dark",
582
+ provider: { openrouter: { options: { apiKey: "dummy" } } },
583
+ }),
584
+ },
585
+ });
586
+ const env = resolveAcpTarget(config).env(config);
587
+ expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT!)).toEqual({
588
+ theme: "dark",
589
+ model: "openrouter/openai/gpt-4o-mini",
590
+ provider: {
591
+ openrouter: { options: { apiKey: "dummy", baseURL: "http://127.0.0.1:8080/api/v1" } },
592
+ },
593
+ });
594
+ });
595
+
596
+ test("OpenCode preset omits gateway override for unset, blank, or default URL", () => {
597
+ for (const url of [undefined, "", "https://openrouter.ai/api/v1/"]) {
598
+ const config = baseConfig({
599
+ env: { ACP_TARGET: "opencode", OPENROUTER_BASE_URL: url },
600
+ });
601
+ const env = resolveAcpTarget(config).env(config);
602
+ const content = JSON.parse(env.OPENCODE_CONFIG_CONTENT!);
603
+ expect(content).toEqual({
604
+ model: config.model,
605
+ provider: {
606
+ openrouter: {
607
+ options: {
608
+ headers: {
609
+ "HTTP-Referer": "https://agent-swarm.dev",
610
+ "X-OpenRouter-Title": "Agent Swarm",
611
+ "X-OpenRouter-Categories": "personal-agent,cloud-agent",
612
+ "X-Title": "Agent Swarm",
613
+ },
614
+ },
615
+ },
616
+ },
617
+ });
618
+ expect(content.provider.openrouter.options.baseURL).toBeUndefined();
619
+ }
620
+ });
621
+
622
+ test("OpenCode preset adds attribution headers to OPENCODE_CONFIG_CONTENT for an OpenRouter host", () => {
623
+ const config = baseConfig({
624
+ model: "",
625
+ env: {
626
+ ACP_TARGET: "opencode",
627
+ OPENROUTER_BASE_URL: "https://openrouter.ai/api/v1",
628
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({
629
+ theme: "dark",
630
+ provider: { openrouter: { options: { headers: { "X-Custom": "preserved" } } } },
631
+ }),
632
+ },
633
+ });
634
+ const env = resolveAcpTarget(config).env(config);
635
+ expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT!)).toEqual({
636
+ theme: "dark",
637
+ provider: {
638
+ openrouter: {
639
+ options: {
640
+ headers: {
641
+ "X-Custom": "preserved",
642
+ "HTTP-Referer": "https://agent-swarm.dev",
643
+ "X-OpenRouter-Title": "Agent Swarm",
644
+ "X-OpenRouter-Categories": "personal-agent,cloud-agent",
645
+ "X-Title": "Agent Swarm",
646
+ },
647
+ },
648
+ },
649
+ },
650
+ });
651
+ });
652
+
653
+ test("OpenCode preset leaves OPENCODE_CONFIG_CONTENT unchanged when attribution is disabled", () => {
654
+ const content = JSON.stringify({
655
+ theme: "dark",
656
+ provider: { openrouter: { options: { headers: { "X-Custom": "preserved" } } } },
657
+ });
658
+ const config = baseConfig({
659
+ model: "",
660
+ env: {
661
+ ACP_TARGET: "opencode",
662
+ OPENROUTER_BASE_URL: "https://openrouter.ai/api/v1",
663
+ OPENROUTER_APP_ATTRIBUTION: "false",
664
+ OPENCODE_CONFIG_CONTENT: content,
665
+ },
666
+ });
667
+ expect(resolveAcpTarget(config).env(config).OPENCODE_CONFIG_CONTENT).toBe(content);
668
+ });
669
+
670
+ test("OpenCode preset injects gateway even without a model fallback", () => {
671
+ const config = baseConfig({
672
+ model: "",
673
+ env: { ACP_TARGET: "opencode", OPENROUTER_BASE_URL: "http://127.0.0.1:8080/api/v1" },
674
+ });
675
+ const env = resolveAcpTarget(config).env(config);
676
+ expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT!).provider.openrouter.options.baseURL).toBe(
677
+ "http://127.0.0.1:8080/api/v1",
678
+ );
679
+ });
680
+
544
681
  test("Gemini preset supplies command, credentials, trust, and a model environment fallback", () => {
545
682
  const config = baseConfig({
546
683
  model: "gemini-3-flash-preview",
@@ -687,7 +824,10 @@ new AgentSideConnection((connection) => new FakeCopilot(connection), stream);
687
824
  chmodSync(copilotStub, 0o755);
688
825
  mkdirSync(join(binDir, "home"));
689
826
 
690
- const { server, apiUrl } = await startTokenStubServer("copilot-token-id", "example-aseph_x");
827
+ const { close: closeTokenStub, apiUrl } = await startTokenStubServer(
828
+ "copilot-token-id",
829
+ "example-aseph_x",
830
+ );
691
831
  try {
692
832
  const config = baseConfig({
693
833
  cwd,
@@ -721,7 +861,7 @@ new AgentSideConnection((connection) => new FakeCopilot(connection), stream);
721
861
  while (existsSync(captured.dir) && Date.now() < deadline) await Bun.sleep(10);
722
862
  expect(existsSync(captured.dir)).toBe(false);
723
863
  } finally {
724
- await new Promise<void>((resolve) => server.close(() => resolve()));
864
+ await closeTokenStub();
725
865
  }
726
866
  });
727
867
 
@@ -1077,7 +1217,7 @@ const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(proce
1077
1217
  new AgentSideConnection((connection) => new FakeAgent(connection), stream);
1078
1218
  `,
1079
1219
  );
1080
- const { server: tokenStub, apiUrl } = await startTokenStubServer(
1220
+ const { close: closeTokenStub, apiUrl } = await startTokenStubServer(
1081
1221
  "stub-token-id",
1082
1222
  "aseph_stubtokenfortest1234567890",
1083
1223
  );
@@ -1140,7 +1280,7 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
1140
1280
  closeDb();
1141
1281
  }
1142
1282
  } finally {
1143
- await new Promise<void>((resolve) => tokenStub.close(() => resolve()));
1283
+ await closeTokenStub();
1144
1284
  }
1145
1285
  });
1146
1286
  });
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildPayload, parseJunit } from "../../scripts/ci-unit-test-report";
3
+
4
+ // Shape emitted by `bun test --reporter=junit` (bun 1.4): one testsuite per file,
5
+ // nested testsuites per describe, and attribute values XML-escaped.
6
+ const JUNIT = `<?xml version="1.0" encoding="UTF-8"?>
7
+ <testsuites name="bun test" tests="6" assertions="5" failures="1" skipped="2" time="0.3">
8
+ <testsuite name="src/a.test.ts" file="src/a.test.ts" tests="4" failures="1" skipped="1" time="0.2">
9
+ <testsuite name="outer" file="src/a.test.ts" line="3" tests="4" time="0">
10
+ <testsuite name="inner &amp; &quot;nested&quot;" file="src/a.test.ts" line="4" tests="2" time="0">
11
+ <testcase name="slow one" classname="inner" time="0.120" file="src/a.test.ts" line="5" assertions="1" />
12
+ <testcase name="breaks &lt;sometimes&gt;" classname="inner" time="0.004" file="src/a.test.ts" line="6" assertions="1">
13
+ <failure type="AssertionError" message="expect(received).toBe(expected)&#10;">AssertionError&#10;</failure>
14
+ </testcase>
15
+ </testsuite>
16
+ <testcase name="fast" classname="outer" time="0.000010" file="src/a.test.ts" line="8" assertions="1" />
17
+ <testcase name="later" classname="outer" time="0" file="src/a.test.ts" line="9" assertions="0">
18
+ <skipped message="TODO" />
19
+ </testcase>
20
+ </testsuite>
21
+ </testsuite>
22
+ <testsuite name="src/b.test.ts" file="src/b.test.ts" tests="2" failures="0" skipped="1" time="0.1">
23
+ <testcase name="top level" classname="" time="0.06" file="src/b.test.ts" line="2" assertions="2" />
24
+ <testcase name="skipped" classname="" time="0" file="src/b.test.ts" line="3" assertions="0">
25
+ <skipped />
26
+ </testcase>
27
+ </testsuite>
28
+ </testsuites>`;
29
+
30
+ describe("ci-unit-test-report", () => {
31
+ test("parses describe chains, statuses, durations and XML entities", () => {
32
+ expect(parseJunit(JUNIT)).toEqual([
33
+ {
34
+ file: "src/a.test.ts",
35
+ name: 'outer > inner & "nested" > slow one',
36
+ ms: 120,
37
+ status: "pass",
38
+ },
39
+ {
40
+ file: "src/a.test.ts",
41
+ name: 'outer > inner & "nested" > breaks <sometimes>',
42
+ ms: 4,
43
+ status: "fail",
44
+ },
45
+ { file: "src/a.test.ts", name: "outer > fast", ms: 0, status: "pass" },
46
+ { file: "src/a.test.ts", name: "outer > later", ms: 0, status: "skip" },
47
+ { file: "src/b.test.ts", name: "top level", ms: 60, status: "pass" },
48
+ { file: "src/b.test.ts", name: "skipped", ms: 0, status: "skip" },
49
+ ]);
50
+ });
51
+
52
+ test("keeps every non-pass and every test at or above minMs, with per-file totals", () => {
53
+ const payload = buildPayload(parseJunit(JUNIT), new Map([["src/a.test.ts", "abc123"]]), 50);
54
+ expect(payload.totals).toEqual({ tests: 6, pass: 3, fail: 1, skip: 2, testMs: 184 });
55
+ expect(payload.files).toEqual([
56
+ ["src/a.test.ts", "abc123", 4, 1, 124],
57
+ ["src/b.test.ts", null, 2, 0, 60],
58
+ ]);
59
+ expect(payload.tests).toEqual([
60
+ [0, 'outer > inner & "nested" > slow one', 120, "p"],
61
+ [0, 'outer > inner & "nested" > breaks <sometimes>', 4, "f"],
62
+ [0, "outer > later", 0, "s"],
63
+ [1, "top level", 60, "p"],
64
+ [1, "skipped", 0, "s"],
65
+ ]);
66
+ });
67
+
68
+ test("an empty report parses to no tests", () => {
69
+ expect(
70
+ parseJunit('<?xml version="1.0"?><testsuites name="bun test" tests="0"></testsuites>'),
71
+ ).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,220 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { appendFile, mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ClaudeAdapter } from "../providers/claude-adapter";
6
+ import { BACKGROUND_KEEPALIVE_INTERVAL_MS } from "../providers/claude-background-keepalive";
7
+ import type { ProviderResult, ProviderSession, ProviderSessionConfig } from "../providers/types";
8
+ import { CHILD_PROCESS_TEST_BUDGET_MS } from "./test-proc";
9
+
10
+ // These tests go through ClaudeAdapter.createSession, so they cover the wiring
11
+ // in each transport: observe() on every parsed message and stop() at teardown.
12
+ // A fake Claude binary ends its turn and goes silent; a local server records the
13
+ // session heartbeats the keepalive sends while it waits.
14
+
15
+ const fixturePath = join(import.meta.dir, "claude-background-keepalive.fixture.ts");
16
+ const TICK_MS = 20;
17
+
18
+ // The keepalive pings every 60s. Run its interval at TICK_MS instead; every
19
+ // other timer keeps its real period.
20
+ const realSetInterval = globalThis.setInterval;
21
+ beforeAll(() => {
22
+ globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...rest: unknown[]) =>
23
+ realSetInterval(
24
+ handler,
25
+ timeout === BACKGROUND_KEEPALIVE_INTERVAL_MS ? TICK_MS : timeout,
26
+ ...rest,
27
+ )) as typeof setInterval;
28
+ });
29
+ afterAll(() => {
30
+ globalThis.setInterval = realSetInterval;
31
+ });
32
+
33
+ const heartbeats = new Map<string, number>();
34
+ const server = Bun.serve({
35
+ port: 0,
36
+ fetch(req) {
37
+ const match = new URL(req.url).pathname.match(/^\/api\/active-sessions\/heartbeat\/(.+)$/);
38
+ if (req.method === "PUT" && match?.[1]) {
39
+ const taskId = decodeURIComponent(match[1]);
40
+ heartbeats.set(taskId, (heartbeats.get(taskId) ?? 0) + 1);
41
+ }
42
+ return Response.json({ updated: true });
43
+ },
44
+ });
45
+ afterAll(() => server.stop(true));
46
+
47
+ const temporaryDirectories: string[] = [];
48
+ let originalHome: string | undefined;
49
+
50
+ beforeEach(async () => {
51
+ // Every session seeds Claude trust into `$HOME/.claude.json`; keep that off the real home.
52
+ originalHome = process.env.HOME;
53
+ const home = await mkdtemp(join(tmpdir(), "claude-bg-keepalive-home-"));
54
+ temporaryDirectories.push(home);
55
+ process.env.HOME = home;
56
+ });
57
+
58
+ afterEach(async () => {
59
+ if (originalHome === undefined) delete process.env.HOME;
60
+ else process.env.HOME = originalHome;
61
+ await Promise.all(
62
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
63
+ );
64
+ });
65
+
66
+ type Transport = "cli" | "sdk";
67
+
68
+ interface Harness {
69
+ session: ProviderSession;
70
+ taskId: string;
71
+ send(command: "empty" | "exit" | "fail"): Promise<void>;
72
+ count(): number;
73
+ completed(): boolean;
74
+ turnEnded(): Promise<void>;
75
+ }
76
+
77
+ async function startSession(transport: Transport, withBackgroundTask: boolean): Promise<Harness> {
78
+ const directory = await mkdtemp(join(tmpdir(), "claude-bg-keepalive-"));
79
+ temporaryDirectories.push(directory);
80
+ const controlFile = join(directory, "control");
81
+ const logFile = join(directory, "session.jsonl");
82
+ const taskId = crypto.randomUUID();
83
+ const config: ProviderSessionConfig = {
84
+ prompt: "wait on the background job",
85
+ systemPrompt: "",
86
+ model: "claude-haiku-4-5",
87
+ role: "worker",
88
+ agentId: crypto.randomUUID(),
89
+ taskId,
90
+ apiUrl: `http://127.0.0.1:${server.port}`,
91
+ apiKey: "test-key",
92
+ cwd: directory,
93
+ logFile,
94
+ env: {
95
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
96
+ CLAUDE_CODE_OAUTH_TOKEN: "example-fixture-oauth-token",
97
+ CLAUDE_TRANSPORT: transport,
98
+ CLAUDE_BINARY: `${process.execPath} ${fixturePath}`,
99
+ BG_FIXTURE_TRANSPORT: transport,
100
+ BG_FIXTURE_CONTROL: controlFile,
101
+ BG_FIXTURE_BACKGROUND: withBackgroundTask ? "1" : "0",
102
+ },
103
+ };
104
+ const session = await new ClaudeAdapter(async () => {}).createSession(config);
105
+ let completed = false;
106
+ void session.waitForCompletion().finally(() => {
107
+ completed = true;
108
+ });
109
+ return {
110
+ session,
111
+ taskId,
112
+ completed: () => completed,
113
+ send: (command) => appendFile(controlFile, `${command}\n`),
114
+ count: () => heartbeats.get(taskId) ?? 0,
115
+ // Both transports log every protocol line; the result line marks the end of the turn.
116
+ turnEnded: () =>
117
+ waitFor(async () => {
118
+ const file = Bun.file(logFile);
119
+ return (await file.exists()) && (await file.text()).includes('"type":"result"');
120
+ }, "the fixture's result message"),
121
+ };
122
+ }
123
+
124
+ async function waitFor(
125
+ condition: () => boolean | Promise<boolean>,
126
+ what: string,
127
+ timeoutMs = 5_000,
128
+ ): Promise<void> {
129
+ const deadline = Date.now() + timeoutMs;
130
+ while (!(await condition())) {
131
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${what}`);
132
+ await Bun.sleep(TICK_MS / 2);
133
+ }
134
+ }
135
+
136
+ /** Waits out in-flight requests, then asserts no further heartbeat arrives. */
137
+ async function expectHeartbeatsStopped(harness: Harness): Promise<void> {
138
+ await Bun.sleep(TICK_MS * 3);
139
+ const settled = harness.count();
140
+ await Bun.sleep(TICK_MS * 8);
141
+ expect(harness.count()).toBe(settled);
142
+ }
143
+
144
+ async function liveSessionWithHeartbeats(transport: Transport): Promise<Harness> {
145
+ const harness = await startSession(transport, true);
146
+ await harness.turnEnded();
147
+ // The turn is over and the process is silent; only the keepalive is sending.
148
+ await waitFor(() => harness.count() >= 3, "keepalive heartbeats after the turn ended");
149
+ return harness;
150
+ }
151
+
152
+ describe.each(["cli", "sdk"] as const)("Claude %s transport background keepalive", (transport) => {
153
+ test(
154
+ "heartbeats while a background task stays live after the turn ends, and stops when the set empties",
155
+ async () => {
156
+ const harness = await liveSessionWithHeartbeats(transport);
157
+ try {
158
+ await harness.send("empty");
159
+ await expectHeartbeatsStopped(harness);
160
+ // The session is still running: the empty set stopped the pings, not teardown.
161
+ expect(harness.completed()).toBe(false);
162
+ } finally {
163
+ await harness.send("exit");
164
+ await harness.session.waitForCompletion();
165
+ }
166
+ },
167
+ CHILD_PROCESS_TEST_BUDGET_MS,
168
+ );
169
+
170
+ test(
171
+ "normal end stops the keepalive",
172
+ async () => {
173
+ const harness = await liveSessionWithHeartbeats(transport);
174
+ await harness.send("exit");
175
+ const result = await harness.session.waitForCompletion();
176
+ expect(result.isError).toBe(false);
177
+ await expectHeartbeatsStopped(harness);
178
+ },
179
+ CHILD_PROCESS_TEST_BUDGET_MS,
180
+ );
181
+
182
+ test(
183
+ "error end stops the keepalive",
184
+ async () => {
185
+ const harness = await liveSessionWithHeartbeats(transport);
186
+ await harness.send("fail");
187
+ const result: ProviderResult = await harness.session.waitForCompletion();
188
+ expect(result.exitCode).not.toBe(0);
189
+ await expectHeartbeatsStopped(harness);
190
+ },
191
+ CHILD_PROCESS_TEST_BUDGET_MS,
192
+ );
193
+
194
+ test(
195
+ "abort stops the keepalive",
196
+ async () => {
197
+ const harness = await liveSessionWithHeartbeats(transport);
198
+ await harness.session.abort();
199
+ await harness.session.waitForCompletion();
200
+ await expectHeartbeatsStopped(harness);
201
+ },
202
+ CHILD_PROCESS_TEST_BUDGET_MS,
203
+ );
204
+
205
+ test(
206
+ "a session with no background work sends no keepalive heartbeats",
207
+ async () => {
208
+ const harness = await startSession(transport, false);
209
+ try {
210
+ await harness.turnEnded();
211
+ await Bun.sleep(TICK_MS * 10);
212
+ expect(harness.count()).toBe(0);
213
+ } finally {
214
+ await harness.send("exit");
215
+ await harness.session.waitForCompletion();
216
+ }
217
+ },
218
+ CHILD_PROCESS_TEST_BUDGET_MS,
219
+ );
220
+ });