@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,425 @@
1
+ /**
2
+ * Every document write bumps `revision`, read-modify-write paths retry instead
3
+ * of losing a concurrent write, and `POST documents` with `baseRevision`
4
+ * rejects a save made against a stale revision.
5
+ */
6
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
7
+
8
+ import {
9
+ createDocument,
10
+ getDocumentById,
11
+ getDocumentsForConversation,
12
+ listAllDocuments,
13
+ mutateDocumentContent,
14
+ replaceInDocument,
15
+ saveDocument,
16
+ updateDocumentContent,
17
+ } from "../documents/document-store.js";
18
+ import { createConversation } from "../persistence/conversation-crud.js";
19
+ import { getDb } from "../persistence/db-connection.js";
20
+ import { initializeDb } from "../persistence/db-init.js";
21
+ import { ROUTES as DOCUMENT_ROUTES } from "../runtime/routes/documents-routes.js";
22
+ import { ConflictError } from "../runtime/routes/errors.js";
23
+ import type { RouteDefinition } from "../runtime/routes/types.js";
24
+ import {
25
+ executeDocumentCreate,
26
+ executeDocumentOpen,
27
+ executeDocumentReplaceText,
28
+ executeDocumentUpdate,
29
+ } from "../tools/document/document-tool.js";
30
+ import type { ToolContext } from "../tools/types.js";
31
+ import { resetDbForTesting } from "./db-test-helpers.js";
32
+
33
+ await initializeDb();
34
+
35
+ const CONVERSATION_ID = "conv-doc-revision";
36
+
37
+ function route(operationId: string): RouteDefinition {
38
+ return DOCUMENT_ROUTES.find((r) => r.operationId === operationId)!;
39
+ }
40
+
41
+ async function post(body: Record<string, unknown>): Promise<unknown> {
42
+ return await route("saveDocument").handler({ body });
43
+ }
44
+
45
+ function seed(content = "one"): 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 revisionOf(surfaceId: string): number {
58
+ return getDocumentById(surfaceId)!.revision;
59
+ }
60
+
61
+ type SentMessage = { type: string; [key: string]: unknown };
62
+
63
+ function makeContext(sent: SentMessage[]): ToolContext {
64
+ return {
65
+ workingDir: "/tmp/project",
66
+ conversationId: CONVERSATION_ID,
67
+ trustClass: "guardian",
68
+ executionChannel: "vellum",
69
+ sendToClient: (message) => {
70
+ sent.push(message);
71
+ },
72
+ };
73
+ }
74
+
75
+ beforeEach(() => {
76
+ const db = getDb();
77
+ db.run("DELETE FROM document_conversations");
78
+ db.run("DELETE FROM documents");
79
+ db.run("DELETE FROM conversations");
80
+ createConversation({ id: CONVERSATION_ID });
81
+ });
82
+
83
+ afterAll(() => {
84
+ resetDbForTesting();
85
+ });
86
+
87
+ describe("revision counter", () => {
88
+ test("a new document starts at revision 1", () => {
89
+ expect(revisionOf(seed())).toBe(1);
90
+ });
91
+
92
+ test("every write path bumps the revision by one", () => {
93
+ const surfaceId = seed("alpha");
94
+
95
+ saveDocument({
96
+ surfaceId,
97
+ conversationId: CONVERSATION_ID,
98
+ title: "Doc",
99
+ content: "beta",
100
+ wordCount: 1,
101
+ });
102
+ expect(revisionOf(surfaceId)).toBe(2);
103
+
104
+ updateDocumentContent(surfaceId, "gamma", "append");
105
+ expect(revisionOf(surfaceId)).toBe(3);
106
+
107
+ updateDocumentContent(surfaceId, "delta", "replace");
108
+ expect(revisionOf(surfaceId)).toBe(4);
109
+
110
+ replaceInDocument(surfaceId, "delta", "epsilon");
111
+ expect(revisionOf(surfaceId)).toBe(5);
112
+ expect(getDocumentById(surfaceId)?.content).toBe("epsilon");
113
+ });
114
+
115
+ test("a replace that matches nothing writes nothing", () => {
116
+ const surfaceId = seed("alpha");
117
+
118
+ const result = replaceInDocument(surfaceId, "missing", "x");
119
+
120
+ expect(result).toMatchObject({
121
+ success: true,
122
+ content_changed: false,
123
+ revision: 1,
124
+ });
125
+ expect(revisionOf(surfaceId)).toBe(1);
126
+ });
127
+
128
+ test("list and read projections carry the revision", () => {
129
+ const surfaceId = seed();
130
+ updateDocumentContent(surfaceId, "two", "append");
131
+
132
+ expect(getDocumentsForConversation(CONVERSATION_ID)[0]?.revision).toBe(2);
133
+ expect(listAllDocuments()[0]?.revision).toBe(2);
134
+ });
135
+ });
136
+
137
+ describe("conditional save", () => {
138
+ test("writes when baseRevision matches", () => {
139
+ const surfaceId = seed("alpha");
140
+
141
+ const result = saveDocument({
142
+ surfaceId,
143
+ conversationId: CONVERSATION_ID,
144
+ title: "Doc",
145
+ content: "beta",
146
+ wordCount: 1,
147
+ baseRevision: 1,
148
+ });
149
+
150
+ expect(result).toEqual({ success: true, surfaceId, revision: 2 });
151
+ expect(getDocumentById(surfaceId)?.content).toBe("beta");
152
+ });
153
+
154
+ test("rejects a stale baseRevision without writing", () => {
155
+ const surfaceId = seed("alpha");
156
+ updateDocumentContent(surfaceId, "assistant edit", "replace");
157
+
158
+ const result = saveDocument({
159
+ surfaceId,
160
+ conversationId: CONVERSATION_ID,
161
+ title: "Stale title",
162
+ content: "stale body",
163
+ wordCount: 2,
164
+ baseRevision: 1,
165
+ });
166
+
167
+ expect(result).toMatchObject({
168
+ success: false,
169
+ conflict: { revision: 2, title: "Doc", content: "assistant edit" },
170
+ });
171
+ expect(getDocumentById(surfaceId)).toMatchObject({
172
+ title: "Doc",
173
+ content: "assistant edit",
174
+ revision: 2,
175
+ });
176
+ });
177
+
178
+ test("creates a document that does not exist yet", () => {
179
+ const result = saveDocument({
180
+ surfaceId: "doc-new",
181
+ conversationId: CONVERSATION_ID,
182
+ title: "New",
183
+ content: "body",
184
+ wordCount: 1,
185
+ baseRevision: 0,
186
+ });
187
+
188
+ expect(result).toEqual({
189
+ success: true,
190
+ surfaceId: "doc-new",
191
+ revision: 1,
192
+ });
193
+ });
194
+ });
195
+
196
+ describe("read-modify-write under interleaving", () => {
197
+ test("a write landing between the read and the write is kept", () => {
198
+ const surfaceId = seed("base");
199
+ let calls = 0;
200
+
201
+ const outcome = mutateDocumentContent(surfaceId, "assistant", (current) => {
202
+ calls++;
203
+ if (calls === 1) {
204
+ // Another writer lands after this attempt read the row.
205
+ saveDocument({
206
+ surfaceId,
207
+ conversationId: CONVERSATION_ID,
208
+ title: "Doc",
209
+ content: "base\n\nuser edit",
210
+ wordCount: 3,
211
+ });
212
+ }
213
+ return { content: `${current.content}\n\nappended`, value: calls };
214
+ });
215
+
216
+ expect(calls).toBe(2);
217
+ expect(outcome).toEqual({
218
+ value: 2,
219
+ content: "base\n\nuser edit\n\nappended",
220
+ revision: 3,
221
+ });
222
+ expect(getDocumentById(surfaceId)?.content).toBe(
223
+ "base\n\nuser edit\n\nappended",
224
+ );
225
+ });
226
+
227
+ test("gives up after repeated losses instead of overwriting", () => {
228
+ const surfaceId = seed("base");
229
+ let calls = 0;
230
+
231
+ expect(() =>
232
+ mutateDocumentContent(surfaceId, "assistant", (current) => {
233
+ calls++;
234
+ saveDocument({
235
+ surfaceId,
236
+ conversationId: CONVERSATION_ID,
237
+ title: "Doc",
238
+ content: `interloper ${calls}`,
239
+ wordCount: 2,
240
+ });
241
+ return { content: `${current.content} mine`, value: null };
242
+ }),
243
+ ).toThrow(/kept changing/);
244
+ expect(getDocumentById(surfaceId)?.content).toBe(`interloper ${calls}`);
245
+ });
246
+
247
+ test("returns null for a missing document", () => {
248
+ expect(
249
+ mutateDocumentContent("doc-missing", "assistant", () => ({
250
+ value: null,
251
+ })),
252
+ ).toBeNull();
253
+ });
254
+ });
255
+
256
+ describe("POST documents", () => {
257
+ test("without baseRevision the write is unconditional", async () => {
258
+ const surfaceId = seed("alpha");
259
+ updateDocumentContent(surfaceId, "assistant edit", "replace");
260
+
261
+ const result = await post({
262
+ surfaceId,
263
+ conversationId: CONVERSATION_ID,
264
+ title: "Doc",
265
+ content: "legacy client body",
266
+ wordCount: 3,
267
+ });
268
+
269
+ expect(result).toEqual({ success: true, surfaceId, revision: 3 });
270
+ expect(getDocumentById(surfaceId)?.content).toBe("legacy client body");
271
+ });
272
+
273
+ test("a null baseRevision is treated as omitted", async () => {
274
+ const surfaceId = seed("alpha");
275
+ updateDocumentContent(surfaceId, "assistant edit", "replace");
276
+
277
+ await expect(
278
+ post({
279
+ surfaceId,
280
+ conversationId: CONVERSATION_ID,
281
+ title: "Doc",
282
+ content: "body",
283
+ wordCount: 1,
284
+ baseRevision: null,
285
+ }),
286
+ ).resolves.toMatchObject({ success: true, revision: 3 });
287
+ });
288
+
289
+ test("a matching baseRevision saves and returns the new revision", async () => {
290
+ const surfaceId = seed("alpha");
291
+
292
+ await expect(
293
+ post({
294
+ surfaceId,
295
+ conversationId: CONVERSATION_ID,
296
+ title: "Doc",
297
+ content: "beta",
298
+ wordCount: 1,
299
+ baseRevision: 1,
300
+ }),
301
+ ).resolves.toEqual({ success: true, surfaceId, revision: 2 });
302
+ });
303
+
304
+ test("a stale baseRevision is a 409 carrying the current state", async () => {
305
+ const surfaceId = seed("alpha");
306
+ updateDocumentContent(surfaceId, "assistant edit", "replace");
307
+
308
+ const error = await post({
309
+ surfaceId,
310
+ conversationId: CONVERSATION_ID,
311
+ title: "Doc",
312
+ content: "stale",
313
+ wordCount: 1,
314
+ baseRevision: 1,
315
+ }).catch((err: unknown) => err);
316
+
317
+ expect(error).toBeInstanceOf(ConflictError);
318
+ expect((error as ConflictError).statusCode).toBe(409);
319
+ expect((error as ConflictError).details).toEqual({
320
+ revision: 2,
321
+ title: "Doc",
322
+ content: "assistant edit",
323
+ });
324
+ expect(getDocumentById(surfaceId)?.content).toBe("assistant edit");
325
+ });
326
+
327
+ test("rejects a malformed baseRevision", async () => {
328
+ const surfaceId = seed();
329
+ for (const baseRevision of [-1, 1.5, "1"]) {
330
+ await expect(
331
+ post({
332
+ surfaceId,
333
+ conversationId: CONVERSATION_ID,
334
+ title: "Doc",
335
+ content: "x",
336
+ wordCount: 1,
337
+ baseRevision,
338
+ }),
339
+ ).rejects.toThrow(/baseRevision must be a non-negative integer/);
340
+ }
341
+ });
342
+
343
+ test("GET documents/:id includes the revision", async () => {
344
+ const surfaceId = seed();
345
+ expect(
346
+ await route("getDocument").handler({ pathParams: { id: surfaceId } }),
347
+ ).toMatchObject({ surfaceId, revision: 1 });
348
+ });
349
+
350
+ test("list items include the revision", async () => {
351
+ const surfaceId = seed();
352
+ const result = (await route("listDocuments").handler({})) as {
353
+ documents: Array<{ surfaceId: string; revision: number }>;
354
+ };
355
+ expect(result.documents).toEqual([
356
+ expect.objectContaining({ surfaceId, revision: 1 }),
357
+ ]);
358
+ });
359
+ });
360
+
361
+ describe("editor events carry the revision", () => {
362
+ test("document_create shows the editor at revision 1", () => {
363
+ const sent: SentMessage[] = [];
364
+ executeDocumentCreate(
365
+ { title: "Plan", initial_content: "hello" },
366
+ makeContext(sent),
367
+ );
368
+
369
+ expect(sent.find((m) => m.type === "document_editor_show")).toMatchObject({
370
+ initialContent: "hello",
371
+ revision: 1,
372
+ });
373
+ });
374
+
375
+ test("document_open shows the stored revision", () => {
376
+ const surfaceId = seed();
377
+ updateDocumentContent(surfaceId, "more", "append");
378
+ const sent: SentMessage[] = [];
379
+
380
+ executeDocumentOpen({ surface_id: surfaceId }, makeContext(sent));
381
+
382
+ expect(sent.find((m) => m.type === "document_editor_show")).toMatchObject({
383
+ surfaceId,
384
+ revision: 2,
385
+ });
386
+ });
387
+
388
+ test("document_update sends the revision after the write", () => {
389
+ const surfaceId = seed();
390
+ const sent: SentMessage[] = [];
391
+
392
+ executeDocumentUpdate(
393
+ { surface_id: surfaceId, content: "two", mode: "append" },
394
+ makeContext(sent),
395
+ );
396
+
397
+ expect(sent).toEqual([
398
+ expect.objectContaining({
399
+ type: "document_editor_update",
400
+ markdown: "two",
401
+ mode: "append",
402
+ revision: 2,
403
+ }),
404
+ ]);
405
+ });
406
+
407
+ test("document_replace_text sends the written body and its revision", () => {
408
+ const surfaceId = seed("hello world");
409
+ const sent: SentMessage[] = [];
410
+
411
+ executeDocumentReplaceText(
412
+ { surface_id: surfaceId, find: "world", replace: "there" },
413
+ makeContext(sent),
414
+ );
415
+
416
+ expect(sent).toEqual([
417
+ expect.objectContaining({
418
+ type: "document_editor_update",
419
+ markdown: "hello there",
420
+ mode: "replace",
421
+ revision: 2,
422
+ }),
423
+ ]);
424
+ });
425
+ });
@@ -33,6 +33,7 @@ function bootstrapDocumentTables(): void {
33
33
  const raw = getSqlite();
34
34
  raw.exec(/*sql*/ `
35
35
  DROP TABLE IF EXISTS document_conversations;
36
+ DROP TABLE IF EXISTS document_revisions;
36
37
  DROP TABLE IF EXISTS documents;
37
38
  DROP TABLE IF EXISTS conversations;
38
39
 
@@ -49,9 +50,21 @@ function bootstrapDocumentTables(): void {
49
50
  word_count INTEGER NOT NULL DEFAULT 0,
50
51
  created_at INTEGER NOT NULL,
51
52
  updated_at INTEGER NOT NULL,
53
+ revision INTEGER NOT NULL DEFAULT 0,
52
54
  workspace_path TEXT
53
55
  );
54
56
 
57
+ CREATE TABLE document_revisions (
58
+ surface_id TEXT NOT NULL REFERENCES documents(surface_id) ON DELETE CASCADE,
59
+ revision INTEGER NOT NULL,
60
+ title TEXT NOT NULL,
61
+ content TEXT NOT NULL,
62
+ word_count INTEGER NOT NULL DEFAULT 0,
63
+ author TEXT NOT NULL,
64
+ created_at INTEGER NOT NULL,
65
+ PRIMARY KEY (surface_id, revision)
66
+ );
67
+
55
68
  CREATE TABLE document_conversations (
56
69
  surface_id TEXT NOT NULL,
57
70
  conversation_id TEXT NOT NULL,
@@ -26,6 +26,7 @@ function bootstrapDocumentTables(): void {
26
26
  const raw = getSqlite();
27
27
  raw.exec(/*sql*/ `
28
28
  DROP TABLE IF EXISTS document_conversations;
29
+ DROP TABLE IF EXISTS document_revisions;
29
30
  DROP TABLE IF EXISTS documents;
30
31
  DROP TABLE IF EXISTS conversations;
31
32
 
@@ -42,9 +43,21 @@ function bootstrapDocumentTables(): void {
42
43
  word_count INTEGER NOT NULL DEFAULT 0,
43
44
  created_at INTEGER NOT NULL,
44
45
  updated_at INTEGER NOT NULL,
46
+ revision INTEGER NOT NULL DEFAULT 0,
45
47
  workspace_path TEXT
46
48
  );
47
49
 
50
+ CREATE TABLE document_revisions (
51
+ surface_id TEXT NOT NULL REFERENCES documents(surface_id) ON DELETE CASCADE,
52
+ revision INTEGER NOT NULL,
53
+ title TEXT NOT NULL,
54
+ content TEXT NOT NULL,
55
+ word_count INTEGER NOT NULL DEFAULT 0,
56
+ author TEXT NOT NULL,
57
+ created_at INTEGER NOT NULL,
58
+ PRIMARY KEY (surface_id, revision)
59
+ );
60
+
48
61
  CREATE TABLE document_conversations (
49
62
  surface_id TEXT NOT NULL,
50
63
  conversation_id TEXT NOT NULL,