@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa

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 (295) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +26 -3
  3. package/docs/automatic-done.md +32 -0
  4. package/docs/desktop-browser-cli.md +3 -2
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
  10. package/node_modules/@vellumai/environments/src/shell.ts +22 -1
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  15. package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
  17. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  18. package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  21. package/openapi.yaml +270 -10
  22. package/package.json +1 -1
  23. package/src/__tests__/agent-loop.test.ts +76 -0
  24. package/src/__tests__/anthropic-provider.test.ts +2 -0
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
  26. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  27. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  28. package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
  29. package/src/__tests__/conversation-evictor.test.ts +54 -0
  30. package/src/__tests__/conversation-pairing.test.ts +112 -0
  31. package/src/__tests__/document-append-idempotency.test.ts +13 -0
  32. package/src/__tests__/document-create-dedupe.test.ts +13 -0
  33. package/src/__tests__/document-find-replace.test.ts +13 -0
  34. package/src/__tests__/document-revision-history.test.ts +390 -0
  35. package/src/__tests__/document-revision.test.ts +425 -0
  36. package/src/__tests__/document-tool-security.test.ts +13 -0
  37. package/src/__tests__/document-update-default-surface.test.ts +13 -0
  38. package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
  39. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  40. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  41. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  42. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  43. package/src/__tests__/host-shell-tool.test.ts +6 -2
  44. package/src/__tests__/identity-routes.test.ts +4 -3
  45. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  46. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  47. package/src/__tests__/llm-schema.test.ts +1 -1
  48. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  49. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  50. package/src/__tests__/openai-provider.test.ts +38 -11
  51. package/src/__tests__/pricing.test.ts +11 -0
  52. package/src/__tests__/public-ingress-urls.test.ts +26 -0
  53. package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
  54. package/src/__tests__/skill-execute-input.test.ts +11 -0
  55. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  56. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  57. package/src/__tests__/usage-attribution.test.ts +31 -0
  58. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  59. package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
  60. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  61. package/src/acp/agent-process.ts +6 -1
  62. package/src/acp/client-handler.ts +6 -1
  63. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  64. package/src/agent/loop.ts +161 -17
  65. package/src/api/events/document-editor-show.ts +5 -0
  66. package/src/api/events/document-editor-update.ts +10 -0
  67. package/src/api/events/tool-result.ts +1 -0
  68. package/src/calls/__tests__/call-controller.test.ts +273 -1
  69. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  70. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
  72. package/src/calls/call-constants.ts +10 -0
  73. package/src/calls/call-controller.ts +359 -25
  74. package/src/calls/channel-admission-reader.ts +15 -9
  75. package/src/calls/media-stream-server.ts +18 -0
  76. package/src/calls/media-stream-stt-session.ts +30 -9
  77. package/src/calls/twilio-provider.ts +14 -31
  78. package/src/calls/voice-session-bridge.ts +18 -1
  79. package/src/cli/commands/browser.ts +1 -2
  80. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  81. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
  82. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  83. package/src/cli/lib/bundled-marketplace.json +54 -2
  84. package/src/cli/lib/inspect-plugin.ts +15 -15
  85. package/src/cli/lib/list-installed-plugins.ts +29 -28
  86. package/src/cli/lib/plugin-artifact.ts +74 -63
  87. package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
  88. package/src/cli/lib/plugin-details.ts +27 -42
  89. package/src/cli/lib/plugin-marketplace.ts +6 -8
  90. package/src/cli/lib/plugin-surfaces.ts +8 -7
  91. package/src/cli.ts +1 -3
  92. package/src/config/__tests__/chat-settings.test.ts +121 -0
  93. package/src/config/assistant-feature-flags.ts +28 -23
  94. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  95. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  96. package/src/config/chat-settings.ts +100 -0
  97. package/src/config/default-profile-catalog.ts +1 -1
  98. package/src/config/feature-flag-cache.ts +7 -25
  99. package/src/config/feature-flag-registry.json +9 -9
  100. package/src/config/loader.ts +5 -1
  101. package/src/config/profile-text-generation.ts +25 -21
  102. package/src/config/schemas/conversations.ts +14 -0
  103. package/src/config/schemas/llm.ts +1 -0
  104. package/src/config/schemas/notifications.ts +8 -1
  105. package/src/contacts/gateway-channel-read.ts +1 -1
  106. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  107. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  108. package/src/conversations/auto-archive-activity.ts +61 -0
  109. package/src/conversations/auto-archive.ts +201 -0
  110. package/src/daemon/computer-use-observation.test.ts +81 -0
  111. package/src/daemon/computer-use-observation.ts +23 -0
  112. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  113. package/src/daemon/conversation-agent-loop.ts +14 -6
  114. package/src/daemon/conversation-evictor.ts +50 -16
  115. package/src/daemon/conversation-messaging.ts +4 -0
  116. package/src/daemon/conversation-process.ts +15 -0
  117. package/src/daemon/conversation-queue-manager.ts +2 -0
  118. package/src/daemon/conversation.ts +2 -0
  119. package/src/daemon/doordash-steps.ts +1 -3
  120. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  121. package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
  122. package/src/daemon/handlers/config-a2a.ts +75 -2
  123. package/src/daemon/handlers/shared.ts +13 -2
  124. package/src/daemon/host-cu-proxy.ts +34 -3
  125. package/src/daemon/lifecycle.ts +15 -2
  126. package/src/daemon/process-message.ts +5 -0
  127. package/src/daemon/shutdown-handlers.ts +3 -0
  128. package/src/daemon/turn-finalization.ts +4 -0
  129. package/src/desktop/desktop-browser-endpoint.ts +11 -2
  130. package/src/desktop/desktop-chrome-policy.test.ts +1 -0
  131. package/src/desktop/desktop-chrome-policy.ts +7 -1
  132. package/src/desktop/desktop-computer-use.test.ts +14 -4
  133. package/src/desktop/desktop-session-manager.ts +5 -3
  134. package/src/documents/document-revisions-store.ts +153 -0
  135. package/src/documents/document-store.ts +471 -133
  136. package/src/followups/followup-store.ts +0 -4
  137. package/src/i18n/__tests__/i18n.test.ts +25 -0
  138. package/src/i18n/assistant-text.ts +18 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/messages.ts +11 -0
  141. package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
  142. package/src/inbound/oauth-callback-url.ts +30 -1
  143. package/src/inbound/public-ingress-urls.ts +12 -2
  144. package/src/ipc/gateway-client.test.ts +40 -0
  145. package/src/ipc/gateway-client.ts +12 -29
  146. package/src/ipc/gateway-flag-listener.ts +7 -10
  147. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
  148. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  149. package/src/live-voice/__tests__/protocol.test.ts +77 -0
  150. package/src/live-voice/__tests__/share-targets.test.ts +163 -0
  151. package/src/live-voice/live-voice-session.ts +23 -6
  152. package/src/live-voice/protocol.ts +22 -0
  153. package/src/live-voice/share-targets.ts +225 -0
  154. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
  155. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
  156. package/src/mcp/client.ts +1 -19
  157. package/src/mcp/mcp-oauth-provider.ts +66 -49
  158. package/src/media/openrouter-image-service.ts +39 -22
  159. package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
  160. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
  161. package/src/monitoring/control.ts +2 -1
  162. package/src/monitoring/oom-inheritance-sweep.ts +96 -0
  163. package/src/monitoring/oom-kill-reporter.ts +469 -0
  164. package/src/monitoring/resource-sampler.ts +30 -0
  165. package/src/notifications/README.md +53 -4
  166. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  167. package/src/notifications/__tests__/broadcaster.test.ts +558 -6
  168. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  169. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  170. package/src/notifications/adapters/platform.ts +14 -0
  171. package/src/notifications/assistant-initiated-thread.ts +101 -0
  172. package/src/notifications/broadcaster.ts +94 -16
  173. package/src/notifications/chat-reply-policy.ts +11 -0
  174. package/src/notifications/completion-policy.ts +12 -2
  175. package/src/notifications/conversation-pairing.ts +15 -70
  176. package/src/notifications/emit-signal.ts +34 -3
  177. package/src/notifications/types.ts +2 -0
  178. package/src/oauth/connect-orchestrator.ts +1 -2
  179. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  180. package/src/persistence/conversation-auto-archive.ts +170 -0
  181. package/src/persistence/conversation-crud.ts +2 -1
  182. package/src/persistence/conversation-queries.ts +15 -8
  183. package/src/persistence/embeddings/embed.test.ts +96 -0
  184. package/src/persistence/embeddings/embed.ts +20 -2
  185. package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
  186. package/src/persistence/embeddings/embedding-gemini.ts +39 -1
  187. package/src/persistence/embeddings/embedding-local.ts +6 -0
  188. package/src/persistence/embeddings/qdrant-manager.ts +5 -0
  189. package/src/persistence/job-utils.ts +10 -0
  190. package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
  191. package/src/persistence/migrations/384-add-document-revision.ts +21 -0
  192. package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
  193. package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
  194. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  195. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  196. package/src/persistence/schema/conversations.ts +1 -0
  197. package/src/persistence/schema/documents.ts +20 -0
  198. package/src/persistence/steps.ts +6 -0
  199. package/src/platform/managed-speech.ts +42 -46
  200. package/src/plugin-api/vision-support.test.ts +21 -21
  201. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
  202. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  203. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  204. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  205. package/src/providers/anthropic/client.ts +1 -1
  206. package/src/providers/fireworks/client.test.ts +168 -0
  207. package/src/providers/fireworks/client.ts +24 -0
  208. package/src/providers/inference/adapter-factory.ts +6 -0
  209. package/src/providers/ionet/client.test.ts +143 -0
  210. package/src/providers/ionet/client.ts +37 -0
  211. package/src/providers/model-catalog.ts +141 -84
  212. package/src/providers/model-intents.ts +4 -4
  213. package/src/providers/openai/chat-completions-provider.ts +33 -2
  214. package/src/providers/retry.ts +56 -4
  215. package/src/providers/search-provider-catalog.ts +10 -0
  216. package/src/providers/types.ts +10 -2
  217. package/src/providers/unparseable-tool-args.ts +29 -5
  218. package/src/providers/usage-tracking.ts +5 -3
  219. package/src/runtime/assistant-event-hub.ts +23 -0
  220. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  221. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  222. package/src/runtime/channel-reply-delivery.ts +2 -0
  223. package/src/runtime/channel-retry-sweep.ts +1 -2
  224. package/src/runtime/http-types.ts +3 -0
  225. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  226. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  227. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  228. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  229. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  230. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  231. package/src/runtime/pending-interactions.ts +20 -2
  232. package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
  233. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
  234. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  235. package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
  236. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
  237. package/src/runtime/routes/contact-routes.ts +2 -2
  238. package/src/runtime/routes/conversation-list-routes.ts +10 -2
  239. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  240. package/src/runtime/routes/conversation-routes.ts +7 -0
  241. package/src/runtime/routes/documents-routes.ts +185 -47
  242. package/src/runtime/routes/events-routes.ts +1 -3
  243. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  244. package/src/runtime/routes/host-bash-routes.ts +1 -0
  245. package/src/runtime/routes/host-browser-routes.ts +15 -0
  246. package/src/runtime/routes/host-cu-routes.ts +4 -1
  247. package/src/runtime/routes/host-file-routes.ts +1 -0
  248. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  249. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  250. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  251. package/src/runtime/routes/identity-routes.ts +3 -3
  252. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  253. package/src/runtime/routes/integrations/a2a.ts +4 -1
  254. package/src/runtime/routes/migration-routes.ts +3 -1
  255. package/src/runtime/routes/notification-routes.ts +1 -2
  256. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  257. package/src/runtime/routes/user-route-dispatcher.ts +5 -1
  258. package/src/runtime/verification-templates.ts +1 -2
  259. package/src/sequence/analytics.ts +0 -4
  260. package/src/sequence/engine.ts +1 -11
  261. package/src/sequence/guardrails.ts +1 -6
  262. package/src/sequence/importer.ts +0 -8
  263. package/src/sequence/store.ts +0 -10
  264. package/src/signals/user-message.ts +0 -6
  265. package/src/skills/catalog-install.ts +52 -23
  266. package/src/tools/browser/browser-execution.ts +20 -23
  267. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
  268. package/src/tools/document/document-tool.ts +13 -11
  269. package/src/tools/executor.ts +1 -2
  270. package/src/tools/host-terminal/host-shell.ts +4 -2
  271. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  272. package/src/tools/network/web-fetch.ts +2 -4
  273. package/src/tools/network/web-search.ts +239 -12
  274. package/src/tools/shared/filesystem/format-diff.ts +1 -2
  275. package/src/tools/shared/shell-output.test.ts +42 -0
  276. package/src/tools/shared/shell-output.ts +27 -4
  277. package/src/tools/skills/execute.ts +10 -7
  278. package/src/tools/terminal/shell.ts +3 -3
  279. package/src/tools/tool-approval-handler.ts +9 -1
  280. package/src/tools/ui-surface/visual-validation.ts +1 -2
  281. package/src/usage/attribution.ts +18 -1
  282. package/src/util/__tests__/oom-priority.test.ts +55 -0
  283. package/src/util/__tests__/worker-memory.test.ts +16 -0
  284. package/src/util/debounce.ts +0 -4
  285. package/src/util/oom-priority.ts +79 -0
  286. package/src/util/platform.ts +0 -2
  287. package/src/util/plugin-manifest.ts +5 -7
  288. package/src/util/pricing.ts +25 -0
  289. package/src/util/worker-memory.ts +9 -3
  290. package/src/util/worker-process.ts +8 -0
  291. package/src/workspace/byok-default-profile-ensure.ts +11 -0
  292. package/src/workspace/hatched-date.ts +11 -4
  293. package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
  294. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  295. package/src/workspace/migrations/registry.ts +4 -0
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Document revision history: which writes snapshot the state they replace,
3
+ * retention, the history routes, restore, and cascade on delete.
4
+ */
5
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
6
+
7
+ import type { AssistantEventEnvelope } from "../api/index.js";
8
+ import {
9
+ listDocumentRevisions,
10
+ MAX_REVISIONS_PER_DOCUMENT,
11
+ USER_SNAPSHOT_INTERVAL_MS,
12
+ } from "../documents/document-revisions-store.js";
13
+ import {
14
+ createDocument,
15
+ deleteDocument,
16
+ getDocumentById,
17
+ mutateDocumentContent,
18
+ replaceInDocument,
19
+ saveDocument,
20
+ updateDocumentContent,
21
+ } from "../documents/document-store.js";
22
+ import { createConversation } from "../persistence/conversation-crud.js";
23
+ import { getDb, getSqlite } from "../persistence/db-connection.js";
24
+ import { initializeDb } from "../persistence/db-init.js";
25
+ import { assistantEventHub } from "../runtime/assistant-event-hub.js";
26
+ import { ROUTES as DOCUMENT_ROUTES } from "../runtime/routes/documents-routes.js";
27
+ import type { RouteDefinition } from "../runtime/routes/types.js";
28
+ import { resetDbForTesting } from "./db-test-helpers.js";
29
+
30
+ await initializeDb();
31
+
32
+ const CONVERSATION_ID = "conv-doc-history";
33
+
34
+ function route(operationId: string): RouteDefinition {
35
+ return DOCUMENT_ROUTES.find((r) => r.operationId === operationId)!;
36
+ }
37
+
38
+ async function invoke(
39
+ operationId: string,
40
+ args: Parameters<RouteDefinition["handler"]>[0],
41
+ ): Promise<unknown> {
42
+ return await route(operationId).handler(args);
43
+ }
44
+
45
+ function seed(content = "v1"): string {
46
+ const created = createDocument({
47
+ conversationId: CONVERSATION_ID,
48
+ title: "Doc",
49
+ content,
50
+ });
51
+ if (!created.success) {
52
+ throw new Error(created.error);
53
+ }
54
+ return created.surfaceId;
55
+ }
56
+
57
+ function userSave(
58
+ surfaceId: string,
59
+ content: string,
60
+ extra: { title?: string; baseRevision?: number } = {},
61
+ ) {
62
+ return saveDocument({
63
+ surfaceId,
64
+ conversationId: CONVERSATION_ID,
65
+ title: extra.title ?? "Doc",
66
+ content,
67
+ wordCount: 1,
68
+ baseRevision: extra.baseRevision,
69
+ });
70
+ }
71
+
72
+ /** `[revision, author]` pairs, newest first. */
73
+ function history(surfaceId: string): Array<[number, string]> {
74
+ return listDocumentRevisions(surfaceId).map((r) => [r.revision, r.author]);
75
+ }
76
+
77
+ function ageSnapshots(surfaceId: string, ms: number): void {
78
+ getSqlite()
79
+ .query(
80
+ "UPDATE document_revisions SET created_at = created_at - ? WHERE surface_id = ?",
81
+ )
82
+ .run(ms, surfaceId);
83
+ }
84
+
85
+ beforeEach(() => {
86
+ const db = getDb();
87
+ db.run("DELETE FROM document_revisions");
88
+ db.run("DELETE FROM document_conversations");
89
+ db.run("DELETE FROM documents");
90
+ db.run("DELETE FROM conversations");
91
+ createConversation({ id: CONVERSATION_ID });
92
+ });
93
+
94
+ afterAll(() => {
95
+ resetDbForTesting();
96
+ });
97
+
98
+ describe("assistant edits", () => {
99
+ test("creating a document takes no snapshot", () => {
100
+ expect(history(seed())).toEqual([]);
101
+ });
102
+
103
+ test("every assistant write snapshots the state it replaces", () => {
104
+ const surfaceId = seed("v1");
105
+
106
+ updateDocumentContent(surfaceId, "v2", "replace");
107
+ updateDocumentContent(surfaceId, "more", "append");
108
+ replaceInDocument(surfaceId, "more", "most");
109
+
110
+ expect(history(surfaceId)).toEqual([
111
+ [3, "assistant"],
112
+ [2, "assistant"],
113
+ [1, "assistant"],
114
+ ]);
115
+ expect(listDocumentRevisions(surfaceId).at(-1)).toMatchObject({
116
+ revision: 1,
117
+ title: "Doc",
118
+ });
119
+ });
120
+
121
+ test("an edit that changes nothing writes and snapshots nothing", () => {
122
+ const surfaceId = seed("hello");
123
+
124
+ replaceInDocument(surfaceId, "absent", "x");
125
+ updateDocumentContent(surfaceId, "hello", "replace");
126
+
127
+ expect(history(surfaceId)).toEqual([]);
128
+ expect(getDocumentById(surfaceId)?.revision).toBe(1);
129
+ });
130
+
131
+ test("a race lost mid-write rolls its snapshot back", () => {
132
+ const surfaceId = seed("v1");
133
+ userSave(surfaceId, "v2"); // snapshots revision 1 as the user's
134
+ let calls = 0;
135
+
136
+ mutateDocumentContent(surfaceId, "assistant", (current) => {
137
+ calls++;
138
+ if (calls === 1) {
139
+ // A recent user snapshot exists, so this save snapshots nothing.
140
+ userSave(surfaceId, "v3");
141
+ }
142
+ return { content: `${current.content} edited`, value: null };
143
+ });
144
+
145
+ // The first attempt's snapshot of revision 2 was rolled back with its
146
+ // failed write; the retry snapshotted revision 3.
147
+ expect(history(surfaceId)).toEqual([
148
+ [3, "assistant"],
149
+ [1, "user"],
150
+ ]);
151
+ expect(getDocumentById(surfaceId)).toMatchObject({
152
+ content: "v3 edited",
153
+ revision: 4,
154
+ });
155
+ });
156
+ });
157
+
158
+ describe("user saves", () => {
159
+ test("the first save of a document snapshots it", () => {
160
+ const surfaceId = seed("v1");
161
+
162
+ userSave(surfaceId, "v2");
163
+
164
+ expect(history(surfaceId)).toEqual([[1, "user"]]);
165
+ expect(listDocumentRevisions(surfaceId)[0]?.wordCount).toBe(1);
166
+ });
167
+
168
+ test("further saves within the interval take no snapshot", () => {
169
+ const surfaceId = seed("v1");
170
+
171
+ userSave(surfaceId, "v2");
172
+ userSave(surfaceId, "v3");
173
+ userSave(surfaceId, "v4");
174
+
175
+ expect(history(surfaceId)).toEqual([[1, "user"]]);
176
+ });
177
+
178
+ test("a save after the interval snapshots again", () => {
179
+ const surfaceId = seed("v1");
180
+ userSave(surfaceId, "v2");
181
+ ageSnapshots(surfaceId, USER_SNAPSHOT_INTERVAL_MS);
182
+
183
+ userSave(surfaceId, "v3");
184
+
185
+ expect(history(surfaceId)).toEqual([
186
+ [2, "user"],
187
+ [1, "user"],
188
+ ]);
189
+ });
190
+
191
+ test("a save after an assistant edit keeps the assistant's result", () => {
192
+ const surfaceId = seed("v1");
193
+ userSave(surfaceId, "v2");
194
+ updateDocumentContent(surfaceId, "assistant text", "replace");
195
+
196
+ userSave(surfaceId, "user rewrite");
197
+
198
+ expect(history(surfaceId)).toEqual([
199
+ [3, "user"],
200
+ [2, "assistant"],
201
+ [1, "user"],
202
+ ]);
203
+ });
204
+
205
+ test("a save that changes nothing takes no snapshot", () => {
206
+ const surfaceId = seed("v1");
207
+
208
+ userSave(surfaceId, "v1");
209
+
210
+ expect(history(surfaceId)).toEqual([]);
211
+ });
212
+
213
+ test("a rejected conditional save takes no snapshot", () => {
214
+ const surfaceId = seed("v1");
215
+ userSave(surfaceId, "v2");
216
+ ageSnapshots(surfaceId, USER_SNAPSHOT_INTERVAL_MS);
217
+
218
+ const result = userSave(surfaceId, "stale", { baseRevision: 1 });
219
+
220
+ expect(result.success).toBe(false);
221
+ expect(history(surfaceId)).toEqual([[1, "user"]]);
222
+ });
223
+ });
224
+
225
+ describe("retention", () => {
226
+ test(`keeps the newest ${MAX_REVISIONS_PER_DOCUMENT} snapshots per document`, () => {
227
+ const surfaceId = seed("v0");
228
+ const other = seed("other");
229
+ updateDocumentContent(other, "other edit", "replace");
230
+
231
+ const writes = MAX_REVISIONS_PER_DOCUMENT + 5;
232
+ for (let i = 1; i <= writes; i++) {
233
+ updateDocumentContent(surfaceId, `v${i}`, "replace");
234
+ }
235
+
236
+ const revisions = listDocumentRevisions(surfaceId).map((r) => r.revision);
237
+ expect(revisions).toHaveLength(MAX_REVISIONS_PER_DOCUMENT);
238
+ expect(revisions[0]).toBe(writes);
239
+ expect(revisions.at(-1)).toBe(writes - MAX_REVISIONS_PER_DOCUMENT + 1);
240
+ expect(history(other)).toEqual([[1, "assistant"]]);
241
+ });
242
+ });
243
+
244
+ describe("history routes", () => {
245
+ test("listing returns metadata newest first, without content", async () => {
246
+ const surfaceId = seed("one two");
247
+ updateDocumentContent(surfaceId, "three", "replace");
248
+
249
+ const result = (await invoke("listDocumentRevisions", {
250
+ pathParams: { id: surfaceId },
251
+ })) as { revisions: Array<Record<string, unknown>> };
252
+
253
+ expect(result.revisions).toEqual([
254
+ {
255
+ revision: 1,
256
+ author: "assistant",
257
+ createdAt: expect.any(Number),
258
+ wordCount: 2,
259
+ title: "Doc",
260
+ },
261
+ ]);
262
+ });
263
+
264
+ test("listing a missing document is a 404", async () => {
265
+ await expect(
266
+ invoke("listDocumentRevisions", { pathParams: { id: "doc-missing" } }),
267
+ ).rejects.toThrow(/Document not found/);
268
+ });
269
+
270
+ test("a single revision includes its content", async () => {
271
+ const surfaceId = seed("original");
272
+ updateDocumentContent(surfaceId, "changed", "replace");
273
+
274
+ expect(
275
+ await invoke("getDocumentRevision", {
276
+ pathParams: { id: surfaceId, revision: "1" },
277
+ }),
278
+ ).toMatchObject({ revision: 1, author: "assistant", content: "original" });
279
+ });
280
+
281
+ test("an unknown or malformed revision is rejected", async () => {
282
+ const surfaceId = seed();
283
+ await expect(
284
+ invoke("getDocumentRevision", {
285
+ pathParams: { id: surfaceId, revision: "7" },
286
+ }),
287
+ ).rejects.toThrow(/Revision not found/);
288
+ await expect(
289
+ invoke("getDocumentRevision", {
290
+ pathParams: { id: surfaceId, revision: "abc" },
291
+ }),
292
+ ).rejects.toThrow(/non-negative integer/);
293
+ });
294
+ });
295
+
296
+ describe("restore", () => {
297
+ test("writes the snapshot back as a new revision and snapshots the current state", async () => {
298
+ const surfaceId = seed("original");
299
+ updateDocumentContent(surfaceId, "assistant rewrite", "replace");
300
+ userSave(surfaceId, "user tweak", { title: "Renamed" });
301
+
302
+ const events: AssistantEventEnvelope[] = [];
303
+ const subscription = assistantEventHub.subscribe({
304
+ type: "process",
305
+ callback: (event) => {
306
+ if (event.message.type === "document_editor_update") {
307
+ events.push(event);
308
+ }
309
+ },
310
+ });
311
+ let result: unknown;
312
+ try {
313
+ result = await invoke("restoreDocumentRevision", {
314
+ pathParams: { id: surfaceId, revision: "1" },
315
+ });
316
+ await new Promise((resolve) => setTimeout(resolve, 20));
317
+ } finally {
318
+ subscription.dispose();
319
+ }
320
+
321
+ expect(result).toEqual({
322
+ success: true,
323
+ surfaceId,
324
+ revision: 4,
325
+ title: "Doc",
326
+ content: "original",
327
+ });
328
+ expect(getDocumentById(surfaceId)).toMatchObject({
329
+ title: "Doc",
330
+ content: "original",
331
+ revision: 4,
332
+ });
333
+ expect(history(surfaceId)).toEqual([
334
+ [3, "user"],
335
+ [2, "user"],
336
+ [1, "assistant"],
337
+ ]);
338
+ expect(
339
+ listDocumentRevisions(surfaceId).find((r) => r.revision === 3),
340
+ ).toMatchObject({ title: "Renamed" });
341
+ expect(events.map((e) => e.message)).toEqual([
342
+ {
343
+ type: "document_editor_update",
344
+ conversationId: CONVERSATION_ID,
345
+ surfaceId,
346
+ markdown: "original",
347
+ mode: "replace",
348
+ revision: 4,
349
+ title: "Doc",
350
+ },
351
+ ]);
352
+ });
353
+
354
+ test("restoring a missing revision is a 404 and writes nothing", async () => {
355
+ const surfaceId = seed("v1");
356
+
357
+ await expect(
358
+ invoke("restoreDocumentRevision", {
359
+ pathParams: { id: surfaceId, revision: "9" },
360
+ }),
361
+ ).rejects.toThrow(/Revision not found/);
362
+ expect(getDocumentById(surfaceId)?.revision).toBe(1);
363
+ expect(history(surfaceId)).toEqual([]);
364
+ });
365
+
366
+ test("restoring on a missing document is a 404", async () => {
367
+ await expect(
368
+ invoke("restoreDocumentRevision", {
369
+ pathParams: { id: "doc-missing", revision: "1" },
370
+ }),
371
+ ).rejects.toThrow(/Document not found/);
372
+ });
373
+ });
374
+
375
+ describe("cascade", () => {
376
+ test("deleting a document drops its history", () => {
377
+ const surfaceId = seed("v1");
378
+ updateDocumentContent(surfaceId, "v2", "replace");
379
+ expect(history(surfaceId)).toHaveLength(1);
380
+
381
+ deleteDocument(surfaceId);
382
+
383
+ const remaining = getSqlite()
384
+ .query(
385
+ "SELECT COUNT(*) AS n FROM document_revisions WHERE surface_id = ?",
386
+ )
387
+ .get(surfaceId) as { n: number };
388
+ expect(remaining.n).toBe(0);
389
+ });
390
+ });