@vellumai/assistant 0.3.0

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 (1068) hide show
  1. package/.dockerignore +27 -0
  2. package/.env.example +22 -0
  3. package/Dockerfile +99 -0
  4. package/Dockerfile.sandbox +5 -0
  5. package/README.md +248 -0
  6. package/bun.lock +1723 -0
  7. package/bunfig.toml +2 -0
  8. package/docs/skills.md +158 -0
  9. package/drizzle/0000_dizzy_maggott.sql +301 -0
  10. package/drizzle/meta/0000_snapshot.json +1999 -0
  11. package/drizzle/meta/_journal.json +13 -0
  12. package/drizzle.config.ts +7 -0
  13. package/eslint.config.mjs +17 -0
  14. package/hook-templates/debug-prompt-logger/hook.json +7 -0
  15. package/hook-templates/debug-prompt-logger/run.sh +68 -0
  16. package/knip.json +9 -0
  17. package/package.json +70 -0
  18. package/scripts/capture-x-graphql.ts +545 -0
  19. package/scripts/ipc/check-contract-inventory.ts +104 -0
  20. package/scripts/ipc/check-swift-decoder-drift.ts +166 -0
  21. package/scripts/ipc/generate-swift.ts +492 -0
  22. package/scripts/test-filesystem-tools.sh +48 -0
  23. package/scripts/test.sh +127 -0
  24. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2485 -0
  25. package/src/__tests__/account-registry.test.ts +245 -0
  26. package/src/__tests__/active-skill-tools.test.ts +378 -0
  27. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  28. package/src/__tests__/agent-loop-thinking.test.ts +81 -0
  29. package/src/__tests__/agent-loop.test.ts +1135 -0
  30. package/src/__tests__/anthropic-provider.test.ts +778 -0
  31. package/src/__tests__/app-builder-tool-scripts.test.ts +290 -0
  32. package/src/__tests__/app-bundler.test.ts +292 -0
  33. package/src/__tests__/app-executors.test.ts +613 -0
  34. package/src/__tests__/app-git-history.test.ts +176 -0
  35. package/src/__tests__/app-git-service.test.ts +169 -0
  36. package/src/__tests__/app-open-proxy.test.ts +62 -0
  37. package/src/__tests__/asset-materialize-tool.test.ts +452 -0
  38. package/src/__tests__/asset-search-tool.test.ts +477 -0
  39. package/src/__tests__/assistant-attachment-directive.test.ts +401 -0
  40. package/src/__tests__/assistant-attachments.test.ts +437 -0
  41. package/src/__tests__/assistant-event-hub.test.ts +226 -0
  42. package/src/__tests__/assistant-event.test.ts +123 -0
  43. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  44. package/src/__tests__/attachments-store.test.ts +476 -0
  45. package/src/__tests__/attachments.test.ts +134 -0
  46. package/src/__tests__/audit-log-rotation.test.ts +154 -0
  47. package/src/__tests__/browser-fill-credential.test.ts +309 -0
  48. package/src/__tests__/browser-manager.test.ts +203 -0
  49. package/src/__tests__/browser-runtime-check.test.ts +55 -0
  50. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +68 -0
  51. package/src/__tests__/browser-skill-endstate.test.ts +195 -0
  52. package/src/__tests__/bundle-scanner.test.ts +313 -0
  53. package/src/__tests__/call-bridge.test.ts +517 -0
  54. package/src/__tests__/call-constants.test.ts +40 -0
  55. package/src/__tests__/call-domain.test.ts +163 -0
  56. package/src/__tests__/call-orchestrator.test.ts +625 -0
  57. package/src/__tests__/call-recovery.test.ts +518 -0
  58. package/src/__tests__/call-routes-http.test.ts +699 -0
  59. package/src/__tests__/call-state-machine.test.ts +143 -0
  60. package/src/__tests__/call-state.test.ts +174 -0
  61. package/src/__tests__/call-store.test.ts +691 -0
  62. package/src/__tests__/channel-approval-routes.test.ts +2356 -0
  63. package/src/__tests__/channel-approval.test.ts +299 -0
  64. package/src/__tests__/channel-approvals.test.ts +521 -0
  65. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  66. package/src/__tests__/channel-guardian.test.ts +1005 -0
  67. package/src/__tests__/checker.test.ts +3519 -0
  68. package/src/__tests__/clarification-resolver.test.ts +159 -0
  69. package/src/__tests__/classifier.test.ts +67 -0
  70. package/src/__tests__/claude-code-skill-regression.test.ts +127 -0
  71. package/src/__tests__/claude-code-tool-profiles.test.ts +88 -0
  72. package/src/__tests__/cli-discover.test.ts +85 -0
  73. package/src/__tests__/cli.test.ts +26 -0
  74. package/src/__tests__/clipboard.test.ts +80 -0
  75. package/src/__tests__/commit-guarantee.test.ts +335 -0
  76. package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
  77. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  78. package/src/__tests__/computer-use-session-compaction.test.ts +132 -0
  79. package/src/__tests__/computer-use-session-lifecycle.test.ts +293 -0
  80. package/src/__tests__/computer-use-session-working-dir.test.ts +117 -0
  81. package/src/__tests__/computer-use-skill-baseline.test.ts +74 -0
  82. package/src/__tests__/computer-use-skill-endstate.test.ts +89 -0
  83. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +217 -0
  84. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +107 -0
  85. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +54 -0
  86. package/src/__tests__/computer-use-tools.test.ts +250 -0
  87. package/src/__tests__/config-schema.test.ts +1462 -0
  88. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  89. package/src/__tests__/conflict-policy.test.ts +121 -0
  90. package/src/__tests__/conflict-store.test.ts +332 -0
  91. package/src/__tests__/connection-policy.test.ts +102 -0
  92. package/src/__tests__/contacts-tools.test.ts +331 -0
  93. package/src/__tests__/context-memory-e2e.test.ts +434 -0
  94. package/src/__tests__/context-token-estimator.test.ts +135 -0
  95. package/src/__tests__/context-window-manager.test.ts +376 -0
  96. package/src/__tests__/contradiction-checker.test.ts +314 -0
  97. package/src/__tests__/conversation-store.test.ts +612 -0
  98. package/src/__tests__/credential-broker-browser-fill.test.ts +517 -0
  99. package/src/__tests__/credential-broker-server-use.test.ts +554 -0
  100. package/src/__tests__/credential-broker.test.ts +167 -0
  101. package/src/__tests__/credential-host-pattern-match.test.ts +104 -0
  102. package/src/__tests__/credential-metadata-store.test.ts +779 -0
  103. package/src/__tests__/credential-policy-validate.test.ts +121 -0
  104. package/src/__tests__/credential-resolve.test.ts +328 -0
  105. package/src/__tests__/credential-security-e2e.test.ts +352 -0
  106. package/src/__tests__/credential-security-invariants.test.ts +583 -0
  107. package/src/__tests__/credential-selection.test.ts +354 -0
  108. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  109. package/src/__tests__/credential-vault.test.ts +852 -0
  110. package/src/__tests__/daemon-assistant-events.test.ts +164 -0
  111. package/src/__tests__/daemon-server-session-init.test.ts +522 -0
  112. package/src/__tests__/date-context.test.ts +373 -0
  113. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  114. package/src/__tests__/delete-managed-skill-tool.test.ts +97 -0
  115. package/src/__tests__/diff.test.ts +121 -0
  116. package/src/__tests__/domain-normalize.test.ts +112 -0
  117. package/src/__tests__/domain-policy.test.ts +124 -0
  118. package/src/__tests__/doordash-client.test.ts +186 -0
  119. package/src/__tests__/doordash-session.test.ts +152 -0
  120. package/src/__tests__/dynamic-page-surface.test.ts +91 -0
  121. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +132 -0
  122. package/src/__tests__/edit-engine.test.ts +180 -0
  123. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  124. package/src/__tests__/email-cli.test.ts +283 -0
  125. package/src/__tests__/encrypted-store.test.ts +332 -0
  126. package/src/__tests__/entity-extractor.test.ts +190 -0
  127. package/src/__tests__/ephemeral-permissions.test.ts +362 -0
  128. package/src/__tests__/evaluate-typescript-tool.test.ts +286 -0
  129. package/src/__tests__/event-bus.test.ts +222 -0
  130. package/src/__tests__/file-edit-tool.test.ts +122 -0
  131. package/src/__tests__/file-ops-service.test.ts +330 -0
  132. package/src/__tests__/file-read-tool.test.ts +75 -0
  133. package/src/__tests__/file-write-tool.test.ts +113 -0
  134. package/src/__tests__/filesystem-tools.test.ts +579 -0
  135. package/src/__tests__/fixtures/credential-security-fixtures.ts +181 -0
  136. package/src/__tests__/fixtures/media-reuse-fixtures.ts +126 -0
  137. package/src/__tests__/fixtures/mock-signup-server.ts +387 -0
  138. package/src/__tests__/fixtures/proxy-fixtures.ts +147 -0
  139. package/src/__tests__/followup-tools.test.ts +303 -0
  140. package/src/__tests__/forbidden-legacy-symbols.test.ts +71 -0
  141. package/src/__tests__/fuzzy-match-property.test.ts +216 -0
  142. package/src/__tests__/fuzzy-match.test.ts +138 -0
  143. package/src/__tests__/gateway-only-enforcement.test.ts +631 -0
  144. package/src/__tests__/gemini-image-service.test.ts +261 -0
  145. package/src/__tests__/gemini-provider.test.ts +651 -0
  146. package/src/__tests__/get-weather.test.ts +318 -0
  147. package/src/__tests__/gmail-integration.test.ts +73 -0
  148. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  149. package/src/__tests__/handlers-cu-observation-blob.test.ts +352 -0
  150. package/src/__tests__/handlers-ipc-blob-probe.test.ts +191 -0
  151. package/src/__tests__/handlers-slack-config.test.ts +200 -0
  152. package/src/__tests__/handlers-task-submit-slash.test.ts +38 -0
  153. package/src/__tests__/handlers-telegram-config.test.ts +968 -0
  154. package/src/__tests__/handlers-twilio-config.test.ts +659 -0
  155. package/src/__tests__/handlers-twitter-config.test.ts +858 -0
  156. package/src/__tests__/headless-browser-interactions.test.ts +536 -0
  157. package/src/__tests__/headless-browser-navigate.test.ts +211 -0
  158. package/src/__tests__/headless-browser-read-tools.test.ts +261 -0
  159. package/src/__tests__/headless-browser-snapshot.test.ts +185 -0
  160. package/src/__tests__/history-repair-observability.test.ts +56 -0
  161. package/src/__tests__/history-repair.test.ts +510 -0
  162. package/src/__tests__/home-base-bootstrap.test.ts +82 -0
  163. package/src/__tests__/hooks-blocking.test.ts +128 -0
  164. package/src/__tests__/hooks-cli.test.ts +144 -0
  165. package/src/__tests__/hooks-config.test.ts +93 -0
  166. package/src/__tests__/hooks-discovery.test.ts +199 -0
  167. package/src/__tests__/hooks-integration.test.ts +189 -0
  168. package/src/__tests__/hooks-manager.test.ts +187 -0
  169. package/src/__tests__/hooks-runner.test.ts +182 -0
  170. package/src/__tests__/hooks-settings.test.ts +154 -0
  171. package/src/__tests__/hooks-templates.test.ts +137 -0
  172. package/src/__tests__/hooks-ts-runner.test.ts +125 -0
  173. package/src/__tests__/hooks-watch.test.ts +100 -0
  174. package/src/__tests__/host-file-edit-tool.test.ts +228 -0
  175. package/src/__tests__/host-file-read-tool.test.ts +123 -0
  176. package/src/__tests__/host-file-write-tool.test.ts +136 -0
  177. package/src/__tests__/host-shell-tool.test.ts +562 -0
  178. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  179. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  180. package/src/__tests__/intent-routing.test.ts +259 -0
  181. package/src/__tests__/ipc-blob-store.test.ts +315 -0
  182. package/src/__tests__/ipc-contract-inventory.test.ts +54 -0
  183. package/src/__tests__/ipc-contract.test.ts +74 -0
  184. package/src/__tests__/ipc-protocol.test.ts +113 -0
  185. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  186. package/src/__tests__/ipc-snapshot.test.ts +1769 -0
  187. package/src/__tests__/ipc-validate.test.ts +407 -0
  188. package/src/__tests__/key-migration.test.ts +206 -0
  189. package/src/__tests__/keychain.test.ts +258 -0
  190. package/src/__tests__/llm-usage-store.test.ts +221 -0
  191. package/src/__tests__/managed-skill-lifecycle.test.ts +257 -0
  192. package/src/__tests__/managed-store.test.ts +608 -0
  193. package/src/__tests__/media-generate-image.test.ts +238 -0
  194. package/src/__tests__/media-reuse-story.e2e.test.ts +676 -0
  195. package/src/__tests__/media-visibility-policy.test.ts +141 -0
  196. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +235 -0
  197. package/src/__tests__/memory-lifecycle-e2e.test.ts +481 -0
  198. package/src/__tests__/memory-query-builder.test.ts +59 -0
  199. package/src/__tests__/memory-recall-quality.test.ts +846 -0
  200. package/src/__tests__/memory-regressions.experimental.test.ts +538 -0
  201. package/src/__tests__/memory-regressions.test.ts +4435 -0
  202. package/src/__tests__/memory-retrieval-budget.test.ts +49 -0
  203. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  204. package/src/__tests__/migration-cli-flows.test.ts +169 -0
  205. package/src/__tests__/migration-ordering.test.ts +249 -0
  206. package/src/__tests__/mock-signup-server.test.ts +528 -0
  207. package/src/__tests__/oauth-callback-registry.test.ts +92 -0
  208. package/src/__tests__/oauth2-gateway-transport.test.ts +285 -0
  209. package/src/__tests__/onboarding-starter-tasks.test.ts +176 -0
  210. package/src/__tests__/onboarding-template-contract.test.ts +58 -0
  211. package/src/__tests__/openai-provider.test.ts +753 -0
  212. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  213. package/src/__tests__/parser.test.ts +472 -0
  214. package/src/__tests__/path-classifier.test.ts +73 -0
  215. package/src/__tests__/path-policy.test.ts +435 -0
  216. package/src/__tests__/platform-move-helper.test.ts +99 -0
  217. package/src/__tests__/platform-socket-path.test.ts +52 -0
  218. package/src/__tests__/platform-workspace-migration.test.ts +1000 -0
  219. package/src/__tests__/platform.test.ts +131 -0
  220. package/src/__tests__/playbook-execution.test.ts +502 -0
  221. package/src/__tests__/playbook-tools.test.ts +340 -0
  222. package/src/__tests__/prebuilt-home-base-seed.test.ts +75 -0
  223. package/src/__tests__/pricing.test.ts +256 -0
  224. package/src/__tests__/profile-compiler.test.ts +374 -0
  225. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  226. package/src/__tests__/provider-registry-ollama.test.ts +16 -0
  227. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  228. package/src/__tests__/proxy-approval-callback.test.ts +601 -0
  229. package/src/__tests__/public-ingress-urls.test.ts +256 -0
  230. package/src/__tests__/qdrant-manager.test.ts +267 -0
  231. package/src/__tests__/ratelimit.test.ts +297 -0
  232. package/src/__tests__/recurrence-engine-rruleset.test.ts +175 -0
  233. package/src/__tests__/recurrence-engine.test.ts +78 -0
  234. package/src/__tests__/recurrence-types.test.ts +79 -0
  235. package/src/__tests__/registry.test.ts +494 -0
  236. package/src/__tests__/relay-server.test.ts +688 -0
  237. package/src/__tests__/reminder-store.test.ts +223 -0
  238. package/src/__tests__/reminder.test.ts +229 -0
  239. package/src/__tests__/request-file-tool.test.ts +158 -0
  240. package/src/__tests__/run-orchestrator-assistant-events.test.ts +227 -0
  241. package/src/__tests__/run-orchestrator.test.ts +425 -0
  242. package/src/__tests__/runtime-attachment-metadata.test.ts +189 -0
  243. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  244. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  245. package/src/__tests__/runtime-runs-http.test.ts +438 -0
  246. package/src/__tests__/runtime-runs.test.ts +260 -0
  247. package/src/__tests__/sandbox-diagnostics.test.ts +408 -0
  248. package/src/__tests__/sandbox-host-parity.test.ts +950 -0
  249. package/src/__tests__/scaffold-managed-skill-tool.test.ts +253 -0
  250. package/src/__tests__/schedule-store.test.ts +484 -0
  251. package/src/__tests__/schedule-tools.test.ts +783 -0
  252. package/src/__tests__/scheduler-recurrence.test.ts +430 -0
  253. package/src/__tests__/script-proxy-certs.test.ts +90 -0
  254. package/src/__tests__/script-proxy-connect-tunnel.test.ts +177 -0
  255. package/src/__tests__/script-proxy-decision-trace.test.ts +156 -0
  256. package/src/__tests__/script-proxy-http-forwarder.test.ts +281 -0
  257. package/src/__tests__/script-proxy-injection-runtime.test.ts +401 -0
  258. package/src/__tests__/script-proxy-mitm-handler.test.ts +407 -0
  259. package/src/__tests__/script-proxy-policy-runtime.test.ts +287 -0
  260. package/src/__tests__/script-proxy-policy.test.ts +310 -0
  261. package/src/__tests__/script-proxy-rewrite-specificity.test.ts +135 -0
  262. package/src/__tests__/script-proxy-router.test.ts +180 -0
  263. package/src/__tests__/script-proxy-session-manager.test.ts +382 -0
  264. package/src/__tests__/script-proxy-session-runtime.test.ts +113 -0
  265. package/src/__tests__/secret-allowlist.test.ts +230 -0
  266. package/src/__tests__/secret-ingress-handler.test.ts +110 -0
  267. package/src/__tests__/secret-onetime-send.test.ts +130 -0
  268. package/src/__tests__/secret-prompt-log-hygiene.test.ts +106 -0
  269. package/src/__tests__/secret-response-routing.test.ts +93 -0
  270. package/src/__tests__/secret-scanner-executor.test.ts +348 -0
  271. package/src/__tests__/secret-scanner.test.ts +900 -0
  272. package/src/__tests__/secure-keys.test.ts +323 -0
  273. package/src/__tests__/server-history-render.test.ts +431 -0
  274. package/src/__tests__/session-abort-tool-results.test.ts +240 -0
  275. package/src/__tests__/session-conflict-gate.test.ts +1136 -0
  276. package/src/__tests__/session-error.test.ts +369 -0
  277. package/src/__tests__/session-evictor.test.ts +188 -0
  278. package/src/__tests__/session-init.benchmark.test.ts +465 -0
  279. package/src/__tests__/session-load-history-repair.test.ts +222 -0
  280. package/src/__tests__/session-pre-run-repair.test.ts +213 -0
  281. package/src/__tests__/session-process-bridge.test.ts +242 -0
  282. package/src/__tests__/session-profile-injection.test.ts +444 -0
  283. package/src/__tests__/session-provider-retry-repair.test.ts +306 -0
  284. package/src/__tests__/session-queue.test.ts +1535 -0
  285. package/src/__tests__/session-runtime-assembly.test.ts +476 -0
  286. package/src/__tests__/session-runtime-workspace.test.ts +183 -0
  287. package/src/__tests__/session-skill-tools.test.ts +2431 -0
  288. package/src/__tests__/session-slash-known.test.ts +368 -0
  289. package/src/__tests__/session-slash-queue.test.ts +288 -0
  290. package/src/__tests__/session-slash-unknown.test.ts +271 -0
  291. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  292. package/src/__tests__/session-tool-setup-app-refresh.test.ts +473 -0
  293. package/src/__tests__/session-tool-setup-memory-scope.test.ts +140 -0
  294. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +140 -0
  295. package/src/__tests__/session-undo.test.ts +75 -0
  296. package/src/__tests__/session-workspace-cache-state.test.ts +246 -0
  297. package/src/__tests__/session-workspace-injection.test.ts +327 -0
  298. package/src/__tests__/session-workspace-tool-tracking.test.ts +240 -0
  299. package/src/__tests__/shared-filesystem-errors.test.ts +78 -0
  300. package/src/__tests__/shell-credential-ref.test.ts +187 -0
  301. package/src/__tests__/shell-identity.test.ts +256 -0
  302. package/src/__tests__/shell-parser-fuzz.test.ts +544 -0
  303. package/src/__tests__/shell-parser-property.test.ts +433 -0
  304. package/src/__tests__/shell-tool-proxy-mode.test.ts +272 -0
  305. package/src/__tests__/signup-e2e.test.ts +353 -0
  306. package/src/__tests__/size-guard.test.ts +117 -0
  307. package/src/__tests__/skill-include-graph.test.ts +303 -0
  308. package/src/__tests__/skill-load-tool.test.ts +409 -0
  309. package/src/__tests__/skill-projection.benchmark.test.ts +338 -0
  310. package/src/__tests__/skill-script-runner-host.test.ts +489 -0
  311. package/src/__tests__/skill-script-runner-sandbox.test.ts +349 -0
  312. package/src/__tests__/skill-script-runner.test.ts +159 -0
  313. package/src/__tests__/skill-tool-factory.test.ts +252 -0
  314. package/src/__tests__/skill-tool-manifest.test.ts +658 -0
  315. package/src/__tests__/skill-version-hash.test.ts +182 -0
  316. package/src/__tests__/skills.test.ts +680 -0
  317. package/src/__tests__/slash-commands-catalog.test.ts +86 -0
  318. package/src/__tests__/slash-commands-parser.test.ts +119 -0
  319. package/src/__tests__/slash-commands-resolver.test.ts +193 -0
  320. package/src/__tests__/slash-commands-rewrite.test.ts +39 -0
  321. package/src/__tests__/speaker-identification.test.ts +52 -0
  322. package/src/__tests__/starter-bundle.test.ts +136 -0
  323. package/src/__tests__/starter-task-flow.test.ts +143 -0
  324. package/src/__tests__/subagent-manager-notify.test.ts +404 -0
  325. package/src/__tests__/subagent-tools.test.ts +801 -0
  326. package/src/__tests__/subagent-types.test.ts +78 -0
  327. package/src/__tests__/swarm-orchestrator.test.ts +428 -0
  328. package/src/__tests__/swarm-plan-validator.test.ts +330 -0
  329. package/src/__tests__/swarm-recursion.test.ts +165 -0
  330. package/src/__tests__/swarm-router-planner.test.ts +208 -0
  331. package/src/__tests__/swarm-session-integration.test.ts +274 -0
  332. package/src/__tests__/swarm-tool.test.ts +145 -0
  333. package/src/__tests__/swarm-worker-backend.test.ts +129 -0
  334. package/src/__tests__/swarm-worker-runner.test.ts +272 -0
  335. package/src/__tests__/system-prompt.test.ts +439 -0
  336. package/src/__tests__/task-compiler.test.ts +284 -0
  337. package/src/__tests__/task-management-tools.test.ts +936 -0
  338. package/src/__tests__/task-runner.test.ts +216 -0
  339. package/src/__tests__/task-scheduler.test.ts +217 -0
  340. package/src/__tests__/task-tools.test.ts +595 -0
  341. package/src/__tests__/terminal-sandbox-docker.test.ts +1064 -0
  342. package/src/__tests__/terminal-sandbox.integration.test.ts +178 -0
  343. package/src/__tests__/terminal-sandbox.test.ts +202 -0
  344. package/src/__tests__/terminal-tools.test.ts +840 -0
  345. package/src/__tests__/test-support/browser-skill-harness.ts +90 -0
  346. package/src/__tests__/test-support/computer-use-skill-harness.ts +45 -0
  347. package/src/__tests__/tool-audit-listener.test.ts +113 -0
  348. package/src/__tests__/tool-domain-event-publisher.test.ts +253 -0
  349. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  350. package/src/__tests__/tool-executor-lifecycle-events.test.ts +516 -0
  351. package/src/__tests__/tool-executor-redaction.test.ts +289 -0
  352. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  353. package/src/__tests__/tool-executor.test.ts +1989 -0
  354. package/src/__tests__/tool-metrics-listener.test.ts +225 -0
  355. package/src/__tests__/tool-notification-listener.test.ts +49 -0
  356. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  357. package/src/__tests__/tool-policy.test.ts +54 -0
  358. package/src/__tests__/tool-profiling-listener.test.ts +268 -0
  359. package/src/__tests__/tool-result-truncation.test.ts +217 -0
  360. package/src/__tests__/tool-trace-listener.test.ts +226 -0
  361. package/src/__tests__/top-level-renderer.test.ts +121 -0
  362. package/src/__tests__/top-level-scanner.test.ts +141 -0
  363. package/src/__tests__/trace-emitter.test.ts +173 -0
  364. package/src/__tests__/trust-store.test.ts +1605 -0
  365. package/src/__tests__/turn-commit.test.ts +554 -0
  366. package/src/__tests__/twilio-provider.test.ts +329 -0
  367. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  368. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  369. package/src/__tests__/twilio-routes.test.ts +577 -0
  370. package/src/__tests__/twitter-auth-handler.test.ts +667 -0
  371. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  372. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  373. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  374. package/src/__tests__/url-safety.test.ts +418 -0
  375. package/src/__tests__/view-image-tool.test.ts +217 -0
  376. package/src/__tests__/weather-skill-regression.test.ts +225 -0
  377. package/src/__tests__/web-fetch.test.ts +869 -0
  378. package/src/__tests__/web-search.test.ts +584 -0
  379. package/src/__tests__/workspace-git-service.test.ts +1153 -0
  380. package/src/__tests__/workspace-heartbeat-service.test.ts +486 -0
  381. package/src/__tests__/workspace-lifecycle.test.ts +292 -0
  382. package/src/__tests__/workspace-policy.test.ts +213 -0
  383. package/src/agent/attachments.ts +35 -0
  384. package/src/agent/loop.ts +500 -0
  385. package/src/agent/message-types.ts +17 -0
  386. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  387. package/src/autonomy/autonomy-resolver.ts +60 -0
  388. package/src/autonomy/autonomy-store.ts +122 -0
  389. package/src/autonomy/disposition-mapper.ts +31 -0
  390. package/src/autonomy/index.ts +11 -0
  391. package/src/autonomy/types.ts +39 -0
  392. package/src/bundler/app-bundler.ts +295 -0
  393. package/src/bundler/bundle-scanner.ts +535 -0
  394. package/src/bundler/bundle-signer.ts +124 -0
  395. package/src/bundler/manifest.ts +21 -0
  396. package/src/bundler/signature-verifier.ts +184 -0
  397. package/src/calls/call-bridge.ts +168 -0
  398. package/src/calls/call-constants.ts +48 -0
  399. package/src/calls/call-domain.ts +430 -0
  400. package/src/calls/call-orchestrator.ts +498 -0
  401. package/src/calls/call-recovery.ts +207 -0
  402. package/src/calls/call-state-machine.ts +68 -0
  403. package/src/calls/call-state.ts +87 -0
  404. package/src/calls/call-store.ts +422 -0
  405. package/src/calls/elevenlabs-client.ts +97 -0
  406. package/src/calls/elevenlabs-config.ts +31 -0
  407. package/src/calls/relay-server.ts +390 -0
  408. package/src/calls/speaker-identification.ts +213 -0
  409. package/src/calls/twilio-config.ts +45 -0
  410. package/src/calls/twilio-provider.ts +263 -0
  411. package/src/calls/twilio-rest.ts +156 -0
  412. package/src/calls/twilio-routes.ts +311 -0
  413. package/src/calls/types.ts +39 -0
  414. package/src/calls/voice-provider.ts +14 -0
  415. package/src/calls/voice-quality.ts +114 -0
  416. package/src/cli/autonomy.ts +188 -0
  417. package/src/cli/config-commands.ts +334 -0
  418. package/src/cli/contacts.ts +149 -0
  419. package/src/cli/core-commands.ts +784 -0
  420. package/src/cli/doordash.ts +1055 -0
  421. package/src/cli/email-guardrails.ts +200 -0
  422. package/src/cli/email.ts +405 -0
  423. package/src/cli/ipc-client.ts +82 -0
  424. package/src/cli/main-screen.tsx +53 -0
  425. package/src/cli/map.ts +270 -0
  426. package/src/cli/twitter.ts +754 -0
  427. package/src/cli.ts +918 -0
  428. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  429. package/src/commands/cc-command-registry.ts +209 -0
  430. package/src/config/bundled-skills/.gitkeep +0 -0
  431. package/src/config/bundled-skills/agentmail/SKILL.md +128 -0
  432. package/src/config/bundled-skills/agentmail/icon.svg +21 -0
  433. package/src/config/bundled-skills/app-builder/SKILL.md +1404 -0
  434. package/src/config/bundled-skills/app-builder/TOOLS.json +279 -0
  435. package/src/config/bundled-skills/app-builder/icon.svg +9 -0
  436. package/src/config/bundled-skills/app-builder/tools/app-create.ts +15 -0
  437. package/src/config/bundled-skills/app-builder/tools/app-delete.ts +10 -0
  438. package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +11 -0
  439. package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +10 -0
  440. package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +18 -0
  441. package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +11 -0
  442. package/src/config/bundled-skills/app-builder/tools/app-list.ts +10 -0
  443. package/src/config/bundled-skills/app-builder/tools/app-query.ts +10 -0
  444. package/src/config/bundled-skills/app-builder/tools/app-update.ts +20 -0
  445. package/src/config/bundled-skills/browser/SKILL.md +28 -0
  446. package/src/config/bundled-skills/browser/TOOLS.json +234 -0
  447. package/src/config/bundled-skills/browser/tools/browser-click.ts +9 -0
  448. package/src/config/bundled-skills/browser/tools/browser-close.ts +9 -0
  449. package/src/config/bundled-skills/browser/tools/browser-extract.ts +9 -0
  450. package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +9 -0
  451. package/src/config/bundled-skills/browser/tools/browser-navigate.ts +9 -0
  452. package/src/config/bundled-skills/browser/tools/browser-press-key.ts +9 -0
  453. package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +9 -0
  454. package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +9 -0
  455. package/src/config/bundled-skills/browser/tools/browser-type.ts +9 -0
  456. package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +9 -0
  457. package/src/config/bundled-skills/claude-code/SKILL.md +50 -0
  458. package/src/config/bundled-skills/claude-code/TOOLS.json +40 -0
  459. package/src/config/bundled-skills/claude-code/tools/claude-code.ts +9 -0
  460. package/src/config/bundled-skills/computer-use/SKILL.md +17 -0
  461. package/src/config/bundled-skills/computer-use/TOOLS.json +326 -0
  462. package/src/config/bundled-skills/computer-use/tools/computer-use-click.ts +9 -0
  463. package/src/config/bundled-skills/computer-use/tools/computer-use-done.ts +9 -0
  464. package/src/config/bundled-skills/computer-use/tools/computer-use-double-click.ts +9 -0
  465. package/src/config/bundled-skills/computer-use/tools/computer-use-drag.ts +9 -0
  466. package/src/config/bundled-skills/computer-use/tools/computer-use-key.ts +9 -0
  467. package/src/config/bundled-skills/computer-use/tools/computer-use-open-app.ts +9 -0
  468. package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +9 -0
  469. package/src/config/bundled-skills/computer-use/tools/computer-use-respond.ts +9 -0
  470. package/src/config/bundled-skills/computer-use/tools/computer-use-right-click.ts +9 -0
  471. package/src/config/bundled-skills/computer-use/tools/computer-use-run-applescript.ts +9 -0
  472. package/src/config/bundled-skills/computer-use/tools/computer-use-scroll.ts +9 -0
  473. package/src/config/bundled-skills/computer-use/tools/computer-use-type-text.ts +9 -0
  474. package/src/config/bundled-skills/computer-use/tools/computer-use-wait.ts +9 -0
  475. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  476. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  477. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +57 -0
  478. package/src/config/bundled-skills/contacts/tools/contact-search.ts +60 -0
  479. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +66 -0
  480. package/src/config/bundled-skills/document/SKILL.md +26 -0
  481. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  482. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  483. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  484. package/src/config/bundled-skills/doordash/SKILL.md +163 -0
  485. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  486. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  487. package/src/config/bundled-skills/followups/icon.svg +24 -0
  488. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  489. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  490. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  491. package/src/config/bundled-skills/google-calendar/SKILL.md +51 -0
  492. package/src/config/bundled-skills/google-calendar/TOOLS.json +108 -0
  493. package/src/config/bundled-skills/google-calendar/calendar-client.ts +165 -0
  494. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +21 -0
  495. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +42 -0
  496. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +13 -0
  497. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +30 -0
  498. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +41 -0
  499. package/src/config/bundled-skills/google-calendar/tools/shared.ts +18 -0
  500. package/src/config/bundled-skills/google-calendar/types.ts +97 -0
  501. package/src/config/bundled-skills/image-studio/SKILL.md +32 -0
  502. package/src/config/bundled-skills/image-studio/TOOLS.json +42 -0
  503. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +115 -0
  504. package/src/config/bundled-skills/macos-automation/SKILL.md +66 -0
  505. package/src/config/bundled-skills/messaging/SKILL.md +153 -0
  506. package/src/config/bundled-skills/messaging/TOOLS.json +357 -0
  507. package/src/config/bundled-skills/messaging/tools/gmail-archive.ts +23 -0
  508. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +23 -0
  509. package/src/config/bundled-skills/messaging/tools/gmail-batch-label.ts +25 -0
  510. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +26 -0
  511. package/src/config/bundled-skills/messaging/tools/gmail-label.ts +25 -0
  512. package/src/config/bundled-skills/messaging/tools/gmail-trash.ts +23 -0
  513. package/src/config/bundled-skills/messaging/tools/gmail-unsubscribe.ts +84 -0
  514. package/src/config/bundled-skills/messaging/tools/messaging-analyze-activity.ts +18 -0
  515. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +125 -0
  516. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +16 -0
  517. package/src/config/bundled-skills/messaging/tools/messaging-draft.ts +49 -0
  518. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +21 -0
  519. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +25 -0
  520. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +28 -0
  521. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +32 -0
  522. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +22 -0
  523. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +31 -0
  524. package/src/config/bundled-skills/messaging/tools/shared.ts +76 -0
  525. package/src/config/bundled-skills/messaging/tools/slack-add-reaction.ts +25 -0
  526. package/src/config/bundled-skills/messaging/tools/slack-leave-channel.ts +23 -0
  527. package/src/config/bundled-skills/phone-calls/SKILL.md +533 -0
  528. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  529. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  530. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +98 -0
  531. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +54 -0
  532. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +76 -0
  533. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +113 -0
  534. package/src/config/bundled-skills/public-ingress/SKILL.md +200 -0
  535. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  536. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  537. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  538. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  539. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  540. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  541. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  542. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  543. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  544. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  545. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  546. package/src/config/bundled-skills/self-upgrade/SKILL.md +68 -0
  547. package/src/config/bundled-skills/start-the-day/SKILL.md +70 -0
  548. package/src/config/bundled-skills/start-the-day/icon.svg +13 -0
  549. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  550. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  551. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  552. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  553. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  554. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  555. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  556. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  557. package/src/config/bundled-skills/tasks/TOOLS.json +281 -0
  558. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  559. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  560. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  561. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  562. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  563. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  564. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  565. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  566. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  567. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  568. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  569. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  570. package/src/config/bundled-skills/twitter/SKILL.md +220 -0
  571. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  572. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  573. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  574. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  575. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  576. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  577. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  578. package/src/config/bundled-skills/weather/SKILL.md +37 -0
  579. package/src/config/bundled-skills/weather/TOOLS.json +32 -0
  580. package/src/config/bundled-skills/weather/icon.svg +24 -0
  581. package/src/config/bundled-skills/weather/tools/get-weather.ts +9 -0
  582. package/src/config/computer-use-prompt.ts +97 -0
  583. package/src/config/defaults.ts +263 -0
  584. package/src/config/loader.ts +339 -0
  585. package/src/config/schema.ts +1436 -0
  586. package/src/config/skill-state.ts +95 -0
  587. package/src/config/skills.ts +972 -0
  588. package/src/config/system-prompt.ts +675 -0
  589. package/src/config/templates/BOOTSTRAP.md +70 -0
  590. package/src/config/templates/IDENTITY.md +25 -0
  591. package/src/config/templates/LOOKS.md +25 -0
  592. package/src/config/templates/SOUL.md +37 -0
  593. package/src/config/templates/USER.md +19 -0
  594. package/src/config/types.ts +42 -0
  595. package/src/config/vellum-skills/chatgpt-import/SKILL.md +24 -0
  596. package/src/config/vellum-skills/chatgpt-import/TOOLS.json +23 -0
  597. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +284 -0
  598. package/src/config/vellum-skills/deploy-fullstack-vercel/SKILL.md +179 -0
  599. package/src/config/vellum-skills/document-writer/SKILL.md +195 -0
  600. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +199 -0
  601. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +153 -0
  602. package/src/config/vellum-skills/telegram-setup/SKILL.md +143 -0
  603. package/src/config/vellum-skills/twilio-setup/SKILL.md +213 -0
  604. package/src/contacts/contact-store.ts +410 -0
  605. package/src/contacts/index.ts +11 -0
  606. package/src/contacts/types.ts +28 -0
  607. package/src/context/token-estimator.ts +108 -0
  608. package/src/context/tool-result-truncation.ts +128 -0
  609. package/src/context/window-manager.ts +531 -0
  610. package/src/daemon/assistant-attachments.ts +691 -0
  611. package/src/daemon/classifier.ts +110 -0
  612. package/src/daemon/computer-use-session.ts +903 -0
  613. package/src/daemon/connection-policy.ts +41 -0
  614. package/src/daemon/date-context.ts +136 -0
  615. package/src/daemon/handlers/apps.ts +530 -0
  616. package/src/daemon/handlers/browser.ts +54 -0
  617. package/src/daemon/handlers/computer-use.ts +187 -0
  618. package/src/daemon/handlers/config.ts +1517 -0
  619. package/src/daemon/handlers/diagnostics.ts +338 -0
  620. package/src/daemon/handlers/documents.ts +173 -0
  621. package/src/daemon/handlers/home-base.ts +78 -0
  622. package/src/daemon/handlers/identity.ts +127 -0
  623. package/src/daemon/handlers/index.ts +129 -0
  624. package/src/daemon/handlers/misc.ts +331 -0
  625. package/src/daemon/handlers/open-bundle-handler.ts +80 -0
  626. package/src/daemon/handlers/publish.ts +187 -0
  627. package/src/daemon/handlers/sessions.ts +555 -0
  628. package/src/daemon/handlers/shared.ts +570 -0
  629. package/src/daemon/handlers/signing.ts +37 -0
  630. package/src/daemon/handlers/skills.ts +486 -0
  631. package/src/daemon/handlers/subagents.ts +210 -0
  632. package/src/daemon/handlers/twitter-auth.ts +198 -0
  633. package/src/daemon/handlers/work-items.ts +632 -0
  634. package/src/daemon/handlers/workspace-files.ts +75 -0
  635. package/src/daemon/handlers.ts +17 -0
  636. package/src/daemon/history-repair.ts +214 -0
  637. package/src/daemon/ipc-blob-store.ts +231 -0
  638. package/src/daemon/ipc-contract-inventory.json +495 -0
  639. package/src/daemon/ipc-contract-inventory.ts +126 -0
  640. package/src/daemon/ipc-contract.ts +2551 -0
  641. package/src/daemon/ipc-protocol.ts +75 -0
  642. package/src/daemon/ipc-validate.ts +188 -0
  643. package/src/daemon/lifecycle.ts +582 -0
  644. package/src/daemon/main.ts +21 -0
  645. package/src/daemon/media-visibility-policy.ts +57 -0
  646. package/src/daemon/ride-shotgun-handler.ts +309 -0
  647. package/src/daemon/server.ts +1215 -0
  648. package/src/daemon/session-agent-loop.ts +922 -0
  649. package/src/daemon/session-attachments.ts +196 -0
  650. package/src/daemon/session-conflict-gate.ts +184 -0
  651. package/src/daemon/session-dynamic-profile.ts +63 -0
  652. package/src/daemon/session-error.ts +290 -0
  653. package/src/daemon/session-evictor.ts +196 -0
  654. package/src/daemon/session-history.ts +437 -0
  655. package/src/daemon/session-lifecycle.ts +147 -0
  656. package/src/daemon/session-media-retry.ts +147 -0
  657. package/src/daemon/session-memory.ts +212 -0
  658. package/src/daemon/session-messaging.ts +145 -0
  659. package/src/daemon/session-notifiers.ts +193 -0
  660. package/src/daemon/session-process.ts +323 -0
  661. package/src/daemon/session-queue-manager.ts +82 -0
  662. package/src/daemon/session-runtime-assembly.ts +447 -0
  663. package/src/daemon/session-skill-tools.ts +356 -0
  664. package/src/daemon/session-slash.ts +305 -0
  665. package/src/daemon/session-surfaces.ts +702 -0
  666. package/src/daemon/session-tool-setup.ts +523 -0
  667. package/src/daemon/session-usage.ts +72 -0
  668. package/src/daemon/session-workspace.ts +19 -0
  669. package/src/daemon/session.ts +400 -0
  670. package/src/daemon/tls-certs.ts +189 -0
  671. package/src/daemon/trace-emitter.ts +82 -0
  672. package/src/daemon/video-thumbnail.ts +62 -0
  673. package/src/daemon/watch-handler.ts +274 -0
  674. package/src/doordash/client.ts +999 -0
  675. package/src/doordash/queries.ts +1311 -0
  676. package/src/doordash/query-extractor.ts +93 -0
  677. package/src/doordash/session.ts +82 -0
  678. package/src/email/provider.ts +117 -0
  679. package/src/email/providers/agentmail.ts +317 -0
  680. package/src/email/providers/index.ts +58 -0
  681. package/src/email/service.ts +303 -0
  682. package/src/email/types.ts +126 -0
  683. package/src/events/bus.ts +157 -0
  684. package/src/events/domain-events.ts +83 -0
  685. package/src/events/index.ts +18 -0
  686. package/src/events/tool-audit-listener.ts +80 -0
  687. package/src/events/tool-domain-event-publisher.ts +111 -0
  688. package/src/events/tool-metrics-listener.ts +159 -0
  689. package/src/events/tool-notification-listener.ts +17 -0
  690. package/src/events/tool-profiling-listener.ts +158 -0
  691. package/src/events/tool-trace-listener.ts +75 -0
  692. package/src/export/formatter.ts +98 -0
  693. package/src/followups/followup-store.ts +168 -0
  694. package/src/followups/index.ts +10 -0
  695. package/src/followups/types.ts +29 -0
  696. package/src/gallery/default-gallery.ts +795 -0
  697. package/src/gallery/gallery-manifest.ts +24 -0
  698. package/src/home-base/app-link-store.ts +82 -0
  699. package/src/home-base/bootstrap.ts +68 -0
  700. package/src/home-base/prebuilt/index.html +662 -0
  701. package/src/home-base/prebuilt/seed-metadata.json +21 -0
  702. package/src/home-base/prebuilt/seed.ts +112 -0
  703. package/src/home-base/prebuilt-home-base-updater.ts +30 -0
  704. package/src/hooks/cli.ts +163 -0
  705. package/src/hooks/config.ts +88 -0
  706. package/src/hooks/discovery.ts +110 -0
  707. package/src/hooks/manager.ts +124 -0
  708. package/src/hooks/runner.ts +123 -0
  709. package/src/hooks/templates.ts +52 -0
  710. package/src/hooks/types.ts +72 -0
  711. package/src/inbound/public-ingress-urls.ts +123 -0
  712. package/src/index.ts +81 -0
  713. package/src/instrument.ts +60 -0
  714. package/src/logfire.ts +99 -0
  715. package/src/media/gemini-image-service.ts +136 -0
  716. package/src/memory/account-store.ts +108 -0
  717. package/src/memory/admin.ts +211 -0
  718. package/src/memory/app-git-service.ts +295 -0
  719. package/src/memory/app-store.ts +577 -0
  720. package/src/memory/attachments-store.ts +397 -0
  721. package/src/memory/channel-delivery-store.ts +353 -0
  722. package/src/memory/channel-guardian-store.ts +669 -0
  723. package/src/memory/checkpoints.ts +52 -0
  724. package/src/memory/clarification-resolver.ts +298 -0
  725. package/src/memory/conflict-intent.ts +157 -0
  726. package/src/memory/conflict-policy.ts +73 -0
  727. package/src/memory/conflict-store.ts +350 -0
  728. package/src/memory/contradiction-checker.ts +358 -0
  729. package/src/memory/conversation-key-store.ts +122 -0
  730. package/src/memory/conversation-store.ts +470 -0
  731. package/src/memory/db.ts +1991 -0
  732. package/src/memory/embedding-backend.ts +229 -0
  733. package/src/memory/embedding-gemini.ts +52 -0
  734. package/src/memory/embedding-local.ts +65 -0
  735. package/src/memory/embedding-ollama.ts +55 -0
  736. package/src/memory/embedding-openai.ts +25 -0
  737. package/src/memory/entity-extractor.ts +474 -0
  738. package/src/memory/external-conversation-store.ts +234 -0
  739. package/src/memory/fingerprint.ts +20 -0
  740. package/src/memory/indexer.ts +156 -0
  741. package/src/memory/items-extractor.ts +461 -0
  742. package/src/memory/job-handlers/backfill.ts +139 -0
  743. package/src/memory/job-handlers/cleanup.ts +58 -0
  744. package/src/memory/job-handlers/conflict.ts +141 -0
  745. package/src/memory/job-handlers/embedding.ts +61 -0
  746. package/src/memory/job-handlers/extraction.ts +123 -0
  747. package/src/memory/job-handlers/index-maintenance.ts +54 -0
  748. package/src/memory/job-handlers/summarization.ts +286 -0
  749. package/src/memory/job-utils.ts +170 -0
  750. package/src/memory/jobs-store.ts +401 -0
  751. package/src/memory/jobs-worker.ts +313 -0
  752. package/src/memory/llm-request-log-store.ts +45 -0
  753. package/src/memory/llm-usage-store.ts +60 -0
  754. package/src/memory/message-content.ts +54 -0
  755. package/src/memory/profile-compiler.ts +160 -0
  756. package/src/memory/published-pages-store.ts +137 -0
  757. package/src/memory/qdrant-client.ts +366 -0
  758. package/src/memory/qdrant-manager.ts +242 -0
  759. package/src/memory/query-builder.ts +45 -0
  760. package/src/memory/retrieval-budget.ts +30 -0
  761. package/src/memory/retriever.ts +653 -0
  762. package/src/memory/runs-store.ts +305 -0
  763. package/src/memory/schema.ts +677 -0
  764. package/src/memory/search/entity.ts +298 -0
  765. package/src/memory/search/formatting.ts +207 -0
  766. package/src/memory/search/lexical.ts +227 -0
  767. package/src/memory/search/ranking.ts +401 -0
  768. package/src/memory/search/semantic.ts +121 -0
  769. package/src/memory/search/types.ts +137 -0
  770. package/src/memory/segmenter.ts +68 -0
  771. package/src/memory/shared-app-links-store.ts +138 -0
  772. package/src/memory/tool-usage-store.ts +62 -0
  773. package/src/messaging/activity-analyzer.ts +76 -0
  774. package/src/messaging/draft-store.ts +88 -0
  775. package/src/messaging/index.ts +3 -0
  776. package/src/messaging/provider-types.ts +80 -0
  777. package/src/messaging/provider.ts +52 -0
  778. package/src/messaging/providers/gmail/adapter.ts +193 -0
  779. package/src/messaging/providers/gmail/client.ts +204 -0
  780. package/src/messaging/providers/gmail/types.ts +90 -0
  781. package/src/messaging/providers/slack/adapter.ts +202 -0
  782. package/src/messaging/providers/slack/client.ts +198 -0
  783. package/src/messaging/providers/slack/types.ts +119 -0
  784. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  785. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  786. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  787. package/src/messaging/registry.ts +35 -0
  788. package/src/messaging/style-analyzer.ts +159 -0
  789. package/src/messaging/thread-summarizer.ts +306 -0
  790. package/src/messaging/triage-engine.ts +323 -0
  791. package/src/messaging/types.ts +55 -0
  792. package/src/permissions/checker.ts +640 -0
  793. package/src/permissions/defaults.ts +254 -0
  794. package/src/permissions/prompter.ts +98 -0
  795. package/src/permissions/secret-prompter.ts +114 -0
  796. package/src/permissions/shell-identity.ts +227 -0
  797. package/src/permissions/trust-store.ts +607 -0
  798. package/src/permissions/types.ts +43 -0
  799. package/src/permissions/workspace-policy.ts +114 -0
  800. package/src/playbooks/index.ts +2 -0
  801. package/src/playbooks/playbook-compiler.ts +90 -0
  802. package/src/playbooks/types.ts +55 -0
  803. package/src/providers/anthropic/client.ts +751 -0
  804. package/src/providers/failover.ts +129 -0
  805. package/src/providers/fireworks/client.ts +20 -0
  806. package/src/providers/gemini/client.ts +285 -0
  807. package/src/providers/ollama/client.ts +30 -0
  808. package/src/providers/openai/client.ts +337 -0
  809. package/src/providers/openrouter/client.ts +20 -0
  810. package/src/providers/ratelimit.ts +93 -0
  811. package/src/providers/registry.ts +146 -0
  812. package/src/providers/retry.ts +81 -0
  813. package/src/providers/stream-timeout.ts +38 -0
  814. package/src/providers/types.ts +109 -0
  815. package/src/runtime/assistant-event-hub.ts +157 -0
  816. package/src/runtime/assistant-event.ts +82 -0
  817. package/src/runtime/channel-approval-parser.ts +60 -0
  818. package/src/runtime/channel-approval-types.ts +73 -0
  819. package/src/runtime/channel-approvals.ts +206 -0
  820. package/src/runtime/channel-guardian-service.ts +212 -0
  821. package/src/runtime/gateway-client.ts +58 -0
  822. package/src/runtime/http-server.ts +1076 -0
  823. package/src/runtime/http-types.ts +66 -0
  824. package/src/runtime/routes/app-routes.ts +174 -0
  825. package/src/runtime/routes/attachment-routes.ts +133 -0
  826. package/src/runtime/routes/call-routes.ts +190 -0
  827. package/src/runtime/routes/channel-routes.ts +1404 -0
  828. package/src/runtime/routes/conversation-routes.ts +352 -0
  829. package/src/runtime/routes/events-routes.ts +148 -0
  830. package/src/runtime/routes/run-routes.ts +257 -0
  831. package/src/runtime/routes/secret-routes.ts +76 -0
  832. package/src/runtime/run-orchestrator.ts +330 -0
  833. package/src/schedule/recurrence-engine.ts +162 -0
  834. package/src/schedule/recurrence-types.ts +67 -0
  835. package/src/schedule/schedule-store.ts +506 -0
  836. package/src/schedule/scheduler.ts +171 -0
  837. package/src/security/encrypted-store.ts +238 -0
  838. package/src/security/keychain.ts +252 -0
  839. package/src/security/oauth-callback-registry.ts +66 -0
  840. package/src/security/oauth2.ts +274 -0
  841. package/src/security/redaction.ts +89 -0
  842. package/src/security/secret-allowlist.ts +164 -0
  843. package/src/security/secret-ingress.ts +57 -0
  844. package/src/security/secret-scanner.ts +550 -0
  845. package/src/security/secure-keys.ts +180 -0
  846. package/src/security/token-manager.ts +141 -0
  847. package/src/services/published-app-updater.ts +69 -0
  848. package/src/services/vercel-deploy.ts +73 -0
  849. package/src/skills/active-skill-tools.ts +81 -0
  850. package/src/skills/clawhub.ts +414 -0
  851. package/src/skills/include-graph.ts +146 -0
  852. package/src/skills/managed-store.ts +233 -0
  853. package/src/skills/path-classifier.ts +128 -0
  854. package/src/skills/slash-commands.ts +174 -0
  855. package/src/skills/tool-manifest.ts +165 -0
  856. package/src/skills/version-hash.ts +110 -0
  857. package/src/slack/slack-webhook.ts +61 -0
  858. package/src/subagent/index.ts +19 -0
  859. package/src/subagent/manager.ts +511 -0
  860. package/src/subagent/types.ts +69 -0
  861. package/src/swarm/backend-claude-code.ts +145 -0
  862. package/src/swarm/index.ts +44 -0
  863. package/src/swarm/limits.ts +37 -0
  864. package/src/swarm/orchestrator.ts +279 -0
  865. package/src/swarm/plan-validator.ts +151 -0
  866. package/src/swarm/router-planner.ts +100 -0
  867. package/src/swarm/router-prompts.ts +36 -0
  868. package/src/swarm/synthesizer.ts +62 -0
  869. package/src/swarm/types.ts +62 -0
  870. package/src/swarm/worker-backend.ts +121 -0
  871. package/src/swarm/worker-prompts.ts +79 -0
  872. package/src/swarm/worker-runner.ts +164 -0
  873. package/src/tasks/SPEC.md +139 -0
  874. package/src/tasks/candidate-store.ts +86 -0
  875. package/src/tasks/ephemeral-permissions.ts +48 -0
  876. package/src/tasks/task-compiler.ts +199 -0
  877. package/src/tasks/task-runner.ts +90 -0
  878. package/src/tasks/task-scheduler.ts +21 -0
  879. package/src/tasks/task-store.ts +127 -0
  880. package/src/tasks/tool-sanitizer.ts +36 -0
  881. package/src/tools/apps/definitions.ts +59 -0
  882. package/src/tools/apps/executors.ts +313 -0
  883. package/src/tools/apps/open-proxy.ts +43 -0
  884. package/src/tools/apps/registry.ts +16 -0
  885. package/src/tools/assets/materialize.ts +218 -0
  886. package/src/tools/assets/search.ts +361 -0
  887. package/src/tools/browser/__tests__/auth-cache.test.ts +219 -0
  888. package/src/tools/browser/__tests__/auth-detector.test.ts +362 -0
  889. package/src/tools/browser/__tests__/jit-auth.test.ts +189 -0
  890. package/src/tools/browser/api-map.ts +293 -0
  891. package/src/tools/browser/auth-cache.ts +149 -0
  892. package/src/tools/browser/auth-detector.ts +347 -0
  893. package/src/tools/browser/auto-navigate.ts +270 -0
  894. package/src/tools/browser/browser-execution.ts +980 -0
  895. package/src/tools/browser/browser-handoff.ts +79 -0
  896. package/src/tools/browser/browser-manager.ts +715 -0
  897. package/src/tools/browser/browser-screencast.ts +217 -0
  898. package/src/tools/browser/headless-browser.ts +450 -0
  899. package/src/tools/browser/jit-auth.ts +51 -0
  900. package/src/tools/browser/network-recorder.ts +349 -0
  901. package/src/tools/browser/network-recording-types.ts +49 -0
  902. package/src/tools/browser/recording-store.ts +49 -0
  903. package/src/tools/browser/runtime-check.ts +43 -0
  904. package/src/tools/browser/x-auto-navigate.ts +207 -0
  905. package/src/tools/calls/call-end.ts +67 -0
  906. package/src/tools/calls/call-start.ts +81 -0
  907. package/src/tools/calls/call-status.ts +81 -0
  908. package/src/tools/claude-code/claude-code.ts +428 -0
  909. package/src/tools/computer-use/definitions.ts +443 -0
  910. package/src/tools/computer-use/registry.ts +22 -0
  911. package/src/tools/computer-use/request-computer-control.ts +53 -0
  912. package/src/tools/computer-use/skill-proxy-bridge.ts +28 -0
  913. package/src/tools/credentials/account-registry.ts +127 -0
  914. package/src/tools/credentials/broker-types.ts +107 -0
  915. package/src/tools/credentials/broker.ts +372 -0
  916. package/src/tools/credentials/domain-policy.ts +51 -0
  917. package/src/tools/credentials/host-pattern-match.ts +60 -0
  918. package/src/tools/credentials/metadata-store.ts +335 -0
  919. package/src/tools/credentials/policy-types.ts +52 -0
  920. package/src/tools/credentials/policy-validate.ts +80 -0
  921. package/src/tools/credentials/resolve.ts +122 -0
  922. package/src/tools/credentials/selection.ts +159 -0
  923. package/src/tools/credentials/tool-policy.ts +25 -0
  924. package/src/tools/credentials/vault.ts +657 -0
  925. package/src/tools/document/document-tool.ts +92 -0
  926. package/src/tools/document/editor-template.ts +237 -0
  927. package/src/tools/execution-target.ts +21 -0
  928. package/src/tools/execution-timeout.ts +49 -0
  929. package/src/tools/executor.ts +815 -0
  930. package/src/tools/filesystem/edit.ts +127 -0
  931. package/src/tools/filesystem/fuzzy-match.ts +202 -0
  932. package/src/tools/filesystem/read.ts +71 -0
  933. package/src/tools/filesystem/view-image.ts +199 -0
  934. package/src/tools/filesystem/write.ts +79 -0
  935. package/src/tools/followups/followup_create.ts +76 -0
  936. package/src/tools/followups/followup_list.ts +60 -0
  937. package/src/tools/followups/followup_resolve.ts +56 -0
  938. package/src/tools/host-filesystem/edit.ts +125 -0
  939. package/src/tools/host-filesystem/read.ts +80 -0
  940. package/src/tools/host-filesystem/write.ts +76 -0
  941. package/src/tools/host-terminal/cli-discover.ts +180 -0
  942. package/src/tools/host-terminal/host-shell.ts +191 -0
  943. package/src/tools/memory/definitions.ts +69 -0
  944. package/src/tools/memory/handlers.ts +246 -0
  945. package/src/tools/memory/register.ts +66 -0
  946. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  947. package/src/tools/network/domain-normalize.ts +85 -0
  948. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  949. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  950. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  951. package/src/tools/network/script-proxy/certs.ts +237 -0
  952. package/src/tools/network/script-proxy/connect-tunnel.ts +82 -0
  953. package/src/tools/network/script-proxy/http-forwarder.ts +151 -0
  954. package/src/tools/network/script-proxy/index.ts +28 -0
  955. package/src/tools/network/script-proxy/logging.ts +196 -0
  956. package/src/tools/network/script-proxy/mitm-handler.ts +269 -0
  957. package/src/tools/network/script-proxy/policy.ts +152 -0
  958. package/src/tools/network/script-proxy/router.ts +60 -0
  959. package/src/tools/network/script-proxy/server.ts +136 -0
  960. package/src/tools/network/script-proxy/session-manager.ts +534 -0
  961. package/src/tools/network/script-proxy/types.ts +125 -0
  962. package/src/tools/network/url-safety.ts +227 -0
  963. package/src/tools/network/web-fetch.ts +713 -0
  964. package/src/tools/network/web-search.ts +296 -0
  965. package/src/tools/policy-context.ts +29 -0
  966. package/src/tools/registry.ts +295 -0
  967. package/src/tools/reminder/reminder-store.ts +148 -0
  968. package/src/tools/reminder/reminder.ts +80 -0
  969. package/src/tools/schedule/create.ts +81 -0
  970. package/src/tools/schedule/delete.ts +28 -0
  971. package/src/tools/schedule/list.ts +69 -0
  972. package/src/tools/schedule/update.ts +97 -0
  973. package/src/tools/shared/filesystem/edit-engine.ts +56 -0
  974. package/src/tools/shared/filesystem/errors.ts +85 -0
  975. package/src/tools/shared/filesystem/file-ops-service.ts +215 -0
  976. package/src/tools/shared/filesystem/format-diff.ts +35 -0
  977. package/src/tools/shared/filesystem/path-policy.ts +125 -0
  978. package/src/tools/shared/filesystem/size-guard.ts +41 -0
  979. package/src/tools/shared/filesystem/types.ts +80 -0
  980. package/src/tools/shared/shell-output.ts +52 -0
  981. package/src/tools/skills/delete-managed.ts +60 -0
  982. package/src/tools/skills/load.ts +139 -0
  983. package/src/tools/skills/sandbox-runner.ts +279 -0
  984. package/src/tools/skills/scaffold-managed.ts +150 -0
  985. package/src/tools/skills/script-contract.ts +6 -0
  986. package/src/tools/skills/skill-script-runner.ts +86 -0
  987. package/src/tools/skills/skill-tool-factory.ts +64 -0
  988. package/src/tools/skills/vellum-catalog.ts +217 -0
  989. package/src/tools/subagent/abort.ts +33 -0
  990. package/src/tools/subagent/message.ts +39 -0
  991. package/src/tools/subagent/read.ts +67 -0
  992. package/src/tools/subagent/spawn.ts +46 -0
  993. package/src/tools/subagent/status.ts +45 -0
  994. package/src/tools/swarm/delegate.ts +183 -0
  995. package/src/tools/system/request-permission.ts +98 -0
  996. package/src/tools/system/version.ts +43 -0
  997. package/src/tools/tasks/index.ts +27 -0
  998. package/src/tools/tasks/task-delete.ts +82 -0
  999. package/src/tools/tasks/task-list.ts +44 -0
  1000. package/src/tools/tasks/task-run.ts +97 -0
  1001. package/src/tools/tasks/task-save.ts +47 -0
  1002. package/src/tools/tasks/work-item-enqueue.ts +234 -0
  1003. package/src/tools/tasks/work-item-list.ts +55 -0
  1004. package/src/tools/tasks/work-item-remove.ts +60 -0
  1005. package/src/tools/tasks/work-item-run.ts +78 -0
  1006. package/src/tools/tasks/work-item-update.ts +114 -0
  1007. package/src/tools/terminal/backends/docker.ts +372 -0
  1008. package/src/tools/terminal/backends/native.ts +190 -0
  1009. package/src/tools/terminal/backends/types.ts +26 -0
  1010. package/src/tools/terminal/evaluate-typescript.ts +275 -0
  1011. package/src/tools/terminal/parser.ts +413 -0
  1012. package/src/tools/terminal/safe-env.ts +37 -0
  1013. package/src/tools/terminal/sandbox-diagnostics.ts +149 -0
  1014. package/src/tools/terminal/sandbox.ts +44 -0
  1015. package/src/tools/terminal/shell.ts +257 -0
  1016. package/src/tools/tool-manifest.ts +198 -0
  1017. package/src/tools/types.ts +176 -0
  1018. package/src/tools/ui-surface/definitions.ts +244 -0
  1019. package/src/tools/ui-surface/registry.ts +14 -0
  1020. package/src/tools/watch/screen-watch.ts +130 -0
  1021. package/src/tools/watch/watch-state.ts +119 -0
  1022. package/src/tools/watcher/create.ts +64 -0
  1023. package/src/tools/watcher/delete.ts +27 -0
  1024. package/src/tools/watcher/digest.ts +50 -0
  1025. package/src/tools/watcher/list.ts +60 -0
  1026. package/src/tools/watcher/update.ts +56 -0
  1027. package/src/tools/weather/service.ts +551 -0
  1028. package/src/twitter/client.ts +690 -0
  1029. package/src/twitter/oauth-client.ts +102 -0
  1030. package/src/twitter/router.ts +101 -0
  1031. package/src/twitter/session.ts +91 -0
  1032. package/src/usage/actors.ts +24 -0
  1033. package/src/usage/types.ts +37 -0
  1034. package/src/util/clipboard.ts +33 -0
  1035. package/src/util/content-id.ts +16 -0
  1036. package/src/util/debounce.ts +88 -0
  1037. package/src/util/diff.ts +181 -0
  1038. package/src/util/errors.ts +129 -0
  1039. package/src/util/logger.ts +243 -0
  1040. package/src/util/network-info.ts +47 -0
  1041. package/src/util/platform.ts +632 -0
  1042. package/src/util/pricing.ts +150 -0
  1043. package/src/util/promise-guard.ts +37 -0
  1044. package/src/util/retry.ts +98 -0
  1045. package/src/util/spinner.ts +51 -0
  1046. package/src/util/time.ts +16 -0
  1047. package/src/util/truncate.ts +6 -0
  1048. package/src/util/xml.ts +4 -0
  1049. package/src/version.ts +3 -0
  1050. package/src/watcher/constants.ts +11 -0
  1051. package/src/watcher/engine.ts +199 -0
  1052. package/src/watcher/provider-registry.ts +15 -0
  1053. package/src/watcher/provider-types.ts +48 -0
  1054. package/src/watcher/providers/gmail.ts +198 -0
  1055. package/src/watcher/providers/google-calendar.ts +228 -0
  1056. package/src/watcher/providers/slack.ts +129 -0
  1057. package/src/watcher/watcher-store.ts +419 -0
  1058. package/src/work-items/work-item-runner.ts +171 -0
  1059. package/src/work-items/work-item-store.ts +325 -0
  1060. package/src/workspace/commit-message-enrichment-service.ts +284 -0
  1061. package/src/workspace/commit-message-provider.ts +95 -0
  1062. package/src/workspace/git-service.ts +857 -0
  1063. package/src/workspace/heartbeat-service.ts +345 -0
  1064. package/src/workspace/provider-commit-message-generator.ts +285 -0
  1065. package/src/workspace/top-level-renderer.ts +19 -0
  1066. package/src/workspace/top-level-scanner.ts +41 -0
  1067. package/src/workspace/turn-commit.ts +175 -0
  1068. package/tsconfig.json +21 -0
@@ -0,0 +1,2356 @@
1
+ import { describe, test, expect, beforeEach, afterAll, afterEach, mock, spyOn } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test isolation: in-memory SQLite via temp directory
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const testDir = mkdtempSync(join(tmpdir(), 'channel-approval-routes-test-'));
11
+
12
+ mock.module('../util/platform.js', () => ({
13
+ getRootDir: () => testDir,
14
+ getDataDir: () => testDir,
15
+ isMacOS: () => process.platform === 'darwin',
16
+ isLinux: () => process.platform === 'linux',
17
+ isWindows: () => process.platform === 'win32',
18
+ getSocketPath: () => join(testDir, 'test.sock'),
19
+ getPidPath: () => join(testDir, 'test.pid'),
20
+ getDbPath: () => join(testDir, 'test.db'),
21
+ getLogPath: () => join(testDir, 'test.log'),
22
+ ensureDataDir: () => {},
23
+ }));
24
+
25
+ mock.module('../util/logger.js', () => ({
26
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
27
+ get: () => () => {},
28
+ }),
29
+ }));
30
+
31
+ // Mock security check to always pass
32
+ mock.module('../security/secret-ingress.js', () => ({
33
+ checkIngressForSecrets: () => ({ blocked: false }),
34
+ }));
35
+
36
+ // Mock render to return the raw content as text
37
+ mock.module('../daemon/handlers.js', () => ({
38
+ renderHistoryContent: (content: unknown) => ({
39
+ text: typeof content === 'string' ? content : JSON.stringify(content),
40
+ }),
41
+ }));
42
+
43
+ import { initializeDb, getDb, resetDb } from '../memory/db.js';
44
+ import { conversations } from '../memory/schema.js';
45
+ import {
46
+ createRun,
47
+ setRunConfirmation,
48
+ } from '../memory/runs-store.js';
49
+ import type { PendingConfirmation } from '../memory/runs-store.js';
50
+ import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
51
+ import * as conversationStore from '../memory/conversation-store.js';
52
+ import {
53
+ createBinding,
54
+ createApprovalRequest,
55
+ getPendingApprovalForRun,
56
+ getUnresolvedApprovalForRun,
57
+ getExpiredPendingApprovals,
58
+ updateApprovalDecision,
59
+ } from '../memory/channel-guardian-store.js';
60
+ import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
61
+ import {
62
+ handleChannelInbound,
63
+ isChannelApprovalsEnabled,
64
+ sweepExpiredGuardianApprovals,
65
+ } from '../runtime/routes/channel-routes.js';
66
+ import * as gatewayClient from '../runtime/gateway-client.js';
67
+
68
+ initializeDb();
69
+
70
+ afterAll(() => {
71
+ resetDb();
72
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Helpers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function ensureConversation(conversationId: string): void {
80
+ const db = getDb();
81
+ try {
82
+ db.insert(conversations).values({
83
+ id: conversationId,
84
+ createdAt: Date.now(),
85
+ updatedAt: Date.now(),
86
+ }).run();
87
+ } catch {
88
+ // already exists
89
+ }
90
+ }
91
+
92
+ function resetTables(): void {
93
+ const db = getDb();
94
+ db.run('DELETE FROM channel_guardian_approval_requests');
95
+ db.run('DELETE FROM channel_guardian_verification_challenges');
96
+ db.run('DELETE FROM channel_guardian_bindings');
97
+ db.run('DELETE FROM message_runs');
98
+ db.run('DELETE FROM channel_inbound_events');
99
+ db.run('DELETE FROM messages');
100
+ db.run('DELETE FROM conversations');
101
+ }
102
+
103
+ const sampleConfirmation: PendingConfirmation = {
104
+ toolName: 'shell',
105
+ toolUseId: 'req-abc-123',
106
+ input: { command: 'rm -rf /tmp/test' },
107
+ riskLevel: 'high',
108
+ allowlistOptions: [{ label: 'rm -rf /tmp/test', pattern: 'rm -rf /tmp/test' }],
109
+ scopeOptions: [{ label: 'everywhere', scope: 'everywhere' }],
110
+ };
111
+
112
+ function makeMockOrchestrator(
113
+ submitResult: 'applied' | 'run_not_found' | 'no_pending_decision' = 'applied',
114
+ ): RunOrchestrator {
115
+ return {
116
+ submitDecision: mock(() => submitResult),
117
+ getRun: mock(() => null),
118
+ startRun: mock(async () => ({
119
+ id: 'run-1',
120
+ conversationId: 'conv-1',
121
+ messageId: null,
122
+ status: 'running' as const,
123
+ pendingConfirmation: null,
124
+ pendingSecret: null,
125
+ inputTokens: 0,
126
+ outputTokens: 0,
127
+ estimatedCost: 0,
128
+ error: null,
129
+ createdAt: Date.now(),
130
+ updatedAt: Date.now(),
131
+ })),
132
+ } as unknown as RunOrchestrator;
133
+ }
134
+
135
+ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
136
+ const body = {
137
+ sourceChannel: 'telegram',
138
+ externalChatId: 'chat-123',
139
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
140
+ content: 'hello',
141
+ replyCallbackUrl: 'https://gateway.test/deliver',
142
+ ...overrides,
143
+ };
144
+ return new Request('http://localhost/channels/inbound', {
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify(body),
148
+ });
149
+ }
150
+
151
+ const noopProcessMessage = mock(async () => ({ messageId: 'msg-1' }));
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Set up / tear down feature flag for each test
155
+ // ---------------------------------------------------------------------------
156
+
157
+ let originalEnv: string | undefined;
158
+
159
+ beforeEach(() => {
160
+ resetTables();
161
+ originalEnv = process.env.CHANNEL_APPROVALS_ENABLED;
162
+ noopProcessMessage.mockClear();
163
+ });
164
+
165
+ afterEach(() => {
166
+ if (originalEnv === undefined) {
167
+ delete process.env.CHANNEL_APPROVALS_ENABLED;
168
+ } else {
169
+ process.env.CHANNEL_APPROVALS_ENABLED = originalEnv;
170
+ }
171
+ });
172
+
173
+ // ═══════════════════════════════════════════════════════════════════════════
174
+ // 1. Feature flag gating
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+
177
+ describe('isChannelApprovalsEnabled', () => {
178
+ test('returns false when env var is not set', () => {
179
+ delete process.env.CHANNEL_APPROVALS_ENABLED;
180
+ expect(isChannelApprovalsEnabled()).toBe(false);
181
+ });
182
+
183
+ test('returns false when env var is "false"', () => {
184
+ process.env.CHANNEL_APPROVALS_ENABLED = 'false';
185
+ expect(isChannelApprovalsEnabled()).toBe(false);
186
+ });
187
+
188
+ test('returns true when env var is "true"', () => {
189
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
190
+ expect(isChannelApprovalsEnabled()).toBe(true);
191
+ });
192
+ });
193
+
194
+ describe('feature flag disabled → normal flow', () => {
195
+ beforeEach(() => {
196
+ delete process.env.CHANNEL_APPROVALS_ENABLED;
197
+ });
198
+
199
+ test('proceeds normally even when pending approvals exist', async () => {
200
+ ensureConversation('conv-1');
201
+ const run = createRun('conv-1');
202
+ setRunConfirmation(run.id, sampleConfirmation);
203
+
204
+ const orchestrator = makeMockOrchestrator();
205
+ const req = makeInboundRequest({
206
+ content: 'approve',
207
+ callbackData: 'apr:run-1:approve_once',
208
+ });
209
+
210
+ const res = await handleChannelInbound(req, noopProcessMessage, undefined, orchestrator);
211
+ const body = await res.json() as Record<string, unknown>;
212
+
213
+ // Should proceed normally — no approval interception
214
+ expect(body.accepted).toBe(true);
215
+ expect(body.approval).toBeUndefined();
216
+ });
217
+ });
218
+
219
+ // ═══════════════════════════════════════════════════════════════════════════
220
+ // 2. Callback data triggers decision handling
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+
223
+ describe('inbound callback metadata triggers decision handling', () => {
224
+ beforeEach(() => {
225
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
226
+ });
227
+
228
+ test('callback data "apr:<runId>:approve_once" is parsed and applied', async () => {
229
+ // We need the conversation to exist AND have a pending run.
230
+ // The channel-delivery-store will create a conversation for us via recordInbound,
231
+ // but we also need the run to be linked to the same conversationId.
232
+ // Let's set up the conversation first, then create a run.
233
+ ensureConversation('conv-1');
234
+
235
+ // Create and record an earlier inbound event for this chat to establish
236
+ // the conversation mapping (so recordInbound returns the same conversationId).
237
+ // Actually, recordInbound auto-creates a conversationId based on source+chat.
238
+ // We need to find out what conversationId will be generated for telegram:chat-123.
239
+
240
+ // Let's use a spy to check if handleChannelDecision-equivalent behavior fires.
241
+ const orchestrator = makeMockOrchestrator();
242
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
243
+
244
+ // First, send a normal message to establish the conversation.
245
+ const initReq = makeInboundRequest({ content: 'init' });
246
+ const initRes = await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
247
+ const _initBody = await initRes.json() as { conversationId?: string; eventId?: string; accepted?: boolean };
248
+
249
+ // Now we need to find the actual conversationId that was created.
250
+ // Check the channel_inbound_events table.
251
+ const db = getDb();
252
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
253
+ const conversationId = events[0]?.conversation_id;
254
+ expect(conversationId).toBeTruthy();
255
+
256
+ // Ensure conversation row exists for FK constraints
257
+ ensureConversation(conversationId!);
258
+
259
+ // Create a pending run for this conversation
260
+ const run = createRun(conversationId!);
261
+ setRunConfirmation(run.id, sampleConfirmation);
262
+
263
+ // Now send a callback data message
264
+ const req = makeInboundRequest({
265
+ content: '',
266
+ callbackData: `apr:${run.id}:approve_once`,
267
+ });
268
+
269
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
270
+ const body = await res.json() as Record<string, unknown>;
271
+
272
+ expect(body.accepted).toBe(true);
273
+ expect(body.approval).toBe('decision_applied');
274
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
275
+
276
+ deliverSpy.mockRestore();
277
+ });
278
+
279
+ test('callback data "apr:<runId>:reject" applies a rejection', async () => {
280
+ const orchestrator = makeMockOrchestrator();
281
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
282
+
283
+ // Establish the conversation
284
+ const initReq = makeInboundRequest({ content: 'init' });
285
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
286
+
287
+ const db = getDb();
288
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
289
+ const conversationId = events[0]?.conversation_id;
290
+ ensureConversation(conversationId!);
291
+
292
+ const run = createRun(conversationId!);
293
+ setRunConfirmation(run.id, sampleConfirmation);
294
+
295
+ const req = makeInboundRequest({
296
+ content: '',
297
+ callbackData: `apr:${run.id}:reject`,
298
+ });
299
+
300
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
301
+ const body = await res.json() as Record<string, unknown>;
302
+
303
+ expect(body.accepted).toBe(true);
304
+ expect(body.approval).toBe('decision_applied');
305
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
306
+
307
+ deliverSpy.mockRestore();
308
+ });
309
+ });
310
+
311
+ // ═══════════════════════════════════════════════════════════════════════════
312
+ // 3. Plain text triggers decision handling
313
+ // ═══════════════════════════════════════════════════════════════════════════
314
+
315
+ describe('inbound text matching approval phrases triggers decision handling', () => {
316
+ beforeEach(() => {
317
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
318
+ });
319
+
320
+ test('text "approve" triggers approve_once decision', async () => {
321
+ const orchestrator = makeMockOrchestrator();
322
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
323
+
324
+ // Establish the conversation
325
+ const initReq = makeInboundRequest({ content: 'init' });
326
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
327
+
328
+ const db = getDb();
329
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
330
+ const conversationId = events[0]?.conversation_id;
331
+ ensureConversation(conversationId!);
332
+
333
+ const run = createRun(conversationId!);
334
+ setRunConfirmation(run.id, sampleConfirmation);
335
+
336
+ const req = makeInboundRequest({ content: 'approve' });
337
+
338
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
339
+ const body = await res.json() as Record<string, unknown>;
340
+
341
+ expect(body.accepted).toBe(true);
342
+ expect(body.approval).toBe('decision_applied');
343
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
344
+
345
+ deliverSpy.mockRestore();
346
+ });
347
+
348
+ test('text "always" triggers approve_always decision', async () => {
349
+ const orchestrator = makeMockOrchestrator();
350
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
351
+
352
+ const initReq = makeInboundRequest({ content: 'init' });
353
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
354
+
355
+ const db = getDb();
356
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
357
+ const conversationId = events[0]?.conversation_id;
358
+ ensureConversation(conversationId!);
359
+
360
+ const run = createRun(conversationId!);
361
+ setRunConfirmation(run.id, sampleConfirmation);
362
+
363
+ const req = makeInboundRequest({ content: 'always' });
364
+
365
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
366
+ const body = await res.json() as Record<string, unknown>;
367
+
368
+ expect(body.accepted).toBe(true);
369
+ expect(body.approval).toBe('decision_applied');
370
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
371
+
372
+ deliverSpy.mockRestore();
373
+ });
374
+ });
375
+
376
+ // ═══════════════════════════════════════════════════════════════════════════
377
+ // 4. Non-decision messages during pending approval trigger reminder
378
+ // ═══════════════════════════════════════════════════════════════════════════
379
+
380
+ describe('non-decision messages during pending approval trigger reminder', () => {
381
+ beforeEach(() => {
382
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
383
+ });
384
+
385
+ test('sends a reminder prompt when message is not a decision', async () => {
386
+ const orchestrator = makeMockOrchestrator();
387
+ const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
388
+ const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
389
+
390
+ const initReq = makeInboundRequest({ content: 'init' });
391
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
392
+
393
+ const db = getDb();
394
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
395
+ const conversationId = events[0]?.conversation_id;
396
+ ensureConversation(conversationId!);
397
+
398
+ const run = createRun(conversationId!);
399
+ setRunConfirmation(run.id, sampleConfirmation);
400
+
401
+ // Send a message that is NOT a decision
402
+ const req = makeInboundRequest({ content: 'what is the weather?' });
403
+
404
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
405
+ const body = await res.json() as Record<string, unknown>;
406
+
407
+ expect(body.accepted).toBe(true);
408
+ expect(body.approval).toBe('reminder_sent');
409
+
410
+ // The approval prompt delivery should have been called
411
+ expect(deliverSpy).toHaveBeenCalled();
412
+ const callArgs = deliverSpy.mock.calls[0];
413
+ // The text should contain the reminder prefix
414
+ expect(callArgs[2]).toContain("I'm still waiting");
415
+ // The approval UI metadata should be present
416
+ expect(callArgs[3]).toBeDefined();
417
+ expect(callArgs[3]!.runId).toBe(run.id);
418
+
419
+ deliverSpy.mockRestore();
420
+ replySpy.mockRestore();
421
+ });
422
+ });
423
+
424
+ // ═══════════════════════════════════════════════════════════════════════════
425
+ // 5. Messages without pending approval proceed normally
426
+ // ═══════════════════════════════════════════════════════════════════════════
427
+
428
+ describe('messages without pending approval proceed normally', () => {
429
+ beforeEach(() => {
430
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
431
+ });
432
+
433
+ test('proceeds to normal processing when no pending approval exists', async () => {
434
+ const orchestrator = makeMockOrchestrator();
435
+
436
+ const req = makeInboundRequest({ content: 'hello world' });
437
+
438
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
439
+ const body = await res.json() as Record<string, unknown>;
440
+
441
+ expect(body.accepted).toBe(true);
442
+ expect(body.approval).toBeUndefined();
443
+ // Normal flow should have been triggered
444
+ });
445
+
446
+ test('text "approve" is processed normally when no pending approval exists', async () => {
447
+ const orchestrator = makeMockOrchestrator();
448
+
449
+ const req = makeInboundRequest({ content: 'approve' });
450
+
451
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
452
+ const body = await res.json() as Record<string, unknown>;
453
+
454
+ expect(body.accepted).toBe(true);
455
+ // Should NOT be treated as an approval decision since there's no pending approval
456
+ expect(body.approval).toBeUndefined();
457
+ });
458
+ });
459
+
460
+ // ═══════════════════════════════════════════════════════════════════════════
461
+ // 6. Empty content with callbackData bypasses validation
462
+ // ═══════════════════════════════════════════════════════════════════════════
463
+
464
+ describe('empty content with callbackData bypasses validation', () => {
465
+ test('rejects empty content without callbackData', async () => {
466
+ const req = makeInboundRequest({ content: '' });
467
+ const res = await handleChannelInbound(req, noopProcessMessage);
468
+ expect(res.status).toBe(400);
469
+ const body = await res.json() as Record<string, unknown>;
470
+ expect(body.error).toBe('content or attachmentIds is required');
471
+ });
472
+
473
+ test('allows empty content when callbackData is present', async () => {
474
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
475
+ const orchestrator = makeMockOrchestrator();
476
+
477
+ // Establish the conversation first
478
+ const initReq = makeInboundRequest({ content: 'init' });
479
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
480
+
481
+ const db = getDb();
482
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
483
+ const conversationId = events[0]?.conversation_id;
484
+ ensureConversation(conversationId!);
485
+
486
+ const run = createRun(conversationId!);
487
+ setRunConfirmation(run.id, sampleConfirmation);
488
+
489
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
490
+
491
+ const req = makeInboundRequest({
492
+ content: '',
493
+ callbackData: `apr:${run.id}:approve_once`,
494
+ });
495
+
496
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
497
+ // Should NOT return 400 — callbackData allows empty content through
498
+ expect(res.status).toBe(200);
499
+ const body = await res.json() as Record<string, unknown>;
500
+ expect(body.accepted).toBe(true);
501
+ expect(body.approval).toBe('decision_applied');
502
+
503
+ deliverSpy.mockRestore();
504
+ });
505
+
506
+ test('allows undefined content when callbackData is present', async () => {
507
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
508
+ const orchestrator = makeMockOrchestrator();
509
+
510
+ // Establish the conversation first
511
+ const initReq = makeInboundRequest({ content: 'init' });
512
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
513
+
514
+ const db = getDb();
515
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
516
+ const conversationId = events[0]?.conversation_id;
517
+ ensureConversation(conversationId!);
518
+
519
+ const run = createRun(conversationId!);
520
+ setRunConfirmation(run.id, sampleConfirmation);
521
+
522
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
523
+
524
+ // Send with no content field at all, just callbackData
525
+ const body = {
526
+ sourceChannel: 'telegram',
527
+ externalChatId: 'chat-123',
528
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
529
+ callbackData: `apr:${run.id}:approve_once`,
530
+ replyCallbackUrl: 'https://gateway.test/deliver',
531
+ };
532
+ const req = new Request('http://localhost/channels/inbound', {
533
+ method: 'POST',
534
+ headers: { 'Content-Type': 'application/json' },
535
+ body: JSON.stringify(body),
536
+ });
537
+
538
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
539
+ expect(res.status).toBe(200);
540
+ const resBody = await res.json() as Record<string, unknown>;
541
+ expect(resBody.accepted).toBe(true);
542
+
543
+ deliverSpy.mockRestore();
544
+ });
545
+ });
546
+
547
+ // ═══════════════════════════════════════════════════════════════════════════
548
+ // 7. Callback run ID validation — stale button press
549
+ // ═══════════════════════════════════════════════════════════════════════════
550
+
551
+ describe('callback run ID validation', () => {
552
+ beforeEach(() => {
553
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
554
+ });
555
+
556
+ test('ignores stale callback when run ID does not match pending run', async () => {
557
+ const orchestrator = makeMockOrchestrator();
558
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
559
+
560
+ // Establish the conversation
561
+ const initReq = makeInboundRequest({ content: 'init' });
562
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
563
+
564
+ const db = getDb();
565
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
566
+ const conversationId = events[0]?.conversation_id;
567
+ ensureConversation(conversationId!);
568
+
569
+ // Create a pending run
570
+ const run = createRun(conversationId!);
571
+ setRunConfirmation(run.id, sampleConfirmation);
572
+
573
+ // Send callback with a DIFFERENT run ID (stale button)
574
+ const req = makeInboundRequest({
575
+ content: '',
576
+ callbackData: `apr:stale-run-id:approve_once`,
577
+ });
578
+
579
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
580
+ const body = await res.json() as Record<string, unknown>;
581
+
582
+ expect(body.accepted).toBe(true);
583
+ expect(body.approval).toBe('stale_ignored');
584
+ // submitDecision should NOT have been called because the run ID didn't match
585
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
586
+
587
+ deliverSpy.mockRestore();
588
+ });
589
+
590
+ test('applies callback when run ID matches pending run', async () => {
591
+ const orchestrator = makeMockOrchestrator();
592
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
593
+
594
+ // Establish the conversation
595
+ const initReq = makeInboundRequest({ content: 'init' });
596
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
597
+
598
+ const db = getDb();
599
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
600
+ const conversationId = events[0]?.conversation_id;
601
+ ensureConversation(conversationId!);
602
+
603
+ // Create a pending run
604
+ const run = createRun(conversationId!);
605
+ setRunConfirmation(run.id, sampleConfirmation);
606
+
607
+ // Send callback with the CORRECT run ID
608
+ const req = makeInboundRequest({
609
+ content: '',
610
+ callbackData: `apr:${run.id}:approve_once`,
611
+ });
612
+
613
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
614
+ const body = await res.json() as Record<string, unknown>;
615
+
616
+ expect(body.accepted).toBe(true);
617
+ expect(body.approval).toBe('decision_applied');
618
+ // submitDecision SHOULD have been called with the correct run ID
619
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
620
+
621
+ deliverSpy.mockRestore();
622
+ });
623
+
624
+ test('plain-text decisions bypass run ID validation (no runId in result)', async () => {
625
+ const orchestrator = makeMockOrchestrator();
626
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
627
+
628
+ // Establish the conversation
629
+ const initReq = makeInboundRequest({ content: 'init' });
630
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
631
+
632
+ const db = getDb();
633
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
634
+ const conversationId = events[0]?.conversation_id;
635
+ ensureConversation(conversationId!);
636
+
637
+ // Create a pending run
638
+ const run = createRun(conversationId!);
639
+ setRunConfirmation(run.id, sampleConfirmation);
640
+
641
+ // Send plain text "yes" — no runId in the parsed result, so validation is skipped
642
+ const req = makeInboundRequest({ content: 'yes' });
643
+
644
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
645
+ const body = await res.json() as Record<string, unknown>;
646
+
647
+ expect(body.accepted).toBe(true);
648
+ expect(body.approval).toBe('decision_applied');
649
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
650
+
651
+ deliverSpy.mockRestore();
652
+ });
653
+ });
654
+
655
+ // ═══════════════════════════════════════════════════════════════════════════
656
+ // 8. linkMessage in approval-aware processing path
657
+ // ═══════════════════════════════════════════════════════════════════════════
658
+
659
+ describe('linkMessage in approval-aware processing path', () => {
660
+ beforeEach(() => {
661
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
662
+ });
663
+
664
+ test('linkMessage is called when run has a messageId and reaches terminal state', async () => {
665
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
666
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
667
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
668
+
669
+ const mockRun = {
670
+ id: 'run-link-test',
671
+ conversationId: 'conv-1',
672
+ messageId: 'user-msg-42',
673
+ status: 'running' as const,
674
+ pendingConfirmation: null,
675
+ pendingSecret: null,
676
+ inputTokens: 0,
677
+ outputTokens: 0,
678
+ estimatedCost: 0,
679
+ error: null,
680
+ createdAt: Date.now(),
681
+ updatedAt: Date.now(),
682
+ };
683
+
684
+ // getRun returns completed status immediately so the poll loop exits
685
+ const orchestrator = {
686
+ submitDecision: mock(() => 'applied' as const),
687
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
688
+ startRun: mock(async () => mockRun),
689
+ } as unknown as RunOrchestrator;
690
+
691
+ const req = makeInboundRequest({ content: 'hello world' });
692
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
693
+
694
+ // Wait for the background async to complete (must exceed RUN_POLL_INTERVAL_MS of 500ms)
695
+ await new Promise((resolve) => setTimeout(resolve, 800));
696
+
697
+ // Verify linkMessage was called with the run's messageId
698
+ const linkCalls = linkSpy.mock.calls.filter(
699
+ (call) => call[1] === 'user-msg-42',
700
+ );
701
+ expect(linkCalls.length).toBeGreaterThanOrEqual(1);
702
+
703
+ // Verify markProcessed was also called
704
+ expect(markSpy).toHaveBeenCalled();
705
+
706
+ linkSpy.mockRestore();
707
+ markSpy.mockRestore();
708
+ deliverSpy.mockRestore();
709
+ });
710
+ });
711
+
712
+ // ═══════════════════════════════════════════════════════════════════════════
713
+ // 9. Terminal state check before markProcessed
714
+ // ═══════════════════════════════════════════════════════════════════════════
715
+
716
+ describe('terminal state check before markProcessed', () => {
717
+ beforeEach(() => {
718
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
719
+ });
720
+
721
+ test('records processing failure when run disappears (non-approval non-terminal state)', async () => {
722
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
723
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
724
+ const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
725
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
726
+
727
+ const mockRun = {
728
+ id: 'run-nonterminal',
729
+ conversationId: 'conv-1',
730
+ messageId: 'user-msg-99',
731
+ status: 'running' as const,
732
+ pendingConfirmation: null,
733
+ pendingSecret: null,
734
+ inputTokens: 0,
735
+ outputTokens: 0,
736
+ estimatedCost: 0,
737
+ error: null,
738
+ createdAt: Date.now(),
739
+ updatedAt: Date.now(),
740
+ };
741
+
742
+ // getRun returns null — run disappeared, poll loop breaks. Since the run
743
+ // is not in needs_confirmation, it falls through to recordProcessingFailure
744
+ // so the retry/dead-letter machinery can handle it.
745
+ const orchNull = {
746
+ submitDecision: mock(() => 'applied' as const),
747
+ getRun: mock(() => null),
748
+ startRun: mock(async () => mockRun),
749
+ } as unknown as RunOrchestrator;
750
+
751
+ const req = makeInboundRequest({ content: 'hello world' });
752
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchNull);
753
+
754
+ // Wait for the background async to complete
755
+ await new Promise((resolve) => setTimeout(resolve, 800));
756
+
757
+ // recordProcessingFailure SHOULD have been called because the run is
758
+ // not in needs_confirmation (it disappeared — status is null).
759
+ expect(failureSpy).toHaveBeenCalled();
760
+
761
+ // markProcessed should NOT have been called
762
+ expect(markSpy).not.toHaveBeenCalled();
763
+
764
+ linkSpy.mockRestore();
765
+ markSpy.mockRestore();
766
+ failureSpy.mockRestore();
767
+ deliverSpy.mockRestore();
768
+ });
769
+
770
+ test('markProcessed is called when run reaches completed state', async () => {
771
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
772
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
773
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
774
+
775
+ const mockRun = {
776
+ id: 'run-completes',
777
+ conversationId: 'conv-1',
778
+ messageId: 'user-msg-100',
779
+ status: 'running' as const,
780
+ pendingConfirmation: null,
781
+ pendingSecret: null,
782
+ inputTokens: 0,
783
+ outputTokens: 0,
784
+ estimatedCost: 0,
785
+ error: null,
786
+ createdAt: Date.now(),
787
+ updatedAt: Date.now(),
788
+ };
789
+
790
+ const orchestrator = {
791
+ submitDecision: mock(() => 'applied' as const),
792
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
793
+ startRun: mock(async () => mockRun),
794
+ } as unknown as RunOrchestrator;
795
+
796
+ const req = makeInboundRequest({ content: 'hello world' });
797
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
798
+
799
+ // Wait for the background async to complete
800
+ await new Promise((resolve) => setTimeout(resolve, 800));
801
+
802
+ // markProcessed should have been called because the run reached completed
803
+ expect(markSpy).toHaveBeenCalled();
804
+
805
+ linkSpy.mockRestore();
806
+ markSpy.mockRestore();
807
+ deliverSpy.mockRestore();
808
+ });
809
+
810
+ test('markProcessed is called when run reaches failed state', async () => {
811
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
812
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
813
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
814
+
815
+ const mockRun = {
816
+ id: 'run-fails',
817
+ conversationId: 'conv-1',
818
+ messageId: 'user-msg-101',
819
+ status: 'running' as const,
820
+ pendingConfirmation: null,
821
+ pendingSecret: null,
822
+ inputTokens: 0,
823
+ outputTokens: 0,
824
+ estimatedCost: 0,
825
+ error: null,
826
+ createdAt: Date.now(),
827
+ updatedAt: Date.now(),
828
+ };
829
+
830
+ const orchestrator = {
831
+ submitDecision: mock(() => 'applied' as const),
832
+ getRun: mock(() => ({ ...mockRun, status: 'failed' as const })),
833
+ startRun: mock(async () => mockRun),
834
+ } as unknown as RunOrchestrator;
835
+
836
+ const req = makeInboundRequest({ content: 'hello world' });
837
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
838
+
839
+ // Wait for the background async to complete
840
+ await new Promise((resolve) => setTimeout(resolve, 800));
841
+
842
+ // markProcessed should have been called because the run reached failed
843
+ expect(markSpy).toHaveBeenCalled();
844
+
845
+ linkSpy.mockRestore();
846
+ markSpy.mockRestore();
847
+ deliverSpy.mockRestore();
848
+ });
849
+ });
850
+
851
+ // ═══════════════════════════════════════════════════════════════════════════
852
+ // 10. No immediate reply after approval decision (WS-A)
853
+ // ═══════════════════════════════════════════════════════════════════════════
854
+
855
+ describe('no immediate reply after approval decision', () => {
856
+ beforeEach(() => {
857
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
858
+ });
859
+
860
+ test('deliverChannelReply is NOT called from interception after decision is applied', async () => {
861
+ const orchestrator = makeMockOrchestrator();
862
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
863
+
864
+ // Establish the conversation
865
+ const initReq = makeInboundRequest({ content: 'init' });
866
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
867
+
868
+ const db = getDb();
869
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
870
+ const conversationId = events[0]?.conversation_id;
871
+ ensureConversation(conversationId!);
872
+
873
+ // Create a pending run
874
+ const run = createRun(conversationId!);
875
+ setRunConfirmation(run.id, sampleConfirmation);
876
+
877
+ // Clear the spy to only track calls from the decision path
878
+ deliverSpy.mockClear();
879
+
880
+ // Send a callback decision
881
+ const req = makeInboundRequest({
882
+ content: '',
883
+ callbackData: `apr:${run.id}:approve_once`,
884
+ });
885
+
886
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
887
+ const body = await res.json() as Record<string, unknown>;
888
+
889
+ expect(body.approval).toBe('decision_applied');
890
+
891
+ // The interception handler should NOT have called deliverChannelReply.
892
+ // The reply should only come from the terminal run completion path.
893
+ expect(deliverSpy).not.toHaveBeenCalled();
894
+
895
+ deliverSpy.mockRestore();
896
+ });
897
+
898
+ test('plain-text decision also does not trigger immediate reply', async () => {
899
+ const orchestrator = makeMockOrchestrator();
900
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
901
+
902
+ // Establish the conversation
903
+ const initReq = makeInboundRequest({ content: 'init' });
904
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
905
+
906
+ const db = getDb();
907
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
908
+ const conversationId = events[0]?.conversation_id;
909
+ ensureConversation(conversationId!);
910
+
911
+ const run = createRun(conversationId!);
912
+ setRunConfirmation(run.id, sampleConfirmation);
913
+
914
+ deliverSpy.mockClear();
915
+
916
+ // Send a plain-text approval
917
+ const req = makeInboundRequest({ content: 'approve' });
918
+
919
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
920
+ const body = await res.json() as Record<string, unknown>;
921
+
922
+ expect(body.approval).toBe('decision_applied');
923
+ expect(deliverSpy).not.toHaveBeenCalled();
924
+
925
+ deliverSpy.mockRestore();
926
+ });
927
+ });
928
+
929
+ // ═══════════════════════════════════════════════════════════════════════════
930
+ // 11. Stale callback with no pending approval returns stale_ignored (WS-B)
931
+ // ═══════════════════════════════════════════════════════════════════════════
932
+
933
+ describe('stale callback handling', () => {
934
+ beforeEach(() => {
935
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
936
+ });
937
+
938
+ test('callback with no pending approval returns stale_ignored and does not start a run', async () => {
939
+ const orchestrator = makeMockOrchestrator();
940
+
941
+ // No pending run/approval — send a stale callback
942
+ const req = makeInboundRequest({
943
+ content: '',
944
+ callbackData: 'apr:stale-run:approve_once',
945
+ });
946
+
947
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
948
+ const body = await res.json() as Record<string, unknown>;
949
+
950
+ expect(body.accepted).toBe(true);
951
+ expect(body.approval).toBe('stale_ignored');
952
+
953
+ // startRun should NOT have been called — the stale callback must not
954
+ // enter processChannelMessageWithApprovals or processChannelMessageInBackground
955
+ expect(orchestrator.startRun).not.toHaveBeenCalled();
956
+ });
957
+
958
+ test('callback with non-empty content but no pending approval returns stale_ignored', async () => {
959
+ const orchestrator = makeMockOrchestrator();
960
+
961
+ // Simulate what normalize.ts does: callbackData present AND content is
962
+ // set to the callback data value (non-empty). Without the fix, this
963
+ // would fall through to normal processing because the old guard only
964
+ // checked for empty content.
965
+ const req = makeInboundRequest({
966
+ content: 'apr:stale-run:approve_once',
967
+ callbackData: 'apr:stale-run:approve_once',
968
+ });
969
+
970
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
971
+ const body = await res.json() as Record<string, unknown>;
972
+
973
+ expect(body.accepted).toBe(true);
974
+ expect(body.approval).toBe('stale_ignored');
975
+ expect(orchestrator.startRun).not.toHaveBeenCalled();
976
+ });
977
+
978
+ test('non-callback message without pending approval proceeds to normal processing', async () => {
979
+ const orchestrator = makeMockOrchestrator();
980
+
981
+ // Regular text message (no callbackData) should proceed normally
982
+ const req = makeInboundRequest({ content: 'hello world' });
983
+
984
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
985
+ const body = await res.json() as Record<string, unknown>;
986
+
987
+ expect(body.accepted).toBe(true);
988
+ // No approval field — normal processing
989
+ expect(body.approval).toBeUndefined();
990
+ });
991
+ });
992
+
993
+ // ═══════════════════════════════════════════════════════════════════════════
994
+ // 12. Timeout handling: needs_confirmation marks processed, other states fail
995
+ // ═══════════════════════════════════════════════════════════════════════════
996
+
997
+ describe('poll timeout handling by run state', () => {
998
+ beforeEach(() => {
999
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1000
+ });
1001
+
1002
+ test('records processing failure when run disappears (getRun returns null) before terminal state', async () => {
1003
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
1004
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
1005
+ const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
1006
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1007
+
1008
+ const mockRun = {
1009
+ id: 'run-timeout-1',
1010
+ conversationId: 'conv-1',
1011
+ messageId: 'user-msg-200',
1012
+ status: 'running' as const,
1013
+ pendingConfirmation: null,
1014
+ pendingSecret: null,
1015
+ inputTokens: 0,
1016
+ outputTokens: 0,
1017
+ estimatedCost: 0,
1018
+ error: null,
1019
+ createdAt: Date.now(),
1020
+ updatedAt: Date.now(),
1021
+ };
1022
+
1023
+ // getRun returns null — run disappeared, poll loop breaks. Since the run
1024
+ // is not in needs_confirmation, it records a processing failure.
1025
+ const orchestrator = {
1026
+ submitDecision: mock(() => 'applied' as const),
1027
+ getRun: mock(() => null),
1028
+ startRun: mock(async () => mockRun),
1029
+ } as unknown as RunOrchestrator;
1030
+
1031
+ const req = makeInboundRequest({ content: 'hello timeout' });
1032
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1033
+
1034
+ // Wait for the background async to complete
1035
+ await new Promise((resolve) => setTimeout(resolve, 800));
1036
+
1037
+ // recordProcessingFailure SHOULD have been called — the run disappeared
1038
+ // and is not in needs_confirmation, so the retry machinery should handle it.
1039
+ expect(failureSpy).toHaveBeenCalled();
1040
+
1041
+ // markProcessed should NOT have been called
1042
+ expect(markSpy).not.toHaveBeenCalled();
1043
+
1044
+ linkSpy.mockRestore();
1045
+ markSpy.mockRestore();
1046
+ failureSpy.mockRestore();
1047
+ deliverSpy.mockRestore();
1048
+ });
1049
+
1050
+ test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
1051
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
1052
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
1053
+ const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
1054
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1055
+
1056
+ const mockRun = {
1057
+ id: 'run-needs-confirm',
1058
+ conversationId: 'conv-1',
1059
+ messageId: 'user-msg-202',
1060
+ status: 'running' as const,
1061
+ pendingConfirmation: null,
1062
+ pendingSecret: null,
1063
+ inputTokens: 0,
1064
+ outputTokens: 0,
1065
+ estimatedCost: 0,
1066
+ error: null,
1067
+ createdAt: Date.now(),
1068
+ updatedAt: Date.now(),
1069
+ };
1070
+
1071
+ // getRun returns needs_confirmation — run is waiting for approval decision.
1072
+ // The event should be marked as processed because the post-decision delivery
1073
+ // in handleApprovalInterception will deliver the reply when the user decides.
1074
+ const orchestrator = {
1075
+ submitDecision: mock(() => 'applied' as const),
1076
+ getRun: mock(() => ({ ...mockRun, status: 'needs_confirmation' as const })),
1077
+ startRun: mock(async () => mockRun),
1078
+ } as unknown as RunOrchestrator;
1079
+
1080
+ const req = makeInboundRequest({ content: 'hello needs_confirm' });
1081
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1082
+
1083
+ // Wait for the background async to complete
1084
+ await new Promise((resolve) => setTimeout(resolve, 800));
1085
+
1086
+ // markProcessed SHOULD have been called — the run is waiting for approval,
1087
+ // and the post-decision delivery path will handle the final reply.
1088
+ expect(markSpy).toHaveBeenCalled();
1089
+
1090
+ // recordProcessingFailure should NOT have been called
1091
+ expect(failureSpy).not.toHaveBeenCalled();
1092
+
1093
+ linkSpy.mockRestore();
1094
+ markSpy.mockRestore();
1095
+ failureSpy.mockRestore();
1096
+ deliverSpy.mockRestore();
1097
+ });
1098
+
1099
+ test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
1100
+ const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
1101
+ const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
1102
+ const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
1103
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1104
+
1105
+ const mockRun = {
1106
+ id: 'run-terminal-ok',
1107
+ conversationId: 'conv-1',
1108
+ messageId: 'user-msg-201',
1109
+ status: 'running' as const,
1110
+ pendingConfirmation: null,
1111
+ pendingSecret: null,
1112
+ inputTokens: 0,
1113
+ outputTokens: 0,
1114
+ estimatedCost: 0,
1115
+ error: null,
1116
+ createdAt: Date.now(),
1117
+ updatedAt: Date.now(),
1118
+ };
1119
+
1120
+ // getRun returns completed — run is terminal
1121
+ const orchestrator = {
1122
+ submitDecision: mock(() => 'applied' as const),
1123
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
1124
+ startRun: mock(async () => mockRun),
1125
+ } as unknown as RunOrchestrator;
1126
+
1127
+ const req = makeInboundRequest({ content: 'hello terminal' });
1128
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1129
+
1130
+ // Wait for the background async to complete
1131
+ await new Promise((resolve) => setTimeout(resolve, 800));
1132
+
1133
+ // recordProcessingFailure should NOT have been called
1134
+ expect(failureSpy).not.toHaveBeenCalled();
1135
+
1136
+ // markProcessed SHOULD have been called
1137
+ expect(markSpy).toHaveBeenCalled();
1138
+
1139
+ linkSpy.mockRestore();
1140
+ markSpy.mockRestore();
1141
+ failureSpy.mockRestore();
1142
+ deliverSpy.mockRestore();
1143
+ });
1144
+ });
1145
+
1146
+ // ═══════════════════════════════════════════════════════════════════════════
1147
+ // 12b. Post-decision delivery after poll timeout
1148
+ // ═══════════════════════════════════════════════════════════════════════════
1149
+
1150
+ describe('post-decision delivery after poll timeout', () => {
1151
+ beforeEach(() => {
1152
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1153
+ });
1154
+
1155
+ test('delivers reply via callback after a late approval decision', async () => {
1156
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1157
+
1158
+ // Establish the conversation
1159
+ const initReq = makeInboundRequest({ content: 'init' });
1160
+ const orchestrator = makeMockOrchestrator();
1161
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1162
+
1163
+ const db = getDb();
1164
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
1165
+ const conversationId = events[0]?.conversation_id;
1166
+ ensureConversation(conversationId!);
1167
+
1168
+ // Create a pending run
1169
+ const run = createRun(conversationId!);
1170
+ setRunConfirmation(run.id, sampleConfirmation);
1171
+
1172
+ // Add a mock assistant message so that deliverReplyViaCallback can find
1173
+ // the final reply to deliver.
1174
+ conversationStore.addMessage(conversationId!, 'assistant', 'Here is your result.');
1175
+
1176
+ // Now create a second orchestrator that simulates the run completing after
1177
+ // the decision is applied (getRun returns completed after first call).
1178
+ let getRunCallCount = 0;
1179
+ const lateOrchestrator = {
1180
+ submitDecision: mock(() => 'applied' as const),
1181
+ getRun: mock(() => {
1182
+ getRunCallCount++;
1183
+ // First call returns needs_confirmation (decision just applied, resuming),
1184
+ // subsequent calls return completed (run finished).
1185
+ if (getRunCallCount <= 1) {
1186
+ return {
1187
+ id: run.id,
1188
+ conversationId: conversationId!,
1189
+ messageId: 'user-msg-late',
1190
+ status: 'needs_confirmation' as const,
1191
+ pendingConfirmation: null,
1192
+ pendingSecret: null,
1193
+ inputTokens: 0,
1194
+ outputTokens: 0,
1195
+ estimatedCost: 0,
1196
+ error: null,
1197
+ createdAt: Date.now(),
1198
+ updatedAt: Date.now(),
1199
+ };
1200
+ }
1201
+ return {
1202
+ id: run.id,
1203
+ conversationId: conversationId!,
1204
+ messageId: 'user-msg-late',
1205
+ status: 'completed' as const,
1206
+ pendingConfirmation: null,
1207
+ pendingSecret: null,
1208
+ inputTokens: 0,
1209
+ outputTokens: 0,
1210
+ estimatedCost: 0,
1211
+ error: null,
1212
+ createdAt: Date.now(),
1213
+ updatedAt: Date.now(),
1214
+ };
1215
+ }),
1216
+ startRun: mock(async () => ({
1217
+ id: run.id,
1218
+ conversationId: conversationId!,
1219
+ messageId: 'user-msg-late',
1220
+ status: 'running' as const,
1221
+ pendingConfirmation: null,
1222
+ pendingSecret: null,
1223
+ inputTokens: 0,
1224
+ outputTokens: 0,
1225
+ estimatedCost: 0,
1226
+ error: null,
1227
+ createdAt: Date.now(),
1228
+ updatedAt: Date.now(),
1229
+ })),
1230
+ } as unknown as RunOrchestrator;
1231
+
1232
+ // Clear spy to only track calls from the decision + post-decision path
1233
+ deliverSpy.mockClear();
1234
+
1235
+ // Send an approval decision — this simulates a late approval after the
1236
+ // original poll has already timed out.
1237
+ const req = makeInboundRequest({
1238
+ content: '',
1239
+ callbackData: `apr:${run.id}:approve_once`,
1240
+ });
1241
+
1242
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', lateOrchestrator);
1243
+ const body = await res.json() as Record<string, unknown>;
1244
+
1245
+ expect(body.accepted).toBe(true);
1246
+ expect(body.approval).toBe('decision_applied');
1247
+
1248
+ // Wait for the async post-decision delivery poll to complete.
1249
+ // It polls every 500ms; the run becomes terminal on the second getRun call.
1250
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1251
+
1252
+ // deliverChannelReply should have been called by the post-decision
1253
+ // delivery path (deliverReplyViaCallback uses deliverChannelReply).
1254
+ expect(deliverSpy).toHaveBeenCalled();
1255
+
1256
+ deliverSpy.mockRestore();
1257
+ });
1258
+ });
1259
+
1260
+ // ═══════════════════════════════════════════════════════════════════════════
1261
+ // 13. sourceChannel is passed to orchestrator.startRun (WS-D)
1262
+ // ═══════════════════════════════════════════════════════════════════════════
1263
+
1264
+ describe('sourceChannel passed to orchestrator.startRun', () => {
1265
+ beforeEach(() => {
1266
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1267
+ });
1268
+
1269
+ test('startRun is called with sourceChannel from inbound event', async () => {
1270
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1271
+
1272
+ const mockRun = {
1273
+ id: 'run-channel-test',
1274
+ conversationId: 'conv-1',
1275
+ messageId: 'user-msg-300',
1276
+ status: 'completed' as const,
1277
+ pendingConfirmation: null,
1278
+ pendingSecret: null,
1279
+ inputTokens: 0,
1280
+ outputTokens: 0,
1281
+ estimatedCost: 0,
1282
+ error: null,
1283
+ createdAt: Date.now(),
1284
+ updatedAt: Date.now(),
1285
+ };
1286
+
1287
+ const orchestrator = {
1288
+ submitDecision: mock(() => 'applied' as const),
1289
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
1290
+ startRun: mock(async () => mockRun),
1291
+ } as unknown as RunOrchestrator;
1292
+
1293
+ const req = makeInboundRequest({
1294
+ content: 'test channel pass-through',
1295
+ sourceChannel: 'telegram',
1296
+ });
1297
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1298
+
1299
+ // Wait for the background async to fire
1300
+ await new Promise((resolve) => setTimeout(resolve, 800));
1301
+
1302
+ // Verify startRun was called with the sourceChannel option
1303
+ expect(orchestrator.startRun).toHaveBeenCalled();
1304
+ const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
1305
+ // 4th argument is the options object
1306
+ const options = startRunArgs[3] as { sourceChannel?: string } | undefined;
1307
+ expect(options).toBeDefined();
1308
+ expect(options!.sourceChannel).toBe('telegram');
1309
+
1310
+ deliverSpy.mockRestore();
1311
+ });
1312
+ });
1313
+
1314
+ // ═══════════════════════════════════════════════════════════════════════════
1315
+ // 14. Plain-text fallback surfacing for non-rich channels (WS-E)
1316
+ // ═══════════════════════════════════════════════════════════════════════════
1317
+
1318
+ // ═══════════════════════════════════════════════════════════════════════════
1319
+ // 15. SMS channel approval decisions
1320
+ // ═══════════════════════════════════════════════════════════════════════════
1321
+
1322
+ describe('SMS channel approval decisions', () => {
1323
+ beforeEach(() => {
1324
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1325
+ });
1326
+
1327
+ function makeSmsInboundRequest(overrides: Record<string, unknown> = {}): Request {
1328
+ const body = {
1329
+ sourceChannel: 'sms',
1330
+ externalChatId: 'sms-chat-123',
1331
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
1332
+ content: 'hello',
1333
+ replyCallbackUrl: 'https://gateway.test/deliver',
1334
+ ...overrides,
1335
+ };
1336
+ return new Request('http://localhost/channels/inbound', {
1337
+ method: 'POST',
1338
+ headers: { 'Content-Type': 'application/json' },
1339
+ body: JSON.stringify(body),
1340
+ });
1341
+ }
1342
+
1343
+ test('plain-text "yes" via SMS triggers approve_once decision', async () => {
1344
+ const orchestrator = makeMockOrchestrator();
1345
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1346
+
1347
+ // Establish the conversation via SMS
1348
+ const initReq = makeSmsInboundRequest({ content: 'init' });
1349
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1350
+
1351
+ const db = getDb();
1352
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
1353
+ const conversationId = events[events.length - 1]?.conversation_id;
1354
+ ensureConversation(conversationId!);
1355
+
1356
+ const run = createRun(conversationId!);
1357
+ setRunConfirmation(run.id, sampleConfirmation);
1358
+
1359
+ const req = makeSmsInboundRequest({ content: 'yes' });
1360
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1361
+ const body = await res.json() as Record<string, unknown>;
1362
+
1363
+ expect(body.accepted).toBe(true);
1364
+ expect(body.approval).toBe('decision_applied');
1365
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
1366
+
1367
+ deliverSpy.mockRestore();
1368
+ });
1369
+
1370
+ test('plain-text "no" via SMS triggers reject decision', async () => {
1371
+ const orchestrator = makeMockOrchestrator();
1372
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1373
+
1374
+ const initReq = makeSmsInboundRequest({ content: 'init' });
1375
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1376
+
1377
+ const db = getDb();
1378
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
1379
+ const conversationId = events[events.length - 1]?.conversation_id;
1380
+ ensureConversation(conversationId!);
1381
+
1382
+ const run = createRun(conversationId!);
1383
+ setRunConfirmation(run.id, sampleConfirmation);
1384
+
1385
+ const req = makeSmsInboundRequest({ content: 'no' });
1386
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1387
+ const body = await res.json() as Record<string, unknown>;
1388
+
1389
+ expect(body.accepted).toBe(true);
1390
+ expect(body.approval).toBe('decision_applied');
1391
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
1392
+
1393
+ deliverSpy.mockRestore();
1394
+ });
1395
+
1396
+ test('non-decision SMS message during pending approval triggers reminder with plain-text fallback', async () => {
1397
+ const orchestrator = makeMockOrchestrator();
1398
+ const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1399
+ const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1400
+
1401
+ const initReq = makeSmsInboundRequest({ content: 'init' });
1402
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1403
+
1404
+ const db = getDb();
1405
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
1406
+ const conversationId = events[events.length - 1]?.conversation_id;
1407
+ ensureConversation(conversationId!);
1408
+
1409
+ const run = createRun(conversationId!);
1410
+ setRunConfirmation(run.id, sampleConfirmation);
1411
+
1412
+ const req = makeSmsInboundRequest({ content: 'what is happening?' });
1413
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1414
+ const body = await res.json() as Record<string, unknown>;
1415
+
1416
+ expect(body.accepted).toBe(true);
1417
+ expect(body.approval).toBe('reminder_sent');
1418
+
1419
+ // SMS is a non-rich channel so the delivered text should include plain-text fallback
1420
+ expect(deliverSpy).toHaveBeenCalled();
1421
+ const callArgs = deliverSpy.mock.calls[0];
1422
+ const deliveredText = callArgs[2] as string;
1423
+ expect(deliveredText).toContain("I'm still waiting");
1424
+ expect(deliveredText).toContain('Reply "yes"');
1425
+
1426
+ deliverSpy.mockRestore();
1427
+ replySpy.mockRestore();
1428
+ });
1429
+
1430
+ test('sourceChannel "sms" is passed to orchestrator.startRun', async () => {
1431
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1432
+
1433
+ const mockRun = {
1434
+ id: 'run-sms-channel-test',
1435
+ conversationId: 'conv-1',
1436
+ messageId: 'user-msg-sms',
1437
+ status: 'completed' as const,
1438
+ pendingConfirmation: null,
1439
+ pendingSecret: null,
1440
+ inputTokens: 0,
1441
+ outputTokens: 0,
1442
+ estimatedCost: 0,
1443
+ error: null,
1444
+ createdAt: Date.now(),
1445
+ updatedAt: Date.now(),
1446
+ };
1447
+
1448
+ const orchestrator = {
1449
+ submitDecision: mock(() => 'applied' as const),
1450
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
1451
+ startRun: mock(async () => mockRun),
1452
+ } as unknown as RunOrchestrator;
1453
+
1454
+ const req = makeSmsInboundRequest({ content: 'test sms channel pass-through' });
1455
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1456
+
1457
+ // Wait for the background async to fire
1458
+ await new Promise((resolve) => setTimeout(resolve, 800));
1459
+
1460
+ expect(orchestrator.startRun).toHaveBeenCalled();
1461
+ const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
1462
+ const options = startRunArgs[3] as { sourceChannel?: string } | undefined;
1463
+ expect(options).toBeDefined();
1464
+ expect(options!.sourceChannel).toBe('sms');
1465
+
1466
+ deliverSpy.mockRestore();
1467
+ });
1468
+ });
1469
+
1470
+ // ═══════════════════════════════════════════════════════════════════════════
1471
+ // 16. SMS guardian verify intercept
1472
+ // ═══════════════════════════════════════════════════════════════════════════
1473
+
1474
+ describe('SMS guardian verify intercept', () => {
1475
+ test('/guardian_verify command works with sourceChannel sms', async () => {
1476
+ // Set up a guardian verification challenge for SMS
1477
+ const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
1478
+ const { secret } = createVerificationChallenge('self', 'sms');
1479
+
1480
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1481
+
1482
+ const req = new Request('http://localhost/channels/inbound', {
1483
+ method: 'POST',
1484
+ headers: { 'Content-Type': 'application/json' },
1485
+ body: JSON.stringify({
1486
+ sourceChannel: 'sms',
1487
+ externalChatId: 'sms-chat-verify',
1488
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
1489
+ content: `/guardian_verify ${secret}`,
1490
+ senderExternalUserId: 'sms-user-42',
1491
+ replyCallbackUrl: 'https://gateway.test/deliver',
1492
+ }),
1493
+ });
1494
+
1495
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token');
1496
+ const body = await res.json() as Record<string, unknown>;
1497
+
1498
+ expect(body.accepted).toBe(true);
1499
+ expect(body.guardianVerification).toBe('verified');
1500
+
1501
+ // Verify the reply was delivered
1502
+ expect(deliverSpy).toHaveBeenCalled();
1503
+ const replyArgs = deliverSpy.mock.calls[0];
1504
+ const replyPayload = replyArgs[1] as { chatId: string; text: string };
1505
+ expect(replyPayload.chatId).toBe('sms-chat-verify');
1506
+ expect(replyPayload.text).toContain('Guardian verified successfully');
1507
+
1508
+ deliverSpy.mockRestore();
1509
+ });
1510
+
1511
+ test('/guardian_verify with invalid token returns failed via SMS', async () => {
1512
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1513
+
1514
+ const req = new Request('http://localhost/channels/inbound', {
1515
+ method: 'POST',
1516
+ headers: { 'Content-Type': 'application/json' },
1517
+ body: JSON.stringify({
1518
+ sourceChannel: 'sms',
1519
+ externalChatId: 'sms-chat-verify-fail',
1520
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
1521
+ content: '/guardian_verify invalid-token-here',
1522
+ senderExternalUserId: 'sms-user-43',
1523
+ replyCallbackUrl: 'https://gateway.test/deliver',
1524
+ }),
1525
+ });
1526
+
1527
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token');
1528
+ const body = await res.json() as Record<string, unknown>;
1529
+
1530
+ expect(body.accepted).toBe(true);
1531
+ expect(body.guardianVerification).toBe('failed');
1532
+
1533
+ expect(deliverSpy).toHaveBeenCalled();
1534
+ const replyArgs = deliverSpy.mock.calls[0];
1535
+ const replyPayload = replyArgs[1] as { chatId: string; text: string };
1536
+ expect(replyPayload.text).toContain('Verification failed');
1537
+
1538
+ deliverSpy.mockRestore();
1539
+ });
1540
+ });
1541
+
1542
+ // ═══════════════════════════════════════════════════════════════════════════
1543
+ // 17. SMS non-guardian actor gating
1544
+ // ═══════════════════════════════════════════════════════════════════════════
1545
+
1546
+ describe('SMS non-guardian actor gating', () => {
1547
+ beforeEach(() => {
1548
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1549
+ });
1550
+
1551
+ test('non-guardian SMS actor gets stricter controls when guardian binding exists', async () => {
1552
+ // Create a guardian binding for the sms channel
1553
+ const { createBinding } = await import('../memory/channel-guardian-store.js');
1554
+ createBinding({
1555
+ assistantId: 'self',
1556
+ channel: 'sms',
1557
+ guardianExternalUserId: 'sms-guardian-user',
1558
+ guardianDeliveryChatId: 'sms-guardian-chat',
1559
+ });
1560
+
1561
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1562
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1563
+
1564
+ const mockRun = {
1565
+ id: 'run-sms-nongrd',
1566
+ conversationId: 'conv-1',
1567
+ messageId: 'user-msg-sms-nongrd',
1568
+ status: 'running' as const,
1569
+ pendingConfirmation: null,
1570
+ pendingSecret: null,
1571
+ inputTokens: 0,
1572
+ outputTokens: 0,
1573
+ estimatedCost: 0,
1574
+ error: null,
1575
+ createdAt: Date.now(),
1576
+ updatedAt: Date.now(),
1577
+ };
1578
+
1579
+ const orchestrator = {
1580
+ submitDecision: mock(() => 'applied' as const),
1581
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
1582
+ startRun: mock(async () => mockRun),
1583
+ } as unknown as RunOrchestrator;
1584
+
1585
+ // Send message from a NON-guardian sms user
1586
+ const req = new Request('http://localhost/channels/inbound', {
1587
+ method: 'POST',
1588
+ headers: { 'Content-Type': 'application/json' },
1589
+ body: JSON.stringify({
1590
+ sourceChannel: 'sms',
1591
+ externalChatId: 'sms-other-chat',
1592
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
1593
+ content: 'do something',
1594
+ senderExternalUserId: 'sms-other-user',
1595
+ replyCallbackUrl: 'https://gateway.test/deliver',
1596
+ }),
1597
+ });
1598
+
1599
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1600
+
1601
+ // Wait for the background async to fire
1602
+ await new Promise((resolve) => setTimeout(resolve, 800));
1603
+
1604
+ // startRun should have been called with forceStrictSideEffects for non-guardian
1605
+ expect(orchestrator.startRun).toHaveBeenCalled();
1606
+ const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
1607
+ const options = startRunArgs[3] as { forceStrictSideEffects?: boolean; sourceChannel?: string } | undefined;
1608
+ expect(options).toBeDefined();
1609
+ expect(options!.forceStrictSideEffects).toBe(true);
1610
+ expect(options!.sourceChannel).toBe('sms');
1611
+
1612
+ deliverSpy.mockRestore();
1613
+ approvalSpy.mockRestore();
1614
+ });
1615
+ });
1616
+
1617
+ describe('plain-text fallback surfacing for non-rich channels', () => {
1618
+ beforeEach(() => {
1619
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1620
+ });
1621
+
1622
+ test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
1623
+ const orchestrator = makeMockOrchestrator();
1624
+ const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1625
+ const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1626
+
1627
+ // Establish the conversation using http-api (non-rich channel)
1628
+ const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'http-api' });
1629
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1630
+
1631
+ const db = getDb();
1632
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
1633
+ const conversationId = events[0]?.conversation_id;
1634
+ ensureConversation(conversationId!);
1635
+
1636
+ const run = createRun(conversationId!);
1637
+ setRunConfirmation(run.id, sampleConfirmation);
1638
+
1639
+ // Send a non-decision message to trigger a reminder
1640
+ const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'http-api' });
1641
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1642
+ const body = await res.json() as Record<string, unknown>;
1643
+
1644
+ expect(body.accepted).toBe(true);
1645
+ expect(body.approval).toBe('reminder_sent');
1646
+
1647
+ // The delivered text should include the plainTextFallback instructions
1648
+ expect(deliverSpy).toHaveBeenCalled();
1649
+ const callArgs = deliverSpy.mock.calls[0];
1650
+ const deliveredText = callArgs[2] as string;
1651
+ // For non-rich channels, the text should contain both the reminder prefix
1652
+ // AND the plainTextFallback instructions (e.g. "Reply yes to approve")
1653
+ expect(deliveredText).toContain("I'm still waiting");
1654
+ expect(deliveredText).toContain('Reply "yes"');
1655
+
1656
+ deliverSpy.mockRestore();
1657
+ replySpy.mockRestore();
1658
+ });
1659
+
1660
+ test('reminder prompt does NOT include plainTextFallback for telegram (rich channel)', async () => {
1661
+ const orchestrator = makeMockOrchestrator();
1662
+ const deliverSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1663
+ const replySpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1664
+
1665
+ // Establish the conversation using telegram (rich channel)
1666
+ const initReq = makeInboundRequest({ content: 'init', sourceChannel: 'telegram' });
1667
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1668
+
1669
+ const db = getDb();
1670
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
1671
+ const conversationId = events[0]?.conversation_id;
1672
+ ensureConversation(conversationId!);
1673
+
1674
+ const run = createRun(conversationId!);
1675
+ setRunConfirmation(run.id, sampleConfirmation);
1676
+
1677
+ // Send a non-decision message to trigger a reminder
1678
+ const req = makeInboundRequest({ content: 'what is happening?', sourceChannel: 'telegram' });
1679
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1680
+ const body = await res.json() as Record<string, unknown>;
1681
+
1682
+ expect(body.accepted).toBe(true);
1683
+ expect(body.approval).toBe('reminder_sent');
1684
+
1685
+ // For rich channels (telegram), the delivered text should be just the
1686
+ // promptText (with reminder prefix) — NOT the plainTextFallback.
1687
+ expect(deliverSpy).toHaveBeenCalled();
1688
+ const callArgs = deliverSpy.mock.calls[0];
1689
+ const deliveredText = callArgs[2] as string;
1690
+ expect(deliveredText).toContain("I'm still waiting");
1691
+ // The raw promptText does not contain "Reply" instructions — those are
1692
+ // only in the plainTextFallback.
1693
+ expect(deliveredText).not.toContain('Reply "yes"');
1694
+
1695
+ deliverSpy.mockRestore();
1696
+ replySpy.mockRestore();
1697
+ });
1698
+ });
1699
+
1700
+ // ---------------------------------------------------------------------------
1701
+ // Helper: orchestrator that creates real DB runs with pending confirmations
1702
+ // ---------------------------------------------------------------------------
1703
+
1704
+ function makeSensitiveOrchestrator(opts: {
1705
+ runId: string;
1706
+ terminalStatus: 'completed' | 'failed';
1707
+ }): RunOrchestrator & { realRunId: () => string | undefined } {
1708
+ let realRunId: string | undefined;
1709
+ let pollCount = 0;
1710
+ return {
1711
+ submitDecision: mock(() => 'applied' as const),
1712
+ getRun: mock(() => {
1713
+ pollCount++;
1714
+ if (pollCount === 1 && realRunId) {
1715
+ return {
1716
+ id: realRunId,
1717
+ conversationId: 'conv-1',
1718
+ messageId: null,
1719
+ status: 'needs_confirmation' as const,
1720
+ pendingConfirmation: sampleConfirmation,
1721
+ pendingSecret: null,
1722
+ inputTokens: 0,
1723
+ outputTokens: 0,
1724
+ estimatedCost: 0,
1725
+ error: null,
1726
+ createdAt: Date.now(),
1727
+ updatedAt: Date.now(),
1728
+ };
1729
+ }
1730
+ return {
1731
+ id: realRunId ?? opts.runId,
1732
+ conversationId: 'conv-1',
1733
+ messageId: null,
1734
+ status: opts.terminalStatus,
1735
+ pendingConfirmation: null,
1736
+ pendingSecret: null,
1737
+ inputTokens: 0,
1738
+ outputTokens: 0,
1739
+ estimatedCost: 0,
1740
+ error: null,
1741
+ createdAt: Date.now(),
1742
+ updatedAt: Date.now(),
1743
+ };
1744
+ }),
1745
+ startRun: mock(async (conversationId: string) => {
1746
+ ensureConversation(conversationId);
1747
+ const run = createRun(conversationId);
1748
+ setRunConfirmation(run.id, sampleConfirmation);
1749
+ realRunId = run.id;
1750
+ return {
1751
+ id: run.id,
1752
+ conversationId,
1753
+ messageId: null,
1754
+ status: 'running' as const,
1755
+ pendingConfirmation: null,
1756
+ pendingSecret: null,
1757
+ inputTokens: 0,
1758
+ outputTokens: 0,
1759
+ estimatedCost: 0,
1760
+ error: null,
1761
+ createdAt: Date.now(),
1762
+ updatedAt: Date.now(),
1763
+ };
1764
+ }),
1765
+ realRunId: () => realRunId,
1766
+ } as unknown as RunOrchestrator & { realRunId: () => string | undefined };
1767
+ }
1768
+
1769
+ // ═══════════════════════════════════════════════════════════════════════════
1770
+ // 18. Fail-closed guardian gate (WS-1)
1771
+ // ═══════════════════════════════════════════════════════════════════════════
1772
+
1773
+ describe('fail-closed guardian gate — unverified channel', () => {
1774
+ beforeEach(() => {
1775
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1776
+ });
1777
+
1778
+ test('no binding + sensitive action → auto-deny and setup notice', async () => {
1779
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1780
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1781
+
1782
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-unv-1', terminalStatus: 'failed' });
1783
+
1784
+ // Non-guardian sender, no binding exists → unverified_channel
1785
+ const req = makeInboundRequest({
1786
+ content: 'do something dangerous',
1787
+ senderExternalUserId: 'user-no-binding',
1788
+ });
1789
+
1790
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1791
+ await new Promise((resolve) => setTimeout(resolve, 1200));
1792
+
1793
+ // The run should have been denied (submitDecision called with deny)
1794
+ expect(orchestrator.submitDecision).toHaveBeenCalled();
1795
+ const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
1796
+ expect(decisionArgs[1]).toBe('deny');
1797
+
1798
+ // The requester should have been notified about missing guardian setup
1799
+ const replyCalls = deliverSpy.mock.calls.filter(
1800
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
1801
+ );
1802
+ expect(replyCalls.length).toBeGreaterThanOrEqual(1);
1803
+
1804
+ // No approval prompt should have been sent to a guardian (none exists)
1805
+ expect(approvalSpy).not.toHaveBeenCalled();
1806
+
1807
+ deliverSpy.mockRestore();
1808
+ approvalSpy.mockRestore();
1809
+ });
1810
+
1811
+ test('no binding + non-sensitive action → completes normally', async () => {
1812
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1813
+
1814
+ // Orchestrator that completes without hitting needs_confirmation
1815
+ const mockRun = {
1816
+ id: 'run-unv-safe',
1817
+ conversationId: 'conv-1',
1818
+ messageId: null,
1819
+ status: 'running' as const,
1820
+ pendingConfirmation: null,
1821
+ pendingSecret: null,
1822
+ inputTokens: 0,
1823
+ outputTokens: 0,
1824
+ estimatedCost: 0,
1825
+ error: null,
1826
+ createdAt: Date.now(),
1827
+ updatedAt: Date.now(),
1828
+ };
1829
+
1830
+ const orchestrator = {
1831
+ submitDecision: mock(() => 'applied' as const),
1832
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
1833
+ startRun: mock(async () => mockRun),
1834
+ } as unknown as RunOrchestrator;
1835
+
1836
+ const req = makeInboundRequest({
1837
+ content: 'what time is it',
1838
+ senderExternalUserId: 'user-no-binding',
1839
+ });
1840
+
1841
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1842
+ const body = await res.json() as Record<string, unknown>;
1843
+
1844
+ expect(body.accepted).toBe(true);
1845
+ await new Promise((resolve) => setTimeout(resolve, 800));
1846
+
1847
+ // submitDecision should NOT have been called — no confirmation needed
1848
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
1849
+
1850
+ deliverSpy.mockRestore();
1851
+ });
1852
+
1853
+ test('unverified channel cannot self-approve via interception', async () => {
1854
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1855
+
1856
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-unv-self', terminalStatus: 'failed' });
1857
+
1858
+ // First, send a message to establish the conversation and trigger
1859
+ // the sensitive action (which will be auto-denied in the poll loop).
1860
+ const initReq = makeInboundRequest({
1861
+ content: 'do something',
1862
+ senderExternalUserId: 'user-no-binding',
1863
+ });
1864
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
1865
+ await new Promise((resolve) => setTimeout(resolve, 1200));
1866
+
1867
+ // Now find the conversation
1868
+ const db = getDb();
1869
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
1870
+ const conversationId = events[0]?.conversation_id;
1871
+ ensureConversation(conversationId!);
1872
+
1873
+ // Create another pending run in this conversation
1874
+ const run2 = createRun(conversationId!);
1875
+ setRunConfirmation(run2.id, sampleConfirmation);
1876
+
1877
+ deliverSpy.mockClear();
1878
+
1879
+ // Try to self-approve
1880
+ const approveReq = makeInboundRequest({
1881
+ content: 'approve',
1882
+ senderExternalUserId: 'user-no-binding',
1883
+ });
1884
+
1885
+ const res = await handleChannelInbound(approveReq, noopProcessMessage, 'token', orchestrator);
1886
+ const body = await res.json() as Record<string, unknown>;
1887
+
1888
+ expect(body.accepted).toBe(true);
1889
+ expect(body.approval).toBe('decision_applied');
1890
+
1891
+ // The denial notice should have been sent
1892
+ const denialCalls = deliverSpy.mock.calls.filter(
1893
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
1894
+ );
1895
+ expect(denialCalls.length).toBeGreaterThanOrEqual(1);
1896
+
1897
+ deliverSpy.mockRestore();
1898
+ });
1899
+ });
1900
+
1901
+ // ═══════════════════════════════════════════════════════════════════════════
1902
+ // 19. Guardian-with-binding path regression
1903
+ // ═══════════════════════════════════════════════════════════════════════════
1904
+
1905
+ describe('guardian-with-binding path regression', () => {
1906
+ beforeEach(() => {
1907
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1908
+ });
1909
+
1910
+ test('non-guardian with binding routes approval to guardian chat', async () => {
1911
+ createBinding({
1912
+ assistantId: 'self',
1913
+ channel: 'telegram',
1914
+ guardianExternalUserId: 'guardian-user-1',
1915
+ guardianDeliveryChatId: 'guardian-chat-1',
1916
+ });
1917
+
1918
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1919
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1920
+
1921
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-binding-1', terminalStatus: 'completed' });
1922
+
1923
+ const req = makeInboundRequest({
1924
+ content: 'do something dangerous',
1925
+ senderExternalUserId: 'non-guardian-user',
1926
+ senderUsername: 'nongrd',
1927
+ });
1928
+
1929
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1930
+ await new Promise((resolve) => setTimeout(resolve, 1200));
1931
+
1932
+ // Approval prompt should have been sent to the guardian's chat
1933
+ expect(approvalSpy).toHaveBeenCalled();
1934
+ const approvalArgs = approvalSpy.mock.calls[0];
1935
+ expect(approvalArgs[1]).toBe('guardian-chat-1');
1936
+
1937
+ // Requester should have been notified the request was sent to the guardian
1938
+ const notifyCalls = deliverSpy.mock.calls.filter(
1939
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
1940
+ );
1941
+ expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
1942
+
1943
+ deliverSpy.mockRestore();
1944
+ approvalSpy.mockRestore();
1945
+ });
1946
+
1947
+ test('guardian sender gets standard self-approval flow', async () => {
1948
+ createBinding({
1949
+ assistantId: 'self',
1950
+ channel: 'telegram',
1951
+ guardianExternalUserId: 'guardian-user-2',
1952
+ guardianDeliveryChatId: 'guardian-chat-2',
1953
+ });
1954
+
1955
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1956
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
1957
+
1958
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-binding-2', terminalStatus: 'completed' });
1959
+
1960
+ // Message from the guardian user — should get standard approval prompt
1961
+ const req = makeInboundRequest({
1962
+ content: 'do something dangerous',
1963
+ senderExternalUserId: 'guardian-user-2',
1964
+ });
1965
+
1966
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1967
+ await new Promise((resolve) => setTimeout(resolve, 1200));
1968
+
1969
+ // Approval prompt should have been sent to the requester's own chat
1970
+ // (standard self-approval flow, not routed to guardian)
1971
+ expect(approvalSpy).toHaveBeenCalled();
1972
+ const approvalArgs = approvalSpy.mock.calls[0];
1973
+ // The chat ID should be the sender's own chat, not guardian-chat-2
1974
+ expect(approvalArgs[1]).toBe('chat-123');
1975
+
1976
+ deliverSpy.mockRestore();
1977
+ approvalSpy.mockRestore();
1978
+ });
1979
+ });
1980
+
1981
+ // ═══════════════════════════════════════════════════════════════════════════
1982
+ // 20. Guardian delivery failure denial (WS-2)
1983
+ // ═══════════════════════════════════════════════════════════════════════════
1984
+
1985
+ describe('guardian delivery failure → denial', () => {
1986
+ beforeEach(() => {
1987
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
1988
+ });
1989
+
1990
+ test('delivery failure denies run and notifies requester', async () => {
1991
+ createBinding({
1992
+ assistantId: 'self',
1993
+ channel: 'telegram',
1994
+ guardianExternalUserId: 'guardian-user-df',
1995
+ guardianDeliveryChatId: 'guardian-chat-df',
1996
+ });
1997
+
1998
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
1999
+ // Make the guardian approval prompt delivery fail
2000
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
2001
+ new Error('Network error: guardian unreachable'),
2002
+ );
2003
+
2004
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-df-1', terminalStatus: 'failed' });
2005
+
2006
+ const req = makeInboundRequest({
2007
+ content: 'do something dangerous',
2008
+ senderExternalUserId: 'non-guardian-df-user',
2009
+ senderUsername: 'nongrd_df',
2010
+ });
2011
+
2012
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2013
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2014
+
2015
+ // The run should have been denied
2016
+ expect(orchestrator.submitDecision).toHaveBeenCalled();
2017
+ const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
2018
+ expect(decisionArgs[1]).toBe('deny');
2019
+
2020
+ // Requester should have been notified that delivery failed
2021
+ const failureCalls = deliverSpy.mock.calls.filter(
2022
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('could not be sent to the guardian for approval'),
2023
+ );
2024
+ expect(failureCalls.length).toBeGreaterThanOrEqual(1);
2025
+
2026
+ // The "has been sent to the guardian for approval" success notice should
2027
+ // NOT have been delivered (since delivery failed).
2028
+ const successCalls = deliverSpy.mock.calls.filter(
2029
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('has been sent to the guardian for approval'),
2030
+ );
2031
+ expect(successCalls.length).toBe(0);
2032
+
2033
+ deliverSpy.mockRestore();
2034
+ approvalSpy.mockRestore();
2035
+ });
2036
+
2037
+ test('no pending/unresolved approvals remain after delivery failure', async () => {
2038
+ createBinding({
2039
+ assistantId: 'self',
2040
+ channel: 'telegram',
2041
+ guardianExternalUserId: 'guardian-user-df2',
2042
+ guardianDeliveryChatId: 'guardian-chat-df2',
2043
+ });
2044
+
2045
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2046
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
2047
+ new Error('Network error: guardian unreachable'),
2048
+ );
2049
+
2050
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-df-2', terminalStatus: 'failed' });
2051
+
2052
+ const req = makeInboundRequest({
2053
+ content: 'do something dangerous',
2054
+ senderExternalUserId: 'non-guardian-df2-user',
2055
+ });
2056
+
2057
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2058
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2059
+
2060
+ // Verify the run ID was created
2061
+ const runId = orchestrator.realRunId();
2062
+ expect(runId).toBeTruthy();
2063
+
2064
+ // After delivery failure, there should be NO pending approval for the run
2065
+ const pendingApproval = getPendingApprovalForRun(runId!);
2066
+ expect(pendingApproval).toBeNull();
2067
+
2068
+ // There should also be NO unresolved approval (it was set to 'denied')
2069
+ const unresolvedApproval = getUnresolvedApprovalForRun(runId!);
2070
+ expect(unresolvedApproval).toBeNull();
2071
+
2072
+ deliverSpy.mockRestore();
2073
+ approvalSpy.mockRestore();
2074
+ });
2075
+ });
2076
+
2077
+ // ═══════════════════════════════════════════════════════════════════════════
2078
+ // 21. Guardian decision scoping — callback for older run resolves correctly
2079
+ // ═══════════════════════════════════════════════════════════════════════════
2080
+
2081
+ describe('guardian decision scoping — multiple pending approvals', () => {
2082
+ beforeEach(() => {
2083
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2084
+ });
2085
+
2086
+ test('callback for older run resolves to the correct approval request', async () => {
2087
+ // Set up a guardian binding so the guardian actor role is recognized
2088
+ createBinding({
2089
+ assistantId: 'self',
2090
+ channel: 'telegram',
2091
+ guardianExternalUserId: 'guardian-scope-user',
2092
+ guardianDeliveryChatId: 'guardian-scope-chat',
2093
+ });
2094
+
2095
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2096
+
2097
+ // Create two approval requests for different runs, both targeting the
2098
+ // same guardian chat. The older run (run-older) was created first.
2099
+ const olderConvId = 'conv-scope-older';
2100
+ const newerConvId = 'conv-scope-newer';
2101
+ ensureConversation(olderConvId);
2102
+ ensureConversation(newerConvId);
2103
+
2104
+ const olderRun = createRun(olderConvId);
2105
+ setRunConfirmation(olderRun.id, sampleConfirmation);
2106
+ createApprovalRequest({
2107
+ runId: olderRun.id,
2108
+ conversationId: olderConvId,
2109
+ channel: 'telegram',
2110
+ requesterExternalUserId: 'requester-a',
2111
+ requesterChatId: 'chat-requester-a',
2112
+ guardianExternalUserId: 'guardian-scope-user',
2113
+ guardianChatId: 'guardian-scope-chat',
2114
+ toolName: 'shell',
2115
+ expiresAt: Date.now() + 300_000,
2116
+ });
2117
+
2118
+ const newerRun = createRun(newerConvId);
2119
+ setRunConfirmation(newerRun.id, sampleConfirmation);
2120
+ createApprovalRequest({
2121
+ runId: newerRun.id,
2122
+ conversationId: newerConvId,
2123
+ channel: 'telegram',
2124
+ requesterExternalUserId: 'requester-b',
2125
+ requesterChatId: 'chat-requester-b',
2126
+ guardianExternalUserId: 'guardian-scope-user',
2127
+ guardianChatId: 'guardian-scope-chat',
2128
+ toolName: 'browser',
2129
+ expiresAt: Date.now() + 300_000,
2130
+ });
2131
+
2132
+ const orchestrator = makeMockOrchestrator();
2133
+
2134
+ // The guardian clicks the approval button for the OLDER run
2135
+ const req = makeInboundRequest({
2136
+ content: '',
2137
+ externalChatId: 'guardian-scope-chat',
2138
+ callbackData: `apr:${olderRun.id}:approve_once`,
2139
+ senderExternalUserId: 'guardian-scope-user',
2140
+ });
2141
+
2142
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2143
+ const body = await res.json() as Record<string, unknown>;
2144
+
2145
+ expect(body.accepted).toBe(true);
2146
+ expect(body.approval).toBe('guardian_decision_applied');
2147
+
2148
+ // The older run's approval should have been resolved
2149
+ const olderApproval = getPendingApprovalForRun(olderRun.id);
2150
+ expect(olderApproval).toBeNull();
2151
+
2152
+ // The newer run's approval should still be pending (untouched)
2153
+ const newerApproval = getPendingApprovalForRun(newerRun.id);
2154
+ expect(newerApproval).not.toBeNull();
2155
+ expect(newerApproval!.status).toBe('pending');
2156
+
2157
+ // Verify the decision was applied to the correct (older) run's conversation
2158
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(olderRun.id, 'allow');
2159
+
2160
+ deliverSpy.mockRestore();
2161
+ });
2162
+ });
2163
+
2164
+ // ═══════════════════════════════════════════════════════════════════════════
2165
+ // 22. Ambiguous plain-text decision with multiple pending requests
2166
+ // ═══════════════════════════════════════════════════════════════════════════
2167
+
2168
+ describe('ambiguous plain-text decision with multiple pending requests', () => {
2169
+ beforeEach(() => {
2170
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2171
+ });
2172
+
2173
+ test('does not apply plain-text decision to wrong run when multiple pending', async () => {
2174
+ createBinding({
2175
+ assistantId: 'self',
2176
+ channel: 'telegram',
2177
+ guardianExternalUserId: 'guardian-ambig-user',
2178
+ guardianDeliveryChatId: 'guardian-ambig-chat',
2179
+ });
2180
+
2181
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2182
+
2183
+ // Create two pending approval requests targeting the same guardian chat
2184
+ const convA = 'conv-ambig-a';
2185
+ const convB = 'conv-ambig-b';
2186
+ ensureConversation(convA);
2187
+ ensureConversation(convB);
2188
+
2189
+ const runA = createRun(convA);
2190
+ setRunConfirmation(runA.id, sampleConfirmation);
2191
+ createApprovalRequest({
2192
+ runId: runA.id,
2193
+ conversationId: convA,
2194
+ channel: 'telegram',
2195
+ requesterExternalUserId: 'requester-x',
2196
+ requesterChatId: 'chat-requester-x',
2197
+ guardianExternalUserId: 'guardian-ambig-user',
2198
+ guardianChatId: 'guardian-ambig-chat',
2199
+ toolName: 'shell',
2200
+ expiresAt: Date.now() + 300_000,
2201
+ });
2202
+
2203
+ const runB = createRun(convB);
2204
+ setRunConfirmation(runB.id, sampleConfirmation);
2205
+ createApprovalRequest({
2206
+ runId: runB.id,
2207
+ conversationId: convB,
2208
+ channel: 'telegram',
2209
+ requesterExternalUserId: 'requester-y',
2210
+ requesterChatId: 'chat-requester-y',
2211
+ guardianExternalUserId: 'guardian-ambig-user',
2212
+ guardianChatId: 'guardian-ambig-chat',
2213
+ toolName: 'browser',
2214
+ expiresAt: Date.now() + 300_000,
2215
+ });
2216
+
2217
+ const orchestrator = makeMockOrchestrator();
2218
+
2219
+ // Guardian sends plain-text "yes" — ambiguous because two approvals are pending
2220
+ const req = makeInboundRequest({
2221
+ content: 'yes',
2222
+ externalChatId: 'guardian-ambig-chat',
2223
+ senderExternalUserId: 'guardian-ambig-user',
2224
+ });
2225
+
2226
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2227
+ const body = await res.json() as Record<string, unknown>;
2228
+
2229
+ expect(body.accepted).toBe(true);
2230
+ expect(body.approval).toBe('guardian_decision_applied');
2231
+
2232
+ // Neither approval should have been resolved — disambiguation was required
2233
+ const approvalA = getPendingApprovalForRun(runA.id);
2234
+ const approvalB = getPendingApprovalForRun(runB.id);
2235
+ expect(approvalA).not.toBeNull();
2236
+ expect(approvalB).not.toBeNull();
2237
+
2238
+ // submitDecision should NOT have been called — no decision was applied
2239
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2240
+
2241
+ // A disambiguation message should have been sent to the guardian
2242
+ const disambigCalls = deliverSpy.mock.calls.filter(
2243
+ (call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending approval requests'),
2244
+ );
2245
+ expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
2246
+
2247
+ deliverSpy.mockRestore();
2248
+ });
2249
+ });
2250
+
2251
+ // ═══════════════════════════════════════════════════════════════════════════
2252
+ // 23. Expired guardian approval auto-denies and transitions to terminal status
2253
+ // ═══════════════════════════════════════════════════════════════════════════
2254
+
2255
+ describe('expired guardian approval auto-denies via sweep', () => {
2256
+ beforeEach(() => {
2257
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2258
+ });
2259
+
2260
+ test('sweepExpiredGuardianApprovals auto-denies and notifies both parties', async () => {
2261
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2262
+
2263
+ // Create a guardian approval that is already expired
2264
+ const convId = 'conv-expiry-sweep';
2265
+ ensureConversation(convId);
2266
+
2267
+ const run = createRun(convId);
2268
+ setRunConfirmation(run.id, sampleConfirmation);
2269
+
2270
+ createApprovalRequest({
2271
+ runId: run.id,
2272
+ conversationId: convId,
2273
+ channel: 'telegram',
2274
+ requesterExternalUserId: 'requester-exp',
2275
+ requesterChatId: 'chat-requester-exp',
2276
+ guardianExternalUserId: 'guardian-exp-user',
2277
+ guardianChatId: 'guardian-exp-chat',
2278
+ toolName: 'shell',
2279
+ expiresAt: Date.now() - 1000, // already expired
2280
+ });
2281
+
2282
+ const orchestrator = makeMockOrchestrator();
2283
+
2284
+ // Run the sweep
2285
+ sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test/deliver', 'token');
2286
+
2287
+ // Wait for async notifications
2288
+ await new Promise((resolve) => setTimeout(resolve, 200));
2289
+
2290
+ // The run should have been denied
2291
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
2292
+
2293
+ // The approval should no longer be pending
2294
+ const pendingApproval = getPendingApprovalForRun(run.id);
2295
+ expect(pendingApproval).toBeNull();
2296
+
2297
+ // There should be no unresolved approval — it was set to 'expired'
2298
+ const unresolvedApproval = getUnresolvedApprovalForRun(run.id);
2299
+ expect(unresolvedApproval).toBeNull();
2300
+
2301
+ // Both requester and guardian should have been notified
2302
+ const requesterNotify = deliverSpy.mock.calls.filter(
2303
+ (call) => typeof call[1] === 'object' &&
2304
+ (call[1] as { chatId?: string }).chatId === 'chat-requester-exp' &&
2305
+ (call[1] as { text?: string }).text?.includes('expired'),
2306
+ );
2307
+ expect(requesterNotify.length).toBeGreaterThanOrEqual(1);
2308
+
2309
+ const guardianNotify = deliverSpy.mock.calls.filter(
2310
+ (call) => typeof call[1] === 'object' &&
2311
+ (call[1] as { chatId?: string }).chatId === 'guardian-exp-chat' &&
2312
+ (call[1] as { text?: string }).text?.includes('expired'),
2313
+ );
2314
+ expect(guardianNotify.length).toBeGreaterThanOrEqual(1);
2315
+
2316
+ deliverSpy.mockRestore();
2317
+ });
2318
+
2319
+ test('non-expired approvals are not affected by the sweep', async () => {
2320
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2321
+
2322
+ const convId = 'conv-not-expired';
2323
+ ensureConversation(convId);
2324
+
2325
+ const run = createRun(convId);
2326
+ setRunConfirmation(run.id, sampleConfirmation);
2327
+
2328
+ createApprovalRequest({
2329
+ runId: run.id,
2330
+ conversationId: convId,
2331
+ channel: 'telegram',
2332
+ requesterExternalUserId: 'requester-ne',
2333
+ requesterChatId: 'chat-requester-ne',
2334
+ guardianExternalUserId: 'guardian-ne-user',
2335
+ guardianChatId: 'guardian-ne-chat',
2336
+ toolName: 'shell',
2337
+ expiresAt: Date.now() + 300_000, // still valid
2338
+ });
2339
+
2340
+ const orchestrator = makeMockOrchestrator();
2341
+
2342
+ sweepExpiredGuardianApprovals(orchestrator, 'https://gateway.test/deliver', 'token');
2343
+
2344
+ await new Promise((resolve) => setTimeout(resolve, 200));
2345
+
2346
+ // The approval should still be pending
2347
+ const pendingApproval = getPendingApprovalForRun(run.id);
2348
+ expect(pendingApproval).not.toBeNull();
2349
+ expect(pendingApproval!.status).toBe('pending');
2350
+
2351
+ // submitDecision should NOT have been called
2352
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
2353
+
2354
+ deliverSpy.mockRestore();
2355
+ });
2356
+ });