vellum 0.2.1 → 0.2.7

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 +71 -100
  3. package/package.json +5 -3
  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 +305 -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-twilio-config.test.ts +221 -0
  36. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  37. package/src/__tests__/intent-routing.test.ts +64 -57
  38. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  39. package/src/__tests__/ipc-snapshot.test.ts +71 -28
  40. package/src/__tests__/llm-usage-store.test.ts +3 -8
  41. package/src/__tests__/media-generate-image.test.ts +1 -1
  42. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  43. package/src/__tests__/memory-regressions.test.ts +100 -2
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  45. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  46. package/src/__tests__/playbook-tools.test.ts +342 -0
  47. package/src/__tests__/profile-compiler.test.ts +2 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
  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 +5 -3
  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 +8 -4
  58. package/src/__tests__/run-orchestrator.test.ts +4 -4
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  60. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  61. package/src/__tests__/runtime-runs.test.ts +4 -4
  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-conflict-gate.test.ts +28 -25
  67. package/src/__tests__/session-error.test.ts +28 -0
  68. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  69. package/src/__tests__/session-queue.test.ts +71 -48
  70. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  71. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  72. package/src/__tests__/signup-e2e.test.ts +2 -1
  73. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  74. package/src/__tests__/skill-script-runner.test.ts +159 -0
  75. package/src/__tests__/speaker-identification.test.ts +52 -0
  76. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  77. package/src/__tests__/subagent-tools.test.ts +141 -41
  78. package/src/__tests__/task-compiler.test.ts +2 -1
  79. package/src/__tests__/task-runner.test.ts +2 -1
  80. package/src/__tests__/task-scheduler.test.ts +2 -1
  81. package/src/__tests__/task-tools.test.ts +49 -56
  82. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  83. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  84. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  85. package/src/__tests__/tool-executor.test.ts +13 -17
  86. package/src/__tests__/turn-commit.test.ts +218 -3
  87. package/src/__tests__/twilio-provider.test.ts +143 -0
  88. package/src/__tests__/twilio-routes.test.ts +789 -0
  89. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  90. package/src/__tests__/view-image-tool.test.ts +217 -0
  91. package/src/__tests__/workspace-git-service.test.ts +186 -0
  92. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  93. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  94. package/src/bundler/app-bundler.ts +12 -8
  95. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  96. package/src/calls/call-bridge.ts +95 -0
  97. package/src/calls/call-constants.ts +43 -5
  98. package/src/calls/call-domain.ts +276 -0
  99. package/src/calls/call-orchestrator.ts +43 -17
  100. package/src/calls/call-recovery.ts +207 -0
  101. package/src/calls/call-state-machine.ts +68 -0
  102. package/src/calls/call-store.ts +192 -5
  103. package/src/calls/relay-server.ts +41 -4
  104. package/src/calls/speaker-identification.ts +213 -0
  105. package/src/calls/twilio-config.ts +8 -8
  106. package/src/calls/twilio-provider.ts +13 -9
  107. package/src/calls/twilio-routes.ts +90 -76
  108. package/src/calls/twilio-webhook-urls.ts +50 -0
  109. package/src/calls/types.ts +1 -1
  110. package/src/cli/config-commands.ts +334 -0
  111. package/src/cli/core-commands.ts +776 -0
  112. package/src/cli/doordash.ts +251 -1
  113. package/src/cli/ipc-client.ts +82 -0
  114. package/src/cli/map.ts +270 -0
  115. package/src/cli/twitter.ts +575 -0
  116. package/src/cli.ts +7 -5
  117. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  118. package/src/commands/cc-command-registry.ts +209 -0
  119. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  120. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  123. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  124. package/src/config/bundled-skills/document/SKILL.md +18 -0
  125. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  126. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  127. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  128. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  129. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  130. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  131. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  133. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  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 +34 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +165 -1
  181. package/src/config/system-prompt.ts +61 -16
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +4 -0
  184. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  185. package/src/contacts/contact-store.ts +4 -4
  186. package/src/daemon/assistant-attachments.ts +10 -0
  187. package/src/daemon/classifier.ts +3 -1
  188. package/src/daemon/computer-use-session.ts +3 -1
  189. package/src/daemon/date-context.ts +136 -0
  190. package/src/daemon/handlers/apps.ts +16 -1
  191. package/src/daemon/handlers/browser.ts +54 -0
  192. package/src/daemon/handlers/computer-use.ts +7 -1
  193. package/src/daemon/handlers/config.ts +205 -5
  194. package/src/daemon/handlers/diagnostics.ts +5 -1
  195. package/src/daemon/handlers/documents.ts +18 -29
  196. package/src/daemon/handlers/home-base.ts +5 -1
  197. package/src/daemon/handlers/index.ts +40 -277
  198. package/src/daemon/handlers/misc.ts +9 -1
  199. package/src/daemon/handlers/publish.ts +6 -1
  200. package/src/daemon/handlers/sessions.ts +65 -12
  201. package/src/daemon/handlers/shared.ts +36 -1
  202. package/src/daemon/handlers/signing.ts +37 -0
  203. package/src/daemon/handlers/skills.ts +20 -6
  204. package/src/daemon/handlers/subagents.ts +8 -3
  205. package/src/daemon/handlers/twitter-auth.ts +169 -0
  206. package/src/daemon/handlers/work-items.ts +384 -68
  207. package/src/daemon/ipc-contract-inventory.json +32 -4
  208. package/src/daemon/ipc-contract.ts +156 -37
  209. package/src/daemon/ipc-protocol.ts +7 -2
  210. package/src/daemon/lifecycle.ts +21 -0
  211. package/src/daemon/main.ts +10 -4
  212. package/src/daemon/ride-shotgun-handler.ts +75 -10
  213. package/src/daemon/server.ts +143 -26
  214. package/src/daemon/session-agent-loop.ts +922 -0
  215. package/src/daemon/session-attachments.ts +28 -5
  216. package/src/daemon/session-conflict-gate.ts +18 -109
  217. package/src/daemon/session-error.ts +24 -3
  218. package/src/daemon/session-lifecycle.ts +147 -0
  219. package/src/daemon/session-media-retry.ts +147 -0
  220. package/src/daemon/session-messaging.ts +145 -0
  221. package/src/daemon/session-notifiers.ts +164 -0
  222. package/src/daemon/session-process.ts +2 -2
  223. package/src/daemon/session-queue-manager.ts +1 -0
  224. package/src/daemon/session-runtime-assembly.ts +52 -0
  225. package/src/daemon/session-skill-tools.ts +124 -5
  226. package/src/daemon/session-slash.ts +3 -0
  227. package/src/daemon/session-surfaces.ts +77 -2
  228. package/src/daemon/session-tool-setup.ts +216 -2
  229. package/src/daemon/session-usage.ts +0 -2
  230. package/src/daemon/session.ts +114 -1404
  231. package/src/daemon/video-thumbnail.ts +60 -0
  232. package/src/doordash/client.ts +121 -27
  233. package/src/doordash/queries.ts +1 -2
  234. package/src/export/formatter.ts +3 -1
  235. package/src/followups/followup-store.ts +4 -2
  236. package/src/followups/types.ts +6 -0
  237. package/src/hooks/templates.ts +1 -1
  238. package/src/index.ts +32 -1153
  239. package/src/memory/attachments-store.ts +28 -83
  240. package/src/memory/channel-delivery-store.ts +7 -21
  241. package/src/memory/clarification-resolver.ts +6 -5
  242. package/src/memory/conflict-intent.ts +114 -0
  243. package/src/memory/contradiction-checker.ts +3 -2
  244. package/src/memory/conversation-key-store.ts +10 -29
  245. package/src/memory/conversation-store.ts +2 -1
  246. package/src/memory/db.ts +96 -2
  247. package/src/memory/entity-extractor.ts +6 -3
  248. package/src/memory/items-extractor.ts +5 -4
  249. package/src/memory/job-handlers/conflict.ts +23 -1
  250. package/src/memory/jobs-store.ts +3 -2
  251. package/src/memory/llm-usage-store.ts +1 -2
  252. package/src/memory/runs-store.ts +1 -2
  253. package/src/memory/schema.ts +23 -2
  254. package/src/messaging/style-analyzer.ts +3 -2
  255. package/src/messaging/thread-summarizer.ts +8 -12
  256. package/src/messaging/triage-engine.ts +4 -2
  257. package/src/providers/openrouter/client.ts +20 -0
  258. package/src/providers/registry.ts +8 -0
  259. package/src/runtime/gateway-client.ts +36 -0
  260. package/src/runtime/http-server.ts +166 -22
  261. package/src/runtime/routes/attachment-routes.ts +2 -3
  262. package/src/runtime/routes/call-routes.ts +140 -0
  263. package/src/runtime/routes/channel-routes.ts +125 -88
  264. package/src/runtime/routes/conversation-routes.ts +5 -5
  265. package/src/runtime/routes/run-routes.ts +2 -2
  266. package/src/runtime/run-orchestrator.ts +9 -3
  267. package/src/schedule/recurrence-engine.ts +138 -0
  268. package/src/schedule/recurrence-types.ts +67 -0
  269. package/src/schedule/schedule-store.ts +102 -57
  270. package/src/schedule/scheduler.ts +9 -6
  271. package/src/security/oauth2.ts +29 -4
  272. package/src/security/secret-allowlist.ts +46 -0
  273. package/src/skills/clawhub.ts +1 -1
  274. package/src/subagent/manager.ts +40 -8
  275. package/src/swarm/backend-claude-code.ts +64 -9
  276. package/src/swarm/worker-prompts.ts +2 -1
  277. package/src/tasks/SPEC.md +34 -28
  278. package/src/tasks/ephemeral-permissions.ts +16 -7
  279. package/src/tasks/task-compiler.ts +5 -4
  280. package/src/tasks/task-runner.ts +10 -5
  281. package/src/tasks/task-scheduler.ts +1 -1
  282. package/src/tasks/tool-sanitizer.ts +36 -0
  283. package/src/tools/assets/search.ts +4 -4
  284. package/src/tools/browser/api-map.ts +293 -0
  285. package/src/tools/browser/auto-navigate.ts +270 -0
  286. package/src/tools/browser/browser-execution.ts +2 -1
  287. package/src/tools/browser/browser-manager.ts +2 -2
  288. package/src/tools/browser/network-recorder.ts +5 -4
  289. package/src/tools/browser/x-auto-navigate.ts +207 -0
  290. package/src/tools/calls/call-end.ts +17 -67
  291. package/src/tools/calls/call-start.ts +24 -85
  292. package/src/tools/calls/call-status.ts +35 -51
  293. package/src/tools/claude-code/claude-code.ts +207 -11
  294. package/src/tools/contacts/contact-merge.ts +46 -78
  295. package/src/tools/contacts/contact-search.ts +35 -79
  296. package/src/tools/contacts/contact-upsert.ts +35 -108
  297. package/src/tools/credentials/vault.ts +20 -4
  298. package/src/tools/document/document-tool.ts +71 -144
  299. package/src/tools/executor.ts +129 -10
  300. package/src/tools/followups/followup_create.ts +46 -88
  301. package/src/tools/followups/followup_list.ts +34 -74
  302. package/src/tools/followups/followup_resolve.ts +31 -66
  303. package/src/tools/host-terminal/cli-discover.ts +2 -1
  304. package/src/tools/host-terminal/host-shell.ts +10 -0
  305. package/src/tools/memory/handlers.ts +5 -4
  306. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  307. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  308. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  309. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  310. package/src/tools/network/web-fetch.ts +18 -6
  311. package/src/tools/playbooks/index.ts +4 -5
  312. package/src/tools/playbooks/playbook-create.ts +3 -47
  313. package/src/tools/playbooks/playbook-delete.ts +1 -25
  314. package/src/tools/playbooks/playbook-list.ts +1 -28
  315. package/src/tools/playbooks/playbook-update.ts +3 -51
  316. package/src/tools/reminder/reminder.ts +5 -78
  317. package/src/tools/schedule/create.ts +69 -74
  318. package/src/tools/schedule/delete.ts +21 -47
  319. package/src/tools/schedule/list.ts +55 -74
  320. package/src/tools/schedule/update.ts +77 -84
  321. package/src/tools/subagent/abort.ts +29 -58
  322. package/src/tools/subagent/message.ts +30 -63
  323. package/src/tools/subagent/read.ts +53 -84
  324. package/src/tools/subagent/spawn.ts +43 -82
  325. package/src/tools/subagent/status.ts +42 -71
  326. package/src/tools/swarm/delegate.ts +2 -1
  327. package/src/tools/tasks/index.ts +8 -8
  328. package/src/tools/tasks/task-delete.ts +60 -88
  329. package/src/tools/tasks/task-list.ts +31 -52
  330. package/src/tools/tasks/task-run.ts +72 -108
  331. package/src/tools/tasks/task-save.ts +33 -65
  332. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  333. package/src/tools/tasks/work-item-list.ts +33 -63
  334. package/src/tools/tasks/work-item-remove.ts +45 -97
  335. package/src/tools/tasks/work-item-update.ts +91 -163
  336. package/src/tools/terminal/backends/native.ts +3 -1
  337. package/src/tools/tool-manifest.ts +0 -62
  338. package/src/tools/types.ts +6 -0
  339. package/src/tools/ui-surface/definitions.ts +3 -1
  340. package/src/tools/watch/screen-watch.ts +3 -1
  341. package/src/tools/watcher/create.ts +52 -98
  342. package/src/tools/watcher/delete.ts +20 -46
  343. package/src/tools/watcher/digest.ts +36 -70
  344. package/src/tools/watcher/list.ts +49 -79
  345. package/src/tools/watcher/update.ts +45 -91
  346. package/src/twitter/client.ts +690 -0
  347. package/src/twitter/session.ts +91 -0
  348. package/src/usage/types.ts +0 -1
  349. package/src/util/truncate.ts +6 -0
  350. package/src/watcher/providers/slack.ts +2 -1
  351. package/src/watcher/watcher-store.ts +3 -2
  352. package/src/work-items/work-item-store.ts +27 -2
  353. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  354. package/src/workspace/git-service.ts +87 -22
  355. package/src/workspace/provider-commit-message-generator.ts +269 -0
  356. package/src/workspace/turn-commit.ts +62 -3
  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,329 @@
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(), 'scheduler-recurrence-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
+ migrateToDataLayout: () => {},
19
+ migrateToWorkspaceLayout: () => {},
20
+ }));
21
+
22
+ mock.module('../util/logger.js', () => ({
23
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
24
+ get: () => () => {},
25
+ }),
26
+ }));
27
+
28
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
29
+ import { createTask } from '../tasks/task-store.js';
30
+ import {
31
+ createSchedule,
32
+ getSchedule,
33
+ getScheduleRuns,
34
+ } from '../schedule/schedule-store.js';
35
+ import { startScheduler } from '../schedule/scheduler.js';
36
+
37
+ initializeDb();
38
+
39
+ /** Access the underlying bun:sqlite Database for raw parameterized queries. */
40
+ function getRawDb(): import('bun:sqlite').Database {
41
+ return (getDb() as unknown as { $client: import('bun:sqlite').Database }).$client;
42
+ }
43
+
44
+ /** Force a schedule to be due by setting next_run_at in the past. */
45
+ function forceScheduleDue(scheduleId: string): void {
46
+ getRawDb().run('UPDATE cron_jobs SET next_run_at = ? WHERE id = ?', [Date.now() - 1000, scheduleId]);
47
+ }
48
+
49
+ afterAll(() => {
50
+ resetDb();
51
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
52
+ });
53
+
54
+ // Build an RRULE expression anchored at the given start date, recurring every minute.
55
+ // This ensures the rule always has future occurrences relative to the test clock.
56
+ function buildEveryMinuteRrule(dtstart: Date = new Date()): string {
57
+ const pad = (n: number) => String(n).padStart(2, '0');
58
+ const ds = `${dtstart.getUTCFullYear()}${pad(dtstart.getUTCMonth() + 1)}${pad(dtstart.getUTCDate())}T${pad(dtstart.getUTCHours())}${pad(dtstart.getUTCMinutes())}${pad(dtstart.getUTCSeconds())}Z`;
59
+ return `DTSTART:${ds}\nRRULE:FREQ=MINUTELY;INTERVAL=1`;
60
+ }
61
+
62
+ // Build an RRULE expression that ended in the past (UNTIL already passed).
63
+ function buildEndedRrule(): string {
64
+ const past = new Date(Date.now() - 86_400_000 * 30); // 30 days ago
65
+ const until = new Date(Date.now() - 86_400_000); // 1 day ago
66
+ const pad = (n: number) => String(n).padStart(2, '0');
67
+ const ds = `${past.getUTCFullYear()}${pad(past.getUTCMonth() + 1)}${pad(past.getUTCDate())}T000000Z`;
68
+ const us = `${until.getUTCFullYear()}${pad(until.getUTCMonth() + 1)}${pad(until.getUTCDate())}T235959Z`;
69
+ return `DTSTART:${ds}\nRRULE:FREQ=DAILY;INTERVAL=1;UNTIL=${us}`;
70
+ }
71
+
72
+ // ── RRULE schedule fires through the scheduler ──────────────────────
73
+
74
+ describe('scheduler RRULE execution', () => {
75
+ beforeEach(() => {
76
+ const db = getDb();
77
+ db.run('DELETE FROM cron_runs');
78
+ db.run('DELETE FROM cron_jobs');
79
+ db.run('DELETE FROM task_runs');
80
+ db.run('DELETE FROM tasks');
81
+ db.run('DELETE FROM messages');
82
+ db.run('DELETE FROM conversations');
83
+ });
84
+
85
+ test('RRULE schedule fires and creates cron_runs entry', async () => {
86
+ const rruleExpr = buildEveryMinuteRrule();
87
+ const schedule = createSchedule({
88
+ name: 'RRULE Test',
89
+ cronExpression: rruleExpr,
90
+ message: 'Hello from RRULE',
91
+ syntax: 'rrule',
92
+ expression: rruleExpr,
93
+ });
94
+
95
+ // Verify it was stored with rrule syntax
96
+ const stored = getSchedule(schedule.id);
97
+ expect(stored).not.toBeNull();
98
+ expect(stored!.syntax).toBe('rrule');
99
+
100
+ // Force it to be due
101
+ forceScheduleDue(schedule.id);
102
+
103
+ const processedMessages: { conversationId: string; message: string }[] = [];
104
+ const processMessage = async (conversationId: string, message: string) => {
105
+ processedMessages.push({ conversationId, message });
106
+ };
107
+
108
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
109
+ await new Promise(resolve => setTimeout(resolve, 500));
110
+ scheduler.stop();
111
+
112
+ // processMessage should have been called with the RRULE message
113
+ expect(processedMessages.some(m => m.message === 'Hello from RRULE')).toBe(true);
114
+
115
+ // A cron_runs entry should have been created
116
+ const runs = getScheduleRuns(schedule.id);
117
+ expect(runs.length).toBeGreaterThanOrEqual(1);
118
+ expect(runs[0].status).toBe('ok');
119
+ });
120
+
121
+ test('RRULE run_task:<id> triggers task runner', async () => {
122
+ const task = createTask({
123
+ title: 'RRULE Task',
124
+ template: 'Execute RRULE task',
125
+ });
126
+
127
+ const rruleExpr = buildEveryMinuteRrule();
128
+ const schedule = createSchedule({
129
+ name: 'RRULE Task Schedule',
130
+ cronExpression: rruleExpr,
131
+ message: `run_task:${task.id}`,
132
+ syntax: 'rrule',
133
+ expression: rruleExpr,
134
+ });
135
+
136
+ forceScheduleDue(schedule.id);
137
+
138
+ const directCalls: { conversationId: string; message: string }[] = [];
139
+ const processMessage = async (conversationId: string, message: string) => {
140
+ directCalls.push({ conversationId, message });
141
+ };
142
+
143
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
144
+ await new Promise(resolve => setTimeout(resolve, 500));
145
+ scheduler.stop();
146
+
147
+ // runTask renders the template, so processMessage gets the template text
148
+ const runTaskCalls = directCalls.filter(c => c.message === 'Execute RRULE task');
149
+ const rawCalls = directCalls.filter(c => c.message.startsWith('run_task:'));
150
+
151
+ expect(runTaskCalls.length).toBe(1);
152
+ expect(rawCalls.length).toBe(0);
153
+
154
+ // A cron_runs entry should exist
155
+ const runs = getScheduleRuns(schedule.id);
156
+ expect(runs.length).toBeGreaterThanOrEqual(1);
157
+ });
158
+
159
+ test('ended RRULE (UNTIL in past) is not repeatedly claimed', async () => {
160
+ const endedExpr = buildEndedRrule();
161
+
162
+ // Insert directly via raw SQL because createSchedule would throw when
163
+ // computing nextRunAt for an already-ended RRULE. This simulates a
164
+ // schedule that was valid when created but has since expired.
165
+ const id = crypto.randomUUID();
166
+ const now = Date.now();
167
+ getRawDb().run(
168
+ `INSERT INTO cron_jobs (id, name, enabled, cron_expression, schedule_syntax, timezone, message, next_run_at, last_run_at, last_status, retry_count, created_by, created_at, updated_at)
169
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
170
+ [id, 'Ended RRULE', 1, endedExpr, 'rrule', null, 'Should not fire', now - 1000, null, null, 0, 'agent', now, now],
171
+ );
172
+
173
+ const processedMessages: string[] = [];
174
+ const processMessage = async (_conversationId: string, message: string) => {
175
+ processedMessages.push(message);
176
+ };
177
+
178
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
179
+ await new Promise(resolve => setTimeout(resolve, 500));
180
+ scheduler.stop();
181
+
182
+ // The ended RRULE should NOT have fired
183
+ expect(processedMessages).not.toContain('Should not fire');
184
+
185
+ // No runs should have been created
186
+ const runs = getScheduleRuns(id);
187
+ expect(runs.length).toBe(0);
188
+ });
189
+
190
+ test('existing cron schedule behavior is unchanged', async () => {
191
+ const schedule = createSchedule({
192
+ name: 'Cron Schedule',
193
+ cronExpression: '* * * * *',
194
+ message: 'Cron message',
195
+ });
196
+
197
+ // Verify it defaults to cron syntax
198
+ const stored = getSchedule(schedule.id);
199
+ expect(stored).not.toBeNull();
200
+ expect(stored!.syntax).toBe('cron');
201
+
202
+ forceScheduleDue(schedule.id);
203
+
204
+ const processedMessages: { conversationId: string; message: string }[] = [];
205
+ const processMessage = async (conversationId: string, message: string) => {
206
+ processedMessages.push({ conversationId, message });
207
+ };
208
+
209
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
210
+ await new Promise(resolve => setTimeout(resolve, 500));
211
+ scheduler.stop();
212
+
213
+ // processMessage should have been called with the cron message
214
+ expect(processedMessages.some(m => m.message === 'Cron message')).toBe(true);
215
+
216
+ // A cron_runs entry should have been created
217
+ const runs = getScheduleRuns(schedule.id);
218
+ expect(runs.length).toBeGreaterThanOrEqual(1);
219
+ expect(runs[0].status).toBe('ok');
220
+ });
221
+
222
+ test('RRULE set with EXDATE skips excluded occurrence and advances to next valid date', async () => {
223
+ // Build an RRULE set that fires every minute but excludes the next immediate occurrence.
224
+ // The scheduler should skip the excluded date and advance to the one after.
225
+ const now = new Date();
226
+ const pad = (n: number) => String(n).padStart(2, '0');
227
+
228
+ // DTSTART one hour ago so there are plenty of past occurrences
229
+ const pastDate = new Date(now.getTime() - 3_600_000);
230
+ const ds = `${pastDate.getUTCFullYear()}${pad(pastDate.getUTCMonth() + 1)}${pad(pastDate.getUTCDate())}T${pad(pastDate.getUTCHours())}${pad(pastDate.getUTCMinutes())}${pad(pastDate.getUTCSeconds())}Z`;
231
+
232
+ // Exclude the current minute's occurrence
233
+ const currentMinuteDate = new Date(now);
234
+ currentMinuteDate.setUTCSeconds(0);
235
+ currentMinuteDate.setUTCMilliseconds(0);
236
+ // Round to the previous minute boundary relative to dtstart
237
+ const exDate = `${currentMinuteDate.getUTCFullYear()}${pad(currentMinuteDate.getUTCMonth() + 1)}${pad(currentMinuteDate.getUTCDate())}T${pad(currentMinuteDate.getUTCHours())}${pad(currentMinuteDate.getUTCMinutes())}00Z`;
238
+
239
+ const expression = `DTSTART:${ds}\nRRULE:FREQ=MINUTELY;INTERVAL=1\nEXDATE:${exDate}`;
240
+
241
+ const schedule = createSchedule({
242
+ name: 'RRULE set EXDATE test',
243
+ cronExpression: expression,
244
+ message: 'Set exclusion test',
245
+ syntax: 'rrule',
246
+ expression,
247
+ });
248
+
249
+ // Force the schedule to be due
250
+ forceScheduleDue(schedule.id);
251
+
252
+ const processedMessages: string[] = [];
253
+ const processMessage = async (_conversationId: string, message: string) => {
254
+ processedMessages.push(message);
255
+ };
256
+
257
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
258
+ await new Promise(resolve => setTimeout(resolve, 500));
259
+ scheduler.stop();
260
+
261
+ // The schedule should have been claimed and nextRunAt advanced
262
+ const after = getSchedule(schedule.id);
263
+ expect(after).not.toBeNull();
264
+ expect(after!.lastRunAt).not.toBeNull();
265
+ // nextRunAt should be in the future (not the excluded date)
266
+ expect(after!.nextRunAt).toBeGreaterThan(Date.now() - 5000);
267
+ });
268
+
269
+ test('RRULE set schedule fires and creates cron_runs entry', async () => {
270
+ const expression = [
271
+ 'DTSTART:20250101T000000Z',
272
+ 'RRULE:FREQ=MINUTELY;INTERVAL=1',
273
+ 'EXDATE:20250101T000100Z',
274
+ ].join('\n');
275
+
276
+ const schedule = createSchedule({
277
+ name: 'Set schedule fire test',
278
+ cronExpression: expression,
279
+ message: 'Set fire test',
280
+ syntax: 'rrule',
281
+ expression,
282
+ });
283
+
284
+ forceScheduleDue(schedule.id);
285
+
286
+ const processedMessages: string[] = [];
287
+ const processMessage = async (_conversationId: string, message: string) => {
288
+ processedMessages.push(message);
289
+ };
290
+
291
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
292
+ await new Promise(resolve => setTimeout(resolve, 500));
293
+ scheduler.stop();
294
+
295
+ expect(processedMessages).toContain('Set fire test');
296
+
297
+ const runs = getScheduleRuns(schedule.id);
298
+ expect(runs.length).toBeGreaterThanOrEqual(1);
299
+ expect(runs[0].status).toBe('ok');
300
+ });
301
+
302
+ test('RRULE schedule advances nextRunAt after firing', async () => {
303
+ const rruleExpr = buildEveryMinuteRrule();
304
+ const schedule = createSchedule({
305
+ name: 'Advancing RRULE',
306
+ cronExpression: rruleExpr,
307
+ message: 'Advance test',
308
+ syntax: 'rrule',
309
+ expression: rruleExpr,
310
+ });
311
+
312
+ const originalNextRunAt = getSchedule(schedule.id)!.nextRunAt;
313
+ forceScheduleDue(schedule.id);
314
+ const forcedDueAt = getSchedule(schedule.id)!.nextRunAt;
315
+
316
+ const processMessage = async () => {};
317
+ const scheduler = startScheduler(processMessage, () => {}, () => {});
318
+ await new Promise(resolve => setTimeout(resolve, 500));
319
+ scheduler.stop();
320
+
321
+ const after = getSchedule(schedule.id);
322
+ expect(after).not.toBeNull();
323
+ // nextRunAt must have moved forward from the forced-due value
324
+ expect(after!.nextRunAt).toBeGreaterThan(forcedDueAt);
325
+ // It should be at or near the original computed value (within a few seconds tolerance)
326
+ expect(Math.abs(after!.nextRunAt - originalNextRunAt)).toBeLessThan(5000);
327
+ expect(after!.lastRunAt).not.toBeNull();
328
+ });
329
+ });
@@ -24,12 +24,12 @@ mock.module('../util/logger.js', () => ({
24
24
  }),
25
25
  }));
26
26
 
27
- import { initializeDb, getDb } from '../memory/db.js';
27
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
28
28
  import { renderHistoryContent, mergeToolResults } from '../daemon/handlers.js';
29
29
  import {
30
30
  uploadAttachment,
31
31
  linkAttachmentToMessage,
32
- getAttachmentsForMessageUnscoped,
32
+ getAttachmentsForMessage,
33
33
  } from '../memory/attachments-store.js';
34
34
  import {
35
35
  createConversation,
@@ -39,6 +39,7 @@ import {
39
39
  initializeDb();
40
40
 
41
41
  afterAll(() => {
42
+ resetDb();
42
43
  try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
43
44
  });
44
45
 
@@ -370,7 +371,7 @@ describe('mergeToolResults', () => {
370
371
  });
371
372
  });
372
373
 
373
- describe('getAttachmentsForMessageUnscoped', () => {
374
+ describe('getAttachmentsForMessage', () => {
374
375
  beforeEach(() => {
375
376
  const db = getDb();
376
377
  db.run('DELETE FROM message_attachments');
@@ -387,10 +388,10 @@ describe('getAttachmentsForMessageUnscoped', () => {
387
388
 
388
389
  test('returns attachments linked to a message', () => {
389
390
  const msgId = createMessage('assistant', 'Here is a chart');
390
- const stored = uploadAttachment('ast-1', 'chart.png', 'image/png', 'iVBOR');
391
+ const stored = uploadAttachment('chart.png', 'image/png', 'iVBOR');
391
392
  linkAttachmentToMessage(msgId, stored.id, 0);
392
393
 
393
- const result = getAttachmentsForMessageUnscoped(msgId);
394
+ const result = getAttachmentsForMessage(msgId);
394
395
  expect(result).toHaveLength(1);
395
396
  expect(result[0].id).toBe(stored.id);
396
397
  expect(result[0].originalFilename).toBe('chart.png');
@@ -399,32 +400,32 @@ describe('getAttachmentsForMessageUnscoped', () => {
399
400
  });
400
401
 
401
402
  test('returns empty array when no attachments are linked', () => {
402
- expect(getAttachmentsForMessageUnscoped('msg-nonexistent')).toEqual([]);
403
+ expect(getAttachmentsForMessage('msg-nonexistent')).toEqual([]);
403
404
  });
404
405
 
405
406
  test('returns multiple attachments in position order', () => {
406
407
  const msgId = createMessage('assistant', 'Two files');
407
- const a1 = uploadAttachment('ast-1', 'first.txt', 'text/plain', 'AAAA');
408
- const a2 = uploadAttachment('ast-1', 'second.txt', 'text/plain', 'BBBB');
408
+ const a1 = uploadAttachment('first.txt', 'text/plain', 'AAAA');
409
+ const a2 = uploadAttachment('second.txt', 'text/plain', 'BBBB');
409
410
 
410
411
  linkAttachmentToMessage(msgId, a2.id, 1);
411
412
  linkAttachmentToMessage(msgId, a1.id, 0);
412
413
 
413
- const result = getAttachmentsForMessageUnscoped(msgId);
414
+ const result = getAttachmentsForMessage(msgId);
414
415
  expect(result).toHaveLength(2);
415
416
  expect(result[0].originalFilename).toBe('first.txt');
416
417
  expect(result[1].originalFilename).toBe('second.txt');
417
418
  });
418
419
 
419
- test('works across different assistantIds', () => {
420
+ test('returns all attachments linked to a message', () => {
420
421
  const msgId = createMessage('assistant', 'Mixed');
421
- const a1 = uploadAttachment('ast-A', 'a.png', 'image/png', 'AAAA');
422
- const a2 = uploadAttachment('ast-B', 'b.png', 'image/png', 'BBBB');
422
+ const a1 = uploadAttachment('a.png', 'image/png', 'AAAA');
423
+ const a2 = uploadAttachment('b.png', 'image/png', 'BBBB');
423
424
 
424
425
  linkAttachmentToMessage(msgId, a1.id, 0);
425
426
  linkAttachmentToMessage(msgId, a2.id, 1);
426
427
 
427
- const result = getAttachmentsForMessageUnscoped(msgId);
428
+ const result = getAttachmentsForMessage(msgId);
428
429
  expect(result).toHaveLength(2);
429
430
  });
430
431
  });
@@ -39,8 +39,18 @@ let resolverResult: {
39
39
 
40
40
  const persistedMessages: Array<{ id: string; role: string; content: string; createdAt: number }> = [];
41
41
 
42
+ function makeMockLogger(): Record<string, unknown> {
43
+ const logger: Record<string, unknown> = {};
44
+ logger.child = () => logger;
45
+ logger.debug = () => {};
46
+ logger.info = () => {};
47
+ logger.warn = () => {};
48
+ logger.error = () => {};
49
+ return logger;
50
+ }
51
+
42
52
  mock.module('../util/logger.js', () => ({
43
- getLogger: () => new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
53
+ getLogger: () => makeMockLogger(),
44
54
  }));
45
55
 
46
56
  mock.module('../util/platform.js', () => ({
@@ -305,7 +315,7 @@ describe('Session conflict soft gate', () => {
305
315
  await session.processMessage('Should I use React or Vue here?', [], (event) => events.push(event));
306
316
 
307
317
  expect(runCalls).toHaveLength(0);
308
- expect(resolverCallCount).toBe(1);
318
+ expect(resolverCallCount).toBe(0);
309
319
  expect(markAskedCalls).toEqual(['conflict-relevant']);
310
320
  const clarificationEvent = events.find((event) => event.type === 'assistant_text_delta');
311
321
  expect(clarificationEvent).toBeDefined();
@@ -315,7 +325,7 @@ describe('Session conflict soft gate', () => {
315
325
  expect(events.some((event) => event.type === 'message_complete')).toBe(true);
316
326
  });
317
327
 
318
- test('irrelevant unresolved conflict asks once and continues with normal answer flow', async () => {
328
+ test('irrelevant unresolved conflict does not inject side-question into normal answer flow', async () => {
319
329
  pendingConflicts = [{
320
330
  id: 'conflict-irrelevant',
321
331
  scopeId: 'default',
@@ -332,13 +342,6 @@ describe('Session conflict soft gate', () => {
332
342
  existingStatement: 'Use Postgres as the default database.',
333
343
  candidateStatement: 'Use MySQL as the default database.',
334
344
  }];
335
- resolverResult = {
336
- resolution: 'keep_existing',
337
- strategy: 'heuristic',
338
- resolvedStatement: null,
339
- explanation: 'Resolved by accident.',
340
- };
341
-
342
345
  const session = makeSession();
343
346
  await session.loadFromDb();
344
347
 
@@ -349,10 +352,10 @@ describe('Session conflict soft gate', () => {
349
352
  const injectedUser = runCalls[0][runCalls[0].length - 1];
350
353
  expect(injectedUser.role).toBe('user');
351
354
  const injectedText = extractText(injectedUser);
352
- expect(injectedText).toContain('Memory clarification request');
353
- expect(injectedText).toContain('Should I assume Postgres or MySQL?');
355
+ expect(injectedText).not.toContain('Memory clarification request');
356
+ expect(injectedText).not.toContain('Should I assume Postgres or MySQL?');
354
357
  expect(resolverCallCount).toBe(0);
355
- expect(markAskedCalls).toEqual(['conflict-irrelevant']);
358
+ expect(markAskedCalls).toEqual([]);
356
359
  expect(events.some((event) => event.type === 'message_complete')).toBe(true);
357
360
  });
358
361
 
@@ -379,7 +382,7 @@ describe('Session conflict soft gate', () => {
379
382
 
380
383
  // First turn asks the clarification and records it as asked.
381
384
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
382
- expect(resolverCallCount).toBe(1);
385
+ expect(resolverCallCount).toBe(0);
383
386
  expect(markAskedCalls).toEqual(['conflict-followup']);
384
387
 
385
388
  resolverResult = {
@@ -392,7 +395,7 @@ describe('Session conflict soft gate', () => {
392
395
  // Follow-up reply does not overlap statement tokens but should still resolve.
393
396
  await session.processMessage('Keep the new one.', [], () => {});
394
397
 
395
- expect(resolverCallCount).toBe(2);
398
+ expect(resolverCallCount).toBe(1);
396
399
  expect(markAskedCalls).toEqual(['conflict-followup']);
397
400
  expect(runCalls).toHaveLength(1);
398
401
  });
@@ -420,7 +423,7 @@ describe('Session conflict soft gate', () => {
420
423
 
421
424
  // First turn asks the clarification.
422
425
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
423
- expect(resolverCallCount).toBe(1);
426
+ expect(resolverCallCount).toBe(0);
424
427
  expect(markAskedCalls).toEqual(['conflict-concise']);
425
428
 
426
429
  resolverResult = {
@@ -433,7 +436,7 @@ describe('Session conflict soft gate', () => {
433
436
  // Short directional reply with no action verb should still resolve.
434
437
  await session.processMessage('both', [], () => {});
435
438
 
436
- expect(resolverCallCount).toBe(2);
439
+ expect(resolverCallCount).toBe(1);
437
440
  expect(runCalls).toHaveLength(1);
438
441
  });
439
442
 
@@ -460,7 +463,7 @@ describe('Session conflict soft gate', () => {
460
463
 
461
464
  // First turn: relevant question triggers clarification ask.
462
465
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
463
- expect(resolverCallCount).toBe(1);
466
+ expect(resolverCallCount).toBe(0);
464
467
  expect(markAskedCalls).toEqual(['conflict-unrelated']);
465
468
 
466
469
  // Second turn: unrelated question containing the cue word "new" should NOT
@@ -473,8 +476,8 @@ describe('Session conflict soft gate', () => {
473
476
  };
474
477
  await session.processMessage("What's new in Bun?", [], () => {});
475
478
 
476
- // The resolver should NOT have been called again for this unrelated question.
477
- expect(resolverCallCount).toBe(1);
479
+ // The resolver should NOT have been called for this unrelated question.
480
+ expect(resolverCallCount).toBe(0);
478
481
  // Normal agent loop should still run.
479
482
  expect(runCalls).toHaveLength(1);
480
483
  });
@@ -502,7 +505,7 @@ describe('Session conflict soft gate', () => {
502
505
 
503
506
  // First turn: triggers clarification ask.
504
507
  await session.processMessage('Should I assume Postgres or MySQL?', [], () => {});
505
- expect(resolverCallCount).toBe(1);
508
+ expect(resolverCallCount).toBe(0);
506
509
  expect(markAskedCalls).toEqual(['conflict-unrelated-no-qmark']);
507
510
 
508
511
  resolverResult = {
@@ -516,11 +519,11 @@ describe('Session conflict soft gate', () => {
516
519
  // Should NOT resolve the conflict.
517
520
  await session.processMessage('I started a new project today', [], () => {});
518
521
 
519
- expect(resolverCallCount).toBe(1);
522
+ expect(resolverCallCount).toBe(0);
520
523
  expect(runCalls).toHaveLength(1);
521
524
  });
522
525
 
523
- test('cooldown prevents repeated asks on subsequent turns', async () => {
526
+ test('irrelevant conflicts remain silent across subsequent turns', async () => {
524
527
  pendingConflicts = [{
525
528
  id: 'conflict-cooldown',
526
529
  scopeId: 'default',
@@ -547,9 +550,9 @@ describe('Session conflict soft gate', () => {
547
550
  expect(runCalls).toHaveLength(2);
548
551
  const firstUserText = extractText(runCalls[0][runCalls[0].length - 1]);
549
552
  const secondUserText = extractText(runCalls[1][runCalls[1].length - 1]);
550
- expect(firstUserText).toContain('Memory clarification request');
553
+ expect(firstUserText).not.toContain('Memory clarification request');
551
554
  expect(secondUserText).not.toContain('Memory clarification request');
552
- expect(markAskedCalls).toEqual(['conflict-cooldown']);
555
+ expect(markAskedCalls).toEqual([]);
553
556
  });
554
557
 
555
558
  test('passes session scopeId through to conflict store queries', async () => {
@@ -117,6 +117,34 @@ describe('classifySessionError', () => {
117
117
  }
118
118
  });
119
119
 
120
+ describe('timeout errors (generic, not network/gateway)', () => {
121
+ const cases = [
122
+ 'timeout',
123
+ 'deadline exceeded',
124
+ 'request timed out',
125
+ ];
126
+
127
+ for (const msg of cases) {
128
+ it(`classifies "${msg}" as PROVIDER_API with timeout message`, () => {
129
+ const result = classifySessionError(new Error(msg), baseCtx);
130
+ expect(result.code).toBe('PROVIDER_API');
131
+ expect(result.userMessage).toContain('took too long');
132
+ expect(result.retryable).toBe(true);
133
+ });
134
+ }
135
+
136
+ it('does not steal "connection timeout" from PROVIDER_NETWORK', () => {
137
+ const result = classifySessionError(new Error('connection timeout'), baseCtx);
138
+ expect(result.code).toBe('PROVIDER_NETWORK');
139
+ });
140
+
141
+ it('does not steal "Gateway timeout" from PROVIDER_API', () => {
142
+ const result = classifySessionError(new Error('Gateway timeout'), baseCtx);
143
+ expect(result.code).toBe('PROVIDER_API');
144
+ expect(result.userMessage).toContain('returned an error');
145
+ });
146
+ });
147
+
120
148
  describe('context-too-large errors', () => {
121
149
  const cases = [
122
150
  'context_length_exceeded',