@vellumai/assistant 0.11.1 → 0.11.2-staging.2

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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -15,6 +15,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
15
15
 
16
16
  import { CompactionCircuit } from "../agent/compaction-circuit.js";
17
17
  import type { AgentEvent } from "../agent/loop.js";
18
+ import type { AssistantEvent } from "../api/index.js";
18
19
  import type { CompactionContext } from "../plugins/defaults/compaction/compact.js";
19
20
  import type { ContextWindowResult } from "../plugins/defaults/compaction/window-manager.js";
20
21
  import type { Message, ProviderResponse } from "../providers/types.js";
@@ -212,7 +213,8 @@ import { INTERNAL_GUARDIAN_TRUST_CONTEXT } from "../daemon/trust-context.js";
212
213
  // Helpers
213
214
  // ---------------------------------------------------------------------------
214
215
 
215
- function makeProvider() {
216
+ function makeProvider(counts?: number[]) {
217
+ const pending = counts ? [...counts] : null;
216
218
  return {
217
219
  name: "mock-provider",
218
220
  async sendMessage(): Promise<ProviderResponse> {
@@ -223,15 +225,21 @@ function makeProvider() {
223
225
  stopReason: "end_turn",
224
226
  };
225
227
  },
228
+ // Present only when the test supplies counts, so the other cases keep
229
+ // exercising the local-estimate fallback.
230
+ ...(pending ? { countInputTokens: async () => pending.shift() ?? 0 } : {}),
226
231
  };
227
232
  }
228
233
 
229
- function makeConversation(): Conversation {
234
+ function makeConversation(options?: {
235
+ counts?: number[];
236
+ onEvent?: (msg: AssistantEvent) => void;
237
+ }): Conversation {
230
238
  const conversation = new Conversation(
231
239
  "conv-1",
232
- makeProvider(),
240
+ makeProvider(options?.counts),
233
241
  "system prompt",
234
- () => {},
242
+ options?.onEvent ?? (() => {}),
235
243
  "/tmp",
236
244
  { maxTokens: 4096 },
237
245
  );
@@ -365,7 +373,10 @@ describe("Conversation.summarizeUpToMessage", () => {
365
373
 
366
374
  const result = await conversation.summarizeUpToMessage("m4");
367
375
 
368
- expect(result).toBe(mockCompactResult);
376
+ // The compaction result passes through with its displayed token counts
377
+ // re-measured by the provider tokenizer, which the mocked manager reports
378
+ // as 0 here.
379
+ expect(result).toEqual(mockCompactResult);
369
380
  expect(compactCalls).toHaveLength(1);
370
381
  expect(compactCalls[0].force).toBe(true);
371
382
  // Row index 4 with no compacted prefix and no summary message → 4.
@@ -853,6 +864,61 @@ describe("Conversation.summarizeUpToMessage", () => {
853
864
  expect(recordOutcome).toHaveBeenCalledTimes(1);
854
865
  expect(recordOutcome).toHaveBeenCalledWith(true, expect.anything());
855
866
  });
867
+
868
+ test("reports tokenizer counts and pushes them to the caller's sink", async () => {
869
+ // The management route resolves its conversation outside the send path, so
870
+ // the instance keeps the store's no-op sender: the push has to ride the
871
+ // sink the route hands in, not `sendToClient`.
872
+ const collected: AssistantEvent[] = [];
873
+ const conversation = makeConversation({ counts: [56_000, 18_000] });
874
+ mockCompactResult = {
875
+ ...makeNoopResult(),
876
+ compacted: true,
877
+ // Estimate-based figures the pipeline records internally; the card and
878
+ // the indicator both report the tokenizer counts instead.
879
+ previousEstimatedInputTokens: 43_000,
880
+ estimatedInputTokens: 12_000,
881
+ maxInputTokens: 200_000,
882
+ compactedPersistedMessages: 4,
883
+ };
884
+
885
+ const result = await conversation.summarizeUpToMessage("m4", (msg) =>
886
+ collected.push(msg),
887
+ );
888
+
889
+ expect(result.previousEstimatedInputTokens).toBe(56_000);
890
+ expect(result.estimatedInputTokens).toBe(18_000);
891
+
892
+ const usage = collected.filter((m) => m.type === "context_window_usage");
893
+ expect(usage).toHaveLength(1);
894
+ const usageEvent = usage[0] as Extract<
895
+ AssistantEvent,
896
+ { type: "context_window_usage" }
897
+ >;
898
+ expect(usageEvent.conversationId).toBe("conv-1");
899
+ expect(usageEvent.tokens).toBe(result.estimatedInputTokens);
900
+ expect(usageEvent.maxTokens).toBe(200_000);
901
+ });
902
+
903
+ test("falls back to the conversation's own sender when no sink is passed", async () => {
904
+ const collected: AssistantEvent[] = [];
905
+ const conversation = makeConversation({
906
+ counts: [56_000, 18_000],
907
+ onEvent: (msg) => collected.push(msg),
908
+ });
909
+ mockCompactResult = {
910
+ ...makeNoopResult(),
911
+ compacted: true,
912
+ maxInputTokens: 200_000,
913
+ compactedPersistedMessages: 4,
914
+ };
915
+
916
+ await conversation.summarizeUpToMessage("m4");
917
+
918
+ expect(
919
+ collected.filter((m) => m.type === "context_window_usage"),
920
+ ).toHaveLength(1);
921
+ });
856
922
  });
857
923
 
858
924
  describe("formatSummarizeUpToResult", () => {
@@ -44,7 +44,8 @@ function bootstrapDocumentTables(): void {
44
44
  content TEXT NOT NULL,
45
45
  word_count INTEGER NOT NULL DEFAULT 0,
46
46
  created_at INTEGER NOT NULL,
47
- updated_at INTEGER NOT NULL
47
+ updated_at INTEGER NOT NULL,
48
+ workspace_path TEXT
48
49
  );
49
50
 
50
51
  CREATE TABLE document_conversations (
@@ -43,7 +43,8 @@ function bootstrapDocumentTables(): void {
43
43
  content TEXT NOT NULL,
44
44
  word_count INTEGER NOT NULL DEFAULT 0,
45
45
  created_at INTEGER NOT NULL,
46
- updated_at INTEGER NOT NULL
46
+ updated_at INTEGER NOT NULL,
47
+ workspace_path TEXT
47
48
  );
48
49
 
49
50
  CREATE TABLE document_conversations (
@@ -48,7 +48,8 @@ function bootstrapDocumentTables(): void {
48
48
  content TEXT NOT NULL,
49
49
  word_count INTEGER NOT NULL DEFAULT 0,
50
50
  created_at INTEGER NOT NULL,
51
- updated_at INTEGER NOT NULL
51
+ updated_at INTEGER NOT NULL,
52
+ workspace_path TEXT
52
53
  );
53
54
 
54
55
  CREATE TABLE document_conversations (
@@ -41,7 +41,8 @@ function bootstrapDocumentTables(): void {
41
41
  content TEXT NOT NULL,
42
42
  word_count INTEGER NOT NULL DEFAULT 0,
43
43
  created_at INTEGER NOT NULL,
44
- updated_at INTEGER NOT NULL
44
+ updated_at INTEGER NOT NULL,
45
+ workspace_path TEXT
45
46
  );
46
47
 
47
48
  CREATE TABLE document_conversations (
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Tests for file-backed documents: the find-or-create route that gives a
3
+ * workspace markdown file a document identity, and the write-through that
4
+ * keeps the file in step with every document content write.
5
+ */
6
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { beforeEach, describe, expect, test } from "bun:test";
9
+
10
+ import {
11
+ createFileBackedDocument,
12
+ getDocumentById,
13
+ getDocumentByWorkspacePath,
14
+ rebindDocumentsToRenamedPath,
15
+ } from "../documents/document-store.js";
16
+ import { getDb, getSqlite } from "../persistence/db-connection.js";
17
+ import { migrateAddDocumentWorkspacePath } from "../persistence/migrations/360-add-document-workspace-path.js";
18
+ import { ROUTES as DOCUMENT_ROUTES } from "../runtime/routes/documents-routes.js";
19
+ import {
20
+ BadRequestError,
21
+ NotFoundError,
22
+ UnprocessableEntityError,
23
+ } from "../runtime/routes/errors.js";
24
+ import type { RouteDefinition } from "../runtime/routes/types.js";
25
+ import { ROUTES as WORKSPACE_ROUTES } from "../runtime/routes/workspace-routes.js";
26
+ import {
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
+ const workspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
34
+ const notesDir = join(workspaceDir, "notes");
35
+
36
+ const CONVERSATION_ID = "conv-file-docs";
37
+
38
+ interface DocumentPayload {
39
+ success: boolean;
40
+ surfaceId: string;
41
+ title: string;
42
+ content: string;
43
+ wordCount: number;
44
+ workspacePath: string | null;
45
+ }
46
+
47
+ function getRoute(operationId: string): RouteDefinition {
48
+ const route = DOCUMENT_ROUTES.find((r) => r.operationId === operationId);
49
+ if (!route) {
50
+ throw new Error(`No document route found for operationId: ${operationId}`);
51
+ }
52
+ return route;
53
+ }
54
+
55
+ /**
56
+ * Open a workspace file as a document. Returns the route payload so callers
57
+ * can assert on the same shape `GET documents/{id}` serves.
58
+ */
59
+ async function openWorkspaceFile(
60
+ path: string,
61
+ conversationId = CONVERSATION_ID,
62
+ ): Promise<DocumentPayload> {
63
+ const route = getRoute("documentForWorkspaceFile");
64
+ const result = await route.handler({ body: { path, conversationId } });
65
+ return result as DocumentPayload;
66
+ }
67
+
68
+ /** Save a document through the client's document save route. */
69
+ async function saveViaRoute(
70
+ surfaceId: string,
71
+ title: string,
72
+ content: string,
73
+ ): Promise<void> {
74
+ const route = getRoute("saveDocument");
75
+ await route.handler({
76
+ body: {
77
+ surfaceId,
78
+ conversationId: CONVERSATION_ID,
79
+ title,
80
+ content,
81
+ wordCount: content.split(/\s+/).filter((w) => w.length > 0).length,
82
+ },
83
+ });
84
+ }
85
+
86
+ function makeToolContext(): ToolContext {
87
+ return {
88
+ workingDir: workspaceDir,
89
+ conversationId: CONVERSATION_ID,
90
+ trustClass: "guardian",
91
+ executionChannel: "web",
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Rebuild the document tables at the pre-360 shape, then apply the migration —
97
+ * the same order a real upgrade takes, so these tests exercise the migrated
98
+ * schema rather than a hand-written copy of it.
99
+ */
100
+ function bootstrapDocumentTables(): void {
101
+ resetDbForTesting();
102
+ const raw = getSqlite();
103
+ raw.exec(/*sql*/ `
104
+ DROP TABLE IF EXISTS document_conversations;
105
+ DROP TABLE IF EXISTS documents;
106
+ DROP TABLE IF EXISTS conversations;
107
+
108
+ CREATE TABLE conversations (
109
+ id TEXT PRIMARY KEY,
110
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
111
+ );
112
+
113
+ CREATE TABLE documents (
114
+ surface_id TEXT PRIMARY KEY,
115
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
116
+ title TEXT NOT NULL,
117
+ content TEXT NOT NULL,
118
+ word_count INTEGER NOT NULL DEFAULT 0,
119
+ created_at INTEGER NOT NULL,
120
+ updated_at INTEGER NOT NULL
121
+ );
122
+
123
+ CREATE TABLE document_conversations (
124
+ surface_id TEXT NOT NULL,
125
+ conversation_id TEXT NOT NULL,
126
+ created_at INTEGER NOT NULL,
127
+ PRIMARY KEY (surface_id, conversation_id),
128
+ FOREIGN KEY (surface_id) REFERENCES documents(surface_id) ON DELETE CASCADE
129
+ );
130
+ `);
131
+ migrateAddDocumentWorkspacePath(getDb());
132
+ raw
133
+ .query(`INSERT OR IGNORE INTO conversations (id, created_at) VALUES (?, ?)`)
134
+ .run(CONVERSATION_ID, Date.now());
135
+ raw
136
+ .query(`INSERT OR IGNORE INTO conversations (id, created_at) VALUES (?, ?)`)
137
+ .run("conv-other", Date.now());
138
+ }
139
+
140
+ beforeEach(() => {
141
+ bootstrapDocumentTables();
142
+ rmSync(notesDir, { recursive: true, force: true });
143
+ mkdirSync(notesDir, { recursive: true });
144
+ });
145
+
146
+ describe("POST documents/for-workspace-file", () => {
147
+ test("creates a document seeded from the file, then returns the same one", async () => {
148
+ writeFileSync(join(notesDir, "plan.md"), "# Plan\n\nFirst draft.");
149
+
150
+ const first = await openWorkspaceFile("notes/plan.md");
151
+ expect(first.success).toBe(true);
152
+ expect(first.title).toBe("plan.md");
153
+ expect(first.content).toBe("# Plan\n\nFirst draft.");
154
+ expect(first.workspacePath).toBe("notes/plan.md");
155
+
156
+ const second = await openWorkspaceFile("notes/plan.md");
157
+ expect(second.surfaceId).toBe(first.surfaceId);
158
+
159
+ // Non-canonical spellings of the same path resolve to the same document.
160
+ const third = await openWorkspaceFile("./notes/../notes/plan.md");
161
+ expect(third.surfaceId).toBe(first.surfaceId);
162
+ });
163
+
164
+ test("refreshes stored content from disk when the file changed underneath", async () => {
165
+ const filePath = join(notesDir, "plan.md");
166
+ writeFileSync(filePath, "original body");
167
+
168
+ const first = await openWorkspaceFile("notes/plan.md");
169
+ expect(first.content).toBe("original body");
170
+
171
+ writeFileSync(filePath, "edited outside the editor");
172
+
173
+ const reopened = await openWorkspaceFile("notes/plan.md");
174
+ expect(reopened.surfaceId).toBe(first.surfaceId);
175
+ expect(reopened.content).toBe("edited outside the editor");
176
+ expect(reopened.wordCount).toBe(4);
177
+ expect(getDocumentById(first.surfaceId)?.content).toBe(
178
+ "edited outside the editor",
179
+ );
180
+ });
181
+
182
+ test("associates the document with every conversation that opens it", async () => {
183
+ writeFileSync(join(notesDir, "plan.md"), "shared");
184
+
185
+ const first = await openWorkspaceFile("notes/plan.md");
186
+ await openWorkspaceFile("notes/plan.md", "conv-other");
187
+
188
+ const rows = getSqlite()
189
+ .query(
190
+ `SELECT conversation_id FROM document_conversations WHERE surface_id = ? ORDER BY conversation_id`,
191
+ )
192
+ .all(first.surfaceId) as Array<{ conversation_id: string }>;
193
+ expect(rows.map((r) => r.conversation_id)).toEqual([
194
+ CONVERSATION_ID,
195
+ "conv-other",
196
+ ]);
197
+ });
198
+
199
+ test("rejects paths that escape the workspace", async () => {
200
+ await expect(openWorkspaceFile("../escape.md")).rejects.toBeInstanceOf(
201
+ BadRequestError,
202
+ );
203
+ await expect(openWorkspaceFile("/etc/passwd.md")).rejects.toBeInstanceOf(
204
+ BadRequestError,
205
+ );
206
+ await expect(
207
+ openWorkspaceFile("notes/../../escape.md"),
208
+ ).rejects.toBeInstanceOf(BadRequestError);
209
+ });
210
+
211
+ test("rejects non-markdown files", async () => {
212
+ writeFileSync(join(notesDir, "data.txt"), "plain text");
213
+
214
+ await expect(openWorkspaceFile("notes/data.txt")).rejects.toBeInstanceOf(
215
+ UnprocessableEntityError,
216
+ );
217
+ });
218
+
219
+ test("requires path and conversationId", () => {
220
+ const route = getRoute("documentForWorkspaceFile");
221
+ expect(() => route.handler({ body: {} })).toThrow(BadRequestError);
222
+ expect(() => route.handler({ body: { path: "notes/plan.md" } })).toThrow(
223
+ BadRequestError,
224
+ );
225
+ });
226
+
227
+ test("404s for a file that was never there", async () => {
228
+ await expect(openWorkspaceFile("notes/missing.md")).rejects.toBeInstanceOf(
229
+ NotFoundError,
230
+ );
231
+ });
232
+
233
+ test("404s distinctly for a bound document whose file was deleted", async () => {
234
+ const filePath = join(notesDir, "plan.md");
235
+ writeFileSync(filePath, "will be deleted");
236
+ const created = await openWorkspaceFile("notes/plan.md");
237
+ rmSync(filePath);
238
+
239
+ await expect(openWorkspaceFile("notes/plan.md")).rejects.toThrow(
240
+ /file backing this document no longer exists/,
241
+ );
242
+
243
+ // The document row survives; the file is never resurrected from it.
244
+ expect(getDocumentById(created.surfaceId)?.content).toBe("will be deleted");
245
+ expect(() => readFileSync(filePath, "utf-8")).toThrow();
246
+ });
247
+ });
248
+
249
+ describe("write-through to the backing file", () => {
250
+ test("the client's document save rewrites the file", async () => {
251
+ const filePath = join(notesDir, "plan.md");
252
+ writeFileSync(filePath, "original body");
253
+ const doc = await openWorkspaceFile("notes/plan.md");
254
+
255
+ await saveViaRoute(doc.surfaceId, "plan.md", "saved from the editor");
256
+
257
+ expect(readFileSync(filePath, "utf-8")).toBe("saved from the editor");
258
+ expect(getDocumentById(doc.surfaceId)?.content).toBe(
259
+ "saved from the editor",
260
+ );
261
+ });
262
+
263
+ test("a document_update tool edit rewrites the file", async () => {
264
+ const filePath = join(notesDir, "plan.md");
265
+ writeFileSync(filePath, "original body");
266
+ const doc = await openWorkspaceFile("notes/plan.md");
267
+
268
+ const result = executeDocumentUpdate(
269
+ {
270
+ surface_id: doc.surfaceId,
271
+ content: "appended by the assistant",
272
+ mode: "append",
273
+ },
274
+ { ...makeToolContext(), sendToClient: () => {} },
275
+ );
276
+ expect(result.isError).toBe(false);
277
+
278
+ const expected = "original body\n\nappended by the assistant";
279
+ expect(readFileSync(filePath, "utf-8")).toBe(expected);
280
+ expect(getDocumentById(doc.surfaceId)?.content).toBe(expected);
281
+ });
282
+
283
+ test("a document_replace_text tool edit rewrites the file", async () => {
284
+ const filePath = join(notesDir, "plan.md");
285
+ writeFileSync(filePath, "hello world");
286
+ const doc = await openWorkspaceFile("notes/plan.md");
287
+
288
+ const result = executeDocumentReplaceText(
289
+ { surface_id: doc.surfaceId, find: "world", replace: "there" },
290
+ { ...makeToolContext(), sendToClient: () => {} },
291
+ );
292
+ expect(result.isError).toBe(false);
293
+
294
+ expect(readFileSync(filePath, "utf-8")).toBe("hello there");
295
+ expect(getDocumentById(doc.surfaceId)?.content).toBe("hello there");
296
+ });
297
+
298
+ test("a failed file write fails the update instead of diverging", async () => {
299
+ const filePath = join(notesDir, "plan.md");
300
+ writeFileSync(filePath, "original body");
301
+ const doc = await openWorkspaceFile("notes/plan.md");
302
+
303
+ // Replace the file with a directory: the write primitive refuses it, so
304
+ // the row must stay on the content that still matches what is on disk.
305
+ rmSync(filePath);
306
+ mkdirSync(filePath);
307
+
308
+ const result = executeDocumentUpdate(
309
+ { surface_id: doc.surfaceId, content: "never lands", mode: "replace" },
310
+ { ...makeToolContext(), sendToClient: () => {} },
311
+ );
312
+
313
+ expect(result.isError).toBe(true);
314
+ expect(getDocumentById(doc.surfaceId)?.content).toBe("original body");
315
+ });
316
+
317
+ test("documents with no backing file are untouched by the write-through", async () => {
318
+ const route = getRoute("saveDocument");
319
+ await route.handler({
320
+ body: {
321
+ surfaceId: "doc-plain",
322
+ conversationId: CONVERSATION_ID,
323
+ title: "Plain",
324
+ content: "db only",
325
+ wordCount: 2,
326
+ },
327
+ });
328
+
329
+ const stored = getDocumentById("doc-plain");
330
+ expect(stored?.content).toBe("db only");
331
+ expect(stored?.workspacePath).toBeNull();
332
+ });
333
+ });
334
+
335
+ // ===========================================================================
336
+ // Rename rebinding
337
+ // ===========================================================================
338
+
339
+ /** Bind a surface ID to a workspace-relative path with throwaway content. */
340
+ function bindDocument(surfaceId: string, workspacePath: string): void {
341
+ const created = createFileBackedDocument({
342
+ surfaceId,
343
+ conversationId: CONVERSATION_ID,
344
+ title: workspacePath,
345
+ content: "body",
346
+ workspacePath,
347
+ });
348
+ expect(created.success).toBe(true);
349
+ }
350
+
351
+ describe("rebindDocumentsToRenamedPath", () => {
352
+ test("moves the document bound to a renamed file", () => {
353
+ bindDocument("doc-renamed", "notes/plan.md");
354
+
355
+ rebindDocumentsToRenamedPath({
356
+ oldPath: "notes/plan.md",
357
+ newPath: "notes/roadmap.md",
358
+ });
359
+
360
+ expect(getDocumentById("doc-renamed")?.workspacePath).toBe(
361
+ "notes/roadmap.md",
362
+ );
363
+ expect(getDocumentByWorkspacePath("notes/plan.md")).toBeNull();
364
+ expect(getDocumentByWorkspacePath("notes/roadmap.md")?.surfaceId).toBe(
365
+ "doc-renamed",
366
+ );
367
+ });
368
+
369
+ test("moves documents nested under a renamed directory", () => {
370
+ bindDocument("doc-top", "notes/plan.md");
371
+ bindDocument("doc-nested", "notes/deep/spec.md");
372
+
373
+ rebindDocumentsToRenamedPath({ oldPath: "notes", newPath: "archive" });
374
+
375
+ expect(getDocumentById("doc-top")?.workspacePath).toBe("archive/plan.md");
376
+ expect(getDocumentById("doc-nested")?.workspacePath).toBe(
377
+ "archive/deep/spec.md",
378
+ );
379
+ });
380
+
381
+ test("leaves documents outside the renamed subtree alone", () => {
382
+ bindDocument("doc-moved", "notes/plan.md");
383
+ bindDocument("doc-sibling", "notes-archive/plan.md");
384
+ bindDocument("doc-unrelated", "other/plan.md");
385
+
386
+ rebindDocumentsToRenamedPath({ oldPath: "notes", newPath: "archive" });
387
+
388
+ expect(getDocumentById("doc-moved")?.workspacePath).toBe("archive/plan.md");
389
+ expect(getDocumentById("doc-sibling")?.workspacePath).toBe(
390
+ "notes-archive/plan.md",
391
+ );
392
+ expect(getDocumentById("doc-unrelated")?.workspacePath).toBe(
393
+ "other/plan.md",
394
+ );
395
+ });
396
+
397
+ test("keeps both bindings when the destination is already bound", () => {
398
+ bindDocument("doc-source", "notes/plan.md");
399
+ bindDocument("doc-destination", "notes/roadmap.md");
400
+
401
+ expect(() =>
402
+ rebindDocumentsToRenamedPath({
403
+ oldPath: "notes/plan.md",
404
+ newPath: "notes/roadmap.md",
405
+ }),
406
+ ).not.toThrow();
407
+
408
+ expect(getDocumentById("doc-source")?.workspacePath).toBe("notes/plan.md");
409
+ expect(getDocumentById("doc-destination")?.workspacePath).toBe(
410
+ "notes/roadmap.md",
411
+ );
412
+ });
413
+
414
+ test("one blocked row does not strand the rest of a directory rename", () => {
415
+ bindDocument("doc-blocked", "notes/plan.md");
416
+ bindDocument("doc-occupier", "archive/plan.md");
417
+ bindDocument("doc-free", "notes/spec.md");
418
+
419
+ rebindDocumentsToRenamedPath({ oldPath: "notes", newPath: "archive" });
420
+
421
+ expect(getDocumentById("doc-blocked")?.workspacePath).toBe("notes/plan.md");
422
+ expect(getDocumentById("doc-occupier")?.workspacePath).toBe(
423
+ "archive/plan.md",
424
+ );
425
+ expect(getDocumentById("doc-free")?.workspacePath).toBe("archive/spec.md");
426
+ });
427
+ });
428
+
429
+ describe("POST workspace/rename keeps documents bound", () => {
430
+ const renameRoute = WORKSPACE_ROUTES.find(
431
+ (r) => r.operationId === "workspace_rename",
432
+ )!;
433
+
434
+ test("a renamed file carries its document to the new path", async () => {
435
+ writeFileSync(join(notesDir, "plan.md"), "# Plan");
436
+ const doc = await openWorkspaceFile("notes/plan.md");
437
+ expect(doc.workspacePath).toBe("notes/plan.md");
438
+
439
+ await renameRoute.handler({
440
+ body: { oldPath: "notes/plan.md", newPath: "notes/roadmap.md" },
441
+ });
442
+
443
+ expect(getDocumentById(doc.surfaceId)?.workspacePath).toBe(
444
+ "notes/roadmap.md",
445
+ );
446
+
447
+ // Reopening the file at its new name yields the same document identity,
448
+ // so comments and history survive the rename.
449
+ const reopened = await openWorkspaceFile("notes/roadmap.md");
450
+ expect(reopened.surfaceId).toBe(doc.surfaceId);
451
+ });
452
+
453
+ test("a renamed directory carries the documents beneath it", async () => {
454
+ writeFileSync(join(notesDir, "plan.md"), "# Plan");
455
+ const doc = await openWorkspaceFile("notes/plan.md");
456
+
457
+ await renameRoute.handler({
458
+ body: { oldPath: "notes", newPath: "archive" },
459
+ });
460
+
461
+ expect(getDocumentById(doc.surfaceId)?.workspacePath).toBe(
462
+ "archive/plan.md",
463
+ );
464
+
465
+ rmSync(join(workspaceDir, "archive"), { recursive: true, force: true });
466
+ });
467
+ });