vellum 0.2.1 → 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 (349) 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 +133 -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__/asset-materialize-tool.test.ts +16 -15
  11. package/src/__tests__/asset-search-tool.test.ts +23 -22
  12. package/src/__tests__/attachments-store.test.ts +56 -127
  13. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  14. package/src/__tests__/browser-skill-endstate.test.ts +4 -3
  15. package/src/__tests__/call-bridge.test.ts +385 -0
  16. package/src/__tests__/call-constants.test.ts +40 -0
  17. package/src/__tests__/call-orchestrator.test.ts +130 -4
  18. package/src/__tests__/call-recovery.test.ts +518 -0
  19. package/src/__tests__/call-routes-http.test.ts +459 -0
  20. package/src/__tests__/call-state-machine.test.ts +143 -0
  21. package/src/__tests__/call-store.test.ts +216 -1
  22. package/src/__tests__/cli-discover.test.ts +1 -1
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
  24. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  25. package/src/__tests__/computer-use-tools.test.ts +250 -0
  26. package/src/__tests__/config-schema.test.ts +299 -3
  27. package/src/__tests__/conflict-store.test.ts +2 -1
  28. package/src/__tests__/contacts-tools.test.ts +331 -0
  29. package/src/__tests__/conversation-store.test.ts +30 -32
  30. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  31. package/src/__tests__/date-context.test.ts +373 -0
  32. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  33. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  34. package/src/__tests__/followup-tools.test.ts +303 -0
  35. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  36. package/src/__tests__/intent-routing.test.ts +64 -57
  37. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  38. package/src/__tests__/ipc-snapshot.test.ts +62 -28
  39. package/src/__tests__/llm-usage-store.test.ts +3 -8
  40. package/src/__tests__/media-generate-image.test.ts +1 -1
  41. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  42. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  43. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  44. package/src/__tests__/playbook-tools.test.ts +342 -0
  45. package/src/__tests__/profile-compiler.test.ts +2 -1
  46. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  47. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  48. package/src/__tests__/recurrence-engine.test.ts +69 -0
  49. package/src/__tests__/recurrence-types.test.ts +71 -0
  50. package/src/__tests__/registry.test.ts +5 -3
  51. package/src/__tests__/relay-server.test.ts +633 -0
  52. package/src/__tests__/reminder-store.test.ts +6 -3
  53. package/src/__tests__/reminder.test.ts +43 -77
  54. package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
  55. package/src/__tests__/run-orchestrator.test.ts +4 -4
  56. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  57. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  58. package/src/__tests__/runtime-runs.test.ts +4 -4
  59. package/src/__tests__/schedule-store.test.ts +482 -0
  60. package/src/__tests__/schedule-tools.test.ts +700 -0
  61. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  62. package/src/__tests__/server-history-render.test.ts +14 -13
  63. package/src/__tests__/session-error.test.ts +28 -0
  64. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  65. package/src/__tests__/session-queue.test.ts +71 -48
  66. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  67. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  68. package/src/__tests__/signup-e2e.test.ts +2 -1
  69. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  70. package/src/__tests__/skill-script-runner.test.ts +159 -0
  71. package/src/__tests__/speaker-identification.test.ts +52 -0
  72. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  73. package/src/__tests__/subagent-tools.test.ts +141 -41
  74. package/src/__tests__/task-compiler.test.ts +2 -1
  75. package/src/__tests__/task-runner.test.ts +2 -1
  76. package/src/__tests__/task-scheduler.test.ts +2 -1
  77. package/src/__tests__/task-tools.test.ts +49 -56
  78. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  79. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  80. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  81. package/src/__tests__/tool-executor.test.ts +13 -17
  82. package/src/__tests__/turn-commit.test.ts +218 -3
  83. package/src/__tests__/twilio-provider.test.ts +143 -0
  84. package/src/__tests__/twilio-routes.test.ts +789 -0
  85. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  86. package/src/__tests__/view-image-tool.test.ts +217 -0
  87. package/src/__tests__/workspace-git-service.test.ts +186 -0
  88. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  89. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  90. package/src/bundler/app-bundler.ts +12 -8
  91. package/src/calls/call-bridge.ts +95 -0
  92. package/src/calls/call-constants.ts +43 -5
  93. package/src/calls/call-domain.ts +276 -0
  94. package/src/calls/call-orchestrator.ts +43 -17
  95. package/src/calls/call-recovery.ts +207 -0
  96. package/src/calls/call-state-machine.ts +68 -0
  97. package/src/calls/call-store.ts +192 -5
  98. package/src/calls/relay-server.ts +41 -4
  99. package/src/calls/speaker-identification.ts +213 -0
  100. package/src/calls/twilio-provider.ts +10 -6
  101. package/src/calls/twilio-routes.ts +90 -76
  102. package/src/calls/types.ts +1 -1
  103. package/src/cli/config-commands.ts +334 -0
  104. package/src/cli/core-commands.ts +776 -0
  105. package/src/cli/doordash.ts +251 -1
  106. package/src/cli/ipc-client.ts +82 -0
  107. package/src/cli/map.ts +246 -0
  108. package/src/cli/twitter.ts +575 -0
  109. package/src/cli.ts +7 -5
  110. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  111. package/src/commands/cc-command-registry.ts +209 -0
  112. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  113. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  114. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  115. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  116. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  117. package/src/config/bundled-skills/document/SKILL.md +18 -0
  118. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  119. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  120. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  121. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  122. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  123. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  124. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  125. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  126. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  127. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  128. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  129. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  130. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  131. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  132. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  133. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  134. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  135. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  136. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  137. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  138. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  139. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  140. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  141. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  142. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  143. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  144. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  145. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  146. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  147. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  148. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  149. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  150. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  151. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  152. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  153. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  154. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  155. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  156. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  157. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  158. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  159. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  160. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  161. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  162. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  163. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  164. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  165. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  166. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  167. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  168. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  169. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  170. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  171. package/src/config/defaults.ts +33 -0
  172. package/src/config/loader.ts +4 -1
  173. package/src/config/schema.ts +161 -1
  174. package/src/config/system-prompt.ts +61 -16
  175. package/src/config/templates/IDENTITY.md +7 -0
  176. package/src/config/types.ts +4 -0
  177. package/src/contacts/contact-store.ts +4 -4
  178. package/src/daemon/assistant-attachments.ts +10 -0
  179. package/src/daemon/classifier.ts +3 -1
  180. package/src/daemon/computer-use-session.ts +3 -1
  181. package/src/daemon/date-context.ts +136 -0
  182. package/src/daemon/handlers/apps.ts +16 -1
  183. package/src/daemon/handlers/browser.ts +54 -0
  184. package/src/daemon/handlers/computer-use.ts +7 -1
  185. package/src/daemon/handlers/config.ts +163 -5
  186. package/src/daemon/handlers/diagnostics.ts +5 -1
  187. package/src/daemon/handlers/documents.ts +18 -29
  188. package/src/daemon/handlers/home-base.ts +5 -1
  189. package/src/daemon/handlers/index.ts +40 -277
  190. package/src/daemon/handlers/misc.ts +9 -1
  191. package/src/daemon/handlers/publish.ts +6 -1
  192. package/src/daemon/handlers/sessions.ts +65 -12
  193. package/src/daemon/handlers/shared.ts +36 -1
  194. package/src/daemon/handlers/signing.ts +37 -0
  195. package/src/daemon/handlers/skills.ts +20 -6
  196. package/src/daemon/handlers/subagents.ts +8 -3
  197. package/src/daemon/handlers/twitter-auth.ts +169 -0
  198. package/src/daemon/handlers/work-items.ts +384 -68
  199. package/src/daemon/ipc-contract-inventory.json +28 -4
  200. package/src/daemon/ipc-contract.ts +133 -37
  201. package/src/daemon/ipc-protocol.ts +7 -2
  202. package/src/daemon/lifecycle.ts +21 -0
  203. package/src/daemon/main.ts +10 -4
  204. package/src/daemon/ride-shotgun-handler.ts +74 -10
  205. package/src/daemon/server.ts +143 -26
  206. package/src/daemon/session-agent-loop.ts +887 -0
  207. package/src/daemon/session-attachments.ts +28 -5
  208. package/src/daemon/session-error.ts +24 -3
  209. package/src/daemon/session-lifecycle.ts +147 -0
  210. package/src/daemon/session-media-retry.ts +147 -0
  211. package/src/daemon/session-messaging.ts +145 -0
  212. package/src/daemon/session-notifiers.ts +164 -0
  213. package/src/daemon/session-process.ts +2 -2
  214. package/src/daemon/session-queue-manager.ts +1 -0
  215. package/src/daemon/session-runtime-assembly.ts +52 -0
  216. package/src/daemon/session-skill-tools.ts +124 -5
  217. package/src/daemon/session-slash.ts +3 -0
  218. package/src/daemon/session-surfaces.ts +77 -2
  219. package/src/daemon/session-tool-setup.ts +216 -2
  220. package/src/daemon/session-usage.ts +0 -2
  221. package/src/daemon/session.ts +114 -1404
  222. package/src/daemon/video-thumbnail.ts +60 -0
  223. package/src/doordash/client.ts +121 -27
  224. package/src/doordash/queries.ts +1 -2
  225. package/src/export/formatter.ts +3 -1
  226. package/src/followups/followup-store.ts +4 -2
  227. package/src/followups/types.ts +6 -0
  228. package/src/hooks/templates.ts +1 -1
  229. package/src/index.ts +32 -1153
  230. package/src/memory/attachments-store.ts +28 -83
  231. package/src/memory/channel-delivery-store.ts +7 -21
  232. package/src/memory/clarification-resolver.ts +6 -5
  233. package/src/memory/contradiction-checker.ts +3 -2
  234. package/src/memory/conversation-key-store.ts +10 -29
  235. package/src/memory/conversation-store.ts +2 -1
  236. package/src/memory/db.ts +96 -2
  237. package/src/memory/entity-extractor.ts +6 -3
  238. package/src/memory/items-extractor.ts +5 -4
  239. package/src/memory/jobs-store.ts +3 -2
  240. package/src/memory/llm-usage-store.ts +1 -2
  241. package/src/memory/runs-store.ts +1 -2
  242. package/src/memory/schema.ts +23 -2
  243. package/src/messaging/style-analyzer.ts +3 -2
  244. package/src/messaging/thread-summarizer.ts +8 -12
  245. package/src/messaging/triage-engine.ts +4 -2
  246. package/src/providers/openrouter/client.ts +20 -0
  247. package/src/providers/registry.ts +8 -0
  248. package/src/runtime/http-server.ts +108 -20
  249. package/src/runtime/routes/attachment-routes.ts +2 -3
  250. package/src/runtime/routes/call-routes.ts +140 -0
  251. package/src/runtime/routes/channel-routes.ts +5 -10
  252. package/src/runtime/routes/conversation-routes.ts +5 -5
  253. package/src/runtime/routes/run-routes.ts +2 -2
  254. package/src/runtime/run-orchestrator.ts +9 -3
  255. package/src/schedule/recurrence-engine.ts +138 -0
  256. package/src/schedule/recurrence-types.ts +67 -0
  257. package/src/schedule/schedule-store.ts +102 -57
  258. package/src/schedule/scheduler.ts +9 -6
  259. package/src/security/oauth2.ts +29 -4
  260. package/src/security/secret-allowlist.ts +46 -0
  261. package/src/skills/clawhub.ts +1 -1
  262. package/src/subagent/manager.ts +40 -8
  263. package/src/swarm/backend-claude-code.ts +64 -9
  264. package/src/swarm/worker-prompts.ts +2 -1
  265. package/src/tasks/SPEC.md +34 -28
  266. package/src/tasks/ephemeral-permissions.ts +16 -7
  267. package/src/tasks/task-compiler.ts +5 -4
  268. package/src/tasks/task-runner.ts +10 -5
  269. package/src/tasks/task-scheduler.ts +1 -1
  270. package/src/tasks/tool-sanitizer.ts +36 -0
  271. package/src/tools/assets/search.ts +4 -4
  272. package/src/tools/browser/api-map.ts +220 -0
  273. package/src/tools/browser/auto-navigate.ts +270 -0
  274. package/src/tools/browser/browser-execution.ts +2 -1
  275. package/src/tools/browser/browser-manager.ts +2 -2
  276. package/src/tools/browser/network-recorder.ts +5 -4
  277. package/src/tools/browser/x-auto-navigate.ts +207 -0
  278. package/src/tools/calls/call-end.ts +17 -67
  279. package/src/tools/calls/call-start.ts +24 -85
  280. package/src/tools/calls/call-status.ts +35 -51
  281. package/src/tools/claude-code/claude-code.ts +77 -11
  282. package/src/tools/contacts/contact-merge.ts +46 -78
  283. package/src/tools/contacts/contact-search.ts +35 -79
  284. package/src/tools/contacts/contact-upsert.ts +35 -108
  285. package/src/tools/credentials/vault.ts +20 -4
  286. package/src/tools/document/document-tool.ts +71 -144
  287. package/src/tools/executor.ts +129 -10
  288. package/src/tools/followups/followup_create.ts +46 -88
  289. package/src/tools/followups/followup_list.ts +34 -74
  290. package/src/tools/followups/followup_resolve.ts +31 -66
  291. package/src/tools/host-terminal/cli-discover.ts +2 -1
  292. package/src/tools/host-terminal/host-shell.ts +10 -0
  293. package/src/tools/memory/handlers.ts +5 -4
  294. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  295. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  296. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  297. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  298. package/src/tools/network/web-fetch.ts +18 -6
  299. package/src/tools/playbooks/index.ts +4 -5
  300. package/src/tools/playbooks/playbook-create.ts +3 -47
  301. package/src/tools/playbooks/playbook-delete.ts +1 -25
  302. package/src/tools/playbooks/playbook-list.ts +1 -28
  303. package/src/tools/playbooks/playbook-update.ts +3 -51
  304. package/src/tools/reminder/reminder.ts +5 -78
  305. package/src/tools/schedule/create.ts +69 -74
  306. package/src/tools/schedule/delete.ts +21 -47
  307. package/src/tools/schedule/list.ts +55 -74
  308. package/src/tools/schedule/update.ts +77 -84
  309. package/src/tools/subagent/abort.ts +29 -58
  310. package/src/tools/subagent/message.ts +30 -63
  311. package/src/tools/subagent/read.ts +53 -84
  312. package/src/tools/subagent/spawn.ts +43 -82
  313. package/src/tools/subagent/status.ts +42 -71
  314. package/src/tools/swarm/delegate.ts +2 -1
  315. package/src/tools/tasks/index.ts +8 -8
  316. package/src/tools/tasks/task-delete.ts +60 -88
  317. package/src/tools/tasks/task-list.ts +31 -52
  318. package/src/tools/tasks/task-run.ts +72 -108
  319. package/src/tools/tasks/task-save.ts +33 -65
  320. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  321. package/src/tools/tasks/work-item-list.ts +33 -63
  322. package/src/tools/tasks/work-item-remove.ts +45 -97
  323. package/src/tools/tasks/work-item-update.ts +91 -163
  324. package/src/tools/terminal/backends/native.ts +3 -1
  325. package/src/tools/tool-manifest.ts +0 -62
  326. package/src/tools/types.ts +6 -0
  327. package/src/tools/ui-surface/definitions.ts +3 -1
  328. package/src/tools/watch/screen-watch.ts +3 -1
  329. package/src/tools/watcher/create.ts +52 -98
  330. package/src/tools/watcher/delete.ts +20 -46
  331. package/src/tools/watcher/digest.ts +36 -70
  332. package/src/tools/watcher/list.ts +49 -79
  333. package/src/tools/watcher/update.ts +45 -91
  334. package/src/twitter/client.ts +690 -0
  335. package/src/twitter/session.ts +91 -0
  336. package/src/usage/types.ts +0 -1
  337. package/src/util/truncate.ts +6 -0
  338. package/src/watcher/providers/slack.ts +2 -1
  339. package/src/watcher/watcher-store.ts +3 -2
  340. package/src/work-items/work-item-store.ts +27 -2
  341. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  342. package/src/workspace/git-service.ts +87 -22
  343. package/src/workspace/provider-commit-message-generator.ts +242 -0
  344. package/src/workspace/turn-commit.ts +62 -3
  345. package/src/tools/contacts/index.ts +0 -4
  346. package/src/tools/document/index.ts +0 -5
  347. package/src/tools/followups/index.ts +0 -3
  348. package/src/tools/subagent/index.ts +0 -5
  349. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -0,0 +1,789 @@
1
+ /**
2
+ * Integration tests for Twilio webhook route handlers.
3
+ *
4
+ * Tests:
5
+ * - Signature valid/invalid/missing header
6
+ * - Fail-closed behavior when auth token is not configured
7
+ * - TWILIO_WEBHOOK_VALIDATION_DISABLED env flag bypass
8
+ * - Duplicate callback replay (idempotency)
9
+ * - Unknown status and malformed payload handling
10
+ * - Handler-level idempotency concurrency (concurrent duplicates, failure-retry)
11
+ */
12
+ import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
13
+ import { createHmac } from 'node:crypto';
14
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'twilio-routes-test-')));
19
+
20
+ mock.module('../util/platform.js', () => ({
21
+ getRootDir: () => testDir,
22
+ getDataDir: () => testDir,
23
+ isMacOS: () => process.platform === 'darwin',
24
+ isLinux: () => process.platform === 'linux',
25
+ isWindows: () => process.platform === 'win32',
26
+ getSocketPath: () => join(testDir, 'test.sock'),
27
+ getPidPath: () => join(testDir, 'test.pid'),
28
+ getDbPath: () => join(testDir, 'test.db'),
29
+ getLogPath: () => join(testDir, 'test.log'),
30
+ ensureDataDir: () => {},
31
+ }));
32
+
33
+ mock.module('../util/logger.js', () => ({
34
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
35
+ get: () => () => {},
36
+ }),
37
+ }));
38
+
39
+ mock.module('../config/loader.js', () => ({
40
+ getConfig: () => ({
41
+ model: 'test',
42
+ provider: 'test',
43
+ apiKeys: {},
44
+ memory: { enabled: false },
45
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
46
+ secretDetection: { enabled: false },
47
+ }),
48
+ }));
49
+
50
+ // Configurable mock auth token — tests can switch between configured/unconfigured
51
+ let mockAuthToken: string | undefined = 'test-auth-token-for-webhooks';
52
+
53
+ mock.module('../security/secure-keys.js', () => ({
54
+ getSecureKey: (account: string) => {
55
+ if (account === 'twilio_auth_token') return mockAuthToken;
56
+ return undefined;
57
+ },
58
+ }));
59
+
60
+ // Use the real TwilioConversationRelayProvider (not mocked) for signature validation
61
+ // but mock the instance methods that hit Twilio API
62
+ mock.module('../calls/twilio-provider.js', () => {
63
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
64
+ const { createHmac: createHmacNode } = require('node:crypto');
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
66
+ const { timingSafeEqual: timingSafeEqualNode } = require('node:crypto');
67
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
68
+ const { getSecureKey } = require('../security/secure-keys.js');
69
+
70
+ return {
71
+ TwilioConversationRelayProvider: class {
72
+ readonly name = 'twilio';
73
+
74
+ static getAuthToken(): string | null {
75
+ return getSecureKey('twilio_auth_token') ?? null;
76
+ }
77
+
78
+ static verifyWebhookSignature(
79
+ url: string,
80
+ params: Record<string, string>,
81
+ signature: string,
82
+ authToken: string,
83
+ ): boolean {
84
+ const sortedKeys = Object.keys(params).sort();
85
+ let data = url;
86
+ for (const key of sortedKeys) {
87
+ data += key + params[key];
88
+ }
89
+ const computed = createHmacNode('sha1', authToken).update(data).digest('base64');
90
+ const a = Buffer.from(computed);
91
+ const b = Buffer.from(signature);
92
+ if (a.length !== b.length) return false;
93
+ return timingSafeEqualNode(a, b);
94
+ }
95
+
96
+ async initiateCall() { return { callSid: 'CA_mock_test' }; }
97
+ async endCall() { return; }
98
+ },
99
+ };
100
+ });
101
+
102
+ // Configurable mock Twilio config — tests can override wssBaseUrl
103
+ let mockWssBaseUrl: string = 'wss://test.example.com';
104
+ let mockWebhookBaseUrl: string = 'https://test.example.com';
105
+
106
+ mock.module('../calls/twilio-config.js', () => ({
107
+ getTwilioConfig: () => ({
108
+ accountSid: 'AC_test',
109
+ authToken: 'test-auth-token-for-webhooks',
110
+ phoneNumber: '+15550001111',
111
+ webhookBaseUrl: mockWebhookBaseUrl,
112
+ wssBaseUrl: mockWssBaseUrl,
113
+ }),
114
+ }));
115
+
116
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
117
+ import { conversations } from '../memory/schema.js';
118
+ import { RuntimeHttpServer } from '../runtime/http-server.js';
119
+ import * as callStore from '../calls/call-store.js';
120
+ import {
121
+ createCallSession,
122
+ updateCallSession,
123
+ getCallEvents,
124
+ buildCallbackDedupeKey,
125
+ claimCallback,
126
+ releaseCallbackClaim,
127
+ } from '../calls/call-store.js';
128
+ import { resolveRelayUrl, handleStatusCallback } from '../calls/twilio-routes.js';
129
+
130
+ initializeDb();
131
+
132
+ // ── Helpers ────────────────────────────────────────────────────────────
133
+
134
+ const TEST_TOKEN = 'test-bearer-token-twilio-routes';
135
+ const AUTH_TOKEN = 'test-auth-token-for-webhooks';
136
+
137
+ let ensuredConvIds = new Set<string>();
138
+
139
+ function ensureConversation(id: string): void {
140
+ if (ensuredConvIds.has(id)) return;
141
+ const db = getDb();
142
+ const now = Date.now();
143
+ db.insert(conversations).values({
144
+ id,
145
+ title: `Test conversation ${id}`,
146
+ createdAt: now,
147
+ updatedAt: now,
148
+ }).run();
149
+ ensuredConvIds.add(id);
150
+ }
151
+
152
+ function resetTables() {
153
+ const db = getDb();
154
+ db.run('DELETE FROM processed_callbacks');
155
+ db.run('DELETE FROM call_pending_questions');
156
+ db.run('DELETE FROM call_events');
157
+ db.run('DELETE FROM call_sessions');
158
+ db.run('DELETE FROM conversations');
159
+ ensuredConvIds = new Set();
160
+ }
161
+
162
+ function computeSignature(
163
+ url: string,
164
+ params: Record<string, string>,
165
+ authToken: string,
166
+ ): string {
167
+ const sortedKeys = Object.keys(params).sort();
168
+ let data = url;
169
+ for (const key of sortedKeys) {
170
+ data += key + params[key];
171
+ }
172
+ return createHmac('sha1', authToken).update(data).digest('base64');
173
+ }
174
+
175
+ function createTestSession(convId: string, callSid: string) {
176
+ ensureConversation(convId);
177
+ const session = createCallSession({
178
+ conversationId: convId,
179
+ provider: 'twilio',
180
+ fromNumber: '+15550001111',
181
+ toNumber: '+15559998888',
182
+ task: 'test task',
183
+ });
184
+ updateCallSession(session.id, { providerCallSid: callSid });
185
+ return session;
186
+ }
187
+
188
+ // ── Tests ──────────────────────────────────────────────────────────────
189
+
190
+ describe('twilio webhook routes', () => {
191
+ let server: RuntimeHttpServer;
192
+ let port: number;
193
+
194
+ beforeEach(() => {
195
+ resetTables();
196
+ mockAuthToken = AUTH_TOKEN;
197
+ mockWssBaseUrl = 'wss://test.example.com';
198
+ mockWebhookBaseUrl = 'https://test.example.com';
199
+ delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
200
+ });
201
+
202
+ afterAll(() => {
203
+ resetDb();
204
+ try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
205
+ });
206
+
207
+ async function startServer(): Promise<void> {
208
+ port = 20000 + Math.floor(Math.random() * 1000);
209
+ server = new RuntimeHttpServer({ port, bearerToken: TEST_TOKEN });
210
+ await server.start();
211
+ }
212
+
213
+ async function stopServer(): Promise<void> {
214
+ await server?.stop();
215
+ }
216
+
217
+ function statusUrl(): string {
218
+ return `http://127.0.0.1:${port}/v1/calls/twilio/status`;
219
+ }
220
+
221
+ function buildFormBody(params: Record<string, string>): string {
222
+ return new URLSearchParams(params).toString();
223
+ }
224
+
225
+ function signedRequest(
226
+ url: string,
227
+ params: Record<string, string>,
228
+ ): { body: string; headers: Record<string, string> } {
229
+ const body = buildFormBody(params);
230
+ const sig = computeSignature(url, params, AUTH_TOKEN);
231
+ return {
232
+ body,
233
+ headers: {
234
+ 'Content-Type': 'application/x-www-form-urlencoded',
235
+ 'X-Twilio-Signature': sig,
236
+ },
237
+ };
238
+ }
239
+
240
+ // ── Signature validation tests ─────────────────────────────────────
241
+
242
+ describe('signature validation', () => {
243
+ test('valid signature returns 200', async () => {
244
+ await startServer();
245
+ createTestSession('conv-sig-1', 'CA_sig_valid');
246
+ const url = statusUrl();
247
+ const params = { CallSid: 'CA_sig_valid', CallStatus: 'completed' };
248
+ const { body, headers } = signedRequest(url, params);
249
+
250
+ const res = await fetch(url, { method: 'POST', headers, body });
251
+ expect(res.status).toBe(200);
252
+
253
+ await stopServer();
254
+ });
255
+
256
+ test('missing X-Twilio-Signature header returns 403', async () => {
257
+ await startServer();
258
+ const url = statusUrl();
259
+ const params = { CallSid: 'CA_no_sig', CallStatus: 'completed' };
260
+
261
+ const res = await fetch(url, {
262
+ method: 'POST',
263
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
264
+ body: buildFormBody(params),
265
+ });
266
+
267
+ expect(res.status).toBe(403);
268
+ const body = await res.json() as { error: string };
269
+ expect(body.error).toBe('Forbidden');
270
+
271
+ await stopServer();
272
+ });
273
+
274
+ test('invalid signature returns 403', async () => {
275
+ await startServer();
276
+ const url = statusUrl();
277
+ const params = { CallSid: 'CA_bad_sig', CallStatus: 'completed' };
278
+
279
+ const res = await fetch(url, {
280
+ method: 'POST',
281
+ headers: {
282
+ 'Content-Type': 'application/x-www-form-urlencoded',
283
+ 'X-Twilio-Signature': 'totally-wrong-signature',
284
+ },
285
+ body: buildFormBody(params),
286
+ });
287
+
288
+ expect(res.status).toBe(403);
289
+
290
+ await stopServer();
291
+ });
292
+
293
+ test('signature computed with wrong token returns 403', async () => {
294
+ await startServer();
295
+ const url = statusUrl();
296
+ const params = { CallSid: 'CA_wrong_token', CallStatus: 'completed' };
297
+ const wrongSig = computeSignature(url, params, 'wrong-auth-token');
298
+
299
+ const res = await fetch(url, {
300
+ method: 'POST',
301
+ headers: {
302
+ 'Content-Type': 'application/x-www-form-urlencoded',
303
+ 'X-Twilio-Signature': wrongSig,
304
+ },
305
+ body: buildFormBody(params),
306
+ });
307
+
308
+ expect(res.status).toBe(403);
309
+
310
+ await stopServer();
311
+ });
312
+ });
313
+
314
+ // ── Fail-closed behavior ──────────────────────────────────────────
315
+
316
+ describe('fail-closed when auth token missing', () => {
317
+ test('returns 403 when auth token is not configured', async () => {
318
+ mockAuthToken = undefined;
319
+ await startServer();
320
+
321
+ const url = statusUrl();
322
+ const params = { CallSid: 'CA_no_token', CallStatus: 'completed' };
323
+
324
+ const res = await fetch(url, {
325
+ method: 'POST',
326
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
327
+ body: buildFormBody(params),
328
+ });
329
+
330
+ expect(res.status).toBe(403);
331
+
332
+ await stopServer();
333
+ });
334
+ });
335
+
336
+ // ── TWILIO_WEBHOOK_VALIDATION_DISABLED bypass ─────────────────────
337
+
338
+ describe('validation disabled env flag', () => {
339
+ test('skips validation when TWILIO_WEBHOOK_VALIDATION_DISABLED=true', async () => {
340
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
341
+ mockAuthToken = undefined; // Token not configured, but bypass should work
342
+ await startServer();
343
+
344
+ createTestSession('conv-bypass-1', 'CA_bypass');
345
+ const url = statusUrl();
346
+ const params = { CallSid: 'CA_bypass', CallStatus: 'completed' };
347
+
348
+ const res = await fetch(url, {
349
+ method: 'POST',
350
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
351
+ body: buildFormBody(params),
352
+ });
353
+
354
+ expect(res.status).toBe(200);
355
+
356
+ await stopServer();
357
+ });
358
+
359
+ test('does NOT skip validation when TWILIO_WEBHOOK_VALIDATION_DISABLED is set but not "true"', async () => {
360
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '1';
361
+ mockAuthToken = undefined;
362
+ await startServer();
363
+
364
+ const url = statusUrl();
365
+ const params = { CallSid: 'CA_no_bypass', CallStatus: 'completed' };
366
+
367
+ const res = await fetch(url, {
368
+ method: 'POST',
369
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
370
+ body: buildFormBody(params),
371
+ });
372
+
373
+ // Should fail-closed: token missing and bypass not activated
374
+ expect(res.status).toBe(403);
375
+
376
+ await stopServer();
377
+ });
378
+
379
+ test('does NOT skip validation when env var is empty string', async () => {
380
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '';
381
+ mockAuthToken = undefined;
382
+ await startServer();
383
+
384
+ const url = statusUrl();
385
+ const params = { CallSid: 'CA_empty_env', CallStatus: 'completed' };
386
+
387
+ const res = await fetch(url, {
388
+ method: 'POST',
389
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
390
+ body: buildFormBody(params),
391
+ });
392
+
393
+ expect(res.status).toBe(403);
394
+
395
+ await stopServer();
396
+ });
397
+ });
398
+
399
+ // ── Callback idempotency / replay tests ───────────────────────────
400
+
401
+ describe('callback idempotency', () => {
402
+ test('replaying the same status callback does not create duplicate events', async () => {
403
+ await startServer();
404
+ const session = createTestSession('conv-idem-1', 'CA_idem_1');
405
+ const url = statusUrl();
406
+ const params = {
407
+ CallSid: 'CA_idem_1',
408
+ CallStatus: 'in-progress',
409
+ Timestamp: '2025-01-15T10:00:00Z',
410
+ };
411
+ const { body, headers } = signedRequest(url, params);
412
+
413
+ // First callback — should process
414
+ const res1 = await fetch(url, { method: 'POST', headers, body });
415
+ expect(res1.status).toBe(200);
416
+
417
+ // Second callback (replay) — should return 200 but not create new events
418
+ const res2 = await fetch(url, { method: 'POST', headers, body });
419
+ expect(res2.status).toBe(200);
420
+
421
+ // Verify only one event was recorded
422
+ const events = getCallEvents(session.id);
423
+ const connectedEvents = events.filter(e => e.eventType === 'call_connected');
424
+ expect(connectedEvents.length).toBe(1);
425
+
426
+ await stopServer();
427
+ });
428
+
429
+ test('different statuses for the same call create separate events', async () => {
430
+ await startServer();
431
+ const session = createTestSession('conv-idem-2', 'CA_idem_2');
432
+ const url = statusUrl();
433
+
434
+ // First: ringing
435
+ const params1 = { CallSid: 'CA_idem_2', CallStatus: 'ringing', Timestamp: 'T1' };
436
+ const req1 = signedRequest(url, params1);
437
+ await fetch(url, { method: 'POST', headers: req1.headers, body: req1.body });
438
+
439
+ // Second: in-progress (different status)
440
+ const params2 = { CallSid: 'CA_idem_2', CallStatus: 'in-progress', Timestamp: 'T2' };
441
+ const req2 = signedRequest(url, params2);
442
+ await fetch(url, { method: 'POST', headers: req2.headers, body: req2.body });
443
+
444
+ const events = getCallEvents(session.id);
445
+ expect(events.length).toBe(2);
446
+
447
+ await stopServer();
448
+ });
449
+
450
+ test('third replay of same callback is still no-op', async () => {
451
+ await startServer();
452
+ const session = createTestSession('conv-idem-3', 'CA_idem_3');
453
+ const url = statusUrl();
454
+ const params = {
455
+ CallSid: 'CA_idem_3',
456
+ CallStatus: 'completed',
457
+ Timestamp: '2025-01-15T11:00:00Z',
458
+ };
459
+ const { body, headers } = signedRequest(url, params);
460
+
461
+ // Process three times
462
+ await fetch(url, { method: 'POST', headers, body });
463
+ await fetch(url, { method: 'POST', headers, body });
464
+ await fetch(url, { method: 'POST', headers, body });
465
+
466
+ const events = getCallEvents(session.id);
467
+ const endedEvents = events.filter(e => e.eventType === 'call_ended');
468
+ expect(endedEvents.length).toBe(1);
469
+
470
+ await stopServer();
471
+ });
472
+ });
473
+
474
+ // ── Unknown status + malformed payload tests ──────────────────────
475
+
476
+ describe('unknown status and malformed payloads', () => {
477
+ test('unknown Twilio status returns 200 but does not record event', async () => {
478
+ await startServer();
479
+ const session = createTestSession('conv-unknown-1', 'CA_unknown_1');
480
+ const url = statusUrl();
481
+ const params = {
482
+ CallSid: 'CA_unknown_1',
483
+ CallStatus: 'some-future-status',
484
+ Timestamp: 'T1',
485
+ };
486
+ const { body, headers } = signedRequest(url, params);
487
+
488
+ const res = await fetch(url, { method: 'POST', headers, body });
489
+ expect(res.status).toBe(200);
490
+
491
+ const events = getCallEvents(session.id);
492
+ expect(events.length).toBe(0);
493
+
494
+ await stopServer();
495
+ });
496
+
497
+ test('missing CallSid returns 200 (graceful handling)', async () => {
498
+ await startServer();
499
+ const url = statusUrl();
500
+ const params = { CallStatus: 'completed' };
501
+ const { body, headers } = signedRequest(url, params);
502
+
503
+ const res = await fetch(url, { method: 'POST', headers, body });
504
+ expect(res.status).toBe(200);
505
+
506
+ await stopServer();
507
+ });
508
+
509
+ test('missing CallStatus returns 200 (graceful handling)', async () => {
510
+ await startServer();
511
+ const url = statusUrl();
512
+ const params = { CallSid: 'CA_no_status' };
513
+ const { body, headers } = signedRequest(url, params);
514
+
515
+ const res = await fetch(url, { method: 'POST', headers, body });
516
+ expect(res.status).toBe(200);
517
+
518
+ await stopServer();
519
+ });
520
+
521
+ test('CallSid not matching any session returns 200 without error', async () => {
522
+ await startServer();
523
+ const url = statusUrl();
524
+ const params = {
525
+ CallSid: 'CA_nonexistent_session',
526
+ CallStatus: 'completed',
527
+ Timestamp: 'T1',
528
+ };
529
+ const { body, headers } = signedRequest(url, params);
530
+
531
+ const res = await fetch(url, { method: 'POST', headers, body });
532
+ expect(res.status).toBe(200);
533
+
534
+ await stopServer();
535
+ });
536
+ });
537
+
538
+ // ── resolveRelayUrl unit tests ──────────────────────────────────────
539
+
540
+ describe('resolveRelayUrl', () => {
541
+ test('uses wssBaseUrl when explicitly set', () => {
542
+ const url = resolveRelayUrl('wss://ws.example.com', 'https://web.example.com');
543
+ expect(url).toBe('wss://ws.example.com/v1/calls/relay');
544
+ });
545
+
546
+ test('falls back to webhookBaseUrl when wssBaseUrl is empty', () => {
547
+ const url = resolveRelayUrl('', 'https://web.example.com');
548
+ expect(url).toBe('wss://web.example.com/v1/calls/relay');
549
+ });
550
+
551
+ test('falls back to webhookBaseUrl when wssBaseUrl is whitespace-only', () => {
552
+ const url = resolveRelayUrl(' ', 'https://web.example.com');
553
+ expect(url).toBe('wss://web.example.com/v1/calls/relay');
554
+ });
555
+
556
+ test('normalizes http to ws in webhookBaseUrl fallback', () => {
557
+ const url = resolveRelayUrl('', 'http://localhost:3000');
558
+ expect(url).toBe('ws://localhost:3000/v1/calls/relay');
559
+ });
560
+
561
+ test('normalizes https to wss in webhookBaseUrl fallback', () => {
562
+ const url = resolveRelayUrl('', 'https://gateway.example.com');
563
+ expect(url).toBe('wss://gateway.example.com/v1/calls/relay');
564
+ });
565
+
566
+ test('strips trailing slash from wssBaseUrl', () => {
567
+ const url = resolveRelayUrl('wss://ws.example.com/', 'https://web.example.com');
568
+ expect(url).toBe('wss://ws.example.com/v1/calls/relay');
569
+ });
570
+
571
+ test('strips trailing slash from webhookBaseUrl fallback', () => {
572
+ const url = resolveRelayUrl('', 'https://web.example.com/');
573
+ expect(url).toBe('wss://web.example.com/v1/calls/relay');
574
+ });
575
+
576
+ test('preserves wss scheme in explicitly set wssBaseUrl', () => {
577
+ const url = resolveRelayUrl('wss://custom-relay.example.com', 'https://web.example.com');
578
+ expect(url).toBe('wss://custom-relay.example.com/v1/calls/relay');
579
+ });
580
+ });
581
+
582
+ // ── TwiML relay URL generation ──────────────────────────────────────
583
+
584
+ describe('voice webhook TwiML relay URL', () => {
585
+ function voiceUrl(sessionId: string): string {
586
+ return `http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook?callSessionId=${sessionId}`;
587
+ }
588
+
589
+ test('TwiML uses explicit wssBaseUrl when set', async () => {
590
+ mockWssBaseUrl = 'wss://explicit-ws.example.com';
591
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
592
+ await startServer();
593
+
594
+ const session = createTestSession('conv-twiml-1', 'CA_twiml_1');
595
+ const url = voiceUrl(session.id);
596
+ const params = { CallSid: 'CA_twiml_1' };
597
+ const body = buildFormBody(params);
598
+
599
+ const res = await fetch(url, {
600
+ method: 'POST',
601
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
602
+ body,
603
+ });
604
+
605
+ expect(res.status).toBe(200);
606
+ const twiml = await res.text();
607
+ expect(twiml).toContain('wss://explicit-ws.example.com/v1/calls/relay');
608
+
609
+ await stopServer();
610
+ });
611
+
612
+ test('TwiML falls back to webhookBaseUrl when wssBaseUrl is empty', async () => {
613
+ mockWssBaseUrl = '';
614
+ mockWebhookBaseUrl = 'https://gateway.example.com';
615
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
616
+ await startServer();
617
+
618
+ const session = createTestSession('conv-twiml-2', 'CA_twiml_2');
619
+ const url = voiceUrl(session.id);
620
+ const params = { CallSid: 'CA_twiml_2' };
621
+ const body = buildFormBody(params);
622
+
623
+ const res = await fetch(url, {
624
+ method: 'POST',
625
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
626
+ body,
627
+ });
628
+
629
+ expect(res.status).toBe(200);
630
+ const twiml = await res.text();
631
+ expect(twiml).toContain('wss://gateway.example.com/v1/calls/relay');
632
+
633
+ await stopServer();
634
+ });
635
+ });
636
+
637
+ // ── Handler-level idempotency concurrency tests ─────────────────
638
+
639
+ describe('handler-level idempotency concurrency', () => {
640
+ test('two concurrent identical status callbacks produce exactly one event', async () => {
641
+ await startServer();
642
+ const session = createTestSession('conv-conc-1', 'CA_conc_1');
643
+ const url = statusUrl();
644
+ const params = {
645
+ CallSid: 'CA_conc_1',
646
+ CallStatus: 'in-progress',
647
+ Timestamp: '2025-01-20T10:00:00Z',
648
+ };
649
+ const { body, headers } = signedRequest(url, params);
650
+
651
+ // Fire two identical callbacks concurrently
652
+ const [res1, res2] = await Promise.all([
653
+ fetch(url, { method: 'POST', headers, body }),
654
+ fetch(url, { method: 'POST', headers, body }),
655
+ ]);
656
+
657
+ // Both should return 200 (one processes, one is deduplicated)
658
+ expect(res1.status).toBe(200);
659
+ expect(res2.status).toBe(200);
660
+
661
+ // Only one event should be recorded despite two concurrent requests
662
+ const events = getCallEvents(session.id);
663
+ const connectedEvents = events.filter(e => e.eventType === 'call_connected');
664
+ expect(connectedEvents.length).toBe(1);
665
+
666
+ await stopServer();
667
+ });
668
+
669
+ test('three concurrent identical status callbacks still produce exactly one event', async () => {
670
+ await startServer();
671
+ const session = createTestSession('conv-conc-2', 'CA_conc_2');
672
+ const url = statusUrl();
673
+ const params = {
674
+ CallSid: 'CA_conc_2',
675
+ CallStatus: 'completed',
676
+ Timestamp: '2025-01-20T11:00:00Z',
677
+ };
678
+ const { body, headers } = signedRequest(url, params);
679
+
680
+ // Fire three identical callbacks concurrently
681
+ const [res1, res2, res3] = await Promise.all([
682
+ fetch(url, { method: 'POST', headers, body }),
683
+ fetch(url, { method: 'POST', headers, body }),
684
+ fetch(url, { method: 'POST', headers, body }),
685
+ ]);
686
+
687
+ expect(res1.status).toBe(200);
688
+ expect(res2.status).toBe(200);
689
+ expect(res3.status).toBe(200);
690
+
691
+ const events = getCallEvents(session.id);
692
+ const endedEvents = events.filter(e => e.eventType === 'call_ended');
693
+ expect(endedEvents.length).toBe(1);
694
+
695
+ await stopServer();
696
+ });
697
+
698
+ test('processing failure releases claim and allows successful retry', async () => {
699
+ await startServer();
700
+ const session = createTestSession('conv-conc-3', 'CA_conc_3');
701
+ const url = statusUrl();
702
+ const params = {
703
+ CallSid: 'CA_conc_3',
704
+ CallStatus: 'in-progress',
705
+ Timestamp: '2025-01-20T12:00:00Z',
706
+ };
707
+
708
+ // Save original before spying so we can delegate on retry
709
+ const originalRecordCallEvent = callStore.recordCallEvent;
710
+
711
+ // Make recordCallEvent throw on the first call to exercise the handler's
712
+ // real catch path (twilio-routes.ts:217), which calls
713
+ // releaseCallbackClaim before re-throwing.
714
+ let shouldThrow = true;
715
+ const spy = spyOn(callStore, 'recordCallEvent').mockImplementation((...args: Parameters<typeof callStore.recordCallEvent>) => {
716
+ if (shouldThrow) {
717
+ shouldThrow = false;
718
+ throw new Error('Simulated side-effect failure');
719
+ }
720
+ spy.mockRestore();
721
+ return originalRecordCallEvent(...args);
722
+ });
723
+
724
+ // Call handleStatusCallback directly (not through Bun.serve) so we can
725
+ // catch the re-thrown error without Bun's HTTP server swallowing it.
726
+ const formBody = new URLSearchParams(params).toString();
727
+ const directReq = new Request(url, {
728
+ method: 'POST',
729
+ body: formBody,
730
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
731
+ });
732
+
733
+ // The handler should claim → throw in recordCallEvent → catch releases claim → re-throw
734
+ let handlerThrew = false;
735
+ try {
736
+ await handleStatusCallback(directReq);
737
+ } catch (err) {
738
+ handlerThrew = true;
739
+ expect((err as Error).message).toBe('Simulated side-effect failure');
740
+ }
741
+ expect(handlerThrew).toBe(true);
742
+
743
+ // No events recorded (the failed attempt rolled back via releaseCallbackClaim)
744
+ const eventsAfterFailure = getCallEvents(session.id);
745
+ expect(eventsAfterFailure.length).toBe(0);
746
+
747
+ // Retry via the real HTTP handler — should succeed because the catch block
748
+ // released the claim, allowing a fresh claim on retry.
749
+ const { body, headers } = signedRequest(url, params);
750
+ const res = await fetch(url, { method: 'POST', headers, body });
751
+ expect(res.status).toBe(200);
752
+
753
+ // Now exactly one event should exist from the successful retry
754
+ const eventsAfterRetry = getCallEvents(session.id);
755
+ const connectedEvents = eventsAfterRetry.filter(e => e.eventType === 'call_connected');
756
+ expect(connectedEvents.length).toBe(1);
757
+
758
+ await stopServer();
759
+ });
760
+
761
+ test('permanently claimed callback cannot be retried', async () => {
762
+ await startServer();
763
+ const session = createTestSession('conv-conc-4', 'CA_conc_4');
764
+ const url = statusUrl();
765
+ const params = {
766
+ CallSid: 'CA_conc_4',
767
+ CallStatus: 'completed',
768
+ Timestamp: '2025-01-20T13:00:00Z',
769
+ };
770
+ const { body, headers } = signedRequest(url, params);
771
+
772
+ // First request processes successfully and finalizes the claim
773
+ const res1 = await fetch(url, { method: 'POST', headers, body });
774
+ expect(res1.status).toBe(200);
775
+
776
+ const events1 = getCallEvents(session.id);
777
+ expect(events1.filter(e => e.eventType === 'call_ended').length).toBe(1);
778
+
779
+ // Second request (retry) — should be deduplicated, no new events
780
+ const res2 = await fetch(url, { method: 'POST', headers, body });
781
+ expect(res2.status).toBe(200);
782
+
783
+ const events2 = getCallEvents(session.id);
784
+ expect(events2.filter(e => e.eventType === 'call_ended').length).toBe(1);
785
+
786
+ await stopServer();
787
+ });
788
+ });
789
+ });