@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
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createServer, type Server } from "node:http";
4
4
  import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
5
- import { mintScriptRunToken, revokeSessionToken } from "../be/users";
5
+ import { mintAgentToken, mintScriptRunToken, revokeSessionToken } from "../be/users";
6
6
  import { handleConfig } from "../http/config";
7
7
  import { handleCore } from "../http/core";
8
8
  import { handleSessions } from "../http/sessions";
@@ -17,12 +17,16 @@ import { listenOnFreePort } from "./test-net";
17
17
  const API_KEY = `script-run-config-operator-${randomBytes(8).toString("hex")}`;
18
18
  const WORKER_SECRET = `worker-secret-${randomBytes(8).toString("hex")}`;
19
19
  const OTHER_SECRET = `other-secret-${randomBytes(8).toString("hex")}`;
20
+ const GLOBAL_SECRET = `global-secret-${randomBytes(8).toString("hex")}`;
21
+ const SHADOWED_GLOBAL = `shadowed-global-${randomBytes(8).toString("hex")}`;
22
+ const SHADOWING_AGENT = `shadowing-agent-${randomBytes(8).toString("hex")}`;
20
23
 
21
24
  let server: Server;
22
25
  let baseUrl: string;
23
26
  let workerId: string;
24
27
  let otherId: string;
25
28
  let otherConfigId: string;
29
+ let ownConfigId: string;
26
30
 
27
31
  function headers(bearer: string, agentId?: string): Record<string, string> {
28
32
  return {
@@ -36,11 +40,32 @@ beforeAll(async () => {
36
40
  initDb(":memory:");
37
41
  workerId = (await createAgent({ name: "script-run-worker", isLead: false, status: "idle" })).id;
38
42
  otherId = (await createAgent({ name: "script-run-other", isLead: false, status: "idle" })).id;
43
+ ownConfigId = (
44
+ await upsertSwarmConfig({
45
+ scope: "agent",
46
+ scopeId: workerId,
47
+ key: "OWN_SECRET",
48
+ value: WORKER_SECRET,
49
+ isSecret: true,
50
+ })
51
+ ).id;
52
+ await upsertSwarmConfig({
53
+ scope: "global",
54
+ key: "GLOBAL_SECRET",
55
+ value: GLOBAL_SECRET,
56
+ isSecret: true,
57
+ });
58
+ await upsertSwarmConfig({
59
+ scope: "global",
60
+ key: "SHADOWED",
61
+ value: SHADOWED_GLOBAL,
62
+ isSecret: true,
63
+ });
39
64
  await upsertSwarmConfig({
40
65
  scope: "agent",
41
66
  scopeId: workerId,
42
- key: "OWN_SECRET",
43
- value: WORKER_SECRET,
67
+ key: "SHADOWED",
68
+ value: SHADOWING_AGENT,
44
69
  isSecret: true,
45
70
  });
46
71
  otherConfigId = (
@@ -118,3 +143,66 @@ describe("script run token is scoped to its executing agent", () => {
118
143
  }
119
144
  });
120
145
  });
146
+
147
+ // `ctx.swarm.config` holds the run's resolved config (own agent over global).
148
+ // The raw list and by-id reads must hand the run's token those same values,
149
+ // and only those, or a script that reads a key through `config_list` gets a
150
+ // masked placeholder and fails while the run already holds the real value.
151
+ describe("script run token reads its held secrets on the raw config routes", () => {
152
+ test("list reveals the rows the run holds and masks the rest", async () => {
153
+ const run = await mintScriptRunToken(workerId, 60_000);
154
+ try {
155
+ const res = await fetch(`${baseUrl}/api/config?includeSecrets=true`, {
156
+ headers: headers(run.plaintext),
157
+ });
158
+ expect(res.status).toBe(200);
159
+ const body = await res.text();
160
+ expect(body).toContain(WORKER_SECRET);
161
+ expect(body).toContain(GLOBAL_SECRET);
162
+ expect(body).toContain(SHADOWING_AGENT);
163
+ // The run holds the agent row for SHADOWED, not the global one.
164
+ expect(body).not.toContain(SHADOWED_GLOBAL);
165
+ expect(body).not.toContain(OTHER_SECRET);
166
+ } finally {
167
+ await revokeSessionToken(run.tokenId);
168
+ }
169
+ });
170
+
171
+ test("by-id reveals an own row and masks another agent's row", async () => {
172
+ const run = await mintScriptRunToken(workerId, 60_000);
173
+ try {
174
+ const own = await fetch(`${baseUrl}/api/config/${ownConfigId}?includeSecrets=true`, {
175
+ headers: headers(run.plaintext),
176
+ });
177
+ expect(await own.text()).toContain(WORKER_SECRET);
178
+ const other = await fetch(`${baseUrl}/api/config/${otherConfigId}?includeSecrets=true`, {
179
+ headers: headers(run.plaintext),
180
+ });
181
+ expect(other.status).toBe(200);
182
+ expect(await other.text()).not.toContain(OTHER_SECRET);
183
+ } finally {
184
+ await revokeSessionToken(run.tokenId);
185
+ }
186
+ });
187
+
188
+ test("without includeSecrets the run token still gets masked values", async () => {
189
+ const run = await mintScriptRunToken(workerId, 60_000);
190
+ try {
191
+ const res = await fetch(`${baseUrl}/api/config`, { headers: headers(run.plaintext) });
192
+ expect(await res.text()).not.toContain(WORKER_SECRET);
193
+ } finally {
194
+ await revokeSessionToken(run.tokenId);
195
+ }
196
+ });
197
+
198
+ test("the worker's own agent token keeps the masked list", async () => {
199
+ const worker = await mintAgentToken(workerId);
200
+ const res = await fetch(`${baseUrl}/api/config?includeSecrets=true`, {
201
+ headers: headers(worker.plaintext),
202
+ });
203
+ expect(res.status).toBe(200);
204
+ const body = await res.text();
205
+ expect(body).not.toContain(WORKER_SECRET);
206
+ expect(body).not.toContain(GLOBAL_SECRET);
207
+ });
208
+ });
@@ -5,6 +5,7 @@ import {
5
5
  closeDb,
6
6
  createSessionLogs,
7
7
  createTaskExtended,
8
+ getDbClient,
8
9
  getSessionLogsBySession,
9
10
  getSessionLogsByTaskId,
10
11
  getTaskById,
@@ -226,6 +227,75 @@ describe("Session Logs API", () => {
226
227
  expect(logs[3]?.iteration).toBe(2);
227
228
  expect(logs[3]?.lineNumber).toBe(1);
228
229
  });
230
+
231
+ test("keeps write order across many flushes of one iteration", async () => {
232
+ const task = await createTaskExtended("Task for multi-flush ordering");
233
+ // The runner flushes every 50 lines or 5s, so one iteration arrives as
234
+ // many POSTs. Each POST used to restart lineNumber at 0.
235
+ const written: string[] = [];
236
+ for (let flush = 0; flush < 3; flush++) {
237
+ const lines = [0, 1, 2, 3].map((i) => `flush${flush}-line${i}`);
238
+ written.push(...lines);
239
+ await createSessionLogs({
240
+ taskId: task.id,
241
+ sessionId: "multi-flush-session",
242
+ iteration: 1,
243
+ cli: "claude",
244
+ lines,
245
+ });
246
+ }
247
+
248
+ const all = await getSessionLogsByTaskId(task.id);
249
+ expect(all.map((l) => l.content)).toEqual(written);
250
+ expect(all.map((l) => l.lineNumber)).toEqual(written.map((_, i) => i));
251
+
252
+ const tail = await getSessionLogsByTaskId(task.id, 5);
253
+ expect(tail.map((l) => l.content)).toEqual(written.slice(-5));
254
+
255
+ const bySession = await getSessionLogsBySession("multi-flush-session", 1);
256
+ expect(bySession.map((l) => l.content)).toEqual(written);
257
+ });
258
+
259
+ test("reads rows stored with per-flush lineNumbers in write order", async () => {
260
+ const task = await createTaskExtended("Task with legacy session log rows");
261
+ // Rows written before the fix: lineNumber restarts per POST, and
262
+ // createdAt ties inside one POST.
263
+ const legacy = [
264
+ ["a0", 0],
265
+ ["a1", 1],
266
+ ["a2", 2],
267
+ ["b0", 0],
268
+ ["b1", 1],
269
+ ["c0", 0],
270
+ ] as const;
271
+ await getDbClient().transaction(async (tx) => {
272
+ for (const [content, lineNumber] of legacy) {
273
+ await tx.run(
274
+ `INSERT INTO session_logs (id, taskId, sessionId, iteration, cli, content, lineNumber, createdAt)
275
+ VALUES (?, ?, 'legacy-session', 1, 'claude', ?, ?, '2026-10-01T00:00:00.000Z')`,
276
+ [crypto.randomUUID(), task.id, content, lineNumber],
277
+ );
278
+ }
279
+ });
280
+ // A flush after deploy lands behind the legacy rows.
281
+ await createSessionLogs({
282
+ taskId: task.id,
283
+ sessionId: "legacy-session",
284
+ iteration: 1,
285
+ cli: "claude",
286
+ lines: ["d0", "d1"],
287
+ });
288
+
289
+ const written = ["a0", "a1", "a2", "b0", "b1", "c0", "d0", "d1"];
290
+ const all = await getSessionLogsByTaskId(task.id);
291
+ expect(all.map((l) => l.content)).toEqual(written);
292
+
293
+ const tail = await getSessionLogsByTaskId(task.id, 4);
294
+ expect(tail.map((l) => l.content)).toEqual(["b1", "c0", "d0", "d1"]);
295
+
296
+ const bySession = await getSessionLogsBySession("legacy-session", 1);
297
+ expect(bySession.map((l) => l.content)).toEqual(written);
298
+ });
229
299
  });
230
300
 
231
301
  describe("POST /api/session-logs", () => {
@@ -4,6 +4,8 @@ import { keyPrefixRange, sessionGroupKey } from "../assets/key";
4
4
  import {
5
5
  closeDb,
6
6
  countSessions,
7
+ createAgent,
8
+ createSessionCost,
7
9
  createSteeringMessage,
8
10
  createTaskExtended,
9
11
  createUser,
@@ -605,6 +607,41 @@ test("session detail carries the viewer's read state with the list rule", async
605
607
  expect(bypass.lastActivityAt).toBeUndefined();
606
608
  });
607
609
 
610
+ test("session detail carries each chain task's cost, so the header can total the whole session", async () => {
611
+ const agent = await createAgent({ name: "cost-agent", isLead: false, status: "idle" });
612
+ const root = await task();
613
+ const child = await task("shared/a/", { parentTaskId: root.id });
614
+ const grandchild = await task("shared/a/", { parentTaskId: child.id });
615
+ const followUp = await task("shared/a/", { parentTaskId: root.id });
616
+ const noCost = await task("shared/a/", { parentTaskId: root.id });
617
+ const spend = async (taskId: string, totalCostUsd: number) =>
618
+ createSessionCost({
619
+ sessionId: crypto.randomUUID(),
620
+ taskId,
621
+ agentId: agent.id,
622
+ totalCostUsd,
623
+ durationMs: 1_000,
624
+ numTurns: 1,
625
+ model: "test-model",
626
+ });
627
+ await spend(root.id, 0.2);
628
+ await spend(root.id, 0.0051);
629
+ await spend(child.id, 0.5);
630
+ await spend(grandchild.id, 0.25);
631
+ await spend(followUp.id, 0.335);
632
+
633
+ const res = await fetch(`${baseUrl}/api/sessions/${root.id}`);
634
+ const body = (await res.json()) as { chain: { id: string; totalCostUsd?: number }[] };
635
+ const costs = Object.fromEntries(body.chain.map((t) => [t.id, t.totalCostUsd]));
636
+ expect(costs[root.id]).toBeCloseTo(0.2051, 6);
637
+ expect(costs[child.id]).toBeCloseTo(0.5, 6);
638
+ expect(costs[grandchild.id]).toBeCloseTo(0.25, 6);
639
+ expect(costs[followUp.id]).toBeCloseTo(0.335, 6);
640
+ expect(costs[noCost.id]).toBeUndefined();
641
+ const total = body.chain.reduce((sum, t) => sum + (t.totalCostUsd ?? 0), 0);
642
+ expect(total).toBeCloseTo(1.2901, 6);
643
+ });
644
+
608
645
  test("limit clamps to 200 and count without chain filters does not recurse", async () => {
609
646
  await getDbClient().transaction(async () => {
610
647
  for (let i = 0; i < 205; i++)