@vellumai/assistant 0.3.5 → 0.3.7

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 (487) hide show
  1. package/README.md +51 -0
  2. package/eslint.config.mjs +31 -0
  3. package/package.json +1 -1
  4. package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
  5. package/scripts/ipc/generate-swift.ts +18 -2
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +338 -1
  7. package/src/__tests__/approval-conversation-turn.test.ts +214 -0
  8. package/src/__tests__/browser-manager.test.ts +1 -0
  9. package/src/__tests__/call-conversation-messages.test.ts +130 -0
  10. package/src/__tests__/call-orchestrator.test.ts +752 -271
  11. package/src/__tests__/call-pointer-messages.test.ts +148 -0
  12. package/src/__tests__/call-recovery.test.ts +3 -0
  13. package/src/__tests__/call-routes-http.test.ts +5 -0
  14. package/src/__tests__/call-store.test.ts +3 -0
  15. package/src/__tests__/channel-approval-routes.test.ts +1260 -85
  16. package/src/__tests__/channel-approval.test.ts +37 -0
  17. package/src/__tests__/channel-approvals.test.ts +4 -65
  18. package/src/__tests__/channel-guardian.test.ts +556 -0
  19. package/src/__tests__/channel-readiness-service.test.ts +74 -7
  20. package/src/__tests__/checker.test.ts +14 -7
  21. package/src/__tests__/clarification-resolver.test.ts +44 -24
  22. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
  23. package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
  24. package/src/__tests__/config-schema.test.ts +12 -7
  25. package/src/__tests__/context-window-manager.test.ts +30 -2
  26. package/src/__tests__/contradiction-checker.test.ts +20 -5
  27. package/src/__tests__/credential-security-invariants.test.ts +6 -2
  28. package/src/__tests__/db-migration-rollback.test.ts +752 -0
  29. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
  30. package/src/__tests__/fuzzy-match-property.test.ts +5 -5
  31. package/src/__tests__/guardian-action-store.test.ts +123 -0
  32. package/src/__tests__/guardian-action-sweep.test.ts +277 -0
  33. package/src/__tests__/guardian-dispatch.test.ts +389 -0
  34. package/src/__tests__/guardian-question-copy.test.ts +47 -0
  35. package/src/__tests__/handlers-telegram-config.test.ts +4 -2
  36. package/src/__tests__/handlers-twilio-config.test.ts +126 -0
  37. package/src/__tests__/intent-routing.test.ts +2 -0
  38. package/src/__tests__/ipc-snapshot.test.ts +228 -1
  39. package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
  40. package/src/__tests__/model-intents.test.ts +96 -0
  41. package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
  42. package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
  43. package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
  44. package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
  45. package/src/__tests__/provider-error-scenarios.test.ts +621 -0
  46. package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
  47. package/src/__tests__/qdrant-manager.test.ts +27 -20
  48. package/src/__tests__/relay-server.test.ts +779 -40
  49. package/src/__tests__/run-orchestrator-assistant-events.test.ts +2 -0
  50. package/src/__tests__/run-orchestrator.test.ts +20 -4
  51. package/src/__tests__/runtime-runs-http.test.ts +17 -1
  52. package/src/__tests__/runtime-runs.test.ts +16 -0
  53. package/src/__tests__/schedule-store.test.ts +18 -4
  54. package/src/__tests__/scheduler-recurrence.test.ts +13 -4
  55. package/src/__tests__/session-abort-tool-results.test.ts +6 -0
  56. package/src/__tests__/session-agent-loop.test.ts +857 -0
  57. package/src/__tests__/session-conflict-gate.test.ts +6 -0
  58. package/src/__tests__/session-pre-run-repair.test.ts +6 -0
  59. package/src/__tests__/session-profile-injection.test.ts +6 -0
  60. package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
  61. package/src/__tests__/session-queue.test.ts +6 -0
  62. package/src/__tests__/session-runtime-assembly.test.ts +237 -13
  63. package/src/__tests__/session-slash-known.test.ts +6 -0
  64. package/src/__tests__/session-slash-queue.test.ts +6 -0
  65. package/src/__tests__/session-slash-unknown.test.ts +6 -0
  66. package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
  67. package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
  68. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
  69. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
  70. package/src/__tests__/session-workspace-injection.test.ts +6 -0
  71. package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
  72. package/src/__tests__/skills.test.ts +2 -0
  73. package/src/__tests__/sms-messaging-provider.test.ts +2 -1
  74. package/src/__tests__/starter-task-flow.test.ts +2 -0
  75. package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
  76. package/src/__tests__/system-prompt.test.ts +2 -0
  77. package/src/__tests__/task-management-tools.test.ts +2 -2
  78. package/src/__tests__/task-runner.test.ts +14 -4
  79. package/src/__tests__/terminal-tools.test.ts +25 -19
  80. package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
  81. package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
  82. package/src/__tests__/tool-executor.test.ts +23 -24
  83. package/src/__tests__/trust-store.test.ts +3 -3
  84. package/src/__tests__/twilio-rest.test.ts +29 -0
  85. package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
  86. package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
  87. package/src/__tests__/twilio-routes.test.ts +141 -21
  88. package/src/__tests__/user-reference.test.ts +2 -0
  89. package/src/__tests__/voice-quality.test.ts +222 -0
  90. package/src/__tests__/web-search.test.ts +45 -29
  91. package/src/agent/loop.ts +1 -1
  92. package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
  93. package/src/amazon/client.ts +1418 -0
  94. package/src/amazon/request-extractor.ts +135 -0
  95. package/src/amazon/session.ts +109 -0
  96. package/src/autonomy/autonomy-store.ts +5 -5
  97. package/src/browser-extension-relay/client.ts +124 -0
  98. package/src/browser-extension-relay/protocol.ts +63 -0
  99. package/src/browser-extension-relay/server.ts +177 -0
  100. package/src/bundler/app-bundler.ts +3 -3
  101. package/src/bundler/bundle-signer.ts +1 -1
  102. package/src/bundler/signature-verifier.ts +1 -1
  103. package/src/calls/call-conversation-messages.ts +33 -0
  104. package/src/calls/call-domain.ts +106 -5
  105. package/src/calls/call-orchestrator.ts +252 -54
  106. package/src/calls/call-pointer-messages.ts +53 -0
  107. package/src/calls/call-recovery.ts +3 -8
  108. package/src/calls/call-store.ts +69 -87
  109. package/src/calls/elevenlabs-config.ts +3 -2
  110. package/src/calls/guardian-action-sweep.ts +105 -0
  111. package/src/calls/guardian-dispatch.ts +203 -0
  112. package/src/calls/guardian-question-copy.ts +133 -0
  113. package/src/calls/relay-server.ts +466 -8
  114. package/src/calls/speaker-identification.ts +1 -1
  115. package/src/calls/twilio-config.ts +7 -5
  116. package/src/calls/twilio-provider.ts +6 -4
  117. package/src/calls/twilio-rest.ts +40 -15
  118. package/src/calls/twilio-routes.ts +60 -45
  119. package/src/calls/types.ts +3 -1
  120. package/src/channels/types.ts +25 -0
  121. package/src/cli/amazon.ts +815 -0
  122. package/src/cli/config-commands.ts +2 -2
  123. package/src/cli/core-commands.ts +4 -3
  124. package/src/cli/influencer.ts +244 -0
  125. package/src/cli/map.ts +89 -6
  126. package/src/cli.ts +1 -1
  127. package/src/config/agent-schema.ts +171 -0
  128. package/src/config/bundled-skills/amazon/SKILL.md +127 -0
  129. package/src/config/bundled-skills/amazon/icon.svg +13 -0
  130. package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
  131. package/src/config/bundled-skills/browser/SKILL.md +1 -0
  132. package/src/config/bundled-skills/browser/TOOLS.json +17 -0
  133. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
  134. package/src/config/bundled-skills/doordash/SKILL.md +51 -51
  135. package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
  136. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
  137. package/src/config/bundled-skills/influencer/SKILL.md +144 -0
  138. package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
  139. package/src/config/bundled-skills/media-processing/SKILL.md +72 -95
  140. package/src/config/bundled-skills/media-processing/TOOLS.json +57 -147
  141. package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
  142. package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
  143. package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
  144. package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
  145. package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
  146. package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
  147. package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
  148. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +7 -9
  149. package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
  150. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +88 -253
  151. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +22 -153
  152. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +2 -2
  153. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +28 -51
  154. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +35 -270
  155. package/src/config/bundled-skills/messaging/SKILL.md +12 -2
  156. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
  157. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
  158. package/src/config/bundled-skills/phone-calls/SKILL.md +86 -21
  159. package/src/config/bundled-skills/twitter/icon.svg +14 -0
  160. package/src/config/bundled-tool-registry.ts +310 -0
  161. package/src/config/calls-schema.ts +181 -0
  162. package/src/config/core-schema.ts +309 -0
  163. package/src/config/defaults.ts +27 -3
  164. package/src/config/env-registry.ts +169 -0
  165. package/src/config/env.ts +175 -0
  166. package/src/config/loader.ts +6 -6
  167. package/src/config/memory-schema.ts +528 -0
  168. package/src/config/sandbox-schema.ts +55 -0
  169. package/src/config/schema.ts +157 -1138
  170. package/src/config/skill-state.ts +1 -1
  171. package/src/config/skills-schema.ts +32 -0
  172. package/src/config/skills.ts +35 -24
  173. package/src/config/system-prompt.ts +107 -56
  174. package/src/config/templates/SOUL.md +1 -1
  175. package/src/config/types.ts +1 -0
  176. package/src/config/user-reference.ts +4 -9
  177. package/src/config/vellum-skills/catalog.json +0 -7
  178. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
  179. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +1 -0
  180. package/src/config/vellum-skills/sms-setup/SKILL.md +112 -14
  181. package/src/context/window-manager.ts +27 -7
  182. package/src/daemon/approval-generators.ts +186 -0
  183. package/src/daemon/approved-devices-store.ts +140 -0
  184. package/src/daemon/assistant-attachments.ts +1 -1
  185. package/src/daemon/classifier.ts +35 -32
  186. package/src/daemon/config-watcher.ts +1 -1
  187. package/src/daemon/daemon-control.ts +254 -0
  188. package/src/daemon/handlers/apps.ts +2 -3
  189. package/src/daemon/handlers/config-channels.ts +158 -0
  190. package/src/daemon/handlers/config-inbox.ts +540 -0
  191. package/src/daemon/handlers/config-ingress.ts +231 -0
  192. package/src/daemon/handlers/config-integrations.ts +258 -0
  193. package/src/daemon/handlers/config-model.ts +143 -0
  194. package/src/daemon/handlers/config-parental.ts +163 -0
  195. package/src/daemon/handlers/config-scheduling.ts +172 -0
  196. package/src/daemon/handlers/config-slack.ts +92 -0
  197. package/src/daemon/handlers/config-telegram.ts +301 -0
  198. package/src/daemon/handlers/config-tools.ts +177 -0
  199. package/src/daemon/handlers/config-trust.ts +104 -0
  200. package/src/daemon/handlers/config-twilio.ts +1080 -0
  201. package/src/daemon/handlers/config.ts +53 -2463
  202. package/src/daemon/handlers/diagnostics.ts +1 -1
  203. package/src/daemon/handlers/dictation.ts +4 -6
  204. package/src/daemon/handlers/documents.ts +18 -32
  205. package/src/daemon/handlers/index.ts +9 -0
  206. package/src/daemon/handlers/misc.ts +3 -5
  207. package/src/daemon/handlers/pairing.ts +98 -0
  208. package/src/daemon/handlers/sessions.ts +74 -5
  209. package/src/daemon/handlers/shared.ts +3 -1
  210. package/src/daemon/handlers/skills.ts +1 -1
  211. package/src/daemon/handlers/twitter-auth.ts +2 -0
  212. package/src/daemon/handlers/work-items.ts +2 -2
  213. package/src/daemon/handlers/workspace-files.ts +4 -3
  214. package/src/daemon/install-cli-launchers.ts +113 -0
  215. package/src/daemon/ipc-contract/apps.ts +356 -0
  216. package/src/daemon/ipc-contract/browser.ts +74 -0
  217. package/src/daemon/ipc-contract/computer-use.ts +151 -0
  218. package/src/daemon/ipc-contract/diagnostics.ts +56 -0
  219. package/src/daemon/ipc-contract/documents.ts +74 -0
  220. package/src/daemon/ipc-contract/inbox.ts +209 -0
  221. package/src/daemon/ipc-contract/integrations.ts +284 -0
  222. package/src/daemon/ipc-contract/memory.ts +48 -0
  223. package/src/daemon/ipc-contract/messages.ts +211 -0
  224. package/src/daemon/ipc-contract/pairing.ts +45 -0
  225. package/src/daemon/ipc-contract/parental-control.ts +95 -0
  226. package/src/daemon/ipc-contract/schedules.ts +97 -0
  227. package/src/daemon/ipc-contract/sessions.ts +321 -0
  228. package/src/daemon/ipc-contract/shared.ts +42 -0
  229. package/src/daemon/ipc-contract/skills.ts +120 -0
  230. package/src/daemon/ipc-contract/subagents.ts +58 -0
  231. package/src/daemon/ipc-contract/surfaces.ts +250 -0
  232. package/src/daemon/ipc-contract/trust.ts +60 -0
  233. package/src/daemon/ipc-contract/work-items.ts +225 -0
  234. package/src/daemon/ipc-contract/workspace.ts +113 -0
  235. package/src/daemon/ipc-contract-inventory.json +62 -0
  236. package/src/daemon/ipc-contract-inventory.ts +55 -29
  237. package/src/daemon/ipc-contract.ts +227 -2527
  238. package/src/daemon/ipc-protocol.ts +1 -1
  239. package/src/daemon/ipc-validate.ts +7 -0
  240. package/src/daemon/lifecycle.ts +97 -379
  241. package/src/daemon/pairing-store.ts +177 -0
  242. package/src/daemon/providers-setup.ts +43 -0
  243. package/src/daemon/ride-shotgun-handler.ts +67 -2
  244. package/src/daemon/server.ts +60 -44
  245. package/src/daemon/session-agent-loop-handlers.ts +421 -0
  246. package/src/daemon/session-agent-loop.ts +113 -275
  247. package/src/daemon/session-dynamic-profile.ts +1 -1
  248. package/src/daemon/session-history.ts +1 -1
  249. package/src/daemon/session-media-retry.ts +1 -1
  250. package/src/daemon/session-messaging.ts +37 -2
  251. package/src/daemon/session-notifiers.ts +5 -25
  252. package/src/daemon/session-process.ts +99 -59
  253. package/src/daemon/session-queue-manager.ts +98 -4
  254. package/src/daemon/session-runtime-assembly.ts +149 -15
  255. package/src/daemon/session-surfaces.ts +26 -4
  256. package/src/daemon/session-tool-setup.ts +28 -30
  257. package/src/daemon/session-workspace.ts +1 -1
  258. package/src/daemon/session.ts +24 -1
  259. package/src/daemon/shutdown-handlers.ts +122 -0
  260. package/src/daemon/trace-emitter.ts +1 -1
  261. package/src/daemon/watch-handler.ts +36 -33
  262. package/src/doordash/cart-queries.ts +787 -0
  263. package/src/doordash/client.ts +144 -127
  264. package/src/doordash/order-queries.ts +85 -0
  265. package/src/doordash/queries.ts +10 -1308
  266. package/src/doordash/search-queries.ts +203 -0
  267. package/src/doordash/session.ts +3 -2
  268. package/src/doordash/store-queries.ts +246 -0
  269. package/src/doordash/types.ts +367 -0
  270. package/src/email/providers/agentmail.ts +2 -1
  271. package/src/email/providers/index.ts +3 -2
  272. package/src/email/service.ts +3 -2
  273. package/src/errors.ts +43 -0
  274. package/src/home-base/prebuilt/seed.ts +1 -1
  275. package/src/hooks/cli.ts +6 -5
  276. package/src/hooks/config.ts +6 -8
  277. package/src/hooks/discovery.ts +6 -5
  278. package/src/hooks/manager.ts +4 -3
  279. package/src/hooks/runner.ts +2 -2
  280. package/src/hooks/templates.ts +5 -5
  281. package/src/inbound/public-ingress-urls.ts +3 -1
  282. package/src/index.ts +4 -2
  283. package/src/influencer/client.ts +1104 -0
  284. package/src/instrument.ts +4 -3
  285. package/src/logfire.ts +4 -3
  286. package/src/memory/admin.ts +25 -35
  287. package/src/memory/attachments-store.ts +4 -7
  288. package/src/memory/channel-delivery-store.ts +30 -1
  289. package/src/memory/channel-guardian-store.ts +200 -1
  290. package/src/memory/clarification-resolver.ts +37 -33
  291. package/src/memory/conflict-store.ts +67 -61
  292. package/src/memory/contradiction-checker.ts +141 -117
  293. package/src/memory/conversation-store.ts +335 -51
  294. package/src/memory/db-connection.ts +27 -4
  295. package/src/memory/db-init.ts +121 -4
  296. package/src/memory/db.ts +14 -1
  297. package/src/memory/embedding-backend.ts +27 -5
  298. package/src/memory/embedding-ollama.ts +2 -1
  299. package/src/memory/entity-extractor.ts +38 -35
  300. package/src/memory/guardian-action-store.ts +430 -0
  301. package/src/memory/inbox-escalation-projection.ts +59 -0
  302. package/src/memory/inbox-thread-store.ts +218 -0
  303. package/src/memory/ingress-invite-store.ts +338 -0
  304. package/src/memory/ingress-member-store.ts +350 -0
  305. package/src/memory/items-extractor.ts +91 -97
  306. package/src/memory/job-handlers/index-maintenance.ts +3 -3
  307. package/src/memory/job-handlers/media-processing.ts +11 -42
  308. package/src/memory/job-handlers/summarization.ts +32 -26
  309. package/src/memory/job-utils.ts +3 -10
  310. package/src/memory/jobs-store.ts +6 -9
  311. package/src/memory/jobs-worker.ts +51 -36
  312. package/src/memory/migrations/001-job-deferrals.ts +45 -0
  313. package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
  314. package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
  315. package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
  316. package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
  317. package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
  318. package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
  319. package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
  320. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
  321. package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
  322. package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
  323. package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
  324. package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
  325. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
  326. package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
  327. package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
  328. package/src/memory/migrations/017-memory-items-indexes.ts +12 -0
  329. package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
  330. package/src/memory/migrations/index.ts +24 -0
  331. package/src/memory/migrations/registry.ts +79 -0
  332. package/src/memory/migrations/validate-migration-state.ts +69 -0
  333. package/src/memory/qdrant-manager.ts +49 -8
  334. package/src/memory/query-builder.ts +1 -1
  335. package/src/memory/raw-query.ts +119 -0
  336. package/src/memory/recall-cache.ts +4 -1
  337. package/src/memory/retriever.ts +163 -47
  338. package/src/memory/schema-migration.ts +25 -984
  339. package/src/memory/schema.ts +130 -7
  340. package/src/memory/search/entity.ts +10 -19
  341. package/src/memory/search/lexical.ts +81 -52
  342. package/src/memory/search/ranking.ts +21 -22
  343. package/src/memory/search/semantic.ts +157 -19
  344. package/src/memory/shared-app-links-store.ts +4 -5
  345. package/src/memory/validation.ts +19 -0
  346. package/src/messaging/draft-store.ts +5 -6
  347. package/src/messaging/providers/sms/adapter.ts +3 -6
  348. package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
  349. package/src/messaging/providers/whatsapp/adapter.ts +136 -0
  350. package/src/messaging/providers/whatsapp/client.ts +67 -0
  351. package/src/messaging/style-analyzer.ts +5 -4
  352. package/src/messaging/thread-summarizer.ts +61 -69
  353. package/src/messaging/triage-engine.ts +62 -71
  354. package/src/migrations/config-merge.ts +53 -0
  355. package/src/migrations/data-layout.ts +68 -0
  356. package/src/migrations/data-merge.ts +33 -0
  357. package/src/migrations/hooks-merge.ts +90 -0
  358. package/src/migrations/index.ts +6 -0
  359. package/src/migrations/log.ts +23 -0
  360. package/src/migrations/skills-merge.ts +33 -0
  361. package/src/migrations/workspace-layout.ts +79 -0
  362. package/src/permissions/checker.ts +126 -11
  363. package/src/permissions/prompter.ts +14 -0
  364. package/src/permissions/shell-identity.ts +31 -1
  365. package/src/permissions/trust-store.ts +21 -1
  366. package/src/providers/anthropic/client.ts +4 -4
  367. package/src/providers/failover.ts +2 -2
  368. package/src/providers/model-intents.ts +70 -0
  369. package/src/providers/ollama/client.ts +2 -1
  370. package/src/providers/provider-send-message.ts +176 -0
  371. package/src/providers/registry.ts +71 -30
  372. package/src/providers/retry.ts +35 -1
  373. package/src/providers/types.ts +12 -1
  374. package/src/runtime/approval-conversation-turn.ts +97 -0
  375. package/src/runtime/approval-message-composer.ts +115 -5
  376. package/src/runtime/assistant-event-hub.ts +3 -1
  377. package/src/runtime/channel-approval-parser.ts +36 -2
  378. package/src/runtime/channel-approvals.ts +0 -21
  379. package/src/runtime/channel-guardian-service.ts +48 -7
  380. package/src/runtime/channel-readiness-service.ts +160 -34
  381. package/src/runtime/channel-readiness-types.ts +10 -4
  382. package/src/runtime/channel-retry-sweep.ts +184 -0
  383. package/src/runtime/guardian-context-resolver.ts +108 -0
  384. package/src/runtime/http-server.ts +289 -745
  385. package/src/runtime/http-types.ts +56 -3
  386. package/src/runtime/middleware/auth.ts +116 -0
  387. package/src/runtime/middleware/error-handler.ts +33 -0
  388. package/src/runtime/middleware/twilio-validation.ts +127 -0
  389. package/src/runtime/routes/app-routes.ts +1 -1
  390. package/src/runtime/routes/call-routes.ts +49 -6
  391. package/src/runtime/routes/channel-delivery-routes.ts +170 -0
  392. package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
  393. package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
  394. package/src/runtime/routes/channel-route-shared.ts +144 -0
  395. package/src/runtime/routes/channel-routes.ts +32 -1634
  396. package/src/runtime/routes/conversation-routes.ts +50 -7
  397. package/src/runtime/routes/events-routes.ts +2 -2
  398. package/src/runtime/routes/identity-routes.ts +126 -0
  399. package/src/runtime/routes/pairing-routes.ts +144 -0
  400. package/src/runtime/routes/run-routes.ts +15 -1
  401. package/src/runtime/run-orchestrator.ts +52 -34
  402. package/src/schedule/schedule-store.ts +36 -32
  403. package/src/schedule/scheduler.ts +3 -3
  404. package/src/security/encrypted-store.ts +5 -7
  405. package/src/security/oauth2.ts +45 -15
  406. package/src/security/parental-control-store.ts +183 -0
  407. package/src/security/secret-allowlist.ts +4 -3
  408. package/src/security/secret-scanner.ts +5 -5
  409. package/src/security/secure-keys.ts +1 -1
  410. package/src/security/token-manager.ts +3 -2
  411. package/src/services/vercel-deploy.ts +6 -2
  412. package/src/skills/tool-manifest.ts +3 -3
  413. package/src/skills/vellum-catalog-remote.ts +75 -16
  414. package/src/slack/slack-webhook.ts +2 -1
  415. package/src/swarm/orchestrator.ts +92 -1
  416. package/src/swarm/router-planner.ts +6 -9
  417. package/src/swarm/worker-prompts.ts +9 -12
  418. package/src/tasks/task-compiler.ts +19 -28
  419. package/src/tasks/task-runner.ts +1 -1
  420. package/src/tools/assets/search.ts +15 -14
  421. package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
  422. package/src/tools/browser/auto-navigate.ts +1 -0
  423. package/src/tools/browser/browser-execution.ts +13 -1
  424. package/src/tools/browser/browser-manager.ts +119 -4
  425. package/src/tools/browser/network-recorder.ts +5 -0
  426. package/src/tools/credentials/broker.ts +11 -2
  427. package/src/tools/credentials/metadata-store.ts +18 -14
  428. package/src/tools/credentials/post-connect-hooks.ts +61 -0
  429. package/src/tools/credentials/vault.ts +49 -23
  430. package/src/tools/executor.ts +80 -18
  431. package/src/tools/host-terminal/cli-discover.ts +1 -1
  432. package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
  433. package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
  434. package/src/tools/network/script-proxy/server.ts +1 -1
  435. package/src/tools/network/script-proxy/session-manager.ts +6 -5
  436. package/src/tools/network/web-fetch.ts +18 -2
  437. package/src/tools/network/web-search.ts +7 -3
  438. package/src/tools/reminder/reminder-store.ts +14 -15
  439. package/src/tools/schedule/create.ts +1 -0
  440. package/src/tools/schedule/list.ts +2 -1
  441. package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
  442. package/src/tools/skills/skill-script-runner.ts +24 -9
  443. package/src/tools/skills/skill-tool-factory.ts +1 -0
  444. package/src/tools/tasks/work-item-enqueue.ts +2 -2
  445. package/src/tools/terminal/evaluate-typescript.ts +21 -12
  446. package/src/tools/terminal/parser.ts +50 -0
  447. package/src/tools/watcher/delete.ts +6 -0
  448. package/src/tools/weather/service.ts +1 -1
  449. package/src/twitter/client.ts +190 -24
  450. package/src/twitter/session.ts +4 -3
  451. package/src/util/clipboard.ts +1 -1
  452. package/src/util/errors.ts +65 -8
  453. package/src/util/fs.ts +40 -0
  454. package/src/util/json.ts +10 -0
  455. package/src/util/log-redact.ts +189 -0
  456. package/src/util/logger.ts +25 -18
  457. package/src/util/object.ts +3 -0
  458. package/src/util/platform.ts +72 -365
  459. package/src/util/pricing.ts +1 -1
  460. package/src/util/promise-guard.ts +1 -1
  461. package/src/util/retry.ts +19 -0
  462. package/src/util/row-mapper.ts +79 -0
  463. package/src/util/silently.ts +21 -0
  464. package/src/watcher/engine.ts +5 -1
  465. package/src/watcher/provider-types.ts +20 -0
  466. package/src/watcher/providers/github.ts +156 -0
  467. package/src/watcher/providers/gmail.ts +1 -0
  468. package/src/watcher/providers/google-calendar.ts +1 -0
  469. package/src/watcher/providers/linear.ts +460 -0
  470. package/src/watcher/providers/slack.ts +1 -0
  471. package/src/work-items/work-item-runner.ts +1 -1
  472. package/src/workspace/git-service.ts +1 -1
  473. package/src/workspace/provider-commit-message-generator.ts +51 -22
  474. package/src/__tests__/call-bridge.test.ts +0 -517
  475. package/src/__tests__/session-process-bridge.test.ts +0 -244
  476. package/src/calls/call-bridge.ts +0 -168
  477. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +0 -137
  478. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +0 -280
  479. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +0 -144
  480. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +0 -136
  481. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +0 -95
  482. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +0 -267
  483. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +0 -110
  484. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +0 -235
  485. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +0 -142
  486. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +0 -150
  487. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +0 -199
@@ -54,6 +54,7 @@ import * as conversationStore from '../memory/conversation-store.js';
54
54
  import {
55
55
  createBinding,
56
56
  createApprovalRequest,
57
+ getAllPendingApprovalsByGuardianChat,
57
58
  getPendingApprovalForRun,
58
59
  getUnresolvedApprovalForRun,
59
60
  } from '../memory/channel-guardian-store.js';
@@ -355,10 +356,10 @@ describe('inbound text matching approval phrases triggers decision handling', ()
355
356
  });
356
357
 
357
358
  // ═══════════════════════════════════════════════════════════════════════════
358
- // 4. Non-decision messages during pending approval trigger reminder
359
+ // 4. Non-decision messages during pending approval (no conversational engine)
359
360
  // ═══════════════════════════════════════════════════════════════════════════
360
361
 
361
- describe('non-decision messages during pending approval trigger reminder', () => {
362
+ describe('non-decision messages during pending approval (legacy fallback)', () => {
362
363
  beforeEach(() => {
363
364
  createBinding({
364
365
  assistantId: 'self',
@@ -368,9 +369,8 @@ describe('non-decision messages during pending approval trigger reminder', () =>
368
369
  });
369
370
  });
370
371
 
371
- test('sends a reminder prompt when message is not a decision', async () => {
372
+ test('sends a status reply when message is not a decision and no conversational engine', async () => {
372
373
  const orchestrator = makeMockOrchestrator();
373
- const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
374
374
  const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
375
375
 
376
376
  const initReq = makeInboundRequest({ content: 'init' });
@@ -391,18 +391,19 @@ describe('non-decision messages during pending approval trigger reminder', () =>
391
391
  const body = await res.json() as Record<string, unknown>;
392
392
 
393
393
  expect(body.accepted).toBe(true);
394
- expect(body.approval).toBe('reminder_sent');
394
+ expect(body.approval).toBe('assistant_turn');
395
395
 
396
- // The approval prompt delivery should have been called
397
- expect(deliverSpy).toHaveBeenCalled();
398
- const callArgs = deliverSpy.mock.calls[0];
399
- // The text should contain the reminder prefix
400
- expect(callArgs[2]).toContain("I'm still waiting");
401
- // The approval UI metadata should be present
402
- expect(callArgs[3]).toBeDefined();
403
- expect(callArgs[3]!.runId).toBe(run.id);
396
+ // A status reply should have been delivered via deliverChannelReply
397
+ expect(replySpy).toHaveBeenCalled();
398
+ const statusCall = replySpy.mock.calls.find(
399
+ (call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'chat-123',
400
+ );
401
+ expect(statusCall).toBeDefined();
402
+ const statusPayload = statusCall![1] as { text?: string };
403
+ // The status text is generated by composeApprovalMessageGenerative
404
+ // with reminder_prompt scenario — it should mention a pending approval.
405
+ expect(statusPayload.text).toContain('pending approval request');
404
406
 
405
- deliverSpy.mockRestore();
406
407
  replySpy.mockRestore();
407
408
  });
408
409
  });
@@ -1522,10 +1523,10 @@ describe('SMS channel approval decisions', () => {
1522
1523
  deliverSpy.mockRestore();
1523
1524
  });
1524
1525
 
1525
- test('non-decision SMS message during pending approval triggers reminder with plain-text fallback', async () => {
1526
+ test('non-decision SMS message during pending approval sends status reply', async () => {
1526
1527
  const orchestrator = makeMockOrchestrator();
1527
- const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1528
- const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1528
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1529
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1529
1530
 
1530
1531
  const initReq = makeSmsInboundRequest({ content: 'init' });
1531
1532
  await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
@@ -1543,17 +1544,23 @@ describe('SMS channel approval decisions', () => {
1543
1544
  const body = await res.json() as Record<string, unknown>;
1544
1545
 
1545
1546
  expect(body.accepted).toBe(true);
1546
- expect(body.approval).toBe('reminder_sent');
1547
+ expect(body.approval).toBe('assistant_turn');
1547
1548
 
1548
- // SMS is a non-rich channel so the delivered text should include plain-text fallback
1549
+ // SMS non-decision: status reply delivered via plain text
1549
1550
  expect(deliverSpy).toHaveBeenCalled();
1550
- const callArgs = deliverSpy.mock.calls[0];
1551
- const deliveredText = callArgs[2] as string;
1552
- expect(deliveredText).toContain("I'm still waiting");
1553
- expect(deliveredText).toContain('Reply "yes"');
1551
+ expect(approvalSpy).not.toHaveBeenCalled();
1552
+ const statusCall = deliverSpy.mock.calls.find(
1553
+ (call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'sms-chat-123',
1554
+ );
1555
+ expect(statusCall).toBeDefined();
1556
+ const statusPayload = statusCall![1] as { text?: string; approval?: unknown };
1557
+ const deliveredText = statusPayload.text ?? '';
1558
+ // Status text from composeApprovalMessageGenerative with reminder_prompt scenario
1559
+ expect(deliveredText).toContain('pending approval request');
1560
+ expect(statusPayload.approval).toBeUndefined();
1554
1561
 
1555
1562
  deliverSpy.mockRestore();
1556
- replySpy.mockRestore();
1563
+ approvalSpy.mockRestore();
1557
1564
  });
1558
1565
 
1559
1566
  test('sourceChannel "sms" is passed to orchestrator.startRun', async () => {
@@ -1755,7 +1762,7 @@ describe('SMS non-guardian actor gating', () => {
1755
1762
  });
1756
1763
  });
1757
1764
 
1758
- describe('plain-text fallback surfacing for non-rich channels', () => {
1765
+ describe('non-decision status reply for different channels', () => {
1759
1766
  beforeEach(() => {
1760
1767
  createBinding({
1761
1768
  assistantId: 'self',
@@ -1765,19 +1772,18 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
1765
1772
  });
1766
1773
  createBinding({
1767
1774
  assistantId: 'self',
1768
- channel: 'http-api',
1775
+ channel: 'sms',
1769
1776
  guardianExternalUserId: 'telegram-user-default',
1770
1777
  guardianDeliveryChatId: 'chat-123',
1771
1778
  });
1772
1779
  });
1773
1780
 
1774
- test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
1781
+ test('non-decision message on non-rich channel (sms) sends status reply', async () => {
1775
1782
  const orchestrator = makeMockOrchestrator();
1776
- const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1777
- const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1783
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1778
1784
 
1779
- // Establish the conversation using http-api (non-rich channel)
1780
- const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'http-api' });
1785
+ // Establish the conversation using sms (non-rich channel)
1786
+ const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'sms' });
1781
1787
  await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1782
1788
 
1783
1789
  const db = getDb();
@@ -1788,30 +1794,28 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
1788
1794
  const run = createRun(conversationId!);
1789
1795
  setRunConfirmation(run.id, sampleConfirmation);
1790
1796
 
1791
- // Send a non-decision message to trigger a reminder
1792
- const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'http-api' });
1797
+ // Send a non-decision message
1798
+ const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'sms' });
1793
1799
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1794
1800
  const body = await res.json() as Record<string, unknown>;
1795
1801
 
1796
1802
  expect(body.accepted).toBe(true);
1797
- expect(body.approval).toBe('reminder_sent');
1803
+ expect(body.approval).toBe('assistant_turn');
1798
1804
 
1799
- // The delivered text should include the plainTextFallback instructions
1805
+ // Status reply delivered via deliverChannelReply
1800
1806
  expect(deliverSpy).toHaveBeenCalled();
1801
- const callArgs = deliverSpy.mock.calls[0];
1802
- const deliveredText = callArgs[2] as string;
1803
- // For non-rich channels, the text should contain both the reminder prefix
1804
- // AND the plainTextFallback instructions (e.g. "Reply yes to approve")
1805
- expect(deliveredText).toContain("I'm still waiting");
1806
- expect(deliveredText).toContain('Reply "yes"');
1807
+ const statusCall = deliverSpy.mock.calls.find(
1808
+ (call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'chat-123',
1809
+ );
1810
+ expect(statusCall).toBeDefined();
1811
+ const statusPayload = statusCall![1] as { text?: string };
1812
+ expect(statusPayload.text).toContain('pending approval request');
1807
1813
 
1808
1814
  deliverSpy.mockRestore();
1809
- replySpy.mockRestore();
1810
1815
  });
1811
1816
 
1812
- test('reminder prompt does NOT include plainTextFallback for telegram (rich channel)', async () => {
1817
+ test('non-decision message on telegram sends status reply', async () => {
1813
1818
  const orchestrator = makeMockOrchestrator();
1814
- const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1815
1819
  const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1816
1820
 
1817
1821
  // Establish the conversation using telegram (rich channel)
@@ -1826,25 +1830,23 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
1826
1830
  const run = createRun(conversationId!);
1827
1831
  setRunConfirmation(run.id, sampleConfirmation);
1828
1832
 
1829
- // Send a non-decision message to trigger a reminder
1833
+ // Send a non-decision message
1830
1834
  const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'telegram' });
1831
1835
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1832
1836
  const body = await res.json() as Record<string, unknown>;
1833
1837
 
1834
1838
  expect(body.accepted).toBe(true);
1835
- expect(body.approval).toBe('reminder_sent');
1839
+ expect(body.approval).toBe('assistant_turn');
1836
1840
 
1837
- // For rich channels (telegram), the delivered text should be just the
1838
- // promptText (with reminder prefix) — NOT the plainTextFallback.
1839
- expect(deliverSpy).toHaveBeenCalled();
1840
- const callArgs = deliverSpy.mock.calls[0];
1841
- const deliveredText = callArgs[2] as string;
1842
- expect(deliveredText).toContain("I'm still waiting");
1843
- // The raw promptText does not contain "Reply" instructions — those are
1844
- // only in the plainTextFallback.
1845
- expect(deliveredText).not.toContain('Reply "yes"');
1841
+ // Status reply delivered via deliverChannelReply
1842
+ expect(replySpy).toHaveBeenCalled();
1843
+ const statusCall = replySpy.mock.calls.find(
1844
+ (call) => typeof call[1] === 'object' && (call[1] as { chatId?: string }).chatId === 'chat-123',
1845
+ );
1846
+ expect(statusCall).toBeDefined();
1847
+ const statusPayload = statusCall![1] as { text?: string };
1848
+ expect(statusPayload.text).toContain('pending approval request');
1846
1849
 
1847
- deliverSpy.mockRestore();
1848
1850
  replySpy.mockRestore();
1849
1851
  });
1850
1852
  });
@@ -2188,14 +2190,14 @@ describe('guardian-with-binding path regression', () => {
2188
2190
  });
2189
2191
 
2190
2192
  // ═══════════════════════════════════════════════════════════════════════════
2191
- // 20. Guardian delivery failure denial (WS-2)
2193
+ // 20. Guardian rich-delivery failure fallback (WS-2)
2192
2194
  // ═══════════════════════════════════════════════════════════════════════════
2193
2195
 
2194
- describe('guardian delivery failure → denial', () => {
2196
+ describe('guardian delivery failure → text fallback', () => {
2195
2197
  beforeEach(() => {
2196
2198
  });
2197
2199
 
2198
- test('delivery failure denies run and notifies requester', async () => {
2200
+ test('rich delivery failure falls back to plain text and keeps request pending', async () => {
2199
2201
  createBinding({
2200
2202
  assistantId: 'self',
2201
2203
  channel: 'telegram',
@@ -2220,29 +2222,30 @@ describe('guardian delivery failure → denial', () => {
2220
2222
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2221
2223
  await new Promise((resolve) => setTimeout(resolve, 1200));
2222
2224
 
2223
- // The run should have been denied
2224
- expect(orchestrator.submitDecision).toHaveBeenCalled();
2225
- const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
2226
- expect(decisionArgs[1]).toBe('deny');
2225
+ // Rich button delivery failed, but plain-text fallback succeeded.
2226
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2227
+ expect(approvalSpy).toHaveBeenCalled();
2227
2228
 
2228
- // Requester should have been notified that delivery failed
2229
- const failureCalls = deliverSpy.mock.calls.filter(
2230
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('denied'),
2229
+ // Guardian should have received a parser-compatible plain-text approval prompt.
2230
+ const guardianPromptCalls = deliverSpy.mock.calls.filter(
2231
+ (call) =>
2232
+ typeof call[1] === 'object' &&
2233
+ (call[1] as { chatId?: string; text?: string }).chatId === 'guardian-chat-df' &&
2234
+ ((call[1] as { text?: string }).text ?? '').includes('Reply "yes"'),
2231
2235
  );
2232
- expect(failureCalls.length).toBeGreaterThanOrEqual(1);
2236
+ expect(guardianPromptCalls.length).toBeGreaterThanOrEqual(1);
2233
2237
 
2234
- // The guardian_request_forwarded success notice should NOT have been
2235
- // delivered (since delivery failed).
2238
+ // Requester should still get the forwarded notice once fallback delivery works.
2236
2239
  const successCalls = deliverSpy.mock.calls.filter(
2237
2240
  (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
2238
2241
  );
2239
- expect(successCalls.length).toBe(0);
2242
+ expect(successCalls.length).toBeGreaterThanOrEqual(1);
2240
2243
 
2241
2244
  deliverSpy.mockRestore();
2242
2245
  approvalSpy.mockRestore();
2243
2246
  });
2244
2247
 
2245
- test('no pending/unresolved approvals remain after delivery failure', async () => {
2248
+ test('terminal run resolution clears approvals even when rich delivery falls back to text', async () => {
2246
2249
  createBinding({
2247
2250
  assistantId: 'self',
2248
2251
  channel: 'telegram',
@@ -2265,15 +2268,19 @@ describe('guardian delivery failure → denial', () => {
2265
2268
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2266
2269
  await new Promise((resolve) => setTimeout(resolve, 1200));
2267
2270
 
2271
+ // Rich delivery failure alone should not apply an explicit deny decision.
2272
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2273
+
2268
2274
  // Verify the run ID was created
2269
2275
  const runId = orchestrator.realRunId();
2270
2276
  expect(runId).toBeTruthy();
2271
2277
 
2272
- // After delivery failure, there should be NO pending approval for the run
2278
+ // This test orchestrator transitions the run to a terminal failed state,
2279
+ // which resolves the approval record via run-completion cleanup.
2273
2280
  const pendingApproval = getPendingApprovalForRun(runId!);
2274
2281
  expect(pendingApproval).toBeNull();
2275
2282
 
2276
- // There should also be NO unresolved approval (it was set to 'denied')
2283
+ // No unresolved approval should remain after terminal resolution.
2277
2284
  const unresolvedApproval = getUnresolvedApprovalForRun(runId!);
2278
2285
  expect(unresolvedApproval).toBeNull();
2279
2286
 
@@ -2283,14 +2290,14 @@ describe('guardian delivery failure → denial', () => {
2283
2290
  });
2284
2291
 
2285
2292
  // ═══════════════════════════════════════════════════════════════════════════
2286
- // 20b. Standard approval prompt delivery failure → auto-deny (WS-B)
2293
+ // 20b. Standard rich prompt delivery failure → text fallback (WS-B)
2287
2294
  // ═══════════════════════════════════════════════════════════════════════════
2288
2295
 
2289
- describe('standard approval prompt delivery failure → auto-deny', () => {
2296
+ describe('standard approval prompt delivery failure → text fallback', () => {
2290
2297
  beforeEach(() => {
2291
2298
  });
2292
2299
 
2293
- test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
2300
+ test('standard prompt rich-delivery failure falls back to plain text without auto-deny', async () => {
2294
2301
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2295
2302
  // Make the approval prompt delivery fail for the standard (self-approval) path
2296
2303
  const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
@@ -2317,10 +2324,16 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
2317
2324
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2318
2325
  await new Promise((resolve) => setTimeout(resolve, 1200));
2319
2326
 
2320
- // The run should have been auto-denied because the prompt could not be delivered
2321
- expect(orchestrator.submitDecision).toHaveBeenCalled();
2322
- const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
2323
- expect(decisionArgs[1]).toBe('deny');
2327
+ expect(approvalSpy).toHaveBeenCalled();
2328
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2329
+
2330
+ const fallbackCalls = deliverSpy.mock.calls.filter(
2331
+ (call) =>
2332
+ typeof call[1] === 'object' &&
2333
+ (call[1] as { chatId?: string; text?: string }).chatId === 'chat-123' &&
2334
+ ((call[1] as { text?: string }).text ?? '').includes('Reply "yes"'),
2335
+ );
2336
+ expect(fallbackCalls.length).toBeGreaterThanOrEqual(1);
2324
2337
 
2325
2338
  deliverSpy.mockRestore();
2326
2339
  approvalSpy.mockRestore();
@@ -2467,18 +2480,28 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2467
2480
 
2468
2481
  const orchestrator = makeMockOrchestrator();
2469
2482
 
2470
- // Guardian sends plain-text "yes" ambiguous because two approvals are pending
2483
+ // Conversational engine that returns keep_pending for disambiguation
2484
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
2485
+ disposition: 'keep_pending' as const,
2486
+ replyText: 'You have 2 pending requests. Which one?',
2487
+ }));
2488
+
2489
+ // Guardian sends plain-text "yes" — ambiguous because two approvals are pending.
2490
+ // The conversational engine handles disambiguation by returning keep_pending.
2471
2491
  const req = makeInboundRequest({
2472
2492
  content: 'yes',
2473
2493
  externalChatId: 'guardian-ambig-chat',
2474
2494
  senderExternalUserId: 'guardian-ambig-user',
2475
2495
  });
2476
2496
 
2477
- const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2497
+ const res = await handleChannelInbound(
2498
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
2499
+ mockConversationGenerator,
2500
+ );
2478
2501
  const body = await res.json() as Record<string, unknown>;
2479
2502
 
2480
2503
  expect(body.accepted).toBe(true);
2481
- expect(body.approval).toBe('guardian_decision_applied');
2504
+ expect(body.approval).toBe('assistant_turn');
2482
2505
 
2483
2506
  // Neither approval should have been resolved — disambiguation was required
2484
2507
  const approvalA = getPendingApprovalForRun(runA.id);
@@ -2489,9 +2512,14 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2489
2512
  // submitDecision should NOT have been called — no decision was applied
2490
2513
  expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2491
2514
 
2492
- // A disambiguation message should have been sent to the guardian
2515
+ // The conversational engine should have been called with both pending approvals
2516
+ expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
2517
+ const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
2518
+ expect((engineCtx.pendingApprovals as Array<unknown>)).toHaveLength(2);
2519
+
2520
+ // A disambiguation reply should have been sent to the guardian
2493
2521
  const disambigCalls = deliverSpy.mock.calls.filter(
2494
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('pending'),
2522
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending'),
2495
2523
  );
2496
2524
  expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
2497
2525
 
@@ -3538,3 +3566,1150 @@ describe('unknown actor identity — forceStrictSideEffects', () => {
3538
3566
  deliverSpy.mockRestore();
3539
3567
  });
3540
3568
  });
3569
+
3570
+ // ═══════════════════════════════════════════════════════════════════════════
3571
+ // Conversational approval engine — standard path
3572
+ // ═══════════════════════════════════════════════════════════════════════════
3573
+
3574
+ describe('conversational approval engine — standard path', () => {
3575
+ beforeEach(() => {
3576
+ createBinding({
3577
+ assistantId: 'self',
3578
+ channel: 'telegram',
3579
+ guardianExternalUserId: 'telegram-user-default',
3580
+ guardianDeliveryChatId: 'chat-123',
3581
+ });
3582
+ });
3583
+
3584
+ test('non-decision follow-up → engine returns keep_pending → assistant reply sent, run remains pending', async () => {
3585
+ const orchestrator = makeMockOrchestrator();
3586
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3587
+
3588
+ const initReq = makeInboundRequest({ content: 'init' });
3589
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
3590
+
3591
+ const db = getDb();
3592
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
3593
+ const conversationId = events[0]?.conversation_id;
3594
+ ensureConversation(conversationId!);
3595
+
3596
+ const run = createRun(conversationId!);
3597
+ setRunConfirmation(run.id, sampleConfirmation);
3598
+
3599
+ deliverSpy.mockClear();
3600
+
3601
+ // Mock conversational engine that returns keep_pending
3602
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
3603
+ disposition: 'keep_pending' as const,
3604
+ replyText: 'There is a pending shell command. Would you like to approve or deny it?',
3605
+ }));
3606
+
3607
+ const req = makeInboundRequest({ content: 'what does this command do?' });
3608
+ const res = await handleChannelInbound(
3609
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
3610
+ mockConversationGenerator,
3611
+ );
3612
+ const body = await res.json() as Record<string, unknown>;
3613
+
3614
+ expect(body.accepted).toBe(true);
3615
+ expect(body.approval).toBe('assistant_turn');
3616
+
3617
+ // The engine reply should have been delivered
3618
+ expect(deliverSpy).toHaveBeenCalled();
3619
+ const replyCall = deliverSpy.mock.calls.find(
3620
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending shell command'),
3621
+ );
3622
+ expect(replyCall).toBeDefined();
3623
+
3624
+ // The orchestrator should NOT have received a decision
3625
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
3626
+
3627
+ deliverSpy.mockRestore();
3628
+ });
3629
+
3630
+ test('natural-language approval → engine returns approve_once → decision applied', async () => {
3631
+ const orchestrator = makeMockOrchestrator();
3632
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3633
+
3634
+ const initReq = makeInboundRequest({ content: 'init' });
3635
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
3636
+
3637
+ const db = getDb();
3638
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
3639
+ const conversationId = events[0]?.conversation_id;
3640
+ ensureConversation(conversationId!);
3641
+
3642
+ const run = createRun(conversationId!);
3643
+ setRunConfirmation(run.id, sampleConfirmation);
3644
+
3645
+ deliverSpy.mockClear();
3646
+
3647
+ // Mock conversational engine that returns approve_once
3648
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
3649
+ disposition: 'approve_once' as const,
3650
+ replyText: 'Got it, approving the shell command.',
3651
+ }));
3652
+
3653
+ const req = makeInboundRequest({ content: 'yeah go ahead and run it' });
3654
+ const res = await handleChannelInbound(
3655
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
3656
+ mockConversationGenerator,
3657
+ );
3658
+ const body = await res.json() as Record<string, unknown>;
3659
+
3660
+ expect(body.accepted).toBe(true);
3661
+ expect(body.approval).toBe('decision_applied');
3662
+
3663
+ // The orchestrator should have received an allow decision
3664
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
3665
+
3666
+ // The engine reply should have been delivered
3667
+ const replyCall = deliverSpy.mock.calls.find(
3668
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('approving the shell command'),
3669
+ );
3670
+ expect(replyCall).toBeDefined();
3671
+
3672
+ deliverSpy.mockRestore();
3673
+ });
3674
+
3675
+ test('"nevermind" style message → engine returns reject → rejection applied', async () => {
3676
+ const orchestrator = makeMockOrchestrator();
3677
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3678
+
3679
+ const initReq = makeInboundRequest({ content: 'init' });
3680
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
3681
+
3682
+ const db = getDb();
3683
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
3684
+ const conversationId = events[0]?.conversation_id;
3685
+ ensureConversation(conversationId!);
3686
+
3687
+ const run = createRun(conversationId!);
3688
+ setRunConfirmation(run.id, sampleConfirmation);
3689
+
3690
+ deliverSpy.mockClear();
3691
+
3692
+ // Mock conversational engine that returns reject
3693
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
3694
+ disposition: 'reject' as const,
3695
+ replyText: 'No problem, I\'ve cancelled the shell command.',
3696
+ }));
3697
+
3698
+ const req = makeInboundRequest({ content: 'nevermind, don\'t run that' });
3699
+ const res = await handleChannelInbound(
3700
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
3701
+ mockConversationGenerator,
3702
+ );
3703
+ const body = await res.json() as Record<string, unknown>;
3704
+
3705
+ expect(body.accepted).toBe(true);
3706
+ expect(body.approval).toBe('decision_applied');
3707
+
3708
+ // The orchestrator should have received a deny decision
3709
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
3710
+
3711
+ // The engine reply should have been delivered
3712
+ const replyCall = deliverSpy.mock.calls.find(
3713
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('cancelled the shell command'),
3714
+ );
3715
+ expect(replyCall).toBeDefined();
3716
+
3717
+ deliverSpy.mockRestore();
3718
+ });
3719
+
3720
+ test('callback button still takes priority even with conversational engine present', async () => {
3721
+ const orchestrator = makeMockOrchestrator();
3722
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3723
+
3724
+ const initReq = makeInboundRequest({ content: 'init' });
3725
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
3726
+
3727
+ const db = getDb();
3728
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
3729
+ const conversationId = events[0]?.conversation_id;
3730
+ ensureConversation(conversationId!);
3731
+
3732
+ const run = createRun(conversationId!);
3733
+ setRunConfirmation(run.id, sampleConfirmation);
3734
+
3735
+ // Mock conversational engine — should NOT be called for callback buttons
3736
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
3737
+ disposition: 'keep_pending' as const,
3738
+ replyText: 'This should not be called',
3739
+ }));
3740
+
3741
+ const req = makeInboundRequest({
3742
+ content: '',
3743
+ callbackData: `apr:${run.id}:approve_once`,
3744
+ });
3745
+
3746
+ const res = await handleChannelInbound(
3747
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
3748
+ mockConversationGenerator,
3749
+ );
3750
+ const body = await res.json() as Record<string, unknown>;
3751
+
3752
+ expect(body.accepted).toBe(true);
3753
+ expect(body.approval).toBe('decision_applied');
3754
+
3755
+ // The callback button should have been used directly, not the engine
3756
+ expect(mockConversationGenerator).not.toHaveBeenCalled();
3757
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
3758
+
3759
+ deliverSpy.mockRestore();
3760
+ });
3761
+ });
3762
+
3763
+ // ═══════════════════════════════════════════════════════════════════════════
3764
+ // Guardian conversational approval engine tests
3765
+ // ═══════════════════════════════════════════════════════════════════════════
3766
+
3767
+ describe('guardian conversational approval via conversation engine', () => {
3768
+ beforeEach(() => {
3769
+ });
3770
+
3771
+ test('guardian follow-up clarification: engine returns keep_pending, reply sent, run remains pending', async () => {
3772
+ createBinding({
3773
+ assistantId: 'self',
3774
+ channel: 'telegram',
3775
+ guardianExternalUserId: 'guardian-conv-user',
3776
+ guardianDeliveryChatId: 'guardian-conv-chat',
3777
+ });
3778
+
3779
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3780
+
3781
+ const convId = 'conv-guardian-clarify';
3782
+ ensureConversation(convId);
3783
+ const run = createRun(convId);
3784
+ setRunConfirmation(run.id, sampleConfirmation);
3785
+
3786
+ createApprovalRequest({
3787
+ runId: run.id,
3788
+ conversationId: convId,
3789
+ channel: 'telegram',
3790
+ requesterExternalUserId: 'requester-clarify',
3791
+ requesterChatId: 'chat-requester-clarify',
3792
+ guardianExternalUserId: 'guardian-conv-user',
3793
+ guardianChatId: 'guardian-conv-chat',
3794
+ toolName: 'shell',
3795
+ expiresAt: Date.now() + 300_000,
3796
+ });
3797
+
3798
+ const orchestrator = makeMockOrchestrator();
3799
+
3800
+ // Engine returns keep_pending for a clarification question
3801
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
3802
+ disposition: 'keep_pending' as const,
3803
+ replyText: 'Could you clarify which action you want me to approve?',
3804
+ }));
3805
+
3806
+ const req = makeInboundRequest({
3807
+ content: 'hmm what does this do?',
3808
+ externalChatId: 'guardian-conv-chat',
3809
+ senderExternalUserId: 'guardian-conv-user',
3810
+ });
3811
+
3812
+ const res = await handleChannelInbound(
3813
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
3814
+ mockConversationGenerator,
3815
+ );
3816
+ const body = await res.json() as Record<string, unknown>;
3817
+
3818
+ expect(body.accepted).toBe(true);
3819
+ expect(body.approval).toBe('assistant_turn');
3820
+
3821
+ // The engine should have been called with role: 'guardian'
3822
+ expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
3823
+ const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
3824
+ expect(callCtx.role).toBe('guardian');
3825
+ expect(callCtx.allowedActions).toEqual(['approve_once', 'reject']);
3826
+ expect(callCtx.userMessage).toBe('hmm what does this do?');
3827
+
3828
+ // Clarification reply delivered to the guardian's chat
3829
+ const replyCall = deliverSpy.mock.calls.find(
3830
+ (call) => (call[1] as { text?: string }).text === 'Could you clarify which action you want me to approve?',
3831
+ );
3832
+ expect(replyCall).toBeTruthy();
3833
+
3834
+ // The orchestrator should NOT have received a decision
3835
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
3836
+
3837
+ // The approval should still be pending
3838
+ const pending = getAllPendingApprovalsByGuardianChat('telegram', 'guardian-conv-chat', 'self');
3839
+ expect(pending).toHaveLength(1);
3840
+
3841
+ deliverSpy.mockRestore();
3842
+ });
3843
+
3844
+ test('guardian natural-language approval: engine returns approve_once, decision applied', async () => {
3845
+ createBinding({
3846
+ assistantId: 'self',
3847
+ channel: 'telegram',
3848
+ guardianExternalUserId: 'guardian-nlp-user',
3849
+ guardianDeliveryChatId: 'guardian-nlp-chat',
3850
+ });
3851
+
3852
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3853
+
3854
+ const convId = 'conv-guardian-nlp';
3855
+ ensureConversation(convId);
3856
+ const run = createRun(convId);
3857
+ setRunConfirmation(run.id, sampleConfirmation);
3858
+
3859
+ createApprovalRequest({
3860
+ runId: run.id,
3861
+ conversationId: convId,
3862
+ channel: 'telegram',
3863
+ requesterExternalUserId: 'requester-nlp',
3864
+ requesterChatId: 'chat-requester-nlp',
3865
+ guardianExternalUserId: 'guardian-nlp-user',
3866
+ guardianChatId: 'guardian-nlp-chat',
3867
+ toolName: 'shell',
3868
+ expiresAt: Date.now() + 300_000,
3869
+ });
3870
+
3871
+ const orchestrator = makeMockOrchestrator();
3872
+
3873
+ // Engine returns approve_once decision
3874
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
3875
+ disposition: 'approve_once' as const,
3876
+ replyText: 'Approved! The shell command will proceed.',
3877
+ }));
3878
+
3879
+ const req = makeInboundRequest({
3880
+ content: 'yes go ahead and run it',
3881
+ externalChatId: 'guardian-nlp-chat',
3882
+ senderExternalUserId: 'guardian-nlp-user',
3883
+ });
3884
+
3885
+ const res = await handleChannelInbound(
3886
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
3887
+ mockConversationGenerator,
3888
+ );
3889
+ const body = await res.json() as Record<string, unknown>;
3890
+
3891
+ expect(body.accepted).toBe(true);
3892
+ expect(body.approval).toBe('guardian_decision_applied');
3893
+
3894
+ // The orchestrator should have received an 'allow' decision
3895
+ expect(orchestrator.submitDecision).toHaveBeenCalledTimes(1);
3896
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
3897
+
3898
+ // The approval record should have been updated (no longer pending)
3899
+ const pending = getAllPendingApprovalsByGuardianChat('telegram', 'guardian-nlp-chat', 'self');
3900
+ expect(pending).toHaveLength(0);
3901
+
3902
+ // The engine context excluded approve_always for guardians
3903
+ const callCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
3904
+ expect(callCtx.allowedActions).toEqual(['approve_once', 'reject']);
3905
+ expect((callCtx.allowedActions as string[])).not.toContain('approve_always');
3906
+
3907
+ deliverSpy.mockRestore();
3908
+ });
3909
+
3910
+ test('guardian callback button approve_always is downgraded to approve_once', async () => {
3911
+ createBinding({
3912
+ assistantId: 'self',
3913
+ channel: 'telegram',
3914
+ guardianExternalUserId: 'guardian-dg-user',
3915
+ guardianDeliveryChatId: 'guardian-dg-chat',
3916
+ });
3917
+
3918
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3919
+
3920
+ const convId = 'conv-guardian-downgrade';
3921
+ ensureConversation(convId);
3922
+ const run = createRun(convId);
3923
+ setRunConfirmation(run.id, sampleConfirmation);
3924
+
3925
+ createApprovalRequest({
3926
+ runId: run.id,
3927
+ conversationId: convId,
3928
+ channel: 'telegram',
3929
+ requesterExternalUserId: 'requester-dg',
3930
+ requesterChatId: 'chat-requester-dg',
3931
+ guardianExternalUserId: 'guardian-dg-user',
3932
+ guardianChatId: 'guardian-dg-chat',
3933
+ toolName: 'shell',
3934
+ expiresAt: Date.now() + 300_000,
3935
+ });
3936
+
3937
+ const orchestrator = makeMockOrchestrator();
3938
+
3939
+ // Guardian clicks approve_always via callback button
3940
+ const req = makeInboundRequest({
3941
+ content: '',
3942
+ externalChatId: 'guardian-dg-chat',
3943
+ callbackData: `apr:${run.id}:approve_always`,
3944
+ senderExternalUserId: 'guardian-dg-user',
3945
+ });
3946
+
3947
+ const res = await handleChannelInbound(
3948
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
3949
+ undefined,
3950
+ );
3951
+ const body = await res.json() as Record<string, unknown>;
3952
+
3953
+ expect(body.accepted).toBe(true);
3954
+ expect(body.approval).toBe('guardian_decision_applied');
3955
+
3956
+ // approve_always should have been downgraded to approve_once ('allow')
3957
+ expect(orchestrator.submitDecision).toHaveBeenCalledTimes(1);
3958
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
3959
+
3960
+ deliverSpy.mockRestore();
3961
+ });
3962
+
3963
+ test('multi-pending guardian disambiguation: engine requests clarification', async () => {
3964
+ createBinding({
3965
+ assistantId: 'self',
3966
+ channel: 'telegram',
3967
+ guardianExternalUserId: 'guardian-multi-user',
3968
+ guardianDeliveryChatId: 'guardian-multi-chat',
3969
+ });
3970
+
3971
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3972
+
3973
+ const convA = 'conv-multi-a';
3974
+ const convB = 'conv-multi-b';
3975
+ ensureConversation(convA);
3976
+ ensureConversation(convB);
3977
+
3978
+ const runA = createRun(convA);
3979
+ setRunConfirmation(runA.id, { ...sampleConfirmation, toolUseId: 'req-multi-a' });
3980
+
3981
+ const runB = createRun(convB);
3982
+ setRunConfirmation(runB.id, { ...sampleConfirmation, toolName: 'file_edit', toolUseId: 'req-multi-b' });
3983
+
3984
+ createApprovalRequest({
3985
+ runId: runA.id,
3986
+ conversationId: convA,
3987
+ channel: 'telegram',
3988
+ requesterExternalUserId: 'requester-multi-a',
3989
+ requesterChatId: 'chat-requester-multi-a',
3990
+ guardianExternalUserId: 'guardian-multi-user',
3991
+ guardianChatId: 'guardian-multi-chat',
3992
+ toolName: 'shell',
3993
+ expiresAt: Date.now() + 300_000,
3994
+ });
3995
+
3996
+ createApprovalRequest({
3997
+ runId: runB.id,
3998
+ conversationId: convB,
3999
+ channel: 'telegram',
4000
+ requesterExternalUserId: 'requester-multi-b',
4001
+ requesterChatId: 'chat-requester-multi-b',
4002
+ guardianExternalUserId: 'guardian-multi-user',
4003
+ guardianChatId: 'guardian-multi-chat',
4004
+ toolName: 'file_edit',
4005
+ expiresAt: Date.now() + 300_000,
4006
+ });
4007
+
4008
+ const orchestrator = makeMockOrchestrator();
4009
+
4010
+ // Engine returns keep_pending for disambiguation
4011
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
4012
+ disposition: 'keep_pending' as const,
4013
+ replyText: 'You have 2 pending requests: shell and file_edit. Which one?',
4014
+ }));
4015
+
4016
+ const req = makeInboundRequest({
4017
+ content: 'approve it',
4018
+ externalChatId: 'guardian-multi-chat',
4019
+ senderExternalUserId: 'guardian-multi-user',
4020
+ });
4021
+
4022
+ const res = await handleChannelInbound(
4023
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4024
+ mockConversationGenerator,
4025
+ );
4026
+ const body = await res.json() as Record<string, unknown>;
4027
+
4028
+ expect(body.accepted).toBe(true);
4029
+ expect(body.approval).toBe('assistant_turn');
4030
+
4031
+ // The engine should have received both pending approvals
4032
+ expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
4033
+ const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
4034
+ expect((engineCtx.pendingApprovals as Array<unknown>)).toHaveLength(2);
4035
+ expect(engineCtx.role).toBe('guardian');
4036
+
4037
+ // Both approvals should remain pending
4038
+ const pendingA = getPendingApprovalForRun(runA.id);
4039
+ const pendingB = getPendingApprovalForRun(runB.id);
4040
+ expect(pendingA).not.toBeNull();
4041
+ expect(pendingB).not.toBeNull();
4042
+
4043
+ // submitDecision should NOT have been called
4044
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4045
+
4046
+ // Disambiguation reply delivered to guardian
4047
+ const disambigCall = deliverSpy.mock.calls.find(
4048
+ (call) => (call[1] as { text?: string }).text?.includes('2 pending requests'),
4049
+ );
4050
+ expect(disambigCall).toBeTruthy();
4051
+
4052
+ deliverSpy.mockRestore();
4053
+ });
4054
+ });
4055
+
4056
+ // ═══════════════════════════════════════════════════════════════════════════
4057
+ // keep_pending must remain conversational (no deterministic fallback)
4058
+ // ═══════════════════════════════════════════════════════════════════════════
4059
+
4060
+ describe('keep_pending remains conversational — standard path', () => {
4061
+ beforeEach(() => {
4062
+ createBinding({
4063
+ assistantId: 'self',
4064
+ channel: 'telegram',
4065
+ guardianExternalUserId: 'telegram-user-default',
4066
+ guardianDeliveryChatId: 'chat-123',
4067
+ });
4068
+ });
4069
+
4070
+ test('explicit "approve" with keep_pending returns assistant_turn and does not auto-decide', async () => {
4071
+ const orchestrator = makeMockOrchestrator();
4072
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4073
+
4074
+ const initReq = makeInboundRequest({ content: 'init' });
4075
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4076
+
4077
+ const db = getDb();
4078
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4079
+ const conversationId = events[0]?.conversation_id;
4080
+ ensureConversation(conversationId!);
4081
+
4082
+ const run = createRun(conversationId!);
4083
+ setRunConfirmation(run.id, sampleConfirmation);
4084
+
4085
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
4086
+ disposition: 'keep_pending' as const,
4087
+ replyText: 'Before deciding, can you confirm the intent?',
4088
+ }));
4089
+
4090
+ const req = makeInboundRequest({ content: 'approve' });
4091
+ const res = await handleChannelInbound(
4092
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4093
+ mockConversationGenerator,
4094
+ );
4095
+ const body = await res.json() as Record<string, unknown>;
4096
+
4097
+ expect(body.accepted).toBe(true);
4098
+ expect(body.approval).toBe('assistant_turn');
4099
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4100
+
4101
+ const followupReply = deliverSpy.mock.calls.find(
4102
+ (call) => (call[1] as { text?: string }).text?.includes('confirm the intent'),
4103
+ );
4104
+ expect(followupReply).toBeDefined();
4105
+
4106
+ deliverSpy.mockRestore();
4107
+ });
4108
+
4109
+ test('keep_pending stays assistant_turn even if pending confirmation disappears mid-turn', async () => {
4110
+ const orchestrator = makeMockOrchestrator();
4111
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4112
+
4113
+ const initReq = makeInboundRequest({ content: 'init' });
4114
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4115
+
4116
+ const db = getDb();
4117
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4118
+ const conversationId = events[0]?.conversation_id;
4119
+ ensureConversation(conversationId!);
4120
+
4121
+ const run = createRun(conversationId!);
4122
+ setRunConfirmation(run.id, sampleConfirmation);
4123
+
4124
+ const mockConversationGenerator = mock(async (_ctx: unknown) => {
4125
+ db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
4126
+ return {
4127
+ disposition: 'keep_pending' as const,
4128
+ replyText: 'Looks like that request is no longer pending.',
4129
+ };
4130
+ });
4131
+
4132
+ const req = makeInboundRequest({ content: 'deny' });
4133
+ const res = await handleChannelInbound(
4134
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4135
+ mockConversationGenerator,
4136
+ );
4137
+ const body = await res.json() as Record<string, unknown>;
4138
+
4139
+ expect(body.accepted).toBe(true);
4140
+ expect(body.approval).toBe('assistant_turn');
4141
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4142
+
4143
+ const followupReply = deliverSpy.mock.calls.find(
4144
+ (call) => (call[1] as { text?: string }).text?.includes('no longer pending'),
4145
+ );
4146
+ expect(followupReply).toBeDefined();
4147
+
4148
+ deliverSpy.mockRestore();
4149
+ });
4150
+ });
4151
+
4152
+ describe('keep_pending remains conversational — guardian path', () => {
4153
+ test('guardian explicit "yes" with keep_pending returns assistant_turn without applying a decision', async () => {
4154
+ createBinding({
4155
+ assistantId: 'self',
4156
+ channel: 'telegram',
4157
+ guardianExternalUserId: 'guardian-user-fb',
4158
+ guardianDeliveryChatId: 'guardian-chat-fb',
4159
+ });
4160
+
4161
+ const orchestrator = makeMockOrchestrator();
4162
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4163
+
4164
+ const initReq = makeInboundRequest({
4165
+ content: 'init',
4166
+ externalChatId: 'requester-chat-fb',
4167
+ senderExternalUserId: 'requester-user-fb',
4168
+ });
4169
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4170
+
4171
+ const db = getDb();
4172
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4173
+ const conversationId = events[0]?.conversation_id;
4174
+ ensureConversation(conversationId!);
4175
+
4176
+ const run = createRun(conversationId!);
4177
+ setRunConfirmation(run.id, sampleConfirmation);
4178
+
4179
+ createApprovalRequest({
4180
+ runId: run.id,
4181
+ conversationId: conversationId!,
4182
+ assistantId: 'self',
4183
+ channel: 'telegram',
4184
+ requesterExternalUserId: 'requester-user-fb',
4185
+ requesterChatId: 'requester-chat-fb',
4186
+ guardianExternalUserId: 'guardian-user-fb',
4187
+ guardianChatId: 'guardian-chat-fb',
4188
+ toolName: 'shell',
4189
+ expiresAt: Date.now() + 300_000,
4190
+ });
4191
+
4192
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
4193
+ disposition: 'keep_pending' as const,
4194
+ replyText: 'Which run are you approving?',
4195
+ }));
4196
+
4197
+ const guardianReq = makeInboundRequest({
4198
+ content: 'yes',
4199
+ externalChatId: 'guardian-chat-fb',
4200
+ senderExternalUserId: 'guardian-user-fb',
4201
+ });
4202
+ const res = await handleChannelInbound(
4203
+ guardianReq, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4204
+ mockConversationGenerator,
4205
+ );
4206
+ const body = await res.json() as Record<string, unknown>;
4207
+
4208
+ expect(body.accepted).toBe(true);
4209
+ expect(body.approval).toBe('assistant_turn');
4210
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4211
+
4212
+ const followupReply = deliverSpy.mock.calls.find(
4213
+ (call) => (call[1] as { text?: string }).text?.includes('Which run are you approving'),
4214
+ );
4215
+ expect(followupReply).toBeDefined();
4216
+
4217
+ deliverSpy.mockRestore();
4218
+ });
4219
+ });
4220
+
4221
+ // ═══════════════════════════════════════════════════════════════════════════
4222
+ // Fix: requester cancel of guardian-gated pending request (P2)
4223
+ // ═══════════════════════════════════════════════════════════════════════════
4224
+
4225
+ describe('requester cancel of guardian-gated pending request', () => {
4226
+ beforeEach(() => {
4227
+ createBinding({
4228
+ assistantId: 'self',
4229
+ channel: 'telegram',
4230
+ guardianExternalUserId: 'guardian-cancel',
4231
+ guardianDeliveryChatId: 'guardian-cancel-chat',
4232
+ });
4233
+ });
4234
+
4235
+ test('requester explicit "deny" can cancel when the conversation engine returns reject', async () => {
4236
+ const orchestrator = makeMockOrchestrator();
4237
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4238
+
4239
+ // Create requester conversation and run
4240
+ const initReq = makeInboundRequest({
4241
+ content: 'init',
4242
+ externalChatId: 'requester-cancel-chat',
4243
+ senderExternalUserId: 'requester-cancel-user',
4244
+ });
4245
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4246
+
4247
+ const db = getDb();
4248
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4249
+ const conversationId = events[0]?.conversation_id;
4250
+ ensureConversation(conversationId!);
4251
+
4252
+ const run = createRun(conversationId!);
4253
+ setRunConfirmation(run.id, sampleConfirmation);
4254
+
4255
+ // Create guardian approval request
4256
+ createApprovalRequest({
4257
+ runId: run.id,
4258
+ conversationId: conversationId!,
4259
+ assistantId: 'self',
4260
+ channel: 'telegram',
4261
+ requesterExternalUserId: 'requester-cancel-user',
4262
+ requesterChatId: 'requester-cancel-chat',
4263
+ guardianExternalUserId: 'guardian-cancel',
4264
+ guardianChatId: 'guardian-cancel-chat',
4265
+ toolName: 'shell',
4266
+ expiresAt: Date.now() + 300_000,
4267
+ });
4268
+
4269
+ deliverSpy.mockClear();
4270
+
4271
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
4272
+ disposition: 'reject' as const,
4273
+ replyText: 'Cancelling this request now.',
4274
+ }));
4275
+
4276
+ // Requester sends "deny" and the engine classifies it as reject.
4277
+ const req = makeInboundRequest({
4278
+ content: 'deny',
4279
+ externalChatId: 'requester-cancel-chat',
4280
+ senderExternalUserId: 'requester-cancel-user',
4281
+ });
4282
+ const res = await handleChannelInbound(
4283
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4284
+ mockConversationGenerator,
4285
+ );
4286
+ const body = await res.json() as Record<string, unknown>;
4287
+
4288
+ expect(body.accepted).toBe(true);
4289
+ expect(body.approval).toBe('decision_applied');
4290
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
4291
+
4292
+ // Guardian approval should be resolved
4293
+ const approval = getPendingApprovalForRun(run.id);
4294
+ expect(approval).toBeNull();
4295
+
4296
+ // Requester should have been notified (cancel notice)
4297
+ const requesterReply = deliverSpy.mock.calls.find(
4298
+ (call) => (call[1] as { chatId?: string }).chatId === 'requester-cancel-chat',
4299
+ );
4300
+ expect(requesterReply).toBeDefined();
4301
+
4302
+ // Guardian should have been notified of the cancellation
4303
+ const guardianNotice = deliverSpy.mock.calls.find(
4304
+ (call) => (call[1] as { chatId?: string }).chatId === 'guardian-cancel-chat',
4305
+ );
4306
+ expect(guardianNotice).toBeDefined();
4307
+
4308
+ deliverSpy.mockRestore();
4309
+ });
4310
+
4311
+ test('requester "nevermind" via conversational engine cancels guardian-gated request', async () => {
4312
+ const orchestrator = makeMockOrchestrator();
4313
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4314
+
4315
+ const initReq = makeInboundRequest({
4316
+ content: 'init',
4317
+ externalChatId: 'requester-cancel-chat',
4318
+ senderExternalUserId: 'requester-cancel-user',
4319
+ });
4320
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4321
+
4322
+ const db = getDb();
4323
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4324
+ const conversationId = events[0]?.conversation_id;
4325
+ ensureConversation(conversationId!);
4326
+
4327
+ const run = createRun(conversationId!);
4328
+ setRunConfirmation(run.id, sampleConfirmation);
4329
+
4330
+ createApprovalRequest({
4331
+ runId: run.id,
4332
+ conversationId: conversationId!,
4333
+ assistantId: 'self',
4334
+ channel: 'telegram',
4335
+ requesterExternalUserId: 'requester-cancel-user',
4336
+ requesterChatId: 'requester-cancel-chat',
4337
+ guardianExternalUserId: 'guardian-cancel',
4338
+ guardianChatId: 'guardian-cancel-chat',
4339
+ toolName: 'shell',
4340
+ expiresAt: Date.now() + 300_000,
4341
+ });
4342
+
4343
+ deliverSpy.mockClear();
4344
+
4345
+ // Conversational engine recognises cancel intent and returns reject
4346
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
4347
+ disposition: 'reject' as const,
4348
+ replyText: 'OK, I have cancelled the pending request.',
4349
+ }));
4350
+
4351
+ const req = makeInboundRequest({
4352
+ content: 'actually never mind, cancel it',
4353
+ externalChatId: 'requester-cancel-chat',
4354
+ senderExternalUserId: 'requester-cancel-user',
4355
+ });
4356
+ const res = await handleChannelInbound(
4357
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4358
+ mockConversationGenerator,
4359
+ );
4360
+ const body = await res.json() as Record<string, unknown>;
4361
+
4362
+ expect(body.accepted).toBe(true);
4363
+ expect(body.approval).toBe('decision_applied');
4364
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
4365
+
4366
+ // Engine should have been called with reject-only allowed actions
4367
+ expect(mockConversationGenerator).toHaveBeenCalledTimes(1);
4368
+ const engineCtx = mockConversationGenerator.mock.calls[0][0] as Record<string, unknown>;
4369
+ expect(engineCtx.allowedActions).toEqual(['reject']);
4370
+
4371
+ // Engine reply should have been delivered to requester
4372
+ const replyCall = deliverSpy.mock.calls.find(
4373
+ (call) => (call[1] as { text?: string }).text?.includes('cancelled the pending request'),
4374
+ );
4375
+ expect(replyCall).toBeDefined();
4376
+
4377
+ deliverSpy.mockRestore();
4378
+ });
4379
+
4380
+ test('requester cancel returns stale_ignored when pending disappears before apply', async () => {
4381
+ const orchestrator = makeMockOrchestrator();
4382
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4383
+
4384
+ const initReq = makeInboundRequest({
4385
+ content: 'init',
4386
+ externalChatId: 'requester-cancel-race-chat',
4387
+ senderExternalUserId: 'requester-cancel-race-user',
4388
+ });
4389
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4390
+
4391
+ const db = getDb();
4392
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4393
+ const conversationId = events[0]?.conversation_id;
4394
+ ensureConversation(conversationId!);
4395
+
4396
+ const run = createRun(conversationId!);
4397
+ setRunConfirmation(run.id, sampleConfirmation);
4398
+
4399
+ createApprovalRequest({
4400
+ runId: run.id,
4401
+ conversationId: conversationId!,
4402
+ assistantId: 'self',
4403
+ channel: 'telegram',
4404
+ requesterExternalUserId: 'requester-cancel-race-user',
4405
+ requesterChatId: 'requester-cancel-race-chat',
4406
+ guardianExternalUserId: 'guardian-cancel',
4407
+ guardianChatId: 'guardian-cancel-chat',
4408
+ toolName: 'shell',
4409
+ expiresAt: Date.now() + 300_000,
4410
+ });
4411
+
4412
+ deliverSpy.mockClear();
4413
+
4414
+ const mockConversationGenerator = mock(async (_ctx: unknown) => {
4415
+ db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
4416
+ return {
4417
+ disposition: 'reject' as const,
4418
+ replyText: 'Cancelling that now.',
4419
+ };
4420
+ });
4421
+
4422
+ const req = makeInboundRequest({
4423
+ content: 'never mind cancel',
4424
+ externalChatId: 'requester-cancel-race-chat',
4425
+ senderExternalUserId: 'requester-cancel-race-user',
4426
+ });
4427
+ const res = await handleChannelInbound(
4428
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4429
+ mockConversationGenerator,
4430
+ );
4431
+ const body = await res.json() as Record<string, unknown>;
4432
+
4433
+ expect(body.accepted).toBe(true);
4434
+ expect(body.approval).toBe('stale_ignored');
4435
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4436
+
4437
+ const staleReply = deliverSpy.mock.calls.find(
4438
+ (call) => (call[1] as { text?: string }).text?.includes('already been resolved'),
4439
+ );
4440
+ expect(staleReply).toBeDefined();
4441
+
4442
+ deliverSpy.mockRestore();
4443
+ });
4444
+
4445
+ test('requester non-cancel message with keep_pending returns conversational reply', async () => {
4446
+ const orchestrator = makeMockOrchestrator();
4447
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4448
+
4449
+ const initReq = makeInboundRequest({
4450
+ content: 'init',
4451
+ externalChatId: 'requester-cancel-chat',
4452
+ senderExternalUserId: 'requester-cancel-user',
4453
+ });
4454
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4455
+
4456
+ const db = getDb();
4457
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4458
+ const conversationId = events[0]?.conversation_id;
4459
+ ensureConversation(conversationId!);
4460
+
4461
+ const run = createRun(conversationId!);
4462
+ setRunConfirmation(run.id, sampleConfirmation);
4463
+
4464
+ createApprovalRequest({
4465
+ runId: run.id,
4466
+ conversationId: conversationId!,
4467
+ assistantId: 'self',
4468
+ channel: 'telegram',
4469
+ requesterExternalUserId: 'requester-cancel-user',
4470
+ requesterChatId: 'requester-cancel-chat',
4471
+ guardianExternalUserId: 'guardian-cancel',
4472
+ guardianChatId: 'guardian-cancel-chat',
4473
+ toolName: 'shell',
4474
+ expiresAt: Date.now() + 300_000,
4475
+ });
4476
+
4477
+ deliverSpy.mockClear();
4478
+
4479
+ // Engine returns keep_pending (not a cancel intent)
4480
+ const mockConversationGenerator = mock(async (_ctx: unknown) => ({
4481
+ disposition: 'keep_pending' as const,
4482
+ replyText: 'Still waiting.',
4483
+ }));
4484
+
4485
+ const req = makeInboundRequest({
4486
+ content: 'what is happening?',
4487
+ externalChatId: 'requester-cancel-chat',
4488
+ senderExternalUserId: 'requester-cancel-user',
4489
+ });
4490
+ const res = await handleChannelInbound(
4491
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4492
+ mockConversationGenerator,
4493
+ );
4494
+ const body = await res.json() as Record<string, unknown>;
4495
+
4496
+ expect(body.accepted).toBe(true);
4497
+ expect(body.approval).toBe('assistant_turn');
4498
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4499
+
4500
+ // Should have received the conversational keep_pending reply
4501
+ const pendingReply = deliverSpy.mock.calls.find(
4502
+ (call) => (call[1] as { text?: string }).text?.includes('Still waiting.'),
4503
+ );
4504
+ expect(pendingReply).toBeDefined();
4505
+
4506
+ deliverSpy.mockRestore();
4507
+ });
4508
+
4509
+ test('requester "approve" is blocked — self-approval not allowed even during cancel check', async () => {
4510
+ const orchestrator = makeMockOrchestrator();
4511
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4512
+
4513
+ const initReq = makeInboundRequest({
4514
+ content: 'init',
4515
+ externalChatId: 'requester-cancel-chat',
4516
+ senderExternalUserId: 'requester-cancel-user',
4517
+ });
4518
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4519
+
4520
+ const db = getDb();
4521
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4522
+ const conversationId = events[0]?.conversation_id;
4523
+ ensureConversation(conversationId!);
4524
+
4525
+ const run = createRun(conversationId!);
4526
+ setRunConfirmation(run.id, sampleConfirmation);
4527
+
4528
+ createApprovalRequest({
4529
+ runId: run.id,
4530
+ conversationId: conversationId!,
4531
+ assistantId: 'self',
4532
+ channel: 'telegram',
4533
+ requesterExternalUserId: 'requester-cancel-user',
4534
+ requesterChatId: 'requester-cancel-chat',
4535
+ guardianExternalUserId: 'guardian-cancel',
4536
+ guardianChatId: 'guardian-cancel-chat',
4537
+ toolName: 'shell',
4538
+ expiresAt: Date.now() + 300_000,
4539
+ });
4540
+
4541
+ deliverSpy.mockClear();
4542
+
4543
+ // Requester tries to self-approve while guardian approval is pending.
4544
+ // Self-approval stays blocked in the requester-cancel path.
4545
+ const req = makeInboundRequest({
4546
+ content: 'approve',
4547
+ externalChatId: 'requester-cancel-chat',
4548
+ senderExternalUserId: 'requester-cancel-user',
4549
+ });
4550
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
4551
+ const body = await res.json() as Record<string, unknown>;
4552
+
4553
+ expect(body.accepted).toBe(true);
4554
+ // Should get the guardian-pending notice, NOT decision_applied
4555
+ expect(body.approval).toBe('assistant_turn');
4556
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4557
+
4558
+ deliverSpy.mockRestore();
4559
+ });
4560
+ });
4561
+
4562
+ // ═══════════════════════════════════════════════════════════════════════════
4563
+ // Fix: stale_ignored when engine decision races with concurrent resolution
4564
+ // ═══════════════════════════════════════════════════════════════════════════
4565
+
4566
+ describe('engine decision race condition — standard path', () => {
4567
+ beforeEach(() => {
4568
+ createBinding({
4569
+ assistantId: 'self',
4570
+ channel: 'telegram',
4571
+ guardianExternalUserId: 'telegram-user-default',
4572
+ guardianDeliveryChatId: 'chat-123',
4573
+ });
4574
+ });
4575
+
4576
+ test('returns stale_ignored when engine approves but run was already resolved', async () => {
4577
+ const orchestrator = makeMockOrchestrator();
4578
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4579
+
4580
+ const initReq = makeInboundRequest({ content: 'init' });
4581
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4582
+
4583
+ const db = getDb();
4584
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4585
+ const conversationId = events[0]?.conversation_id;
4586
+ ensureConversation(conversationId!);
4587
+
4588
+ const run = createRun(conversationId!);
4589
+ setRunConfirmation(run.id, sampleConfirmation);
4590
+
4591
+ deliverSpy.mockClear();
4592
+
4593
+ // Engine returns approve_once, but clears the pending confirmation
4594
+ // before handleChannelDecision is called (simulating race condition)
4595
+ const mockConversationGenerator = mock(async (_ctx: unknown) => {
4596
+ db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
4597
+ return {
4598
+ disposition: 'approve_once' as const,
4599
+ replyText: 'Approved! Running the command now.',
4600
+ };
4601
+ });
4602
+
4603
+ const req = makeInboundRequest({ content: 'go ahead' });
4604
+ const res = await handleChannelInbound(
4605
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4606
+ mockConversationGenerator,
4607
+ );
4608
+ const body = await res.json() as Record<string, unknown>;
4609
+
4610
+ expect(body.accepted).toBe(true);
4611
+ expect(body.approval).toBe('stale_ignored');
4612
+
4613
+ // submitDecision should NOT have been called since there was no pending
4614
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4615
+
4616
+ // The engine's optimistic "Approved!" reply should NOT have been delivered
4617
+ const approvedReply = deliverSpy.mock.calls.find(
4618
+ (call) => (call[1] as { text?: string }).text?.includes('Approved!'),
4619
+ );
4620
+ expect(approvedReply).toBeUndefined();
4621
+
4622
+ // A stale notice should have been delivered instead
4623
+ const staleReply = deliverSpy.mock.calls.find(
4624
+ (call) => (call[1] as { text?: string }).text?.includes('already been resolved'),
4625
+ );
4626
+ expect(staleReply).toBeDefined();
4627
+
4628
+ deliverSpy.mockRestore();
4629
+ });
4630
+ });
4631
+
4632
+ describe('engine decision race condition — guardian path', () => {
4633
+ test('returns stale_ignored when guardian engine approves but run was already resolved', async () => {
4634
+ createBinding({
4635
+ assistantId: 'self',
4636
+ channel: 'telegram',
4637
+ guardianExternalUserId: 'guardian-race-user',
4638
+ guardianDeliveryChatId: 'guardian-race-chat',
4639
+ });
4640
+
4641
+ const orchestrator = makeMockOrchestrator();
4642
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
4643
+
4644
+ const initReq = makeInboundRequest({
4645
+ content: 'init',
4646
+ externalChatId: 'requester-race-chat',
4647
+ senderExternalUserId: 'requester-race-user',
4648
+ });
4649
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
4650
+
4651
+ const db = getDb();
4652
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
4653
+ const conversationId = events[0]?.conversation_id;
4654
+ ensureConversation(conversationId!);
4655
+
4656
+ const run = createRun(conversationId!);
4657
+ setRunConfirmation(run.id, sampleConfirmation);
4658
+
4659
+ createApprovalRequest({
4660
+ runId: run.id,
4661
+ conversationId: conversationId!,
4662
+ assistantId: 'self',
4663
+ channel: 'telegram',
4664
+ requesterExternalUserId: 'requester-race-user',
4665
+ requesterChatId: 'requester-race-chat',
4666
+ guardianExternalUserId: 'guardian-race-user',
4667
+ guardianChatId: 'guardian-race-chat',
4668
+ toolName: 'shell',
4669
+ expiresAt: Date.now() + 300_000,
4670
+ });
4671
+
4672
+ deliverSpy.mockClear();
4673
+
4674
+ // Guardian engine returns approve_once, but clears pending confirmation
4675
+ // to simulate a concurrent resolution (expiry sweep or requester cancel)
4676
+ const mockConversationGenerator = mock(async (_ctx: unknown) => {
4677
+ db.$client.prepare('UPDATE message_runs SET pending_confirmation = NULL WHERE id = ?').run(run.id);
4678
+ return {
4679
+ disposition: 'approve_once' as const,
4680
+ replyText: 'Approved the request.',
4681
+ };
4682
+ });
4683
+
4684
+ const guardianReq = makeInboundRequest({
4685
+ content: 'approve it',
4686
+ externalChatId: 'guardian-race-chat',
4687
+ senderExternalUserId: 'guardian-race-user',
4688
+ });
4689
+ const res = await handleChannelInbound(
4690
+ guardianReq, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
4691
+ mockConversationGenerator,
4692
+ );
4693
+ const body = await res.json() as Record<string, unknown>;
4694
+
4695
+ expect(body.accepted).toBe(true);
4696
+ expect(body.approval).toBe('stale_ignored');
4697
+
4698
+ // submitDecision should NOT have been called
4699
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
4700
+
4701
+ // The engine's "Approved the request." should NOT be delivered
4702
+ const optimisticReply = deliverSpy.mock.calls.find(
4703
+ (call) => (call[1] as { text?: string }).text?.includes('Approved the request'),
4704
+ );
4705
+ expect(optimisticReply).toBeUndefined();
4706
+
4707
+ // A stale notice should have been delivered instead
4708
+ const staleReply = deliverSpy.mock.calls.find(
4709
+ (call) => (call[1] as { text?: string }).text?.includes('already been resolved'),
4710
+ );
4711
+ expect(staleReply).toBeDefined();
4712
+
4713
+ deliverSpy.mockRestore();
4714
+ });
4715
+ });