@vellumai/assistant 0.3.4 → 0.3.6

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 (506) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +88 -2
  3. package/eslint.config.mjs +31 -0
  4. package/package.json +1 -1
  5. package/scripts/ipc/check-swift-decoder-drift.ts +4 -1
  6. package/scripts/ipc/generate-swift.ts +31 -2
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +438 -1
  8. package/src/__tests__/approval-conversation-turn.test.ts +214 -0
  9. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  10. package/src/__tests__/approval-message-composer.test.ts +253 -0
  11. package/src/__tests__/browser-manager.test.ts +1 -0
  12. package/src/__tests__/call-conversation-messages.test.ts +130 -0
  13. package/src/__tests__/call-domain.test.ts +12 -2
  14. package/src/__tests__/call-orchestrator.test.ts +799 -249
  15. package/src/__tests__/call-pointer-messages.test.ts +148 -0
  16. package/src/__tests__/call-recovery.test.ts +3 -0
  17. package/src/__tests__/call-routes-http.test.ts +32 -2
  18. package/src/__tests__/call-store.test.ts +3 -0
  19. package/src/__tests__/channel-approval-routes.test.ts +1277 -98
  20. package/src/__tests__/channel-approval.test.ts +37 -0
  21. package/src/__tests__/channel-approvals.test.ts +36 -50
  22. package/src/__tests__/channel-guardian.test.ts +630 -22
  23. package/src/__tests__/channel-readiness-service.test.ts +324 -0
  24. package/src/__tests__/checker.test.ts +14 -7
  25. package/src/__tests__/clarification-resolver.test.ts +44 -24
  26. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -4
  27. package/src/__tests__/computer-use-session-working-dir.test.ts +8 -0
  28. package/src/__tests__/config-schema.test.ts +14 -8
  29. package/src/__tests__/context-window-manager.test.ts +30 -2
  30. package/src/__tests__/contradiction-checker.test.ts +20 -5
  31. package/src/__tests__/credential-security-invariants.test.ts +7 -2
  32. package/src/__tests__/daemon-lifecycle.test.ts +13 -12
  33. package/src/__tests__/db-migration-rollback.test.ts +752 -0
  34. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  35. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +2 -0
  36. package/src/__tests__/entity-search.test.ts +615 -0
  37. package/src/__tests__/fuzzy-match-property.test.ts +5 -5
  38. package/src/__tests__/guardian-action-store.test.ts +123 -0
  39. package/src/__tests__/guardian-action-sweep.test.ts +277 -0
  40. package/src/__tests__/guardian-dispatch.test.ts +389 -0
  41. package/src/__tests__/guardian-question-copy.test.ts +47 -0
  42. package/src/__tests__/handlers-telegram-config.test.ts +4 -2
  43. package/src/__tests__/handlers-twilio-config.test.ts +533 -0
  44. package/src/__tests__/intent-routing.test.ts +2 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +291 -1
  46. package/src/__tests__/memory-upsert-concurrency.test.ts +828 -0
  47. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  48. package/src/__tests__/model-intents.test.ts +96 -0
  49. package/src/__tests__/no-direct-anthropic-sdk-imports.test.ts +42 -0
  50. package/src/__tests__/oauth2-gateway-transport.test.ts +130 -0
  51. package/src/__tests__/onboarding-starter-tasks.test.ts +2 -0
  52. package/src/__tests__/provider-commit-message-generator.test.ts +89 -13
  53. package/src/__tests__/provider-error-scenarios.test.ts +621 -0
  54. package/src/__tests__/provider-fail-open-selection.test.ts +119 -0
  55. package/src/__tests__/qdrant-manager.test.ts +27 -20
  56. package/src/__tests__/relay-server.test.ts +779 -40
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +6 -0
  58. package/src/__tests__/run-orchestrator.test.ts +42 -4
  59. package/src/__tests__/runtime-runs-http.test.ts +17 -1
  60. package/src/__tests__/runtime-runs.test.ts +16 -0
  61. package/src/__tests__/schedule-store.test.ts +18 -4
  62. package/src/__tests__/scheduler-recurrence.test.ts +13 -4
  63. package/src/__tests__/session-abort-tool-results.test.ts +6 -0
  64. package/src/__tests__/session-agent-loop.test.ts +857 -0
  65. package/src/__tests__/session-conflict-gate.test.ts +6 -0
  66. package/src/__tests__/session-pre-run-repair.test.ts +6 -0
  67. package/src/__tests__/session-profile-injection.test.ts +6 -0
  68. package/src/__tests__/session-provider-retry-repair.test.ts +6 -0
  69. package/src/__tests__/session-queue.test.ts +6 -0
  70. package/src/__tests__/session-runtime-assembly.test.ts +321 -13
  71. package/src/__tests__/session-slash-known.test.ts +6 -0
  72. package/src/__tests__/session-slash-queue.test.ts +6 -0
  73. package/src/__tests__/session-slash-unknown.test.ts +6 -0
  74. package/src/__tests__/session-surfaces-task-progress.test.ts +2 -0
  75. package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
  76. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
  77. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
  78. package/src/__tests__/session-workspace-injection.test.ts +6 -0
  79. package/src/__tests__/session-workspace-tool-tracking.test.ts +6 -0
  80. package/src/__tests__/skills.test.ts +2 -0
  81. package/src/__tests__/sms-messaging-provider.test.ts +126 -0
  82. package/src/__tests__/starter-task-flow.test.ts +2 -0
  83. package/src/__tests__/swarm-dag-pathological.test.ts +535 -0
  84. package/src/__tests__/system-prompt.test.ts +2 -0
  85. package/src/__tests__/task-management-tools.test.ts +2 -2
  86. package/src/__tests__/task-runner.test.ts +14 -4
  87. package/src/__tests__/terminal-tools.test.ts +25 -19
  88. package/src/__tests__/tool-execution-abort-cleanup.test.ts +545 -0
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +11 -11
  90. package/src/__tests__/tool-executor.test.ts +23 -24
  91. package/src/__tests__/trust-store.test.ts +3 -3
  92. package/src/__tests__/twilio-rest.test.ts +29 -0
  93. package/src/__tests__/twilio-routes-elevenlabs.test.ts +3 -0
  94. package/src/__tests__/twilio-routes-twiml.test.ts +11 -0
  95. package/src/__tests__/twilio-routes.test.ts +167 -11
  96. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  97. package/src/__tests__/user-reference.test.ts +2 -0
  98. package/src/__tests__/voice-quality.test.ts +222 -0
  99. package/src/__tests__/web-search.test.ts +46 -30
  100. package/src/__tests__/work-item-output.test.ts +110 -0
  101. package/src/agent/loop.ts +1 -1
  102. package/src/agent-heartbeat/agent-heartbeat-service.ts +2 -10
  103. package/src/amazon/client.ts +1418 -0
  104. package/src/amazon/request-extractor.ts +135 -0
  105. package/src/amazon/session.ts +109 -0
  106. package/src/autonomy/autonomy-store.ts +5 -5
  107. package/src/browser-extension-relay/client.ts +124 -0
  108. package/src/browser-extension-relay/protocol.ts +63 -0
  109. package/src/browser-extension-relay/server.ts +177 -0
  110. package/src/bundler/app-bundler.ts +3 -3
  111. package/src/bundler/bundle-signer.ts +1 -1
  112. package/src/bundler/signature-verifier.ts +1 -1
  113. package/src/calls/call-conversation-messages.ts +33 -0
  114. package/src/calls/call-domain.ts +114 -10
  115. package/src/calls/call-orchestrator.ts +268 -59
  116. package/src/calls/call-pointer-messages.ts +53 -0
  117. package/src/calls/call-recovery.ts +3 -8
  118. package/src/calls/call-store.ts +69 -87
  119. package/src/calls/elevenlabs-config.ts +3 -2
  120. package/src/calls/guardian-action-sweep.ts +105 -0
  121. package/src/calls/guardian-dispatch.ts +203 -0
  122. package/src/calls/guardian-question-copy.ts +133 -0
  123. package/src/calls/relay-server.ts +466 -8
  124. package/src/calls/speaker-identification.ts +1 -1
  125. package/src/calls/twilio-config.ts +22 -14
  126. package/src/calls/twilio-provider.ts +6 -4
  127. package/src/calls/twilio-rest.ts +308 -7
  128. package/src/calls/twilio-routes.ts +65 -12
  129. package/src/calls/types.ts +3 -1
  130. package/src/channels/types.ts +25 -0
  131. package/src/cli/amazon.ts +815 -0
  132. package/src/cli/config-commands.ts +2 -2
  133. package/src/cli/core-commands.ts +4 -3
  134. package/src/cli/influencer.ts +244 -0
  135. package/src/cli/map.ts +89 -6
  136. package/src/cli.ts +1 -1
  137. package/src/config/agent-schema.ts +171 -0
  138. package/src/config/bundled-skills/amazon/SKILL.md +127 -0
  139. package/src/config/bundled-skills/amazon/icon.svg +13 -0
  140. package/src/config/bundled-skills/api-mapping/SKILL.md +78 -0
  141. package/src/config/bundled-skills/browser/SKILL.md +1 -0
  142. package/src/config/bundled-skills/browser/TOOLS.json +17 -0
  143. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +25 -0
  144. package/src/config/bundled-skills/doordash/SKILL.md +51 -51
  145. package/src/config/bundled-skills/email-setup/SKILL.md +14 -5
  146. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +183 -0
  147. package/src/config/bundled-skills/influencer/SKILL.md +144 -0
  148. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  149. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  150. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  151. package/src/config/bundled-skills/macos-automation/icon.svg +12 -0
  152. package/src/config/bundled-skills/media-processing/SKILL.md +176 -0
  153. package/src/config/bundled-skills/media-processing/TOOLS.json +230 -0
  154. package/src/config/bundled-skills/media-processing/__tests__/concurrency-pool.test.ts +77 -0
  155. package/src/config/bundled-skills/media-processing/__tests__/cost-tracker.test.ts +69 -0
  156. package/src/config/bundled-skills/media-processing/__tests__/preprocess.test.ts +303 -0
  157. package/src/config/bundled-skills/media-processing/services/concurrency-pool.ts +55 -0
  158. package/src/config/bundled-skills/media-processing/services/cost-tracker.ts +86 -0
  159. package/src/config/bundled-skills/media-processing/services/gemini-map.ts +339 -0
  160. package/src/config/bundled-skills/media-processing/services/preprocess.ts +551 -0
  161. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +259 -0
  162. package/src/config/bundled-skills/media-processing/services/reduce.ts +197 -0
  163. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +136 -0
  164. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +59 -0
  165. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  166. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  167. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +143 -0
  168. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  169. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +65 -0
  170. package/src/config/bundled-skills/messaging/SKILL.md +33 -8
  171. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +4 -7
  172. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +2 -1
  173. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  174. package/src/config/bundled-skills/phone-calls/SKILL.md +88 -23
  175. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  176. package/src/config/bundled-skills/twitter/icon.svg +14 -0
  177. package/src/config/bundled-tool-registry.ts +310 -0
  178. package/src/config/calls-schema.ts +181 -0
  179. package/src/config/core-schema.ts +309 -0
  180. package/src/config/defaults.ts +28 -3
  181. package/src/config/env-registry.ts +162 -0
  182. package/src/config/env.ts +175 -0
  183. package/src/config/loader.ts +6 -6
  184. package/src/config/memory-schema.ts +528 -0
  185. package/src/config/sandbox-schema.ts +55 -0
  186. package/src/config/schema.ts +158 -1133
  187. package/src/config/skill-state.ts +1 -1
  188. package/src/config/skills-schema.ts +32 -0
  189. package/src/config/skills.ts +35 -24
  190. package/src/config/system-prompt.ts +131 -56
  191. package/src/config/templates/IDENTITY.md +2 -2
  192. package/src/config/templates/SOUL.md +1 -1
  193. package/src/config/types.ts +1 -0
  194. package/src/config/user-reference.ts +4 -9
  195. package/src/config/vellum-skills/catalog.json +6 -7
  196. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +5 -1
  197. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -3
  198. package/src/config/vellum-skills/sms-setup/SKILL.md +216 -0
  199. package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
  200. package/src/context/window-manager.ts +27 -7
  201. package/src/daemon/approval-generators.ts +186 -0
  202. package/src/daemon/approved-devices-store.ts +140 -0
  203. package/src/daemon/assistant-attachments.ts +1 -1
  204. package/src/daemon/classifier.ts +35 -32
  205. package/src/daemon/config-watcher.ts +1 -1
  206. package/src/daemon/daemon-control.ts +217 -0
  207. package/src/daemon/handlers/apps.ts +2 -3
  208. package/src/daemon/handlers/config-channels.ts +158 -0
  209. package/src/daemon/handlers/config-inbox.ts +540 -0
  210. package/src/daemon/handlers/config-ingress.ts +231 -0
  211. package/src/daemon/handlers/config-integrations.ts +258 -0
  212. package/src/daemon/handlers/config-model.ts +143 -0
  213. package/src/daemon/handlers/config-parental.ts +163 -0
  214. package/src/daemon/handlers/config-scheduling.ts +172 -0
  215. package/src/daemon/handlers/config-slack.ts +92 -0
  216. package/src/daemon/handlers/config-telegram.ts +301 -0
  217. package/src/daemon/handlers/config-tools.ts +177 -0
  218. package/src/daemon/handlers/config-trust.ts +104 -0
  219. package/src/daemon/handlers/config-twilio.ts +1080 -0
  220. package/src/daemon/handlers/config.ts +53 -1689
  221. package/src/daemon/handlers/diagnostics.ts +1 -1
  222. package/src/daemon/handlers/dictation.ts +180 -0
  223. package/src/daemon/handlers/documents.ts +18 -32
  224. package/src/daemon/handlers/identity.ts +14 -23
  225. package/src/daemon/handlers/index.ts +11 -0
  226. package/src/daemon/handlers/misc.ts +3 -5
  227. package/src/daemon/handlers/pairing.ts +98 -0
  228. package/src/daemon/handlers/sessions.ts +56 -5
  229. package/src/daemon/handlers/shared.ts +6 -1
  230. package/src/daemon/handlers/skills.ts +1 -1
  231. package/src/daemon/handlers/twitter-auth.ts +2 -0
  232. package/src/daemon/handlers/work-items.ts +17 -9
  233. package/src/daemon/handlers/workspace-files.ts +4 -3
  234. package/src/daemon/install-cli-launchers.ts +113 -0
  235. package/src/daemon/ipc-contract/apps.ts +356 -0
  236. package/src/daemon/ipc-contract/browser.ts +74 -0
  237. package/src/daemon/ipc-contract/computer-use.ts +151 -0
  238. package/src/daemon/ipc-contract/diagnostics.ts +56 -0
  239. package/src/daemon/ipc-contract/documents.ts +74 -0
  240. package/src/daemon/ipc-contract/inbox.ts +209 -0
  241. package/src/daemon/ipc-contract/integrations.ts +284 -0
  242. package/src/daemon/ipc-contract/memory.ts +48 -0
  243. package/src/daemon/ipc-contract/messages.ts +211 -0
  244. package/src/daemon/ipc-contract/pairing.ts +45 -0
  245. package/src/daemon/ipc-contract/parental-control.ts +95 -0
  246. package/src/daemon/ipc-contract/schedules.ts +97 -0
  247. package/src/daemon/ipc-contract/sessions.ts +315 -0
  248. package/src/daemon/ipc-contract/shared.ts +42 -0
  249. package/src/daemon/ipc-contract/skills.ts +120 -0
  250. package/src/daemon/ipc-contract/subagents.ts +58 -0
  251. package/src/daemon/ipc-contract/surfaces.ts +250 -0
  252. package/src/daemon/ipc-contract/trust.ts +60 -0
  253. package/src/daemon/ipc-contract/work-items.ts +225 -0
  254. package/src/daemon/ipc-contract/workspace.ts +113 -0
  255. package/src/daemon/ipc-contract-inventory.json +70 -0
  256. package/src/daemon/ipc-contract-inventory.ts +55 -29
  257. package/src/daemon/ipc-contract.ts +229 -2426
  258. package/src/daemon/ipc-protocol.ts +1 -1
  259. package/src/daemon/ipc-validate.ts +7 -0
  260. package/src/daemon/lifecycle.ts +97 -377
  261. package/src/daemon/pairing-store.ts +177 -0
  262. package/src/daemon/providers-setup.ts +43 -0
  263. package/src/daemon/ride-shotgun-handler.ts +68 -3
  264. package/src/daemon/server.ts +66 -46
  265. package/src/daemon/session-agent-loop-handlers.ts +421 -0
  266. package/src/daemon/session-agent-loop.ts +117 -275
  267. package/src/daemon/session-dynamic-profile.ts +1 -1
  268. package/src/daemon/session-history.ts +1 -1
  269. package/src/daemon/session-media-retry.ts +1 -1
  270. package/src/daemon/session-messaging.ts +37 -2
  271. package/src/daemon/session-notifiers.ts +5 -25
  272. package/src/daemon/session-process.ts +99 -59
  273. package/src/daemon/session-queue-manager.ts +96 -4
  274. package/src/daemon/session-runtime-assembly.ts +199 -10
  275. package/src/daemon/session-surfaces.ts +19 -4
  276. package/src/daemon/session-tool-setup.ts +30 -30
  277. package/src/daemon/session-workspace.ts +1 -1
  278. package/src/daemon/session.ts +35 -2
  279. package/src/daemon/shutdown-handlers.ts +122 -0
  280. package/src/daemon/trace-emitter.ts +1 -1
  281. package/src/daemon/watch-handler.ts +36 -33
  282. package/src/doordash/cart-queries.ts +787 -0
  283. package/src/doordash/client.ts +144 -127
  284. package/src/doordash/order-queries.ts +85 -0
  285. package/src/doordash/queries.ts +10 -1308
  286. package/src/doordash/search-queries.ts +203 -0
  287. package/src/doordash/session.ts +3 -2
  288. package/src/doordash/store-queries.ts +246 -0
  289. package/src/doordash/types.ts +367 -0
  290. package/src/email/providers/agentmail.ts +2 -1
  291. package/src/email/providers/index.ts +3 -2
  292. package/src/email/service.ts +3 -2
  293. package/src/errors.ts +43 -0
  294. package/src/home-base/prebuilt/seed.ts +1 -1
  295. package/src/hooks/cli.ts +6 -5
  296. package/src/hooks/config.ts +6 -8
  297. package/src/hooks/discovery.ts +6 -5
  298. package/src/hooks/manager.ts +4 -3
  299. package/src/hooks/runner.ts +2 -2
  300. package/src/hooks/templates.ts +5 -5
  301. package/src/inbound/public-ingress-urls.ts +6 -4
  302. package/src/index.ts +4 -2
  303. package/src/influencer/client.ts +1104 -0
  304. package/src/instrument.ts +4 -3
  305. package/src/logfire.ts +4 -3
  306. package/src/memory/admin.ts +25 -35
  307. package/src/memory/attachments-store.ts +4 -7
  308. package/src/memory/channel-delivery-store.ts +30 -1
  309. package/src/memory/channel-guardian-store.ts +202 -2
  310. package/src/memory/clarification-resolver.ts +37 -33
  311. package/src/memory/conflict-store.ts +67 -61
  312. package/src/memory/contradiction-checker.ts +141 -117
  313. package/src/memory/conversation-store.ts +335 -51
  314. package/src/memory/db-connection.ts +27 -4
  315. package/src/memory/db-init.ts +265 -4
  316. package/src/memory/db.ts +14 -1
  317. package/src/memory/embedding-backend.ts +27 -5
  318. package/src/memory/embedding-ollama.ts +2 -1
  319. package/src/memory/entity-extractor.ts +38 -35
  320. package/src/memory/guardian-action-store.ts +430 -0
  321. package/src/memory/inbox-escalation-projection.ts +59 -0
  322. package/src/memory/inbox-thread-store.ts +218 -0
  323. package/src/memory/ingress-invite-store.ts +338 -0
  324. package/src/memory/ingress-member-store.ts +350 -0
  325. package/src/memory/items-extractor.ts +91 -97
  326. package/src/memory/job-handlers/index-maintenance.ts +3 -3
  327. package/src/memory/job-handlers/media-processing.ts +69 -0
  328. package/src/memory/job-handlers/summarization.ts +32 -26
  329. package/src/memory/job-utils.ts +3 -10
  330. package/src/memory/jobs-store.ts +8 -10
  331. package/src/memory/jobs-worker.ts +55 -36
  332. package/src/memory/media-store.ts +759 -0
  333. package/src/memory/migrations/001-job-deferrals.ts +45 -0
  334. package/src/memory/migrations/002-tool-invocations-fk.ts +43 -0
  335. package/src/memory/migrations/003-memory-fts-backfill.ts +24 -0
  336. package/src/memory/migrations/004-entity-relation-dedup.ts +87 -0
  337. package/src/memory/migrations/005-fingerprint-scope-unique.ts +80 -0
  338. package/src/memory/migrations/006-scope-salted-fingerprints.ts +62 -0
  339. package/src/memory/migrations/007-assistant-id-to-self.ts +254 -0
  340. package/src/memory/migrations/008-remove-assistant-id-columns.ts +208 -0
  341. package/src/memory/migrations/009-llm-usage-events-drop-assistant-id.ts +83 -0
  342. package/src/memory/migrations/010-ext-conv-bindings-channel-chat-unique.ts +56 -0
  343. package/src/memory/migrations/011-call-sessions-provider-sid-dedup.ts +63 -0
  344. package/src/memory/migrations/012-call-sessions-add-initiated-from.ts +19 -0
  345. package/src/memory/migrations/013-guardian-action-tables.ts +68 -0
  346. package/src/memory/migrations/014-backfill-inbox-thread-state.ts +76 -0
  347. package/src/memory/migrations/015-drop-active-search-index.ts +27 -0
  348. package/src/memory/migrations/016-memory-segments-indexes.ts +11 -0
  349. package/src/memory/migrations/017-memory-items-indexes.ts +10 -0
  350. package/src/memory/migrations/018-remaining-table-indexes.ts +13 -0
  351. package/src/memory/migrations/index.ts +24 -0
  352. package/src/memory/migrations/registry.ts +79 -0
  353. package/src/memory/migrations/validate-migration-state.ts +69 -0
  354. package/src/memory/qdrant-manager.ts +49 -8
  355. package/src/memory/query-builder.ts +1 -1
  356. package/src/memory/raw-query.ts +119 -0
  357. package/src/memory/recall-cache.ts +4 -1
  358. package/src/memory/retriever.ts +165 -47
  359. package/src/memory/schema-migration.ts +25 -984
  360. package/src/memory/schema.ts +228 -7
  361. package/src/memory/search/entity.ts +205 -31
  362. package/src/memory/search/lexical.ts +81 -52
  363. package/src/memory/search/ranking.ts +27 -23
  364. package/src/memory/search/semantic.ts +157 -19
  365. package/src/memory/search/types.ts +24 -0
  366. package/src/memory/shared-app-links-store.ts +4 -5
  367. package/src/memory/validation.ts +19 -0
  368. package/src/messaging/draft-store.ts +5 -6
  369. package/src/messaging/provider-types.ts +2 -0
  370. package/src/messaging/providers/sms/adapter.ts +201 -0
  371. package/src/messaging/providers/sms/client.ts +93 -0
  372. package/src/messaging/providers/sms/types.ts +7 -0
  373. package/src/messaging/providers/telegram-bot/adapter.ts +2 -5
  374. package/src/messaging/providers/whatsapp/adapter.ts +136 -0
  375. package/src/messaging/providers/whatsapp/client.ts +67 -0
  376. package/src/messaging/style-analyzer.ts +5 -4
  377. package/src/messaging/thread-summarizer.ts +61 -69
  378. package/src/messaging/triage-engine.ts +62 -71
  379. package/src/migrations/config-merge.ts +53 -0
  380. package/src/migrations/data-layout.ts +68 -0
  381. package/src/migrations/data-merge.ts +33 -0
  382. package/src/migrations/hooks-merge.ts +90 -0
  383. package/src/migrations/index.ts +6 -0
  384. package/src/migrations/log.ts +23 -0
  385. package/src/migrations/skills-merge.ts +33 -0
  386. package/src/migrations/workspace-layout.ts +79 -0
  387. package/src/permissions/checker.ts +133 -11
  388. package/src/permissions/prompter.ts +14 -0
  389. package/src/permissions/shell-identity.ts +31 -1
  390. package/src/permissions/trust-store.ts +21 -1
  391. package/src/providers/anthropic/client.ts +4 -4
  392. package/src/providers/failover.ts +2 -2
  393. package/src/providers/model-intents.ts +70 -0
  394. package/src/providers/ollama/client.ts +2 -1
  395. package/src/providers/provider-send-message.ts +176 -0
  396. package/src/providers/registry.ts +71 -30
  397. package/src/providers/retry.ts +35 -1
  398. package/src/providers/types.ts +12 -1
  399. package/src/runtime/approval-conversation-turn.ts +97 -0
  400. package/src/runtime/approval-message-composer.ts +253 -0
  401. package/src/runtime/channel-approval-parser.ts +36 -2
  402. package/src/runtime/channel-approvals.ts +11 -24
  403. package/src/runtime/channel-guardian-service.ts +88 -21
  404. package/src/runtime/channel-readiness-service.ts +418 -0
  405. package/src/runtime/channel-readiness-types.ts +35 -0
  406. package/src/runtime/channel-retry-sweep.ts +184 -0
  407. package/src/runtime/guardian-context-resolver.ts +108 -0
  408. package/src/runtime/http-server.ts +275 -717
  409. package/src/runtime/http-types.ts +59 -3
  410. package/src/runtime/middleware/auth.ts +116 -0
  411. package/src/runtime/middleware/error-handler.ts +33 -0
  412. package/src/runtime/middleware/twilio-validation.ts +127 -0
  413. package/src/runtime/routes/app-routes.ts +1 -1
  414. package/src/runtime/routes/call-routes.ts +51 -7
  415. package/src/runtime/routes/channel-delivery-routes.ts +170 -0
  416. package/src/runtime/routes/channel-guardian-routes.ts +1191 -0
  417. package/src/runtime/routes/channel-inbound-routes.ts +1152 -0
  418. package/src/runtime/routes/channel-route-shared.ts +144 -0
  419. package/src/runtime/routes/channel-routes.ts +32 -1588
  420. package/src/runtime/routes/conversation-routes.ts +50 -7
  421. package/src/runtime/routes/events-routes.ts +2 -2
  422. package/src/runtime/routes/identity-routes.ts +126 -0
  423. package/src/runtime/routes/pairing-routes.ts +143 -0
  424. package/src/runtime/routes/run-routes.ts +15 -1
  425. package/src/runtime/run-orchestrator.ts +86 -35
  426. package/src/schedule/schedule-store.ts +36 -32
  427. package/src/schedule/scheduler.ts +3 -3
  428. package/src/security/encrypted-store.ts +5 -7
  429. package/src/security/oauth2.ts +45 -15
  430. package/src/security/parental-control-store.ts +183 -0
  431. package/src/security/secret-allowlist.ts +4 -3
  432. package/src/security/secret-scanner.ts +5 -5
  433. package/src/security/secure-keys.ts +1 -1
  434. package/src/security/token-manager.ts +3 -2
  435. package/src/services/vercel-deploy.ts +6 -2
  436. package/src/skills/tool-manifest.ts +3 -3
  437. package/src/skills/vellum-catalog-remote.ts +75 -16
  438. package/src/slack/slack-webhook.ts +2 -1
  439. package/src/swarm/orchestrator.ts +92 -1
  440. package/src/swarm/router-planner.ts +6 -9
  441. package/src/swarm/worker-prompts.ts +9 -12
  442. package/src/tasks/task-compiler.ts +19 -28
  443. package/src/tasks/task-runner.ts +1 -1
  444. package/src/tools/assets/materialize.ts +2 -2
  445. package/src/tools/assets/search.ts +15 -14
  446. package/src/tools/browser/__tests__/auth-detector.test.ts +1 -0
  447. package/src/tools/browser/auto-navigate.ts +1 -0
  448. package/src/tools/browser/browser-execution.ts +10 -1
  449. package/src/tools/browser/browser-manager.ts +119 -4
  450. package/src/tools/browser/network-recorder.ts +5 -0
  451. package/src/tools/calls/call-start.ts +1 -0
  452. package/src/tools/credentials/broker.ts +11 -2
  453. package/src/tools/credentials/metadata-store.ts +18 -14
  454. package/src/tools/credentials/post-connect-hooks.ts +61 -0
  455. package/src/tools/credentials/vault.ts +49 -23
  456. package/src/tools/execution-target.ts +11 -1
  457. package/src/tools/executor.ts +68 -9
  458. package/src/tools/host-terminal/cli-discover.ts +1 -1
  459. package/src/tools/network/script-proxy/http-forwarder.ts +1 -1
  460. package/src/tools/network/script-proxy/mitm-handler.ts +1 -1
  461. package/src/tools/network/script-proxy/server.ts +1 -1
  462. package/src/tools/network/script-proxy/session-manager.ts +6 -5
  463. package/src/tools/network/web-fetch.ts +18 -2
  464. package/src/tools/network/web-search.ts +8 -4
  465. package/src/tools/reminder/reminder-store.ts +14 -15
  466. package/src/tools/schedule/create.ts +1 -0
  467. package/src/tools/schedule/list.ts +2 -1
  468. package/src/tools/shared/filesystem/file-ops-service.ts +5 -7
  469. package/src/tools/skills/skill-script-runner.ts +24 -9
  470. package/src/tools/skills/skill-tool-factory.ts +1 -0
  471. package/src/tools/tasks/work-item-enqueue.ts +2 -2
  472. package/src/tools/terminal/evaluate-typescript.ts +21 -12
  473. package/src/tools/terminal/parser.ts +50 -0
  474. package/src/tools/types.ts +2 -0
  475. package/src/tools/watcher/delete.ts +6 -0
  476. package/src/tools/weather/service.ts +1 -1
  477. package/src/twitter/client.ts +190 -24
  478. package/src/twitter/router.ts +1 -1
  479. package/src/twitter/session.ts +4 -3
  480. package/src/util/clipboard.ts +1 -1
  481. package/src/util/errors.ts +65 -8
  482. package/src/util/fs.ts +40 -0
  483. package/src/util/json.ts +10 -0
  484. package/src/util/log-redact.ts +189 -0
  485. package/src/util/logger.ts +19 -17
  486. package/src/util/object.ts +3 -0
  487. package/src/util/platform.ts +105 -363
  488. package/src/util/pricing.ts +1 -1
  489. package/src/util/promise-guard.ts +1 -1
  490. package/src/util/retry.ts +19 -0
  491. package/src/util/row-mapper.ts +79 -0
  492. package/src/util/silently.ts +21 -0
  493. package/src/watcher/engine.ts +5 -1
  494. package/src/watcher/provider-types.ts +20 -0
  495. package/src/watcher/providers/github.ts +156 -0
  496. package/src/watcher/providers/gmail.ts +1 -0
  497. package/src/watcher/providers/google-calendar.ts +1 -0
  498. package/src/watcher/providers/linear.ts +460 -0
  499. package/src/watcher/providers/slack.ts +1 -0
  500. package/src/work-items/work-item-runner.ts +1 -1
  501. package/src/workspace/git-service.ts +1 -1
  502. package/src/workspace/provider-commit-message-generator.ts +51 -22
  503. package/src/__tests__/call-bridge.test.ts +0 -517
  504. package/src/__tests__/session-process-bridge.test.ts +0 -244
  505. package/src/calls/call-bridge.ts +0 -168
  506. 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 () => {
@@ -1635,7 +1642,9 @@ describe('SMS guardian verify intercept', () => {
1635
1642
  const replyArgs = deliverSpy.mock.calls[0];
1636
1643
  const replyPayload = replyArgs[1] as { chatId: string; text: string };
1637
1644
  expect(replyPayload.chatId).toBe('sms-chat-verify');
1638
- expect(replyPayload.text).toContain('Guardian verified successfully');
1645
+ expect(typeof replyPayload.text).toBe('string');
1646
+ expect(replyPayload.text.toLowerCase()).toContain('guardian');
1647
+ expect(replyPayload.text.toLowerCase()).toContain('verif');
1639
1648
 
1640
1649
  deliverSpy.mockRestore();
1641
1650
  });
@@ -1668,7 +1677,9 @@ describe('SMS guardian verify intercept', () => {
1668
1677
  expect(deliverSpy).toHaveBeenCalled();
1669
1678
  const replyArgs = deliverSpy.mock.calls[0];
1670
1679
  const replyPayload = replyArgs[1] as { chatId: string; text: string };
1671
- expect(replyPayload.text).toContain('Verification failed');
1680
+ expect(typeof replyPayload.text).toBe('string');
1681
+ expect(replyPayload.text.toLowerCase()).toContain('verif');
1682
+ expect(replyPayload.text.toLowerCase()).toContain('failed');
1672
1683
 
1673
1684
  deliverSpy.mockRestore();
1674
1685
  });
@@ -1751,7 +1762,7 @@ describe('SMS non-guardian actor gating', () => {
1751
1762
  });
1752
1763
  });
1753
1764
 
1754
- describe('plain-text fallback surfacing for non-rich channels', () => {
1765
+ describe('non-decision status reply for different channels', () => {
1755
1766
  beforeEach(() => {
1756
1767
  createBinding({
1757
1768
  assistantId: 'self',
@@ -1761,19 +1772,18 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
1761
1772
  });
1762
1773
  createBinding({
1763
1774
  assistantId: 'self',
1764
- channel: 'http-api',
1775
+ channel: 'sms',
1765
1776
  guardianExternalUserId: 'telegram-user-default',
1766
1777
  guardianDeliveryChatId: 'chat-123',
1767
1778
  });
1768
1779
  });
1769
1780
 
1770
- 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 () => {
1771
1782
  const orchestrator = makeMockOrchestrator();
1772
- const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1773
- const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1783
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1774
1784
 
1775
- // Establish the conversation using http-api (non-rich channel)
1776
- 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' });
1777
1787
  await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1778
1788
 
1779
1789
  const db = getDb();
@@ -1784,30 +1794,28 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
1784
1794
  const run = createRun(conversationId!);
1785
1795
  setRunConfirmation(run.id, sampleConfirmation);
1786
1796
 
1787
- // Send a non-decision message to trigger a reminder
1788
- 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' });
1789
1799
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1790
1800
  const body = await res.json() as Record<string, unknown>;
1791
1801
 
1792
1802
  expect(body.accepted).toBe(true);
1793
- expect(body.approval).toBe('reminder_sent');
1803
+ expect(body.approval).toBe('assistant_turn');
1794
1804
 
1795
- // The delivered text should include the plainTextFallback instructions
1805
+ // Status reply delivered via deliverChannelReply
1796
1806
  expect(deliverSpy).toHaveBeenCalled();
1797
- const callArgs = deliverSpy.mock.calls[0];
1798
- const deliveredText = callArgs[2] as string;
1799
- // For non-rich channels, the text should contain both the reminder prefix
1800
- // AND the plainTextFallback instructions (e.g. "Reply yes to approve")
1801
- expect(deliveredText).toContain("I'm still waiting");
1802
- 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');
1803
1813
 
1804
1814
  deliverSpy.mockRestore();
1805
- replySpy.mockRestore();
1806
1815
  });
1807
1816
 
1808
- test('reminder prompt does NOT include plainTextFallback for telegram (rich channel)', async () => {
1817
+ test('non-decision message on telegram sends status reply', async () => {
1809
1818
  const orchestrator = makeMockOrchestrator();
1810
- const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1811
1819
  const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1812
1820
 
1813
1821
  // Establish the conversation using telegram (rich channel)
@@ -1822,25 +1830,23 @@ describe('plain-text fallback surfacing for non-rich channels', () => {
1822
1830
  const run = createRun(conversationId!);
1823
1831
  setRunConfirmation(run.id, sampleConfirmation);
1824
1832
 
1825
- // Send a non-decision message to trigger a reminder
1833
+ // Send a non-decision message
1826
1834
  const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'telegram' });
1827
1835
  const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1828
1836
  const body = await res.json() as Record<string, unknown>;
1829
1837
 
1830
1838
  expect(body.accepted).toBe(true);
1831
- expect(body.approval).toBe('reminder_sent');
1839
+ expect(body.approval).toBe('assistant_turn');
1832
1840
 
1833
- // For rich channels (telegram), the delivered text should be just the
1834
- // promptText (with reminder prefix) — NOT the plainTextFallback.
1835
- expect(deliverSpy).toHaveBeenCalled();
1836
- const callArgs = deliverSpy.mock.calls[0];
1837
- const deliveredText = callArgs[2] as string;
1838
- expect(deliveredText).toContain("I'm still waiting");
1839
- // The raw promptText does not contain "Reply" instructions — those are
1840
- // only in the plainTextFallback.
1841
- 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');
1842
1849
 
1843
- deliverSpy.mockRestore();
1844
1850
  replySpy.mockRestore();
1845
1851
  });
1846
1852
  });
@@ -1944,11 +1950,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
1944
1950
 
1945
1951
  // The deny decision should carry guardian setup context for assistant reply generation.
1946
1952
  expect(typeof decisionArgs[2]).toBe('string');
1947
- expect((decisionArgs[2] as string)).toContain('no guardian is configured');
1953
+ expect((decisionArgs[2] as string).toLowerCase()).toContain('no guardian');
1948
1954
 
1949
1955
  // The runtime should not send a second deterministic denial notice.
1950
1956
  const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
1951
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
1957
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
1952
1958
  );
1953
1959
  expect(deterministicNoticeCalls.length).toBe(0);
1954
1960
 
@@ -2045,11 +2051,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
2045
2051
  const lastDecision = submitCalls[submitCalls.length - 1];
2046
2052
  expect(lastDecision[1]).toBe('deny');
2047
2053
  expect(typeof lastDecision[2]).toBe('string');
2048
- expect((lastDecision[2] as string)).toContain('no guardian is configured');
2054
+ expect((lastDecision[2] as string).toLowerCase()).toContain('no guardian');
2049
2055
 
2050
2056
  // Interception should not emit a separate deterministic denial notice.
2051
2057
  const denialCalls = deliverSpy.mock.calls.filter(
2052
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
2058
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
2053
2059
  );
2054
2060
  expect(denialCalls.length).toBe(0);
2055
2061
 
@@ -2092,9 +2098,9 @@ describe('guardian-with-binding path regression', () => {
2092
2098
  const approvalArgs = approvalSpy.mock.calls[0];
2093
2099
  expect(approvalArgs[1]).toBe('guardian-chat-1');
2094
2100
 
2095
- // Requester should have been notified the request was sent to the guardian
2101
+ // Requester should have been notified the request was forwarded to the guardian
2096
2102
  const notifyCalls = deliverSpy.mock.calls.filter(
2097
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
2103
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('guardian'),
2098
2104
  );
2099
2105
  expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
2100
2106
 
@@ -2184,14 +2190,14 @@ describe('guardian-with-binding path regression', () => {
2184
2190
  });
2185
2191
 
2186
2192
  // ═══════════════════════════════════════════════════════════════════════════
2187
- // 20. Guardian delivery failure denial (WS-2)
2193
+ // 20. Guardian rich-delivery failure fallback (WS-2)
2188
2194
  // ═══════════════════════════════════════════════════════════════════════════
2189
2195
 
2190
- describe('guardian delivery failure → denial', () => {
2196
+ describe('guardian delivery failure → text fallback', () => {
2191
2197
  beforeEach(() => {
2192
2198
  });
2193
2199
 
2194
- test('delivery failure denies run and notifies requester', async () => {
2200
+ test('rich delivery failure falls back to plain text and keeps request pending', async () => {
2195
2201
  createBinding({
2196
2202
  assistantId: 'self',
2197
2203
  channel: 'telegram',
@@ -2216,29 +2222,30 @@ describe('guardian delivery failure → denial', () => {
2216
2222
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2217
2223
  await new Promise((resolve) => setTimeout(resolve, 1200));
2218
2224
 
2219
- // The run should have been denied
2220
- expect(orchestrator.submitDecision).toHaveBeenCalled();
2221
- const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
2222
- 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();
2223
2228
 
2224
- // Requester should have been notified that delivery failed
2225
- const failureCalls = deliverSpy.mock.calls.filter(
2226
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('could not be sent to the guardian for approval'),
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"'),
2227
2235
  );
2228
- expect(failureCalls.length).toBeGreaterThanOrEqual(1);
2236
+ expect(guardianPromptCalls.length).toBeGreaterThanOrEqual(1);
2229
2237
 
2230
- // The "has been sent to the guardian for approval" success notice should
2231
- // NOT have been delivered (since delivery failed).
2238
+ // Requester should still get the forwarded notice once fallback delivery works.
2232
2239
  const successCalls = deliverSpy.mock.calls.filter(
2233
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
2240
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
2234
2241
  );
2235
- expect(successCalls.length).toBe(0);
2242
+ expect(successCalls.length).toBeGreaterThanOrEqual(1);
2236
2243
 
2237
2244
  deliverSpy.mockRestore();
2238
2245
  approvalSpy.mockRestore();
2239
2246
  });
2240
2247
 
2241
- 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 () => {
2242
2249
  createBinding({
2243
2250
  assistantId: 'self',
2244
2251
  channel: 'telegram',
@@ -2261,15 +2268,19 @@ describe('guardian delivery failure → denial', () => {
2261
2268
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2262
2269
  await new Promise((resolve) => setTimeout(resolve, 1200));
2263
2270
 
2271
+ // Rich delivery failure alone should not apply an explicit deny decision.
2272
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2273
+
2264
2274
  // Verify the run ID was created
2265
2275
  const runId = orchestrator.realRunId();
2266
2276
  expect(runId).toBeTruthy();
2267
2277
 
2268
- // 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.
2269
2280
  const pendingApproval = getPendingApprovalForRun(runId!);
2270
2281
  expect(pendingApproval).toBeNull();
2271
2282
 
2272
- // There should also be NO unresolved approval (it was set to 'denied')
2283
+ // No unresolved approval should remain after terminal resolution.
2273
2284
  const unresolvedApproval = getUnresolvedApprovalForRun(runId!);
2274
2285
  expect(unresolvedApproval).toBeNull();
2275
2286
 
@@ -2279,14 +2290,14 @@ describe('guardian delivery failure → denial', () => {
2279
2290
  });
2280
2291
 
2281
2292
  // ═══════════════════════════════════════════════════════════════════════════
2282
- // 20b. Standard approval prompt delivery failure → auto-deny (WS-B)
2293
+ // 20b. Standard rich prompt delivery failure → text fallback (WS-B)
2283
2294
  // ═══════════════════════════════════════════════════════════════════════════
2284
2295
 
2285
- describe('standard approval prompt delivery failure → auto-deny', () => {
2296
+ describe('standard approval prompt delivery failure → text fallback', () => {
2286
2297
  beforeEach(() => {
2287
2298
  });
2288
2299
 
2289
- 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 () => {
2290
2301
  const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2291
2302
  // Make the approval prompt delivery fail for the standard (self-approval) path
2292
2303
  const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
@@ -2313,10 +2324,16 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
2313
2324
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2314
2325
  await new Promise((resolve) => setTimeout(resolve, 1200));
2315
2326
 
2316
- // The run should have been auto-denied because the prompt could not be delivered
2317
- expect(orchestrator.submitDecision).toHaveBeenCalled();
2318
- const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
2319
- 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);
2320
2337
 
2321
2338
  deliverSpy.mockRestore();
2322
2339
  approvalSpy.mockRestore();
@@ -2463,18 +2480,28 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2463
2480
 
2464
2481
  const orchestrator = makeMockOrchestrator();
2465
2482
 
2466
- // 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.
2467
2491
  const req = makeInboundRequest({
2468
2492
  content: 'yes',
2469
2493
  externalChatId: 'guardian-ambig-chat',
2470
2494
  senderExternalUserId: 'guardian-ambig-user',
2471
2495
  });
2472
2496
 
2473
- const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2497
+ const res = await handleChannelInbound(
2498
+ req, noopProcessMessage, 'token', orchestrator, 'self', undefined, undefined,
2499
+ mockConversationGenerator,
2500
+ );
2474
2501
  const body = await res.json() as Record<string, unknown>;
2475
2502
 
2476
2503
  expect(body.accepted).toBe(true);
2477
- expect(body.approval).toBe('guardian_decision_applied');
2504
+ expect(body.approval).toBe('assistant_turn');
2478
2505
 
2479
2506
  // Neither approval should have been resolved — disambiguation was required
2480
2507
  const approvalA = getPendingApprovalForRun(runA.id);
@@ -2485,9 +2512,14 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
2485
2512
  // submitDecision should NOT have been called — no decision was applied
2486
2513
  expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2487
2514
 
2488
- // 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
2489
2521
  const disambigCalls = deliverSpy.mock.calls.filter(
2490
- (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending approval requests'),
2522
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending'),
2491
2523
  );
2492
2524
  expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
2493
2525
 
@@ -3178,12 +3210,12 @@ describe('guardian enforcement behavior', () => {
3178
3210
  const lastDecision = submitCalls[submitCalls.length - 1];
3179
3211
  expect(lastDecision[1]).toBe('deny');
3180
3212
  expect(typeof lastDecision[2]).toBe('string');
3181
- expect((lastDecision[2] as string)).toContain('identity could not be verified');
3213
+ expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
3182
3214
 
3183
3215
  // No separate deterministic denial notice should be emitted here.
3184
3216
  const denialCalls = deliverSpy.mock.calls.filter(
3185
3217
  (call) => typeof call[1] === 'object'
3186
- && ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
3218
+ && ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
3187
3219
  );
3188
3220
  expect(denialCalls.length).toBe(0);
3189
3221
 
@@ -3217,11 +3249,11 @@ describe('guardian enforcement behavior', () => {
3217
3249
  const lastDecision = submitCalls[submitCalls.length - 1];
3218
3250
  expect(lastDecision[1]).toBe('deny');
3219
3251
  expect(typeof lastDecision[2]).toBe('string');
3220
- expect((lastDecision[2] as string)).toContain('identity could not be verified');
3252
+ expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
3221
3253
 
3222
3254
  const denialCalls = deliverSpy.mock.calls.filter(
3223
3255
  (call) => typeof call[1] === 'object'
3224
- && ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
3256
+ && ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
3225
3257
  );
3226
3258
  expect(denialCalls.length).toBe(0);
3227
3259
  expect(approvalSpy).not.toHaveBeenCalled();
@@ -3534,3 +3566,1150 @@ describe('unknown actor identity — forceStrictSideEffects', () => {
3534
3566
  deliverSpy.mockRestore();
3535
3567
  });
3536
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
+ });