@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
package/src/agent/loop.ts CHANGED
@@ -1028,6 +1028,10 @@ export class AgentLoop {
1028
1028
  // rejection means the repair could not recover, so the error surfaces
1029
1029
  // instead of looping. Turn-scoped, so each turn recovers afresh.
1030
1030
  let orderingRepairAttempted = false;
1031
+ // One resume per turn after a call dies mid-generation: a second
1032
+ // interruption means re-issuing is not getting through, so the error
1033
+ // surfaces instead of looping.
1034
+ let interruptedCallResumed = false;
1031
1035
  let lastLlmCallTime = 0;
1032
1036
  let exitReason: ExitReason | null = null;
1033
1037
  // Armed at the end of a tool-use iteration so the budget gate runs at the
@@ -1176,6 +1180,12 @@ export class AgentLoop {
1176
1180
  // elsewhere in the turn body (tool execution, the success-path stop
1177
1181
  // chain, post-model-call hooks) must not re-enter the stop chain.
1178
1182
  let providerCallError: unknown;
1183
+ // Set once the model streams its first token on this iteration's call.
1184
+ // Two readers: latency instrumentation stamps time-to-first-token off
1185
+ // its rising edge, and the outer catch reads it to tell a refused
1186
+ // request from a generation that died mid-flight. Declared here so that
1187
+ // catch can reach it.
1188
+ let streamedTokens = false;
1179
1189
 
1180
1190
  try {
1181
1191
  // ── Pre-call budget gate ─────────────────────────────────────
@@ -1527,11 +1537,6 @@ export class AgentLoop {
1527
1537
  // the client would otherwise see nothing.
1528
1538
  let streamedVisibleText = false;
1529
1539
 
1530
- // Latency instrumentation: stamp the first streamed token (thinking or
1531
- // text) of THIS call exactly once, so each per-call segment carries its
1532
- // own time-to-first-token. Reset per provider call.
1533
- let firstTokenMarked = false;
1534
-
1535
1540
  // The `onEvent` wrapping below applies sensitive-output placeholder
1536
1541
  // substitution to streamed text while forwarding every other event
1537
1542
  // type through unchanged.
@@ -1541,10 +1546,10 @@ export class AgentLoop {
1541
1546
  config: providerConfig,
1542
1547
  onEvent: (event) => {
1543
1548
  if (
1544
- !firstTokenMarked &&
1549
+ !streamedTokens &&
1545
1550
  (event.type === "thinking_delta" || event.type === "text_delta")
1546
1551
  ) {
1547
- firstTokenMarked = true;
1552
+ streamedTokens = true;
1548
1553
  latencyTracker?.markFirstToken(
1549
1554
  event.type === "thinking_delta" ? "thinking" : "text",
1550
1555
  );
@@ -2498,6 +2503,30 @@ export class AgentLoop {
2498
2503
  newMessagesStart = history.length;
2499
2504
  continue;
2500
2505
  }
2506
+
2507
+ // Last: the call died mid-generation. `streamedTokens` means the
2508
+ // provider accepted the request and started producing before it
2509
+ // failed, so the request is fine and re-issuing it as-is is the
2510
+ // recovery — nothing to repair, which is why no branch above claims
2511
+ // it. A rejection that streamed nothing is the opposite case (the
2512
+ // provider refused the request), and an identical resend would be
2513
+ // refused identically, so those surface. Main-agent turns only:
2514
+ // background call sites answer to callers that handle their own
2515
+ // failures.
2516
+ if (
2517
+ streamedTokens &&
2518
+ !interruptedCallResumed &&
2519
+ callSite === "mainAgent" &&
2520
+ postModelCallContinues < MAX_POST_MODEL_CALL_CONTINUES
2521
+ ) {
2522
+ interruptedCallResumed = true;
2523
+ postModelCallContinues++;
2524
+ rlog.warn(
2525
+ { turn: toolUseTurns, messageCount: history.length, err },
2526
+ "Model call died mid-generation, resuming the turn",
2527
+ );
2528
+ continue;
2529
+ }
2501
2530
  }
2502
2531
 
2503
2532
  rlog.error(
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `context_window_usage` SSE event.
3
+ *
4
+ * Server → client push of a conversation's current context-window usage,
5
+ * measured with the provider's own tokenizer. Emitted by user-initiated
6
+ * compaction (`/compact`, "summarize up to here"), which recomputes the
7
+ * count outside any turn and so has no `usage_update` to carry it. Clients
8
+ * apply it to the context-window indicator, which otherwise only refreshes
9
+ * on the next LLM call.
10
+ *
11
+ * `tokens` and `maxTokens` are the same figures the compaction result card
12
+ * reports, so the indicator and the card always agree.
13
+ *
14
+ * Canonical wire-contract source. Daemon code imports the type directly
15
+ * from this file; external consumers import via `@vellumai/assistant-api`.
16
+ */
17
+
18
+ import { z } from "zod";
19
+
20
+ export const ContextWindowUsageEventSchema = z.object({
21
+ type: z.literal("context_window_usage"),
22
+ conversationId: z.string(),
23
+ /** Prompt tokens the conversation's context currently occupies. */
24
+ tokens: z.number(),
25
+ /** Input-token ceiling of the resolved context window. */
26
+ maxTokens: z.number(),
27
+ });
28
+
29
+ export type ContextWindowUsageEvent = z.infer<
30
+ typeof ContextWindowUsageEventSchema
31
+ >;
@@ -23,9 +23,17 @@ export const NotificationIntentEventSchema = z.object({
23
23
  title: z.string(),
24
24
  body: z.string(),
25
25
  deliveryId: z.string().optional(),
26
+ correlationId: z.string().optional(),
26
27
  deepLinkMetadata: z.record(z.string(), z.unknown()).optional(),
27
28
  targetGuardianPrincipalId: z.string().optional(),
28
29
  silent: z.boolean().optional(),
30
+ /**
31
+ * True when the platform (APNs) channel accepted a remote push for
32
+ * this delivery, so native clients can avoid double-bannering.
33
+ */
34
+ remotePushDispatched: z.boolean().optional(),
35
+ /** Native platforms that accepted this remote push. */
36
+ remotePushPlatforms: z.array(z.enum(["ios", "android"])).optional(),
29
37
  });
30
38
 
31
39
  export type NotificationIntentEvent = z.infer<
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `ui_surface_pending` SSE event.
3
+ *
4
+ * Server → client notice that a `ui_show` call whose input is still streaming
5
+ * will produce a surface of `surfaceType`, so the client can hold a
6
+ * placeholder for it. Authoring a `visual` fragment is the longest input
7
+ * stream the model produces, and until the call closes there is nothing on
8
+ * screen at all: the `ui_show` chip is suppressed (the surface renders in its
9
+ * place) and the surface itself does not exist yet.
10
+ *
11
+ * The daemon emits this at most once per tool call, as soon as the
12
+ * accumulated tool input names a surface type worth a placeholder. It carries
13
+ * no input: the fragment is large and the client only needs to know that one
14
+ * is coming.
15
+ *
16
+ * `toolUseId` is the id of the producing `ui_show` call, the correlation key
17
+ * the client uses to retire the placeholder when the call resolves.
18
+ * `messageId` is the assistant row the call belongs to when the daemon has
19
+ * already opened one.
20
+ *
21
+ * Canonical wire-contract source. Daemon code imports the type directly from
22
+ * this file; external consumers import via `@vellumai/assistant-api`.
23
+ */
24
+
25
+ import { z } from "zod";
26
+
27
+ export const UISurfacePendingEventSchema = z.object({
28
+ type: z.literal("ui_surface_pending"),
29
+ conversationId: z.string(),
30
+ surfaceType: z.literal("visual"),
31
+ toolUseId: z.string(),
32
+ messageId: z.string().optional(),
33
+ });
34
+
35
+ export type UISurfacePendingEvent = z.infer<typeof UISurfacePendingEventSchema>;
package/src/api/index.ts CHANGED
@@ -25,6 +25,7 @@ import { ConfirmationStateChangedEventSchema } from "./events/confirmation-state
25
25
  import { ContactRequestEventSchema } from "./events/contact-request.js";
26
26
  import { ContactsChangedEventSchema } from "./events/contacts-changed.js";
27
27
  import { ContextCompactedEventSchema } from "./events/context-compacted.js";
28
+ import { ContextWindowUsageEventSchema } from "./events/context-window-usage.js";
28
29
  import { ConversationErrorEventSchema } from "./events/conversation-error.js";
29
30
  import { ConversationInferenceProfileUpdatedEventSchema } from "./events/conversation-inference-profile-updated.js";
30
31
  import { ConversationListInvalidatedEventSchema } from "./events/conversation-list-invalidated.js";
@@ -119,6 +120,7 @@ import { ToolUsePreviewStartEventSchema } from "./events/tool-use-preview-start.
119
120
  import { ToolUseStartEventSchema } from "./events/tool-use-start.js";
120
121
  import { UISurfaceCompleteEventSchema } from "./events/ui-surface-complete.js";
121
122
  import { UISurfaceDismissEventSchema } from "./events/ui-surface-dismiss.js";
123
+ import { UISurfacePendingEventSchema } from "./events/ui-surface-pending.js";
122
124
  import { UISurfaceShowEventSchema } from "./events/ui-surface-show.js";
123
125
  import { UISurfaceUndoResultEventSchema } from "./events/ui-surface-undo-result.js";
124
126
  import { UISurfaceUpdateEventSchema } from "./events/ui-surface-update.js";
@@ -270,6 +272,10 @@ export {
270
272
  type ContextCompactedEvent,
271
273
  ContextCompactedEventSchema,
272
274
  } from "./events/context-compacted.js";
275
+ export {
276
+ type ContextWindowUsageEvent,
277
+ ContextWindowUsageEventSchema,
278
+ } from "./events/context-window-usage.js";
273
279
  export {
274
280
  type ConversationErrorCode,
275
281
  ConversationErrorCodeSchema,
@@ -607,6 +613,10 @@ export {
607
613
  type UISurfaceDismissEvent,
608
614
  UISurfaceDismissEventSchema,
609
615
  } from "./events/ui-surface-dismiss.js";
616
+ export {
617
+ type UISurfacePendingEvent,
618
+ UISurfacePendingEventSchema,
619
+ } from "./events/ui-surface-pending.js";
610
620
  export {
611
621
  type SurfaceAction,
612
622
  SurfaceActionSchema,
@@ -836,6 +846,8 @@ export {
836
846
  TableRowSchema,
837
847
  type TableSurfaceData,
838
848
  TableSurfaceDataSchema,
849
+ type VisualSurfaceData,
850
+ VisualSurfaceDataSchema,
839
851
  type WorkResultDiff,
840
852
  WorkResultDiffSchema,
841
853
  type WorkResultItem,
@@ -895,6 +907,7 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [
895
907
  ContactRequestEventSchema,
896
908
  ContactsChangedEventSchema,
897
909
  ContextCompactedEventSchema,
910
+ ContextWindowUsageEventSchema,
898
911
  ConversationErrorEventSchema,
899
912
  ConversationInferenceProfileUpdatedEventSchema,
900
913
  ConversationListInvalidatedEventSchema,
@@ -973,6 +986,7 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [
973
986
  ToolUseStartEventSchema,
974
987
  UISurfaceCompleteEventSchema,
975
988
  UISurfaceDismissEventSchema,
989
+ UISurfacePendingEventSchema,
976
990
  UISurfaceShowEventSchema,
977
991
  UISurfaceUndoResultEventSchema,
978
992
  UISurfaceUpdateEventSchema,
@@ -415,7 +415,14 @@ export type ConversationSurfaceBlock = z.infer<
415
415
  typeof ConversationSurfaceBlockSchema
416
416
  >;
417
417
 
418
- /** A vellum attachment projection (no provider analog). */
418
+ /**
419
+ * A vellum attachment projection (no provider analog).
420
+ *
421
+ * The inlined `attachment` is a metadata-only positional reference:
422
+ * `data`/`thumbnailData` are never populated here. The payload ships once,
423
+ * on the message's flat `attachments` array (joinable by `attachment.id`),
424
+ * or on demand via the attachment content endpoint.
425
+ */
419
426
  export const ConversationAttachmentBlockSchema = z.object({
420
427
  type: z.literal("attachment"),
421
428
  attachment: ConversationMessageAttachmentSchema,
@@ -491,10 +498,13 @@ export const ConversationMessageSchema = z.object({
491
498
  /** Display timestamp as an ISO-8601 string. */
492
499
  timestamp: z.string(),
493
500
  /**
494
- * Flat list of attachment metadata for the row. Not yet supersedable by
495
- * `contentBlocks`: `renderHistoryContent` emits an `attachment` content
501
+ * Flat list of attachment metadata for the row, and the sole carrier of
502
+ * inline attachment payloads: image rows populate `data`/`thumbnailData`
503
+ * here, while the `attachment` blocks in `contentBlocks` stay metadata-only
504
+ * references joinable by id. Not supersedable by `contentBlocks` for a
505
+ * second reason: `renderHistoryContent` emits an `attachment` content
496
506
  * block only for file-block refs with an inline placement, so orphan rows
497
- * (unmatched ids, count mismatch, no DB rows see `alignAttachments`) ship
507
+ * (unmatched ids, count mismatch, no DB rows; see `alignAttachments`) ship
498
508
  * here alone. Kept non-deprecated until `contentBlocks` reaches attachment
499
509
  * parity, so clients that migrate off the positional arrays don't drop those
500
510
  * chips.
@@ -586,6 +596,17 @@ export const ConversationMessageSchema = z.object({
586
596
  * system notices — no avatar, no persona bubble — and never group them
587
597
  * with adjacent assistant turns. */
588
598
  systemCard: z.boolean().optional(),
599
+ /** Present when this assistant row is a daemon-persisted provider-failure
600
+ * notice (`metadata.messageKind === "provider_error"`); clients may render
601
+ * a themed card instead of a persona bubble. `code` is the stable
602
+ * classified error code (e.g. `"PROVIDER_BILLING"`), `category` the
603
+ * classified category (e.g. `"credits_exhausted"`). */
604
+ providerError: z
605
+ .object({
606
+ code: z.string().optional(),
607
+ category: z.string().optional(),
608
+ })
609
+ .optional(),
589
610
  slackMessage: ConversationSlackMessageSchema.optional(),
590
611
  /**
591
612
  * Queue state for a user message that is still waiting in the daemon's
@@ -54,6 +54,38 @@ export function coerceSurfaceDataRecord(
54
54
  return {};
55
55
  }
56
56
 
57
+ /**
58
+ * Describe why a `data` payload the caller double-encoded as a JSON object or
59
+ * array literal could not be decoded, or null when it decoded fine.
60
+ *
61
+ * {@link coerceSurfaceDataRecord} collapses an undecodable string to `{}`, which
62
+ * every downstream shape check then reads as "no fields were sent". Callers pair
63
+ * the two so a rejection names the encoding failure instead of blaming whichever
64
+ * field happened to be looked up first.
65
+ *
66
+ * Scoped to strings that open with `{` or `[`: those are an object payload that
67
+ * lost a quote or a brace on the way through. A string that never resembled one
68
+ * (`"delete it?"`) is a different mistake, and the surface's own shape doc says
69
+ * more about it than a parse error would.
70
+ */
71
+ export function describeSurfaceDataStringParseFailure(
72
+ value: unknown,
73
+ ): string | null {
74
+ if (typeof value !== "string") {
75
+ return null;
76
+ }
77
+ const trimmed = value.trimStart();
78
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
79
+ return null;
80
+ }
81
+ try {
82
+ JSON.parse(value);
83
+ return null;
84
+ } catch (error) {
85
+ return error instanceof Error ? error.message : String(error);
86
+ }
87
+ }
88
+
57
89
  /** Optional string that drops (rather than rejects on) a non-string value. */
58
90
  const tolerantString = () => z.string().optional().catch(undefined);
59
91
 
@@ -458,6 +490,59 @@ export const WorkResultSurfaceDataSchema = z.object({
458
490
  });
459
491
  export type WorkResultSurfaceData = z.infer<typeof WorkResultSurfaceDataSchema>;
460
492
 
493
+ /**
494
+ * Inline visual: a self-contained html fragment rendered in a sandboxed
495
+ * frame. Model-invokable via `ui_show`, which validates the fragment (no
496
+ * external resources, design-token styling) before emitting it — see
497
+ * `tools/ui-surface/visual-validation.ts`.
498
+ */
499
+ export const VisualSurfaceDataSchema = z.object({
500
+ /** The visual's markup. Load-bearing: empty renders a blank box. */
501
+ html: z.string().catch(""),
502
+ /** Preferred render height in px. */
503
+ height: tolerantNumber(),
504
+ });
505
+ export type VisualSurfaceData = z.infer<typeof VisualSurfaceDataSchema>;
506
+
507
+ /**
508
+ * Bounds for the caller's initial height estimate, in CSS pixels. The maximum
509
+ * matches the client renderer's clamp (`visual-surface.tsx`), so an estimate
510
+ * the frame would clip never reaches the wire.
511
+ */
512
+ const VISUAL_MIN_HEIGHT = 80;
513
+ const VISUAL_MAX_HEIGHT = 1400;
514
+
515
+ /**
516
+ * Normalize a visual `ui_show` payload: recover `html`/`height` the model
517
+ * placed at the top level of the tool input instead of nesting inside `data`,
518
+ * clamp the height estimate, then parse through the canonical schema. Shared
519
+ * by the tool's validation guard and the daemon resolver so both layers see
520
+ * the same fragment.
521
+ */
522
+ export function normalizeVisualShowData(
523
+ input: Record<string, unknown>,
524
+ data: Record<string, unknown>,
525
+ ): VisualSurfaceData {
526
+ const normalized: Record<string, unknown> = { ...data };
527
+ if (typeof normalized.html !== "string" && typeof input.html === "string") {
528
+ normalized.html = input.html;
529
+ }
530
+ if (normalized.height == null && input.height != null) {
531
+ normalized.height = input.height;
532
+ }
533
+ const parsed = VisualSurfaceDataSchema.parse(normalized);
534
+ if (parsed.height === undefined || parsed.height <= 0) {
535
+ return { html: parsed.html };
536
+ }
537
+ return {
538
+ html: parsed.html,
539
+ height: Math.min(
540
+ VISUAL_MAX_HEIGHT,
541
+ Math.max(VISUAL_MIN_HEIGHT, Math.round(parsed.height)),
542
+ ),
543
+ };
544
+ }
545
+
461
546
  // === Surface type registry ===
462
547
 
463
548
  export const SURFACE_TYPES = [
@@ -477,6 +562,7 @@ export const SURFACE_TYPES = [
477
562
  "work_result",
478
563
  "skill_card",
479
564
  "call_summary",
565
+ "visual",
480
566
  ] as const;
481
567
 
482
568
  export const SurfaceTypeSchema = z.enum(SURFACE_TYPES);
@@ -534,7 +620,8 @@ export type SurfaceData =
534
620
  | DynamicPageSurfaceData
535
621
  | FileUploadSurfaceData
536
622
  | DocumentPreviewSurfaceData
537
- | WorkResultSurfaceData;
623
+ | WorkResultSurfaceData
624
+ | VisualSurfaceData;
538
625
 
539
626
  /**
540
627
  * Per-type `data` payload shapes, keyed by surface type. This is the
@@ -568,6 +655,7 @@ export interface SurfaceDataByType {
568
655
  work_result: WorkResultSurfaceData;
569
656
  skill_card: Record<string, unknown>;
570
657
  call_summary: Record<string, unknown>;
658
+ visual: VisualSurfaceData;
571
659
  }
572
660
 
573
661
  /** Any surface `data` payload, including the opaque (non-renderable) types. */
@@ -600,6 +688,7 @@ export const SURFACE_DATA_SCHEMAS: {
600
688
  work_result: WorkResultSurfaceDataSchema,
601
689
  skill_card: z.record(z.string(), z.unknown()),
602
690
  call_summary: z.record(z.string(), z.unknown()),
691
+ visual: VisualSurfaceDataSchema,
603
692
  };
604
693
 
605
694
  /**
@@ -32,39 +32,16 @@ import {
32
32
  markSurfaceCompleted,
33
33
  } from "../daemon/conversation-surfaces.js";
34
34
  import { withdrawSlackApprovalCard } from "../messaging/providers/slack/withdraw.js";
35
+ import { withdrawTelegramApprovalCard } from "../messaging/providers/telegram-bot/withdraw.js";
35
36
  import { approvalCardSurfaceId } from "../notifications/approval-card-data.js";
36
37
  import {
37
38
  type ApprovalAction,
38
- isParkAction,
39
- PARK_STATUS_LABEL,
39
+ resolveDecisionStatusWord,
40
40
  } from "../runtime/channel-approval-types.js";
41
41
  import { getLogger } from "../util/logger.js";
42
42
 
43
43
  const log = getLogger("guardian-card-withdrawal");
44
44
 
45
- /** Completion-summary label shown on an in-app card for a resolved request. */
46
- const SURFACE_STATUS_LABELS: Partial<Record<GuardianRequestStatus, string>> = {
47
- approved: "Approved",
48
- denied: "Denied",
49
- expired: "Expired",
50
- cancelled: "Cancelled",
51
- };
52
-
53
- /**
54
- * The completion-summary label for a resolved card. A `denied` status reached by
55
- * a park action reads as the neutral {@link PARK_STATUS_LABEL} rather than
56
- * "Denied" — a parked contact was neither trusted nor kept out.
57
- */
58
- function resolveStatusLabel(
59
- status: GuardianRequestStatus,
60
- decidedAction: ApprovalAction | undefined,
61
- ): string {
62
- if (status === "denied" && isParkAction(decidedAction)) {
63
- return PARK_STATUS_LABEL;
64
- }
65
- return SURFACE_STATUS_LABELS[status] ?? "Resolved";
66
- }
67
-
68
45
  /** The request fields withdrawal reads — structural subset of the wire row. */
69
46
  export interface WithdrawableGuardianRequest {
70
47
  id: string;
@@ -92,10 +69,20 @@ export interface WithdrawGuardianCardsParams {
92
69
  * The action the guardian took, when the terminal status came from a decision
93
70
  * (omitted for the expiry sweep). A `denied` status can mean either a neutral
94
71
  * park (`leave_unverified`) or an active rejection (`block`/`reject`); the
95
- * action disambiguates them so a park renders neutrally as
96
- * {@link PARK_STATUS_LABEL} instead of "Denied".
72
+ * action disambiguates them so a park renders neutrally as the park label
73
+ * (see {@link resolveDecisionStatusWord}) instead of "Denied".
97
74
  */
98
75
  decidedAction?: ApprovalAction;
76
+ /**
77
+ * True when the deciding flow delivers the resolver's own guardian-facing
78
+ * reply on the origin channel (the resolver returned `guardianReplyText`).
79
+ * Telegram's quoted status reply is suppressed only when the origin chat is
80
+ * Telegram AND that richer reply is coming; most resolvers (tool grants,
81
+ * tool approvals, questions) reply to the requester, not the guardian, so
82
+ * without this flag the withdrawal's status reply is the only durable
83
+ * outcome the guardian's chat gets.
84
+ */
85
+ hasOriginGuardianReply?: boolean;
99
86
  }
100
87
 
101
88
  /**
@@ -105,7 +92,13 @@ export interface WithdrawGuardianCardsParams {
105
92
  export async function withdrawGuardianRequestCards(
106
93
  params: WithdrawGuardianCardsParams,
107
94
  ): Promise<void> {
108
- const { request, status, originChannel, decidedAction } = params;
95
+ const {
96
+ request,
97
+ status,
98
+ originChannel,
99
+ decidedAction,
100
+ hasOriginGuardianReply,
101
+ } = params;
109
102
 
110
103
  let deliveries: GuardianRequestDeliveryWire[];
111
104
  try {
@@ -130,9 +123,17 @@ export async function withdrawGuardianRequestCards(
130
123
  );
131
124
  } else if (delivery.destinationChannel === "slack") {
132
125
  await withdrawSlackCard(request, delivery, status, decidedAction);
126
+ } else if (delivery.destinationChannel === "telegram") {
127
+ await withdrawTelegramCard(
128
+ delivery,
129
+ status,
130
+ originChannel,
131
+ decidedAction,
132
+ hasOriginGuardianReply ?? false,
133
+ );
133
134
  }
134
- // Telegram/WhatsApp direct delivery can't edit a message in place (it
135
- // would post a new one), so their stale clicks are left to the existing
135
+ // WhatsApp direct delivery can't edit a message in place (it would
136
+ // post a new one), so its stale clicks are left to the existing
136
137
  // "already resolved" reply until in-place edit support lands.
137
138
  } catch (err) {
138
139
  log.warn(
@@ -175,7 +176,7 @@ function withdrawVellumCard(
175
176
  if (!surfaceId) {
176
177
  return;
177
178
  }
178
- const summary = resolveStatusLabel(status, decidedAction);
179
+ const summary = resolveDecisionStatusWord(status, decidedAction);
179
180
  if (originChannel === "vellum") {
180
181
  markSurfaceCompleted(
181
182
  { conversationId: delivery.destinationConversationId },
@@ -214,3 +215,37 @@ async function withdrawSlackCard(
214
215
  decidedAtMs: request.updatedAt,
215
216
  });
216
217
  }
218
+
219
+ /**
220
+ * Withdraw the Telegram approval card: remove its inline keyboard in place
221
+ * and post a silent reply quoting the card with the terminal outcome.
222
+ * Telegram bots cannot re-read a message, so unlike Slack the outcome rides
223
+ * a quoted reply rather than an in-message edit; the card's own text is left
224
+ * untouched for the audit trail.
225
+ *
226
+ * The status reply is suppressed only when the decision was made on Telegram
227
+ * AND its flow delivers the resolver's own guardian-facing reply there
228
+ * (`hasOriginGuardianReply`), where a second notice would read as a
229
+ * duplicate. Most resolvers reply to the requester, not the guardian, so a
230
+ * Telegram-origin decision usually still needs this reply as its durable
231
+ * outcome. No-ops when the channel-native message id was not captured at
232
+ * delivery time.
233
+ */
234
+ async function withdrawTelegramCard(
235
+ delivery: GuardianRequestDeliveryWire,
236
+ status: GuardianRequestStatus,
237
+ originChannel: string | undefined,
238
+ decidedAction: ApprovalAction | undefined,
239
+ hasOriginGuardianReply: boolean,
240
+ ): Promise<void> {
241
+ if (!delivery.destinationChatId || !delivery.destinationMessageId) {
242
+ return;
243
+ }
244
+ await withdrawTelegramApprovalCard({
245
+ chatId: delivery.destinationChatId,
246
+ messageId: delivery.destinationMessageId,
247
+ status,
248
+ ...(decidedAction ? { decidedAction } : {}),
249
+ postStatusReply: !(originChannel === "telegram" && hasOriginGuardianReply),
250
+ });
251
+ }
@@ -532,6 +532,10 @@ export async function applyGuardianDecision(
532
532
  status: targetStatus,
533
533
  originChannel: actorContext.channel,
534
534
  decidedAction: cardAction,
535
+ // Tells the Telegram projection whether the origin chat is about to get
536
+ // the resolver's own guardian-facing reply (delivered by our caller), so
537
+ // it can skip its quoted status reply instead of duplicating it.
538
+ hasOriginGuardianReply: Boolean(resolverReplyText),
535
539
  }).catch((err) => {
536
540
  log.warn(
537
541
  { err, requestId },