@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
@@ -1,6 +1,7 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { renderContextBoundNote } from "../prompts/context-bound-note";
4
5
  import { createProviderAdapter } from "../providers";
5
6
  import {
6
7
  ClaudeManagedAdapter,
@@ -13,6 +14,7 @@ import {
13
14
  getClaudeManagedModelPricing,
14
15
  } from "../providers/claude-managed-models";
15
16
  import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
17
+ import { HELD_NOTE_TEXT, holdTemplateRender } from "./fixtures/held-template-render";
16
18
 
17
19
  // Stash + restore env vars so this file plays nicely with the rest of the
18
20
  // suite (other tests don't expect MANAGED_AGENT_ID / MANAGED_ENVIRONMENT_ID
@@ -1503,3 +1505,276 @@ describe("ClaudeManagedAdapter (Phase 6) — full happy-path integration", () =>
1503
1505
  expect(result.output).toBe("Read the file and here's what I found.");
1504
1506
  });
1505
1507
  });
1508
+
1509
+ describe("ClaudeManagedAdapter — one-time context-bound note", () => {
1510
+ const tmpLogDir = `/tmp/claude-managed-adapter-bound-note-${Date.now()}`;
1511
+ let seq = 0;
1512
+
1513
+ beforeAll(() => {
1514
+ mkdirSync(tmpLogDir, { recursive: true });
1515
+ process.env.ANTHROPIC_API_KEY = "sk-test";
1516
+ process.env.MANAGED_AGENT_ID = "agent_x";
1517
+ process.env.MANAGED_ENVIRONMENT_ID = "env_x";
1518
+ });
1519
+
1520
+ afterAll(() => {
1521
+ rmSync(tmpLogDir, { recursive: true, force: true });
1522
+ });
1523
+
1524
+ /** A main-thread model request whose prompt is `input + cacheRead + cacheWrite`. */
1525
+ function requestEnd(input: unknown, cacheRead: unknown = 0, cacheWrite: unknown = 0) {
1526
+ seq += 1;
1527
+ return {
1528
+ type: "span.model_request_end",
1529
+ id: `req-${seq}`,
1530
+ processed_at: "2026-01-01T00:00:00Z",
1531
+ is_error: false,
1532
+ model_request_start_id: `start-${seq}`,
1533
+ model_usage: {
1534
+ input_tokens: input,
1535
+ output_tokens: 10,
1536
+ cache_read_input_tokens: cacheRead,
1537
+ cache_creation_input_tokens: cacheWrite,
1538
+ },
1539
+ };
1540
+ }
1541
+
1542
+ function toolUse(kind: "agent.tool_use" | "agent.mcp_tool_use" = "agent.tool_use") {
1543
+ seq += 1;
1544
+ return {
1545
+ type: kind,
1546
+ id: `tu-${seq}`,
1547
+ processed_at: "2026-01-01T00:00:00Z",
1548
+ name: "read_file",
1549
+ ...(kind === "agent.mcp_tool_use" ? { mcp_server_name: "agent-swarm" } : {}),
1550
+ input: { path: `/tmp/${seq}` },
1551
+ };
1552
+ }
1553
+
1554
+ const idle = () => ({
1555
+ type: "session.status_idle",
1556
+ id: `idle-${++seq}`,
1557
+ processed_at: "2026-01-01T00:00:00Z",
1558
+ stop_reason: { type: "end_turn" },
1559
+ });
1560
+
1561
+ /** Run a session over `events` and return the texts of every message sent after the prompt. */
1562
+ async function runNotes(
1563
+ events: Array<Record<string, unknown>>,
1564
+ threshold: string,
1565
+ onSend?: Parameters<typeof makeFakeClient>[0]["onSend"],
1566
+ model?: string,
1567
+ ) {
1568
+ const spy = makeFakeClient({
1569
+ streamEvents: async function* () {
1570
+ for (const e of events) yield e;
1571
+ },
1572
+ onSend,
1573
+ });
1574
+ const adapter = new ClaudeManagedAdapter({ client: spy.client });
1575
+ const session = await adapter.createSession(
1576
+ tConfig({
1577
+ logFile: join(tmpLogDir, `note-${++seq}.log`),
1578
+ env: { CONTEXT_BOUND_NOTE_TOKENS: threshold },
1579
+ ...(model ? { model } : {}),
1580
+ }),
1581
+ );
1582
+ const result = await session.waitForCompletion();
1583
+ // The note is rendered and sent off the event loop; let it land.
1584
+ await Bun.sleep(150);
1585
+ const notes = spy.sent.slice(1).map((sent) => {
1586
+ const content = sent.events[0]?.content as Array<{ text?: string }> | undefined;
1587
+ return { type: sent.events[0]?.type, text: content?.[0]?.text };
1588
+ });
1589
+ return { result, notes };
1590
+ }
1591
+
1592
+ test("fires once when a tool call follows a request at the threshold, not again", async () => {
1593
+ const { result, notes } = await runNotes(
1594
+ [
1595
+ requestEnd(150_000, 40_000, 10_000),
1596
+ toolUse(),
1597
+ requestEnd(240_000, 10_000, 0),
1598
+ toolUse("agent.mcp_tool_use"),
1599
+ requestEnd(260_000),
1600
+ toolUse(),
1601
+ idle(),
1602
+ ],
1603
+ "200000",
1604
+ );
1605
+ expect(result.isError).toBe(false);
1606
+ expect(notes).toEqual([
1607
+ { type: "user.message", text: await renderContextBoundNote(200_000, "claude-sonnet-4-6") },
1608
+ ]);
1609
+ });
1610
+
1611
+ test("the note shows the window share for a known model and omits it for an unknown one", async () => {
1612
+ const noteFor = async (model: string) =>
1613
+ (await runNotes([requestEnd(250_000), toolUse(), idle()], "200000", undefined, model)).notes;
1614
+ const rest =
1615
+ "Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.";
1616
+ expect(await noteFor("claude-sonnet-4-6")).toEqual([
1617
+ {
1618
+ type: "user.message",
1619
+ text: `Context is 250,000 tokens (25% of the 1,000,000-token window). ${rest}`,
1620
+ },
1621
+ ]);
1622
+ expect(await noteFor("made-up-model")).toEqual([
1623
+ { type: "user.message", text: `Context is 250,000 tokens. ${rest}` },
1624
+ ]);
1625
+ });
1626
+
1627
+ test("no-op below the threshold, then fires once it is crossed", async () => {
1628
+ const { notes } = await runNotes(
1629
+ [requestEnd(199_999), toolUse(), requestEnd(205_000), toolUse(), idle()],
1630
+ "200000",
1631
+ );
1632
+ expect(notes).toEqual([
1633
+ { type: "user.message", text: await renderContextBoundNote(205_000, "claude-sonnet-4-6") },
1634
+ ]);
1635
+ });
1636
+
1637
+ test("off at 0 and when unset", async () => {
1638
+ const events = () => [requestEnd(500_000), toolUse(), idle()];
1639
+ expect((await runNotes(events(), "0")).notes).toEqual([]);
1640
+ expect((await runNotes(events(), "")).notes).toEqual([]);
1641
+ // Control: the same session with a threshold gets the note.
1642
+ expect((await runNotes(events(), "200000")).notes).toHaveLength(1);
1643
+ });
1644
+
1645
+ test("no note when the request ends the turn without a tool call", async () => {
1646
+ expect((await runNotes([requestEnd(300_000), idle()], "200000")).notes).toEqual([]);
1647
+ // Control: a tool call after the same request carries the note.
1648
+ expect((await runNotes([requestEnd(300_000), toolUse(), idle()], "200000")).notes).toHaveLength(
1649
+ 1,
1650
+ );
1651
+ });
1652
+
1653
+ test("fails open on missing or corrupt usage", async () => {
1654
+ const { result, notes } = await runNotes(
1655
+ [
1656
+ // Missing: a tool call before any request reported usage.
1657
+ toolUse(),
1658
+ // Corrupt: non-numeric usage fields.
1659
+ requestEnd("lots", null, {}),
1660
+ toolUse(),
1661
+ requestEnd(Number.NaN),
1662
+ toolUse(),
1663
+ // Control: a valid request afterwards still gets the note.
1664
+ requestEnd(210_000),
1665
+ toolUse(),
1666
+ idle(),
1667
+ ],
1668
+ "200000",
1669
+ );
1670
+ expect(result.isError).toBe(false);
1671
+ expect(notes).toEqual([
1672
+ { type: "user.message", text: await renderContextBoundNote(210_000, "claude-sonnet-4-6") },
1673
+ ]);
1674
+ });
1675
+
1676
+ test("fails open when sending the note rejects", async () => {
1677
+ let sends = 0;
1678
+ const { result, notes } = await runNotes(
1679
+ [requestEnd(300_000), toolUse(), requestEnd(310_000), toolUse(), idle()],
1680
+ "200000",
1681
+ () => {
1682
+ sends += 1;
1683
+ if (sends > 1) throw new Error("send failed");
1684
+ },
1685
+ );
1686
+ expect(result.isError).toBe(false);
1687
+ // One attempt only: a failed send does not re-arm the note.
1688
+ expect(notes).toHaveLength(1);
1689
+ });
1690
+
1691
+ describe("note rendered late", () => {
1692
+ const toolResult = (toolUseId: string) => ({
1693
+ type: "agent.tool_result",
1694
+ id: `tr-${++seq}`,
1695
+ processed_at: "2026-01-01T00:00:00Z",
1696
+ tool_use_id: toolUseId,
1697
+ content: [],
1698
+ });
1699
+
1700
+ /**
1701
+ * Holds the render until the stream reaches `releaseAt`. Returns the
1702
+ * `user.message` events sent after the prompt, read once the late render
1703
+ * had time to land.
1704
+ */
1705
+ async function lateNotes(
1706
+ releaseAt: "tool-open" | "tool-returned" | "idle" | "abort",
1707
+ ): Promise<{ notes: unknown[]; isError: boolean }> {
1708
+ const held = holdTemplateRender();
1709
+ const tool = toolUse();
1710
+ let unblock!: () => void;
1711
+ const blocked = new Promise<void>((resolve) => {
1712
+ unblock = resolve;
1713
+ });
1714
+ const spy = makeFakeClient({
1715
+ streamEvents: async function* () {
1716
+ yield requestEnd(300_000);
1717
+ yield tool;
1718
+ await held.requested;
1719
+ if (releaseAt === "tool-open") {
1720
+ held.release();
1721
+ await Bun.sleep(50);
1722
+ }
1723
+ yield toolResult(tool.id);
1724
+ if (releaseAt === "tool-returned") {
1725
+ held.release();
1726
+ await Bun.sleep(50);
1727
+ }
1728
+ if (releaseAt === "abort") await blocked;
1729
+ yield idle();
1730
+ },
1731
+ });
1732
+ try {
1733
+ const session = await new ClaudeManagedAdapter({ client: spy.client }).createSession(
1734
+ tConfig({
1735
+ logFile: join(tmpLogDir, `late-${++seq}.log`),
1736
+ env: { CONTEXT_BOUND_NOTE_TOKENS: "200000" },
1737
+ }),
1738
+ );
1739
+ if (releaseAt === "abort") {
1740
+ await held.requested;
1741
+ await session.abort();
1742
+ unblock();
1743
+ }
1744
+ const result = await session.waitForCompletion();
1745
+ held.release();
1746
+ await Bun.sleep(100);
1747
+ const notes = spy.sent
1748
+ .slice(1)
1749
+ .filter((sent) => sent.events[0]?.type === "user.message")
1750
+ .map((sent) => sent.events[0]);
1751
+ return { notes, isError: result.isError };
1752
+ } finally {
1753
+ unblock();
1754
+ held.stop();
1755
+ }
1756
+ }
1757
+
1758
+ test("control: a note ready while its tool call is open is sent", async () => {
1759
+ const { notes, isError } = await lateNotes("tool-open");
1760
+ expect(isError).toBe(false);
1761
+ expect(notes).toEqual([
1762
+ { type: "user.message", content: [{ type: "text", text: HELD_NOTE_TEXT }] },
1763
+ ]);
1764
+ });
1765
+
1766
+ test("dropped once the tool result is back and the final reply is pending", async () => {
1767
+ expect((await lateNotes("tool-returned")).notes).toEqual([]);
1768
+ });
1769
+
1770
+ test("dropped after the session went idle", async () => {
1771
+ const { notes, isError } = await lateNotes("idle");
1772
+ expect(isError).toBe(false);
1773
+ expect(notes).toEqual([]);
1774
+ });
1775
+
1776
+ test("dropped after abort", async () => {
1777
+ expect((await lateNotes("abort")).notes).toEqual([]);
1778
+ });
1779
+ });
1780
+ });
@@ -18,6 +18,7 @@ import {
18
18
  } from "node:fs";
19
19
  import { homedir, tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
+ import { renderContextBoundNote } from "../prompts/context-bound-note";
21
22
  import type {
22
23
  AgentMessageItem,
23
24
  CommandExecutionItem,
@@ -33,6 +34,7 @@ import {
33
34
  resolveCodexModel,
34
35
  } from "../providers/codex-models";
35
36
  import type { ProviderEvent, ProviderResult, ProviderSessionConfig } from "../providers/types";
37
+ import { HELD_NOTE_TEXT, holdTemplateRender } from "./fixtures/held-template-render";
36
38
 
37
39
  /**
38
40
  * Build a tiny fake `Thread` whose `runStreamed` returns a fixed sequence of
@@ -2611,3 +2613,164 @@ describe("CodexSession — rate-limit error preservation", () => {
2611
2613
  expect(result.rateLimitResetAt).toBeUndefined();
2612
2614
  });
2613
2615
  });
2616
+
2617
+ describe("CodexSession context-bound note", () => {
2618
+ let prevSkipEnv: string | undefined;
2619
+
2620
+ beforeAll(() => {
2621
+ prevSkipEnv = process.env.SKIP_SESSION_SUMMARY;
2622
+ process.env.SKIP_SESSION_SUMMARY = "1";
2623
+ });
2624
+
2625
+ afterAll(() => {
2626
+ if (prevSkipEnv === undefined) delete process.env.SKIP_SESSION_SUMMARY;
2627
+ else process.env.SKIP_SESSION_SUMMARY = prevSkipEnv;
2628
+ });
2629
+
2630
+ /** One `context.updated` per request; each request's input tokens in order. */
2631
+ function requestEvents(inputs: unknown[]): ThreadEvent[] {
2632
+ return inputs.map(
2633
+ (input_tokens) =>
2634
+ ({
2635
+ type: "context.updated",
2636
+ usage: { input_tokens, cached_input_tokens: 0, output_tokens: 1 },
2637
+ }) as ThreadEvent,
2638
+ );
2639
+ }
2640
+
2641
+ /**
2642
+ * Runs the session over `events` and records every `thread.steer` call. The
2643
+ * turn stays open briefly after the requests so the async note can land.
2644
+ */
2645
+ async function runWithSteers(
2646
+ events: ThreadEvent[],
2647
+ env: Record<string, string>,
2648
+ steer: (input: string) => Promise<void> = async () => {},
2649
+ beforeTurnCompleted: () => Promise<unknown> = () => Bun.sleep(50),
2650
+ model?: string,
2651
+ ): Promise<{ steers: string[]; result: ProviderResult }> {
2652
+ const steers: string[] = [];
2653
+ const thread = {
2654
+ id: null,
2655
+ async steer(input: string) {
2656
+ steers.push(input);
2657
+ await steer(input);
2658
+ },
2659
+ async interrupt() {},
2660
+ async close() {},
2661
+ async runStreamed() {
2662
+ async function* generate(): AsyncGenerator<ThreadEvent> {
2663
+ yield { type: "thread.started", thread_id: "thread-bound-note" };
2664
+ yield { type: "turn.started" };
2665
+ for (const event of events) yield event;
2666
+ await beforeTurnCompleted();
2667
+ yield {
2668
+ type: "turn.completed",
2669
+ usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
2670
+ };
2671
+ }
2672
+ return { events: generate() };
2673
+ },
2674
+ };
2675
+ const adapter = new CodexAdapter({
2676
+ bypassSubprocess: true,
2677
+ threadFactory: () => thread as never,
2678
+ });
2679
+ const session = await adapter.createSession(testConfig({ env, ...(model ? { model } : {}) }));
2680
+ const result = await session.waitForCompletion();
2681
+ return { steers, result };
2682
+ }
2683
+
2684
+ test("steers the note once at the threshold, not again on a larger request", async () => {
2685
+ const { steers, result } = await runWithSteers(requestEvents([400, 1_200, 5_000]), {
2686
+ CONTEXT_BOUND_NOTE_TOKENS: "1000",
2687
+ });
2688
+ expect(result.isError).toBe(false);
2689
+ expect(steers).toEqual([await renderContextBoundNote(1_200, "gpt-5.4")]);
2690
+ expect(steers[0]).toContain("1,200");
2691
+ });
2692
+
2693
+ test("the note shows the window share for a known model and omits it for an unknown one", async () => {
2694
+ const noteFor = async (model: string) =>
2695
+ (
2696
+ await runWithSteers(
2697
+ requestEvents([525_000]),
2698
+ { CONTEXT_BOUND_NOTE_TOKENS: "1000" },
2699
+ undefined,
2700
+ undefined,
2701
+ model,
2702
+ )
2703
+ ).steers;
2704
+ const rest =
2705
+ "Keep this task bounded: finish the current step, store-progress, and complete or defer instead of starting new exploration.";
2706
+ expect(await noteFor("gpt-5.4")).toEqual([
2707
+ `Context is 525,000 tokens (50% of the 1,050,000-token window). ${rest}`,
2708
+ ]);
2709
+ expect(await noteFor("made-up-codex-model")).toEqual([`Context is 525,000 tokens. ${rest}`]);
2710
+ });
2711
+
2712
+ test("off when CONTEXT_BOUND_NOTE_TOKENS is unset", async () => {
2713
+ const { steers } = await runWithSteers(requestEvents([250_000, 900_000]), {});
2714
+ expect(steers).toEqual([]);
2715
+ });
2716
+
2717
+ test("no-op below the threshold", async () => {
2718
+ const { steers } = await runWithSteers(requestEvents([400, 999]), {
2719
+ CONTEXT_BOUND_NOTE_TOKENS: "1000",
2720
+ });
2721
+ expect(steers).toEqual([]);
2722
+ });
2723
+
2724
+ test("off at 0", async () => {
2725
+ const { steers } = await runWithSteers(requestEvents([250_000, 500_000]), {
2726
+ CONTEXT_BOUND_NOTE_TOKENS: "0",
2727
+ });
2728
+ expect(steers).toEqual([]);
2729
+ });
2730
+
2731
+ test("fails open on missing or corrupt usage", async () => {
2732
+ const { steers, result } = await runWithSteers(requestEvents([undefined, "lots", null]), {
2733
+ CONTEXT_BOUND_NOTE_TOKENS: "1000",
2734
+ });
2735
+ expect(steers).toEqual([]);
2736
+ expect(result.isError).toBe(false);
2737
+ });
2738
+
2739
+ test("a rejected steer still lets the session complete", async () => {
2740
+ const { steers, result } = await runWithSteers(
2741
+ requestEvents([5_000]),
2742
+ { CONTEXT_BOUND_NOTE_TOKENS: "1000" },
2743
+ async () => {
2744
+ throw new Error("steer refused");
2745
+ },
2746
+ );
2747
+ expect(steers).toHaveLength(1);
2748
+ expect(result.isError).toBe(false);
2749
+ });
2750
+
2751
+ test("a note rendered after the turn completed is dropped", async () => {
2752
+ const lateSteers = async (releaseInTurn: boolean) => {
2753
+ const held = holdTemplateRender();
2754
+ try {
2755
+ const run = await runWithSteers(
2756
+ requestEvents([5_000]),
2757
+ { CONTEXT_BOUND_NOTE_TOKENS: "1000" },
2758
+ undefined,
2759
+ async () => {
2760
+ await held.requested;
2761
+ if (releaseInTurn) held.release();
2762
+ await Bun.sleep(50);
2763
+ },
2764
+ );
2765
+ held.release();
2766
+ await Bun.sleep(50);
2767
+ return run.steers;
2768
+ } finally {
2769
+ held.stop();
2770
+ }
2771
+ };
2772
+ expect(await lateSteers(false)).toEqual([]);
2773
+ // Control: ready while the turn is still open.
2774
+ expect(await lateSteers(true)).toEqual([HELD_NOTE_TEXT]);
2775
+ });
2776
+ });