@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
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Shared Slack Web API transport: the single implementation of request
3
+ * dispatch, rate-limit/5xx retries, envelope checking, and error
4
+ * classification used by both Slack feature surfaces (`api.ts` for
5
+ * streaming/uploads/channel info, `client.ts` for the typed messaging
6
+ * wrappers). Not to be confused with `transport.ts`, the outbound
7
+ * `ChannelTransport` delivery seam.
8
+ *
9
+ * There is exactly ONE `SlackApiError` class, defined here. Callers branch on
10
+ * three fields, all always present:
11
+ * - `slackError`: Slack's error code (`"channel_not_found"`, ...), or
12
+ * `"http_<status>"` for HTTP-level failures, or `"unknown_error"`.
13
+ * - `category`: coarse classification for policy decisions
14
+ * (`auth` / `rate_limit` / `permission` / ...).
15
+ * - `status`: HTTP-shaped status for OAuth-refresh compatibility:
16
+ * explicit HTTP status when the failure was HTTP-level, otherwise derived
17
+ * from the category (`auth` → 401, `rate_limit` → 429, else 400).
18
+ * `oauth/connection.js` retry logic and the adapter's user→bot fallback
19
+ * key on `status === 401`.
20
+ */
21
+
22
+ import type { OAuthConnection } from "../../../oauth/connection.js";
23
+ import { getLogger } from "../../../util/logger.js";
24
+ import type { SlackApiResponse } from "./types.js";
25
+
26
+ const log = getLogger("slack-web-api");
27
+
28
+ export const SLACK_API_BASE = "https://slack.com/api";
29
+ const MAX_RATE_LIMIT_RETRIES = 3;
30
+ const DEFAULT_RETRY_AFTER_S = 1;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Error classification
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export type SlackErrorCategory =
37
+ | "auth"
38
+ | "rate_limit"
39
+ | "not_found"
40
+ | "permission"
41
+ | "channel_not_found"
42
+ | "client_error"
43
+ | "transient"
44
+ | "unknown";
45
+
46
+ const SLACK_ERROR_CODE_MAP: Record<string, SlackErrorCategory> = {
47
+ invalid_auth: "auth",
48
+ token_expired: "auth",
49
+ token_revoked: "auth",
50
+ not_authed: "auth",
51
+ account_inactive: "auth",
52
+ org_login_required: "auth",
53
+ rate_limited: "rate_limit",
54
+ ratelimited: "rate_limit",
55
+ channel_not_found: "channel_not_found",
56
+ is_archived: "channel_not_found",
57
+ not_in_channel: "permission",
58
+ missing_scope: "permission",
59
+ ekm_access_denied: "permission",
60
+ not_allowed_token_type: "permission",
61
+ restricted_action: "permission",
62
+ cannot_dm_bot: "permission",
63
+ user_not_found: "not_found",
64
+ message_not_found: "not_found",
65
+ thread_not_found: "not_found",
66
+ invalid_blocks: "client_error",
67
+ };
68
+
69
+ export function classifySlackError(
70
+ errorCode: string | undefined,
71
+ ): SlackErrorCategory {
72
+ if (!errorCode) {
73
+ return "unknown";
74
+ }
75
+ return SLACK_ERROR_CODE_MAP[errorCode] ?? "unknown";
76
+ }
77
+
78
+ function deriveStatus(category: SlackErrorCategory): number {
79
+ switch (category) {
80
+ case "auth":
81
+ return 401;
82
+ case "rate_limit":
83
+ return 429;
84
+ default:
85
+ return 400;
86
+ }
87
+ }
88
+
89
+ export class SlackApiError extends Error {
90
+ readonly slackError: string;
91
+ readonly category: SlackErrorCategory;
92
+ readonly status: number;
93
+
94
+ constructor(
95
+ slackError: string | undefined,
96
+ opts: { status?: number; message?: string } = {},
97
+ ) {
98
+ const code = slackError ?? "unknown_error";
99
+ super(opts.message ?? `Slack API error: ${code}`);
100
+ this.name = "SlackApiError";
101
+ this.slackError = code;
102
+ this.category = classifySlackError(slackError);
103
+ this.status = opts.status ?? deriveStatus(this.category);
104
+ }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Envelope checking
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Check a Slack API response envelope and throw a classified `SlackApiError`
113
+ * when `ok` is false. Slack reports most failures as HTTP 200 with an error
114
+ * code in the body, so this runs on every successful HTTP exchange.
115
+ */
116
+ export function checkSlackEnvelope<T extends SlackApiResponse>(data: T): T {
117
+ if (!data.ok) {
118
+ throw new SlackApiError(data.error);
119
+ }
120
+ return data;
121
+ }
122
+
123
+ function sleepMs(ms: number): Promise<void> {
124
+ return new Promise((resolve) => setTimeout(resolve, ms));
125
+ }
126
+
127
+ function retryAfterSeconds(header: string | null): number {
128
+ return parseInt(header ?? "", 10) || DEFAULT_RETRY_AFTER_S;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Raw-token requests
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export interface SlackRequestOptions {
136
+ /** Query parameters; presence selects GET (unless `body`/`form` is set). */
137
+ query?: Record<string, string | undefined>;
138
+ /** JSON body; presence selects POST with a JSON content type. */
139
+ body?: Record<string, unknown>;
140
+ /** Form body; presence selects POST with form encoding. */
141
+ form?: URLSearchParams;
142
+ }
143
+
144
+ function buildRequest(
145
+ token: string,
146
+ method: string,
147
+ opts: SlackRequestOptions,
148
+ ): { url: string; init: RequestInit } {
149
+ let url = `${SLACK_API_BASE}/${method}`;
150
+ const headers: Record<string, string> = {
151
+ Authorization: `Bearer ${token}`,
152
+ };
153
+
154
+ if (opts.body) {
155
+ headers["Content-Type"] = "application/json; charset=utf-8";
156
+ return {
157
+ url,
158
+ init: { method: "POST", headers, body: JSON.stringify(opts.body) },
159
+ };
160
+ }
161
+ if (opts.form) {
162
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
163
+ return { url, init: { method: "POST", headers, body: opts.form } };
164
+ }
165
+
166
+ const searchParams = new URLSearchParams();
167
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
168
+ if (v !== undefined) {
169
+ searchParams.set(k, v);
170
+ }
171
+ }
172
+ const query = searchParams.toString();
173
+ if (query) {
174
+ url += `?${query}`;
175
+ }
176
+ return { url, init: { method: "GET", headers } };
177
+ }
178
+
179
+ /**
180
+ * Execute a raw-token Slack Web API request with the shared retry policy:
181
+ * HTTP 429 honors `Retry-After`, 5xx retries with fixed backoff, and
182
+ * body-level rate-limit errors (Slack sometimes returns HTTP 200 +
183
+ * `rate_limited`/`ratelimited`) retry via their classified category. All
184
+ * failures throw the unified `SlackApiError`.
185
+ */
186
+ export async function rawSlackRequest<T extends SlackApiResponse>(
187
+ token: string,
188
+ method: string,
189
+ opts: SlackRequestOptions = {},
190
+ ): Promise<T> {
191
+ const { url, init } = buildRequest(token, method, opts);
192
+
193
+ for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {
194
+ const resp = await fetch(url, init);
195
+
196
+ if (resp.status === 429) {
197
+ if (attempt >= MAX_RATE_LIMIT_RETRIES) {
198
+ throw new SlackApiError("rate_limited", {
199
+ status: 429,
200
+ message: "Slack API rate limited",
201
+ });
202
+ }
203
+ const retryAfter = retryAfterSeconds(resp.headers.get("Retry-After"));
204
+ log.warn({ method, retryAfter, attempt }, "Slack rate limited, retrying");
205
+ await sleepMs(retryAfter * 1000);
206
+ continue;
207
+ }
208
+
209
+ if (resp.status >= 500) {
210
+ if (attempt >= MAX_RATE_LIMIT_RETRIES) {
211
+ throw new SlackApiError(`http_${resp.status}`, {
212
+ status: resp.status,
213
+ message: `Slack ${method} failed with status ${resp.status} after retries`,
214
+ });
215
+ }
216
+ log.warn(
217
+ { method, status: resp.status, attempt },
218
+ "Slack 5xx error, retrying",
219
+ );
220
+ await sleepMs(DEFAULT_RETRY_AFTER_S * 1000);
221
+ continue;
222
+ }
223
+
224
+ if (!resp.ok) {
225
+ throw new SlackApiError(`http_${resp.status}`, {
226
+ status: resp.status,
227
+ message: `Slack API HTTP ${resp.status}`,
228
+ });
229
+ }
230
+
231
+ const data = (await resp.json()) as T;
232
+
233
+ if (
234
+ !data.ok &&
235
+ classifySlackError(data.error) === "rate_limit" &&
236
+ attempt < MAX_RATE_LIMIT_RETRIES
237
+ ) {
238
+ log.warn(
239
+ { method, slackError: data.error, attempt },
240
+ "Slack rate limited (body), retrying",
241
+ );
242
+ await sleepMs(DEFAULT_RETRY_AFTER_S * 1000);
243
+ continue;
244
+ }
245
+
246
+ return checkSlackEnvelope(data);
247
+ }
248
+
249
+ // Unreachable: every loop exit path above either returns or throws.
250
+ throw new SlackApiError("rate_limited", {
251
+ status: 429,
252
+ message: "Slack API rate limited",
253
+ });
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // OAuth-connection requests
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Execute a Slack API request via a refreshing `OAuthConnection`.
262
+ *
263
+ * Slack returns HTTP 200 with `{ ok: false, error: "invalid_auth" }` for auth
264
+ * errors. Because `connection.request()` delegates to `withValidToken`, which
265
+ * only retries HTTP-level 401s, envelope auth errors (classified to
266
+ * `status === 401`) are caught here and retried once via
267
+ * `connection.withToken()`, which forces a token refresh first.
268
+ */
269
+ async function connectionSlackRequest<T extends SlackApiResponse>(
270
+ connection: OAuthConnection,
271
+ method: string,
272
+ opts: SlackRequestOptions,
273
+ ): Promise<T> {
274
+ // `connection.request()` carries only query/body; silently dropping a form
275
+ // payload would send Slack a parameterless request that fails with a
276
+ // misleading Slack-side error. Fail fast instead: form-encoded methods are
277
+ // only reachable on Socket Mode installs, whose auth is a raw token.
278
+ if (opts.form) {
279
+ throw new SlackApiError("form_unsupported_over_oauth", {
280
+ message:
281
+ `Slack ${method} uses a form-encoded body, which is not supported ` +
282
+ "over a legacy OAuth connection; a Socket Mode bot token is required",
283
+ });
284
+ }
285
+
286
+ const query: Record<string, string> | undefined = opts.query
287
+ ? Object.fromEntries(
288
+ Object.entries(opts.query).filter(
289
+ (entry): entry is [string, string] => entry[1] !== undefined,
290
+ ),
291
+ )
292
+ : undefined;
293
+
294
+ for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {
295
+ const resp = await connection.request({
296
+ method: opts.body ? "POST" : "GET",
297
+ path: `/${method}`,
298
+ query: query && Object.keys(query).length > 0 ? query : undefined,
299
+ body: opts.body,
300
+ });
301
+
302
+ if (resp.status === 429) {
303
+ if (attempt >= MAX_RATE_LIMIT_RETRIES) {
304
+ throw new SlackApiError("rate_limited", {
305
+ status: 429,
306
+ message: "Slack API rate limited",
307
+ });
308
+ }
309
+ const retryAfter = retryAfterSeconds(
310
+ resp.headers["retry-after"] ?? resp.headers["Retry-After"] ?? null,
311
+ );
312
+ await sleepMs(retryAfter * 1000);
313
+ continue;
314
+ }
315
+
316
+ if (resp.status >= 400) {
317
+ throw new SlackApiError(`http_${resp.status}`, {
318
+ status: resp.status,
319
+ message: `Slack API HTTP ${resp.status}`,
320
+ });
321
+ }
322
+
323
+ const data = resp.body as T;
324
+
325
+ if (
326
+ !data.ok &&
327
+ classifySlackError(data.error) === "rate_limit" &&
328
+ attempt < MAX_RATE_LIMIT_RETRIES
329
+ ) {
330
+ await sleepMs(DEFAULT_RETRY_AFTER_S * 1000);
331
+ continue;
332
+ }
333
+
334
+ try {
335
+ return checkSlackEnvelope(data);
336
+ } catch (err) {
337
+ if (err instanceof SlackApiError && err.status === 401) {
338
+ return connection.withToken((freshToken) =>
339
+ rawSlackRequest<T>(freshToken, method, opts),
340
+ );
341
+ }
342
+ throw err;
343
+ }
344
+ }
345
+
346
+ // Unreachable: every loop exit path above either returns or throws.
347
+ throw new SlackApiError("rate_limited", {
348
+ status: 429,
349
+ message: "Slack API rate limited",
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Dispatch a Slack Web API request on whichever auth form the caller holds:
355
+ * a raw token (Socket Mode installs) or a refreshing `OAuthConnection`
356
+ * (legacy OAuth installs).
357
+ */
358
+ export async function slackRequest<T extends SlackApiResponse>(
359
+ auth: OAuthConnection | string,
360
+ method: string,
361
+ opts: SlackRequestOptions = {},
362
+ ): Promise<T> {
363
+ if (typeof auth === "string") {
364
+ return rawSlackRequest<T>(auth, method, opts);
365
+ }
366
+ return connectionSlackRequest<T>(auth, method, opts);
367
+ }
@@ -17,7 +17,7 @@
17
17
 
18
18
  import {
19
19
  isParkAction,
20
- PARK_STATUS_LABEL,
20
+ resolveDecisionStatusWord,
21
21
  } from "../../../runtime/channel-approval-types.js";
22
22
  import { getLogger } from "../../../util/logger.js";
23
23
  import { callSlackApi, getSlackMessageBlocks } from "./api.js";
@@ -46,13 +46,6 @@ const STATUS_GLYPH: Record<string, string> = {
46
46
  cancelled: ":no_entry_sign:",
47
47
  };
48
48
 
49
- const STATUS_WORD: Record<string, string> = {
50
- approved: "Approved",
51
- denied: "Denied",
52
- expired: "Expired",
53
- cancelled: "Cancelled",
54
- };
55
-
56
49
  /**
57
50
  * Glyph for a parked (leave-unverified) decision. A neutral "on hold" mark
58
51
  * rather than the `denied` red cross — the sender was neither trusted nor kept
@@ -69,9 +62,10 @@ export interface WithdrawSlackApprovalCardParams {
69
62
  status: string;
70
63
  /**
71
64
  * The action the guardian took, when known. A `denied` status reached by a
72
- * park action (`leave_unverified`) reads as the neutral
73
- * {@link PARK_STATUS_LABEL} rather than "Denied"; `block`/`reject` stay a
74
- * denial. Omitted for status-only transitions (e.g. the expiry sweep).
65
+ * park action (`leave_unverified`) reads as the neutral park label (see
66
+ * {@link resolveDecisionStatusWord}) rather than "Denied"; `block`/`reject`
67
+ * stay a denial. Omitted for status-only transitions (e.g. the expiry
68
+ * sweep).
75
69
  */
76
70
  decidedAction?: string;
77
71
  /** Slack user id of the decider, when the decision came from Slack. */
@@ -87,9 +81,7 @@ export interface WithdrawSlackApprovalCardParams {
87
81
  function buildStatusText(params: WithdrawSlackApprovalCardParams): string {
88
82
  const park = params.status === "denied" && isParkAction(params.decidedAction);
89
83
  const glyph = park ? PARK_STATUS_GLYPH : (STATUS_GLYPH[params.status] ?? "");
90
- const word = park
91
- ? PARK_STATUS_LABEL
92
- : (STATUS_WORD[params.status] ?? "Resolved");
84
+ const word = resolveDecisionStatusWord(params.status, params.decidedAction);
93
85
  let line = glyph ? `${glyph} *${word}*` : `*${word}*`;
94
86
  if (params.decidedByExternalUserId) {
95
87
  line += ` by <@${params.decidedByExternalUserId}>`;
@@ -30,7 +30,7 @@ mock.module("./api.js", () => ({
30
30
  }));
31
31
 
32
32
  const { TelegramNonRetryableError } = await import("./api.js");
33
- const { sendTelegramRichReply } = await import("./send.js");
33
+ const { sendTelegramReply, sendTelegramRichReply } = await import("./send.js");
34
34
  const { telegramTransport } = await import("./transport.js");
35
35
 
36
36
  const approval: ApprovalUIMetadata = {
@@ -148,6 +148,36 @@ describe("sendTelegramRichReply", () => {
148
148
  });
149
149
  });
150
150
 
151
+ describe("sendTelegramReply message id capture", () => {
152
+ test("returns the sent message id so approval cards can be addressed later", async () => {
153
+ callTelegramBotApiMock.mockImplementation(
154
+ async () => ({ message_id: 42 }) as never,
155
+ );
156
+
157
+ const result = await sendTelegramReply("123", "Please approve", approval);
158
+
159
+ expect(result.lastMessageId).toBe("42");
160
+ });
161
+
162
+ test("returns the id of the last chunk for a split message", async () => {
163
+ let nextId = 1;
164
+ callTelegramBotApiMock.mockImplementation(
165
+ async () => ({ message_id: nextId++ }) as never,
166
+ );
167
+
168
+ const result = await sendTelegramReply("123", "x".repeat(4500));
169
+
170
+ expect(callsTo("sendMessage")).toHaveLength(2);
171
+ expect(result.lastMessageId).toBe("2");
172
+ });
173
+
174
+ test("omits the message id when the API response lacks one", async () => {
175
+ const result = await sendTelegramReply("123", "Hello");
176
+
177
+ expect(result.lastMessageId).toBeUndefined();
178
+ });
179
+ });
180
+
151
181
  describe("telegramTransport.deliver routing", () => {
152
182
  const ctx: CallbackContext = { callbackUrl: "/deliver/telegram", params: {} };
153
183
 
@@ -13,6 +13,7 @@ import { getLogger } from "../../../util/logger.js";
13
13
  import {
14
14
  callTelegramBotApi,
15
15
  callTelegramBotApiMultipart,
16
+ type TelegramMessage,
16
17
  TelegramNonRetryableError,
17
18
  } from "./api.js";
18
19
  import { renderTelegramHtml } from "./render.js";
@@ -121,6 +122,17 @@ function buildInlineKeyboard(approval: ApprovalUIMetadata): {
121
122
  // Public API
122
123
  // ---------------------------------------------------------------------------
123
124
 
125
+ /** Outcome of a Telegram reply send. */
126
+ export interface TelegramSendResult {
127
+ /**
128
+ * Channel-native id of the last sent chunk (the message carrying the
129
+ * inline keyboard when an approval was attached). Callers that need to
130
+ * address the message later (e.g. approval-card withdrawal) persist this.
131
+ * Undefined when the API response did not carry a message id.
132
+ */
133
+ lastMessageId?: string;
134
+ }
135
+
124
136
  /**
125
137
  * Send a Telegram text reply, splitting long messages and optionally
126
138
  * attaching inline keyboard buttons for approval prompts.
@@ -130,9 +142,10 @@ export async function sendTelegramReply(
130
142
  text: string,
131
143
  approval?: ApprovalUIMetadata,
132
144
  opts?: TelegramSendOptions,
133
- ): Promise<void> {
145
+ ): Promise<TelegramSendResult> {
134
146
  const chunks = splitText(text, TELEGRAM_MAX_MESSAGE_LEN);
135
147
 
148
+ let lastMessageId: string | undefined;
136
149
  for (let i = 0; i < chunks.length; i++) {
137
150
  const payload: Record<string, unknown> = {
138
151
  chat_id: chatId,
@@ -146,10 +159,18 @@ export async function sendTelegramReply(
146
159
  payload.reply_markup = buildInlineKeyboard(approval);
147
160
  }
148
161
 
149
- await callTelegramBotApi("sendMessage", payload);
162
+ const sent = await callTelegramBotApi<TelegramMessage>(
163
+ "sendMessage",
164
+ payload,
165
+ );
166
+ lastMessageId =
167
+ typeof sent?.message_id === "number"
168
+ ? String(sent.message_id)
169
+ : undefined;
150
170
  }
151
171
 
152
172
  log.debug({ chatId, chunks: chunks.length }, "Telegram reply sent");
173
+ return lastMessageId !== undefined ? { lastMessageId } : {};
153
174
  }
154
175
 
155
176
  /**
@@ -0,0 +1,152 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // Derive the mock signature from the real export so the test cannot drift from
4
+ // the production call signature.
5
+ type CallTelegramBotApi = typeof import("./api.js").callTelegramBotApi;
6
+
7
+ const callTelegramBotApiMock = mock<CallTelegramBotApi>(
8
+ async () => ({}) as never,
9
+ );
10
+
11
+ mock.module("./api.js", () => ({
12
+ callTelegramBotApi: (method: string, body: Record<string, unknown>) =>
13
+ callTelegramBotApiMock(method, body),
14
+ }));
15
+
16
+ const { withdrawTelegramApprovalCard } = await import("./withdraw.js");
17
+
18
+ const callsTo = (method: string) =>
19
+ callTelegramBotApiMock.mock.calls.filter((call) => call[0] === method);
20
+
21
+ beforeEach(() => {
22
+ callTelegramBotApiMock.mockReset();
23
+ callTelegramBotApiMock.mockImplementation(async () => ({}) as never);
24
+ });
25
+
26
+ describe("withdrawTelegramApprovalCard", () => {
27
+ test("clears the inline keyboard and posts a silent quoted status reply", async () => {
28
+ await withdrawTelegramApprovalCard({
29
+ chatId: "chat-1",
30
+ messageId: "42",
31
+ status: "approved",
32
+ postStatusReply: true,
33
+ });
34
+
35
+ const edits = callsTo("editMessageReplyMarkup");
36
+ expect(edits).toHaveLength(1);
37
+ expect(edits[0]?.[1]).toEqual({
38
+ chat_id: "chat-1",
39
+ message_id: 42,
40
+ reply_markup: null,
41
+ });
42
+
43
+ const sends = callsTo("sendMessage");
44
+ expect(sends).toHaveLength(1);
45
+ expect(sends[0]?.[1]).toEqual({
46
+ chat_id: "chat-1",
47
+ text: "✅ Approved",
48
+ reply_parameters: { message_id: 42, allow_sending_without_reply: true },
49
+ disable_notification: true,
50
+ });
51
+ });
52
+
53
+ test("skips the status reply when the decision originated on Telegram", async () => {
54
+ await withdrawTelegramApprovalCard({
55
+ chatId: "chat-1",
56
+ messageId: "42",
57
+ status: "denied",
58
+ postStatusReply: false,
59
+ });
60
+
61
+ expect(callsTo("editMessageReplyMarkup")).toHaveLength(1);
62
+ expect(callsTo("sendMessage")).toHaveLength(0);
63
+ });
64
+
65
+ test("falls back to an empty inline keyboard when the null form is rejected", async () => {
66
+ callTelegramBotApiMock.mockImplementationOnce(async () => {
67
+ throw new Error("Bad Request: REPLY_MARKUP_INVALID");
68
+ });
69
+
70
+ await withdrawTelegramApprovalCard({
71
+ chatId: "chat-1",
72
+ messageId: "7",
73
+ status: "expired",
74
+ postStatusReply: true,
75
+ });
76
+
77
+ const edits = callsTo("editMessageReplyMarkup");
78
+ expect(edits).toHaveLength(2);
79
+ expect(edits[1]?.[1]).toEqual({
80
+ chat_id: "chat-1",
81
+ message_id: 7,
82
+ reply_markup: { inline_keyboard: [] },
83
+ });
84
+ expect(callsTo("sendMessage")).toHaveLength(1);
85
+ expect(callsTo("sendMessage")[0]?.[1]).toMatchObject({
86
+ text: "⌛ Expired",
87
+ });
88
+ });
89
+
90
+ test("treats 'message is not modified' as already cleared", async () => {
91
+ callTelegramBotApiMock.mockImplementationOnce(async () => {
92
+ throw new Error("Bad Request: message is not modified");
93
+ });
94
+
95
+ await withdrawTelegramApprovalCard({
96
+ chatId: "chat-1",
97
+ messageId: "7",
98
+ status: "approved",
99
+ postStatusReply: true,
100
+ });
101
+
102
+ // No empty-keyboard retry: the keyboard is already gone.
103
+ expect(callsTo("editMessageReplyMarkup")).toHaveLength(1);
104
+ expect(callsTo("sendMessage")).toHaveLength(1);
105
+ });
106
+
107
+ test("still posts the status reply when both keyboard edits fail", async () => {
108
+ callTelegramBotApiMock.mockImplementation(
109
+ async (method: string): Promise<never> => {
110
+ if (method === "editMessageReplyMarkup") {
111
+ throw new Error("Bad Request: message to edit not found");
112
+ }
113
+ return {} as never;
114
+ },
115
+ );
116
+
117
+ await withdrawTelegramApprovalCard({
118
+ chatId: "chat-1",
119
+ messageId: "7",
120
+ status: "approved",
121
+ postStatusReply: true,
122
+ });
123
+
124
+ expect(callsTo("editMessageReplyMarkup")).toHaveLength(2);
125
+ expect(callsTo("sendMessage")).toHaveLength(1);
126
+ });
127
+
128
+ test("renders a parked denial with the neutral park label", async () => {
129
+ await withdrawTelegramApprovalCard({
130
+ chatId: "chat-1",
131
+ messageId: "9",
132
+ status: "denied",
133
+ decidedAction: "leave_unverified",
134
+ postStatusReply: true,
135
+ });
136
+
137
+ expect(callsTo("sendMessage")[0]?.[1]).toMatchObject({
138
+ text: "⏸️ Left unverified",
139
+ });
140
+ });
141
+
142
+ test("skips everything on a non-numeric message id", async () => {
143
+ await withdrawTelegramApprovalCard({
144
+ chatId: "chat-1",
145
+ messageId: "not-a-number",
146
+ status: "approved",
147
+ postStatusReply: true,
148
+ });
149
+
150
+ expect(callTelegramBotApiMock.mock.calls).toHaveLength(0);
151
+ });
152
+ });