@vellumai/assistant 0.8.7 → 0.8.8

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 (387) hide show
  1. package/Dockerfile +20 -4
  2. package/docker-entrypoint.sh +4 -2
  3. package/docker-init-apt-root.sh +3 -1
  4. package/docker-kata-apt-env.sh +3 -1
  5. package/docker-kata-runtime-family.sh +12 -0
  6. package/docs/architecture/memory.md +1 -1
  7. package/docs/plugins.md +75 -79
  8. package/examples/plugins/echo/README.md +6 -12
  9. package/examples/plugins/echo/register.ts +0 -41
  10. package/node_modules/@vellumai/skill-host-contracts/src/server-message.ts +3 -3
  11. package/openapi.yaml +3381 -348
  12. package/package.json +1 -1
  13. package/scripts/generate-openapi.ts +68 -41
  14. package/src/__tests__/agent-loop-exit-reason.test.ts +34 -39
  15. package/src/__tests__/agent-loop-provider-error-recording.test.ts +1 -1
  16. package/src/__tests__/agent-loop.test.ts +37 -87
  17. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  18. package/src/__tests__/annotate-activity-metadata.test.ts +262 -0
  19. package/src/__tests__/annotate-risk-options.test.ts +2 -3
  20. package/src/__tests__/anthropic-provider.test.ts +95 -2
  21. package/src/__tests__/assistant-event-hub.test.ts +25 -0
  22. package/src/__tests__/assistant-events-sse-shed.test.ts +8 -0
  23. package/src/__tests__/{conversation-stream-state.test.ts → assistant-stream-state.test.ts} +252 -91
  24. package/src/__tests__/auth-fallback-events-store.test.ts +116 -0
  25. package/src/__tests__/background-workers-disk-pressure.test.ts +6 -0
  26. package/src/__tests__/btw-routes.test.ts +62 -3
  27. package/src/__tests__/build-persisted-content.test.ts +184 -0
  28. package/src/__tests__/catalog-files.test.ts +1 -1
  29. package/src/__tests__/clawhub-files.test.ts +1 -1
  30. package/src/__tests__/compaction-pipeline.test.ts +1 -1
  31. package/src/__tests__/compaction.benchmark.test.ts +0 -30
  32. package/src/__tests__/config-watcher.test.ts +1 -1
  33. package/src/__tests__/conversation-abort-tool-results.test.ts +57 -19
  34. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +6 -2
  35. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +10 -4
  36. package/src/__tests__/conversation-agent-loop-overflow.test.ts +313 -1136
  37. package/src/__tests__/conversation-agent-loop.test.ts +596 -1616
  38. package/src/__tests__/conversation-analysis-routes.test.ts +6 -0
  39. package/src/__tests__/conversation-history-web-search.test.ts +11 -1
  40. package/src/__tests__/conversation-pairing.test.ts +4 -31
  41. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +6 -0
  42. package/src/__tests__/conversation-provider-retry-repair.test.ts +26 -5
  43. package/src/__tests__/conversation-queue.test.ts +2 -0
  44. package/src/__tests__/conversation-routes-disk-view.test.ts +3 -0
  45. package/src/__tests__/conversation-routes-slash-commands.test.ts +6 -5
  46. package/src/__tests__/conversation-runtime-assembly.test.ts +170 -229
  47. package/src/__tests__/conversation-runtime-workspace.test.ts +3 -24
  48. package/src/__tests__/conversation-slash-commands.test.ts +8 -42
  49. package/src/__tests__/conversation-slash-queue.test.ts +6 -1
  50. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +84 -0
  51. package/src/__tests__/conversation-sync-tags.test.ts +27 -15
  52. package/src/__tests__/conversation-title-service.test.ts +135 -2
  53. package/src/__tests__/conversation-workspace-injection.test.ts +6 -1
  54. package/src/__tests__/cross-provider-web-search.test.ts +214 -1
  55. package/src/__tests__/db-schedule-syntax-migration.test.ts +5 -0
  56. package/src/__tests__/dm-persistence.test.ts +5 -1
  57. package/src/__tests__/empty-response-hook.test.ts +304 -0
  58. package/src/__tests__/feature-flag-test-helpers.ts +2 -2
  59. package/src/__tests__/gemini-image-service.test.ts +13 -0
  60. package/src/__tests__/helpers/mock-provider.ts +110 -0
  61. package/src/__tests__/helpers/native-web-search-harness.ts +129 -0
  62. package/src/__tests__/history-repair-hook.test.ts +1 -0
  63. package/src/__tests__/identity-intro-cache.test.ts +12 -100
  64. package/src/__tests__/identity-routes.test.ts +248 -7
  65. package/src/__tests__/inbound-slack-persistence.test.ts +5 -1
  66. package/src/__tests__/injector-background-turn.test.ts +2 -8
  67. package/src/__tests__/injector-chain.test.ts +106 -270
  68. package/src/__tests__/injector-disk-pressure.test.ts +3 -12
  69. package/src/__tests__/injector-document-comments.test.ts +2 -2
  70. package/src/__tests__/injector-pkb-v2-silenced.test.ts +30 -22
  71. package/src/__tests__/injector-v3-suppression.test.ts +31 -37
  72. package/src/__tests__/internal-telemetry-routes.test.ts +109 -0
  73. package/src/__tests__/list-messages-page-latest.test.ts +60 -0
  74. package/src/__tests__/list-messages-tool-merge.test.ts +20 -0
  75. package/src/__tests__/llm-usage-store.test.ts +223 -1
  76. package/src/__tests__/memory-retrieval-hook.test.ts +297 -0
  77. package/src/__tests__/memory-v2-static-injector.test.ts +103 -35
  78. package/src/__tests__/native-web-search.test.ts +191 -0
  79. package/src/__tests__/onboarding-template-contract.test.ts +2 -0
  80. package/src/__tests__/openai-image-service.test.ts +17 -0
  81. package/src/__tests__/openai-provider.test.ts +31 -1
  82. package/src/__tests__/persist-unsendable-image.test.ts +215 -0
  83. package/src/__tests__/persistence-secret-redaction.test.ts +1 -0
  84. package/src/__tests__/pipeline-runner.test.ts +29 -39
  85. package/src/__tests__/pkb-autoinject.test.ts +2 -5
  86. package/src/__tests__/plugin-bootstrap.test.ts +13 -28
  87. package/src/__tests__/plugin-registry.test.ts +0 -27
  88. package/src/__tests__/plugin-types.test.ts +2 -125
  89. package/src/__tests__/process-message-display-content.test.ts +6 -2
  90. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +5 -1
  91. package/src/__tests__/resolve-trust-class.test.ts +4 -4
  92. package/src/__tests__/runtime-events-sse-reconnect.test.ts +60 -23
  93. package/src/__tests__/schedule-routes.test.ts +603 -2
  94. package/src/__tests__/schedule-store.test.ts +41 -0
  95. package/src/__tests__/schedule-tools.test.ts +35 -0
  96. package/src/__tests__/server-history-render.test.ts +314 -1
  97. package/src/__tests__/skillssh-files.test.ts +1 -1
  98. package/src/__tests__/system-prompt.test.ts +20 -0
  99. package/src/__tests__/task-scheduler.test.ts +162 -1
  100. package/src/__tests__/terminal-tools.test.ts +6 -1
  101. package/src/__tests__/title-generate-hook.test.ts +319 -0
  102. package/src/__tests__/tool-error-hook.test.ts +278 -0
  103. package/src/__tests__/tool-preview-lifecycle.test.ts +468 -5
  104. package/src/__tests__/tool-result-metadata-plumbing.test.ts +1 -0
  105. package/src/__tests__/tool-result-truncate-hook.test.ts +127 -0
  106. package/src/__tests__/tool-result-truncation.test.ts +0 -2
  107. package/src/__tests__/ui-choice-copy-surfaces.test.ts +254 -0
  108. package/src/__tests__/ui-work-result-surface.test.ts +159 -0
  109. package/src/__tests__/usage-routes.test.ts +285 -1
  110. package/src/__tests__/user-plugin-loader.test.ts +2 -2
  111. package/src/__tests__/voice-session-bridge.test.ts +6 -3
  112. package/src/__tests__/web-search-backend-failure.test.ts +166 -0
  113. package/src/agent/loop.ts +346 -442
  114. package/src/api/events/assistant-thinking-delta.ts +33 -0
  115. package/src/api/events/tool-output-chunk.ts +45 -0
  116. package/src/api/events/tool-use-preview-start.ts +32 -0
  117. package/src/api/events/trace-event.ts +69 -0
  118. package/src/api/index.ts +48 -13
  119. package/src/api/responses/conversation-message.ts +368 -0
  120. package/src/avatar/__tests__/avatar-store.test.ts +34 -29
  121. package/src/cli/commands/__tests__/notifications.test.ts +58 -14
  122. package/src/cli/commands/notifications.ts +112 -60
  123. package/src/config/assistant-feature-flags.ts +22 -11
  124. package/src/config/bundled-skills/app-builder/SKILL.md +3 -20
  125. package/src/config/bundled-skills/app-builder/references/examples/README.md +17 -0
  126. package/src/config/bundled-skills/app-builder/references/examples/expense-tracker.md +515 -0
  127. package/src/config/bundled-skills/app-builder/references/examples/focus-timer.md +342 -0
  128. package/src/config/bundled-skills/app-builder/references/examples/habit-tracker.md +490 -0
  129. package/src/config/bundled-skills/document-editor/SKILL.md +1 -1
  130. package/src/config/bundled-skills/messaging/SKILL.md +0 -7
  131. package/src/config/feature-flag-cache.ts +3 -3
  132. package/src/config/feature-flag-registry.json +35 -3
  133. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  134. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -0
  135. package/src/config/schemas/llm.ts +1 -0
  136. package/src/config/schemas/memory-v2.ts +8 -0
  137. package/src/config/schemas/memory-v3.ts +8 -0
  138. package/src/config/schemas/platform.ts +8 -0
  139. package/src/config/seed-inference-profiles.ts +2 -2
  140. package/src/config/skills.ts +13 -0
  141. package/src/context/compactor.ts +1 -1
  142. package/src/context/strip-injections.ts +122 -0
  143. package/src/context/token-estimator.ts +23 -0
  144. package/src/context/tool-result-truncation.ts +0 -23
  145. package/src/context/window-manager.ts +3 -6
  146. package/src/credential-execution/executable-discovery.ts +16 -0
  147. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +6 -0
  148. package/src/daemon/__tests__/inference-profile-notification.test.ts +153 -0
  149. package/src/daemon/__tests__/native-web-search-metadata.test.ts +10 -8
  150. package/src/daemon/assistant-attachments.ts +1 -1
  151. package/src/daemon/config-watcher.ts +2 -2
  152. package/src/daemon/context-overflow-reducer.ts +0 -1
  153. package/src/daemon/conversation-agent-loop-handlers.ts +605 -153
  154. package/src/daemon/conversation-agent-loop.ts +281 -760
  155. package/src/daemon/conversation-history.ts +5 -4
  156. package/src/daemon/conversation-lifecycle.ts +3 -4
  157. package/src/daemon/conversation-messaging.ts +7 -6
  158. package/src/daemon/conversation-process.ts +11 -16
  159. package/src/daemon/conversation-runtime-assembly.ts +130 -347
  160. package/src/daemon/conversation-slash.ts +6 -25
  161. package/src/daemon/conversation-surfaces.ts +222 -4
  162. package/src/daemon/conversation-tool-setup.ts +2 -29
  163. package/src/daemon/conversation.ts +32 -14
  164. package/src/daemon/external-plugins-bootstrap.ts +9 -10
  165. package/src/daemon/handlers/config-a2a.ts +51 -36
  166. package/src/daemon/handlers/config-slack-channel.ts +20 -14
  167. package/src/daemon/handlers/config-telegram.ts +16 -2
  168. package/src/daemon/handlers/shared.ts +156 -84
  169. package/src/daemon/handlers/skills.ts +39 -10
  170. package/src/daemon/lifecycle.ts +4 -0
  171. package/src/daemon/message-types/apps.ts +1 -29
  172. package/src/daemon/message-types/messages.ts +9 -57
  173. package/src/daemon/message-types/skills.ts +2 -0
  174. package/src/daemon/message-types/surfaces.ts +136 -3
  175. package/src/daemon/now-scratchpad.ts +21 -0
  176. package/src/daemon/orphan-reaper.test.ts +210 -0
  177. package/src/daemon/orphan-reaper.ts +240 -0
  178. package/src/daemon/persist-unsendable-image.ts +117 -0
  179. package/src/daemon/process-message.ts +1 -3
  180. package/src/daemon/trace-emitter.ts +6 -4
  181. package/src/daemon/trust-context.ts +19 -0
  182. package/src/daemon/wake-target-adapter.ts +3 -1
  183. package/src/home/home-greeting-cache.ts +24 -1
  184. package/src/ipc/gateway-client.test.ts +2 -2
  185. package/src/ipc/gateway-client.ts +3 -3
  186. package/src/media/gemini-image-service.ts +15 -0
  187. package/src/media/openai-image-service.ts +14 -0
  188. package/src/media/types.ts +34 -0
  189. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +56 -0
  190. package/src/memory/auth-fallback-events-store.ts +94 -0
  191. package/src/memory/conversation-title-service.ts +65 -41
  192. package/src/memory/db-init.ts +4 -0
  193. package/src/memory/graph/__tests__/conversation-graph-memory-registry.test.ts +119 -0
  194. package/src/memory/graph/conversation-graph-memory.ts +65 -0
  195. package/src/memory/jobs-store.ts +33 -0
  196. package/src/memory/jobs-worker.ts +31 -4
  197. package/src/memory/llm-usage-store.ts +224 -50
  198. package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +6 -5
  199. package/src/memory/migrations/270-schedule-source-conversation.ts +13 -0
  200. package/src/memory/migrations/271-create-auth-fallback-events.ts +21 -0
  201. package/src/memory/migrations/index.ts +2 -0
  202. package/src/memory/pkb/autoinject.ts +61 -0
  203. package/src/memory/pkb/context.ts +50 -0
  204. package/src/memory/pkb/types.ts +14 -0
  205. package/src/memory/schedule-attribution-sql.ts +104 -0
  206. package/src/memory/schema/infrastructure.ts +16 -0
  207. package/src/memory/usage-grouped-buckets.ts +6 -1
  208. package/src/memory/v2/__tests__/consolidation-job.test.ts +1 -1
  209. package/src/memory/v2/consolidation-job.ts +1 -1
  210. package/src/memory/v3/__tests__/health.test.ts +16 -0
  211. package/src/memory/v3/__tests__/orchestrate.test.ts +45 -9
  212. package/src/memory/v3/__tests__/provider-blocks.test.ts +13 -0
  213. package/src/memory/v3/__tests__/router.test.ts +101 -29
  214. package/src/memory/v3/__tests__/selector.test.ts +93 -27
  215. package/src/memory/v3/__tests__/shadow-plugin.test.ts +23 -5
  216. package/src/memory/v3/health.ts +0 -0
  217. package/src/memory/v3/llm-retry.ts +32 -0
  218. package/src/memory/v3/orchestrate.ts +26 -14
  219. package/src/memory/v3/provider-blocks.ts +15 -5
  220. package/src/memory/v3/router.ts +48 -42
  221. package/src/memory/v3/selector.ts +57 -42
  222. package/src/memory/v3/shadow-plugin.ts +47 -15
  223. package/src/memory/v3/types.ts +8 -0
  224. package/src/notifications/conversation-pairing.ts +8 -15
  225. package/src/notifications/decision-engine.ts +6 -3
  226. package/src/notifications/home-feed-side-effect.ts +12 -1
  227. package/src/permissions/prompter.ts +4 -0
  228. package/src/plugin-api/constants.ts +4 -0
  229. package/src/plugin-api/index.ts +8 -1
  230. package/src/plugin-api/types.ts +151 -1
  231. package/src/plugins/defaults/empty-response/hooks/stop.ts +126 -0
  232. package/src/plugins/defaults/empty-response/register.ts +8 -13
  233. package/src/plugins/defaults/index.ts +1 -15
  234. package/src/plugins/defaults/injectors/register.ts +243 -74
  235. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +91 -0
  236. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +216 -0
  237. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +35 -0
  238. package/src/plugins/defaults/title-generate/hooks/stop.ts +75 -0
  239. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +35 -0
  240. package/src/plugins/defaults/title-generate/package.json +1 -1
  241. package/src/plugins/defaults/title-generate/register.ts +18 -18
  242. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +118 -0
  243. package/src/plugins/defaults/tool-error/package.json +1 -1
  244. package/src/plugins/defaults/tool-error/register.ts +9 -21
  245. package/src/plugins/defaults/tool-result-truncate/hooks/post-tool-use.ts +32 -0
  246. package/src/plugins/defaults/tool-result-truncate/register.ts +10 -21
  247. package/src/plugins/defaults/tool-result-truncate/terminal.ts +37 -18
  248. package/src/plugins/pipeline.ts +6 -18
  249. package/src/plugins/registry.ts +8 -25
  250. package/src/plugins/types.ts +43 -474
  251. package/src/proactive-artifact/aux-message-injector.ts +3 -3
  252. package/src/proactive-artifact/job.test.ts +7 -12
  253. package/src/prompts/__tests__/system-prompt.test.ts +36 -0
  254. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +62 -0
  255. package/src/prompts/templates/BOOTSTRAP.md +2 -2
  256. package/src/prompts/templates/system-sections.ts +15 -0
  257. package/src/providers/anthropic/client.ts +37 -29
  258. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +112 -0
  259. package/src/providers/openai/chat-completions-provider.ts +44 -0
  260. package/src/providers/openrouter/client.ts +1 -0
  261. package/src/providers/placeholder-sentinels.ts +35 -0
  262. package/src/runtime/__tests__/agent-wake.test.ts +5 -1
  263. package/src/runtime/agent-wake.ts +2 -2
  264. package/src/runtime/assistant-event-hub.ts +36 -6
  265. package/src/runtime/{conversation-stream-state.ts → assistant-stream-state.ts} +132 -58
  266. package/src/runtime/http-router.ts +16 -21
  267. package/src/runtime/http-types.ts +16 -70
  268. package/src/runtime/pending-interactions.ts +1 -0
  269. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +265 -2
  270. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +31 -1
  271. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +6 -2
  272. package/src/runtime/routes/__tests__/tts-routes.test.ts +6 -2
  273. package/src/runtime/routes/app-management-routes.ts +6 -117
  274. package/src/runtime/routes/app-routes.ts +13 -15
  275. package/src/runtime/routes/attachment-routes.ts +26 -15
  276. package/src/runtime/routes/avatar-routes.ts +26 -0
  277. package/src/runtime/routes/btw-routes.ts +29 -23
  278. package/src/runtime/routes/consolidation-routes.ts +120 -20
  279. package/src/runtime/routes/conversation-query-routes.ts +2 -0
  280. package/src/runtime/routes/conversation-routes.ts +358 -184
  281. package/src/runtime/routes/documents-routes.ts +4 -0
  282. package/src/runtime/routes/domain-routes.ts +51 -37
  283. package/src/runtime/routes/epoch-millis-range.ts +34 -0
  284. package/src/runtime/routes/events-routes.ts +28 -34
  285. package/src/runtime/routes/gateway-log-routes.ts +26 -4
  286. package/src/runtime/routes/heartbeat-routes.ts +32 -12
  287. package/src/runtime/routes/identity-intro-cache.ts +11 -34
  288. package/src/runtime/routes/identity-routes.ts +208 -17
  289. package/src/runtime/routes/image-generation-routes.ts +40 -2
  290. package/src/runtime/routes/index.ts +2 -0
  291. package/src/runtime/routes/integrations/a2a.ts +12 -10
  292. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +16 -0
  293. package/src/runtime/routes/integrations/slack/channel.ts +4 -0
  294. package/src/runtime/routes/integrations/slack/share.ts +27 -6
  295. package/src/runtime/routes/integrations/telegram.ts +6 -0
  296. package/src/runtime/routes/integrations/twilio.ts +42 -0
  297. package/src/runtime/routes/internal-telemetry-routes.ts +88 -0
  298. package/src/runtime/routes/log-export-routes.ts +8 -0
  299. package/src/runtime/routes/memory-v2-routes.ts +15 -8
  300. package/src/runtime/routes/memory-v3-routes.ts +50 -28
  301. package/src/runtime/routes/oauth-apps.ts +66 -12
  302. package/src/runtime/routes/oauth-providers.ts +44 -5
  303. package/src/runtime/routes/platform-routes.ts +81 -5
  304. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +6 -4
  305. package/src/runtime/routes/playground/force-compact.ts +1 -1
  306. package/src/runtime/routes/rename-conversation-routes.ts +5 -0
  307. package/src/runtime/routes/schedule-routes.ts +152 -42
  308. package/src/runtime/routes/secret-routes.ts +14 -2
  309. package/src/runtime/routes/skills-routes.ts +43 -14
  310. package/src/runtime/routes/tool-call-confirmation-enrichment.test.ts +161 -0
  311. package/src/runtime/routes/tool-call-confirmation-enrichment.ts +107 -0
  312. package/src/runtime/routes/trust-rules-routes.ts +26 -2
  313. package/src/runtime/routes/tts-routes.ts +35 -0
  314. package/src/runtime/routes/types.ts +66 -8
  315. package/src/runtime/routes/usage-routes.ts +47 -39
  316. package/src/runtime/routes/webhook-routes.ts +41 -2
  317. package/src/runtime/routes/workspace-routes.ts +4 -0
  318. package/src/runtime/services/__tests__/analyze-conversation.test.ts +6 -0
  319. package/src/runtime/services/analyze-conversation.ts +2 -2
  320. package/src/schedule/schedule-store.ts +20 -1
  321. package/src/schedule/schedule-usage-store.ts +83 -0
  322. package/src/schedule/scheduler.ts +12 -5
  323. package/src/skills/catalog-files.ts +2 -2
  324. package/src/skills/catalog-install.ts +3 -0
  325. package/src/skills/categories-cache.ts +118 -0
  326. package/src/skills/clawhub-files.ts +1 -2
  327. package/src/skills/skillssh-files.ts +1 -2
  328. package/src/telemetry/types.ts +29 -1
  329. package/src/telemetry/usage-telemetry-reporter.test.ts +112 -3
  330. package/src/telemetry/usage-telemetry-reporter.ts +57 -2
  331. package/src/tools/executor.ts +1 -53
  332. package/src/tools/network/__tests__/web-search-metadata.test.ts +7 -1
  333. package/src/tools/network/__tests__/web-search.test.ts +11 -3
  334. package/src/tools/network/web-search-error.test.ts +248 -0
  335. package/src/tools/network/web-search-error.ts +267 -0
  336. package/src/tools/network/web-search.ts +207 -48
  337. package/src/tools/schedule/create.ts +2 -0
  338. package/src/tools/terminal/safe-env.ts +10 -1
  339. package/src/tools/ui-surface/definitions.ts +9 -1
  340. package/src/tts/__tests__/provider-catalog-consistency.test.ts +85 -1
  341. package/src/tts/provider-catalog.ts +76 -1
  342. package/src/util/mutex.ts +47 -0
  343. package/src/workspace/git-service.ts +1 -42
  344. package/src/workspace/migrations/095-bump-heartbeat-interval-30m-to-60m.ts +51 -0
  345. package/src/workspace/migrations/096-reduce-quality-profile-effort.ts +72 -0
  346. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +93 -0
  347. package/src/workspace/migrations/registry.ts +6 -0
  348. package/src/__tests__/bootstrap-turn-cleanup.test.ts +0 -44
  349. package/src/__tests__/empty-response-pipeline.test.ts +0 -423
  350. package/src/__tests__/llm-call-pipeline.test.ts +0 -287
  351. package/src/__tests__/memory-retrieval-pipeline.test.ts +0 -418
  352. package/src/__tests__/persistence-pipeline.test.ts +0 -503
  353. package/src/__tests__/title-generate-pipeline.test.ts +0 -211
  354. package/src/__tests__/token-estimate-pipeline.test.ts +0 -479
  355. package/src/__tests__/tool-error-pipeline.test.ts +0 -241
  356. package/src/__tests__/tool-execute-pipeline.test.ts +0 -417
  357. package/src/__tests__/tool-result-truncate-pipeline.test.ts +0 -341
  358. package/src/daemon/bootstrap-turn-cleanup.ts +0 -45
  359. package/src/gallery/default-gallery.ts +0 -1359
  360. package/src/gallery/gallery-manifest.ts +0 -28
  361. package/src/home/feature-gate.ts +0 -22
  362. package/src/plugins/defaults/empty-response/middlewares/emptyResponse.ts +0 -22
  363. package/src/plugins/defaults/empty-response/terminal.ts +0 -106
  364. package/src/plugins/defaults/injectors/package.json +0 -15
  365. package/src/plugins/defaults/llm-call/middlewares/llmCall.ts +0 -17
  366. package/src/plugins/defaults/llm-call/package.json +0 -15
  367. package/src/plugins/defaults/llm-call/register.ts +0 -45
  368. package/src/plugins/defaults/memory-retrieval/middlewares/memoryRetrieval.ts +0 -17
  369. package/src/plugins/defaults/memory-retrieval/package.json +0 -15
  370. package/src/plugins/defaults/memory-retrieval/register.ts +0 -181
  371. package/src/plugins/defaults/persistence/middlewares/persistence.ts +0 -19
  372. package/src/plugins/defaults/persistence/package.json +0 -15
  373. package/src/plugins/defaults/persistence/register.ts +0 -38
  374. package/src/plugins/defaults/persistence/terminal.ts +0 -83
  375. package/src/plugins/defaults/title-generate/terminal.ts +0 -31
  376. package/src/plugins/defaults/token-estimate/middlewares/tokenEstimate.ts +0 -23
  377. package/src/plugins/defaults/token-estimate/package.json +0 -15
  378. package/src/plugins/defaults/token-estimate/register.ts +0 -34
  379. package/src/plugins/defaults/token-estimate/terminal.ts +0 -40
  380. package/src/plugins/defaults/tool-error/middlewares/toolError.ts +0 -21
  381. package/src/plugins/defaults/tool-error/terminal.ts +0 -47
  382. package/src/plugins/defaults/tool-execute/middlewares/toolExecute.ts +0 -23
  383. package/src/plugins/defaults/tool-execute/package.json +0 -15
  384. package/src/plugins/defaults/tool-execute/register.ts +0 -49
  385. package/src/plugins/defaults/tool-result-truncate/middlewares/toolResultTruncate.ts +0 -23
  386. package/src/plugins/defaults/tool-result-truncate/types.ts +0 -22
  387. package/src/skills/category-inference.ts +0 -111
@@ -4,34 +4,42 @@ import type { AssistantEvent } from "../runtime/assistant-event.js";
4
4
  import type {
5
5
  EventTargeting,
6
6
  ReplaySubscriber,
7
- } from "../runtime/conversation-stream-state.js";
7
+ } from "../runtime/assistant-stream-state.js";
8
8
  import {
9
9
  _peekStreamForTesting,
10
- _resetConversationStreamsForTesting,
11
- clearConversationStream,
10
+ _resetStreamStateForTesting,
11
+ getCurrentSeq,
12
+ getPersistedSeq,
12
13
  getReplayWindow,
14
+ recordPersistedSeq,
13
15
  stampAndBuffer,
14
- } from "../runtime/conversation-stream-state.js";
16
+ } from "../runtime/assistant-stream-state.js";
15
17
 
16
18
  const CONV = "conv_test";
17
19
 
18
20
  function mkEvent(overrides: Partial<AssistantEvent> = {}): AssistantEvent {
21
+ const conversationId =
22
+ "conversationId" in overrides ? overrides.conversationId : CONV;
19
23
  return {
20
24
  id: `uuid-${Math.random().toString(36).slice(2, 10)}`,
21
- conversationId: CONV,
25
+ conversationId,
22
26
  emittedAt: new Date().toISOString(),
23
- message: { type: "assistant_text_delta", conversationId: CONV, text: "x" },
27
+ message: {
28
+ type: "assistant_text_delta",
29
+ conversationId,
30
+ text: "x",
31
+ },
24
32
  ...overrides,
25
33
  } as AssistantEvent;
26
34
  }
27
35
 
28
- describe("conversation-stream-state", () => {
36
+ describe("assistant-stream-state", () => {
29
37
  beforeEach(() => {
30
- _resetConversationStreamsForTesting();
38
+ _resetStreamStateForTesting();
31
39
  });
32
40
 
33
41
  describe("stampAndBuffer", () => {
34
- test("assigns monotonic seq starting at 1 per conversation", () => {
42
+ test("assigns monotonic seq starting at 1", () => {
35
43
  const a = mkEvent();
36
44
  const b = mkEvent();
37
45
  const c = mkEvent();
@@ -43,16 +51,26 @@ describe("conversation-stream-state", () => {
43
51
  expect(c.seq).toBe(3);
44
52
  });
45
53
 
46
- test("seq is per-conversation, not global", () => {
54
+ test("seq is a single global counter shared across conversations", () => {
55
+ /**
56
+ * All conversations draw from one global seq space, so a reconnect
57
+ * cursor can be a single number rather than a per-conversation map.
58
+ */
59
+
60
+ // GIVEN events interleaved across two conversations
47
61
  const a = mkEvent({ conversationId: "conv_a" });
48
62
  const b = mkEvent({ conversationId: "conv_b" });
49
63
  const a2 = mkEvent({ conversationId: "conv_a" });
64
+
65
+ // WHEN they are stamped
50
66
  stampAndBuffer(a);
51
67
  stampAndBuffer(b);
52
68
  stampAndBuffer(a2);
69
+
70
+ // THEN seq is contiguous across conversations, not reset per conversation
53
71
  expect(a.seq).toBe(1);
54
- expect(b.seq).toBe(1); // independent counter
55
- expect(a2.seq).toBe(2);
72
+ expect(b.seq).toBe(2);
73
+ expect(a2.seq).toBe(3);
56
74
  });
57
75
 
58
76
  test("no-op when conversationId is absent (unscoped broadcasts)", () => {
@@ -61,17 +79,17 @@ describe("conversation-stream-state", () => {
61
79
  expect(event.seq).toBeUndefined();
62
80
  });
63
81
 
64
- test("pushes event onto ring buffer", () => {
82
+ test("pushes event onto the ring buffer", () => {
65
83
  stampAndBuffer(mkEvent());
66
84
  stampAndBuffer(mkEvent());
67
- const peek = _peekStreamForTesting(CONV);
68
- expect(peek?.ringLength).toBe(2);
69
- expect(peek?.oldestSeq).toBe(1);
70
- expect(peek?.newestSeq).toBe(2);
85
+ const peek = _peekStreamForTesting();
86
+ expect(peek.ringLength).toBe(2);
87
+ expect(peek.oldestSeq).toBe(1);
88
+ expect(peek.newestSeq).toBe(2);
71
89
  });
72
90
 
73
91
  test("targeted events are buffered with targeting metadata", () => {
74
- /** Targeted events now stay in the ring so replay can filter them. */
92
+ /** Targeted events stay in the ring so replay can filter them. */
75
93
 
76
94
  // GIVEN a targeting modifier
77
95
  const targeting: EventTargeting = {
@@ -84,9 +102,9 @@ describe("conversation-stream-state", () => {
84
102
 
85
103
  // THEN it receives a seq and lands in the ring
86
104
  expect(targeted.seq).toBe(1);
87
- const peek = _peekStreamForTesting(CONV);
88
- expect(peek?.ringLength).toBe(1);
89
- expect(peek?.oldestSeq).toBe(1);
105
+ const peek = _peekStreamForTesting();
106
+ expect(peek.ringLength).toBe(1);
107
+ expect(peek.oldestSeq).toBe(1);
90
108
  });
91
109
 
92
110
  test("seq stays monotonic across targeted and untargeted events", () => {
@@ -106,21 +124,21 @@ describe("conversation-stream-state", () => {
106
124
 
107
125
  // THEN seqs are monotonic and all four are buffered
108
126
  expect([a.seq, b.seq, c.seq, d.seq]).toEqual([1, 2, 3, 4]);
109
- const peek = _peekStreamForTesting(CONV);
110
- expect(peek?.ringLength).toBe(4);
111
- expect(peek?.oldestSeq).toBe(1);
112
- expect(peek?.newestSeq).toBe(4);
127
+ const peek = _peekStreamForTesting();
128
+ expect(peek.ringLength).toBe(4);
129
+ expect(peek.oldestSeq).toBe(1);
130
+ expect(peek.newestSeq).toBe(4);
113
131
  });
114
132
  });
115
133
 
116
134
  describe("ring buffer eviction", () => {
117
135
  test("evicts oldest entries past the 200-event count cap", () => {
118
136
  for (let i = 0; i < 250; i++) stampAndBuffer(mkEvent());
119
- const peek = _peekStreamForTesting(CONV);
120
- expect(peek?.ringLength).toBe(200);
137
+ const peek = _peekStreamForTesting();
138
+ expect(peek.ringLength).toBe(200);
121
139
  // Newest is 250, oldest should be 51 (250 - 200 + 1)
122
- expect(peek?.newestSeq).toBe(250);
123
- expect(peek?.oldestSeq).toBe(51);
140
+ expect(peek.newestSeq).toBe(250);
141
+ expect(peek.oldestSeq).toBe(51);
124
142
  });
125
143
 
126
144
  test("evicts past the 256 KB size cap", () => {
@@ -137,14 +155,13 @@ describe("conversation-stream-state", () => {
137
155
  }),
138
156
  );
139
157
  }
140
- const peek = _peekStreamForTesting(CONV);
141
- expect(peek).not.toBeNull();
158
+ const peek = _peekStreamForTesting();
142
159
  // 60 * ~8KB = ~480KB pushed; ring must have evicted down under 256KB.
143
- expect(peek!.totalSizeBytes).toBeLessThanOrEqual(256 * 1024);
144
- expect(peek!.ringLength).toBeLessThan(60);
160
+ expect(peek.totalSizeBytes).toBeLessThanOrEqual(256 * 1024);
161
+ expect(peek.ringLength).toBeLessThan(60);
145
162
  });
146
163
 
147
- test("evicts past the 30s age cap", async () => {
164
+ test("evicts past the 30s age cap", () => {
148
165
  const originalNow = Date.now;
149
166
  let fakeNow = 1_000_000;
150
167
  Date.now = () => fakeNow;
@@ -157,11 +174,11 @@ describe("conversation-stream-state", () => {
157
174
  fakeNow = 1_000_000 + 31_000;
158
175
  stampAndBuffer(mkEvent()); // triggers eviction sweep on push
159
176
 
160
- const peek = _peekStreamForTesting(CONV);
177
+ const peek = _peekStreamForTesting();
161
178
  // First event is now > 30s old → evicted. Second + third remain.
162
- expect(peek?.ringLength).toBe(2);
163
- expect(peek?.oldestSeq).toBe(2);
164
- expect(peek?.newestSeq).toBe(3);
179
+ expect(peek.ringLength).toBe(2);
180
+ expect(peek.oldestSeq).toBe(2);
181
+ expect(peek.newestSeq).toBe(3);
165
182
  } finally {
166
183
  Date.now = originalNow;
167
184
  }
@@ -172,7 +189,7 @@ describe("conversation-stream-state", () => {
172
189
  test("returns events with seq > lastSeenSeq in order", () => {
173
190
  const events = Array.from({ length: 5 }, () => mkEvent());
174
191
  events.forEach((e) => stampAndBuffer(e));
175
- const replay = getReplayWindow(CONV, 2);
192
+ const replay = getReplayWindow(2);
176
193
  expect(replay).not.toBeNull();
177
194
  expect(replay!.map((e) => e.seq)).toEqual([3, 4, 5]);
178
195
  });
@@ -180,22 +197,22 @@ describe("conversation-stream-state", () => {
180
197
  test("returns empty array when lastSeenSeq is current (nothing to replay)", () => {
181
198
  stampAndBuffer(mkEvent());
182
199
  stampAndBuffer(mkEvent());
183
- const replay = getReplayWindow(CONV, 2);
200
+ const replay = getReplayWindow(2);
184
201
  expect(replay).toEqual([]);
185
202
  });
186
203
 
187
204
  test("returns null when lastSeenSeq is older than oldest buffered entry", () => {
188
205
  // Force eviction by pushing past the count cap.
189
206
  for (let i = 0; i < 250; i++) stampAndBuffer(mkEvent());
190
- const peek = _peekStreamForTesting(CONV);
191
- expect(peek?.oldestSeq).toBe(51);
207
+ const peek = _peekStreamForTesting();
208
+ expect(peek.oldestSeq).toBe(51);
192
209
  // Client claims to have last seen seq=10 — that's far below oldest.
193
- const replay = getReplayWindow(CONV, 10);
210
+ const replay = getReplayWindow(10);
194
211
  expect(replay).toBeNull();
195
212
  });
196
213
 
197
- test("returns empty array for a conversation with no stream state", () => {
198
- const replay = getReplayWindow("conv_never_streamed", 0);
214
+ test("returns empty array when the ring is empty", () => {
215
+ const replay = getReplayWindow(0);
199
216
  expect(replay).toEqual([]);
200
217
  });
201
218
 
@@ -204,12 +221,12 @@ describe("conversation-stream-state", () => {
204
221
  stampAndBuffer(mkEvent()); // seq 2
205
222
  stampAndBuffer(mkEvent()); // seq 3
206
223
  // Client saw nothing → lastSeenSeq=0, oldest=1, replay [1,2,3].
207
- const replay = getReplayWindow(CONV, 0);
224
+ const replay = getReplayWindow(0);
208
225
  expect(replay).not.toBeNull();
209
226
  expect(replay!.map((e) => e.seq)).toEqual([1, 2, 3]);
210
227
  });
211
228
 
212
- test("evicts age-expired entries at read time on idle stream", () => {
229
+ test("evicts age-expired entries at read time on an idle stream", () => {
213
230
  const originalNow = Date.now;
214
231
  let fakeNow = 5_000_000;
215
232
  Date.now = () => fakeNow;
@@ -223,15 +240,14 @@ describe("conversation-stream-state", () => {
223
240
 
224
241
  // Eviction has not run since the last write -- the buffer still
225
242
  // physically holds [1, 2]. getReplayWindow must sweep first.
226
- const replay = getReplayWindow(CONV, 0);
243
+ const replay = getReplayWindow(0);
227
244
 
228
245
  // Both events were past their TTL, so eviction drains the ring
229
- // and the call returns [] (no replay possible, no snapshot
230
- // needed either -- client claims they saw nothing and there is
231
- // nothing left).
246
+ // and the call returns [] (no replay possible, no snapshot needed
247
+ // either -- client claims they saw nothing and there is nothing
248
+ // left).
232
249
  expect(replay).toEqual([]);
233
- // State entry is dropped after the drain.
234
- expect(_peekStreamForTesting(CONV)).toBeNull();
250
+ expect(_peekStreamForTesting().ringLength).toBe(0);
235
251
  } finally {
236
252
  Date.now = originalNow;
237
253
  }
@@ -252,13 +268,13 @@ describe("conversation-stream-state", () => {
252
268
  stampAndBuffer(mkEvent()); // seq 3
253
269
  // After this write, evict() already ran and dropped the stale
254
270
  // entries from the write path. Verify that.
255
- const peek = _peekStreamForTesting(CONV);
256
- expect(peek?.ringLength).toBe(1);
257
- expect(peek?.oldestSeq).toBe(3);
271
+ const peek = _peekStreamForTesting();
272
+ expect(peek.ringLength).toBe(1);
273
+ expect(peek.oldestSeq).toBe(3);
258
274
 
259
275
  // Client reconnects claiming lastSeenSeq=1. Oldest buffered is
260
276
  // 3, so 1 < 3 - 1 = 2 -> snapshot fallback (null).
261
- const replay = getReplayWindow(CONV, 1);
277
+ const replay = getReplayWindow(1);
262
278
  expect(replay).toBeNull();
263
279
  } finally {
264
280
  Date.now = originalNow;
@@ -266,6 +282,42 @@ describe("conversation-stream-state", () => {
266
282
  });
267
283
  });
268
284
 
285
+ describe("getReplayWindow — conversation filter", () => {
286
+ test("restricts replay to a single conversation when conversationId is given", () => {
287
+ /**
288
+ * A scoped subscription only delivers its own conversation live, so
289
+ * replay must not push other conversations' buffered events.
290
+ */
291
+
292
+ // GIVEN events interleaved across two conversations in the global ring
293
+ stampAndBuffer(mkEvent({ conversationId: "conv_a" })); // seq 1
294
+ stampAndBuffer(mkEvent({ conversationId: "conv_b" })); // seq 2
295
+ stampAndBuffer(mkEvent({ conversationId: "conv_a" })); // seq 3
296
+ stampAndBuffer(mkEvent({ conversationId: "conv_b" })); // seq 4
297
+
298
+ // WHEN replay is scoped to conv_a
299
+ const replay = getReplayWindow(0, undefined, "conv_a");
300
+
301
+ // THEN only conv_a's events return, still in global seq order
302
+ expect(replay!.map((e) => e.seq)).toEqual([1, 3]);
303
+ });
304
+
305
+ test("unfiltered replay returns every conversation's events in seq order", () => {
306
+ /** The unfiltered (assistant-wide) stream resumes the whole ring. */
307
+
308
+ // GIVEN events across two conversations
309
+ stampAndBuffer(mkEvent({ conversationId: "conv_a" })); // seq 1
310
+ stampAndBuffer(mkEvent({ conversationId: "conv_b" })); // seq 2
311
+ stampAndBuffer(mkEvent({ conversationId: "conv_a" })); // seq 3
312
+
313
+ // WHEN replay is requested without a conversation filter
314
+ const replay = getReplayWindow(0);
315
+
316
+ // THEN all events return in one contiguous global seq order
317
+ expect(replay!.map((e) => e.seq)).toEqual([1, 2, 3]);
318
+ });
319
+ });
320
+
269
321
  describe("getReplayWindow — targeting filter", () => {
270
322
  const MACOS_CLIENT: ReplaySubscriber = {
271
323
  type: "client",
@@ -298,9 +350,9 @@ describe("conversation-stream-state", () => {
298
350
  stampAndBuffer(mkEvent());
299
351
 
300
352
  // WHEN each subscriber type requests replay
301
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
302
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
303
- const procReplay = getReplayWindow(CONV, 0, PROCESS_SUB);
353
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
354
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
355
+ const procReplay = getReplayWindow(0, PROCESS_SUB);
304
356
 
305
357
  // THEN all see both events
306
358
  expect(macReplay!.map((e) => e.seq)).toEqual([1, 2]);
@@ -318,9 +370,9 @@ describe("conversation-stream-state", () => {
318
370
  });
319
371
 
320
372
  // WHEN each subscriber requests replay
321
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
322
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
323
- const procReplay = getReplayWindow(CONV, 0, PROCESS_SUB);
373
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
374
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
375
+ const procReplay = getReplayWindow(0, PROCESS_SUB);
324
376
 
325
377
  // THEN macOS sees both; web and process see only the untargeted event
326
378
  expect(macReplay!.map((e) => e.seq)).toEqual([1, 2]);
@@ -337,9 +389,9 @@ describe("conversation-stream-state", () => {
337
389
  });
338
390
 
339
391
  // WHEN macOS and chrome-extension request replay
340
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
341
- const extReplay = getReplayWindow(CONV, 0, CHROME_EXT_CLIENT);
342
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
392
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
393
+ const extReplay = getReplayWindow(0, CHROME_EXT_CLIENT);
394
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
343
395
 
344
396
  // THEN both capable clients see it; web does not
345
397
  expect(macReplay!.map((e) => e.seq)).toEqual([1]);
@@ -356,9 +408,9 @@ describe("conversation-stream-state", () => {
356
408
  });
357
409
 
358
410
  // WHEN different clients request replay
359
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
360
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
361
- const procReplay = getReplayWindow(CONV, 0, PROCESS_SUB);
411
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
412
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
413
+ const procReplay = getReplayWindow(0, PROCESS_SUB);
362
414
 
363
415
  // THEN only the named client receives it
364
416
  expect(macReplay!.map((e) => e.seq)).toEqual([1]);
@@ -378,8 +430,8 @@ describe("conversation-stream-state", () => {
378
430
  });
379
431
 
380
432
  // WHEN the named client (without the capability) and macOS request replay
381
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
382
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
433
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
434
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
383
435
 
384
436
  // THEN neither receives it — web-1 lacks the capability, mac-1 isn't the target
385
437
  expect(webReplay).toEqual([]);
@@ -395,9 +447,9 @@ describe("conversation-stream-state", () => {
395
447
  });
396
448
 
397
449
  // WHEN web-1 and mac-1 request replay
398
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
399
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
400
- const procReplay = getReplayWindow(CONV, 0, PROCESS_SUB);
450
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
451
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
452
+ const procReplay = getReplayWindow(0, PROCESS_SUB);
401
453
 
402
454
  // THEN web-1 is suppressed; mac-1 and process subscribers see it
403
455
  expect(webReplay).toEqual([]);
@@ -414,9 +466,9 @@ describe("conversation-stream-state", () => {
414
466
  });
415
467
 
416
468
  // WHEN different subscribers request replay
417
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
418
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
419
- const procReplay = getReplayWindow(CONV, 0, PROCESS_SUB);
469
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
470
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
471
+ const procReplay = getReplayWindow(0, PROCESS_SUB);
420
472
 
421
473
  // THEN only the macos client receives it
422
474
  expect(macReplay!.map((e) => e.seq)).toEqual([1]);
@@ -441,8 +493,8 @@ describe("conversation-stream-state", () => {
441
493
  stampAndBuffer(mkEvent()); // seq 4: untargeted
442
494
 
443
495
  // WHEN each subscriber requests replay from seq 0
444
- const macReplay = getReplayWindow(CONV, 0, MACOS_CLIENT);
445
- const webReplay = getReplayWindow(CONV, 0, WEB_CLIENT);
496
+ const macReplay = getReplayWindow(0, MACOS_CLIENT);
497
+ const webReplay = getReplayWindow(0, WEB_CLIENT);
446
498
 
447
499
  // THEN macOS sees all four; web sees 1 + 4 (not 2=no capability, not 3=excluded)
448
500
  expect(macReplay!.map((e) => e.seq)).toEqual([1, 2, 3, 4]);
@@ -450,7 +502,7 @@ describe("conversation-stream-state", () => {
450
502
  });
451
503
 
452
504
  test("no subscriber argument returns all entries unfiltered", () => {
453
- /** Backwards-compatible: omitting subscriber skips filtering. */
505
+ /** Omitting subscriber skips targeting filtering. */
454
506
 
455
507
  // GIVEN targeted and untargeted events
456
508
  stampAndBuffer(mkEvent());
@@ -459,26 +511,135 @@ describe("conversation-stream-state", () => {
459
511
  });
460
512
 
461
513
  // WHEN replay is requested without a subscriber
462
- const replay = getReplayWindow(CONV, 0);
514
+ const replay = getReplayWindow(0);
463
515
 
464
516
  // THEN all events are returned
465
517
  expect(replay!.map((e) => e.seq)).toEqual([1, 2]);
466
518
  });
519
+
520
+ test("subscriber and conversation filters compose", () => {
521
+ /**
522
+ * When both filters are supplied, an entry must satisfy targeting
523
+ * AND belong to the requested conversation.
524
+ */
525
+
526
+ // GIVEN bash-targeted events across two conversations
527
+ stampAndBuffer(mkEvent({ conversationId: "conv_a" }), {
528
+ targeting: { targetCapability: "host_bash" },
529
+ }); // seq 1
530
+ stampAndBuffer(mkEvent({ conversationId: "conv_b" }), {
531
+ targeting: { targetCapability: "host_bash" },
532
+ }); // seq 2
533
+ stampAndBuffer(mkEvent({ conversationId: "conv_a" })); // seq 3 untargeted
534
+
535
+ // WHEN a web client (no host_bash) replays scoped to conv_a
536
+ const webReplay = getReplayWindow(0, WEB_CLIENT, "conv_a");
537
+ // AND macOS replays scoped to conv_a
538
+ const macReplay = getReplayWindow(0, MACOS_CLIENT, "conv_a");
539
+
540
+ // THEN web sees only conv_a's untargeted event; macOS sees both conv_a entries
541
+ expect(webReplay!.map((e) => e.seq)).toEqual([3]);
542
+ expect(macReplay!.map((e) => e.seq)).toEqual([1, 3]);
543
+ });
467
544
  });
468
545
 
469
- describe("clearConversationStream", () => {
470
- test("drops all state for the conversation", () => {
471
- stampAndBuffer(mkEvent());
546
+ describe("getCurrentSeq", () => {
547
+ test("is 0 before anything is stamped", () => {
548
+ expect(getCurrentSeq()).toBe(0);
549
+ });
550
+
551
+ test("reports the seq just assigned by stampAndBuffer", () => {
552
+ const a = mkEvent();
553
+ stampAndBuffer(a);
554
+ expect(a.seq).toBe(1);
555
+ expect(getCurrentSeq()).toBe(1);
556
+
557
+ const b = mkEvent();
558
+ stampAndBuffer(b);
559
+ expect(b.seq).toBe(2);
560
+ expect(getCurrentSeq()).toBe(2);
561
+ });
562
+
563
+ test("unscoped (unstamped) events do not advance it", () => {
472
564
  stampAndBuffer(mkEvent());
473
- expect(_peekStreamForTesting(CONV)).not.toBeNull();
565
+ // An event with no conversationId is never stamped.
566
+ stampAndBuffer(mkEvent({ conversationId: undefined }));
567
+ expect(getCurrentSeq()).toBe(1);
568
+ });
569
+ });
474
570
 
475
- clearConversationStream(CONV);
571
+ describe("persisted seq", () => {
572
+ test("getPersistedSeq is null for an unknown conversation", () => {
573
+ expect(getPersistedSeq("conv_unknown")).toBeNull();
574
+ });
476
575
 
477
- expect(_peekStreamForTesting(CONV)).toBeNull();
478
- // Subsequent emit starts seq fresh at 1.
479
- const event = mkEvent();
480
- stampAndBuffer(event);
481
- expect(event.seq).toBe(1);
576
+ test("records and retrieves a per-conversation value", () => {
577
+ recordPersistedSeq("conv_a", 7);
578
+ expect(getPersistedSeq("conv_a")).toBe(7);
579
+ expect(getPersistedSeq("conv_b")).toBeNull();
580
+ });
581
+
582
+ test("tracks conversations independently", () => {
583
+ recordPersistedSeq("conv_a", 3);
584
+ recordPersistedSeq("conv_b", 9);
585
+ expect(getPersistedSeq("conv_a")).toBe(3);
586
+ expect(getPersistedSeq("conv_b")).toBe(9);
587
+ });
588
+
589
+ test("advances monotonically and never regresses", () => {
590
+ recordPersistedSeq("conv_a", 5);
591
+ recordPersistedSeq("conv_a", 12);
592
+ expect(getPersistedSeq("conv_a")).toBe(12);
593
+
594
+ // A lower seq (e.g. an out-of-order async commit) is clamped.
595
+ recordPersistedSeq("conv_a", 8);
596
+ expect(getPersistedSeq("conv_a")).toBe(12);
597
+ });
598
+
599
+ test("ignores non-positive and non-finite seq values", () => {
600
+ recordPersistedSeq("conv_a", 0);
601
+ recordPersistedSeq("conv_a", -3);
602
+ recordPersistedSeq("conv_a", Number.NaN);
603
+ recordPersistedSeq("conv_a", Number.POSITIVE_INFINITY);
604
+ expect(getPersistedSeq("conv_a")).toBeNull();
605
+ });
606
+
607
+ test("is cleared by reset", () => {
608
+ recordPersistedSeq("conv_a", 4);
609
+ _resetStreamStateForTesting();
610
+ expect(getPersistedSeq("conv_a")).toBeNull();
611
+ });
612
+
613
+ test("evicts the least-recently-recorded conversation past the cap", () => {
614
+ // The map is LRU-bounded at 1024 conversations. Fill to the cap,
615
+ // then one more insert evicts the oldest key.
616
+ const CAP = 1024;
617
+ for (let i = 0; i < CAP; i++) {
618
+ recordPersistedSeq(`conv_${i}`, i + 1);
619
+ }
620
+ // All present at the cap.
621
+ expect(getPersistedSeq("conv_0")).toBe(1);
622
+ expect(getPersistedSeq(`conv_${CAP - 1}`)).toBe(CAP);
623
+
624
+ // One more distinct conversation evicts the oldest (conv_0).
625
+ recordPersistedSeq("conv_overflow", 9999);
626
+ expect(getPersistedSeq("conv_0")).toBeNull();
627
+ expect(getPersistedSeq("conv_1")).toBe(2);
628
+ expect(getPersistedSeq("conv_overflow")).toBe(9999);
629
+ });
630
+
631
+ test("re-recording refreshes recency so a kept key is not evicted first", () => {
632
+ const CAP = 1024;
633
+ for (let i = 0; i < CAP; i++) {
634
+ recordPersistedSeq(`conv_${i}`, i + 1);
635
+ }
636
+ // Touch the oldest key so it moves to the most-recent end.
637
+ recordPersistedSeq("conv_0", 5000);
638
+
639
+ // The next insert now evicts conv_1 (the new oldest), not conv_0.
640
+ recordPersistedSeq("conv_overflow", 9999);
641
+ expect(getPersistedSeq("conv_0")).toBe(5000);
642
+ expect(getPersistedSeq("conv_1")).toBeNull();
482
643
  });
483
644
  });
484
645
  });
@@ -0,0 +1,116 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ mock.module("../util/logger.js", () => ({
4
+ getLogger: () =>
5
+ new Proxy({} as Record<string, unknown>, {
6
+ get: () => () => {},
7
+ }),
8
+ }));
9
+
10
+ let collectUsageData = true;
11
+
12
+ mock.module("../config/loader.js", () => ({
13
+ getConfig: () => ({
14
+ ui: {},
15
+ model: "test",
16
+ provider: "test",
17
+ memory: { enabled: false },
18
+ rateLimit: { maxRequestsPerMinute: 0 },
19
+ secretDetection: { enabled: false },
20
+ collectUsageData,
21
+ }),
22
+ }));
23
+
24
+ import {
25
+ type AuthFallbackCount,
26
+ queryUnreportedAuthFallbackEvents,
27
+ recordAuthFallbackCounts,
28
+ } from "../memory/auth-fallback-events-store.js";
29
+ import { getDb } from "../memory/db-connection.js";
30
+ import { initializeDb } from "../memory/db-init.js";
31
+ import { authFallbackEvents } from "../memory/schema.js";
32
+
33
+ initializeDb();
34
+
35
+ function resetTable(): void {
36
+ getDb().delete(authFallbackEvents).run();
37
+ }
38
+
39
+ const SAMPLE: AuthFallbackCount[] = [
40
+ {
41
+ guard: "edge",
42
+ path: "/v1/messages",
43
+ failureKind: "missing_authorization",
44
+ count: 7,
45
+ },
46
+ {
47
+ guard: "edge-scoped",
48
+ path: "/v1/files",
49
+ failureKind: "insufficient_scope",
50
+ count: 2,
51
+ },
52
+ ];
53
+
54
+ describe("auth-fallback-events-store", () => {
55
+ beforeEach(() => {
56
+ collectUsageData = true;
57
+ resetTable();
58
+ });
59
+
60
+ test("records one row per count entry and they are queryable", () => {
61
+ const recorded = recordAuthFallbackCounts(1000, 2000, SAMPLE);
62
+ expect(recorded).toBe(2);
63
+
64
+ const rows = queryUnreportedAuthFallbackEvents(0, undefined, 100);
65
+ expect(rows.length).toBe(2);
66
+ const byGuard = Object.fromEntries(rows.map((r) => [r.guard, r]));
67
+ expect(byGuard["edge"]).toMatchObject({
68
+ path: "/v1/messages",
69
+ failureKind: "missing_authorization",
70
+ count: 7,
71
+ windowStart: 1000,
72
+ windowEnd: 2000,
73
+ });
74
+ expect(byGuard["edge-scoped"]).toMatchObject({
75
+ path: "/v1/files",
76
+ failureKind: "insufficient_scope",
77
+ count: 2,
78
+ });
79
+ });
80
+
81
+ test("honors the collectUsageData opt-out (records nothing)", () => {
82
+ collectUsageData = false;
83
+ const recorded = recordAuthFallbackCounts(1000, 2000, SAMPLE);
84
+ expect(recorded).toBe(0);
85
+ expect(queryUnreportedAuthFallbackEvents(0, undefined, 100).length).toBe(0);
86
+ });
87
+
88
+ test("empty counts batch is a no-op", () => {
89
+ expect(recordAuthFallbackCounts(1000, 2000, [])).toBe(0);
90
+ expect(queryUnreportedAuthFallbackEvents(0, undefined, 100).length).toBe(0);
91
+ });
92
+
93
+ test("query advances past the compound (createdAt, id) cursor", () => {
94
+ recordAuthFallbackCounts(1000, 2000, SAMPLE);
95
+ const all = queryUnreportedAuthFallbackEvents(0, undefined, 100);
96
+ expect(all.length).toBe(2);
97
+
98
+ // All rows share the same createdAt (one insert batch). Paginating with a
99
+ // limit of 1 must use the id tiebreaker to make forward progress, not loop.
100
+ const first = queryUnreportedAuthFallbackEvents(0, undefined, 1);
101
+ expect(first.length).toBe(1);
102
+ const second = queryUnreportedAuthFallbackEvents(
103
+ first[0].createdAt,
104
+ first[0].id,
105
+ 1,
106
+ );
107
+ expect(second.length).toBe(1);
108
+ expect(second[0].id).not.toBe(first[0].id);
109
+
110
+ // Cursor past the last row returns nothing.
111
+ const last = all[all.length - 1];
112
+ expect(
113
+ queryUnreportedAuthFallbackEvents(last.createdAt, last.id, 100).length,
114
+ ).toBe(0);
115
+ });
116
+ });