vellum 0.2.0 → 0.2.2

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 (361) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +5 -2
  3. package/package.json +4 -2
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +161 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/app-bundler.test.ts +12 -33
  11. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  12. package/src/__tests__/asset-search-tool.test.ts +23 -22
  13. package/src/__tests__/attachments-store.test.ts +56 -127
  14. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  15. package/src/__tests__/browser-skill-endstate.test.ts +5 -8
  16. package/src/__tests__/call-bridge.test.ts +385 -0
  17. package/src/__tests__/call-constants.test.ts +40 -0
  18. package/src/__tests__/call-orchestrator.test.ts +454 -0
  19. package/src/__tests__/call-recovery.test.ts +518 -0
  20. package/src/__tests__/call-routes-http.test.ts +459 -0
  21. package/src/__tests__/call-state-machine.test.ts +143 -0
  22. package/src/__tests__/call-state.test.ts +133 -0
  23. package/src/__tests__/call-store.test.ts +691 -0
  24. package/src/__tests__/cli-discover.test.ts +1 -1
  25. package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
  26. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  27. package/src/__tests__/computer-use-tools.test.ts +250 -0
  28. package/src/__tests__/config-schema.test.ts +348 -3
  29. package/src/__tests__/conflict-store.test.ts +2 -1
  30. package/src/__tests__/contacts-tools.test.ts +331 -0
  31. package/src/__tests__/conversation-store.test.ts +30 -32
  32. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  33. package/src/__tests__/date-context.test.ts +373 -0
  34. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  35. package/src/__tests__/doordash-session.test.ts +9 -0
  36. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  37. package/src/__tests__/followup-tools.test.ts +303 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  39. package/src/__tests__/intent-routing.test.ts +64 -57
  40. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  41. package/src/__tests__/ipc-snapshot.test.ts +96 -28
  42. package/src/__tests__/llm-usage-store.test.ts +3 -8
  43. package/src/__tests__/media-generate-image.test.ts +1 -1
  44. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  45. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  46. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  47. package/src/__tests__/playbook-tools.test.ts +342 -0
  48. package/src/__tests__/profile-compiler.test.ts +2 -1
  49. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  50. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  51. package/src/__tests__/recurrence-engine.test.ts +69 -0
  52. package/src/__tests__/recurrence-types.test.ts +71 -0
  53. package/src/__tests__/registry.test.ts +17 -10
  54. package/src/__tests__/relay-server.test.ts +633 -0
  55. package/src/__tests__/reminder-store.test.ts +6 -3
  56. package/src/__tests__/reminder.test.ts +43 -77
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +222 -0
  58. package/src/__tests__/run-orchestrator.test.ts +7 -7
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +19 -20
  60. package/src/__tests__/runtime-runs-http.test.ts +5 -23
  61. package/src/__tests__/runtime-runs.test.ts +11 -11
  62. package/src/__tests__/schedule-store.test.ts +482 -0
  63. package/src/__tests__/schedule-tools.test.ts +700 -0
  64. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  65. package/src/__tests__/server-history-render.test.ts +14 -13
  66. package/src/__tests__/session-error.test.ts +28 -0
  67. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  68. package/src/__tests__/session-queue.test.ts +89 -16
  69. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  70. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  71. package/src/__tests__/signup-e2e.test.ts +2 -1
  72. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  73. package/src/__tests__/skill-script-runner.test.ts +159 -0
  74. package/src/__tests__/speaker-identification.test.ts +52 -0
  75. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  76. package/src/__tests__/subagent-tools.test.ts +141 -41
  77. package/src/__tests__/task-compiler.test.ts +2 -1
  78. package/src/__tests__/task-runner.test.ts +2 -1
  79. package/src/__tests__/task-scheduler.test.ts +2 -1
  80. package/src/__tests__/task-tools.test.ts +49 -56
  81. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  82. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  84. package/src/__tests__/tool-executor.test.ts +13 -17
  85. package/src/__tests__/turn-commit.test.ts +273 -2
  86. package/src/__tests__/twilio-provider.test.ts +143 -0
  87. package/src/__tests__/twilio-routes.test.ts +789 -0
  88. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  89. package/src/__tests__/view-image-tool.test.ts +217 -0
  90. package/src/__tests__/workspace-git-service.test.ts +403 -0
  91. package/src/__tests__/workspace-heartbeat-service.test.ts +141 -2
  92. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  93. package/src/bundler/app-bundler.ts +35 -14
  94. package/src/calls/call-bridge.ts +95 -0
  95. package/src/calls/call-constants.ts +48 -0
  96. package/src/calls/call-domain.ts +276 -0
  97. package/src/calls/call-orchestrator.ts +390 -0
  98. package/src/calls/call-recovery.ts +207 -0
  99. package/src/calls/call-state-machine.ts +68 -0
  100. package/src/calls/call-state.ts +64 -0
  101. package/src/calls/call-store.ts +416 -0
  102. package/src/calls/relay-server.ts +335 -0
  103. package/src/calls/speaker-identification.ts +213 -0
  104. package/src/calls/twilio-config.ts +34 -0
  105. package/src/calls/twilio-provider.ts +173 -0
  106. package/src/calls/twilio-routes.ts +250 -0
  107. package/src/calls/types.ts +37 -0
  108. package/src/calls/voice-provider.ts +14 -0
  109. package/src/cli/config-commands.ts +334 -0
  110. package/src/cli/core-commands.ts +776 -0
  111. package/src/cli/doordash.ts +256 -25
  112. package/src/cli/ipc-client.ts +82 -0
  113. package/src/cli/map.ts +246 -0
  114. package/src/cli/twitter.ts +575 -0
  115. package/src/cli.ts +7 -5
  116. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  117. package/src/commands/cc-command-registry.ts +209 -0
  118. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  119. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  120. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  123. package/src/config/bundled-skills/document/SKILL.md +18 -0
  124. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  125. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  126. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  127. package/src/config/bundled-skills/doordash/SKILL.md +163 -0
  128. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  129. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  130. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  131. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  133. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -24
  135. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  136. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  137. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  138. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  139. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  140. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  141. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  142. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  143. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  144. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  145. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  146. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  149. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  150. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  151. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  152. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  153. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  154. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  155. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  156. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  157. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  158. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  159. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  160. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  161. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  162. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  163. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  164. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  165. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  166. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  167. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  168. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  169. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  170. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  171. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  172. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  173. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  174. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  175. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  176. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  177. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  178. package/src/config/defaults.ts +44 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +218 -1
  181. package/src/config/system-prompt.ts +100 -6
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +5 -0
  184. package/src/contacts/contact-store.ts +4 -4
  185. package/src/daemon/assistant-attachments.ts +10 -0
  186. package/src/daemon/classifier.ts +3 -1
  187. package/src/daemon/computer-use-session.ts +3 -1
  188. package/src/daemon/date-context.ts +136 -0
  189. package/src/daemon/handlers/apps.ts +16 -1
  190. package/src/daemon/handlers/browser.ts +54 -0
  191. package/src/daemon/handlers/computer-use.ts +7 -1
  192. package/src/daemon/handlers/config.ts +192 -4
  193. package/src/daemon/handlers/diagnostics.ts +5 -1
  194. package/src/daemon/handlers/documents.ts +18 -29
  195. package/src/daemon/handlers/home-base.ts +5 -1
  196. package/src/daemon/handlers/index.ts +40 -271
  197. package/src/daemon/handlers/misc.ts +9 -1
  198. package/src/daemon/handlers/publish.ts +6 -1
  199. package/src/daemon/handlers/sessions.ts +65 -12
  200. package/src/daemon/handlers/shared.ts +36 -1
  201. package/src/daemon/handlers/signing.ts +37 -0
  202. package/src/daemon/handlers/skills.ts +20 -6
  203. package/src/daemon/handlers/subagents.ts +8 -3
  204. package/src/daemon/handlers/twitter-auth.ts +169 -0
  205. package/src/daemon/handlers/work-items.ts +495 -39
  206. package/src/daemon/ipc-contract-inventory.json +40 -4
  207. package/src/daemon/ipc-contract.ts +185 -37
  208. package/src/daemon/ipc-protocol.ts +7 -2
  209. package/src/daemon/lifecycle.ts +48 -5
  210. package/src/daemon/main.ts +10 -4
  211. package/src/daemon/ride-shotgun-handler.ts +74 -10
  212. package/src/daemon/server.ts +144 -29
  213. package/src/daemon/session-agent-loop.ts +887 -0
  214. package/src/daemon/session-attachments.ts +28 -5
  215. package/src/daemon/session-error.ts +24 -3
  216. package/src/daemon/session-lifecycle.ts +147 -0
  217. package/src/daemon/session-media-retry.ts +147 -0
  218. package/src/daemon/session-messaging.ts +145 -0
  219. package/src/daemon/session-notifiers.ts +164 -0
  220. package/src/daemon/session-process.ts +2 -2
  221. package/src/daemon/session-queue-manager.ts +1 -0
  222. package/src/daemon/session-runtime-assembly.ts +52 -0
  223. package/src/daemon/session-skill-tools.ts +124 -5
  224. package/src/daemon/session-slash.ts +3 -0
  225. package/src/daemon/session-surfaces.ts +77 -2
  226. package/src/daemon/session-tool-setup.ts +222 -2
  227. package/src/daemon/session-usage.ts +0 -2
  228. package/src/daemon/session.ts +114 -1365
  229. package/src/daemon/video-thumbnail.ts +60 -0
  230. package/src/doordash/client.ts +121 -27
  231. package/src/doordash/queries.ts +1 -2
  232. package/src/export/formatter.ts +3 -1
  233. package/src/followups/followup-store.ts +4 -2
  234. package/src/followups/types.ts +6 -0
  235. package/src/hooks/templates.ts +1 -1
  236. package/src/index.ts +32 -1151
  237. package/src/media/gemini-image-service.ts +1 -1
  238. package/src/memory/attachments-store.ts +28 -83
  239. package/src/memory/channel-delivery-store.ts +7 -21
  240. package/src/memory/clarification-resolver.ts +6 -5
  241. package/src/memory/contradiction-checker.ts +3 -2
  242. package/src/memory/conversation-key-store.ts +10 -29
  243. package/src/memory/conversation-store.ts +2 -1
  244. package/src/memory/db.ts +362 -2
  245. package/src/memory/entity-extractor.ts +6 -3
  246. package/src/memory/items-extractor.ts +5 -4
  247. package/src/memory/jobs-store.ts +3 -2
  248. package/src/memory/llm-usage-store.ts +1 -2
  249. package/src/memory/runs-store.ts +1 -2
  250. package/src/memory/schema.ts +65 -2
  251. package/src/messaging/style-analyzer.ts +3 -2
  252. package/src/messaging/thread-summarizer.ts +8 -12
  253. package/src/messaging/triage-engine.ts +4 -2
  254. package/src/providers/openrouter/client.ts +20 -0
  255. package/src/providers/registry.ts +8 -0
  256. package/src/runtime/http-server.ts +277 -25
  257. package/src/runtime/http-types.ts +0 -2
  258. package/src/runtime/routes/attachment-routes.ts +5 -6
  259. package/src/runtime/routes/call-routes.ts +140 -0
  260. package/src/runtime/routes/channel-routes.ts +12 -19
  261. package/src/runtime/routes/conversation-routes.ts +5 -9
  262. package/src/runtime/routes/run-routes.ts +4 -8
  263. package/src/runtime/run-orchestrator.ts +39 -6
  264. package/src/schedule/recurrence-engine.ts +138 -0
  265. package/src/schedule/recurrence-types.ts +67 -0
  266. package/src/schedule/schedule-store.ts +102 -57
  267. package/src/schedule/scheduler.ts +9 -6
  268. package/src/security/oauth2.ts +29 -4
  269. package/src/security/secret-allowlist.ts +46 -0
  270. package/src/skills/clawhub.ts +1 -1
  271. package/src/subagent/manager.ts +40 -8
  272. package/src/swarm/backend-claude-code.ts +64 -9
  273. package/src/swarm/worker-prompts.ts +2 -1
  274. package/src/tasks/SPEC.md +34 -28
  275. package/src/tasks/ephemeral-permissions.ts +16 -7
  276. package/src/tasks/task-compiler.ts +5 -4
  277. package/src/tasks/task-runner.ts +10 -5
  278. package/src/tasks/task-scheduler.ts +1 -1
  279. package/src/tasks/tool-sanitizer.ts +36 -0
  280. package/src/tools/assets/search.ts +4 -4
  281. package/src/tools/browser/api-map.ts +220 -0
  282. package/src/tools/browser/auto-navigate.ts +270 -0
  283. package/src/tools/browser/browser-execution.ts +2 -1
  284. package/src/tools/browser/browser-manager.ts +2 -2
  285. package/src/tools/browser/network-recorder.ts +5 -4
  286. package/src/tools/browser/x-auto-navigate.ts +207 -0
  287. package/src/tools/calls/call-end.ts +67 -0
  288. package/src/tools/calls/call-start.ts +73 -0
  289. package/src/tools/calls/call-status.ts +81 -0
  290. package/src/tools/claude-code/claude-code.ts +77 -11
  291. package/src/tools/contacts/contact-merge.ts +46 -78
  292. package/src/tools/contacts/contact-search.ts +35 -79
  293. package/src/tools/contacts/contact-upsert.ts +35 -108
  294. package/src/tools/credentials/vault.ts +21 -5
  295. package/src/tools/document/document-tool.ts +71 -144
  296. package/src/tools/executor.ts +129 -10
  297. package/src/tools/followups/followup_create.ts +46 -88
  298. package/src/tools/followups/followup_list.ts +34 -74
  299. package/src/tools/followups/followup_resolve.ts +31 -66
  300. package/src/tools/host-terminal/cli-discover.ts +2 -1
  301. package/src/tools/host-terminal/host-shell.ts +10 -0
  302. package/src/tools/memory/handlers.ts +5 -4
  303. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  304. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  305. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  306. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  307. package/src/tools/network/web-fetch.ts +18 -6
  308. package/src/tools/playbooks/index.ts +4 -5
  309. package/src/tools/playbooks/playbook-create.ts +3 -47
  310. package/src/tools/playbooks/playbook-delete.ts +1 -25
  311. package/src/tools/playbooks/playbook-list.ts +1 -28
  312. package/src/tools/playbooks/playbook-update.ts +3 -51
  313. package/src/tools/registry.ts +2 -4
  314. package/src/tools/reminder/reminder.ts +5 -78
  315. package/src/tools/schedule/create.ts +69 -74
  316. package/src/tools/schedule/delete.ts +21 -47
  317. package/src/tools/schedule/list.ts +55 -74
  318. package/src/tools/schedule/update.ts +77 -84
  319. package/src/tools/subagent/abort.ts +29 -58
  320. package/src/tools/subagent/message.ts +30 -63
  321. package/src/tools/subagent/read.ts +53 -84
  322. package/src/tools/subagent/spawn.ts +43 -82
  323. package/src/tools/subagent/status.ts +42 -71
  324. package/src/tools/swarm/delegate.ts +2 -1
  325. package/src/tools/tasks/index.ts +8 -6
  326. package/src/tools/tasks/task-delete.ts +69 -56
  327. package/src/tools/tasks/task-list.ts +31 -52
  328. package/src/tools/tasks/task-run.ts +74 -102
  329. package/src/tools/tasks/task-save.ts +33 -65
  330. package/src/tools/tasks/work-item-enqueue.ts +192 -134
  331. package/src/tools/tasks/work-item-list.ts +33 -78
  332. package/src/tools/tasks/work-item-remove.ts +60 -0
  333. package/src/tools/tasks/work-item-update.ts +114 -0
  334. package/src/tools/terminal/backends/native.ts +3 -1
  335. package/src/tools/tool-manifest.ts +20 -74
  336. package/src/tools/types.ts +6 -0
  337. package/src/tools/ui-surface/definitions.ts +6 -1
  338. package/src/tools/watch/screen-watch.ts +3 -1
  339. package/src/tools/watcher/create.ts +52 -98
  340. package/src/tools/watcher/delete.ts +20 -46
  341. package/src/tools/watcher/digest.ts +36 -70
  342. package/src/tools/watcher/list.ts +49 -79
  343. package/src/tools/watcher/update.ts +45 -91
  344. package/src/twitter/client.ts +690 -0
  345. package/src/twitter/session.ts +91 -0
  346. package/src/usage/types.ts +0 -1
  347. package/src/util/truncate.ts +6 -0
  348. package/src/watcher/providers/slack.ts +2 -1
  349. package/src/watcher/watcher-store.ts +3 -2
  350. package/src/work-items/work-item-store.ts +236 -2
  351. package/src/workspace/commit-message-enrichment-service.ts +284 -0
  352. package/src/workspace/commit-message-provider.ts +95 -0
  353. package/src/workspace/git-service.ts +272 -52
  354. package/src/workspace/heartbeat-service.ts +70 -13
  355. package/src/workspace/provider-commit-message-generator.ts +242 -0
  356. package/src/workspace/turn-commit.ts +100 -51
  357. package/src/tools/contacts/index.ts +0 -4
  358. package/src/tools/document/index.ts +0 -5
  359. package/src/tools/followups/index.ts +0 -3
  360. package/src/tools/subagent/index.ts +0 -5
  361. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -0,0 +1,691 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const testDir = mkdtempSync(join(tmpdir(), 'call-store-test-'));
7
+
8
+ mock.module('../util/platform.js', () => ({
9
+ getDataDir: () => testDir,
10
+ isMacOS: () => process.platform === 'darwin',
11
+ isLinux: () => process.platform === 'linux',
12
+ isWindows: () => process.platform === 'win32',
13
+ getSocketPath: () => join(testDir, 'test.sock'),
14
+ getPidPath: () => join(testDir, 'test.pid'),
15
+ getDbPath: () => join(testDir, 'test.db'),
16
+ getLogPath: () => join(testDir, 'test.log'),
17
+ ensureDataDir: () => {},
18
+ }));
19
+
20
+ mock.module('../util/logger.js', () => ({
21
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
22
+ get: () => () => {},
23
+ }),
24
+ }));
25
+
26
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
27
+ import { conversations } from '../memory/schema.js';
28
+ import {
29
+ createCallSession,
30
+ getCallSession,
31
+ getCallSessionByCallSid,
32
+ getActiveCallSessionForConversation,
33
+ updateCallSession,
34
+ recordCallEvent,
35
+ getCallEvents,
36
+ createPendingQuestion,
37
+ getPendingQuestion,
38
+ answerPendingQuestion,
39
+ expirePendingQuestions,
40
+ claimCallback,
41
+ releaseCallbackClaim,
42
+ finalizeCallbackClaim,
43
+ } from '../calls/call-store.js';
44
+
45
+ initializeDb();
46
+
47
+ afterAll(() => {
48
+ resetDb();
49
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
50
+ });
51
+
52
+ /** Ensure a conversation row exists for the given ID so FK constraints pass. */
53
+ let ensuredConvIds = new Set<string>();
54
+ function ensureConversation(id: string): void {
55
+ if (ensuredConvIds.has(id)) return;
56
+ const db = getDb();
57
+ const now = Date.now();
58
+ db.insert(conversations).values({
59
+ id,
60
+ title: `Test conversation ${id}`,
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ }).run();
64
+ ensuredConvIds.add(id);
65
+ }
66
+
67
+ function resetTables() {
68
+ const db = getDb();
69
+ db.run('DELETE FROM call_pending_questions');
70
+ db.run('DELETE FROM call_events');
71
+ db.run('DELETE FROM call_sessions');
72
+ db.run('DELETE FROM processed_callbacks');
73
+ db.run('DELETE FROM conversations');
74
+ ensuredConvIds = new Set();
75
+ }
76
+
77
+ /** Wrapper that ensures the FK conversation row exists before creating a session. */
78
+ function createTestCallSession(opts: Parameters<typeof createCallSession>[0]) {
79
+ ensureConversation(opts.conversationId);
80
+ return createCallSession(opts);
81
+ }
82
+
83
+ describe('call-store', () => {
84
+ beforeEach(() => {
85
+ resetTables();
86
+ });
87
+
88
+ // ── Call Sessions ─────────────────────────────────────────────────
89
+
90
+ test('createCallSession creates a session with correct defaults', () => {
91
+ const session = createTestCallSession({
92
+ conversationId: 'conv-1',
93
+ provider: 'twilio',
94
+ fromNumber: '+15551234567',
95
+ toNumber: '+15559876543',
96
+ task: 'Book appointment',
97
+ });
98
+
99
+ expect(session.id).toBeDefined();
100
+ expect(session.conversationId).toBe('conv-1');
101
+ expect(session.provider).toBe('twilio');
102
+ expect(session.fromNumber).toBe('+15551234567');
103
+ expect(session.toNumber).toBe('+15559876543');
104
+ expect(session.task).toBe('Book appointment');
105
+ expect(session.status).toBe('initiated');
106
+ expect(session.providerCallSid).toBeNull();
107
+ expect(session.startedAt).toBeNull();
108
+ expect(session.endedAt).toBeNull();
109
+ expect(session.lastError).toBeNull();
110
+ expect(typeof session.createdAt).toBe('number');
111
+ expect(typeof session.updatedAt).toBe('number');
112
+ });
113
+
114
+ test('createCallSession defaults task to null when not provided', () => {
115
+ const session = createTestCallSession({
116
+ conversationId: 'conv-2',
117
+ provider: 'twilio',
118
+ fromNumber: '+15551111111',
119
+ toNumber: '+15552222222',
120
+ });
121
+
122
+ expect(session.task).toBeNull();
123
+ });
124
+
125
+ test('getCallSession retrieves by ID', () => {
126
+ const created = createTestCallSession({
127
+ conversationId: 'conv-3',
128
+ provider: 'twilio',
129
+ fromNumber: '+15551111111',
130
+ toNumber: '+15552222222',
131
+ });
132
+
133
+ const retrieved = getCallSession(created.id);
134
+ expect(retrieved).not.toBeNull();
135
+ expect(retrieved!.id).toBe(created.id);
136
+ expect(retrieved!.conversationId).toBe('conv-3');
137
+ });
138
+
139
+ test('getCallSession returns null for missing ID', () => {
140
+ const result = getCallSession('nonexistent-id');
141
+ expect(result).toBeNull();
142
+ });
143
+
144
+ test('getCallSessionByCallSid looks up by provider call SID', () => {
145
+ const session = createTestCallSession({
146
+ conversationId: 'conv-4',
147
+ provider: 'twilio',
148
+ fromNumber: '+15551111111',
149
+ toNumber: '+15552222222',
150
+ });
151
+
152
+ updateCallSession(session.id, { providerCallSid: 'CA_test_sid_123' });
153
+
154
+ const found = getCallSessionByCallSid('CA_test_sid_123');
155
+ expect(found).not.toBeNull();
156
+ expect(found!.id).toBe(session.id);
157
+ expect(found!.providerCallSid).toBe('CA_test_sid_123');
158
+ });
159
+
160
+ test('getCallSessionByCallSid returns null for unknown SID', () => {
161
+ const result = getCallSessionByCallSid('CA_unknown');
162
+ expect(result).toBeNull();
163
+ });
164
+
165
+ test('getActiveCallSessionForConversation finds non-terminal sessions', () => {
166
+ const session = createTestCallSession({
167
+ conversationId: 'conv-5',
168
+ provider: 'twilio',
169
+ fromNumber: '+15551111111',
170
+ toNumber: '+15552222222',
171
+ });
172
+
173
+ const active = getActiveCallSessionForConversation('conv-5');
174
+ expect(active).not.toBeNull();
175
+ expect(active!.id).toBe(session.id);
176
+ });
177
+
178
+ test('getActiveCallSessionForConversation returns null when all sessions are completed', () => {
179
+ const session = createTestCallSession({
180
+ conversationId: 'conv-6',
181
+ provider: 'twilio',
182
+ fromNumber: '+15551111111',
183
+ toNumber: '+15552222222',
184
+ });
185
+
186
+ updateCallSession(session.id, { status: 'completed' });
187
+
188
+ const active = getActiveCallSessionForConversation('conv-6');
189
+ expect(active).toBeNull();
190
+ });
191
+
192
+ test('getActiveCallSessionForConversation returns null when all sessions are failed', () => {
193
+ const session = createTestCallSession({
194
+ conversationId: 'conv-7',
195
+ provider: 'twilio',
196
+ fromNumber: '+15551111111',
197
+ toNumber: '+15552222222',
198
+ });
199
+
200
+ updateCallSession(session.id, { status: 'failed' });
201
+
202
+ const active = getActiveCallSessionForConversation('conv-7');
203
+ expect(active).toBeNull();
204
+ });
205
+
206
+ test('getActiveCallSessionForConversation returns most recent active session', () => {
207
+ // Create two sessions for the same conversation
208
+ const older = createTestCallSession({
209
+ conversationId: 'conv-8',
210
+ provider: 'twilio',
211
+ fromNumber: '+15551111111',
212
+ toNumber: '+15552222222',
213
+ });
214
+ // Mark older as completed
215
+ updateCallSession(older.id, { status: 'completed' });
216
+
217
+ const newer = createTestCallSession({
218
+ conversationId: 'conv-8',
219
+ provider: 'twilio',
220
+ fromNumber: '+15551111111',
221
+ toNumber: '+15553333333',
222
+ });
223
+
224
+ const active = getActiveCallSessionForConversation('conv-8');
225
+ expect(active).not.toBeNull();
226
+ expect(active!.id).toBe(newer.id);
227
+ });
228
+
229
+ test('updateCallSession updates status, providerCallSid, and timestamps', () => {
230
+ const session = createTestCallSession({
231
+ conversationId: 'conv-9',
232
+ provider: 'twilio',
233
+ fromNumber: '+15551111111',
234
+ toNumber: '+15552222222',
235
+ });
236
+
237
+ const now = Date.now();
238
+ updateCallSession(session.id, {
239
+ status: 'in_progress',
240
+ providerCallSid: 'CA_updated_sid',
241
+ startedAt: now,
242
+ });
243
+
244
+ const updated = getCallSession(session.id);
245
+ expect(updated).not.toBeNull();
246
+ expect(updated!.status).toBe('in_progress');
247
+ expect(updated!.providerCallSid).toBe('CA_updated_sid');
248
+ expect(updated!.startedAt).toBe(now);
249
+ // updatedAt should be updated
250
+ expect(updated!.updatedAt).toBeGreaterThanOrEqual(session.updatedAt);
251
+ });
252
+
253
+ test('updateCallSession sets endedAt and lastError', () => {
254
+ const session = createTestCallSession({
255
+ conversationId: 'conv-10',
256
+ provider: 'twilio',
257
+ fromNumber: '+15551111111',
258
+ toNumber: '+15552222222',
259
+ });
260
+
261
+ const endTime = Date.now();
262
+ updateCallSession(session.id, {
263
+ status: 'failed',
264
+ endedAt: endTime,
265
+ lastError: 'Network timeout',
266
+ });
267
+
268
+ const updated = getCallSession(session.id);
269
+ expect(updated!.status).toBe('failed');
270
+ expect(updated!.endedAt).toBe(endTime);
271
+ expect(updated!.lastError).toBe('Network timeout');
272
+ });
273
+
274
+ // ── Call Events ───────────────────────────────────────────────────
275
+
276
+ test('recordCallEvent creates events with correct fields', () => {
277
+ const session = createTestCallSession({
278
+ conversationId: 'conv-11',
279
+ provider: 'twilio',
280
+ fromNumber: '+15551111111',
281
+ toNumber: '+15552222222',
282
+ });
283
+
284
+ const event = recordCallEvent(session.id, 'call_started', { twilioStatus: 'initiated' });
285
+
286
+ expect(event.id).toBeDefined();
287
+ expect(event.callSessionId).toBe(session.id);
288
+ expect(event.eventType).toBe('call_started');
289
+ expect(typeof event.createdAt).toBe('number');
290
+ });
291
+
292
+ test('recordCallEvent stores JSON payload', () => {
293
+ const session = createTestCallSession({
294
+ conversationId: 'conv-12',
295
+ provider: 'twilio',
296
+ fromNumber: '+15551111111',
297
+ toNumber: '+15552222222',
298
+ });
299
+
300
+ const payload = { text: 'Hello, how are you?', lang: 'en-US' };
301
+ const event = recordCallEvent(session.id, 'caller_spoke', payload);
302
+
303
+ const parsed = JSON.parse(event.payloadJson);
304
+ expect(parsed.text).toBe('Hello, how are you?');
305
+ expect(parsed.lang).toBe('en-US');
306
+ });
307
+
308
+ test('recordCallEvent defaults payload to empty JSON object', () => {
309
+ const session = createTestCallSession({
310
+ conversationId: 'conv-13',
311
+ provider: 'twilio',
312
+ fromNumber: '+15551111111',
313
+ toNumber: '+15552222222',
314
+ });
315
+
316
+ const event = recordCallEvent(session.id, 'call_connected');
317
+
318
+ expect(event.payloadJson).toBe('{}');
319
+ });
320
+
321
+ test('getCallEvents retrieves events in creation order', () => {
322
+ const session = createTestCallSession({
323
+ conversationId: 'conv-14',
324
+ provider: 'twilio',
325
+ fromNumber: '+15551111111',
326
+ toNumber: '+15552222222',
327
+ });
328
+
329
+ recordCallEvent(session.id, 'call_started');
330
+ recordCallEvent(session.id, 'call_connected');
331
+ recordCallEvent(session.id, 'caller_spoke', { transcript: 'Hi' });
332
+
333
+ const events = getCallEvents(session.id);
334
+ expect(events).toHaveLength(3);
335
+ expect(events[0].eventType).toBe('call_started');
336
+ expect(events[1].eventType).toBe('call_connected');
337
+ expect(events[2].eventType).toBe('caller_spoke');
338
+ // Should be in ascending creation order
339
+ expect(events[0].createdAt).toBeLessThanOrEqual(events[1].createdAt);
340
+ expect(events[1].createdAt).toBeLessThanOrEqual(events[2].createdAt);
341
+ });
342
+
343
+ test('getCallEvents returns empty array for session with no events', () => {
344
+ const session = createTestCallSession({
345
+ conversationId: 'conv-15',
346
+ provider: 'twilio',
347
+ fromNumber: '+15551111111',
348
+ toNumber: '+15552222222',
349
+ });
350
+
351
+ const events = getCallEvents(session.id);
352
+ expect(events).toHaveLength(0);
353
+ });
354
+
355
+ // ── Pending Questions ─────────────────────────────────────────────
356
+
357
+ test('createPendingQuestion creates with status pending', () => {
358
+ const session = createTestCallSession({
359
+ conversationId: 'conv-16',
360
+ provider: 'twilio',
361
+ fromNumber: '+15551111111',
362
+ toNumber: '+15552222222',
363
+ });
364
+
365
+ const question = createPendingQuestion(session.id, 'What is your preferred date?');
366
+
367
+ expect(question.id).toBeDefined();
368
+ expect(question.callSessionId).toBe(session.id);
369
+ expect(question.questionText).toBe('What is your preferred date?');
370
+ expect(question.status).toBe('pending');
371
+ expect(typeof question.askedAt).toBe('number');
372
+ expect(question.answeredAt).toBeNull();
373
+ expect(question.answerText).toBeNull();
374
+ });
375
+
376
+ test('getPendingQuestion finds pending question for session', () => {
377
+ const session = createTestCallSession({
378
+ conversationId: 'conv-17',
379
+ provider: 'twilio',
380
+ fromNumber: '+15551111111',
381
+ toNumber: '+15552222222',
382
+ });
383
+
384
+ const created = createPendingQuestion(session.id, 'What is your name?');
385
+
386
+ const found = getPendingQuestion(session.id);
387
+ expect(found).not.toBeNull();
388
+ expect(found!.id).toBe(created.id);
389
+ expect(found!.questionText).toBe('What is your name?');
390
+ expect(found!.status).toBe('pending');
391
+ });
392
+
393
+ test('getPendingQuestion returns null when no pending questions', () => {
394
+ const session = createTestCallSession({
395
+ conversationId: 'conv-18',
396
+ provider: 'twilio',
397
+ fromNumber: '+15551111111',
398
+ toNumber: '+15552222222',
399
+ });
400
+
401
+ const found = getPendingQuestion(session.id);
402
+ expect(found).toBeNull();
403
+ });
404
+
405
+ test('answerPendingQuestion updates status to answered', () => {
406
+ const session = createTestCallSession({
407
+ conversationId: 'conv-19',
408
+ provider: 'twilio',
409
+ fromNumber: '+15551111111',
410
+ toNumber: '+15552222222',
411
+ });
412
+
413
+ const question = createPendingQuestion(session.id, 'What color?');
414
+ answerPendingQuestion(question.id, 'Blue');
415
+
416
+ // Should no longer appear as pending
417
+ const pending = getPendingQuestion(session.id);
418
+ expect(pending).toBeNull();
419
+
420
+ // Verify the record was updated by querying directly
421
+ const db = getDb();
422
+ const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
423
+ const updated = raw.query('SELECT * FROM call_pending_questions WHERE id = ?').get(question.id) as {
424
+ status: string;
425
+ answer_text: string;
426
+ answered_at: number;
427
+ };
428
+ expect(updated.status).toBe('answered');
429
+ expect(updated.answer_text).toBe('Blue');
430
+ expect(typeof updated.answered_at).toBe('number');
431
+ });
432
+
433
+ test('expirePendingQuestions marks all pending questions as expired', () => {
434
+ const session = createTestCallSession({
435
+ conversationId: 'conv-20',
436
+ provider: 'twilio',
437
+ fromNumber: '+15551111111',
438
+ toNumber: '+15552222222',
439
+ });
440
+
441
+ createPendingQuestion(session.id, 'Question 1');
442
+ createPendingQuestion(session.id, 'Question 2');
443
+
444
+ expirePendingQuestions(session.id);
445
+
446
+ // No more pending questions
447
+ const pending = getPendingQuestion(session.id);
448
+ expect(pending).toBeNull();
449
+
450
+ // Verify both were expired
451
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
452
+ const rows = raw.query('SELECT status FROM call_pending_questions WHERE call_session_id = ?').all(session.id) as Array<{ status: string }>;
453
+ expect(rows).toHaveLength(2);
454
+ for (const row of rows) {
455
+ expect(row.status).toBe('expired');
456
+ }
457
+ });
458
+
459
+ test('expirePendingQuestions does not affect already-answered questions', () => {
460
+ const session = createTestCallSession({
461
+ conversationId: 'conv-21',
462
+ provider: 'twilio',
463
+ fromNumber: '+15551111111',
464
+ toNumber: '+15552222222',
465
+ });
466
+
467
+ const q1 = createPendingQuestion(session.id, 'Question 1');
468
+ createPendingQuestion(session.id, 'Question 2');
469
+
470
+ // Answer q1 first
471
+ answerPendingQuestion(q1.id, 'Answer 1');
472
+
473
+ // Then expire all pending
474
+ expirePendingQuestions(session.id);
475
+
476
+ // q1 should still be answered, not expired
477
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
478
+ const q1Row = raw.query('SELECT status FROM call_pending_questions WHERE id = ?').get(q1.id) as { status: string };
479
+ expect(q1Row.status).toBe('answered');
480
+ });
481
+
482
+ // ── Callback Claim ──────────────────────────────────────────────
483
+
484
+ test('claimCallback returns a claim ID on first call', () => {
485
+ const session = createTestCallSession({
486
+ conversationId: 'conv-22',
487
+ provider: 'twilio',
488
+ fromNumber: '+15551111111',
489
+ toNumber: '+15552222222',
490
+ });
491
+
492
+ const result = claimCallback('test-dedupe-key-1', session.id);
493
+ expect(result).toBeTypeOf('string');
494
+ expect(result!.length).toBeGreaterThan(0);
495
+ });
496
+
497
+ test('claimCallback returns null on duplicate key', () => {
498
+ const session = createTestCallSession({
499
+ conversationId: 'conv-23',
500
+ provider: 'twilio',
501
+ fromNumber: '+15551111111',
502
+ toNumber: '+15552222222',
503
+ });
504
+
505
+ const first = claimCallback('test-dedupe-key-2', session.id);
506
+ const second = claimCallback('test-dedupe-key-2', session.id);
507
+
508
+ expect(first).toBeTypeOf('string');
509
+ expect(second).toBeNull();
510
+ });
511
+
512
+ test('releaseCallbackClaim allows re-claim', () => {
513
+ const session = createTestCallSession({
514
+ conversationId: 'conv-24',
515
+ provider: 'twilio',
516
+ fromNumber: '+15551111111',
517
+ toNumber: '+15552222222',
518
+ });
519
+
520
+ const first = claimCallback('test-dedupe-key-3', session.id);
521
+ expect(first).toBeTypeOf('string');
522
+
523
+ releaseCallbackClaim('test-dedupe-key-3', first!);
524
+
525
+ const second = claimCallback('test-dedupe-key-3', session.id);
526
+ expect(second).toBeTypeOf('string');
527
+ });
528
+
529
+ test('releaseCallbackClaim with wrong claimId does not release', () => {
530
+ const session = createTestCallSession({
531
+ conversationId: 'conv-24b',
532
+ provider: 'twilio',
533
+ fromNumber: '+15551111111',
534
+ toNumber: '+15552222222',
535
+ });
536
+
537
+ const claimId = claimCallback('test-dedupe-key-3b', session.id);
538
+ expect(claimId).toBeTypeOf('string');
539
+
540
+ // Attempt to release with a wrong claim ID — should be a no-op
541
+ releaseCallbackClaim('test-dedupe-key-3b', 'wrong-claim-id');
542
+
543
+ // The claim should still be held, so re-claiming should fail
544
+ const second = claimCallback('test-dedupe-key-3b', session.id);
545
+ expect(second).toBeNull();
546
+ });
547
+
548
+ test('claimCallback INSERT OR IGNORE pattern is safe for same key', () => {
549
+ const session = createTestCallSession({
550
+ conversationId: 'conv-25',
551
+ provider: 'twilio',
552
+ fromNumber: '+15551111111',
553
+ toNumber: '+15552222222',
554
+ });
555
+
556
+ // Claim the key
557
+ const first = claimCallback('test-dedupe-key-4', session.id);
558
+ expect(first).toBeTypeOf('string');
559
+
560
+ // Subsequent claims with the same key should all return null without throwing
561
+ expect(claimCallback('test-dedupe-key-4', session.id)).toBeNull();
562
+ expect(claimCallback('test-dedupe-key-4', session.id)).toBeNull();
563
+
564
+ // Only one row should exist in the table for this key
565
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
566
+ const rows = raw.query('SELECT COUNT(*) as cnt FROM processed_callbacks WHERE dedupe_key = ?').get('test-dedupe-key-4') as { cnt: number };
567
+ expect(rows.cnt).toBe(1);
568
+ });
569
+
570
+ test('claimCallback reclaims expired orphaned claims', () => {
571
+ const session = createTestCallSession({
572
+ conversationId: 'conv-26',
573
+ provider: 'twilio',
574
+ fromNumber: '+15551111111',
575
+ toNumber: '+15552222222',
576
+ });
577
+
578
+ // Claim the key
579
+ const first = claimCallback('test-dedupe-key-expired', session.id);
580
+ expect(first).toBeTypeOf('string');
581
+
582
+ // Simulate an orphaned claim by backdating the created_at to well past expiry
583
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
584
+ const oldTimestamp = Date.now() - 120_000; // 2 minutes ago, well past 60s expiry
585
+ raw.query('UPDATE processed_callbacks SET created_at = ? WHERE dedupe_key = ?').run(oldTimestamp, 'test-dedupe-key-expired');
586
+
587
+ // Reclaim should succeed because the old claim has expired
588
+ const second = claimCallback('test-dedupe-key-expired', session.id);
589
+ expect(second).toBeTypeOf('string');
590
+
591
+ // The new claim should have a different claim ID
592
+ expect(second).not.toBe(first);
593
+ });
594
+
595
+ test('claimCallback does not reclaim finalized claims', () => {
596
+ const session = createTestCallSession({
597
+ conversationId: 'conv-27',
598
+ provider: 'twilio',
599
+ fromNumber: '+15551111111',
600
+ toNumber: '+15552222222',
601
+ });
602
+
603
+ // Claim and finalize
604
+ const first = claimCallback('test-dedupe-key-finalized', session.id);
605
+ expect(first).toBeTypeOf('string');
606
+ finalizeCallbackClaim('test-dedupe-key-finalized', first!);
607
+
608
+ // Attempting to reclaim a finalized key should fail because the far-future
609
+ // timestamp means it will never be considered expired
610
+ const second = claimCallback('test-dedupe-key-finalized', session.id);
611
+ expect(second).toBeNull();
612
+ });
613
+
614
+ test('finalizeCallbackClaim makes claim permanent', () => {
615
+ const session = createTestCallSession({
616
+ conversationId: 'conv-28',
617
+ provider: 'twilio',
618
+ fromNumber: '+15551111111',
619
+ toNumber: '+15552222222',
620
+ });
621
+
622
+ // Claim and finalize
623
+ const claimId = claimCallback('test-dedupe-key-permanent', session.id)!;
624
+ finalizeCallbackClaim('test-dedupe-key-permanent', claimId);
625
+
626
+ // Verify the created_at is set far in the future
627
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
628
+ const row = raw.query('SELECT created_at FROM processed_callbacks WHERE dedupe_key = ?').get('test-dedupe-key-permanent') as { created_at: number };
629
+ // Should be at least 50 years in the future from now
630
+ const fiftyYearsMs = 50 * 365 * 24 * 60 * 60 * 1000;
631
+ expect(row.created_at).toBeGreaterThan(Date.now() + fiftyYearsMs);
632
+ });
633
+
634
+ test('finalizeCallbackClaim with wrong claimId does not finalize', () => {
635
+ const session = createTestCallSession({
636
+ conversationId: 'conv-28b',
637
+ provider: 'twilio',
638
+ fromNumber: '+15551111111',
639
+ toNumber: '+15552222222',
640
+ });
641
+
642
+ // Claim the key
643
+ const claimId = claimCallback('test-dedupe-key-permanent-b', session.id)!;
644
+ expect(claimId).toBeTypeOf('string');
645
+
646
+ // Try to finalize with wrong claimId — should be a no-op
647
+ finalizeCallbackClaim('test-dedupe-key-permanent-b', 'wrong-claim-id');
648
+
649
+ // Verify the created_at was NOT set to far-future (it should still be close to now)
650
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
651
+ const row = raw.query('SELECT created_at FROM processed_callbacks WHERE dedupe_key = ?').get('test-dedupe-key-permanent-b') as { created_at: number };
652
+ const oneMinuteMs = 60 * 1000;
653
+ expect(row.created_at).toBeLessThan(Date.now() + oneMinuteMs);
654
+ });
655
+
656
+ test('handler A cannot release handler B claim after reclaim', () => {
657
+ const session = createTestCallSession({
658
+ conversationId: 'conv-29',
659
+ provider: 'twilio',
660
+ fromNumber: '+15551111111',
661
+ toNumber: '+15552222222',
662
+ });
663
+
664
+ // Handler A claims
665
+ const claimA = claimCallback('test-dedupe-key-ownership', session.id)!;
666
+ expect(claimA).toBeTypeOf('string');
667
+
668
+ // Simulate handler A taking too long: backdate the claim so it expires
669
+ const raw = (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
670
+ const oldTimestamp = Date.now() - 120_000;
671
+ raw.query('UPDATE processed_callbacks SET created_at = ? WHERE dedupe_key = ?').run(oldTimestamp, 'test-dedupe-key-ownership');
672
+
673
+ // Handler B reclaims (succeeds because the old claim expired)
674
+ const claimB = claimCallback('test-dedupe-key-ownership', session.id)!;
675
+ expect(claimB).toBeTypeOf('string');
676
+ expect(claimB).not.toBe(claimA);
677
+
678
+ // Handler B finalizes
679
+ finalizeCallbackClaim('test-dedupe-key-ownership', claimB);
680
+
681
+ // Handler A tries to release using its old claimId — should be a no-op
682
+ releaseCallbackClaim('test-dedupe-key-ownership', claimA);
683
+
684
+ // Verify B's finalized claim is still intact
685
+ const row = raw.query('SELECT created_at, claim_id FROM processed_callbacks WHERE dedupe_key = ?').get('test-dedupe-key-ownership') as { created_at: number; claim_id: string };
686
+ expect(row).not.toBeNull();
687
+ expect(row.claim_id).toBe(claimB);
688
+ const fiftyYearsMs = 50 * 365 * 24 * 60 * 60 * 1000;
689
+ expect(row.created_at).toBeGreaterThan(Date.now() + fiftyYearsMs);
690
+ });
691
+ });
@@ -71,7 +71,7 @@ describe('cliDiscoverTool', () => {
71
71
  expect(result.isError).toBe(false);
72
72
  // Should at least find git which is nearly universally available
73
73
  expect(result.content).toContain('**git**');
74
- }, 30_000);
74
+ }, 60_000);
75
75
 
76
76
  test('includes version info for found CLIs', async () => {
77
77
  const result = await cliDiscoverTool.execute(