@desplega.ai/agent-swarm 1.166.0 → 1.167.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 (194) hide show
  1. package/dist/acp-adapter-zpvy6321.js +34 -0
  2. package/dist/{actions-qz3m692w.js → actions-fq2zrwtf.js} +25 -25
  3. package/dist/{amp-adapter-j1xbmsqz.js → amp-adapter-5gbk7501.js} +4 -4
  4. package/dist/{anthropic-messages-hst62ws9.js → anthropic-messages-13yn502s.js} +4 -4
  5. package/dist/{app-43vrdmv8.js → app-nctetn1e.js} +10 -9
  6. package/dist/{assistant-ertffmka.js → assistant-0aq8gjvj.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-1ze5853n.js} +9 -8
  9. package/dist/{boot-reembed-s7k7c6v2.js → boot-reembed-fmp5ww69.js} +9 -8
  10. package/dist/{boot-scrub-sweep-nepsk4t1.js → boot-scrub-sweep-cbp8xd4e.js} +10 -9
  11. package/dist/{boot-seeds-vvzs1zhk.js → boot-seeds-22tx8a53.js} +12 -11
  12. package/dist/{claude-adapter-2zvmekrp.js → claude-adapter-0jhgeh1d.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-wh77rh83.js} +22 -22
  16. package/dist/{cli-zsh093hk.js → cli-014vns2y.js} +63 -1
  17. package/dist/{cli-qvfk83fy.js → cli-13xwcqvj.js} +2 -2
  18. package/dist/{cli-dw2q9f7g.js → cli-1yrcfa5s.js} +4 -4
  19. package/dist/{cli-jvj6a0cv.js → cli-577bxkf6.js} +27 -17
  20. package/dist/{cli-zy7vwsne.js → cli-5qkcdv8c.js} +16 -9
  21. package/dist/{cli-cwxhbf43.js → cli-5t5my0h8.js} +85 -2
  22. package/dist/{cli-y59h2pk9.js → cli-69egj654.js} +1 -1
  23. package/dist/{cli-68r1j4sg.js → cli-6bpvv8h4.js} +3 -3
  24. package/dist/{cli-cpfmqz3n.js → cli-6qxmjpem.js} +5 -5
  25. package/dist/{cli-aqcey741.js → cli-6vfz44sr.js} +1 -1
  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-a35hzdve.js +33 -0
  30. package/dist/{cli-x3x0qwky.js → cli-bxqez1p0.js} +51 -23
  31. package/dist/{cli-gt8azyet.js → cli-c2tpw9ck.js} +1 -1
  32. package/dist/{cli-ecknp7p1.js → cli-c8r99akr.js} +10 -10
  33. package/dist/{cli-3qn4r334.js → cli-cexctgn5.js} +20 -14
  34. package/dist/{cli-vzc73wet.js → cli-cf2vgn9d.js} +87 -69
  35. package/dist/{cli-ng97fjq7.js → cli-cmxkbzzs.js} +33 -7
  36. package/dist/{cli-nexwh8zg.js → cli-g7k7zbcz.js} +12 -5
  37. package/dist/{cli-nvvfd8bq.js → cli-jbzbr36n.js} +4091 -3793
  38. package/dist/{cli-3ddybbbx.js → cli-jvmtxqtc.js} +26 -14
  39. package/dist/{cli-pkke6geq.js → cli-km4x66j0.js} +83 -65
  40. package/dist/{cli-j2jbr8af.js → cli-kw0jcfje.js} +1 -1
  41. package/dist/{cli-8t229cmr.js → cli-m7vmeepg.js} +30 -17
  42. package/dist/{cli-yshaazs1.js → cli-mkdsy1nq.js} +62 -20
  43. package/dist/{cli-b0f70z24.js → cli-mn8qkd8v.js} +1 -1
  44. package/dist/{cli-0qjcsxgz.js → cli-mxry91sa.js} +30 -9
  45. package/dist/cli-nkqjj42q.js +104 -0
  46. package/dist/{cli-rggdq8wq.js → cli-p8rt8spp.js} +3 -3
  47. package/dist/{cli-v2a8x45d.js → cli-pkj1w46r.js} +10 -5
  48. package/dist/cli-q1vnz8wv.js +96 -0
  49. package/dist/{cli-fj86xr3c.js → cli-qd97v6z2.js} +2 -2
  50. package/dist/{cli-66zh26sp.js → cli-qzmzwegq.js} +57 -17
  51. package/dist/{cli-p4ztb80h.js → cli-r7eeddkq.js} +13 -8
  52. package/dist/{cli-8ssjez7e.js → cli-rhy7z1rp.js} +22 -11
  53. package/dist/{cli-4mevd1hf.js → cli-s9y81s7j.js} +5 -5
  54. package/dist/{cli-h3x613ze.js → cli-tc5zec7v.js} +6 -6
  55. package/dist/{cli-ttbsse3f.js → cli-tmhsxhb8.js} +1 -1
  56. package/dist/{cli-0nhdwxd5.js → cli-tx2zvn23.js} +3 -3
  57. package/dist/{cli-mwegs4af.js → cli-ty4bzkqr.js} +3 -3
  58. package/dist/{cli-ysnyhws9.js → cli-v1pwxreq.js} +54 -51
  59. package/dist/{cli-feh3dkah.js → cli-v1w7pbd8.js} +2 -1
  60. package/dist/{cli-10zmp1p7.js → cli-vr5avem2.js} +27 -32
  61. package/dist/{cli-bzr0ftht.js → cli-wnb9b2g3.js} +19 -0
  62. package/dist/{cli-df56tqdw.js → cli-wydz3feg.js} +108 -57
  63. package/dist/{cli-krqm5f11.js → cli-xezn9nkh.js} +16 -13
  64. package/dist/{cli-8kt0bmgx.js → cli-xf713zz0.js} +2 -2
  65. package/dist/{cli-x7kpr72a.js → cli-y0kjpfm9.js} +2 -2
  66. package/dist/{cli-6zcfw0x7.js → cli-y195hv1a.js} +1 -1
  67. package/dist/{cli-fn697bnt.js → cli-ykt8f7va.js} +21 -8
  68. package/dist/{cli-4r1sjfc4.js → cli-ymmc1zxg.js} +8 -8
  69. package/dist/{cli-8kgvmkak.js → cli-ypgba1a8.js} +1 -1
  70. package/dist/{cli-rsy933yz.js → cli-z1dqhc9y.js} +30 -19
  71. package/dist/{cli-qbksc292.js → cli-zy39d433.js} +2 -0
  72. package/dist/cli.js +26 -26
  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-cgesnjkr.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-8kd7ddhr.js} +18 -18
  77. package/dist/{commands-mhxam6z2.js → commands-h6zaaxym.js} +9 -8
  78. package/dist/{cursor-adapter-2qda3st7.js → cursor-adapter-v8mm6bp5.js} +5 -5
  79. package/dist/{db-sjks418k.js → db-qg3myn53.js} +9 -8
  80. package/dist/{dispatcher-1es759rn.js → dispatcher-7vnwj9qw.js} +24 -24
  81. package/dist/{dsh-adapter-bek4cyw9.js → dsh-adapter-tz6hfp5r.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-jdt2dz2s.js} +18 -8
  87. package/dist/{handlers-1gkbhktr.js → handlers-2qtkpncr.js} +27 -27
  88. package/dist/{hook-ds2zxq9v.js → hook-51yfcgsv.js} +42 -31
  89. package/dist/{hook-vxa3tcsy.js → hook-qnbdgwvd.js} +14 -14
  90. package/dist/{http-3h21z9md.js → http-jsmtag08.js} +320 -98
  91. package/dist/{index-9b65j1m5.js → index-2t9kesn1.js} +9 -8
  92. package/dist/{index-dkfxpkmm.js → index-bt8wkv1w.js} +25 -25
  93. package/dist/{index-f8xnqrye.js → index-cx1sb9bq.js} +33 -24
  94. package/dist/{index-bn6cy8ps.js → index-ekywyv2v.js} +24 -24
  95. package/dist/{index-n99fycq7.js → index-t4g06w93.js} +24 -24
  96. package/dist/{keepalive-3xspa9jw.js → keepalive-4aezx4bj.js} +11 -10
  97. package/dist/{lead-6qphc124.js → lead-r44bkpm5.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-grz5jrzh.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-wm4ctjfj.js} +10 -9
  103. package/dist/{onboard-54qmnqxe.js → onboard-v60zgqf0.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-ghh4dyf8.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-mjrqvnvw.js} +11 -10
  114. package/dist/{rbac-roles-j78wdksm.js → rbac-roles-axv4d74x.js} +10 -9
  115. package/dist/{rbac-roles-9pjphh9q.js → rbac-roles-fd5tskj9.js} +9 -8
  116. package/dist/{render-v2-pxjs49fx.js → render-v2-k50j33qd.js} +10 -9
  117. package/dist/{runtime-v2t1r79e.js → runtime-h9ha1mq6.js} +4 -4
  118. package/dist/{setup-ymej0xtx.js → setup-4syh0pd2.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-jcdbg8rr.js} +27 -27
  122. package/dist/{worker-qa9fkz5s.js → worker-x8j8c9sx.js} +47 -47
  123. package/openapi.json +1 -1
  124. package/package.json +8 -8
  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/seed-pricing.ts +84 -53
  129. package/src/be/swarm-config-guard.ts +13 -0
  130. package/src/commands/provider-credentials.ts +28 -5
  131. package/src/commands/runner.ts +7 -12
  132. package/src/heartbeat/heartbeat.ts +91 -19
  133. package/src/hooks/hook.ts +72 -27
  134. package/src/http/mcp.ts +5 -0
  135. package/src/http/onboarding.ts +2 -0
  136. package/src/providers/acp-targets.ts +9 -4
  137. package/src/providers/amp-adapter.ts +1 -1
  138. package/src/providers/claude-adapter.ts +26 -0
  139. package/src/providers/codex-adapter.ts +30 -3
  140. package/src/providers/codex-oauth/auth-json.ts +21 -4
  141. package/src/providers/codex-oauth/types.ts +2 -0
  142. package/src/providers/dsh-adapter.ts +10 -1
  143. package/src/providers/grok-adapter.ts +18 -2
  144. package/src/providers/opencode-adapter.ts +21 -5
  145. package/src/providers/pi-mono-extension.ts +56 -25
  146. package/src/providers/pricing-sources.md +2 -0
  147. package/src/server.ts +63 -3
  148. package/src/tests/acp-adapter.test.ts +152 -12
  149. package/src/tests/ci-unit-test-report.test.ts +73 -0
  150. package/src/tests/claude-default-route.test.ts +75 -0
  151. package/src/tests/claude-hook-output.test.ts +233 -0
  152. package/src/tests/codex-adapter-pool-auth-revalidate.test.ts +4 -2
  153. package/src/tests/codex-adapter.test.ts +166 -26
  154. package/src/tests/codex-oauth-auth-timestamps.test.ts +76 -0
  155. package/src/tests/codex-oauth.test.ts +4 -1
  156. package/src/tests/context-window.test.ts +2 -2
  157. package/src/tests/credential-check.test.ts +51 -1
  158. package/src/tests/dsh-adapter.test.ts +23 -1
  159. package/src/tests/enabled-tools.test.ts +269 -0
  160. package/src/tests/entrypoint-codex-oauth-seed.test.ts +12 -0
  161. package/src/tests/grok-adapter.test.ts +13 -0
  162. package/src/tests/haiku-5-5-pricing.test.ts +38 -0
  163. package/src/tests/heartbeat-checklist.test.ts +158 -1
  164. package/src/tests/onboarding.test.ts +44 -0
  165. package/src/tests/opencode-adapter.test.ts +66 -3
  166. package/src/tests/opencode-plugin.test.ts +37 -0
  167. package/src/tests/openrouter-attribution-call-sites.test.ts +204 -0
  168. package/src/tests/openrouter-base-url.test.ts +30 -39
  169. package/src/tests/pi-mono-extension.test.ts +161 -2
  170. package/src/tests/pricing-refresh.test.ts +70 -1
  171. package/src/tests/reasoning-effort.test.ts +3 -1
  172. package/src/tests/telemetry-trigger.test.ts +12 -1
  173. package/src/tests/workflow-llm.test.ts +14 -0
  174. package/src/tests/workflow-system-one-decision-providers.test.ts +33 -0
  175. package/src/tools/request-human-input.ts +7 -2
  176. package/src/utils/enabled-tools.ts +27 -0
  177. package/src/utils/internal-ai/complete-structured.ts +11 -3
  178. package/src/utils/openrouter-base-url.ts +23 -0
  179. package/src/workflows/executors/raw-llm.ts +1 -0
  180. package/src/workflows/executors/system-one-decision.ts +2 -0
  181. package/src/workflows/executors/validate.ts +1 -0
  182. package/src/workflows/executors/workflow-llm.ts +9 -2
  183. package/templates/skills/agent-browser/SKILL.md +2 -0
  184. package/templates/skills/agent-browser/content.md +2 -0
  185. package/templates/skills/code-quality/content.md +1 -0
  186. package/templates/skills/heartbeat-runbook/content.md +1 -1
  187. package/templates/skills/work-on-task/SKILL.md +4 -0
  188. package/templates/skills/work-on-task/content.md +4 -0
  189. package/templates/skills/workflow-iterate/content.md +9 -0
  190. package/dist/acp-adapter-yzfxn8vj.js +0 -23
  191. package/dist/cli-3dpj7atn.js +0 -11
  192. package/dist/cli-gvcgmx69.js +0 -183
  193. package/dist/{cli-jhm6hzjn.js → cli-mxvnnv5q.js} +7 -7
  194. package/dist/{codex-hook-d2rkvf39.js → codex-hook-15dgc4cb.js} +1 -1
@@ -3,7 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { normalizeGrokModel } from "@desplega/model-catalog";
6
- import { getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
6
+ import {
7
+ getOpenRouterAttributionHeaders,
8
+ getOpenRouterBaseUrl,
9
+ } from "../utils/openrouter-base-url";
7
10
  import { registerVolatileSecret } from "../utils/secret-scrubber";
8
11
  import { ACPAdapter } from "./acp-adapter";
9
12
  import { grokTargetProfile, isGrokOpenRouterModel } from "./acp-targets";
@@ -64,6 +67,8 @@ export interface GrokCustomModel {
64
67
  baseUrl: string;
65
68
  /** Env var holding the endpoint's API key. */
66
69
  envKey: string;
70
+ /** Sent verbatim on every request to the endpoint (`extra_headers`). */
71
+ headers?: Record<string, string>;
67
72
  }
68
73
 
69
74
  /** `openrouter/<vendor>/<id>` -> a `[model."openrouter/<vendor>/<id>"]` block on OpenRouter. */
@@ -73,14 +78,24 @@ export function grokOpenRouterModel(
73
78
  ): GrokCustomModel | null {
74
79
  const id = model.trim();
75
80
  if (!isGrokOpenRouterModel(id)) return null;
81
+ const baseUrl = getOpenRouterBaseUrl(env);
76
82
  return {
77
83
  id,
78
84
  model: id.slice("openrouter/".length),
79
- baseUrl: getOpenRouterBaseUrl(env),
85
+ baseUrl,
80
86
  envKey: "OPENROUTER_API_KEY",
87
+ headers: getOpenRouterAttributionHeaders(baseUrl, env),
81
88
  };
82
89
  }
83
90
 
91
+ /** `key = { "k" = "v", ... }`, or nothing for an empty map. JSON strings are valid TOML basic strings. */
92
+ function tomlInlineTable(key: string, entries: Record<string, string> | undefined): string[] {
93
+ const pairs = Object.entries(entries ?? {});
94
+ if (pairs.length === 0) return [];
95
+ const body = pairs.map(([k, v]) => `${JSON.stringify(k)} = ${JSON.stringify(v)}`).join(", ");
96
+ return [`${key} = { ${body} }`];
97
+ }
98
+
84
99
  /** `$GROK_HOME/config.toml` for one swarm session. */
85
100
  export function buildGrokConfigToml(
86
101
  disabledPlugins: readonly string[],
@@ -95,6 +110,7 @@ export function buildGrokConfigToml(
95
110
  `base_url = ${JSON.stringify(customModel.baseUrl)}`,
96
111
  `env_key = ${JSON.stringify(customModel.envKey)}`,
97
112
  `api_backend = "chat_completions"`,
113
+ ...tomlInlineTable("extra_headers", customModel.headers),
98
114
  ]
99
115
  : [];
100
116
  return [
@@ -20,7 +20,11 @@ import {
20
20
  import { validateOpencodeCredentials } from "../utils/credentials";
21
21
  import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
22
22
  import { swarmRuntimeInstanceId } from "../utils/multi-runtime";
23
- import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
23
+ import {
24
+ DEFAULT_OPENROUTER_BASE_URL,
25
+ getOpenRouterAttributionHeaders,
26
+ getOpenRouterBaseUrl,
27
+ } from "../utils/openrouter-base-url";
24
28
  import { scrubSecrets } from "../utils/secret-scrubber";
25
29
  import { resolveSlashSkillPrompt } from "./codex-skill-resolver";
26
30
  import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
@@ -177,22 +181,34 @@ function formatUnknownError(err: unknown): string {
177
181
  * Route OpenRouter traffic through the configured gateway (see
178
182
  * src/utils/openrouter-base-url.ts). opencode's bundled openrouter provider
179
183
  * honors `provider.openrouter.options.baseURL`. Mutates `opencodeConfig` in
180
- * place; no-op when `OPENROUTER_BASE_URL` is unset/blank/default, so default
181
- * openrouter.ai behavior is preserved. Exported for tests.
184
+ * place, and attribute direct OpenRouter requests to Agent Swarm.
182
185
  */
183
186
  export function applyOpenRouterBaseUrlOverride(
184
187
  opencodeConfig: Config,
185
188
  env: Record<string, string | undefined> = process.env,
186
189
  ): void {
187
190
  const openRouterBaseUrl = getOpenRouterBaseUrl(env as NodeJS.ProcessEnv);
188
- if (openRouterBaseUrl === DEFAULT_OPENROUTER_BASE_URL) return;
191
+ const headers = getOpenRouterAttributionHeaders(openRouterBaseUrl, env);
192
+ const overrideBaseUrl = openRouterBaseUrl !== DEFAULT_OPENROUTER_BASE_URL;
193
+ if (!overrideBaseUrl && Object.keys(headers).length === 0) return;
189
194
  opencodeConfig.provider = {
190
195
  ...opencodeConfig.provider,
191
196
  openrouter: {
192
197
  ...opencodeConfig.provider?.openrouter,
193
198
  options: {
194
199
  ...opencodeConfig.provider?.openrouter?.options,
195
- baseURL: openRouterBaseUrl,
200
+ ...(overrideBaseUrl ? { baseURL: openRouterBaseUrl } : {}),
201
+ ...(Object.keys(headers).length > 0
202
+ ? {
203
+ headers: {
204
+ ...(opencodeConfig.provider?.openrouter?.options?.headers as
205
+ | Record<string, string>
206
+ | undefined),
207
+ ...headers,
208
+ "X-Title": "Agent Swarm",
209
+ },
210
+ }
211
+ : {}),
196
212
  },
197
213
  },
198
214
  };
@@ -11,6 +11,7 @@ import { buildRatingsFromLlm, fetchRetrievalsForTask, postRatings } from "../be/
11
11
  import { checkToolLoop, clearToolHistory } from "../hooks/tool-loop-detection";
12
12
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
13
13
  import { getMemoryRaterNames } from "../utils/memory-raters";
14
+ import { getOpenRouterAttributionHeaders } from "../utils/openrouter-base-url";
14
15
  import { scrubSecrets } from "../utils/secret-scrubber";
15
16
 
16
17
  export interface SwarmHooksConfig {
@@ -421,6 +422,24 @@ export async function summarizeSessionForPi(
421
422
  }
422
423
  }
423
424
 
425
+ /** `customType` of the advisory messages this extension sends to the model. */
426
+ export const SWARM_NUDGE_CUSTOM_TYPE = "swarm-nudge";
427
+
428
+ /**
429
+ * Deliver an advisory nudge to the model. pi runs extensions in-process and
430
+ * never feeds their stdout to the model, so a console.log here only reaches
431
+ * the worker log. `sendMessage` steers the text in after the current tool
432
+ * batch while the agent is streaming, and otherwise appends it to the session
433
+ * so the next prompt carries it.
434
+ */
435
+ function sendNudge(pi: Parameters<ExtensionFactory>[0], text: string): void {
436
+ pi.sendMessage({
437
+ customType: SWARM_NUDGE_CUSTOM_TYPE,
438
+ content: [{ type: "text", text }],
439
+ display: false,
440
+ });
441
+ }
442
+
424
443
  /**
425
444
  * Create the swarm hooks extension factory for pi-mono.
426
445
  *
@@ -431,6 +450,13 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
431
450
  return (pi) => {
432
451
  let lastContextPostTime = 0;
433
452
 
453
+ pi.on("before_provider_headers", (event, ctx) => {
454
+ Object.assign(
455
+ event.headers,
456
+ getOpenRouterAttributionHeaders(ctx.model?.baseUrl ?? "", config.env),
457
+ );
458
+ });
459
+
434
460
  // === session_start → SessionStart ===
435
461
  pi.on("session_start", async (_event, _ctx) => {
436
462
  // Installed MCP servers: pi's MCP extension connects them in the
@@ -458,7 +484,7 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
458
484
  if (config.isLead) {
459
485
  const ctx = await fetchConcurrentContext(config);
460
486
  if (ctx) {
461
- console.log(ctx);
487
+ sendNudge(pi, ctx);
462
488
  }
463
489
  }
464
490
  });
@@ -498,7 +524,7 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
498
524
  }
499
525
 
500
526
  if (loopResult.severity === "warning" && loopResult.reason) {
501
- console.log(`Warning: ${loopResult.reason}`);
527
+ sendNudge(pi, `Warning: ${loopResult.reason}`);
502
528
  }
503
529
  }
504
530
 
@@ -586,7 +612,8 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
586
612
 
587
613
  // Reminders
588
614
  if (config.isLead && event.toolName?.endsWith("send-task")) {
589
- console.log(
615
+ sendNudge(
616
+ pi,
590
617
  "Task sent successfully. Monitor progress using the get-task-details tool periodically.",
591
618
  );
592
619
  }
@@ -594,29 +621,30 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
594
621
  return undefined;
595
622
  });
596
623
 
597
- // === context → PreCompact ===
598
- // The context event allows injecting messages before compaction.
599
- // We log the goal reminder to console (it gets captured in context).
600
- pi.on("context", async (_event, ctx) => {
601
- if (!config.taskId) return undefined;
602
-
603
- try {
604
- const taskDetails = await fetchTaskDetails(config);
605
- if (taskDetails) {
606
- const reminder = [
607
- "=== GOAL REMINDER (injected before context compaction) ===",
608
- `Task ID: ${taskDetails.id}`,
609
- `Task: ${taskDetails.task}`,
610
- ];
611
- if (taskDetails.progress) {
612
- reminder.push(`Current Progress: ${taskDetails.progress}`);
613
- }
614
- reminder.push("=== Continue working on this task after compaction ===");
615
- console.log(reminder.join("\n"));
624
+ // === session_compact → PreCompact ===
625
+ // Re-anchor the model on its task once compaction has summarized the
626
+ // history. `context` fires before every LLM call, so it cannot carry this.
627
+ pi.on("session_compact", async () => {
628
+ if (!config.taskId) return;
629
+
630
+ const taskDetails = await fetchTaskDetails(config);
631
+ if (taskDetails) {
632
+ const reminder = [
633
+ "=== GOAL REMINDER (injected before context compaction) ===",
634
+ `Task ID: ${taskDetails.id}`,
635
+ `Task: ${taskDetails.task}`,
636
+ ];
637
+ if (taskDetails.progress) {
638
+ reminder.push(`Current Progress: ${taskDetails.progress}`);
616
639
  }
617
- } catch {
618
- /* don't block compaction */
640
+ reminder.push("=== Continue working on this task after compaction ===");
641
+ sendNudge(pi, reminder.join("\n"));
619
642
  }
643
+ });
644
+
645
+ // === context (before every LLM call) ===
646
+ pi.on("context", async (_event, ctx) => {
647
+ if (!config.taskId) return undefined;
620
648
 
621
649
  // Report context usage as a compaction event
622
650
  const usage = ctx.getContextUsage?.();
@@ -644,7 +672,10 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
644
672
  const { cancelled, reason } = await isTaskCancelled(config);
645
673
  if (cancelled) {
646
674
  const cancelReason = reason || "Task cancelled by lead or creator";
647
- console.log(
675
+ // "handled" skips the turn; the nudge lands in the session for the
676
+ // next one instead of paying for a turn on a cancelled task.
677
+ sendNudge(
678
+ pi,
648
679
  `🛑 TASK CANCELLED: ${cancelReason}. Stop working and use store-progress to acknowledge.`,
649
680
  );
650
681
  return { action: "handled" as const };
@@ -178,6 +178,8 @@ and heartbeat-classified roots are omitted, and the metrics remain separate.
178
178
  - **Codex usage:** the app-server reports cumulative token counters, including cache writes.
179
179
  The adapter calculates each turn's delta before adding it to the session total.
180
180
  This prevents repeated billing of earlier turns when the adapter processes queued input.
181
+ Context occupancy is separate: it comes from the per-request `tokenUsage.last`
182
+ snapshot (and `modelContextWindow` when present), never from the turn delta.
181
183
  - **Claude breakdown validity is all-or-nothing:** the claude adapter drops the
182
184
  entire `modelUsage` breakdown when any entry carries a missing, non-finite,
183
185
  or negative token counter — zero-filling would let the server price a
package/src/server.ts CHANGED
@@ -196,6 +196,7 @@ import {
196
196
  registerTriggerWorkflowTool,
197
197
  registerUpdateWorkflowTool,
198
198
  } from "./tools/workflows";
199
+ import { parseEnabledTools } from "./utils/enabled-tools";
199
200
  import { resolveScriptsOnlyMode } from "./utils/scripts-only-mode";
200
201
 
201
202
  // Every known capability, including the ones disabled by default. Exported for
@@ -293,18 +294,75 @@ export function isScriptsOnlyMcp(): boolean {
293
294
  return resolveScriptsOnlyMode({ env: process.env.SCRIPTS_ONLY_MCP });
294
295
  }
295
296
 
297
+ const loggedToolAllowlistWarnings = new Set<string>();
298
+
299
+ function warnToolAllowlistOnce(message: string): void {
300
+ if (loggedToolAllowlistWarnings.has(message)) return;
301
+ loggedToolAllowlistWarnings.add(message);
302
+ console.warn(message);
303
+ }
304
+
305
+ /**
306
+ * SWARM_ENABLED_TOOLS: an optional explicit allowlist for the externally
307
+ * exposed MCP surface. Returns undefined (capability-driven surface) when the
308
+ * value is unset, blank, or invalid. Never logs the raw value.
309
+ */
310
+ function resolveToolAllowlist(value: string | undefined): string[] | undefined {
311
+ if (value === undefined) return undefined;
312
+ try {
313
+ const tools = parseEnabledTools(value);
314
+ if (!tools) {
315
+ warnToolAllowlistOnce("[MCP] SWARM_ENABLED_TOOLS is set but lists no tools; ignoring it");
316
+ }
317
+ return tools;
318
+ } catch {
319
+ warnToolAllowlistOnce("[MCP] Invalid SWARM_ENABLED_TOOLS; ignoring it");
320
+ return undefined;
321
+ }
322
+ }
323
+
324
+ /** Drop every registered tool the allowlist does not name; warn on unknown names. */
325
+ function applyToolAllowlist(server: McpServer, allowlist: readonly string[]): void {
326
+ const tools = (server as unknown as { _registeredTools: Record<string, { remove(): void }> })
327
+ ._registeredTools;
328
+ const allowed = new Set(allowlist);
329
+ for (const [name, tool] of Object.entries(tools)) {
330
+ if (!allowed.has(name)) tool.remove();
331
+ }
332
+ const unknown = allowlist.filter((name) => !Object.hasOwn(tools, name));
333
+ if (unknown.length > 0) {
334
+ warnToolAllowlistOnce(
335
+ `[MCP] SWARM_ENABLED_TOOLS names unknown tools, ignoring them: ${unknown.join(", ")}`,
336
+ );
337
+ }
338
+ }
339
+
296
340
  export async function createServer(
297
- opts: { scriptsOnly?: boolean; fullSurface?: boolean; preloadedTools?: readonly string[] } = {},
341
+ opts: {
342
+ scriptsOnly?: boolean;
343
+ fullSurface?: boolean;
344
+ preloadedTools?: readonly string[];
345
+ /** Raw SWARM_ENABLED_TOOLS value for this session; defaults to the env. */
346
+ enabledTools?: string;
347
+ } = {},
298
348
  ) {
299
349
  // Reload env
300
350
  await loadGlobalConfigsIntoEnv(true);
301
351
 
352
+ // The tool allowlist replaces CAPABILITIES and SCRIPTS_ONLY_MCP when set.
353
+ // Full-surface consumers (the scripts SDK bridge) ignore it.
354
+ const toolAllowlist =
355
+ opts.fullSurface === true
356
+ ? undefined
357
+ : resolveToolAllowlist(opts.enabledTools ?? process.env.SWARM_ENABLED_TOOLS);
358
+
302
359
  // Capability flags shape the externally exposed MCP tool list only. Internal
303
360
  // full-surface consumers (the scripts SDK bridge, drift-check tests) pass
304
361
  // fullSurface to register every tool group regardless of CAPABILITIES.
362
+ // An allowlist also registers every group, then prunes to the listed names.
305
363
  // This shadows the module-level hasCapability for the registrations below.
306
364
  const hasCapability = (cap: CAPABILITIES_T): boolean =>
307
- opts.fullSurface === true || getCapabilities().has(cap);
365
+ opts.fullSurface === true || toolAllowlist !== undefined || getCapabilities().has(cap);
308
366
 
309
367
  // Initialize database with WAL mode
310
368
  // Uses DATABASE_PATH env var for Docker volume compatibility (WAL needs .sqlite, .sqlite-wal, .sqlite-shm on same filesystem)
@@ -355,7 +413,7 @@ export async function createServer(
355
413
  // Scripts-only surface (experimental code-mode): register just the script
356
414
  // catalog tools and stop. script-connections / script-apis stay out — they
357
415
  // are lead-only security admin and excluded from the scripts SDK too.
358
- if (opts.scriptsOnly ?? isScriptsOnlyMcp()) {
416
+ if (toolAllowlist === undefined && (opts.scriptsOnly ?? isScriptsOnlyMcp())) {
359
417
  registerScriptSearchTool(server);
360
418
  registerScriptRunTool(server);
361
419
  registerScriptUpsertTool(server);
@@ -630,5 +688,7 @@ export async function createServer(
630
688
  registerUpdateServiceStatusTool(server);
631
689
  }
632
690
 
691
+ if (toolAllowlist) applyToolAllowlist(server, toolAllowlist);
692
+
633
693
  return server;
634
694
  }
@@ -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
+ });