@desplega.ai/agent-swarm 1.156.0 → 1.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/dist/{acp-adapter-c1msptrw.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-wd41qtdc.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-tz2qrnah.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-c3q1zj2t.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-0be1krkm.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-r00j03f3.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-9j4hd0vw.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-ncgxz1r2.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-112w28qc.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-k7pb5kfz.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-mx3bzbv8.js → cli-20cj37vt.js} +1968 -44
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-24w39x6n.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-90x9ewbz.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-08n8jxcw.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-7j2098n5.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-b05d7szr.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-9w4ex8vh.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-28wkjpdp.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-7pgcpcrg.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-jk26c1sz.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-865swy5p.js → cli-c07r9a2m.js} +3 -2
  29. package/dist/{cli-nyqza443.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-9pyah9c4.js → cli-ftfvggzx.js} +633 -166
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-y8bfxjtw.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-7vj52450.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-tp0d7vzb.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-s8hpxeg4.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-hc2fkhdv.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-t1cd3mhp.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-xa4j64zh.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-szcyv1mm.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +30 -16
  47. package/dist/{codex-adapter-g4tj1f2g.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-ad8ha7zt.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-z3gd8ppc.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-bezm97r9.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-a97hc33y.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-wtdy1x02.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-7edgmzng.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fvs58a5b.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-phnf93hp.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-7x131mmf.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-1fktvwxc.js → index-a3vn96n8.js} +31 -28
  59. package/dist/{index-yp8rasxk.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-50jxgw0d.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-7jwk7nsm.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-nnz07ebd.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-mg7qcgww.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-ywmnqrpx.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-c63sqfs1.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-2zj8sqkq.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-jgdfm26f.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-3whntb69.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-3zptcc3p.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-dbvpyysc.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-qwx0j8z9.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-sjbcmvep.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-xezqdkb7.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +3 -2
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/codex-agents-md.ts +140 -17
  149. package/src/providers/modelsdev-reasoning.json +551 -0
  150. package/src/rbac/legacy-policy.ts +16 -0
  151. package/src/rbac/permissions.ts +4 -0
  152. package/src/scheduler/deferred-task-waits.ts +28 -0
  153. package/src/scheduler/schedule-task.ts +3 -0
  154. package/src/scheduler/scheduler.ts +14 -0
  155. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  156. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  157. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  158. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  159. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  160. package/src/server.ts +12 -7
  161. package/src/slack/app.ts +35 -4
  162. package/src/slack/connection-state.ts +17 -0
  163. package/src/tasks/context-key.ts +1 -0
  164. package/src/tasks/worker-follow-up.ts +6 -2
  165. package/src/tests/agent-task-activity.test.ts +74 -0
  166. package/src/tests/agents-list-model-display.test.ts +6 -1
  167. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  168. package/src/tests/approval-requests.test.ts +395 -0
  169. package/src/tests/approval-sweeps-config.test.ts +47 -0
  170. package/src/tests/approval-sweeps.test.ts +508 -0
  171. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  172. package/src/tests/apps-spike.test.ts +56 -1
  173. package/src/tests/apps-sync-engine.test.ts +20 -9
  174. package/src/tests/boot-seeds.test.ts +170 -0
  175. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  176. package/src/tests/claude-managed-adapter.test.ts +6 -0
  177. package/src/tests/codex-adapter.test.ts +126 -1
  178. package/src/tests/config-key-filter.test.ts +80 -0
  179. package/src/tests/context-window.test.ts +1 -0
  180. package/src/tests/db-client-busy-retry.test.ts +96 -0
  181. package/src/tests/db-client.test.ts +60 -1
  182. package/src/tests/db-task-read-contract.test.ts +10 -0
  183. package/src/tests/db-task-write-contract.test.ts +9 -0
  184. package/src/tests/deferred-schedule-reaper.test.ts +193 -0
  185. package/src/tests/docs-index-footprint.test.js +3 -3
  186. package/src/tests/events-http.test.ts +103 -0
  187. package/src/tests/extensions-catalog.test.ts +22 -17
  188. package/src/tests/extensions-pre-task.test.ts +107 -0
  189. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  190. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  191. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  192. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  193. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  194. package/src/tests/heartbeat.test.ts +99 -3
  195. package/src/tests/hook-mcp-config.test.ts +29 -0
  196. package/src/tests/http-api-integration.test.ts +16 -2
  197. package/src/tests/kv-storage.test.ts +62 -0
  198. package/src/tests/linear-outbound-sync.test.ts +120 -5
  199. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  200. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  201. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  202. package/src/tests/memory-hybrid.test.ts +161 -1
  203. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  204. package/src/tests/memory.test.ts +22 -0
  205. package/src/tests/onboard-container-engine.test.ts +240 -0
  206. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  207. package/src/tests/pricing-routes.test.ts +14 -0
  208. package/src/tests/profile-sync-warning.test.ts +8 -4
  209. package/src/tests/promote-draft-task-route.test.ts +19 -0
  210. package/src/tests/provider-registration.test.ts +296 -42
  211. package/src/tests/rbac-engine.test.ts +30 -0
  212. package/src/tests/reasoning-effort.test.ts +8 -0
  213. package/src/tests/script-connections.test.ts +7 -0
  214. package/src/tests/sdk-allowlist.test.ts +39 -0
  215. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  216. package/src/tests/sessions-detail-batch.test.ts +250 -0
  217. package/src/tests/sessions.test.ts +118 -0
  218. package/src/tests/slack-config.test.ts +22 -0
  219. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  220. package/src/tests/status.test.ts +36 -1
  221. package/src/tests/swarm-diff.test.ts +316 -10
  222. package/src/tests/task-completion-idempotency.test.ts +2 -1
  223. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  224. package/src/tests/tool-annotations.test.ts +1 -1
  225. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  226. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  227. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  228. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  229. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  230. package/src/tests/workflow-http-v2.test.ts +99 -8
  231. package/src/tests/workflow-retry-validation.test.ts +56 -0
  232. package/src/tests/workflow-tla-races.test.ts +389 -0
  233. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  234. package/src/tools/cancel-approval-request.ts +57 -0
  235. package/src/tools/memory-search.ts +1 -0
  236. package/src/tools/request-human-input.ts +4 -2
  237. package/src/tools/schedules/list-schedules.ts +4 -2
  238. package/src/tools/send-task.ts +4 -0
  239. package/src/tools/templates.ts +19 -0
  240. package/src/tools/tool-config.ts +2 -1
  241. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  242. package/src/types.ts +38 -0
  243. package/src/utils/context-window.ts +1 -0
  244. package/src/workflows/approval-cancel.ts +152 -0
  245. package/src/workflows/approval-notifications.ts +84 -0
  246. package/src/workflows/checkpoint.ts +10 -5
  247. package/src/workflows/definition.ts +21 -0
  248. package/src/workflows/engine.ts +152 -21
  249. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  250. package/src/workflows/recovery.ts +100 -45
  251. package/src/workflows/resume.ts +49 -18
  252. package/src/workflows/retry-poller.ts +71 -21
  253. package/templates/extensions/README.md +1 -0
  254. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  255. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  256. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  257. package/templates/skills/apps/content.md +20 -1
  258. package/templates/skills/code-quality/content.md +26 -1
  259. package/templates/skills/pages/content.md +16 -4
  260. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  261. package/templates/skills/scheduling/content.md +1 -1
  262. package/dist/cli-2daksqps.js +0 -378
  263. package/dist/rbac-roles-3zw68x72.js +0 -37
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Counterexamples from the TLA+ heartbeat model (specs/tla/heartbeat/).
3
+ *
4
+ * Each test replays one TLC trace against a real temp SQLite DB, calling the
5
+ * functions ACTIONS.md maps each trace step to. A test that passes on main
6
+ * means the trace was model drift, not a bug.
7
+ */
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { unlink } from "node:fs/promises";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTaskExtended,
15
+ getChildTasks,
16
+ getDbClient,
17
+ getTaskById,
18
+ initDb,
19
+ startTask,
20
+ updateTaskProgress,
21
+ } from "../be/db";
22
+ import { codeLevelTriage, setBeforeHeartbeatSupersedeForTests } from "../heartbeat/heartbeat";
23
+
24
+ const TEST_DB_PATH = "./test-heartbeat-stall-cas.sqlite";
25
+
26
+ async function backdateTask(taskId: string, isoTime: string): Promise<void> {
27
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
28
+ isoTime,
29
+ taskId,
30
+ ]);
31
+ }
32
+
33
+ describe("Heartbeat stall classifier CAS (TLA+ counterexample)", () => {
34
+ beforeAll(async () => {
35
+ try {
36
+ await unlink(TEST_DB_PATH);
37
+ } catch {
38
+ // File doesn't exist
39
+ }
40
+ closeDb();
41
+ initDb(TEST_DB_PATH);
42
+ });
43
+
44
+ afterAll(async () => {
45
+ closeDb();
46
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
47
+ try {
48
+ await unlink(path);
49
+ } catch {
50
+ // Files may not exist
51
+ }
52
+ }
53
+ });
54
+
55
+ beforeEach(async () => {
56
+ setBeforeHeartbeatSupersedeForTests(null);
57
+ const db = getDbClient();
58
+ await db.run("DELETE FROM agent_tasks");
59
+ await db.run("DELETE FROM agents");
60
+ await db.run("DELETE FROM active_sessions");
61
+ });
62
+
63
+ // Trace: PollStart -> Age -> HbRead -> Progress -> HbWrite (NoLiveKill).
64
+ test("stall classifier does not supersede a task that made progress after the candidate read", async () => {
65
+ const agent = await createAgent({ name: "quiet-worker", isLead: false, status: "busy" });
66
+ const task = await createTaskExtended("Long quiet work", { agentId: agent.id });
67
+ await startTask(task.id);
68
+ await backdateTask(task.id, new Date(Date.now() - 10 * 60 * 1000).toISOString());
69
+
70
+ // The worker reports progress after the sweep read the candidate list.
71
+ setBeforeHeartbeatSupersedeForTests(async (candidate) => {
72
+ await updateTaskProgress(candidate.id, "still working");
73
+ });
74
+
75
+ await codeLevelTriage();
76
+
77
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
78
+ expect(await getChildTasks(task.id)).toHaveLength(0);
79
+ });
80
+ });
@@ -1,10 +1,12 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import {
4
+ cancelTask,
4
5
  claimTask,
5
6
  closeDb,
6
7
  createAgent,
7
8
  createTaskExtended,
9
+ failTask,
8
10
  getActiveSessionForTask,
9
11
  getDbClient,
10
12
  getIdleWorkersWithCapacity,
@@ -280,6 +282,13 @@ describe("Heartbeat Triage", () => {
280
282
  // ==========================================================================
281
283
 
282
284
  describe("Code-Level Triage", () => {
285
+ test("reports approval sweep counts in staleCleanup", async () => {
286
+ const findings = await codeLevelTriage();
287
+
288
+ expect(typeof findings.staleCleanup.approvalAutoCancelled).toBe("number");
289
+ expect(typeof findings.staleCleanup.approvalTimedOut).toBe("number");
290
+ });
291
+
283
292
  test("auto-supersedes stalled task with no active session (DES-523)", async () => {
284
293
  const agent = await createAgent({ name: "dead-worker", isLead: false, status: "busy" });
285
294
  const task = await createTaskExtended("Stalled task", { agentId: agent.id });
@@ -1037,6 +1046,93 @@ describe("Heartbeat Triage", () => {
1037
1046
  expect(retryTask!.priority).toBe(90);
1038
1047
  expect(retryTask!.source).toBe("slack");
1039
1048
  });
1049
+
1050
+ async function sweptTaskWithDependent(taskType?: string) {
1051
+ const agent = await createAgent({ name: "dead-worker", isLead: false, status: "busy" });
1052
+ const other = await createAgent({ name: "dependent-owner", isLead: false, status: "idle" });
1053
+ const parent = await createTaskExtended("Thread root", { agentId: other.id });
1054
+ const swept = await createTaskExtended("Research step", { agentId: agent.id, taskType });
1055
+ await startTask(swept.id);
1056
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
1057
+ new Date(Date.now() - 1000).toISOString(),
1058
+ swept.id,
1059
+ ]);
1060
+ const done = await createTaskExtended("Already done", { agentId: other.id });
1061
+ await getDbClient().run("UPDATE agent_tasks SET status = 'completed' WHERE id = ?", [
1062
+ done.id,
1063
+ ]);
1064
+ const dependent = await createTaskExtended("Act on the research", {
1065
+ agentId: other.id,
1066
+ dependsOn: [swept.id, done.id],
1067
+ parentTaskId: parent.id,
1068
+ priority: 70,
1069
+ slackChannelId: "C0TEST",
1070
+ slackThreadTs: "1700000000.000100",
1071
+ slackUserId: "U0TEST",
1072
+ followUpConfig: { onCompleted: "Tell the requester" },
1073
+ });
1074
+ return { swept, done, dependent, parent, other };
1075
+ }
1076
+
1077
+ test("reboot sweep re-points a never-started dependent to the retry child instead of failing it", async () => {
1078
+ const { swept, done, dependent, parent, other } = await sweptTaskWithDependent();
1079
+ const cancelled = await createTaskExtended("Cancelled for another reason", {
1080
+ agentId: other.id,
1081
+ dependsOn: [swept.id],
1082
+ });
1083
+ await cancelTask(cancelled.id, "not needed");
1084
+
1085
+ await runRebootSweep();
1086
+
1087
+ expect((await getTaskById(swept.id))?.status).toBe("failed");
1088
+ const retryTaskId = getRebootAffectedTasks()[0]!.retryTaskId;
1089
+ expect(retryTaskId).not.toBeNull();
1090
+
1091
+ const survivor = await getTaskById(dependent.id);
1092
+ expect(survivor?.status).toBe("pending");
1093
+ expect(survivor?.failureReason).toBeFalsy();
1094
+ expect(survivor?.dependsOn).toEqual([retryTaskId!, done.id]);
1095
+ expect(survivor?.agentId).toBe(other.id);
1096
+ expect(survivor?.parentTaskId).toBe(parent.id);
1097
+ expect(survivor?.priority).toBe(70);
1098
+ expect(survivor?.slackChannelId).toBe("C0TEST");
1099
+ expect(survivor?.slackThreadTs).toBe("1700000000.000100");
1100
+ expect(survivor?.slackUserId).toBe("U0TEST");
1101
+ expect(survivor?.followUpConfig).toEqual({ onCompleted: "Tell the requester" });
1102
+
1103
+ const log = await getDbClient().get<{ oldValue: string; newValue: string }>(
1104
+ "SELECT oldValue, newValue FROM agent_log WHERE taskId = ? AND eventType = 'task_dependency_repointed'",
1105
+ [dependent.id],
1106
+ );
1107
+ expect(log).toEqual({ oldValue: swept.id, newValue: retryTaskId! });
1108
+
1109
+ // Already terminal for another reason: untouched.
1110
+ const stillCancelled = await getTaskById(cancelled.id);
1111
+ expect(stillCancelled?.status).toBe("cancelled");
1112
+ expect(stillCancelled?.dependsOn).toEqual([swept.id]);
1113
+ });
1114
+
1115
+ test("reboot sweep without a retry child still cascade-fails dependents", async () => {
1116
+ const { swept, dependent } = await sweptTaskWithDependent("heartbeat-checklist");
1117
+
1118
+ await runRebootSweep();
1119
+
1120
+ expect(getRebootAffectedTasks()[0]!.retryTaskId).toBeNull();
1121
+ const dead = await getTaskById(dependent.id);
1122
+ expect(dead?.status).toBe("failed");
1123
+ expect(dead?.failureReason).toBe(`Blocked dependency ${swept.id.slice(0, 8)} was failed`);
1124
+ });
1125
+
1126
+ test("a non-reboot failure still cascade-fails dependents", async () => {
1127
+ const { swept, dependent } = await sweptTaskWithDependent();
1128
+
1129
+ await failTask(swept.id, "Worker reported an error");
1130
+
1131
+ const dead = await getTaskById(dependent.id);
1132
+ expect(dead?.status).toBe("failed");
1133
+ expect(dead?.failureReason).toBe(`Blocked dependency ${swept.id.slice(0, 8)} was failed`);
1134
+ expect(dead?.dependsOn).toContain(swept.id);
1135
+ });
1040
1136
  });
1041
1137
 
1042
1138
  // ==========================================================================
@@ -1083,13 +1179,13 @@ describe("Heartbeat Triage", () => {
1083
1179
  task.id,
1084
1180
  ]);
1085
1181
 
1086
- // Session with pre-boot heartbeat (stale)
1182
+ // Session with pre-boot heartbeat, stale past the 15 min threshold
1087
1183
  await insertActiveSession({
1088
1184
  agentId: agent.id,
1089
1185
  taskId: task.id,
1090
1186
  triggerType: "task_assigned",
1091
1187
  });
1092
- const preBootHb = new Date(bootTime - 30_000).toISOString();
1188
+ const preBootHb = new Date(bootTime - 20 * 60_000).toISOString();
1093
1189
  await getDbClient().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
1094
1190
  preBootHb,
1095
1191
  task.id,
@@ -1189,7 +1285,7 @@ describe("Heartbeat Triage", () => {
1189
1285
  taskId: staleTask.id,
1190
1286
  triggerType: "task_assigned",
1191
1287
  });
1192
- const preBootHb = new Date(bootTime - 30_000).toISOString();
1288
+ const preBootHb = new Date(bootTime - 20 * 60_000).toISOString();
1193
1289
  await getDbClient().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
1194
1290
  preBootHb,
1195
1291
  staleTask.id,
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { loadHookMcpConfig } from "../hooks/hook";
3
+
4
+ describe("loadHookMcpConfig", () => {
5
+ test("falls back to the workspace config when the project has no .mcp.json", async () => {
6
+ const tempDir = `/tmp/hook-mcp-fallback-${crypto.randomUUID()}`;
7
+ const projectDir = `${tempDir}/repo`;
8
+ const workspaceDir = `${tempDir}/workspace`;
9
+ const expected = {
10
+ url: "http://localhost:3013/mcp",
11
+ headers: {
12
+ Authorization: "Bearer example-test-key",
13
+ "X-Agent-ID": "agent-test-id",
14
+ },
15
+ };
16
+
17
+ await Bun.$`mkdir -p ${projectDir} ${workspaceDir}`.quiet();
18
+ try {
19
+ await Bun.write(
20
+ `${workspaceDir}/.mcp.json`,
21
+ JSON.stringify({ mcpServers: { "agent-swarm": expected } }),
22
+ );
23
+
24
+ expect(await loadHookMcpConfig(projectDir, workspaceDir)).toEqual(expected);
25
+ } finally {
26
+ await Bun.$`rm -rf ${tempDir}`.quiet();
27
+ }
28
+ });
29
+ });
@@ -573,7 +573,8 @@ describe("Tasks", () => {
573
573
  expect(status).toBe(200);
574
574
  expect(body.tasks).toBeDefined();
575
575
  expect(Array.isArray(body.tasks)).toBe(true);
576
- expect(body.total).toBeGreaterThanOrEqual(2);
576
+ // `total` is an opt-in COUNT(*): omitted unless `includeTotal=true`.
577
+ expect(body.total).toBeUndefined();
577
578
  });
578
579
 
579
580
  test("GET /api/tasks?status=pending — filter by status", async () => {
@@ -599,12 +600,25 @@ describe("Tasks", () => {
599
600
  });
600
601
 
601
602
  test("GET /api/tasks?limit=1 — pagination", async () => {
602
- const { status, body } = await get("/api/tasks?limit=1");
603
+ const { status, body } = await get("/api/tasks?limit=1&includeTotal=true");
603
604
  expect(status).toBe(200);
604
605
  expect(body.tasks.length).toBe(1);
605
606
  expect(body.total).toBeGreaterThanOrEqual(2);
606
607
  });
607
608
 
609
+ test("GET /api/tasks?fields=timeline — narrow timeline rows", async () => {
610
+ const { status, body } = await get("/api/tasks?fields=timeline&orderBy=createdAt&limit=5");
611
+ expect(status).toBe(200);
612
+ expect(body.tasks.length).toBeGreaterThanOrEqual(1);
613
+ for (const t of body.tasks) {
614
+ expect(typeof t.id).toBe("string");
615
+ expect(typeof t.status).toBe("string");
616
+ expect(t.progress).toBeUndefined();
617
+ expect(t.tags).toBeUndefined();
618
+ }
619
+ expect(body.total).toBeUndefined();
620
+ });
621
+
608
622
  test("GET /api/tasks/:id — get specific task", async () => {
609
623
  const { status, body } = await get(`/api/tasks/${ids.task}`);
610
624
  expect(status).toBe(200);
@@ -9,7 +9,9 @@ import {
9
9
  getKv,
10
10
  incrKv,
11
11
  initDb,
12
+ type KvJsonFieldEquals,
12
13
  KvTypeCollisionError,
14
+ kvPrefixUpperBound,
13
15
  listKv,
14
16
  sweepExpiredKv,
15
17
  sweepExpiredKvPrefix,
@@ -200,6 +202,66 @@ describe("kv-storage helpers", () => {
200
202
  expect(exact.map((e) => e.key)).toEqual(["x_1"]);
201
203
  });
202
204
 
205
+ test("listKv prefix is an exact, case-sensitive range on the primary key", async () => {
206
+ for (const key of ["m/row/1", "m/row/2", "M/row/3", "m/rowx", "m/row", "n/row/1", "m/row/é"]) {
207
+ await upsertKv({ namespace: NS, key, value: 1, valueType: "integer" });
208
+ }
209
+ const rows = await listKv(NS, { prefix: "m/row/", limit: 100, offset: 0 });
210
+ expect(rows.map((e) => e.key)).toEqual(["m/row/1", "m/row/2", "m/row/é"]);
211
+ expect(await countKv(NS, { prefix: "m/row/" })).toBe(3);
212
+ expect(kvPrefixUpperBound("m/row/")).toBe("m/row0");
213
+ expect(kvPrefixUpperBound(`a${String.fromCodePoint(0x10ffff)}`)).toBe("b");
214
+ expect(kvPrefixUpperBound(String.fromCodePoint(0xd7ff))).toBe(String.fromCodePoint(0xe000));
215
+
216
+ const plan = await getDbClient().query<{ detail: string }>(
217
+ `EXPLAIN QUERY PLAN SELECT key FROM kv_entries
218
+ WHERE namespace = ? AND (expires_at IS NULL OR expires_at > ?) AND key >= ? AND key < ?`,
219
+ [NS, 0, "m/row/", "m/row0"],
220
+ );
221
+ expect(plan.map((row) => row.detail).join(" ")).toContain("key>? AND key<?");
222
+ });
223
+
224
+ test("listKv jsonFieldEquals narrows rows in SQL and skips corrupt JSON", async () => {
225
+ await upsertKv({
226
+ namespace: NS,
227
+ key: "r/1",
228
+ value: { run: "a", ok: true, n: 2 },
229
+ valueType: "json",
230
+ });
231
+ await upsertKv({
232
+ namespace: NS,
233
+ key: "r/2",
234
+ value: { run: "b", ok: false, n: 2 },
235
+ valueType: "json",
236
+ });
237
+ await upsertKv({
238
+ namespace: NS,
239
+ key: "r/3",
240
+ value: { run: "a", ok: false, n: 3.5 },
241
+ valueType: "json",
242
+ });
243
+ await getDbClient().run(
244
+ "INSERT INTO kv_entries (namespace, key, value, value_type) VALUES (?, ?, ?, 'json')",
245
+ [NS, "r/4", "{not json"],
246
+ );
247
+ const keys = async (jsonFieldEquals: KvJsonFieldEquals[]) =>
248
+ (await listKv(NS, { prefix: "r/", limit: 100, offset: 0, jsonFieldEquals })).map(
249
+ (e) => e.key,
250
+ );
251
+ expect(await keys([])).toEqual(["r/1", "r/2", "r/3", "r/4"]);
252
+ expect(await keys([{ field: "run", value: "a" }])).toEqual(["r/1", "r/3"]);
253
+ expect(await keys([{ field: "ok", value: true }])).toEqual(["r/1"]);
254
+ // Non-safe-integer numbers are not prefiltered (the caller re-checks).
255
+ expect(await keys([{ field: "n", value: 3.5 }])).toEqual(["r/1", "r/2", "r/3", "r/4"]);
256
+ expect(
257
+ await keys([
258
+ { field: "run", value: "a" },
259
+ { field: "n", value: 2 },
260
+ ]),
261
+ ).toEqual(["r/1"]);
262
+ expect(await keys([{ field: "missing", value: "a" }])).toEqual([]);
263
+ });
264
+
203
265
  test("incrKv creates from missing", async () => {
204
266
  const entry = await incrKv(NS, "counter", 3);
205
267
  expect(entry.value).toBe(3);
@@ -2,7 +2,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, tes
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
4
  import { createTrackerSync, getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
5
- import { initLinearOutboundSync, teardownLinearOutboundSync } from "../linear/outbound";
5
+ import {
6
+ formatLinearOutcomeBody,
7
+ initLinearOutboundSync,
8
+ LINEAR_BODY_MAX,
9
+ teardownLinearOutboundSync,
10
+ } from "../linear/outbound";
6
11
  import { taskSessionMap } from "../linear/sync";
7
12
  import { workflowEventBus } from "../workflows/event-bus";
8
13
 
@@ -79,8 +84,8 @@ describe("Linear Outbound Sync", () => {
79
84
  const callArgs = mockCreateComment.mock.calls[0] as unknown[];
80
85
  const arg = callArgs[0] as { issueId: string; body: string };
81
86
  expect(arg.issueId).toBe("LIN-OUT-COMPLETED");
82
- expect(arg.body).toContain("Task completed");
83
- expect(arg.body).toContain("All done!");
87
+ // The output is the whole comment: no boilerplate, no collapsed `+++` section.
88
+ expect(arg.body).toBe("All done!");
84
89
 
85
90
  // Verify sync record updated
86
91
  const updated = await getTrackerSync("linear", "task", "outbound-task-completed");
@@ -107,8 +112,118 @@ describe("Linear Outbound Sync", () => {
107
112
  const callArgs = mockCreateComment.mock.calls[0] as unknown[];
108
113
  const arg = callArgs[0] as { issueId: string; body: string };
109
114
  expect(arg.issueId).toBe("LIN-OUT-FAILED");
110
- expect(arg.body).toContain("Task failed");
111
- expect(arg.body).toContain("Build error in module X");
115
+ expect(arg.body).toBe("Task failed.\n\nBuild error in module X");
116
+ });
117
+
118
+ test("task.completed with an AgentSession posts ONE response activity carrying the full output", async () => {
119
+ const taskId = "outbound-session-completed";
120
+ await createTrackerSync({
121
+ provider: "linear",
122
+ entityType: "task",
123
+ swarmId: taskId,
124
+ externalId: "LIN-OUT-SESSION-COMPLETED",
125
+ syncDirection: "bidirectional",
126
+ });
127
+ taskSessionMap.set(taskId, "session-completed");
128
+ // Longer than the old 2000-char cut, shorter than the Linear body cap.
129
+ const output = `## Answer\n\n${"x".repeat(5000)}`;
130
+
131
+ workflowEventBus.emit("task.completed", { taskId, output });
132
+ await new Promise((resolve) => setTimeout(resolve, 10));
133
+
134
+ expect(mockEndAgentSession).toHaveBeenCalledTimes(1);
135
+ expect(mockEndAgentSession.mock.calls[0] as unknown[]).toEqual([
136
+ "session-completed",
137
+ output,
138
+ "response",
139
+ ]);
140
+ expect(mockCreateComment).not.toHaveBeenCalled();
141
+ expect(taskSessionMap.has(taskId)).toBe(false);
142
+ });
143
+
144
+ test("task.failed with an AgentSession posts ONE error activity with the plain reason", async () => {
145
+ const taskId = "outbound-session-failed";
146
+ await createTrackerSync({
147
+ provider: "linear",
148
+ entityType: "task",
149
+ swarmId: taskId,
150
+ externalId: "LIN-OUT-SESSION-FAILED",
151
+ syncDirection: "bidirectional",
152
+ });
153
+ taskSessionMap.set(taskId, "session-failed");
154
+
155
+ workflowEventBus.emit("task.failed", { taskId, failureReason: "tsc:check failed" });
156
+ await new Promise((resolve) => setTimeout(resolve, 10));
157
+
158
+ expect(mockEndAgentSession).toHaveBeenCalledTimes(1);
159
+ expect(mockEndAgentSession.mock.calls[0] as unknown[]).toEqual([
160
+ "session-failed",
161
+ "Task failed.\n\ntsc:check failed",
162
+ "error",
163
+ ]);
164
+ expect(mockCreateComment).not.toHaveBeenCalled();
165
+ });
166
+
167
+ test("task.completed with no output still ends the session with a short body", async () => {
168
+ const taskId = "outbound-session-empty";
169
+ await createTrackerSync({
170
+ provider: "linear",
171
+ entityType: "task",
172
+ swarmId: taskId,
173
+ externalId: "LIN-OUT-SESSION-EMPTY",
174
+ syncDirection: "bidirectional",
175
+ });
176
+ taskSessionMap.set(taskId, "session-empty");
177
+
178
+ workflowEventBus.emit("task.completed", { taskId, output: " " });
179
+ await new Promise((resolve) => setTimeout(resolve, 10));
180
+
181
+ expect(mockEndAgentSession.mock.calls[0] as unknown[]).toEqual([
182
+ "session-empty",
183
+ "Task completed.",
184
+ "response",
185
+ ]);
186
+ });
187
+
188
+ describe("formatLinearOutcomeBody", () => {
189
+ const savedDashboard = process.env.SWARM_DASHBOARD_URL;
190
+ const savedApp = process.env.APP_URL;
191
+
192
+ afterEach(() => {
193
+ if (savedDashboard === undefined) delete process.env.SWARM_DASHBOARD_URL;
194
+ else process.env.SWARM_DASHBOARD_URL = savedDashboard;
195
+ if (savedApp === undefined) delete process.env.APP_URL;
196
+ else process.env.APP_URL = savedApp;
197
+ });
198
+
199
+ test("returns text at the cap unchanged", () => {
200
+ const text = "a".repeat(LINEAR_BODY_MAX);
201
+ expect(formatLinearOutcomeBody(text, "t1")).toBe(text);
202
+ });
203
+
204
+ test("caps oversized text with a visible note linking the swarm task", () => {
205
+ delete process.env.SWARM_DASHBOARD_URL;
206
+ process.env.APP_URL = "https://swarm.example.com/";
207
+ const text = "b".repeat(LINEAR_BODY_MAX + 500);
208
+
209
+ const body = formatLinearOutcomeBody(text, "task-123");
210
+
211
+ expect(body.length).toBeLessThanOrEqual(LINEAR_BODY_MAX);
212
+ expect(body.startsWith("b".repeat(1000))).toBe(true);
213
+ expect(body).toContain(`of ${text.length} characters`);
214
+ expect(body.endsWith("Full text: https://swarm.example.com/tasks/task-123_")).toBe(true);
215
+ expect(body).not.toContain("+++");
216
+ });
217
+
218
+ test("names the task id when no dashboard URL is configured", () => {
219
+ delete process.env.SWARM_DASHBOARD_URL;
220
+ delete process.env.APP_URL;
221
+
222
+ const body = formatLinearOutcomeBody("c".repeat(LINEAR_BODY_MAX + 1), "task-456");
223
+
224
+ expect(body.length).toBeLessThanOrEqual(LINEAR_BODY_MAX);
225
+ expect(body.endsWith("Full text: swarm task `task-456`_")).toBe(true);
226
+ });
112
227
  });
113
228
 
114
229
  test("no-op when no tracker_sync mapping exists", async () => {
@@ -11,6 +11,7 @@ import {
11
11
  getAgentById,
12
12
  getAllAgents,
13
13
  getAllTasks,
14
+ getDbClient,
14
15
  getScheduledTasks,
15
16
  initDb,
16
17
  listAllPages,
@@ -227,6 +228,96 @@ describe("list-endpoint slimming", () => {
227
228
  expect(full?.totalCostUsd).toBeCloseTo(0.0168, 6);
228
229
  });
229
230
 
231
+ test("getAllTasks — SQL-side preview matches truncating the full text", async () => {
232
+ // The SQL cut counts UTF-8 bytes while JS slices UTF-16 units, so pin the
233
+ // boundary with astral (surrogate-pair) and multibyte characters. SQLite's
234
+ // text functions stop at an embedded NUL, so pin that too, plus a leading
235
+ // BOM a default TextDecoder would strip. The BOM goes in as SQL bytes:
236
+ // bun:sqlite drops a leading BOM when it binds a string.
237
+ const agentId = "preview-parity-agent";
238
+ const marker = "preview-parity-";
239
+ const texts = [
240
+ `${marker}${"a".repeat(285)}`, // exactly 300 units
241
+ `${marker}${"a".repeat(286)}`, // 301 units
242
+ `${marker}${"😀".repeat(200)}`,
243
+ `${marker}${"é".repeat(284)}😀`,
244
+ `${marker}${"ñ".repeat(500)}`,
245
+ `${marker}short`,
246
+ "a\u0000b",
247
+ `\u0000${"x".repeat(350)}`,
248
+ ];
249
+ for (const text of texts) await createTaskExtended(text, { agentId });
250
+ const bom = await createTaskExtended(`${marker}bom`, { agentId });
251
+ texts.push(`\uFEFF${marker}bom`);
252
+ await getDbClient().run(
253
+ "UPDATE agent_tasks SET task = CAST(x'EFBBBF' AS TEXT) || task WHERE id = ?",
254
+ [bom.id],
255
+ );
256
+
257
+ const full = await getAllTasks({ agentId, limit: 50 });
258
+ expect(full.map((t) => t.task).sort()).toEqual([...texts].sort());
259
+ for (const opts of [{ slim: true }, { fields: "timeline" as const }]) {
260
+ const rows = await getAllTasks({ agentId, limit: 50 }, opts);
261
+ expect(rows.map((t) => t.id)).toEqual(full.map((t) => t.id));
262
+ for (const [i, row] of full.entries()) {
263
+ const expected = row.task.length > 300 ? `${row.task.slice(0, 300)}…` : row.task;
264
+ expect(rows[i]!.task).toBe(expected);
265
+ }
266
+ }
267
+ });
268
+
269
+ test("getAllTasks — slim and full pages hold the same rows in the same order", async () => {
270
+ for (let i = 0; i < 12; i++) {
271
+ await createTaskExtended(`page-parity ${i}`, { agentId: "slim-agent-1" });
272
+ }
273
+ for (const orderBy of ["lastUpdatedAt", "createdAt"] as const) {
274
+ for (const offset of [0, 5, 10]) {
275
+ const filters = { search: "page-parity", limit: 5, offset, orderBy };
276
+ const slimIds = (await getAllTasks(filters, { slim: true })).map((t) => t.id);
277
+ const fullIds = (await getAllTasks(filters)).map((t) => t.id);
278
+ const timelineIds = (await getAllTasks(filters, { fields: "timeline" })).map((t) => t.id);
279
+ expect(slimIds).toEqual(fullIds);
280
+ expect(timelineIds).toEqual(fullIds);
281
+ }
282
+ }
283
+ });
284
+
285
+ test("getAllTasks — timeline projection carries only what the timeline draws", async () => {
286
+ const task = await createTaskExtended(`timeline-row ${"T".repeat(1000)}`, {
287
+ agentId: "slim-agent-1",
288
+ tags: ["timeline"],
289
+ });
290
+ await createSessionCost({
291
+ sessionId: "timeline-cost-session",
292
+ taskId: task.id,
293
+ agentId: "slim-agent-1",
294
+ totalCostUsd: 0.25,
295
+ durationMs: 1000,
296
+ numTurns: 1,
297
+ model: "test-model",
298
+ });
299
+
300
+ const [row] = await getAllTasks({ search: "timeline-row", limit: 1 }, { fields: "timeline" });
301
+ expect(Object.keys(row!).sort()).toEqual(
302
+ [
303
+ "agentId",
304
+ "createdAt",
305
+ "finishedAt",
306
+ "id",
307
+ "lastUpdatedAt",
308
+ "parentTaskId",
309
+ "peakContextTokens",
310
+ "status",
311
+ "task",
312
+ "title",
313
+ "totalCostUsd",
314
+ ].sort(),
315
+ );
316
+ expect(row!.id).toBe(task.id);
317
+ expect(row!.task).toBe(`${task.task.slice(0, 300)}…`);
318
+ expect(row!.totalCostUsd).toBeCloseTo(0.25, 6);
319
+ });
320
+
230
321
  test("listRecentSessions — slim root is a truncated task summary", async () => {
231
322
  const longText = "Q".repeat(2000);
232
323
  await createTaskExtended(longText, { agentId: "slim-agent-1" });