@vellumai/assistant 0.5.1 → 0.5.3

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 (405) hide show
  1. package/ARCHITECTURE.md +163 -54
  2. package/docs/architecture/integrations.md +62 -67
  3. package/docs/credential-execution-service.md +3 -3
  4. package/docs/skills.md +100 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop.test.ts +111 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +3 -4
  8. package/src/__tests__/app-builder-tool-scripts.test.ts +13 -151
  9. package/src/__tests__/app-dir-path-guard.test.ts +78 -0
  10. package/src/__tests__/app-executors.test.ts +1 -291
  11. package/src/__tests__/app-git-history.test.ts +4 -4
  12. package/src/__tests__/app-routes-csp.test.ts +1 -0
  13. package/src/__tests__/app-store-dir-names.test.ts +426 -0
  14. package/src/__tests__/attachments-store.test.ts +169 -21
  15. package/src/__tests__/attachments.test.ts +115 -1
  16. package/src/__tests__/btw-routes.test.ts +1 -0
  17. package/src/__tests__/canonical-guardian-store.test.ts +38 -0
  18. package/src/__tests__/channel-reply-delivery.test.ts +55 -0
  19. package/src/__tests__/checker.test.ts +54 -0
  20. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  21. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  22. package/src/__tests__/compaction.benchmark.test.ts +2 -1
  23. package/src/__tests__/config-schema-cmd.test.ts +68 -21
  24. package/src/__tests__/config-schema.test.ts +1 -1
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +156 -5
  26. package/src/__tests__/conversation-agent-loop.test.ts +297 -2
  27. package/src/__tests__/conversation-attachments.test.ts +17 -19
  28. package/src/__tests__/conversation-disk-view-integration.test.ts +277 -0
  29. package/src/__tests__/conversation-disk-view.test.ts +810 -0
  30. package/src/__tests__/conversation-error.test.ts +1 -1
  31. package/src/__tests__/conversation-fork-crud.test.ts +551 -0
  32. package/src/__tests__/conversation-fork-route.test.ts +386 -0
  33. package/src/__tests__/conversation-history-web-search.test.ts +1 -1
  34. package/src/__tests__/conversation-key-store-disk-view.test.ts +130 -0
  35. package/src/__tests__/conversation-media-retry.test.ts +8 -2
  36. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  37. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  38. package/src/__tests__/conversation-queue.test.ts +36 -1
  39. package/src/__tests__/conversation-routes-disk-view.test.ts +439 -0
  40. package/src/__tests__/conversation-routes-guardian-reply.test.ts +2 -2
  41. package/src/__tests__/conversation-routes-slash-commands.test.ts +2 -7
  42. package/src/__tests__/conversation-runtime-assembly.test.ts +17 -2
  43. package/src/__tests__/conversation-skill-tools.test.ts +4 -9
  44. package/src/__tests__/conversation-slash-commands.test.ts +149 -0
  45. package/src/__tests__/conversation-store.test.ts +24 -21
  46. package/src/__tests__/conversation-surfaces-state-update.test.ts +246 -0
  47. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -0
  48. package/src/__tests__/conversation-title-service.test.ts +137 -0
  49. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +25 -315
  50. package/src/__tests__/conversation-tool-setup-memory-scope.test.ts +1 -0
  51. package/src/__tests__/conversation-tool-setup-side-effect-flag.test.ts +1 -0
  52. package/src/__tests__/conversation-wipe.test.ts +226 -0
  53. package/src/__tests__/conversation-workspace-cache-state.test.ts +44 -2
  54. package/src/__tests__/conversation-workspace-injection.test.ts +11 -0
  55. package/src/__tests__/credential-security-invariants.test.ts +3 -0
  56. package/src/__tests__/credential-vault-unit.test.ts +5 -10
  57. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  58. package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +241 -0
  59. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +214 -0
  60. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  61. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  62. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  63. package/src/__tests__/diagnostics-export.test.ts +70 -1
  64. package/src/__tests__/first-greeting.test.ts +80 -0
  65. package/src/__tests__/gateway-only-guard.test.ts +1 -0
  66. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +3 -7
  67. package/src/__tests__/history-repair.test.ts +32 -10
  68. package/src/__tests__/http-conversation-lineage.test.ts +251 -0
  69. package/src/__tests__/image-source-path-reinject.test.ts +136 -0
  70. package/src/__tests__/inline-command-runner.test.ts +311 -0
  71. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  72. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  73. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  74. package/src/__tests__/llm-context-normalization.test.ts +1116 -0
  75. package/src/__tests__/llm-context-route-provider.test.ts +217 -0
  76. package/src/__tests__/llm-request-log-turn-query.test.ts +270 -0
  77. package/src/__tests__/media-generate-image.test.ts +47 -94
  78. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  79. package/src/__tests__/memory-brief-time.test.ts +285 -0
  80. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  81. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  82. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  83. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  84. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  85. package/src/__tests__/memory-lifecycle-e2e.test.ts +3 -1
  86. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  87. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  88. package/src/__tests__/memory-recall-quality.test.ts +7 -7
  89. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  90. package/src/__tests__/memory-reducer-types.test.ts +699 -0
  91. package/src/__tests__/memory-reducer.test.ts +698 -0
  92. package/src/__tests__/memory-regressions.test.ts +6 -4
  93. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  94. package/src/__tests__/migration-cross-version-compatibility.test.ts +4 -1
  95. package/src/__tests__/migration-export-http.test.ts +3 -1
  96. package/src/__tests__/migration-import-commit-http.test.ts +18 -4
  97. package/src/__tests__/migration-import-preflight-http.test.ts +1 -3
  98. package/src/__tests__/mime-builder.test.ts +3 -2
  99. package/src/__tests__/non-member-access-request.test.ts +12 -1
  100. package/src/__tests__/notification-decision-identity.test.ts +52 -0
  101. package/src/__tests__/oauth-apps-routes.test.ts +103 -0
  102. package/src/__tests__/oauth-store.test.ts +115 -0
  103. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  104. package/src/__tests__/provider-error-scenarios.test.ts +1 -3
  105. package/src/__tests__/provider-failover-actual-provider.test.ts +66 -0
  106. package/src/__tests__/recording-handler.test.ts +17 -0
  107. package/src/__tests__/registry.test.ts +3 -8
  108. package/src/__tests__/relay-server.test.ts +1 -1
  109. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -3
  110. package/src/__tests__/schema-transforms.test.ts +165 -5
  111. package/src/__tests__/server-history-render.test.ts +2 -2
  112. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  113. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  114. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  115. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  116. package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
  117. package/src/__tests__/slack-inbound-verification.test.ts +2 -2
  118. package/src/__tests__/starter-task-flow.test.ts +1 -0
  119. package/src/__tests__/suggestion-routes.test.ts +443 -0
  120. package/src/__tests__/swarm-conversation-integration.test.ts +1 -0
  121. package/src/__tests__/swarm-recursion.test.ts +1 -0
  122. package/src/__tests__/swarm-tool.test.ts +1 -0
  123. package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
  124. package/src/__tests__/tool-preview-lifecycle.test.ts +32 -5
  125. package/src/__tests__/top-level-renderer.test.ts +22 -0
  126. package/src/__tests__/turn-boundary-resolution.test.ts +243 -0
  127. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  128. package/src/__tests__/web-fetch.test.ts +6 -2
  129. package/src/__tests__/workspace-migration-006-services-config.test.ts +335 -0
  130. package/src/__tests__/workspace-migration-007-web-search-provider-rename.test.ts +312 -0
  131. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +278 -0
  132. package/src/__tests__/workspace-migration-010-app-dir-rename.test.ts +275 -0
  133. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +77 -0
  134. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +401 -0
  135. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +328 -0
  136. package/src/__tests__/workspace-migration-seed-device-id.test.ts +6 -10
  137. package/src/agent/attachments.ts +27 -1
  138. package/src/agent/loop.ts +29 -1
  139. package/src/avatar/traits-png-sync.ts +80 -25
  140. package/src/bundler/app-bundler.ts +4 -4
  141. package/src/calls/call-domain.ts +1 -0
  142. package/src/calls/voice-session-bridge.ts +1 -0
  143. package/src/cli/commands/auth.ts +92 -0
  144. package/src/cli/commands/avatar.ts +7 -6
  145. package/src/cli/commands/config.ts +2 -0
  146. package/src/cli/commands/oauth/providers.ts +29 -0
  147. package/src/cli/program.ts +12 -0
  148. package/src/cli.ts +15 -48
  149. package/src/config/bundled-skills/app-builder/SKILL.md +103 -28
  150. package/src/config/bundled-skills/app-builder/TOOLS.json +5 -199
  151. package/src/config/bundled-skills/app-builder/tools/{app-query.ts → app-refresh.ts} +2 -2
  152. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +2 -3
  153. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +6 -9
  154. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +4 -6
  155. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +2 -3
  156. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +2 -3
  157. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +2 -3
  158. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +2 -3
  159. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +4 -6
  160. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +2 -3
  161. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +2 -3
  162. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +2 -3
  163. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +2 -3
  164. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -3
  165. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +2 -3
  166. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  167. package/src/config/bundled-skills/image-studio/SKILL.md +2 -2
  168. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  169. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +45 -72
  170. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -2
  171. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -1
  172. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -3
  173. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  174. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  175. package/src/config/bundled-skills/slack/tools/shared.ts +19 -4
  176. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +2 -3
  177. package/src/config/bundled-skills/transcribe/SKILL.md +1 -1
  178. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -6
  179. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +19 -83
  180. package/src/config/bundled-tool-registry.ts +2 -14
  181. package/src/config/feature-flag-registry.json +24 -0
  182. package/src/config/loader.ts +65 -0
  183. package/src/config/raw-config-utils.ts +58 -0
  184. package/src/config/schema-utils.ts +28 -7
  185. package/src/config/schema.ts +20 -0
  186. package/src/config/schemas/elevenlabs.ts +18 -0
  187. package/src/config/schemas/memory-lifecycle.ts +4 -2
  188. package/src/config/schemas/memory-simplified.ts +101 -0
  189. package/src/config/schemas/memory-storage.ts +1 -1
  190. package/src/config/schemas/memory.ts +4 -0
  191. package/src/config/schemas/services.ts +8 -6
  192. package/src/config/skills.ts +50 -4
  193. package/src/contacts/contact-store.ts +13 -6
  194. package/src/contacts/contacts-write.ts +0 -1
  195. package/src/context/window-manager.ts +13 -2
  196. package/src/daemon/conversation-agent-loop-handlers.ts +54 -8
  197. package/src/daemon/conversation-agent-loop.ts +127 -20
  198. package/src/daemon/conversation-attachments.ts +18 -36
  199. package/src/daemon/conversation-error.ts +2 -1
  200. package/src/daemon/conversation-history.ts +18 -4
  201. package/src/daemon/conversation-lifecycle.ts +50 -16
  202. package/src/daemon/conversation-messaging.ts +70 -26
  203. package/src/daemon/conversation-process.ts +58 -34
  204. package/src/daemon/conversation-runtime-assembly.ts +22 -38
  205. package/src/daemon/conversation-slash.ts +121 -256
  206. package/src/daemon/conversation-surfaces.ts +170 -24
  207. package/src/daemon/conversation-tool-setup.ts +0 -6
  208. package/src/daemon/conversation-workspace.ts +21 -1
  209. package/src/daemon/conversation.ts +69 -30
  210. package/src/daemon/first-greeting.ts +35 -0
  211. package/src/daemon/handlers/config-embeddings.ts +156 -0
  212. package/src/daemon/handlers/config-model.ts +62 -26
  213. package/src/daemon/handlers/conversations.ts +0 -23
  214. package/src/daemon/handlers/identity.ts +12 -1
  215. package/src/daemon/handlers/recording.ts +26 -21
  216. package/src/daemon/host-cu-proxy.ts +2 -2
  217. package/src/daemon/lifecycle.ts +115 -65
  218. package/src/daemon/message-protocol.ts +3 -0
  219. package/src/daemon/message-types/conversations.ts +18 -0
  220. package/src/daemon/message-types/messages.ts +1 -0
  221. package/src/daemon/message-types/shared.ts +2 -0
  222. package/src/daemon/message-types/surfaces.ts +2 -0
  223. package/src/daemon/message-types/upgrades.ts +23 -0
  224. package/src/daemon/server.ts +83 -12
  225. package/src/daemon/shutdown-handlers.ts +8 -5
  226. package/src/daemon/startup-error.ts +9 -0
  227. package/src/daemon/tool-side-effects.ts +11 -28
  228. package/src/events/tool-permission-telemetry-listener.ts +1 -3
  229. package/src/followups/followup-store.ts +47 -1
  230. package/src/instrument.ts +0 -4
  231. package/src/media/app-icon-generator.ts +2 -2
  232. package/src/memory/app-git-service.ts +28 -16
  233. package/src/memory/app-store.ts +230 -41
  234. package/src/memory/archive-store.ts +400 -0
  235. package/src/memory/attachments-store.ts +558 -130
  236. package/src/memory/brief-formatting.ts +33 -0
  237. package/src/memory/brief-open-loops.ts +266 -0
  238. package/src/memory/brief-time.ts +161 -0
  239. package/src/memory/brief.ts +75 -0
  240. package/src/memory/conversation-attention-store.ts +70 -0
  241. package/src/memory/conversation-crud.ts +591 -8
  242. package/src/memory/conversation-directories.ts +125 -0
  243. package/src/memory/conversation-disk-view.ts +390 -0
  244. package/src/memory/conversation-key-store.ts +17 -5
  245. package/src/memory/conversation-queries.ts +5 -1
  246. package/src/memory/conversation-title-service.ts +21 -49
  247. package/src/memory/db-init.ts +40 -0
  248. package/src/memory/embedding-backend.ts +42 -53
  249. package/src/memory/embedding-gemini.test.ts +4 -4
  250. package/src/memory/embedding-local.ts +1 -3
  251. package/src/memory/embedding-ollama.ts +1 -3
  252. package/src/memory/embedding-openai.ts +1 -3
  253. package/src/memory/indexer.ts +114 -21
  254. package/src/memory/items-extractor.ts +42 -13
  255. package/src/memory/job-handlers/conversation-starters.ts +6 -1
  256. package/src/memory/job-handlers/embedding.test.ts +2 -4
  257. package/src/memory/job-handlers/embedding.ts +83 -0
  258. package/src/memory/job-utils.ts +1 -1
  259. package/src/memory/jobs-store.ts +6 -0
  260. package/src/memory/jobs-worker.ts +12 -0
  261. package/src/memory/llm-request-log-store.ts +100 -1
  262. package/src/memory/migrations/102-alter-table-columns.ts +5 -0
  263. package/src/memory/migrations/146-schedule-oneshot-routing.ts +3 -3
  264. package/src/memory/migrations/147-migrate-reminders-to-schedules.ts +66 -70
  265. package/src/memory/migrations/148-drop-reminders-table.ts +5 -9
  266. package/src/memory/migrations/160-drop-loopback-port-column.ts +1 -3
  267. package/src/memory/migrations/174-rename-thread-starters-table.ts +0 -7
  268. package/src/memory/migrations/178-oauth-providers-managed-service-config-key.ts +15 -0
  269. package/src/memory/migrations/179-llm-request-log-message-id.ts +16 -0
  270. package/src/memory/migrations/180-backfill-inline-attachments-to-disk.ts +66 -0
  271. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +46 -0
  272. package/src/memory/migrations/182-oauth-providers-display-metadata.ts +20 -0
  273. package/src/memory/migrations/183-add-conversation-fork-lineage.ts +22 -0
  274. package/src/memory/migrations/184-llm-request-log-provider.ts +12 -0
  275. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  276. package/src/memory/migrations/186-memory-archive.ts +109 -0
  277. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  278. package/src/memory/migrations/index.ts +10 -0
  279. package/src/memory/migrations/registry.ts +13 -0
  280. package/src/memory/qdrant-client.ts +23 -4
  281. package/src/memory/reducer-store.ts +271 -0
  282. package/src/memory/reducer-types.ts +99 -0
  283. package/src/memory/reducer.ts +453 -0
  284. package/src/memory/retriever.test.ts +601 -2
  285. package/src/memory/retriever.ts +85 -9
  286. package/src/memory/schema/conversations.ts +9 -0
  287. package/src/memory/schema/index.ts +2 -0
  288. package/src/memory/schema/infrastructure.ts +13 -7
  289. package/src/memory/schema/memory-archive.ts +121 -0
  290. package/src/memory/schema/memory-brief.ts +55 -0
  291. package/src/memory/schema/oauth.ts +6 -0
  292. package/src/memory/search/semantic.ts +17 -4
  293. package/src/messaging/providers/gmail/mime-builder.ts +3 -1
  294. package/src/notifications/copy-composer.ts +26 -0
  295. package/src/notifications/decision-engine.ts +14 -1
  296. package/src/notifications/emit-signal.ts +1 -1
  297. package/src/notifications/signal.ts +36 -0
  298. package/src/oauth/byo-connection.test.ts +1 -45
  299. package/src/oauth/byo-connection.ts +2 -8
  300. package/src/oauth/connect-orchestrator.ts +15 -11
  301. package/src/oauth/connection-resolver.test.ts +191 -0
  302. package/src/oauth/connection-resolver.ts +66 -38
  303. package/src/oauth/connection.ts +0 -1
  304. package/src/oauth/oauth-store.ts +99 -47
  305. package/src/oauth/platform-connection.test.ts +0 -1
  306. package/src/oauth/platform-connection.ts +11 -3
  307. package/src/oauth/seed-providers.ts +78 -3
  308. package/src/oauth/token-persistence.ts +16 -10
  309. package/src/permissions/checker.ts +160 -14
  310. package/src/permissions/defaults.ts +14 -0
  311. package/src/prompts/templates/BOOTSTRAP.md +2 -0
  312. package/src/providers/anthropic/client.ts +8 -1
  313. package/src/providers/failover.ts +4 -1
  314. package/src/providers/gemini/client.ts +50 -0
  315. package/src/providers/model-catalog.ts +92 -0
  316. package/src/providers/model-intents.ts +29 -20
  317. package/src/providers/openai/client.ts +49 -0
  318. package/src/providers/types.ts +2 -0
  319. package/src/runtime/access-request-helper.ts +16 -7
  320. package/src/runtime/auth/credential-service.ts +3 -1
  321. package/src/runtime/auth/route-policy.ts +14 -1
  322. package/src/runtime/btw-sidechain.ts +101 -0
  323. package/src/runtime/channel-reply-delivery.ts +17 -1
  324. package/src/runtime/http-router.ts +3 -1
  325. package/src/runtime/http-server.ts +196 -141
  326. package/src/runtime/http-types.ts +1 -0
  327. package/src/runtime/migrations/vbundle-builder.ts +5 -1
  328. package/src/runtime/routes/access-request-decision.ts +41 -0
  329. package/src/runtime/routes/app-management-routes.ts +6 -3
  330. package/src/runtime/routes/app-routes.ts +7 -3
  331. package/src/runtime/routes/approval-routes.ts +1 -0
  332. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +34 -2
  333. package/src/runtime/routes/attachment-routes.ts +45 -15
  334. package/src/runtime/routes/btw-routes.ts +21 -61
  335. package/src/runtime/routes/conversation-management-routes.ts +74 -0
  336. package/src/runtime/routes/conversation-query-routes.ts +187 -10
  337. package/src/runtime/routes/conversation-routes.ts +269 -28
  338. package/src/runtime/routes/conversation-starter-routes.ts +9 -11
  339. package/src/runtime/routes/diagnostics-routes.ts +1 -0
  340. package/src/runtime/routes/identity-routes.ts +2 -35
  341. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +2 -2
  342. package/src/runtime/routes/llm-context-normalization.ts +1212 -0
  343. package/src/runtime/routes/log-export-routes.ts +3 -0
  344. package/src/runtime/routes/memory-item-routes.test.ts +34 -0
  345. package/src/runtime/routes/memory-item-routes.ts +94 -5
  346. package/src/runtime/routes/migration-routes.ts +4 -1
  347. package/src/runtime/routes/oauth-apps.ts +291 -0
  348. package/src/runtime/routes/secret-routes.ts +30 -1
  349. package/src/runtime/routes/settings-routes.ts +14 -0
  350. package/src/runtime/routes/surface-action-routes.ts +68 -1
  351. package/src/runtime/routes/trace-event-routes.ts +4 -1
  352. package/src/schedule/schedule-store.ts +30 -21
  353. package/src/security/secure-keys.ts +21 -0
  354. package/src/signals/bash.ts +1 -1
  355. package/src/skills/inline-command-expansions.ts +204 -0
  356. package/src/skills/inline-command-render.ts +127 -0
  357. package/src/skills/inline-command-runner.ts +242 -0
  358. package/src/skills/transitive-version-hash.ts +88 -0
  359. package/src/swarm/backend-claude-code.ts +3 -6
  360. package/src/tasks/task-store.ts +43 -1
  361. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -2
  362. package/src/telemetry/usage-telemetry-reporter.ts +3 -1
  363. package/src/tools/AGENTS.md +6 -10
  364. package/src/tools/apps/executors.ts +17 -232
  365. package/src/tools/claude-code/claude-code.ts +2 -3
  366. package/src/tools/credentials/vault.ts +7 -12
  367. package/src/tools/host-filesystem/read.ts +13 -10
  368. package/src/tools/network/__tests__/web-search.test.ts +4 -2
  369. package/src/tools/permission-checker.ts +8 -1
  370. package/src/tools/schedule/list.ts +2 -7
  371. package/src/tools/schema-transforms.ts +5 -0
  372. package/src/tools/shared/filesystem/format-diff.ts +2 -7
  373. package/src/tools/skills/execute.ts +1 -1
  374. package/src/tools/skills/load.ts +140 -6
  375. package/src/tools/tool-manifest.ts +0 -6
  376. package/src/tools/ui-surface/definitions.ts +2 -2
  377. package/src/util/device-id.ts +28 -5
  378. package/src/util/platform.ts +24 -0
  379. package/src/util/pricing.ts +1 -0
  380. package/src/util/retry.ts +1 -3
  381. package/src/workspace/migrations/003-seed-device-id.ts +3 -4
  382. package/src/workspace/migrations/006-services-config.ts +5 -0
  383. package/src/workspace/migrations/008-voice-timeout-and-max-steps.ts +12 -0
  384. package/src/workspace/migrations/009-backfill-conversation-disk-view.ts +10 -0
  385. package/src/workspace/migrations/010-app-dir-rename.ts +223 -0
  386. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +24 -13
  387. package/src/workspace/migrations/012-rename-conversation-disk-view-dirs.ts +64 -0
  388. package/src/workspace/migrations/013-repair-conversation-disk-view.ts +11 -0
  389. package/src/workspace/migrations/rebuild-conversation-disk-view.ts +186 -0
  390. package/src/workspace/migrations/registry.ts +11 -1
  391. package/src/workspace/top-level-renderer.ts +12 -0
  392. package/src/__tests__/asset-materialize-tool.test.ts +0 -523
  393. package/src/__tests__/asset-search-tool.test.ts +0 -536
  394. package/src/__tests__/fixtures/media-reuse-fixtures.ts +0 -56
  395. package/src/__tests__/media-reuse-story.e2e.test.ts +0 -762
  396. package/src/__tests__/media-visibility-policy.test.ts +0 -190
  397. package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +0 -14
  398. package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +0 -13
  399. package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +0 -21
  400. package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +0 -14
  401. package/src/config/bundled-skills/app-builder/tools/app-list.ts +0 -13
  402. package/src/config/bundled-skills/app-builder/tools/app-update.ts +0 -23
  403. package/src/daemon/media-visibility-policy.ts +0 -59
  404. package/src/tools/assets/materialize.ts +0 -248
  405. package/src/tools/assets/search.ts +0 -400
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ resolveSlash,
5
+ type SlashContext,
6
+ } from "../daemon/conversation-slash.js";
7
+
8
+ function makeSlashContext(
9
+ overrides: Partial<SlashContext> = {},
10
+ ): SlashContext {
11
+ return {
12
+ messageCount: 4,
13
+ inputTokens: 1024,
14
+ outputTokens: 256,
15
+ maxInputTokens: 200000,
16
+ model: "claude-opus-4-6",
17
+ provider: "anthropic",
18
+ estimatedCost: 0.03,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ async function resolveCommandsLines(context?: SlashContext): Promise<string[]> {
24
+ const result = await resolveSlash("/commands", context);
25
+ expect(result.kind).toBe("unknown");
26
+ if (result.kind !== "unknown") {
27
+ throw new Error("Expected /commands to resolve to kind=unknown");
28
+ }
29
+ return result.message.split("\n");
30
+ }
31
+
32
+ describe("resolveSlash /commands interface-aware help", () => {
33
+ test("renders desktop command help for macOS", async () => {
34
+ const lines = await resolveCommandsLines(
35
+ makeSlashContext({ userMessageInterface: "macos" }),
36
+ );
37
+ expect(lines).toEqual([
38
+ "/commands — List all available commands",
39
+ "/models — List all available models",
40
+ "/status — Show conversation status and context usage",
41
+ "/btw — Ask a side question while the assistant is working",
42
+ "/fork — Fork the current conversation into a new branch",
43
+ "/pair — Generate pairing info for connecting a mobile device",
44
+ ]);
45
+ expect(lines).not.toContain(
46
+ "/model — Switch the active model",
47
+ );
48
+ });
49
+
50
+ test("renders iOS command help with /fork but without /pair", async () => {
51
+ const lines = await resolveCommandsLines(
52
+ makeSlashContext({ userMessageInterface: "ios" }),
53
+ );
54
+ expect(lines).toEqual([
55
+ "/commands — List all available commands",
56
+ "/models — List all available models",
57
+ "/status — Show conversation status and context usage",
58
+ "/btw — Ask a side question while the assistant is working",
59
+ "/fork — Fork the current conversation into a new branch",
60
+ ]);
61
+ });
62
+
63
+ test("renders explicit cli command help without /pair", async () => {
64
+ const lines = await resolveCommandsLines(
65
+ makeSlashContext({ userMessageInterface: "cli" }),
66
+ );
67
+ expect(lines).toEqual([
68
+ "/commands — List all available commands",
69
+ "/models — List all available models",
70
+ "/status — Show conversation status and context usage",
71
+ "/btw — Ask a side question while the assistant is working",
72
+ ]);
73
+ });
74
+
75
+ test("keeps legacy fallback help when no interface is provided", async () => {
76
+ const lines = await resolveCommandsLines(makeSlashContext());
77
+ expect(lines).toEqual([
78
+ "/commands — List all available commands",
79
+ "/models — List all available models",
80
+ "/pair — Generate pairing info for connecting a mobile device",
81
+ "/status — Show conversation status and context usage",
82
+ ]);
83
+ });
84
+
85
+ test("keeps context-free fallback without /status", async () => {
86
+ const lines = await resolveCommandsLines();
87
+ expect(lines).toEqual([
88
+ "/commands — List all available commands",
89
+ "/models — List all available models",
90
+ "/pair — Generate pairing info for connecting a mobile device",
91
+ ]);
92
+ });
93
+ });
94
+
95
+ describe("resolveSlash command contract", () => {
96
+ test("keeps unsupported slash forms as passthrough", async () => {
97
+ const slashForms = [
98
+ "/commands foo",
99
+ "/models foo",
100
+ "/status foo",
101
+ "/pair foo",
102
+ "/btw",
103
+ ];
104
+
105
+ for (const input of slashForms) {
106
+ const result = await resolveSlash(
107
+ input,
108
+ makeSlashContext({ userMessageInterface: "macos" }),
109
+ );
110
+ expect(result).toEqual({ kind: "passthrough", content: input });
111
+ }
112
+ });
113
+
114
+ test("rejects /pair on iOS interfaces", async () => {
115
+ const result = await resolveSlash(
116
+ "/pair",
117
+ makeSlashContext({ userMessageInterface: "ios" }),
118
+ );
119
+ expect(result.kind).toBe("unknown");
120
+ if (result.kind !== "unknown") {
121
+ throw new Error("Expected /pair on iOS to resolve to kind=unknown");
122
+ }
123
+ expect(result.message).toContain("only available in the macOS desktop app");
124
+ });
125
+
126
+ test("keeps /pair rejected for explicit non-macOS interfaces", async () => {
127
+ const result = await resolveSlash(
128
+ "/pair",
129
+ makeSlashContext({ userMessageInterface: "cli" }),
130
+ );
131
+ expect(result.kind).toBe("unknown");
132
+ if (result.kind !== "unknown") {
133
+ throw new Error("Expected /pair on cli to resolve to kind=unknown");
134
+ }
135
+ expect(result.message).toContain("only available in the macOS desktop app");
136
+ });
137
+
138
+ test("keeps /pair handling enabled on macOS interfaces", async () => {
139
+ const result = await resolveSlash(
140
+ "/pair",
141
+ makeSlashContext({ userMessageInterface: "macos" }),
142
+ );
143
+ expect(result.kind).toBe("unknown");
144
+ if (result.kind !== "unknown") {
145
+ throw new Error("Expected /pair on macOS to resolve to kind=unknown");
146
+ }
147
+ expect(result.message).toContain("Pairing is not available");
148
+ });
149
+ });
@@ -549,7 +549,11 @@ describe("attachment reuse across conversation lifecycles", () => {
549
549
  test("attachment uploaded in conversation A is retrievable by ID without any conversation reference", async () => {
550
550
  const convA = createConversation("Conversation A");
551
551
  const msgA = await addMessage(convA.id, "assistant", "Here is a file");
552
- const stored = uploadAttachment("report.pdf", "application/pdf", "JVBER");
552
+ const stored = uploadAttachment(
553
+ "report.pdf",
554
+ "application/pdf",
555
+ "JVBERA==",
556
+ );
553
557
  linkAttachmentToMessage(msgA.id, stored.id, 0);
554
558
 
555
559
  // Create a completely separate conversation
@@ -557,14 +561,14 @@ describe("attachment reuse across conversation lifecycles", () => {
557
561
  await addMessage(convB.id, "user", "hello");
558
562
 
559
563
  // The attachment is retrievable by ID regardless of which conversation is active.
560
- const fetched = getAttachmentById(stored.id);
564
+ const fetched = getAttachmentById(stored.id, { hydrateFileData: true });
561
565
  expect(fetched).not.toBeNull();
562
566
  expect(fetched!.id).toBe(stored.id);
563
567
  expect(fetched!.originalFilename).toBe("report.pdf");
564
- expect(fetched!.dataBase64).toBe("JVBER");
568
+ expect(fetched!.dataBase64).toBe("JVBERA==");
565
569
  });
566
570
 
567
- test("attachment can be linked to messages in different conversations", async () => {
571
+ test("re-linking an attachment across conversations creates a conversation-local row", async () => {
568
572
  const convA = createConversation("Conversation A");
569
573
  const convB = createConversation("Conversation B");
570
574
 
@@ -576,17 +580,17 @@ describe("attachment reuse across conversation lifecycles", () => {
576
580
  linkAttachmentToMessage(msgA.id, stored.id, 0);
577
581
  linkAttachmentToMessage(msgB.id, stored.id, 0);
578
582
 
579
- // Both messages see the attachment
583
+ // Both messages see the attachment, but each conversation keeps its own row.
580
584
  const linkedA = getAttachmentsForMessage(msgA.id);
581
585
  expect(linkedA).toHaveLength(1);
582
586
  expect(linkedA[0].id).toBe(stored.id);
583
587
 
584
588
  const linkedB = getAttachmentsForMessage(msgB.id);
585
589
  expect(linkedB).toHaveLength(1);
586
- expect(linkedB[0].id).toBe(stored.id);
590
+ expect(linkedB[0].id).not.toBe(stored.id);
587
591
  });
588
592
 
589
- test("deleting conversation A does not orphan attachment reused in conversation B", async () => {
593
+ test("deleting conversation A does not remove the copied attachment in conversation B", async () => {
590
594
  const convA = createConversation("Conversation A");
591
595
  const convB = createConversation("Conversation B");
592
596
 
@@ -600,33 +604,33 @@ describe("attachment reuse across conversation lifecycles", () => {
600
604
  const stored = uploadAttachment("chart.png", "image/png", "AAAA");
601
605
  linkAttachmentToMessage(msgA.id, stored.id, 0);
602
606
  linkAttachmentToMessage(msgB.id, stored.id, 0);
607
+ const linkedB = getAttachmentsForMessage(msgB.id);
608
+ expect(linkedB).toHaveLength(1);
603
609
 
604
610
  // Delete conversation A's exchange
605
611
  deleteLastExchange(convA.id);
606
612
 
607
- // Attachment survives because convB still references it
608
- const fetched = getAttachmentById(stored.id);
613
+ // Conversation B keeps its own attachment row and file.
614
+ const fetched = getAttachmentById(linkedB[0].id);
609
615
  expect(fetched).not.toBeNull();
610
616
 
611
617
  // convB's message still has the attachment linked
612
- const linkedB = getAttachmentsForMessage(msgB.id);
613
- expect(linkedB).toHaveLength(1);
614
- expect(linkedB[0].id).toBe(stored.id);
618
+ const linkedBAfterDelete = getAttachmentsForMessage(msgB.id);
619
+ expect(linkedBAfterDelete).toHaveLength(1);
620
+ expect(linkedBAfterDelete[0].id).toBe(linkedB[0].id);
615
621
  });
616
622
 
617
- test("content-hash dedup works across conversations", async () => {
623
+ test("identical uploads remain distinct across conversations", async () => {
618
624
  const convA = createConversation("Conversation A");
619
625
  const convB = createConversation("Conversation B");
620
626
 
621
627
  await addMessage(convA.id, "user", "upload in A");
622
628
  await addMessage(convB.id, "user", "upload in B");
623
629
 
624
- // Same content uploaded in two different conversation contexts
625
630
  const first = uploadAttachment("photo.png", "image/png", "DEDUPCROSS");
626
631
  const second = uploadAttachment("photo.png", "image/png", "DEDUPCROSS");
627
632
 
628
- // Dedup returns the same attachment row
629
- expect(second.id).toBe(first.id);
633
+ expect(second.id).not.toBe(first.id);
630
634
  });
631
635
  });
632
636
 
@@ -664,7 +668,7 @@ describe("no private-conversation attachment visibility boundary", () => {
664
668
  expect(fetched!.originalFilename).toBe("secret.pdf");
665
669
  });
666
670
 
667
- test("attachment from private conversation can be linked to a standard conversation message", async () => {
671
+ test("attachment from a private conversation is copied when linked into a standard conversation", async () => {
668
672
  const privateConv = createConversation({
669
673
  title: "Private",
670
674
  conversationType: "private",
@@ -695,7 +699,7 @@ describe("no private-conversation attachment visibility boundary", () => {
695
699
 
696
700
  const linkedStandard = getAttachmentsForMessage(standardMsg.id);
697
701
  expect(linkedStandard).toHaveLength(1);
698
- expect(linkedStandard[0].id).toBe(stored.id);
702
+ expect(linkedStandard[0].id).not.toBe(stored.id);
699
703
  });
700
704
 
701
705
  test("getAttachmentsForMessage returns private conversation attachments", async () => {
@@ -712,7 +716,7 @@ describe("no private-conversation attachment visibility boundary", () => {
712
716
  expect(linked[0].id).toBe(stored.id);
713
717
  });
714
718
 
715
- test("content-hash dedup works across private and standard conversations", () => {
719
+ test("identical uploads remain distinct across private and standard conversations", () => {
716
720
  createConversation({ title: "Private", conversationType: "private" });
717
721
  createConversation({ title: "Standard", conversationType: "standard" });
718
722
 
@@ -728,8 +732,7 @@ describe("no private-conversation attachment visibility boundary", () => {
728
732
  "CROSSCONVERSATION",
729
733
  );
730
734
 
731
- // Dedup returns the same row — no conversation-type isolation
732
- expect(fromStandard.id).toBe(fromPrivate.id);
735
+ expect(fromStandard.id).not.toBe(fromPrivate.id);
733
736
  });
734
737
 
735
738
  test("clearAll removes attachments from both private and standard conversations", async () => {
@@ -0,0 +1,246 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ createSurfaceMutex,
5
+ handleSurfaceAction,
6
+ type SurfaceConversationContext,
7
+ surfaceProxyResolver,
8
+ } from "../daemon/conversation-surfaces.js";
9
+ import type {
10
+ ServerMessage,
11
+ SurfaceData,
12
+ SurfaceType,
13
+ } from "../daemon/message-protocol.js";
14
+
15
+ /**
16
+ * Build a minimal SurfaceConversationContext for testing.
17
+ * Tracks calls to enqueueMessage and processMessage so tests can assert
18
+ * whether an LLM turn was triggered.
19
+ */
20
+ function makeContext(opts?: {
21
+ sent?: ServerMessage[];
22
+ }): SurfaceConversationContext & {
23
+ enqueueCalls: Array<{ content: string; requestId: string }>;
24
+ processCalls: Array<{ content: string; requestId?: string }>;
25
+ } {
26
+ const sent = opts?.sent ?? [];
27
+ const enqueueCalls: Array<{ content: string; requestId: string }> = [];
28
+ const processCalls: Array<{ content: string; requestId?: string }> = [];
29
+
30
+ return {
31
+ conversationId: "test-session",
32
+ traceEmitter: { emit: () => {} },
33
+ sendToClient: (msg) => sent.push(msg),
34
+ pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
35
+ lastSurfaceAction: new Map<
36
+ string,
37
+ { actionId: string; data?: Record<string, unknown> }
38
+ >(),
39
+ surfaceState: new Map<
40
+ string,
41
+ { surfaceType: SurfaceType; data: SurfaceData; title?: string }
42
+ >(),
43
+ surfaceUndoStacks: new Map<string, string[]>(),
44
+ accumulatedSurfaceState: new Map<string, Record<string, unknown>>(),
45
+ surfaceActionRequestIds: new Set<string>(),
46
+ currentTurnSurfaces: [],
47
+ isProcessing: () => false,
48
+ enqueueMessage: (content, _attachments, _onEvent, requestId) => {
49
+ enqueueCalls.push({ content, requestId });
50
+ return { queued: false, requestId };
51
+ },
52
+ getQueueDepth: () => 0,
53
+ processMessage: async (content, _attachments, _onEvent, requestId) => {
54
+ processCalls.push({ content, requestId });
55
+ return "ok";
56
+ },
57
+ withSurface: createSurfaceMutex(),
58
+ enqueueCalls,
59
+ processCalls,
60
+ };
61
+ }
62
+
63
+ /** Register a dynamic_page surface in the context so state_update is accepted. */
64
+ function registerDynamicPage(
65
+ ctx: SurfaceConversationContext,
66
+ surfaceId: string,
67
+ ): void {
68
+ ctx.pendingSurfaceActions.set(surfaceId, { surfaceType: "dynamic_page" });
69
+ ctx.surfaceState.set(surfaceId, {
70
+ surfaceType: "dynamic_page",
71
+ data: { html: "<div>test</div>" } as SurfaceData,
72
+ });
73
+ }
74
+
75
+ describe("state_update silent accumulation", () => {
76
+ test("accumulates state from multiple calls via shallow merge", () => {
77
+ const ctx = makeContext();
78
+ registerDynamicPage(ctx, "surface-1");
79
+
80
+ handleSurfaceAction(ctx, "surface-1", "state_update", { page: 2 });
81
+ handleSurfaceAction(ctx, "surface-1", "state_update", {
82
+ selectedTab: "overview",
83
+ });
84
+ handleSurfaceAction(ctx, "surface-1", "state_update", { page: 5 });
85
+
86
+ const accumulated = ctx.accumulatedSurfaceState.get("surface-1");
87
+ expect(accumulated).toEqual({ page: 5, selectedTab: "overview" });
88
+ });
89
+
90
+ test("ignores calls with undefined data", () => {
91
+ const ctx = makeContext();
92
+ registerDynamicPage(ctx, "surface-1");
93
+
94
+ handleSurfaceAction(ctx, "surface-1", "state_update", { count: 1 });
95
+ handleSurfaceAction(ctx, "surface-1", "state_update", undefined);
96
+
97
+ const accumulated = ctx.accumulatedSurfaceState.get("surface-1");
98
+ expect(accumulated).toEqual({ count: 1 });
99
+ });
100
+
101
+ test("does not accumulate for non-dynamic_page surfaces", () => {
102
+ const ctx = makeContext();
103
+ // Register as a table surface instead of dynamic_page
104
+ ctx.pendingSurfaceActions.set("surface-table", { surfaceType: "table" });
105
+ ctx.surfaceState.set("surface-table", {
106
+ surfaceType: "table",
107
+ data: {
108
+ columns: [],
109
+ rows: [],
110
+ } as unknown as SurfaceData,
111
+ });
112
+
113
+ handleSurfaceAction(ctx, "surface-table", "state_update", { page: 1 });
114
+
115
+ const accumulated = ctx.accumulatedSurfaceState.get("surface-table");
116
+ expect(accumulated).toBeUndefined();
117
+ });
118
+ });
119
+
120
+ describe("state_update does not trigger LLM", () => {
121
+ test("does not call enqueueMessage or processMessage", () => {
122
+ const ctx = makeContext();
123
+ registerDynamicPage(ctx, "surface-1");
124
+
125
+ handleSurfaceAction(ctx, "surface-1", "state_update", {
126
+ currentSlide: 3,
127
+ });
128
+
129
+ expect(ctx.enqueueCalls).toHaveLength(0);
130
+ expect(ctx.processCalls).toHaveLength(0);
131
+ });
132
+
133
+ test("does not add to surfaceActionRequestIds", () => {
134
+ const ctx = makeContext();
135
+ registerDynamicPage(ctx, "surface-1");
136
+
137
+ handleSurfaceAction(ctx, "surface-1", "state_update", { zoom: 1.5 });
138
+
139
+ expect(ctx.surfaceActionRequestIds.size).toBe(0);
140
+ });
141
+ });
142
+
143
+ describe("accumulated state injection into reactive actions", () => {
144
+ test("subsequent reactive action includes accumulated state in message content", () => {
145
+ const ctx = makeContext();
146
+ registerDynamicPage(ctx, "surface-1");
147
+
148
+ // Accumulate some state
149
+ handleSurfaceAction(ctx, "surface-1", "state_update", { page: 3 });
150
+ handleSurfaceAction(ctx, "surface-1", "state_update", {
151
+ selectedItem: "item-42",
152
+ });
153
+
154
+ // Fire a reactive action (e.g. "save")
155
+ handleSurfaceAction(ctx, "surface-1", "save");
156
+
157
+ // The enqueueMessage call should include the accumulated state
158
+ expect(ctx.enqueueCalls).toHaveLength(1);
159
+ const content = ctx.enqueueCalls[0].content;
160
+ expect(content).toContain("Accumulated surface state:");
161
+ expect(content).toContain('"page":3');
162
+ expect(content).toContain('"selectedItem":"item-42"');
163
+ });
164
+
165
+ test("empty accumulated state is not appended", () => {
166
+ const ctx = makeContext();
167
+ registerDynamicPage(ctx, "surface-1");
168
+
169
+ // Fire a reactive action without any prior state_update
170
+ handleSurfaceAction(ctx, "surface-1", "refresh");
171
+
172
+ expect(ctx.enqueueCalls).toHaveLength(1);
173
+ const content = ctx.enqueueCalls[0].content;
174
+ expect(content).not.toContain("Accumulated surface state:");
175
+ });
176
+ });
177
+
178
+ describe("per-surface state isolation", () => {
179
+ test("accumulated state from surface A does not appear in surface B reactive action", () => {
180
+ const ctx = makeContext();
181
+ registerDynamicPage(ctx, "surface-a");
182
+ registerDynamicPage(ctx, "surface-b");
183
+
184
+ // Accumulate state only on surface A
185
+ handleSurfaceAction(ctx, "surface-a", "state_update", {
186
+ filterA: "active",
187
+ });
188
+
189
+ // Fire a reactive action on surface B
190
+ handleSurfaceAction(ctx, "surface-b", "submit");
191
+
192
+ expect(ctx.enqueueCalls).toHaveLength(1);
193
+ const content = ctx.enqueueCalls[0].content;
194
+ expect(content).not.toContain("filterA");
195
+ expect(content).not.toContain("Accumulated surface state:");
196
+ });
197
+
198
+ test("each surface maintains its own accumulated state", () => {
199
+ const ctx = makeContext();
200
+ registerDynamicPage(ctx, "surface-a");
201
+ registerDynamicPage(ctx, "surface-b");
202
+
203
+ handleSurfaceAction(ctx, "surface-a", "state_update", { page: 1 });
204
+ handleSurfaceAction(ctx, "surface-b", "state_update", { page: 99 });
205
+
206
+ expect(ctx.accumulatedSurfaceState.get("surface-a")).toEqual({ page: 1 });
207
+ expect(ctx.accumulatedSurfaceState.get("surface-b")).toEqual({ page: 99 });
208
+ });
209
+ });
210
+
211
+ describe("cleanup on dismiss", () => {
212
+ test("ui_dismiss clears accumulated state for the surface", async () => {
213
+ const ctx = makeContext();
214
+ registerDynamicPage(ctx, "surface-1");
215
+
216
+ // Accumulate state
217
+ handleSurfaceAction(ctx, "surface-1", "state_update", { dirty: true });
218
+ expect(ctx.accumulatedSurfaceState.get("surface-1")).toEqual({
219
+ dirty: true,
220
+ });
221
+
222
+ // Dismiss via surfaceProxyResolver (ui_dismiss)
223
+ await surfaceProxyResolver(ctx, "ui_dismiss", {
224
+ surface_id: "surface-1",
225
+ });
226
+
227
+ // Accumulated state should be cleared
228
+ expect(ctx.accumulatedSurfaceState.has("surface-1")).toBe(false);
229
+ });
230
+
231
+ test("ui_dismiss does not affect other surfaces accumulated state", async () => {
232
+ const ctx = makeContext();
233
+ registerDynamicPage(ctx, "surface-1");
234
+ registerDynamicPage(ctx, "surface-2");
235
+
236
+ handleSurfaceAction(ctx, "surface-1", "state_update", { x: 1 });
237
+ handleSurfaceAction(ctx, "surface-2", "state_update", { y: 2 });
238
+
239
+ await surfaceProxyResolver(ctx, "ui_dismiss", {
240
+ surface_id: "surface-1",
241
+ });
242
+
243
+ expect(ctx.accumulatedSurfaceState.has("surface-1")).toBe(false);
244
+ expect(ctx.accumulatedSurfaceState.get("surface-2")).toEqual({ y: 2 });
245
+ });
246
+ });
@@ -34,6 +34,7 @@ function makeContext(
34
34
  { surfaceType: SurfaceType; data: SurfaceData; title?: string }
35
35
  >(),
36
36
  surfaceUndoStacks: new Map<string, string[]>(),
37
+ accumulatedSurfaceState: new Map<string, Record<string, unknown>>(),
37
38
  surfaceActionRequestIds: new Set<string>(),
38
39
  currentTurnSurfaces: [],
39
40
  isProcessing: () => false,
@@ -0,0 +1,137 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ const mockRunBtwSidechain = mock(async () => ({
4
+ text: "Project kickoff",
5
+ hadTextDeltas: true,
6
+ response: {
7
+ content: [{ type: "text", text: "Project kickoff" }],
8
+ model: "test-model",
9
+ usage: { inputTokens: 10, outputTokens: 5 },
10
+ stopReason: "end_turn",
11
+ },
12
+ }));
13
+
14
+ const mockGetConversation = mock(
15
+ (_conversationId: string) =>
16
+ ({
17
+ title: "Generating title...",
18
+ isAutoTitle: 1,
19
+ }) as {
20
+ title: string;
21
+ isAutoTitle: number;
22
+ },
23
+ );
24
+ const mockGetMessages = mock(() => [
25
+ { role: "user", content: "first message" },
26
+ { role: "assistant", content: "first reply" },
27
+ { role: "user", content: "follow-up" },
28
+ ]);
29
+ const mockUpdateConversationTitle = mock(() => {});
30
+ const mockGetConfiguredProvider = mock(async () => null);
31
+ const mockGetConfig = mock(() => ({
32
+ daemon: {
33
+ titleGenerationMaxTokens: 37,
34
+ },
35
+ }));
36
+
37
+ mock.module("../runtime/btw-sidechain.js", () => ({
38
+ runBtwSidechain: mockRunBtwSidechain,
39
+ }));
40
+
41
+ mock.module("../memory/conversation-crud.js", () => ({
42
+ getConversation: mockGetConversation,
43
+ getMessages: mockGetMessages,
44
+ updateConversationTitle: mockUpdateConversationTitle,
45
+ }));
46
+
47
+ mock.module("../providers/provider-send-message.js", () => ({
48
+ getConfiguredProvider: mockGetConfiguredProvider,
49
+ }));
50
+
51
+ mock.module("../config/loader.js", () => ({
52
+ getConfig: mockGetConfig,
53
+ }));
54
+
55
+ mock.module("../util/logger.js", () => ({
56
+ getLogger: () =>
57
+ new Proxy({} as Record<string, unknown>, {
58
+ get: () => () => {},
59
+ }),
60
+ }));
61
+
62
+ import {
63
+ generateAndPersistConversationTitle,
64
+ regenerateConversationTitle,
65
+ } from "../memory/conversation-title-service.js";
66
+
67
+ describe("conversation-title-service", () => {
68
+ beforeEach(() => {
69
+ mockRunBtwSidechain.mockClear();
70
+ mockGetConversation.mockClear();
71
+ mockGetMessages.mockClear();
72
+ mockUpdateConversationTitle.mockClear();
73
+ mockGetConfiguredProvider.mockClear();
74
+ mockGetConfig.mockClear();
75
+ });
76
+
77
+ test("uses the BTW side-chain helper for initial title generation", async () => {
78
+ const provider = {
79
+ name: "test-provider",
80
+ sendMessage: mock(async () => {
81
+ throw new Error("provider.sendMessage should not be called directly");
82
+ }),
83
+ };
84
+
85
+ const result = await generateAndPersistConversationTitle({
86
+ conversationId: "conv-1",
87
+ provider,
88
+ userMessage: "Help me plan the kickoff",
89
+ });
90
+
91
+ expect(result).toEqual({ title: "Project kickoff", updated: true });
92
+ expect(mockRunBtwSidechain).toHaveBeenCalledTimes(1);
93
+ expect(mockRunBtwSidechain).toHaveBeenCalledWith(
94
+ expect.objectContaining({
95
+ provider,
96
+ maxTokens: 37,
97
+ modelIntent: "latency-optimized",
98
+ timeoutMs: 10_000,
99
+ }),
100
+ );
101
+ expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
102
+ "conv-1",
103
+ "Project kickoff",
104
+ 1,
105
+ );
106
+ });
107
+
108
+ test("uses the BTW side-chain helper for title regeneration", async () => {
109
+ const provider = {
110
+ name: "test-provider",
111
+ sendMessage: mock(async () => {
112
+ throw new Error("provider.sendMessage should not be called directly");
113
+ }),
114
+ };
115
+
116
+ const result = await regenerateConversationTitle({
117
+ conversationId: "conv-1",
118
+ provider,
119
+ });
120
+
121
+ expect(result).toEqual({ title: "Project kickoff", updated: true });
122
+ expect(mockRunBtwSidechain).toHaveBeenCalledTimes(1);
123
+ expect(mockRunBtwSidechain).toHaveBeenCalledWith(
124
+ expect.objectContaining({
125
+ provider,
126
+ maxTokens: 37,
127
+ modelIntent: "latency-optimized",
128
+ timeoutMs: 10_000,
129
+ }),
130
+ );
131
+ expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
132
+ "conv-1",
133
+ "Project kickoff",
134
+ 1,
135
+ );
136
+ });
137
+ });