@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
@@ -7,6 +7,7 @@ const runDeterministicChecksMock = mock();
7
7
  const createEventMock = mock();
8
8
  const updateEventDedupeKeyMock = mock();
9
9
  const dispatchDecisionMock = mock();
10
+ const isPlatformClientConfiguredMock = mock();
10
11
 
11
12
  mock.module("../channels/config.js", () => ({
12
13
  getDeliverableChannels: () => ["vellum", "telegram"],
@@ -38,6 +39,12 @@ mock.module("../notifications/broadcaster.js", () => ({
38
39
  },
39
40
  }));
40
41
 
42
+ // Capture the real enforcement before the module mock below replaces it:
43
+ // the single_channel interplay tests exercise the genuine cap logic against
44
+ // the urgency force-add's channel ordering.
45
+ const { enforceRoutingIntent: realEnforceRoutingIntent } =
46
+ await import("../notifications/decision-engine.js");
47
+
41
48
  mock.module("../notifications/decision-engine.js", () => ({
42
49
  evaluateSignal: (...args: unknown[]) => evaluateSignalMock(...args),
43
50
  enforceRoutingIntent: (...args: unknown[]) =>
@@ -78,27 +85,41 @@ mock.module("../notifications/runtime-dispatch.js", () => ({
78
85
  dispatchDecision: (...args: unknown[]) => dispatchDecisionMock(...args),
79
86
  }));
80
87
 
88
+ mock.module("../platform/client.js", () => ({
89
+ // The adapter import graph needs the class symbol; nothing in these tests
90
+ // dispatches through it.
91
+ VellumPlatformClient: class {
92
+ static async create(): Promise<null> {
93
+ return null;
94
+ }
95
+ },
96
+ isPlatformClientConfigured: () => isPlatformClientConfiguredMock(),
97
+ }));
98
+
81
99
  import { emitNotificationSignal } from "../notifications/emit-signal.js";
82
100
 
83
- describe("emitNotificationSignal routing intent re-persistence", () => {
84
- beforeEach(() => {
85
- evaluateSignalMock.mockReset();
86
- enforceRoutingIntentMock.mockReset();
87
- updateDecisionMock.mockReset();
88
- runDeterministicChecksMock.mockReset();
89
- createEventMock.mockReset();
90
- updateEventDedupeKeyMock.mockReset();
91
- dispatchDecisionMock.mockReset();
92
-
93
- createEventMock.mockReturnValue({ id: "evt-1" });
94
- runDeterministicChecksMock.mockResolvedValue({ passed: true });
95
- dispatchDecisionMock.mockResolvedValue({
96
- dispatched: true,
97
- reason: "ok",
98
- deliveryResults: [],
99
- });
101
+ beforeEach(() => {
102
+ evaluateSignalMock.mockReset();
103
+ enforceRoutingIntentMock.mockReset();
104
+ updateDecisionMock.mockReset();
105
+ runDeterministicChecksMock.mockReset();
106
+ createEventMock.mockReset();
107
+ updateEventDedupeKeyMock.mockReset();
108
+ dispatchDecisionMock.mockReset();
109
+ isPlatformClientConfiguredMock.mockReset();
110
+
111
+ createEventMock.mockReturnValue({ id: "evt-1" });
112
+ runDeterministicChecksMock.mockResolvedValue({ passed: true });
113
+ dispatchDecisionMock.mockResolvedValue({
114
+ dispatched: true,
115
+ reason: "ok",
116
+ deliveryResults: [],
100
117
  });
118
+ isPlatformClientConfiguredMock.mockResolvedValue(true);
119
+ enforceRoutingIntentMock.mockImplementation((decision: unknown) => decision);
120
+ });
101
121
 
122
+ describe("emitNotificationSignal routing intent re-persistence", () => {
102
123
  test("re-persists selectedChannels/reasoningSummary when enforcement changes the decision", async () => {
103
124
  const preDecision = {
104
125
  shouldNotify: true,
@@ -226,21 +247,6 @@ describe("emitNotificationSignal routing intent re-persistence", () => {
226
247
  });
227
248
 
228
249
  describe("emitNotificationSignal source-active pre-gate", () => {
229
- beforeEach(() => {
230
- evaluateSignalMock.mockReset();
231
- runDeterministicChecksMock.mockReset();
232
- createEventMock.mockReset();
233
- dispatchDecisionMock.mockReset();
234
-
235
- createEventMock.mockReturnValue({ id: "evt-src-active" });
236
- runDeterministicChecksMock.mockResolvedValue({ passed: true });
237
- dispatchDecisionMock.mockResolvedValue({
238
- dispatched: true,
239
- reason: "ok",
240
- deliveryResults: [],
241
- });
242
- });
243
-
244
250
  test("suppresses visibleInSourceNow signals before the decision engine runs", async () => {
245
251
  const result = await emitNotificationSignal({
246
252
  sourceEventName: "ingress.trusted_contact.verification_sent",
@@ -294,23 +300,6 @@ describe("emitNotificationSignal source-active pre-gate", () => {
294
300
  });
295
301
 
296
302
  describe("access-request vellum floor", () => {
297
- beforeEach(() => {
298
- evaluateSignalMock.mockReset();
299
- enforceRoutingIntentMock.mockReset();
300
- updateDecisionMock.mockReset();
301
- runDeterministicChecksMock.mockReset();
302
- createEventMock.mockReset();
303
- updateEventDedupeKeyMock.mockReset();
304
- dispatchDecisionMock.mockReset();
305
- createEventMock.mockReturnValue({ id: "evt-1" });
306
- runDeterministicChecksMock.mockResolvedValue({ passed: true });
307
- dispatchDecisionMock.mockResolvedValue({
308
- dispatched: true,
309
- reason: "ok",
310
- deliveryResults: [],
311
- });
312
- });
313
-
314
303
  test("rescues a suppressed access-request decision onto the vellum channel", async () => {
315
304
  evaluateSignalMock.mockResolvedValue({
316
305
  shouldNotify: false,
@@ -467,3 +456,255 @@ describe("access-request vellum floor", () => {
467
456
  expect(dispatched.reasoningSummary).not.toContain("vellum forced");
468
457
  });
469
458
  });
459
+
460
+ describe("high/critical urgency channel force", () => {
461
+ function makeDecision(overrides: Record<string, unknown>) {
462
+ return {
463
+ shouldNotify: true,
464
+ selectedChannels: ["telegram"],
465
+ reasoningSummary: "LLM selected telegram only",
466
+ renderedCopy: {},
467
+ dedupeKey: "dedupe-urg-1",
468
+ confidence: 0.9,
469
+ fallbackUsed: false,
470
+ persistedDecisionId: "dec-urg-1",
471
+ ...overrides,
472
+ };
473
+ }
474
+
475
+ function emitWithUrgency(urgency: "low" | "medium" | "high" | "critical") {
476
+ return emitNotificationSignal({
477
+ sourceEventName: "schedule.notify",
478
+ sourceChannel: "scheduler",
479
+ sourceContextId: "rem-urg-1",
480
+ attentionHints: {
481
+ requiresAction: true,
482
+ urgency,
483
+ isAsyncBackground: false,
484
+ visibleInSourceNow: false,
485
+ },
486
+ contextPayload: { reminderId: "rem-urg-1" },
487
+ });
488
+ }
489
+
490
+ test("forces vellum and platform onto a high-urgency decision missing both", async () => {
491
+ evaluateSignalMock.mockResolvedValue(makeDecision({}));
492
+
493
+ const result = await emitWithUrgency("high");
494
+
495
+ expect(result.dispatched).toBe(true);
496
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
497
+ selectedChannels: string[];
498
+ reasoningSummary: string;
499
+ };
500
+ expect(dispatched.selectedChannels).toContain("vellum");
501
+ expect(dispatched.selectedChannels).toContain("platform");
502
+ expect(dispatched.selectedChannels).toContain("telegram");
503
+ expect(dispatched.reasoningSummary).toContain(
504
+ "(vellum, platform forced: high urgency)",
505
+ );
506
+ });
507
+
508
+ test("re-persists the stored decision with the force-added channels when routing enforcement is a no-op", async () => {
509
+ evaluateSignalMock.mockResolvedValue(makeDecision({}));
510
+
511
+ await emitWithUrgency("high");
512
+
513
+ // The identity enforcement mock leaves the decision unchanged, so only
514
+ // the urgency force-add differs from the evaluated decision. The stored
515
+ // row must still be synced to the dispatched channels.
516
+ expect(updateDecisionMock).toHaveBeenCalledTimes(1);
517
+ expect(updateDecisionMock).toHaveBeenCalledWith("dec-urg-1", {
518
+ selectedChannels: ["vellum", "telegram", "platform"],
519
+ reasoningSummary:
520
+ "LLM selected telegram only (vellum, platform forced: high urgency)",
521
+ validationResults: {
522
+ dedupeKey: "dedupe-urg-1",
523
+ channelCount: 3,
524
+ hasCopy: false,
525
+ },
526
+ });
527
+ });
528
+
529
+ test("forces only the missing channel when the other is already selected", async () => {
530
+ evaluateSignalMock.mockResolvedValue(
531
+ makeDecision({ selectedChannels: ["vellum"] }),
532
+ );
533
+
534
+ await emitWithUrgency("critical");
535
+
536
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
537
+ selectedChannels: string[];
538
+ reasoningSummary: string;
539
+ };
540
+ expect(dispatched.selectedChannels).toEqual(["vellum", "platform"]);
541
+ expect(dispatched.reasoningSummary).toContain(
542
+ "(platform forced: critical urgency)",
543
+ );
544
+ });
545
+
546
+ test("leaves a high-urgency decision untouched when both channels are already selected", async () => {
547
+ evaluateSignalMock.mockResolvedValue(
548
+ makeDecision({ selectedChannels: ["vellum", "platform"] }),
549
+ );
550
+
551
+ await emitWithUrgency("high");
552
+
553
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
554
+ selectedChannels: string[];
555
+ reasoningSummary: string;
556
+ };
557
+ expect(dispatched.selectedChannels).toEqual(["vellum", "platform"]);
558
+ expect(dispatched.reasoningSummary).not.toContain("forced");
559
+ expect(updateDecisionMock).not.toHaveBeenCalled();
560
+ });
561
+
562
+ test("leaves medium and low urgency selections untouched", async () => {
563
+ for (const urgency of ["medium", "low"] as const) {
564
+ dispatchDecisionMock.mockClear();
565
+ evaluateSignalMock.mockResolvedValue(makeDecision({}));
566
+
567
+ await emitWithUrgency(urgency);
568
+
569
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
570
+ selectedChannels: string[];
571
+ reasoningSummary: string;
572
+ };
573
+ expect(dispatched.selectedChannels).toEqual(["telegram"]);
574
+ expect(dispatched.reasoningSummary).not.toContain("forced");
575
+ }
576
+ });
577
+
578
+ test("does not force channels when the decision suppresses the notification", async () => {
579
+ evaluateSignalMock.mockResolvedValue(
580
+ makeDecision({ shouldNotify: false, selectedChannels: [] }),
581
+ );
582
+
583
+ await emitWithUrgency("high");
584
+
585
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
586
+ shouldNotify: boolean;
587
+ selectedChannels: string[];
588
+ reasoningSummary: string;
589
+ };
590
+ expect(dispatched.shouldNotify).toBe(false);
591
+ expect(dispatched.selectedChannels).toEqual([]);
592
+ expect(dispatched.reasoningSummary).not.toContain("forced");
593
+ });
594
+
595
+ test("routing-intent enforcement runs after the force-add and can cap channels", async () => {
596
+ evaluateSignalMock.mockResolvedValue(makeDecision({}));
597
+ // single_channel enforcement caps the selection to the source channel.
598
+ enforceRoutingIntentMock.mockImplementation(
599
+ (decision: { selectedChannels: string[] }) => ({
600
+ ...decision,
601
+ selectedChannels: ["telegram"],
602
+ }),
603
+ );
604
+
605
+ await emitNotificationSignal({
606
+ sourceEventName: "schedule.notify",
607
+ sourceChannel: "scheduler",
608
+ sourceContextId: "rem-urg-2",
609
+ routingIntent: "single_channel",
610
+ attentionHints: {
611
+ requiresAction: true,
612
+ urgency: "high",
613
+ isAsyncBackground: false,
614
+ visibleInSourceNow: false,
615
+ },
616
+ contextPayload: { reminderId: "rem-urg-2" },
617
+ });
618
+
619
+ // Enforcement sees the force-added channels...
620
+ const enforcedInput = enforceRoutingIntentMock.mock.calls[0][0] as {
621
+ selectedChannels: string[];
622
+ };
623
+ expect(enforcedInput.selectedChannels).toEqual([
624
+ "vellum",
625
+ "telegram",
626
+ "platform",
627
+ ]);
628
+ // ...and its cap wins over the force-add.
629
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
630
+ selectedChannels: string[];
631
+ };
632
+ expect(dispatched.selectedChannels).toEqual(["telegram"]);
633
+ });
634
+
635
+ test("forces only vellum when the platform client is not configured", async () => {
636
+ isPlatformClientConfiguredMock.mockResolvedValue(false);
637
+ evaluateSignalMock.mockResolvedValue(makeDecision({}));
638
+
639
+ await emitWithUrgency("high");
640
+
641
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
642
+ selectedChannels: string[];
643
+ reasoningSummary: string;
644
+ };
645
+ expect(dispatched.selectedChannels).toEqual(["vellum", "telegram"]);
646
+ expect(dispatched.reasoningSummary).toContain(
647
+ "(vellum forced: high urgency)",
648
+ );
649
+ });
650
+
651
+ // Real single_channel enforcement caps to the source channel when it is
652
+ // connected, else to the FIRST selected channel. The force-add prepends
653
+ // vellum precisely so that fallback keeps the in-app banner; these tests
654
+ // run the genuine enforcement to pin the interplay.
655
+ describe("interplay with real single_channel enforcement", () => {
656
+ beforeEach(() => {
657
+ enforceRoutingIntentMock.mockImplementation(
658
+ realEnforceRoutingIntent as (...args: unknown[]) => unknown,
659
+ );
660
+ });
661
+
662
+ function emitHighUrgencySingleChannel(
663
+ sourceChannel: "watcher" | "assistant_tool",
664
+ ) {
665
+ return emitNotificationSignal({
666
+ sourceEventName: "watcher.escalation",
667
+ sourceChannel,
668
+ sourceContextId: "watch-1",
669
+ routingIntent: "single_channel",
670
+ attentionHints: {
671
+ requiresAction: true,
672
+ urgency: "high",
673
+ isAsyncBackground: false,
674
+ visibleInSourceNow: false,
675
+ },
676
+ contextPayload: {},
677
+ });
678
+ }
679
+
680
+ test("vellum survives the cap when the decision selected only vellum", async () => {
681
+ evaluateSignalMock.mockResolvedValue(
682
+ makeDecision({ selectedChannels: ["vellum"] }),
683
+ );
684
+
685
+ await emitHighUrgencySingleChannel("watcher");
686
+
687
+ // Force-add appends platform: ["vellum", "platform"]. The source
688
+ // channel is not connected, so the cap falls back to index 0.
689
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
690
+ selectedChannels: string[];
691
+ };
692
+ expect(dispatched.selectedChannels).toEqual(["vellum"]);
693
+ });
694
+
695
+ test("the cap picks the prepended vellum when the decision selected another channel", async () => {
696
+ evaluateSignalMock.mockResolvedValue(
697
+ makeDecision({ selectedChannels: ["telegram"] }),
698
+ );
699
+
700
+ await emitHighUrgencySingleChannel("assistant_tool");
701
+
702
+ // Force-add yields ["vellum", "telegram", "platform"]; the
703
+ // non-connected source channel makes the cap take index 0.
704
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
705
+ selectedChannels: string[];
706
+ };
707
+ expect(dispatched.selectedChannels).toEqual(["vellum"]);
708
+ });
709
+ });
710
+ });
@@ -14,6 +14,13 @@ mock.module("../messaging/providers/slack/withdraw.js", () => ({
14
14
  withdrawSlackApprovalCard,
15
15
  }));
16
16
 
17
+ const withdrawTelegramApprovalCard = mock(
18
+ async (_params: Record<string, unknown>) => {},
19
+ );
20
+ mock.module("../messaging/providers/telegram-bot/withdraw.js", () => ({
21
+ withdrawTelegramApprovalCard,
22
+ }));
23
+
17
24
  // The recorder writes through the gateway client; serve that surface from
18
25
  // the in-memory sim the assertions read.
19
26
  import {
@@ -60,6 +67,7 @@ describe("withdrawGuardianRequestCards", () => {
60
67
  completeSurfaceAndNotify.mockClear();
61
68
  markSurfaceCompleted.mockClear();
62
69
  withdrawSlackApprovalCard.mockClear();
70
+ withdrawTelegramApprovalCard.mockClear();
63
71
  });
64
72
 
65
73
  test("withdraws + broadcasts the in-app card when the decision came from another surface", async () => {
@@ -199,7 +207,7 @@ describe("withdrawGuardianRequestCards", () => {
199
207
  expect(withdrawSlackApprovalCard).toHaveBeenCalledTimes(1);
200
208
  });
201
209
 
202
- test("ignores channels without in-place edit support (telegram)", async () => {
210
+ test("withdraws the Telegram card with a status reply when resolved from another surface", async () => {
203
211
  const req = makeRequest({ sourceChannel: "telegram" });
204
212
  bridgeState.seedDelivery({
205
213
  requestId: req.id,
@@ -208,10 +216,90 @@ describe("withdrawGuardianRequestCards", () => {
208
216
  destinationMessageId: "9",
209
217
  });
210
218
 
211
- await withdrawGuardianRequestCards({ request: req, status: "approved" });
219
+ await withdrawGuardianRequestCards({
220
+ request: req,
221
+ status: "approved",
222
+ originChannel: "vellum",
223
+ });
212
224
 
213
225
  expect(withdrawSlackApprovalCard).not.toHaveBeenCalled();
214
- expect(completeSurfaceAndNotify).not.toHaveBeenCalled();
226
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledTimes(1);
227
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledWith({
228
+ chatId: "T1",
229
+ messageId: "9",
230
+ status: "approved",
231
+ postStatusReply: true,
232
+ });
233
+ });
234
+
235
+ test("suppresses the Telegram status reply only when the origin flow replies to the guardian", async () => {
236
+ const req = makeRequest({ sourceChannel: "telegram" });
237
+ bridgeState.seedDelivery({
238
+ requestId: req.id,
239
+ destinationChannel: "telegram",
240
+ destinationChatId: "T1",
241
+ destinationMessageId: "9",
242
+ });
243
+
244
+ await withdrawGuardianRequestCards({
245
+ request: req,
246
+ status: "denied",
247
+ originChannel: "telegram",
248
+ decidedAction: "reject",
249
+ hasOriginGuardianReply: true,
250
+ });
251
+
252
+ // The resolver's guardian-facing reply is being delivered in that chat;
253
+ // withdrawal only drops the keyboard.
254
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledWith({
255
+ chatId: "T1",
256
+ messageId: "9",
257
+ status: "denied",
258
+ decidedAction: "reject",
259
+ postStatusReply: false,
260
+ });
261
+ });
262
+
263
+ test("posts the Telegram status reply for origin decisions without a guardian-facing resolver reply", async () => {
264
+ const req = makeRequest({ sourceChannel: "telegram" });
265
+ bridgeState.seedDelivery({
266
+ requestId: req.id,
267
+ destinationChannel: "telegram",
268
+ destinationChatId: "T1",
269
+ destinationMessageId: "9",
270
+ });
271
+
272
+ // Most resolvers (tool grants, tool approvals, questions) reply to the
273
+ // requester only, so the quoted status reply is the guardian's only
274
+ // durable outcome even for a decision made on Telegram itself.
275
+ await withdrawGuardianRequestCards({
276
+ request: req,
277
+ status: "approved",
278
+ originChannel: "telegram",
279
+ decidedAction: "approve_once",
280
+ hasOriginGuardianReply: false,
281
+ });
282
+
283
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledWith({
284
+ chatId: "T1",
285
+ messageId: "9",
286
+ status: "approved",
287
+ decidedAction: "approve_once",
288
+ postStatusReply: true,
289
+ });
290
+ });
291
+
292
+ test("skips the Telegram edit when no channel message id was captured", async () => {
293
+ const req = makeRequest({ sourceChannel: "telegram" });
294
+ bridgeState.seedDelivery({
295
+ requestId: req.id,
296
+ destinationChannel: "telegram",
297
+ destinationChatId: "T1",
298
+ });
299
+
300
+ await withdrawGuardianRequestCards({ request: req, status: "approved" });
301
+
302
+ expect(withdrawTelegramApprovalCard).not.toHaveBeenCalled();
215
303
  });
216
304
 
217
305
  test("is best-effort: a failing surface never blocks the others or throws", async () => {
@@ -442,6 +442,72 @@ describe("HTTP POST /v1/messages clientTimezone transport metadata", () => {
442
442
  });
443
443
  });
444
444
 
445
+ describe("HTTP POST /v1/messages visibleAppId transport metadata", () => {
446
+ beforeEach(() => {
447
+ routeGuardianReplyMock.mockClear();
448
+ addMessageMock.mockClear();
449
+ });
450
+
451
+ async function captureTransport(
452
+ body: Record<string, unknown>,
453
+ ): Promise<Record<string, unknown> | undefined> {
454
+ const conversation = makeConversation({
455
+ persistUserMessage: mock(async () => ({
456
+ id: "persisted-msg-id",
457
+ deduplicated: false,
458
+ })),
459
+ runAgentLoop: mock(async () => undefined),
460
+ });
461
+ let capturedOptions: Record<string, unknown> | undefined;
462
+
463
+ const res = await sendMessage("hello", conversation, body, {
464
+ onGetOrCreateConversation: (_conversationId, opts) => {
465
+ capturedOptions = opts;
466
+ },
467
+ });
468
+
469
+ expect(res.status).toBe(202);
470
+ return capturedOptions?.transport as Record<string, unknown> | undefined;
471
+ }
472
+
473
+ test("carries the reported visible app onto the transport", async () => {
474
+ expect(await captureTransport({ visibleAppId: "app-abc" })).toEqual({
475
+ channelId: "vellum",
476
+ interfaceId: "macos",
477
+ visibleAppId: "app-abc",
478
+ });
479
+ });
480
+
481
+ test("omits visibleAppId when no app is in view", async () => {
482
+ expect(await captureTransport({})).toEqual({
483
+ channelId: "vellum",
484
+ interfaceId: "macos",
485
+ });
486
+ });
487
+
488
+ test("carries a long plugin-bundled app id, which the viewer can open", async () => {
489
+ // `plugins~<plugin>~<app>`, both segments at the filesystem's 255-byte
490
+ // ceiling. The open route resolves ids this long, so ingress must not clip
491
+ // them or the app on screen goes unreported.
492
+ const longPluginAppId = `plugins~${"p".repeat(255)}~${"a".repeat(255)}`;
493
+
494
+ expect(await captureTransport({ visibleAppId: longPluginAppId })).toEqual({
495
+ channelId: "vellum",
496
+ interfaceId: "macos",
497
+ visibleAppId: longPluginAppId,
498
+ });
499
+ });
500
+
501
+ test("drops a traversal-shaped visibleAppId without rejecting the message", async () => {
502
+ expect(
503
+ await captureTransport({ visibleAppId: "../../etc/passwd" }),
504
+ ).toEqual({
505
+ channelId: "vellum",
506
+ interfaceId: "macos",
507
+ });
508
+ });
509
+ });
510
+
445
511
  // ============================================================================
446
512
  // CLIENT METADATA — sanitized x-vellum-* headers persisted under
447
513
  // metadata.client for turn analytics
@@ -193,6 +193,93 @@ describe("handleListMessages attachments", () => {
193
193
  expect(docAtt!.data).toBeUndefined();
194
194
  });
195
195
 
196
+ test("contentBlocks attachment blocks are metadata-only references", async () => {
197
+ // The flat `attachments` array is the sole payload carrier. An inline
198
+ // file-block ref places an `attachment` block in `contentBlocks`, but
199
+ // that block must reference the row by id without repeating its base64:
200
+ // duplicating it doubles every image in the response and in client
201
+ // memory.
202
+ const conv = createConversation();
203
+ const stored = uploadAttachment("photo.png", "image/png", IMAGE_BASE64);
204
+ const msg = await addMessage(
205
+ conv.id,
206
+ "user",
207
+ JSON.stringify([
208
+ { type: "text", text: "check this image" },
209
+ {
210
+ type: "file",
211
+ source: { type: "workspace_ref", attachmentId: stored.id },
212
+ filename: "photo.png",
213
+ media_type: "image/png",
214
+ },
215
+ ]),
216
+ );
217
+ linkAttachmentToMessage(msg.id, stored.id, 0);
218
+
219
+ const response = handleListMessages(createTestArgs(conv.id));
220
+ const body = response as {
221
+ messages: {
222
+ attachments?: AttachmentPayload[];
223
+ contentBlocks?: Array<{
224
+ type: string;
225
+ attachment?: {
226
+ id: string;
227
+ data?: string;
228
+ thumbnailData?: string;
229
+ };
230
+ }>;
231
+ }[];
232
+ };
233
+
234
+ expect(body.messages).toHaveLength(1);
235
+ const row = body.messages[0];
236
+
237
+ // Payload ships on the flat array.
238
+ expect(row.attachments).toHaveLength(1);
239
+ expect(row.attachments![0].data).toBe(IMAGE_BASE64);
240
+
241
+ // The inline block references the same row without the payload.
242
+ const blocks = (row.contentBlocks ?? []).filter(
243
+ (b) => b.type === "attachment",
244
+ );
245
+ expect(blocks).toHaveLength(1);
246
+ expect(blocks[0].attachment!.id).toBe(stored.id);
247
+ expect(blocks[0].attachment!.data).toBeUndefined();
248
+ expect(blocks[0].attachment!.thumbnailData).toBeUndefined();
249
+ });
250
+
251
+ test("synthesized attachment blocks are metadata-only references", async () => {
252
+ // Directive attachments take the appendix path (no file-block ref), which
253
+ // must strip the payload the same way the inline path does.
254
+ const conv = createConversation();
255
+ const msg = await addMessage(
256
+ conv.id,
257
+ "assistant",
258
+ JSON.stringify([{ type: "text", text: "" }]),
259
+ );
260
+ const stored = uploadAttachment("output.png", "image/png", IMAGE_BASE64);
261
+ linkAttachmentToMessage(msg.id, stored.id, 0);
262
+
263
+ const response = handleListMessages(createTestArgs(conv.id));
264
+ const body = response as {
265
+ messages: {
266
+ attachments?: AttachmentPayload[];
267
+ contentBlocks?: Array<{
268
+ type: string;
269
+ attachment?: { id: string; data?: string };
270
+ }>;
271
+ }[];
272
+ };
273
+
274
+ const row = body.messages[0];
275
+ expect(row.attachments![0].data).toBe(IMAGE_BASE64);
276
+ const blocks = (row.contentBlocks ?? []).filter(
277
+ (b) => b.type === "attachment",
278
+ );
279
+ expect(blocks).toHaveLength(1);
280
+ expect(blocks[0].attachment!.data).toBeUndefined();
281
+ });
282
+
196
283
  test("attachment-only assistant message synthesizes contentBlocks", async () => {
197
284
  // When the assistant's entire response was a <vellum-attachment/> tag,
198
285
  // parseDirectives strips it → cleanText is empty → renderHistoryContent