@desplega.ai/agent-swarm 1.131.1 → 1.133.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-jgcy477k.js → actions-cskpayd7.js} +11 -9
  3. package/dist/{anthropic-messages-rjmd2e7n.js → anthropic-messages-tpxtbx7b.js} +28 -12
  4. package/dist/{app-5da78bd4.js → app-ang77nbb.js} +8 -6
  5. package/dist/{assistant-f2vkk1hj.js → assistant-b4exebdw.js} +16 -13
  6. package/dist/{azure-openai-responses-c0yjmbfd.js → azure-openai-responses-m3921t4y.js} +8 -8
  7. package/dist/{boot-reembed-q7mp8hcr.js → boot-reembed-ps2aftpb.js} +9 -7
  8. package/dist/{boot-reembed-xmw8wcx6.js → boot-reembed-znyk3nxb.js} +10 -8
  9. package/dist/{boot-scrub-logs-dsqrv79n.js → boot-scrub-logs-2ax3xgz1.js} +7 -5
  10. package/dist/{claude-adapter-q6x7yspx.js → claude-adapter-3ct8kgxw.js} +13 -9
  11. package/dist/{claude-managed-adapter-wsg37bt8.js → claude-managed-adapter-akx4ntf2.js} +2 -2
  12. package/dist/{cli-114fvtmj.js → cli-13w2hwkh.js} +1 -1
  13. package/dist/{cli-4rkfg1wx.js → cli-1532szb1.js} +8 -6
  14. package/dist/{cli-g74vpjp2.js → cli-23d3rseb.js} +33 -17
  15. package/dist/{cli-3ty0t082.js → cli-2a75z9c5.js} +196 -7
  16. package/dist/{cli-pxfxkx5m.js → cli-2t30rj0d.js} +1 -1
  17. package/dist/{cli-rgrfzg8z.js → cli-3c3tdgab.js} +10 -10
  18. package/dist/{cli-6dp3ydxa.js → cli-3zzxa9jz.js} +15 -11
  19. package/dist/{cli-2f07m0xr.js → cli-4k6233gc.js} +1 -1
  20. package/dist/{cli-t8d349cy.js → cli-5p2tz05r.js} +2 -2
  21. package/dist/{cli-w2wxd56v.js → cli-5z2yhzvd.js} +127 -49
  22. package/dist/{cli-kd1ck4rx.js → cli-6zfq31nj.js} +9 -3
  23. package/dist/{cli-er3a4bb0.js → cli-8m9de10a.js} +6 -1
  24. package/dist/{cli-gm9cbhbq.js → cli-8t14vg9w.js} +27 -8
  25. package/dist/{cli-ccf9vy7q.js → cli-9nfc2es8.js} +6 -6
  26. package/dist/{cli-ycrnynpw.js → cli-9ta3pwms.js} +11 -4
  27. package/dist/{cli-7dm2zqy2.js → cli-avr0ns7z.js} +2 -2
  28. package/dist/{cli-n6h8qt8n.js → cli-c897wyhw.js} +12 -7
  29. package/dist/{cli-gecy3z2s.js → cli-dgk20ft7.js} +4 -4
  30. package/dist/cli-et1bbwqf.js +13 -0
  31. package/dist/{cli-bqw0mt1n.js → cli-f5w27pj3.js} +3 -3
  32. package/dist/{cli-2wadx7p2.js → cli-f8dre4bz.js} +2 -2
  33. package/dist/{cli-6pcz9h15.js → cli-ftwpv451.js} +170 -387
  34. package/dist/{cli-nrkkknat.js → cli-g854s2dw.js} +1 -1
  35. package/dist/{cli-793jvbsh.js → cli-ggmdxeqa.js} +13 -8
  36. package/dist/cli-hkwfe3qj.js +530 -0
  37. package/dist/{cli-5dsbcjjw.js → cli-jqwnqx9m.js} +1 -1
  38. package/dist/{cli-9dr5qzc0.js → cli-ny122vh4.js} +21 -10
  39. package/dist/{cli-sy7jk3tx.js → cli-pvzqhbs9.js} +2 -2
  40. package/dist/cli-pzq5rjym.js +15 -0
  41. package/dist/{cli-xpxde7rm.js → cli-qes8ef67.js} +48 -30
  42. package/dist/{cli-sajgpkqg.js → cli-qs9hhsvb.js} +1723 -325
  43. package/dist/cli-qys3rhhz.js +27 -0
  44. package/dist/{cli-se2p6rkb.js → cli-r6tpm37h.js} +2 -2
  45. package/dist/{cli-g1re8r05.js → cli-rch80c76.js} +2 -2
  46. package/dist/{cli-0258203w.js → cli-rpp0x4zy.js} +137 -17
  47. package/dist/{cli-5n6e5yfn.js → cli-s1k0wxhe.js} +12 -7
  48. package/dist/{cli-aca5pz9f.js → cli-sfd9v7sj.js} +1 -1
  49. package/dist/{cli-2rk0cx7s.js → cli-sh3ytsyj.js} +49 -39
  50. package/dist/{cli-zsspvvm2.js → cli-w1mp9jy7.js} +1 -1
  51. package/dist/{cli-6dpytdr2.js → cli-xdb4mpnp.js} +2 -2
  52. package/dist/{cli-57khth04.js → cli-xmyqv00z.js} +120 -4
  53. package/dist/{cli-jbcva0xj.js → cli-xv8t8p3c.js} +56 -31
  54. package/dist/{cli-f1am9szv.js → cli-xxv5pn4f.js} +1 -1
  55. package/dist/{cli-jevgrb4w.js → cli-y08jvxzx.js} +601 -140
  56. package/dist/{cli-50vbt00w.js → cli-zfbf3srj.js} +650 -245
  57. package/dist/{cli-ygrbmjja.js → cli-zmq89k34.js} +6 -6
  58. package/dist/cli.js +22 -22
  59. package/dist/{codex-adapter-htjkn22b.js → codex-adapter-bef93pq4.js} +9 -7
  60. package/dist/{codex-hook-hq54t9ft.js → codex-hook-e8dba65p.js} +3 -2
  61. package/dist/{codex-session-runner-wjfc7smz.js → codex-session-runner-5pkxys7x.js} +9 -7
  62. package/dist/{commands-xhrfwqsk.js → commands-e23s1bff.js} +9 -7
  63. package/dist/{db-anvwb22f.js → db-m3nabatn.js} +17 -5
  64. package/dist/{google-generative-ai-hysrd8qx.js → google-generative-ai-8maw55xh.js} +11 -8
  65. package/dist/{google-vertex-p5arrga0.js → google-vertex-8vjqmx8g.js} +11 -8
  66. package/dist/{handlers-b9kbc621.js → handlers-akazedvr.js} +16 -13
  67. package/dist/{hook-xkvn6c5h.js → hook-ze4cpf0v.js} +55 -41
  68. package/dist/{hook-cgk4wxmx.js → hook-zfqyyenw.js} +10 -7
  69. package/dist/{http-9xh6ta4r.js → http-sja0n6kw.js} +608 -147
  70. package/dist/index-0gr8vd6k.js +39 -0
  71. package/dist/{index-eaxyqcmj.js → index-354vkhdv.js} +154 -74
  72. package/dist/{index-070v4eyb.js → index-5qthek5t.js} +2 -3
  73. package/dist/{index-25wjsdv2.js → index-95t3cqq5.js} +16 -14
  74. package/dist/{index-9znhar3x.js → index-sea66619.js} +3 -4
  75. package/dist/{index-ywds4812.js → index-vpn36mma.js} +15 -13
  76. package/dist/{keepalive-vc7s2aa6.js → keepalive-3sp3meqj.js} +16 -10
  77. package/dist/{lead-r2yqfmx7.js → lead-mefeewkf.js} +39 -36
  78. package/dist/{maintenance-mw70k468.js → maintenance-8tbrbjfg.js} +10 -8
  79. package/dist/mistral-conversations-00zrp7g4.js +717 -0
  80. package/dist/{oauth-refresh-sweep-yecgjpk4.js → oauth-refresh-sweep-srjwjz6m.js} +8 -6
  81. package/dist/{onboard-xj0f73f9.js → onboard-mt2tgyjg.js} +7 -7
  82. package/dist/{openai-codex-responses-5g27pd6f.js → openai-codex-responses-map32h2s.js} +18 -18
  83. package/dist/{openai-completions-3vkmfz2a.js → openai-completions-w871b1cq.js} +12 -11
  84. package/dist/{openai-responses-dg4t7j7a.js → openai-responses-b0j7xnw8.js} +15 -12
  85. package/dist/{opencode-adapter-1gm38aba.js → opencode-adapter-b39jefcj.js} +5 -3
  86. package/dist/{openrouter-images-g3gm8931.js → openrouter-images-srfkywy0.js} +4 -4
  87. package/dist/{otel-impl-nxg0n0b3.js → otel-impl-z6pxt3j9.js} +224 -9
  88. package/dist/{pi-mono-adapter-4g5kby31.js → pi-mono-adapter-btvqmj58.js} +1138 -408
  89. package/dist/{pricing-refresh-2cn6effr.js → pricing-refresh-kyq8cezh.js} +9 -7
  90. package/dist/{rbac-roles-1hcxdtg7.js → rbac-roles-3ryfrfna.js} +8 -6
  91. package/dist/{rbac-roles-vynday5g.js → rbac-roles-6qfvbee1.js} +9 -7
  92. package/dist/{render-v2-tc0bh8kk.js → render-v2-p61ppjey.js} +8 -6
  93. package/dist/{seed-pricing-8kceb43f.js → seed-pricing-j56bbj4y.js} +8 -6
  94. package/dist/{setup-46xtgq3c.js → setup-tpr6c1kn.js} +2 -2
  95. package/dist/{worker-qrbz6hmv.js → worker-9w1v0mbb.js} +39 -36
  96. package/openapi.json +542 -16
  97. package/package.json +6 -6
  98. package/src/be/budget-refusal-notify.ts +14 -7
  99. package/src/be/db-queries/task-artifacts.ts +182 -0
  100. package/src/be/db.ts +783 -169
  101. package/src/be/events.ts +5 -0
  102. package/src/be/migrations/131_session_logs_created_at_index.sql +2 -0
  103. package/src/be/migrations/132_multi_runtime_instances.sql +44 -0
  104. package/src/be/migrations/133_workflow_run_trigger_type.sql +12 -0
  105. package/src/be/migrations/135_backfill_task_pull_request_attachments.sql +228 -0
  106. package/src/be/migrations/136_task_requester_provenance.sql +37 -0
  107. package/src/be/migrations/137_memory_retrieval_composite_index.sql +1 -0
  108. package/src/be/multi-runtime.ts +430 -0
  109. package/src/be/scripts/extract-schema.ts +3 -1
  110. package/src/be/scripts/typecheck.ts +3 -2
  111. package/src/be/seed-skills/index.ts +7 -0
  112. package/src/be/steering.ts +1 -1
  113. package/src/be/swarm-config-guard.ts +18 -0
  114. package/src/commands/credential-wait.ts +54 -0
  115. package/src/commands/onboard/steps/core-credentials.tsx +1 -1
  116. package/src/commands/onboard/steps/custom-templates.tsx +1 -1
  117. package/src/commands/onboard/steps/harness-credentials.tsx +1 -1
  118. package/src/commands/onboard/steps/health-check.tsx +1 -1
  119. package/src/commands/onboard/steps/start.tsx +1 -1
  120. package/src/commands/profile-sync-audit.ts +56 -0
  121. package/src/commands/profile-sync.ts +287 -15
  122. package/src/commands/provider-credentials.ts +46 -19
  123. package/src/commands/runner.ts +196 -33
  124. package/src/commands/templates.ts +22 -0
  125. package/src/github/handlers.ts +55 -9
  126. package/src/heartbeat/heartbeat.ts +71 -12
  127. package/src/hooks/hook.ts +49 -33
  128. package/src/http/active-sessions.ts +11 -1
  129. package/src/http/agents.ts +261 -30
  130. package/src/http/all-routes.ts +1 -0
  131. package/src/http/config.ts +13 -2
  132. package/src/http/core.ts +102 -4
  133. package/src/http/db-query-bounded.ts +313 -0
  134. package/src/http/db-query-shared.ts +249 -0
  135. package/src/http/db-query.ts +54 -42
  136. package/src/http/events.ts +2 -0
  137. package/src/http/index.ts +22 -2
  138. package/src/http/mcp-bridge.ts +37 -3
  139. package/src/http/mcp.ts +12 -0
  140. package/src/http/memory.ts +13 -2
  141. package/src/http/openapi.ts +1 -0
  142. package/src/http/poll.ts +70 -27
  143. package/src/http/route-def.ts +43 -9
  144. package/src/http/schedules.ts +2 -2
  145. package/src/http/scripts.ts +10 -1
  146. package/src/http/session-data.ts +44 -0
  147. package/src/http/tasks.ts +2 -2
  148. package/src/http/users.ts +74 -52
  149. package/src/http/webhooks.ts +5 -2
  150. package/src/http/workflows.ts +1 -4
  151. package/src/integrations/kapso/inbound.ts +1 -1
  152. package/src/jira/outbound.ts +39 -8
  153. package/src/jira/webhook-lifecycle.ts +13 -2
  154. package/src/linear/outbound.ts +43 -10
  155. package/src/oauth/keepalive.ts +13 -2
  156. package/src/prompts/base-prompt.ts +1 -1
  157. package/src/prompts/session-templates.ts +45 -7
  158. package/src/providers/claude-adapter.ts +6 -0
  159. package/src/providers/codex-adapter.ts +6 -0
  160. package/src/providers/opencode-adapter.ts +6 -0
  161. package/src/providers/pi-mono-adapter.ts +8 -0
  162. package/src/providers/pricing-sources.md +24 -0
  163. package/src/queue-stall-alarm.ts +216 -0
  164. package/src/scheduler/scheduler.ts +7 -0
  165. package/src/script-workflows/executor.ts +2 -1
  166. package/src/scripts-runtime/executors/native.ts +2 -1
  167. package/src/scripts-runtime/executors/types.ts +5 -0
  168. package/src/scripts-runtime/loader.ts +5 -0
  169. package/src/scripts-runtime/swarm-config.ts +8 -0
  170. package/src/scripts-runtime/swarm-sdk.ts +7 -0
  171. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  172. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  173. package/src/tests/budget-claim-gate.test.ts +20 -0
  174. package/src/tests/claude-adapter.test.ts +32 -0
  175. package/src/tests/codex-adapter.test.ts +30 -0
  176. package/src/tests/create-task-validation.test.ts +128 -0
  177. package/src/tests/credential-wait.test.ts +67 -1
  178. package/src/tests/db-query.test.ts +711 -2
  179. package/src/tests/http-api-integration.test.ts +81 -0
  180. package/src/tests/http-users.test.ts +166 -1
  181. package/src/tests/identity-field-budget.test.ts +176 -0
  182. package/src/tests/mcp-tools-user.test.ts +34 -0
  183. package/src/tests/mcp-user-route.test.ts +17 -0
  184. package/src/tests/metrics-http.test.ts +44 -0
  185. package/src/tests/multi-runtime-registration.test.ts +2703 -0
  186. package/src/tests/openapi-response-contract.test.ts +24 -0
  187. package/src/tests/openapi-route-headers.test.ts +83 -0
  188. package/src/tests/opencode-adapter.test.ts +40 -0
  189. package/src/tests/outbound-listener-rejection.test.ts +138 -0
  190. package/src/tests/pi-mono-adapter.test.ts +98 -0
  191. package/src/tests/profile-sync-warning.test.ts +277 -0
  192. package/src/tests/profile-sync.test.ts +232 -6
  193. package/src/tests/prompt-template-session.test.ts +32 -3
  194. package/src/tests/queue-stall-alarm.test.ts +226 -0
  195. package/src/tests/requester-comms.test.ts +32 -0
  196. package/src/tests/runner-requester-profile.test.ts +38 -0
  197. package/src/tests/schedule-target-type.test.ts +38 -0
  198. package/src/tests/script-connections.test.ts +13 -1
  199. package/src/tests/scripts-runtime-identity.test.ts +375 -0
  200. package/src/tests/scripts-runtime.test.ts +4 -3
  201. package/src/tests/sdk-allowlist.test.ts +47 -0
  202. package/src/tests/send-task-requested-by.test.ts +9 -0
  203. package/src/tests/session-costs.test.ts +506 -1
  204. package/src/tests/shutdown-error-scrubbing.test.ts +92 -0
  205. package/src/tests/steering-core.test.ts +28 -0
  206. package/src/tests/task-artifacts.test.ts +386 -0
  207. package/src/tests/task-attachments-schema.test.ts +17 -1
  208. package/src/tests/task-completion-idempotency.test.ts +57 -0
  209. package/src/tests/task-pull-request-backfill-migration.test.ts +321 -0
  210. package/src/tests/update-profile-api.test.ts +67 -39
  211. package/src/tests/update-profile-auth.test.ts +48 -4
  212. package/src/tests/user-attribution-migration.test.ts +54 -0
  213. package/src/tests/user-identity.test.ts +72 -0
  214. package/src/tests/workflow-async-v2.test.ts +39 -1
  215. package/src/tests/workflow-http-v2.test.ts +3 -2
  216. package/src/tests/workflow-mcp-trigger-schema.test.ts +52 -3
  217. package/src/tests/workflow-retry-v2.test.ts +45 -1
  218. package/src/tools/db-query.ts +18 -8
  219. package/src/tools/get-task-details.ts +24 -3
  220. package/src/tools/manage-user.ts +36 -3
  221. package/src/tools/poll-task.ts +93 -14
  222. package/src/tools/script-common.ts +5 -0
  223. package/src/tools/send-task.ts +1 -4
  224. package/src/tools/store-progress.ts +13 -2
  225. package/src/tools/swarm-config/delete-config.ts +9 -0
  226. package/src/tools/swarm-config/set-config.ts +3 -2
  227. package/src/tools/task-action.ts +30 -0
  228. package/src/tools/task-tool-ctx.ts +8 -1
  229. package/src/tools/update-profile.ts +16 -21
  230. package/src/tools/utils.ts +5 -0
  231. package/src/tools/workflows/trigger-workflow.ts +7 -1
  232. package/src/types.ts +165 -1
  233. package/src/utils/github-pull-request.ts +76 -0
  234. package/src/utils/identity-field-budget.ts +73 -0
  235. package/src/utils/multi-runtime.ts +20 -0
  236. package/src/utils/requester-comms.ts +24 -0
  237. package/src/workflows/engine.ts +22 -2
  238. package/src/workflows/resume.ts +19 -11
  239. package/src/workflows/retry-poller.ts +1 -0
  240. package/src/workflows/triggers.ts +4 -0
  241. package/templates/skills/db-query-guidance/config.json +14 -0
  242. package/templates/skills/db-query-guidance/content.md +57 -0
  243. package/dist/cli-3te4jdkm.js +0 -225
  244. package/dist/cli-bmze986d.js +0 -7
  245. package/dist/cli-kh3ba2zf.js +0 -607
  246. package/dist/index-vg4snay4.js +0 -36
  247. package/dist/mistral-conversations-81fwav0j.js +0 -30578
  248. package/dist/otel-nqa12wf3.js +0 -3149
  249. package/dist/{cli-wz9115x0.js → cli-zr9q011v.js} +6 -6
  250. package/dist/{pi-messages-tqj8q6b9.js → pi-messages-sssy32x4.js} +3 -3
@@ -87,6 +87,30 @@ describe("route handle respond()", () => {
87
87
  expect(out.status).toBe(200);
88
88
  expect(out.body).toBe(JSON.stringify({ n: "not-a-number" }));
89
89
  });
90
+
91
+ test("fail-open: gate armed outside tests logs the violation and still sends", () => {
92
+ // Step-6 regression: with the gate armed (VALIDATE_HTTP_RESPONSES=true)
93
+ // but NODE_ENV != test, a schema violation must not throw — it logs to
94
+ // stderr and the response still goes out (2026-08-18 incident shape).
95
+ const probe = `
96
+ import { z } from "zod";
97
+ import { route } from "${import.meta.dir}/../http/route-def";
98
+ const r = route({
99
+ method: "get", path: "/probe", pattern: ["probe"], summary: "p", tags: ["t"],
100
+ responses: { 200: { description: "ok", schema: z.object({ n: z.number() }) } },
101
+ });
102
+ let status = 0, body = "";
103
+ r.respond({ writeHead(s) { status = s; return this; }, end(b) { body = b; return this; } }, 200, { n: "not-a-number" });
104
+ console.log(JSON.stringify({ status, body }));
105
+ `;
106
+ const env = { ...process.env, NODE_ENV: "production", VALIDATE_HTTP_RESPONSES: "true" };
107
+ const proc = Bun.spawnSync(["bun", "-e", probe], { env });
108
+ expect(proc.exitCode).toBe(0);
109
+ const out = JSON.parse(proc.stdout.toString().trim());
110
+ expect(out.status).toBe(200);
111
+ expect(out.body).toBe(JSON.stringify({ n: "not-a-number" }));
112
+ expect(proc.stderr.toString()).toContain("Response schema violation");
113
+ });
90
114
  });
91
115
 
92
116
  // ─── coverage-gate audit (per-status regression) ─────────────────────────────
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Request headers declared on a route must reach the generated spec as real
3
+ * OpenAPI parameters — documenting them in prose leaves generated clients
4
+ * unable to send them.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import "../http/all-routes";
9
+ import { generateOpenApiSpec } from "../http/openapi";
10
+
11
+ type Parameter = {
12
+ name: string;
13
+ in: string;
14
+ required?: boolean;
15
+ description?: string;
16
+ schema?: { type?: string };
17
+ };
18
+
19
+ const spec = JSON.parse(generateOpenApiSpec({ version: "test" })) as {
20
+ paths: Record<string, Record<string, { parameters?: Parameter[] }>>;
21
+ };
22
+
23
+ function headerParams(path: string, method = "post"): Parameter[] {
24
+ return (spec.paths[path]?.[method]?.parameters ?? []).filter((p) => p.in === "header");
25
+ }
26
+
27
+ /** Every operation whose contract includes the runtime identity. */
28
+ const RUNTIME_HEADER_OPERATIONS: Array<[string, string]> = [
29
+ ["/ping", "post"],
30
+ ["/close", "post"],
31
+ ["/api/poll", "get"],
32
+ ["/api/agents/{id}/credential-status", "put"],
33
+ ["/api/scripts/run", "post"],
34
+ ["/api/mcp-bridge", "post"],
35
+ ];
36
+
37
+ describe("runtime identity header in OpenAPI", () => {
38
+ for (const [path, method] of RUNTIME_HEADER_OPERATIONS) {
39
+ test(`${method.toUpperCase()} ${path} declares X-Runtime-Instance-ID as a header parameter`, () => {
40
+ const header = headerParams(path, method).find((p) => p.name === "X-Runtime-Instance-ID");
41
+ expect(header).toBeDefined();
42
+ expect(header?.in).toBe("header");
43
+ expect(header?.schema?.type).toBe("string");
44
+ });
45
+
46
+ test(`${method.toUpperCase()} ${path} marks the header optional — the requirement is mode-dependent`, () => {
47
+ const header = headerParams(path, method).find((p) => p.name === "X-Runtime-Instance-ID");
48
+ expect(header?.required).toBe(false);
49
+ });
50
+
51
+ test(`${method.toUpperCase()} ${path} describes what the header identifies`, () => {
52
+ const header = headerParams(path, method).find((p) => p.name === "X-Runtime-Instance-ID");
53
+ expect(header?.description).toContain("runtime instance");
54
+ expect(header?.description).toContain("MULTI_RUNTIME_ENABLED");
55
+ });
56
+ }
57
+
58
+ test("routes that declare no headers emit none", () => {
59
+ // Representative routes across the surface, none of which opt in.
60
+ expect(headerParams("/api/agents")).toHaveLength(0);
61
+ expect(headerParams("/api/config", "put")).toHaveLength(0);
62
+ expect(headerParams("/api/tasks")).toHaveLength(0);
63
+ });
64
+
65
+ test("header support adds parameters to no other operation", () => {
66
+ const withHeaders: string[] = [];
67
+ for (const [path, methods] of Object.entries(spec.paths)) {
68
+ for (const [method, op] of Object.entries(methods)) {
69
+ if ((op.parameters ?? []).some((p) => p.in === "header")) {
70
+ withHeaders.push(`${method.toUpperCase()} ${path}`);
71
+ }
72
+ }
73
+ }
74
+ expect(withHeaders.sort()).toEqual([
75
+ "GET /api/poll",
76
+ "POST /api/mcp-bridge",
77
+ "POST /api/scripts/run",
78
+ "POST /close",
79
+ "POST /ping",
80
+ "PUT /api/agents/{id}/credential-status",
81
+ ]);
82
+ });
83
+ });
@@ -1017,6 +1017,46 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
1017
1017
  expect(opts.config?.permission?.edit).toBe("allow");
1018
1018
  });
1019
1019
 
1020
+ test("swarm MCP headers carry the per-boot runtime identity when set", async () => {
1021
+ const prev = process.env.SWARM_RUNTIME_INSTANCE_ID;
1022
+ process.env.SWARM_RUNTIME_INSTANCE_ID = "opencode-runtime-1";
1023
+ try {
1024
+ const events: OpencodeEvent[] = [
1025
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
1026
+ ];
1027
+ await driveSession(events, testConfig({ taskId: "task-1" }));
1028
+
1029
+ const opts = lastCreateOpencodeConfig as {
1030
+ config?: { mcp?: Record<string, { headers?: Record<string, string> }> };
1031
+ };
1032
+ expect(opts.config?.mcp?.swarm?.headers?.["X-Runtime-Instance-ID"]).toBe(
1033
+ "opencode-runtime-1",
1034
+ );
1035
+ } finally {
1036
+ if (prev === undefined) delete process.env.SWARM_RUNTIME_INSTANCE_ID;
1037
+ else process.env.SWARM_RUNTIME_INSTANCE_ID = prev;
1038
+ }
1039
+ });
1040
+
1041
+ test("swarm MCP headers omit the runtime identity when unset", async () => {
1042
+ const prev = process.env.SWARM_RUNTIME_INSTANCE_ID;
1043
+ delete process.env.SWARM_RUNTIME_INSTANCE_ID;
1044
+ try {
1045
+ const events: OpencodeEvent[] = [
1046
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
1047
+ ];
1048
+ await driveSession(events, testConfig({ taskId: "task-1" }));
1049
+
1050
+ const opts = lastCreateOpencodeConfig as {
1051
+ config?: { mcp?: Record<string, { headers?: Record<string, string> }> };
1052
+ };
1053
+ expect(opts.config?.mcp?.swarm?.headers?.["X-Runtime-Instance-ID"]).toBeUndefined();
1054
+ } finally {
1055
+ if (prev === undefined) delete process.env.SWARM_RUNTIME_INSTANCE_ID;
1056
+ else process.env.SWARM_RUNTIME_INSTANCE_ID = prev;
1057
+ }
1058
+ });
1059
+
1020
1060
  test("per-task agent file is written with system prompt", async () => {
1021
1061
  const cwd = `/tmp/opencode-test-agent-${Date.now()}`;
1022
1062
  await Bun.$`mkdir -p ${cwd}`.quiet();
@@ -0,0 +1,138 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { initJiraOutboundSync, teardownJiraOutboundSync } from "../jira/outbound";
3
+ import { initLinearOutboundSync, teardownLinearOutboundSync } from "../linear/outbound";
4
+ import { workflowEventBus } from "../workflows/event-bus";
5
+
6
+ /**
7
+ * `EventEmitter.emit()` invokes listeners synchronously and discards whatever
8
+ * they return, so wrapping the *emitting* side in `.catch()` never observes an
9
+ * async subscriber's rejection — it reaches the process-level
10
+ * `unhandledRejection` handler instead, with no subsystem context.
11
+ *
12
+ * These tests exercise the subscription boundary rather than EventEmitter
13
+ * itself. A `null` payload makes the handler's first destructuring statement
14
+ * throw, which rejects the returned promise from *outside* any internal
15
+ * `try`/`catch` — the same shape as a database failure in the tracker lookup
16
+ * that both subsystems perform before their first `try`.
17
+ */
18
+
19
+ /** Let the rejection settle: one microtask drain plus one macrotask turn. */
20
+ async function settle(): Promise<void> {
21
+ await Promise.resolve();
22
+ await new Promise((resolve) => setTimeout(resolve, 0));
23
+ }
24
+
25
+ describe("outbound event-bus listener rejection handling", () => {
26
+ let errorSpy: ReturnType<typeof spyOn<Console, "error">>;
27
+ let logSpy: ReturnType<typeof spyOn<Console, "log">>;
28
+ let unhandled: unknown[];
29
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
30
+
31
+ beforeEach(() => {
32
+ unhandled = [];
33
+ process.on("unhandledRejection", onUnhandled);
34
+ errorSpy = spyOn(console, "error").mockImplementation(() => {});
35
+ logSpy = spyOn(console, "log").mockImplementation(() => {});
36
+ });
37
+
38
+ afterEach(() => {
39
+ teardownJiraOutboundSync();
40
+ teardownLinearOutboundSync();
41
+ errorSpy.mockRestore();
42
+ logSpy.mockRestore();
43
+ process.off("unhandledRejection", onUnhandled);
44
+ });
45
+
46
+ /**
47
+ * Other suites in the same process may leave their own subscribers attached,
48
+ * and they log through the same `console.error`. Assert on the subsystem
49
+ * under test rather than on global log emptiness, so this file does not
50
+ * depend on which tests ran before it.
51
+ */
52
+ const messages = (prefix: string) =>
53
+ errorSpy.mock.calls.map((args) => String(args[0])).filter((m) => m.startsWith(prefix));
54
+
55
+ test("a rejecting Jira listener is reported by the subsystem, not left unhandled", async () => {
56
+ initJiraOutboundSync();
57
+
58
+ workflowEventBus.emit("task.completed", null);
59
+ await settle();
60
+
61
+ expect(messages("[Jira Outbound]")).toContain("[Jira Outbound] task.completed handler failed:");
62
+ expect(unhandled).toEqual([]);
63
+ });
64
+
65
+ test("a rejecting Linear listener is reported by the subsystem, not left unhandled", async () => {
66
+ initLinearOutboundSync();
67
+
68
+ workflowEventBus.emit("task.completed", null);
69
+ await settle();
70
+
71
+ expect(messages("[Linear Outbound]")).toContain(
72
+ "[Linear Outbound] task.completed handler failed:",
73
+ );
74
+ expect(unhandled).toEqual([]);
75
+ });
76
+
77
+ test("the reported error carries the reason as scrubbed text, not the raw object", async () => {
78
+ initJiraOutboundSync();
79
+
80
+ workflowEventBus.emit("task.failed", null);
81
+ await settle();
82
+
83
+ const call = errorSpy.mock.calls.find(
84
+ (args) => String(args[0]) === "[Jira Outbound] task.failed handler failed:",
85
+ );
86
+ expect(call).toBeDefined();
87
+ // Text, not the Error itself: a raw SDK error would serialise attached
88
+ // request/response fields that can hold credentials.
89
+ expect(typeof call?.[1]).toBe("string");
90
+ // The underlying reason survives; only the object wrapper is dropped.
91
+ expect(String(call?.[1])).toMatch(/null/);
92
+ });
93
+
94
+ test("every subscribed event is observed, not just one", async () => {
95
+ initJiraOutboundSync();
96
+
97
+ for (const event of ["task.created", "task.completed", "task.failed", "task.cancelled"]) {
98
+ workflowEventBus.emit(event, null);
99
+ }
100
+ await settle();
101
+
102
+ for (const event of ["task.created", "task.completed", "task.failed", "task.cancelled"]) {
103
+ expect(messages("[Jira Outbound]")).toContain(`[Jira Outbound] ${event} handler failed:`);
104
+ }
105
+ expect(unhandled).toEqual([]);
106
+ });
107
+
108
+ test("teardown removes the wrapper that was registered", async () => {
109
+ initJiraOutboundSync();
110
+ initLinearOutboundSync();
111
+ teardownJiraOutboundSync();
112
+ teardownLinearOutboundSync();
113
+ errorSpy.mockClear();
114
+
115
+ workflowEventBus.emit("task.completed", null);
116
+ await settle();
117
+
118
+ // If `off()` had been passed a different reference than `on()`, the
119
+ // listener would still be attached and would report here.
120
+ expect(messages("[Jira Outbound]")).toEqual([]);
121
+ expect(messages("[Linear Outbound]")).toEqual([]);
122
+ expect(unhandled).toEqual([]);
123
+ });
124
+
125
+ test("normally handled events still complete without being reported", async () => {
126
+ initJiraOutboundSync();
127
+ initLinearOutboundSync();
128
+
129
+ // No taskId — both subsystems return early, which is the ordinary
130
+ // no-op path for the vast majority of swarm tasks.
131
+ workflowEventBus.emit("task.completed", {});
132
+ await settle();
133
+
134
+ expect(messages("[Jira Outbound]")).toEqual([]);
135
+ expect(messages("[Linear Outbound]")).toEqual([]);
136
+ expect(unhandled).toEqual([]);
137
+ });
138
+ });
@@ -171,6 +171,104 @@ describe("PiMonoAdapter.createSession — OPENROUTER_BASE_URL gateway", () => {
171
171
  });
172
172
  });
173
173
 
174
+ // ─── MCP runtime identity (multi-runtime dispatch gate) ──────────────────────
175
+
176
+ describe("PiMonoAdapter.createSession — MCP runtime identity header", () => {
177
+ let createAgentSessionSpy: ReturnType<typeof spyOn>;
178
+
179
+ afterEach(() => {
180
+ createAgentSessionSpy?.mockRestore();
181
+ });
182
+
183
+ function spyOnCreateAgentSession() {
184
+ createAgentSessionSpy = spyOn(piCodingAgent, "createAgentSession").mockImplementation((async (
185
+ _opts: Record<string, unknown>,
186
+ ) => {
187
+ return {
188
+ session: {
189
+ sessionId: "fake-session",
190
+ isStreaming: false,
191
+ model: undefined,
192
+ subscribe: () => () => {},
193
+ dispose: () => {},
194
+ },
195
+ };
196
+ }) as typeof piCodingAgent.createAgentSession);
197
+ }
198
+
199
+ function makeMcpConfig(apiUrl: string): ProviderSessionConfig {
200
+ return {
201
+ prompt: "hello",
202
+ systemPrompt: "",
203
+ model: "openrouter/google/gemini-3-flash-preview",
204
+ role: "worker",
205
+ agentId: "test-agent",
206
+ taskId: "test-task",
207
+ apiUrl,
208
+ apiKey: "test-key",
209
+ cwd: "/tmp",
210
+ logFile: `/tmp/pi-mcp-header-test-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
211
+ };
212
+ }
213
+
214
+ /** Stub swarm API: records the runtime header on every /mcp request. */
215
+ function serveStub(seen: Array<string | null>) {
216
+ return Bun.serve({
217
+ port: 0,
218
+ fetch(req) {
219
+ const url = new URL(req.url);
220
+ if (url.pathname === "/mcp") {
221
+ seen.push(req.headers.get("x-runtime-instance-id"));
222
+ return Response.json({
223
+ jsonrpc: "2.0",
224
+ id: 1,
225
+ result: { protocolVersion: "2025-03-26", capabilities: {}, tools: [] },
226
+ });
227
+ }
228
+ return Response.json({ servers: [] });
229
+ },
230
+ });
231
+ }
232
+
233
+ test("swarm MCP requests carry the per-boot X-Runtime-Instance-ID", async () => {
234
+ const seen: Array<string | null> = [];
235
+ const server = serveStub(seen);
236
+ const prev = process.env.SWARM_RUNTIME_INSTANCE_ID;
237
+ process.env.SWARM_RUNTIME_INSTANCE_ID = "pi-runtime-1";
238
+ try {
239
+ spyOnCreateAgentSession();
240
+ const adapter = new PiMonoAdapter();
241
+ await adapter.createSession(makeMcpConfig(`http://localhost:${server.port}`));
242
+ // initialize + initialized + tools/list all hit /mcp — every one must
243
+ // present the same per-boot identity.
244
+ expect(seen.length).toBeGreaterThan(0);
245
+ expect(new Set(seen)).toEqual(new Set(["pi-runtime-1"]));
246
+ } finally {
247
+ if (prev === undefined) delete process.env.SWARM_RUNTIME_INSTANCE_ID;
248
+ else process.env.SWARM_RUNTIME_INSTANCE_ID = prev;
249
+ server.stop(true);
250
+ }
251
+ });
252
+
253
+ test("no per-boot identity in the environment → no runtime header", async () => {
254
+ const seen: Array<string | null> = [];
255
+ const server = serveStub(seen);
256
+ const prev = process.env.SWARM_RUNTIME_INSTANCE_ID;
257
+ delete process.env.SWARM_RUNTIME_INSTANCE_ID;
258
+ try {
259
+ spyOnCreateAgentSession();
260
+ const adapter = new PiMonoAdapter();
261
+ await adapter.createSession(makeMcpConfig(`http://localhost:${server.port}`));
262
+ expect(seen.length).toBeGreaterThan(0);
263
+ expect(new Set(seen)).toEqual(new Set([null]));
264
+ } finally {
265
+ if (prev === undefined) delete process.env.SWARM_RUNTIME_INSTANCE_ID;
266
+ else process.env.SWARM_RUNTIME_INSTANCE_ID = prev;
267
+ server.stop(true);
268
+ }
269
+ });
270
+ });
271
+
174
272
  describe("AGENTS.md symlink management", () => {
175
273
  const tmpDir = `/tmp/pi-mono-test-${Date.now()}`;
176
274
 
@@ -0,0 +1,277 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ contentSha256,
4
+ fetchProfileSyncRejectionBanner,
5
+ prependProfileSyncRejectionBanner,
6
+ renderProfileSyncRejectionBanner,
7
+ TOOLS_MD_PATH,
8
+ WORKSPACE_CLAUDE_MD_PATH,
9
+ } from "../commands/profile-sync";
10
+ import type { SwarmEvent } from "../types";
11
+
12
+ const rejectionEvent: SwarmEvent = {
13
+ id: "00000000-0000-4000-8000-000000000001",
14
+ category: "system",
15
+ event: "system.profile_sync_rejected",
16
+ status: "error",
17
+ source: "api",
18
+ agentId: "agent-1",
19
+ createdAt: "2026-08-18T15:55:00.000Z",
20
+ data: {
21
+ field: "toolsMd",
22
+ diskSize: 84_574,
23
+ dbSize: 83_679,
24
+ budget: 20_000,
25
+ delta: 895,
26
+ dbHash: contentSha256("db tools"),
27
+ changeSource: "session_sync",
28
+ },
29
+ };
30
+
31
+ const reconciliationEvent: SwarmEvent = {
32
+ ...rejectionEvent,
33
+ id: "00000000-0000-4000-8000-000000000003",
34
+ event: "system.profile_sync_reconciled",
35
+ status: "ok",
36
+ createdAt: "2026-08-18T16:00:00.000Z",
37
+ };
38
+
39
+ describe("profile sync rejection session warning", () => {
40
+ test("carries field, disk size, DB size, budget, delta, and recovery path", async () => {
41
+ const banner = await renderProfileSyncRejectionBanner(rejectionEvent);
42
+
43
+ expect(banner).toContain("/workspace/TOOLS.md");
44
+ expect(banner).toContain("Disk size: 84574");
45
+ expect(banner).toContain("DB size: 83679");
46
+ expect(banner).toContain("budget: 20000");
47
+ expect(banner).toContain("delta: +895");
48
+ expect(banner).toContain(".pre-boot-<timestamp>.bak");
49
+ expect(banner.indexOf("live /workspace/TOOLS.md")).toBeLessThan(
50
+ banner.indexOf("/workspace/TOOLS.md.pre-boot-<timestamp>.bak"),
51
+ );
52
+ expect(banner).toContain(rejectionEvent.id);
53
+ });
54
+
55
+ test("queries the latest persisted event for the current agent", async () => {
56
+ const requests: Request[] = [];
57
+ const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
58
+ const request = new Request(input, init);
59
+ requests.push(request);
60
+ const field = new URL(request.url).searchParams.get("dataField");
61
+ const body = request.url.endsWith("/me")
62
+ ? { toolsMd: "db tools" }
63
+ : { events: field === "toolsMd" ? [rejectionEvent] : [] };
64
+ return new Response(JSON.stringify(body), {
65
+ status: 200,
66
+ headers: { "Content-Type": "application/json" },
67
+ });
68
+ }) as typeof fetch;
69
+
70
+ const result = await prependProfileSyncRejectionBanner(
71
+ "ORIGINAL TASK PROMPT",
72
+ {
73
+ apiUrl: "https://api.example.test",
74
+ apiKey: "secret-key",
75
+ agentId: "agent-1",
76
+ },
77
+ fetchImpl,
78
+ );
79
+
80
+ expect(requests[0]?.url).toContain("event=system.profile_sync_rejected");
81
+ expect(requests[0]?.url).toContain("agentId=agent-1");
82
+ expect(requests[0]?.url).toContain("limit=1");
83
+ expect(requests[0]?.url).toContain("dataField=soulMd");
84
+ expect(requests[0]?.headers.get("X-Agent-ID")).toBe("agent-1");
85
+ expect(requests.at(-1)?.url).toBe("https://api.example.test/me");
86
+ expect(result.injected).toBeTrue();
87
+ expect(result.prompt).toContain("PERSISTED PROFILE SYNC REJECTION");
88
+ expect(result.prompt).toEndWith("ORIGINAL TASK PROMPT");
89
+ });
90
+
91
+ test("stops warning after the stored field changes", async () => {
92
+ const fetchImpl = (async (input: string | URL | Request) => {
93
+ const url = String(input);
94
+ const field = new URL(url).searchParams.get("dataField");
95
+ const body = url.endsWith("/me")
96
+ ? { toolsMd: "reconciled tools" }
97
+ : { events: field === "toolsMd" ? [rejectionEvent] : [] };
98
+ return new Response(JSON.stringify(body), {
99
+ status: 200,
100
+ headers: { "Content-Type": "application/json" },
101
+ });
102
+ }) as typeof fetch;
103
+
104
+ const banner = await fetchProfileSyncRejectionBanner(
105
+ {
106
+ apiUrl: "https://api.example.test",
107
+ apiKey: "secret-key",
108
+ agentId: "agent-1",
109
+ },
110
+ fetchImpl,
111
+ );
112
+
113
+ expect(banner).toBe("");
114
+ });
115
+
116
+ test("stops warning after a successful no-op reconciliation", async () => {
117
+ const fetchImpl = (async (input: string | URL | Request) => {
118
+ const url = new URL(String(input));
119
+ const field = url.searchParams.get("dataField");
120
+ const event = url.searchParams.get("event");
121
+ const body = url.pathname.endsWith("/me")
122
+ ? { toolsMd: "db tools" }
123
+ : {
124
+ events:
125
+ field !== "toolsMd"
126
+ ? []
127
+ : event === "system.profile_sync_reconciled"
128
+ ? [reconciliationEvent]
129
+ : [rejectionEvent],
130
+ };
131
+ return new Response(JSON.stringify(body), {
132
+ status: 200,
133
+ headers: { "Content-Type": "application/json" },
134
+ });
135
+ }) as typeof fetch;
136
+
137
+ const banner = await fetchProfileSyncRejectionBanner(
138
+ {
139
+ apiUrl: "https://api.example.test",
140
+ apiKey: "secret-key",
141
+ agentId: "agent-1",
142
+ },
143
+ fetchImpl,
144
+ );
145
+
146
+ expect(banner).toBe("");
147
+ });
148
+
149
+ test("keeps warning when the latest reconciliation predates the rejection", async () => {
150
+ const olderReconciliation = {
151
+ ...reconciliationEvent,
152
+ createdAt: "2026-08-18T15:50:00.000Z",
153
+ };
154
+ const fetchImpl = (async (input: string | URL | Request) => {
155
+ const url = new URL(String(input));
156
+ const field = url.searchParams.get("dataField");
157
+ const event = url.searchParams.get("event");
158
+ const body = url.pathname.endsWith("/me")
159
+ ? { toolsMd: "db tools" }
160
+ : {
161
+ events:
162
+ field !== "toolsMd"
163
+ ? []
164
+ : event === "system.profile_sync_reconciled"
165
+ ? [olderReconciliation]
166
+ : [rejectionEvent],
167
+ };
168
+ return new Response(JSON.stringify(body), {
169
+ status: 200,
170
+ headers: { "Content-Type": "application/json" },
171
+ });
172
+ }) as typeof fetch;
173
+
174
+ const banner = await fetchProfileSyncRejectionBanner(
175
+ {
176
+ apiUrl: "https://api.example.test",
177
+ apiKey: "secret-key",
178
+ agentId: "agent-1",
179
+ },
180
+ fetchImpl,
181
+ );
182
+
183
+ expect(banner).toContain("PERSISTED PROFILE SYNC REJECTION");
184
+ });
185
+
186
+ test("clears a warning only when boot restored the local file to the DB value", async () => {
187
+ const fetchImpl = (async (input: string | URL | Request) => {
188
+ const url = new URL(String(input));
189
+ const field = url.searchParams.get("dataField");
190
+ const event = url.searchParams.get("event");
191
+ const body = url.pathname.endsWith("/me")
192
+ ? { toolsMd: "db tools" }
193
+ : {
194
+ events:
195
+ field === "toolsMd" && event === "system.profile_sync_rejected"
196
+ ? [rejectionEvent]
197
+ : [],
198
+ };
199
+ return new Response(JSON.stringify(body), {
200
+ status: 200,
201
+ headers: { "Content-Type": "application/json" },
202
+ });
203
+ }) as typeof fetch;
204
+ const config = {
205
+ apiUrl: "https://api.example.test",
206
+ apiKey: "secret-key",
207
+ agentId: "agent-1",
208
+ claudeMdPath: WORKSPACE_CLAUDE_MD_PATH,
209
+ };
210
+
211
+ const restored = await fetchProfileSyncRejectionBanner(config, fetchImpl, async (path) =>
212
+ path === TOOLS_MD_PATH ? "db tools" : undefined,
213
+ );
214
+ const stillDiverged = await fetchProfileSyncRejectionBanner(config, fetchImpl, async (path) =>
215
+ path === TOOLS_MD_PATH ? "rejected tools" : undefined,
216
+ );
217
+
218
+ expect(restored).toBe("");
219
+ expect(stillDiverged).toContain("PERSISTED PROFILE SYNC REJECTION");
220
+ });
221
+
222
+ test("surfaces the latest unresolved rejection for every affected field", async () => {
223
+ const claudeEvent: SwarmEvent = {
224
+ ...rejectionEvent,
225
+ id: "00000000-0000-4000-8000-000000000002",
226
+ data: {
227
+ ...rejectionEvent.data,
228
+ field: "claudeMd",
229
+ diskSize: 99_617,
230
+ dbSize: 99_602,
231
+ delta: 15,
232
+ dbHash: contentSha256("db claude"),
233
+ },
234
+ };
235
+ const fetchImpl = (async (input: string | URL | Request) => {
236
+ const url = String(input);
237
+ const field = new URL(url).searchParams.get("dataField");
238
+ const body = url.endsWith("/me")
239
+ ? { toolsMd: "db tools", claudeMd: "db claude" }
240
+ : {
241
+ events:
242
+ field === "claudeMd" ? [claudeEvent] : field === "toolsMd" ? [rejectionEvent] : [],
243
+ };
244
+ return new Response(JSON.stringify(body), {
245
+ status: 200,
246
+ headers: { "Content-Type": "application/json" },
247
+ });
248
+ }) as typeof fetch;
249
+
250
+ const banner = await fetchProfileSyncRejectionBanner(
251
+ {
252
+ apiUrl: "https://api.example.test",
253
+ apiKey: "secret-key",
254
+ agentId: "agent-1",
255
+ },
256
+ fetchImpl,
257
+ );
258
+
259
+ expect(banner).toContain("/workspace/CLAUDE.md");
260
+ expect(banner).toContain("~/.claude/CLAUDE.md");
261
+ expect(banner).toContain("is not archived by that boot step");
262
+ expect(banner).toContain("/workspace/TOOLS.md");
263
+ });
264
+
265
+ test("fails open when the audit event cannot be fetched", async () => {
266
+ const banner = await fetchProfileSyncRejectionBanner(
267
+ {
268
+ apiUrl: "https://api.example.test",
269
+ apiKey: "secret-key",
270
+ agentId: "agent-1",
271
+ },
272
+ (async () => new Response("unavailable", { status: 503 })) as typeof fetch,
273
+ );
274
+
275
+ expect(banner).toBe("");
276
+ });
277
+ });