@desplega.ai/agent-swarm 2.5.3 → 2.5.4

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 (197) hide show
  1. package/dist/{acp-adapter-gr2fcyyj.js → acp-adapter-ek38cwsv.js} +4 -4
  2. package/dist/{actions-wv766ghf.js → actions-92mepq03.js} +17 -17
  3. package/dist/{amp-adapter-speb906t.js → amp-adapter-9gaxg015.js} +2 -2
  4. package/dist/{app-ag29n14f.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-3x8bz3eg.js → assistant-zyr5kz47.js} +19 -19
  6. package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-f0afqnne.js} +4 -4
  11. package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-bqzwjd9x.js} +1 -1
  13. package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-vwn81jbs.js → cli-19202kew.js} +3 -3
  16. package/dist/{cli-svrj71pg.js → cli-1r5ad7j5.js} +7 -7
  17. package/dist/{cli-hhxjnjzs.js → cli-25n3mv5d.js} +8 -6
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-hcpj2xqz.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-35gtk1p6.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-40jsnrjm.js → cli-5bnsc0e1.js} +7 -5
  22. package/dist/{cli-fzcn3d87.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-s49hpn37.js → cli-64mh9mf3.js} +19 -5
  24. package/dist/{cli-e8jyq7d8.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-p9rafdrb.js → cli-7p4jxgtt.js} +4 -4
  26. package/dist/{cli-w1zwctj2.js → cli-9j129brq.js} +9 -3
  27. package/dist/{cli-ce0ak9ms.js → cli-abs8ndtr.js} +11 -11
  28. package/dist/{cli-aecdcr77.js → cli-asq5ydm4.js} +123 -50
  29. package/dist/{cli-n45mr7zh.js → cli-bvpgr9ny.js} +2 -2
  30. package/dist/{cli-210xd1f4.js → cli-c61mbdds.js} +3 -3
  31. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  32. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  33. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  34. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  35. package/dist/{cli-s53vp709.js → cli-eftzyh9y.js} +1 -1
  36. package/dist/{cli-3ngdk5te.js → cli-ethgy7ha.js} +1 -1
  37. package/dist/{cli-99xgw26r.js → cli-ff1tm9n9.js} +1 -1
  38. package/dist/{cli-ewm3pwbq.js → cli-g9vd9ece.js} +37 -12
  39. package/dist/{cli-jjznk3f7.js → cli-gbfxp48k.js} +62 -28
  40. package/dist/{cli-z21whrrg.js → cli-gn5af0x5.js} +14 -14
  41. package/dist/{cli-1fc85mvt.js → cli-gwhjmh8g.js} +4 -4
  42. package/dist/{cli-avdvgcay.js → cli-hh5we352.js} +24 -5
  43. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  44. package/dist/{cli-kxawjrxg.js → cli-jb30dzvk.js} +332 -36
  45. package/dist/{cli-25j01dpj.js → cli-jbnn4vxb.js} +3 -3
  46. package/dist/{cli-qzme5949.js → cli-k4tjsdx9.js} +44 -39
  47. package/dist/{cli-8s5wx3nz.js → cli-m4ecdxvz.js} +40 -13
  48. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  49. package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
  50. package/dist/{cli-502eqv93.js → cli-s682zgqz.js} +1 -1
  51. package/dist/{cli-fsdprwrg.js → cli-sbwsxvtd.js} +1 -1
  52. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  53. package/dist/{cli-egjggehm.js → cli-tsma7p8v.js} +1 -1
  54. package/dist/{cli-qgf8emrn.js → cli-wghjkr0e.js} +5 -5
  55. package/dist/{cli-p0rzjkya.js → cli-wx2nj19m.js} +29 -10
  56. package/dist/{cli-e2ar4bp1.js → cli-x0f2v69c.js} +111 -42
  57. package/dist/{cli-y561h5a9.js → cli-xtrz6bzs.js} +5 -5
  58. package/dist/{cli-gpsz7g88.js → cli-xzfq4at3.js} +7 -7
  59. package/dist/cli.js +18 -18
  60. package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-wch12q99.js} +2 -2
  61. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  62. package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-41pfcr95.js} +2 -2
  63. package/dist/{commands-t04rt2pc.js → commands-9849dkj6.js} +2 -2
  64. package/dist/{controller-rgna04r2.js → controller-ja0sd95p.js} +27 -1
  65. package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-xp05dj6x.js} +16 -16
  66. package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-623qxqew.js} +1 -2
  67. package/dist/{db-a4m7haw0.js → db-47vj40dy.js} +3 -3
  68. package/dist/{db-24ck09xy.js → db-w759qmx0.js} +2 -2
  69. package/dist/{dispatcher-nwf9ss0q.js → dispatcher-7b8e4z2j.js} +15 -15
  70. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  71. package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-2ezw3xea.js} +1 -2
  72. package/dist/{external-channel-j1e2p34p.js → external-channel-e2s028da.js} +2 -2
  73. package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-ys7gaqee.js} +5 -5
  74. package/dist/{handlers-19xr6dnz.js → handlers-wy1dr6ja.js} +18 -18
  75. package/dist/{heartbeat-vtesda5d.js → heartbeat-w3mntghj.js} +17 -17
  76. package/dist/{hook-8wqaszda.js → hook-3jjywnpb.js} +9 -3
  77. package/dist/{hook-b2hhj6rd.js → hook-g7kvv6x9.js} +3 -2
  78. package/dist/{http-mxcqhhcs.js → http-tkp7d4kb.js} +267 -75
  79. package/dist/{index-srg522m6.js → index-1ef1myq2.js} +15 -15
  80. package/dist/{index-1r8wfpzs.js → index-3n8m9ea2.js} +1 -1
  81. package/dist/{index-kw2mmat7.js → index-nmm79qgr.js} +17 -17
  82. package/dist/{index-n1vbvta9.js → index-q7m26kvs.js} +24 -22
  83. package/dist/{index-spwxjgay.js → index-xdaahthb.js} +15 -15
  84. package/dist/{keepalive-dsd8rtgx.js → keepalive-v4ta8wgg.js} +3 -3
  85. package/dist/{lead-zyerk1x2.js → lead-sawnyqxk.js} +33 -33
  86. package/dist/{maintenance-jqmqwnet.js → maintenance-84n1ex21.js} +1 -1
  87. package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  88. package/dist/{onboard-w4yd7s8t.js → onboard-sfmpe4np.js} +2 -2
  89. package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-jmpst4q4.js} +3 -3
  90. package/dist/{otel-impl-f36byzbd.js → otel-impl-v6cc0zve.js} +1 -1
  91. package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  92. package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-fsk1e1zc.js} +3 -3
  93. package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-prmkdnkw.js} +2 -2
  94. package/dist/{rbac-roles-682k2mt5.js → rbac-roles-zxmxqa39.js} +1 -1
  95. package/dist/{render-v2-hgytz8nn.js → render-v2-pjwxvnkw.js} +3 -3
  96. package/dist/{resume-2dvq348g.js → resume-frdtgfa0.js} +8 -6
  97. package/dist/{session-access-7f21ekq9.js → session-access-fpez4y5q.js} +4 -4
  98. package/dist/{setup-epffxtbk.js → setup-mq6m3m8q.js} +1 -1
  99. package/dist/{work-objects-xz1a5rqk.js → work-objects-kfahhjsq.js} +18 -18
  100. package/dist/{worker-42t8emjt.js → worker-v5e5rxj3.js} +33 -33
  101. package/openapi.json +68 -3
  102. package/package.json +5 -5
  103. package/plugin/commands/create-pr.md +2 -2
  104. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  105. package/src/be/db/approvals.ts +48 -0
  106. package/src/be/db/tasks/write.ts +15 -5
  107. package/src/be/db/workflow-idempotency.ts +183 -0
  108. package/src/be/db/workflow-runs.ts +318 -7
  109. package/src/be/db.ts +67 -17
  110. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  111. package/src/be/sealed-json.ts +1 -0
  112. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  113. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  114. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  115. package/src/be/sql-connections.ts +7 -1
  116. package/src/be/swarm-config-guard.ts +2 -0
  117. package/src/be/users.ts +9 -0
  118. package/src/commands/controller.ts +33 -0
  119. package/src/commands/runner.ts +6 -2
  120. package/src/extensions/apply-task-create.ts +57 -11
  121. package/src/extensions/contract-types.generated.ts +1 -1
  122. package/src/extensions/contract.ts +25 -0
  123. package/src/extensions/dispatcher.ts +4 -0
  124. package/src/extensions/post-bridge.ts +8 -0
  125. package/src/hooks/context-bound-note.ts +196 -0
  126. package/src/hooks/hook.ts +12 -0
  127. package/src/http/config.ts +48 -16
  128. package/src/http/idempotency-key.ts +117 -0
  129. package/src/http/workflows.ts +129 -24
  130. package/src/prompts/context-bound-note.ts +48 -0
  131. package/src/prompts/session-templates.ts +25 -1
  132. package/src/providers/amp-adapter.ts +30 -8
  133. package/src/providers/claude-adapter.ts +29 -4
  134. package/src/providers/claude-managed-adapter.ts +26 -0
  135. package/src/providers/claude-sdk-session.ts +10 -0
  136. package/src/providers/claude-session-events.ts +13 -0
  137. package/src/providers/codex-adapter.ts +12 -0
  138. package/src/providers/opencode-adapter.ts +18 -0
  139. package/src/providers/pi-mono-adapter.ts +35 -0
  140. package/src/runtime-providers/docker.ts +6 -2
  141. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  142. package/src/tests/amp-adapter.test.ts +97 -0
  143. package/src/tests/approval-requests.test.ts +10 -7
  144. package/src/tests/approval-sweeps.test.ts +12 -6
  145. package/src/tests/base-prompt.test.ts +1 -1
  146. package/src/tests/claude-hook-output.test.ts +79 -1
  147. package/src/tests/claude-managed-adapter.test.ts +275 -0
  148. package/src/tests/codex-adapter.test.ts +163 -0
  149. package/src/tests/context-bound-note.test.ts +269 -0
  150. package/src/tests/extensions-pre-task.test.ts +273 -0
  151. package/src/tests/fetch-resolved-env.test.ts +17 -0
  152. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  153. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  154. package/src/tests/fixtures/fake-amp.ts +22 -8
  155. package/src/tests/fixtures/held-template-render.ts +42 -0
  156. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  157. package/src/tests/openapi-route-headers.test.ts +2 -0
  158. package/src/tests/opencode-adapter.test.ts +134 -0
  159. package/src/tests/pi-mono-adapter.test.ts +105 -0
  160. package/src/tests/prompt-template-session.test.ts +4 -3
  161. package/src/tests/runner-push-delivery.test.ts +25 -0
  162. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  163. package/src/tests/runtime-providers-docker.test.ts +2 -0
  164. package/src/tests/script-connections.test.ts +3 -0
  165. package/src/tests/script-run-config-scope.test.ts +91 -3
  166. package/src/tests/session-logs.test.ts +70 -0
  167. package/src/tests/sessions-tree.test.ts +37 -0
  168. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  169. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  170. package/src/tests/workflow-idempotency.test.ts +915 -0
  171. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  172. package/src/utils/context-window.ts +8 -6
  173. package/src/workflows/approval-cancel.ts +15 -13
  174. package/src/workflows/approval-notifications.ts +5 -1
  175. package/src/workflows/checkpoint.ts +6 -3
  176. package/src/workflows/engine.ts +30 -8
  177. package/src/workflows/executors/foreach.ts +2 -2
  178. package/src/workflows/foreach-driver.ts +10 -3
  179. package/src/workflows/foreach-join.ts +29 -6
  180. package/src/workflows/recovery.ts +6 -1
  181. package/src/workflows/resume.ts +168 -56
  182. package/src/workflows/retry-poller.ts +49 -3
  183. package/src/workflows/task-step-routing.ts +29 -7
  184. package/templates/ai-toolbox.manifest.json +8 -8
  185. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  186. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  187. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  188. package/templates/skills/agent-browser/SKILL.md +4 -4
  189. package/templates/skills/agent-browser/content.md +4 -4
  190. package/templates/skills/code-quality/config.json +1 -1
  191. package/templates/skills/code-quality/content.md +2 -2
  192. package/templates/skills/delegate-work/content.md +29 -25
  193. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  194. package/templates/skills/qa/content.md +2 -2
  195. package/templates/skills/researching/content.md +2 -0
  196. package/templates/skills/work-on-task/SKILL.md +1 -1
  197. package/templates/skills/work-on-task/content.md +1 -1
@@ -0,0 +1,269 @@
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ claimContextBoundNote,
8
+ contextBoundNoteThreshold,
9
+ contextStatePath,
10
+ createContextBoundNote,
11
+ recordContextState,
12
+ } from "../hooks/context-bound-note";
13
+ import { renderContextBoundNote } from "../prompts/context-bound-note";
14
+ import { normalizeClaudeMessage } from "../providers/claude-session-events";
15
+
16
+ let dir: string;
17
+ let n = 0;
18
+
19
+ beforeAll(async () => {
20
+ dir = await mkdtemp(join(tmpdir(), "context-bound-note-"));
21
+ });
22
+
23
+ afterAll(async () => {
24
+ await rm(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ /** A fresh task file path with `contextTokens` recorded next to it. */
28
+ function taskFileWith(contextTokens?: number): string {
29
+ const taskFile = join(dir, `task-${++n}.json`);
30
+ if (contextTokens !== undefined) recordContextState(taskFile, contextTokens);
31
+ return taskFile;
32
+ }
33
+
34
+ describe("contextBoundNoteThreshold", () => {
35
+ test("off when unset, blank, invalid or 0", () => {
36
+ expect(contextBoundNoteThreshold({})).toBe(0);
37
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: "" })).toBe(0);
38
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: " " })).toBe(0);
39
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: "-5" })).toBe(0);
40
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: "lots" })).toBe(0);
41
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: "1e5" })).toBe(0);
42
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: "99999999999999999999" })).toBe(
43
+ 0,
44
+ );
45
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: "0" })).toBe(0);
46
+ });
47
+
48
+ test("a positive integer turns the note on at that threshold", () => {
49
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: "150000" })).toBe(150_000);
50
+ expect(contextBoundNoteThreshold({ CONTEXT_BOUND_NOTE_TOKENS: " 200000 " })).toBe(200_000);
51
+ });
52
+ });
53
+
54
+ describe("claimContextBoundNote", () => {
55
+ test("fires once at the threshold, not again on later calls", async () => {
56
+ const taskFile = taskFileWith(200_000);
57
+ expect(await claimContextBoundNote(taskFile, 200_000)).toEqual({ contextTokens: 200_000 });
58
+ expect(await claimContextBoundNote(taskFile, 200_000)).toBeNull();
59
+ recordContextState(taskFile, 400_000);
60
+ expect(await claimContextBoundNote(taskFile, 200_000)).toBeNull();
61
+ });
62
+
63
+ test("concurrent hooks fire the note once", async () => {
64
+ const taskFile = taskFileWith(300_000);
65
+ const results = await Promise.all(
66
+ Array.from({ length: 8 }, () => claimContextBoundNote(taskFile, 200_000)),
67
+ );
68
+ expect(results.filter((r) => r !== null)).toEqual([{ contextTokens: 300_000 }]);
69
+ });
70
+
71
+ test("no-op below the threshold, then fires once it is crossed", async () => {
72
+ const taskFile = taskFileWith(199_999);
73
+ expect(await claimContextBoundNote(taskFile, 200_000)).toBeNull();
74
+ recordContextState(taskFile, 210_000);
75
+ expect(await claimContextBoundNote(taskFile, 200_000)).toEqual({ contextTokens: 210_000 });
76
+ });
77
+
78
+ test("returns the model the session recorded", async () => {
79
+ const taskFile = join(dir, `task-${++n}.json`);
80
+ recordContextState(taskFile, 250_000, "claude-opus-5-5");
81
+ expect(await claimContextBoundNote(taskFile, 200_000)).toEqual({
82
+ contextTokens: 250_000,
83
+ model: "claude-opus-5-5",
84
+ });
85
+ });
86
+
87
+ test("disabled at 0", async () => {
88
+ expect(await claimContextBoundNote(taskFileWith(900_000), 0)).toBeNull();
89
+ });
90
+
91
+ test("fails open on missing task file, missing state or corrupt state", async () => {
92
+ expect(await claimContextBoundNote(undefined, 200_000)).toBeNull();
93
+ expect(await claimContextBoundNote(taskFileWith(), 200_000)).toBeNull();
94
+
95
+ const corrupt = taskFileWith();
96
+ await Bun.write(contextStatePath(corrupt), "{not json");
97
+ expect(await claimContextBoundNote(corrupt, 200_000)).toBeNull();
98
+
99
+ const wrongShape = taskFileWith();
100
+ await Bun.write(contextStatePath(wrongShape), JSON.stringify({ contextTokens: "300000" }));
101
+ expect(await claimContextBoundNote(wrongShape, 200_000)).toBeNull();
102
+ });
103
+
104
+ test("fails open when the marker cannot be written", async () => {
105
+ const taskFile = taskFileWith(300_000);
106
+ const writeFile = spyOn(fsPromises, "writeFile").mockRejectedValue(
107
+ Object.assign(new Error("EIO: i/o error"), { code: "EIO" }),
108
+ );
109
+ try {
110
+ expect(await claimContextBoundNote(taskFile, 200_000)).toBeNull();
111
+ expect(writeFile).toHaveBeenCalledTimes(1);
112
+ } finally {
113
+ writeFile.mockRestore();
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("createContextBoundNote", () => {
119
+ function observer(
120
+ threshold: number,
121
+ inject: (note: string) => unknown = () => {},
122
+ canDeliver: () => boolean = () => true,
123
+ model?: () => string | undefined,
124
+ ) {
125
+ const notes: string[] = [];
126
+ const observe = createContextBoundNote({
127
+ threshold,
128
+ canDeliver,
129
+ model,
130
+ inject: (note) => {
131
+ notes.push(note);
132
+ return inject(note);
133
+ },
134
+ });
135
+ return { observe, notes };
136
+ }
137
+
138
+ test("fires once at the threshold, not again on later requests", async () => {
139
+ const { observe, notes } = observer(200_000);
140
+ await observe(200_000);
141
+ await observe(400_000);
142
+ expect(notes).toEqual([await renderContextBoundNote(200_000)]);
143
+ });
144
+
145
+ test("no-op below the threshold, then fires once it is crossed", async () => {
146
+ const { observe, notes } = observer(200_000);
147
+ await observe(199_999);
148
+ expect(notes).toEqual([]);
149
+ await observe(210_000);
150
+ expect(notes).toEqual([await renderContextBoundNote(210_000)]);
151
+ });
152
+
153
+ test("disabled at 0", async () => {
154
+ const { observe, notes } = observer(0);
155
+ await observe(900_000);
156
+ expect(notes).toEqual([]);
157
+ });
158
+
159
+ test("fails open on missing or corrupt sizes", async () => {
160
+ const { observe, notes } = observer(200_000);
161
+ await observe(undefined);
162
+ await observe(Number.NaN);
163
+ await observe(Number.POSITIVE_INFINITY);
164
+ await observe("300000" as unknown as number);
165
+ expect(notes).toEqual([]);
166
+ });
167
+
168
+ test("never rejects when injection fails, and does not retry", async () => {
169
+ const { observe, notes } = observer(200_000, () => Promise.reject(new Error("closed")));
170
+ await expect(observe(300_000)).resolves.toBeUndefined();
171
+ await observe(300_000);
172
+ expect(notes).toHaveLength(1);
173
+ });
174
+
175
+ test("holds a note rendered after its window closed, then delivers it on the next call", async () => {
176
+ let open = false;
177
+ const { observe, notes } = observer(200_000, undefined, () => open);
178
+ await observe(300_000);
179
+ expect(notes).toEqual([]);
180
+ open = true;
181
+ await observe(500_000);
182
+ // The held note, not a re-render at the later size.
183
+ expect(notes).toEqual([await renderContextBoundNote(300_000)]);
184
+ await observe(600_000);
185
+ expect(notes).toHaveLength(1);
186
+ });
187
+
188
+ test("never delivers when the window stays closed", async () => {
189
+ const { observe, notes } = observer(200_000, undefined, () => false);
190
+ await observe(300_000);
191
+ await observe(400_000);
192
+ expect(notes).toEqual([]);
193
+ });
194
+
195
+ test("renders with the session's model at render time", async () => {
196
+ let model = "made-up-model";
197
+ const { observe, notes } = observer(200_000, undefined, undefined, () => model);
198
+ model = "claude-opus-5-5";
199
+ await observe(210_000);
200
+ expect(notes).toEqual([
201
+ "Context is 210,000 tokens (21% of the 1,000,000-token window). Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.",
202
+ ]);
203
+ });
204
+
205
+ test("checks the window after rendering, not before", async () => {
206
+ let open = true;
207
+ const { observe, notes } = observer(200_000, undefined, () => open);
208
+ const pending = observe(300_000);
209
+ open = false;
210
+ await pending;
211
+ expect(notes).toEqual([]);
212
+ });
213
+ });
214
+
215
+ describe("renderContextBoundNote", () => {
216
+ const rest =
217
+ "Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.";
218
+
219
+ test("renders the code default with a formatted token count and no model", async () => {
220
+ expect(await renderContextBoundNote(213_456)).toBe(`Context is 213,456 tokens. ${rest}`);
221
+ });
222
+
223
+ test("adds the window share for a known model", async () => {
224
+ expect(await renderContextBoundNote(210_000, "claude-opus-5-5")).toBe(
225
+ `Context is 210,000 tokens (21% of the 1,000,000-token window). ${rest}`,
226
+ );
227
+ expect(await renderContextBoundNote(200_000, "gpt-5")).toBe(
228
+ `Context is 200,000 tokens (50% of the 400,000-token window). ${rest}`,
229
+ );
230
+ });
231
+
232
+ test("omits the share for an unknown model instead of using the 200k fallback", async () => {
233
+ expect(await renderContextBoundNote(210_000, "made-up-model")).toBe(
234
+ `Context is 210,000 tokens. ${rest}`,
235
+ );
236
+ });
237
+ });
238
+
239
+ describe("normalizeClaudeMessage requestContextTokens", () => {
240
+ const state = {
241
+ taskId: "task-fixture",
242
+ agentId: "agent-fixture",
243
+ model: "claude-opus-5-5",
244
+ contextWindowSize: 1_000_000,
245
+ transport: "cli" as const,
246
+ };
247
+ const usage = {
248
+ input_tokens: 10,
249
+ cache_read_input_tokens: 180_000,
250
+ cache_creation_input_tokens: 25_000,
251
+ output_tokens: 4_000,
252
+ };
253
+
254
+ test("is input + cache read + cache write of a main-thread request", () => {
255
+ const normalized = normalizeClaudeMessage(
256
+ { type: "assistant", message: { content: [], usage } },
257
+ state,
258
+ );
259
+ expect(normalized.requestContextTokens).toBe(205_010);
260
+ });
261
+
262
+ test("is unset for subagent messages", () => {
263
+ const normalized = normalizeClaudeMessage(
264
+ { type: "assistant", parent_tool_use_id: "toolu_1", message: { content: [], usage } },
265
+ state,
266
+ );
267
+ expect(normalized.requestContextTokens).toBeUndefined();
268
+ });
269
+ });
@@ -22,9 +22,12 @@ import * as dbClient from "../be/db-client";
22
22
  import type { InstallExtensionArgs as ExtensionInstallBody } from "../be/extensions/db";
23
23
  import { installExtension, listExtensionRuns } from "../be/extensions/db";
24
24
  import { getCatalogEntry } from "../extensions/catalog";
25
+ import type { TaskBlockedEvent } from "../extensions/contract";
25
26
  import { disableExtension, enableExtension, stopExtensionRuntime } from "../extensions/lifecycle";
27
+ import { initExtensionPostBridge, teardownExtensionPostBridge } from "../extensions/post-bridge";
26
28
  import { handleTasks } from "../http/tasks";
27
29
  import { dispatchScheduleTarget } from "../scheduler/scheduler";
30
+ import { bufferThreadMessage, instantFlush } from "../slack/thread-buffer";
28
31
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
29
32
  import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
30
33
  import { registerSendTaskTool, sendTaskHandler } from "../tools/send-task";
@@ -145,6 +148,276 @@ describe("extension task boundaries", () => {
145
148
  await client.run("DELETE FROM agents");
146
149
  });
147
150
 
151
+ describe("post.task.blocked", () => {
152
+ const events: unknown[] = [];
153
+ const capture = (event: unknown) => {
154
+ events.push(event);
155
+ };
156
+
157
+ beforeAll(() => {
158
+ initExtensionPostBridge();
159
+ workflowEventBus.on("task.create_blocked", capture);
160
+ });
161
+
162
+ afterAll(() => {
163
+ workflowEventBus.off("task.create_blocked", capture);
164
+ teardownExtensionPostBridge();
165
+ });
166
+
167
+ beforeEach(() => {
168
+ events.length = 0;
169
+ });
170
+
171
+ async function waitForCounter(
172
+ extension: Awaited<ReturnType<typeof installAndEnable>>,
173
+ key: string,
174
+ count = 1,
175
+ ): Promise<void> {
176
+ const deadline = Date.now() + 3_000;
177
+ while (Date.now() < deadline) {
178
+ const entry = await getKv(`task:agent:${extension.agentId}`, key);
179
+ if (entry?.value === count) return;
180
+ await Bun.sleep(10);
181
+ }
182
+ throw new Error(`post.task.blocked was not recorded ${count} time(s)`);
183
+ }
184
+
185
+ async function recordedBlock(
186
+ extension: Awaited<ReturnType<typeof installAndEnable>>,
187
+ count = 1,
188
+ ): Promise<TaskBlockedEvent> {
189
+ await waitForCounter(extension, "ext:record-blocks:count", count);
190
+ return (await getKv(`task:agent:${extension.agentId}`, "ext:record-blocks:last"))
191
+ ?.value as TaskBlockedEvent;
192
+ }
193
+
194
+ test.each([
195
+ "rest",
196
+ "mcp",
197
+ "slack",
198
+ "schedule",
199
+ ] as const)("%s delivers one blocked event with its source and requester", async (origin) => {
200
+ const blocker = await installAndEnable("block-tasks-from-source", { source: origin });
201
+ const recorder = await installAndEnable("record-blocks");
202
+ let requester: TaskBlockedEvent["requester"] = {};
203
+ let scheduleId: string | undefined;
204
+
205
+ if (origin === "rest") {
206
+ const user = await createUser({ name: "Blocked REST requester" });
207
+ requester = { userId: user.id };
208
+ expect(
209
+ (await postTask({ task: "blocked REST task", requestedByUserId: user.id })).status,
210
+ ).toBe(422);
211
+ } else if (origin === "mcp") {
212
+ const caller = await createAgent({ name: "blocked-mcp-caller", status: "idle" });
213
+ requester = { agentId: caller.id };
214
+ const server = new McpServer({ name: "blocked-event-send", version: "1.0.0" });
215
+ registerSendTaskTool(server);
216
+ const result = await registeredSendTask(server).handler(sendTaskArgs("blocked MCP task"), {
217
+ sessionId: "blocked-event",
218
+ requestInfo: { headers: { "x-agent-id": caller.id } },
219
+ });
220
+ expect(result.isError).toBe(true);
221
+ } else if (origin === "slack") {
222
+ requester = { slackUserId: "U_BLOCKED_EVENT" };
223
+ await createAgent({ name: "blocked-slack-lead", isLead: true, status: "idle" });
224
+ bufferThreadMessage(
225
+ "C_BLOCKED_EVENT",
226
+ "1000.0001",
227
+ "blocked Slack task",
228
+ "U_BLOCKED_EVENT",
229
+ "1000.0002",
230
+ );
231
+ await instantFlush("C_BLOCKED_EVENT:1000.0001");
232
+ } else {
233
+ const schedule = await createScheduledTask({
234
+ name: "blocked-event-schedule",
235
+ taskTemplate: "blocked schedule task",
236
+ intervalMs: 60_000,
237
+ });
238
+ scheduleId = schedule.id;
239
+ await expect(dispatchScheduleTarget(schedule)).rejects.toThrow(
240
+ "Task source schedule is blocked",
241
+ );
242
+ }
243
+
244
+ const event = await recordedBlock(recorder);
245
+ expect(events).toHaveLength(1);
246
+ expect(event).toMatchObject({
247
+ extension: { id: blocker.id, name: "block-tasks-from-source" },
248
+ origin,
249
+ source: origin === "rest" ? "api" : origin,
250
+ requester,
251
+ });
252
+ expect(event.requester).toEqual(requester);
253
+ if (scheduleId) expect(event.context?.scheduleId).toBe(scheduleId);
254
+ expect(event).not.toHaveProperty("task");
255
+ expect(event).not.toHaveProperty("taskId");
256
+ });
257
+
258
+ test("scrubs and bounds the payload, preserving correlation on each retry", async () => {
259
+ const secret = `ghp_${"Z".repeat(36)}`;
260
+ await installSource(
261
+ "secret-blocker",
262
+ `
263
+ import { block, type SwarmExtension } from "swarm-extension";
264
+ const extension: SwarmExtension = (api) => {
265
+ api.on("pre.task.create", () => block(${JSON.stringify(`Ticket required: ${secret}`)}));
266
+ };
267
+ export default extension;
268
+ `,
269
+ );
270
+ const recorder = await installAndEnable("record-blocks");
271
+ const context = {
272
+ contextKey: "github:acme/project:7",
273
+ scheduleId: crypto.randomUUID(),
274
+ workflowRunId: crypto.randomUUID(),
275
+ workflowRunStepId: crypto.randomUUID(),
276
+ parentTaskId: crypto.randomUUID(),
277
+ vcsRepo: "acme/project",
278
+ vcsNumber: 7,
279
+ slackChannelId: "C_CORRELATED_BLOCK",
280
+ slackThreadTs: "2000.0001",
281
+ };
282
+ const requester = {
283
+ userId: crypto.randomUUID(),
284
+ agentId: crypto.randomUUID(),
285
+ slackUserId: "U_CORRELATED_BLOCK",
286
+ };
287
+ for (const attempt of [1, 2]) {
288
+ await expect(
289
+ createTaskWithSiblingAwareness(
290
+ `${"x".repeat(480)} ${secret}`,
291
+ {
292
+ ...context,
293
+ source: "github",
294
+ requestedByUserId: requester.userId,
295
+ creatorAgentId: requester.agentId,
296
+ slackUserId: requester.slackUserId,
297
+ },
298
+ { origin: "webhook" },
299
+ ),
300
+ ).rejects.toThrow("Ticket required:");
301
+ const event = await recordedBlock(recorder, attempt);
302
+ expect(events).toHaveLength(attempt);
303
+ expect(event.reason).toContain("[REDACTED:");
304
+ expect(event.reason).not.toContain(secret);
305
+ expect(event.description).toHaveLength(500);
306
+ expect(event.description).not.toContain("ghp_");
307
+ expect(event.context).toEqual(context);
308
+ expect(event.requester).toEqual(requester);
309
+ }
310
+ });
311
+
312
+ test("a rejected model rewrite emits a block attributed to its editor", async () => {
313
+ const codex = await createAgent({
314
+ name: "blocked-rewrite-codex",
315
+ harnessProvider: "codex",
316
+ status: "idle",
317
+ });
318
+ const editor = await installSource(
319
+ "incompatible-model",
320
+ `
321
+ import { modify, type SwarmExtension } from "swarm-extension";
322
+ const extension: SwarmExtension = (api) => {
323
+ api.on("pre.task.create", () => modify({ model: "claude-opus-5-5" }));
324
+ };
325
+ export default extension;
326
+ `,
327
+ );
328
+ const recorder = await installAndEnable("record-blocks");
329
+ const result = await postTask({
330
+ task: "incompatible rewrite",
331
+ agentId: codex.id,
332
+ routingReason: "human_pinned",
333
+ });
334
+ expect(result.status).toBe(422);
335
+ const event = await recordedBlock(recorder);
336
+ expect(events).toHaveLength(1);
337
+ expect(event.extension).toEqual({ id: editor.id, name: "incompatible-model" });
338
+ expect(event.reason).toContain("rewrite");
339
+ expect(event.reason).toContain("codex");
340
+ });
341
+
342
+ test("the creator is skipped while the blocking extension receives its own block", async () => {
343
+ const creator = await installAndEnable("record-blocks");
344
+ const blocker = await installSource(
345
+ "blocking-recorder",
346
+ `
347
+ import { block, type SwarmExtension } from "swarm-extension";
348
+ const extension: SwarmExtension = (api) => {
349
+ api.on("pre.task.create", () => block("blocked by recorder"));
350
+ api.on("post.task.blocked", async (_event, ctx) => { await ctx.state.incr("count"); });
351
+ };
352
+ export default extension;
353
+ `,
354
+ );
355
+ const observerBundle = await loadBundleFixture("record-blocks");
356
+ const installedObserver = await installExtension({
357
+ ...observerBundle,
358
+ manifest: { ...observerBundle.manifest, name: "last-block-observer" },
359
+ priority: 1_000,
360
+ });
361
+ const observer = await enableExtension(installedObserver.extension.id);
362
+ const server = new McpServer({ name: "extension-blocked-event", version: "1.0.0" });
363
+ registerSendTaskTool(server);
364
+ const result = await registeredSendTask(server).handler(
365
+ sendTaskArgs("extension task"),
366
+ markExtensionRequestOrigin({
367
+ sessionId: "blocked-extension",
368
+ requestInfo: { headers: { "x-agent-id": creator.agentId! } },
369
+ }),
370
+ );
371
+ expect(result.isError).toBe(true);
372
+ // The final subscriber acknowledges only after every earlier recipient has finished.
373
+ await waitForCounter(observer, "ext:last-block-observer:count");
374
+ expect(
375
+ (await getKv(`task:agent:${blocker.agentId}`, "ext:blocking-recorder:count"))?.value,
376
+ ).toBe(1);
377
+ expect(events).toHaveLength(1);
378
+ expect(events[0]).toMatchObject({ origin: "extension:record-blocks" });
379
+ expect(await getKv(`task:agent:${creator.agentId}`, "ext:record-blocks:count")).toBeNull();
380
+ expect(await listExtensionRuns(creator.id)).toEqual([]);
381
+ const runs = await listExtensionRuns(blocker.id);
382
+ expect(runs).toContainEqual(
383
+ expect.objectContaining({
384
+ event: "post.task.blocked",
385
+ agentId: creator.agentId,
386
+ subject: "extension:record-blocks: blocking-recorder",
387
+ }),
388
+ );
389
+ });
390
+
391
+ test("continue and valid rewrites emit no blocked event", async () => {
392
+ const recorder = await installAndEnable("record-blocks");
393
+ expect((await postTask({ task: "continued task" })).status).toBe(201);
394
+ await installAndEnable("rewrite-task-priority");
395
+ const modified = await postTask({ task: "rewritten task" });
396
+ expect(modified.status).toBe(201);
397
+ expect(modified.body.priority).toBe(1);
398
+ expect(events).toEqual([]);
399
+ expect(await getKv(`task:agent:${recorder.agentId}`, "ext:record-blocks:count")).toBeNull();
400
+ });
401
+
402
+ test("a throwing bus observer does not replace the blocked response", async () => {
403
+ await installAndEnable("block-tasks-from-source", { source: "rest" });
404
+ let calls = 0;
405
+ const observer = () => {
406
+ calls++;
407
+ throw new Error("broken block observer");
408
+ };
409
+ const error = spyOn(console, "error").mockImplementation(() => {});
410
+ workflowEventBus.on("task.create_blocked", observer);
411
+ try {
412
+ expect((await postTask({ task: "still blocked" })).status).toBe(422);
413
+ expect(calls).toBe(1);
414
+ } finally {
415
+ workflowEventBus.off("task.create_blocked", observer);
416
+ error.mockRestore();
417
+ }
418
+ });
419
+ });
420
+
148
421
  test("REST task creation applies rewrites and reports blocks as 422", async () => {
149
422
  const rewrite = await installAndEnable("rewrite-task-priority");
150
423
  const created = await postTask({ task: "rewrite this task" });
@@ -14,6 +14,7 @@ import {
14
14
  fetchResolvedEnv,
15
15
  RELOADABLE_ENV_KEYS,
16
16
  } from "../commands/runner";
17
+ import { contextBoundNoteThreshold } from "../hooks/context-bound-note";
17
18
  import { checkCodexCredentials } from "../providers/codex-adapter";
18
19
  import { MASKED_SECRET_VALUE } from "../utils/config-secrets";
19
20
  import { registerVolatileSecret } from "../utils/secret-scrubber";
@@ -795,6 +796,22 @@ describe("agent-scoped PI_CODEMODE_MODELS reaches the worker env", () => {
795
796
  });
796
797
  });
797
798
 
799
+ describe("agent-scoped CONTEXT_BOUND_NOTE_TOKENS turns the note on for one agent", () => {
800
+ // Each session's env is this agent's /api/config/resolved result (global,
801
+ // then agent, then repo rows), and every adapter reads the threshold from it.
802
+ test("the agent with the row gets the threshold; an agent without it stays off", async () => {
803
+ mockResponsesByAgentId.set("bound-note-pilot", {
804
+ status: 200,
805
+ body: { configs: [{ key: "CONTEXT_BOUND_NOTE_TOKENS", value: "150000" }] },
806
+ });
807
+ const baseEnv: Record<string, string | undefined> = {};
808
+ const pilot = await fetchResolvedEnv(testUrl, "key", "bound-note-pilot", baseEnv);
809
+ const other = await fetchResolvedEnv(testUrl, "key", "bound-note-other", baseEnv);
810
+ expect(contextBoundNoteThreshold(pilot.env)).toBe(150_000);
811
+ expect(contextBoundNoteThreshold(other.env)).toBe(0);
812
+ });
813
+ });
814
+
798
815
  describe("deleting a reloadable config row", () => {
799
816
  // One key per test: the runner remembers each overridden key's boot value
800
817
  // for the life of the process.
@@ -0,0 +1,10 @@
1
+ import type { SwarmExtension } from "swarm-extension";
2
+
3
+ const extension: SwarmExtension = (api) => {
4
+ api.on("post.task.blocked", async (event, ctx) => {
5
+ await ctx.state.set("last", event);
6
+ await ctx.state.incr("count");
7
+ });
8
+ };
9
+
10
+ export default extension;
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "record-blocks",
3
+ "description": "Records blocked task attempts",
4
+ "version": "1.0.0",
5
+ "runtime": "api",
6
+ "assets": { "hooks": "hooks.ts" }
7
+ }
@@ -104,12 +104,19 @@ async function main() {
104
104
  );
105
105
 
106
106
  const sessionId = "T-fake-session";
107
- const usage = {
108
- input_tokens: 4,
109
- output_tokens: 5,
110
- cache_creation_input_tokens: 1000,
111
- cache_read_input_tokens: 200,
112
- };
107
+ // `AMP_TEST_REQUEST_USAGE`: "missing" drops per-request usage, "corrupt" sends non-numbers.
108
+ const requestUsage = process.env.AMP_TEST_REQUEST_USAGE;
109
+ const usage =
110
+ requestUsage === "missing"
111
+ ? undefined
112
+ : requestUsage === "corrupt"
113
+ ? { input_tokens: "lots", cache_creation_input_tokens: null, cache_read_input_tokens: {} }
114
+ : {
115
+ input_tokens: 4,
116
+ output_tokens: 5,
117
+ cache_creation_input_tokens: 1000,
118
+ cache_read_input_tokens: 200,
119
+ };
113
120
  // Amp runs shell commands in a session of its own, outside the process group.
114
121
  // Return only once the child has left: a group kill that lands before its
115
122
  // setsid() would take it down and hide a leak.
@@ -217,8 +224,13 @@ async function main() {
217
224
  session_id: sessionId,
218
225
  });
219
226
  turns += 1;
220
- const steerTool = mode === "steer" || mode.startsWith("steer-crash");
221
- if (mode === "tool" || (steerTool && index === 0)) {
227
+ const steerTool = mode === "steer" || mode === "steer-twice" || mode.startsWith("steer-crash");
228
+ const toolCalls = mode === "steer-twice" ? 2 : 1;
229
+ for (
230
+ let call = 0;
231
+ call < toolCalls && (mode === "tool" || (steerTool && index === 0));
232
+ call++
233
+ ) {
222
234
  assistant(
223
235
  [{ type: "tool_use", id: "TU-1", name: "code_exec", input: { code: "1+1" } }],
224
236
  "tool_use",
@@ -261,6 +273,8 @@ async function main() {
261
273
  if (mode === "success-child") await startDetachedChild();
262
274
  // A turn that thinks for a while with no tool call.
263
275
  if (mode === "steer-no-tool") await Bun.sleep(600);
276
+ // `AMP_TEST_POST_RESULT_DELAY_MS`: the final reply takes this long after the last tool result.
277
+ await Bun.sleep(Number(process.env.AMP_TEST_POST_RESULT_DELAY_MS) || 0);
264
278
  lastText = mode.startsWith("steer") ? `DONE${index + 1}` : "Done ✓";
265
279
  assistant([{ type: "text", text: lastText }], "end_turn");
266
280
  };