@vellumai/assistant 0.6.2 → 0.6.4

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 (895) hide show
  1. package/ARCHITECTURE.md +273 -10
  2. package/Dockerfile +2 -3
  3. package/bun.lock +41 -49
  4. package/bunfig.toml +3 -0
  5. package/docs/architecture/memory.md +1 -1
  6. package/docs/backup-troubleshooting.md +52 -0
  7. package/docs/browser-use-architecture-phase2.md +174 -0
  8. package/docs/stt-provider-onboarding.md +120 -0
  9. package/knip.json +12 -2
  10. package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
  11. package/node_modules/@vellumai/ces-contracts/package.json +3 -3
  12. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +42 -0
  13. package/openapi.yaml +1111 -86
  14. package/package.json +40 -42
  15. package/scripts/generate-openapi.ts +0 -2
  16. package/scripts/test.sh +73 -18
  17. package/src/__tests__/acp-session.test.ts +43 -0
  18. package/src/__tests__/agent-image-optimize.test.ts +28 -0
  19. package/src/__tests__/agent-loop.test.ts +123 -0
  20. package/src/__tests__/anthropic-provider.test.ts +263 -10
  21. package/src/__tests__/app-builder-tool-scripts.test.ts +1 -0
  22. package/src/__tests__/app-executors.test.ts +1 -0
  23. package/src/__tests__/app-source-watcher.test.ts +37 -11
  24. package/src/__tests__/approval-routes-http.test.ts +178 -1
  25. package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
  26. package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
  27. package/src/__tests__/browser-fill-credential.test.ts +240 -94
  28. package/src/__tests__/browser-manager.test.ts +40 -27
  29. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  30. package/src/__tests__/browser-skill-endstate.test.ts +31 -7
  31. package/src/__tests__/btw-routes.test.ts +7 -0
  32. package/src/__tests__/call-controller.test.ts +581 -20
  33. package/src/__tests__/catalog-files.test.ts +1000 -0
  34. package/src/__tests__/channel-approvals.test.ts +53 -0
  35. package/src/__tests__/channel-invite-transport.test.ts +2 -2
  36. package/src/__tests__/channel-readiness-routes.test.ts +16 -20
  37. package/src/__tests__/channel-readiness-service.test.ts +12 -7
  38. package/src/__tests__/checker.test.ts +157 -10
  39. package/src/__tests__/clawhub-files.test.ts +347 -0
  40. package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
  41. package/src/__tests__/config-analysis.test.ts +100 -0
  42. package/src/__tests__/config-managed-gemini-defaults.test.ts +326 -0
  43. package/src/__tests__/config-schema-cmd.test.ts +2 -2
  44. package/src/__tests__/config-schema.test.ts +1248 -224
  45. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
  46. package/src/__tests__/config-watcher.test.ts +43 -8
  47. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +23 -0
  48. package/src/__tests__/contact-store-user-file.test.ts +512 -0
  49. package/src/__tests__/contacts-write.test.ts +197 -0
  50. package/src/__tests__/context-overflow-approval.test.ts +16 -1
  51. package/src/__tests__/context-window-manager.test.ts +88 -0
  52. package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
  53. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -1
  54. package/src/__tests__/conversation-agent-loop.test.ts +99 -3
  55. package/src/__tests__/conversation-analysis-routes.test.ts +2 -2
  56. package/src/__tests__/conversation-attachments.test.ts +80 -4
  57. package/src/__tests__/conversation-confirmation-signals.test.ts +290 -0
  58. package/src/__tests__/conversation-error.test.ts +70 -0
  59. package/src/__tests__/conversation-fork-crud.test.ts +17 -0
  60. package/src/__tests__/conversation-history-web-search.test.ts +12 -4
  61. package/src/__tests__/conversation-host-access-routes.test.ts +229 -0
  62. package/src/__tests__/conversation-init.benchmark.test.ts +6 -1
  63. package/src/__tests__/conversation-inject-context.test.ts +103 -0
  64. package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
  65. package/src/__tests__/conversation-list-source.test.ts +145 -0
  66. package/src/__tests__/conversation-pre-run-repair.test.ts +2 -0
  67. package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
  68. package/src/__tests__/conversation-queue.test.ts +946 -62
  69. package/src/__tests__/conversation-routes-disk-view.test.ts +275 -0
  70. package/src/__tests__/conversation-routes-guardian-reply.test.ts +16 -0
  71. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  72. package/src/__tests__/conversation-runtime-assembly.test.ts +324 -46
  73. package/src/__tests__/conversation-skill-tools.test.ts +7 -4
  74. package/src/__tests__/conversation-slash-commands.test.ts +33 -0
  75. package/src/__tests__/conversation-slash-queue.test.ts +89 -18
  76. package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
  77. package/src/__tests__/conversation-starter-routes.test.ts +126 -0
  78. package/src/__tests__/conversation-starters-cadence.test.ts +161 -0
  79. package/src/__tests__/conversation-store.test.ts +195 -0
  80. package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
  81. package/src/__tests__/conversation-workspace-cache-state.test.ts +193 -0
  82. package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
  83. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
  84. package/src/__tests__/credential-execution-approval-bridge.test.ts +32 -1
  85. package/src/__tests__/credential-health-service.test.ts +352 -0
  86. package/src/__tests__/credential-security-invariants.test.ts +6 -3
  87. package/src/__tests__/credential-vault-unit.test.ts +383 -7
  88. package/src/__tests__/credential-vault.test.ts +152 -13
  89. package/src/__tests__/credentials-cli.test.ts +42 -18
  90. package/src/__tests__/cross-provider-web-search.test.ts +146 -35
  91. package/src/__tests__/date-context.test.ts +4 -4
  92. package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
  93. package/src/__tests__/device-id.test.ts +112 -0
  94. package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
  95. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
  96. package/src/__tests__/email-html-renderer.test.ts +71 -0
  97. package/src/__tests__/email-invite-adapter.test.ts +36 -32
  98. package/src/__tests__/embedding-managed-proxy-selection.test.ts +256 -0
  99. package/src/__tests__/emit-event-signal.test.ts +71 -0
  100. package/src/__tests__/extension-id-sync-guard.test.ts +222 -0
  101. package/src/__tests__/fixtures/mock-chrome-extension.ts +386 -0
  102. package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
  103. package/src/__tests__/gateway-only-guard.test.ts +2 -0
  104. package/src/__tests__/gemini-provider.test.ts +66 -2
  105. package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
  106. package/src/__tests__/gmail-archive-fallback.test.ts +193 -0
  107. package/src/__tests__/gmail-archive-gate.test.ts +246 -0
  108. package/src/__tests__/gmail-preferences.test.ts +117 -0
  109. package/src/__tests__/guardian-routing-invariants.test.ts +70 -2
  110. package/src/__tests__/headless-browser-interactions.test.ts +738 -359
  111. package/src/__tests__/headless-browser-mode.test.ts +614 -0
  112. package/src/__tests__/headless-browser-navigate.test.ts +528 -49
  113. package/src/__tests__/headless-browser-read-tools.test.ts +274 -100
  114. package/src/__tests__/headless-browser-snapshot.test.ts +250 -77
  115. package/src/__tests__/heartbeat-service.test.ts +70 -17
  116. package/src/__tests__/home-state-routes.test.ts +162 -0
  117. package/src/__tests__/host-bash-proxy.test.ts +145 -1
  118. package/src/__tests__/host-browser-e2e-cloud.test.ts +596 -0
  119. package/src/__tests__/host-browser-e2e-self-hosted-capability.test.ts +286 -0
  120. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +374 -0
  121. package/src/__tests__/host-browser-event-routes.test.ts +350 -0
  122. package/src/__tests__/host-browser-proxy.test.ts +444 -0
  123. package/src/__tests__/host-browser-routes.test.ts +198 -0
  124. package/src/__tests__/host-browser-ws-events-e2e.test.ts +423 -0
  125. package/src/__tests__/host-cu-proxy.test.ts +166 -1
  126. package/src/__tests__/host-file-proxy.test.ts +185 -1
  127. package/src/__tests__/host-file-read-tool.test.ts +52 -0
  128. package/src/__tests__/host-proxy-interface.test.ts +165 -0
  129. package/src/__tests__/host-shell-tool.test.ts +1 -11
  130. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  131. package/src/__tests__/identity-intro-cache.test.ts +40 -10
  132. package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
  133. package/src/__tests__/integration-status.test.ts +6 -7
  134. package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
  135. package/src/__tests__/list-messages-tool-merge.test.ts +37 -12
  136. package/src/__tests__/llm-context-normalization.test.ts +488 -0
  137. package/src/__tests__/llm-context-route-provider.test.ts +86 -5
  138. package/src/__tests__/llm-usage-store.test.ts +363 -0
  139. package/src/__tests__/mcp-client-auth.test.ts +40 -4
  140. package/src/__tests__/mcp-health-check.test.ts +10 -3
  141. package/src/__tests__/media-stream-output.test.ts +555 -0
  142. package/src/__tests__/media-stream-parser.test.ts +374 -0
  143. package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
  144. package/src/__tests__/media-stream-stt-session.test.ts +588 -0
  145. package/src/__tests__/media-turn-detector.test.ts +440 -0
  146. package/src/__tests__/message-queue.test.ts +125 -0
  147. package/src/__tests__/migration-cross-version-compatibility.test.ts +3 -1
  148. package/src/__tests__/migration-export-http.test.ts +67 -8
  149. package/src/__tests__/migration-export-streaming.test.ts +66 -0
  150. package/src/__tests__/migration-import-commit-http.test.ts +109 -7
  151. package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
  152. package/src/__tests__/migration-validate-http.test.ts +3 -3
  153. package/src/__tests__/mock-gateway-ipc.ts +151 -0
  154. package/src/__tests__/model-intents.test.ts +2 -2
  155. package/src/__tests__/native-host-marker-sync-guard.test.ts +157 -0
  156. package/src/__tests__/oauth-apps-routes.test.ts +18 -12
  157. package/src/__tests__/oauth-cli.test.ts +709 -60
  158. package/src/__tests__/oauth-connect-orchestrator.test.ts +118 -24
  159. package/src/__tests__/oauth-provider-seed-logos.test.ts +23 -0
  160. package/src/__tests__/oauth-provider-serializer.test.ts +147 -10
  161. package/src/__tests__/oauth-provider-visibility.test.ts +19 -21
  162. package/src/__tests__/oauth-providers-routes.test.ts +52 -14
  163. package/src/__tests__/oauth-store.test.ts +1465 -176
  164. package/src/__tests__/oauth2-gateway-transport.test.ts +460 -26
  165. package/src/__tests__/onboarding-template-contract.test.ts +81 -70
  166. package/src/__tests__/openai-provider.test.ts +178 -2
  167. package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
  168. package/src/__tests__/openai-responses-provider.test.ts +1105 -0
  169. package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
  170. package/src/__tests__/outlook-categories.test.ts +1 -1
  171. package/src/__tests__/outlook-client-automation.test.ts +1 -1
  172. package/src/__tests__/outlook-compose-tools.test.ts +1 -1
  173. package/src/__tests__/outlook-email-watcher.test.ts +1 -1
  174. package/src/__tests__/outlook-follow-up.test.ts +1 -1
  175. package/src/__tests__/outlook-messaging-provider.test.ts +2 -2
  176. package/src/__tests__/outlook-trash.test.ts +1 -1
  177. package/src/__tests__/outlook-unsubscribe.test.ts +32 -3
  178. package/src/__tests__/permission-checker-host-gate.test.ts +74 -14
  179. package/src/__tests__/permission-mode.test.ts +28 -56
  180. package/src/__tests__/persona-resolver.test.ts +251 -0
  181. package/src/__tests__/platform-bash-auto-approve.test.ts +4 -0
  182. package/src/__tests__/platform-callback-registration.test.ts +19 -0
  183. package/src/__tests__/platform.test.ts +92 -1
  184. package/src/__tests__/post-turn-tool-result-truncation.test.ts +343 -0
  185. package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
  186. package/src/__tests__/pricing.test.ts +174 -0
  187. package/src/__tests__/proxy-approval-callback.test.ts +18 -0
  188. package/src/__tests__/qdrant-manager.test.ts +29 -8
  189. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
  190. package/src/__tests__/relationship-state-contract.test.ts +175 -0
  191. package/src/__tests__/relay-server.test.ts +423 -5
  192. package/src/__tests__/require-fresh-approval.test.ts +40 -1
  193. package/src/__tests__/sanitize-config-for-transfer.test.ts +132 -0
  194. package/src/__tests__/schedule-routes.test.ts +162 -0
  195. package/src/__tests__/search-skills-unified.test.ts +118 -0
  196. package/src/__tests__/secret-detection-handler.test.ts +84 -0
  197. package/src/__tests__/secret-ingress-http.test.ts +1 -0
  198. package/src/__tests__/secret-scanner-executor.test.ts +4 -0
  199. package/src/__tests__/secure-keys.test.ts +107 -0
  200. package/src/__tests__/send-endpoint-busy.test.ts +8 -1
  201. package/src/__tests__/sequence-store.test.ts +1 -1
  202. package/src/__tests__/server-history-render.test.ts +49 -0
  203. package/src/__tests__/set-permission-mode.test.ts +13 -250
  204. package/src/__tests__/settings-routes.test.ts +201 -0
  205. package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
  206. package/src/__tests__/skills-file-content-endpoint.test.ts +801 -0
  207. package/src/__tests__/skills-files-catalog-fallback.test.ts +738 -0
  208. package/src/__tests__/skills.test.ts +5 -2
  209. package/src/__tests__/skillssh-files.test.ts +446 -0
  210. package/src/__tests__/slack-block-formatting.test.ts +110 -0
  211. package/src/__tests__/slack-channel-config.test.ts +576 -16
  212. package/src/__tests__/stt-catalog-parity.test.ts +282 -0
  213. package/src/__tests__/stt-stream-session.test.ts +535 -0
  214. package/src/__tests__/subagent-detail.test.ts +44 -2
  215. package/src/__tests__/subagent-disposal.test.ts +1 -0
  216. package/src/__tests__/subagent-fork-notifications.test.ts +291 -0
  217. package/src/__tests__/subagent-fork-spawn.test.ts +384 -0
  218. package/src/__tests__/subagent-manager-notify.test.ts +1 -0
  219. package/src/__tests__/subagent-notify-parent.test.ts +1 -0
  220. package/src/__tests__/subagent-spawn-tool-fork.test.ts +411 -0
  221. package/src/__tests__/subagent-tools.test.ts +1 -0
  222. package/src/__tests__/subagent-types.test.ts +1 -0
  223. package/src/__tests__/system-prompt-ask-mode.test.ts +27 -71
  224. package/src/__tests__/system-prompt.test.ts +184 -27
  225. package/src/__tests__/task-scheduler.test.ts +32 -6
  226. package/src/__tests__/telegram-config.test.ts +10 -13
  227. package/src/__tests__/telephony-stt-routing.test.ts +329 -0
  228. package/src/__tests__/terminal-tools.test.ts +25 -5
  229. package/src/__tests__/test-preload.ts +18 -0
  230. package/src/__tests__/test-support/browser-skill-harness.ts +4 -1
  231. package/src/__tests__/tool-approval-handler.test.ts +73 -0
  232. package/src/__tests__/tool-executor-lifecycle-events.test.ts +9 -5
  233. package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
  234. package/src/__tests__/tool-executor.test.ts +33 -24
  235. package/src/__tests__/tool-result-truncation.test.ts +36 -0
  236. package/src/__tests__/tool-side-effects-slack-dm.test.ts +22 -0
  237. package/src/__tests__/top-level-renderer.test.ts +73 -1
  238. package/src/__tests__/transport-hints-queue.test.ts +14 -29
  239. package/src/__tests__/trust-store.test.ts +7 -1
  240. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
  241. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +109 -0
  242. package/src/__tests__/tts-catalog-parity.test.ts +345 -0
  243. package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
  244. package/src/__tests__/twilio-routes.test.ts +376 -0
  245. package/src/__tests__/unicode.test.ts +293 -0
  246. package/src/__tests__/update-bulletin-format.test.ts +59 -0
  247. package/src/__tests__/update-bulletin.test.ts +206 -5
  248. package/src/__tests__/usage-routes.test.ts +25 -4
  249. package/src/__tests__/user-reference.test.ts +46 -61
  250. package/src/__tests__/v2-consent-policy.test.ts +103 -0
  251. package/src/__tests__/verification-control-plane-policy.test.ts +4 -0
  252. package/src/__tests__/voice-config-update.test.ts +403 -0
  253. package/src/__tests__/voice-quality.test.ts +434 -19
  254. package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
  255. package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
  256. package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
  257. package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
  258. package/src/__tests__/workspace-migration-meets.test.ts +244 -0
  259. package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
  260. package/src/__tests__/workspace-policy.test.ts +2 -0
  261. package/src/acp/client-handler.ts +30 -4
  262. package/src/agent/image-optimize.ts +24 -12
  263. package/src/agent/loop.ts +55 -9
  264. package/src/approvals/guardian-request-resolvers.ts +21 -15
  265. package/src/backup/__tests__/backup-key.test.ts +152 -0
  266. package/src/backup/__tests__/backup-worker.test.ts +767 -0
  267. package/src/backup/__tests__/list-snapshots.test.ts +87 -0
  268. package/src/backup/__tests__/local-writer.test.ts +218 -0
  269. package/src/backup/__tests__/offsite-writer.test.ts +641 -0
  270. package/src/backup/__tests__/paths.test.ts +300 -0
  271. package/src/backup/__tests__/restore.test.ts +498 -0
  272. package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
  273. package/src/backup/__tests__/stream-crypt.test.ts +228 -0
  274. package/src/backup/backup-key.ts +137 -0
  275. package/src/backup/backup-worker.ts +459 -0
  276. package/src/backup/list-snapshots.ts +147 -0
  277. package/src/backup/local-writer.ts +133 -0
  278. package/src/backup/offsite-writer.ts +222 -0
  279. package/src/backup/paths.ts +226 -0
  280. package/src/backup/restore.ts +322 -0
  281. package/src/backup/snapshot-lock.ts +431 -0
  282. package/src/backup/stream-crypt.ts +263 -0
  283. package/src/browser-session/__tests__/manager.test.ts +297 -0
  284. package/src/browser-session/backends/cdp-inspect.ts +30 -0
  285. package/src/browser-session/backends/extension.ts +26 -0
  286. package/src/browser-session/backends/local.ts +24 -0
  287. package/src/browser-session/events.ts +164 -0
  288. package/src/browser-session/index.ts +27 -0
  289. package/src/browser-session/manager.ts +159 -0
  290. package/src/browser-session/types.ts +28 -0
  291. package/src/bundler/package-resolver.ts +4 -0
  292. package/src/calls/audio-store.ts +11 -5
  293. package/src/calls/call-controller.ts +226 -71
  294. package/src/calls/call-domain.ts +9 -0
  295. package/src/calls/call-speech-output.ts +190 -0
  296. package/src/calls/call-transport.ts +77 -0
  297. package/src/calls/media-stream-audio-transcode.ts +173 -0
  298. package/src/calls/media-stream-output.ts +660 -0
  299. package/src/calls/media-stream-parser.ts +300 -0
  300. package/src/calls/media-stream-protocol.ts +166 -0
  301. package/src/calls/media-stream-server.ts +592 -0
  302. package/src/calls/media-stream-stt-session.ts +460 -0
  303. package/src/calls/media-turn-detector.ts +230 -0
  304. package/src/calls/relay-server.ts +90 -75
  305. package/src/calls/resolve-call-tts-provider.ts +136 -0
  306. package/src/calls/telephony-stt-routing.ts +145 -0
  307. package/src/calls/tts-call-strategy.ts +161 -0
  308. package/src/calls/tts-text-sanitizer.ts +32 -16
  309. package/src/calls/twilio-routes.ts +281 -17
  310. package/src/calls/voice-quality.ts +78 -35
  311. package/src/calls/voice-session-bridge.ts +8 -1
  312. package/src/channels/__tests__/types.test.ts +134 -0
  313. package/src/channels/types.ts +69 -3
  314. package/src/cli/__tests__/run-assistant-command.ts +11 -1
  315. package/src/cli/commands/__tests__/backup.test.ts +1165 -0
  316. package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
  317. package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
  318. package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
  319. package/src/cli/commands/__tests__/email-download.test.ts +16 -1
  320. package/src/cli/commands/__tests__/email-list.test.ts +22 -4
  321. package/src/cli/commands/__tests__/email-register.test.ts +4 -4
  322. package/src/cli/commands/__tests__/email-send.test.ts +37 -4
  323. package/src/cli/commands/__tests__/email-status.test.ts +5 -1
  324. package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
  325. package/src/cli/commands/backup.ts +993 -0
  326. package/src/cli/commands/conversations.ts +77 -0
  327. package/src/cli/commands/credentials.ts +3 -4
  328. package/src/cli/commands/domain.ts +210 -0
  329. package/src/cli/commands/email.ts +273 -16
  330. package/src/cli/commands/mcp.ts +16 -4
  331. package/src/cli/commands/oauth/__tests__/connect.test.ts +56 -44
  332. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +21 -21
  333. package/src/cli/commands/oauth/__tests__/mode.test.ts +17 -17
  334. package/src/cli/commands/oauth/__tests__/ping.test.ts +16 -16
  335. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +32 -33
  336. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +330 -0
  337. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +117 -12
  338. package/src/cli/commands/oauth/__tests__/status.test.ts +10 -10
  339. package/src/cli/commands/oauth/__tests__/token.test.ts +7 -7
  340. package/src/cli/commands/oauth/apps.ts +7 -4
  341. package/src/cli/commands/oauth/connect.ts +6 -3
  342. package/src/cli/commands/oauth/disconnect.ts +1 -1
  343. package/src/cli/commands/oauth/mode.ts +12 -3
  344. package/src/cli/commands/oauth/providers.ts +215 -36
  345. package/src/cli/commands/oauth/shared.ts +7 -6
  346. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +254 -0
  347. package/src/cli/commands/platform/__tests__/connect.test.ts +6 -0
  348. package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
  349. package/src/cli/commands/platform/__tests__/status.test.ts +6 -0
  350. package/src/cli/commands/platform/index.ts +107 -10
  351. package/src/cli/commands/usage.ts +10 -9
  352. package/src/cli/lib/daemon-credential-client.ts +4 -0
  353. package/src/cli/program.ts +30 -4
  354. package/src/config/__tests__/backup-schema.test.ts +134 -0
  355. package/src/config/assistant-feature-flags.ts +61 -62
  356. package/src/config/bundled-skills/app-builder/SKILL.md +26 -249
  357. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +141 -0
  358. package/src/config/bundled-skills/app-builder/references/INTERACTION_HOOKS.md +56 -0
  359. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +125 -0
  360. package/src/config/bundled-skills/browser/SKILL.md +30 -5
  361. package/src/config/bundled-skills/browser/TOOLS.json +123 -0
  362. package/src/config/bundled-skills/browser/tools/browser-attach.ts +12 -0
  363. package/src/config/bundled-skills/browser/tools/browser-detach.ts +12 -0
  364. package/src/config/bundled-skills/browser/tools/browser-status.ts +12 -0
  365. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +17 -0
  366. package/src/config/bundled-skills/contacts/SKILL.md +5 -2
  367. package/src/config/bundled-skills/document/SKILL.md +4 -0
  368. package/src/config/bundled-skills/gmail/SKILL.md +54 -8
  369. package/src/config/bundled-skills/gmail/TOOLS.json +33 -3
  370. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +116 -9
  371. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +138 -11
  372. package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +59 -0
  373. package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +82 -0
  374. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +113 -17
  375. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -2
  376. package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
  377. package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
  378. package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
  379. package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
  380. package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
  381. package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
  382. package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
  383. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
  384. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  385. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
  386. package/src/config/bundled-skills/outlook/SKILL.md +9 -2
  387. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +2 -2
  388. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  389. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +27 -18
  390. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  391. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  392. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
  393. package/src/config/bundled-skills/slack/SKILL.md +1 -0
  394. package/src/config/bundled-skills/subagent/SKILL.md +21 -0
  395. package/src/config/bundled-skills/subagent/TOOLS.json +8 -4
  396. package/src/config/bundled-skills/tasks/SKILL.md +5 -0
  397. package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
  398. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
  399. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
  400. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
  401. package/src/config/bundled-tool-registry.ts +8 -0
  402. package/src/config/env-registry.ts +38 -0
  403. package/src/config/env.ts +49 -4
  404. package/src/config/feature-flag-registry.json +85 -14
  405. package/src/config/loader.ts +82 -13
  406. package/src/config/sanitize-for-transfer.ts +47 -0
  407. package/src/config/schema.ts +81 -15
  408. package/src/config/schemas/__tests__/stt.test.ts +43 -0
  409. package/src/config/schemas/analysis.ts +51 -0
  410. package/src/config/schemas/backup.ts +72 -0
  411. package/src/config/schemas/calls.ts +1 -26
  412. package/src/config/schemas/elevenlabs.ts +0 -59
  413. package/src/config/schemas/filing.ts +47 -7
  414. package/src/config/schemas/heartbeat.ts +27 -5
  415. package/src/config/schemas/host-browser.ts +112 -0
  416. package/src/config/schemas/inference.ts +1 -1
  417. package/src/config/schemas/memory-lifecycle.ts +14 -2
  418. package/src/config/schemas/memory-retrieval.ts +103 -0
  419. package/src/config/schemas/security.ts +0 -6
  420. package/src/config/schemas/services.ts +52 -0
  421. package/src/config/schemas/stt.ts +59 -0
  422. package/src/config/schemas/tts.ts +230 -0
  423. package/src/config/schemas/updates.ts +14 -0
  424. package/src/config/skills.ts +4 -0
  425. package/src/config/types.ts +4 -1
  426. package/src/contacts/contact-store.ts +56 -11
  427. package/src/contacts/contacts-write.ts +38 -1
  428. package/src/context/post-turn-tool-result-truncation.ts +177 -0
  429. package/src/context/tool-result-truncation.ts +2 -1
  430. package/src/context/window-manager.ts +61 -10
  431. package/src/credential-execution/approval-bridge.ts +49 -15
  432. package/src/credential-execution/executable-discovery.ts +12 -2
  433. package/src/credential-execution/process-manager.ts +33 -2
  434. package/src/credential-health/credential-health-service.ts +366 -0
  435. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
  436. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
  437. package/src/daemon/__tests__/conversation-tool-setup.test.ts +195 -0
  438. package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
  439. package/src/daemon/app-source-watcher.ts +35 -0
  440. package/src/daemon/config-watcher.ts +99 -5
  441. package/src/daemon/context-overflow-approval.ts +5 -0
  442. package/src/daemon/conversation-agent-loop-handlers.ts +23 -2
  443. package/src/daemon/conversation-agent-loop.ts +153 -42
  444. package/src/daemon/conversation-attachments.ts +40 -0
  445. package/src/daemon/conversation-error.ts +11 -0
  446. package/src/daemon/conversation-history.ts +40 -6
  447. package/src/daemon/conversation-launch.ts +220 -0
  448. package/src/daemon/conversation-lifecycle.ts +59 -9
  449. package/src/daemon/conversation-messaging.ts +37 -3
  450. package/src/daemon/conversation-notifiers.ts +5 -0
  451. package/src/daemon/conversation-process.ts +622 -13
  452. package/src/daemon/conversation-queue-manager.ts +24 -0
  453. package/src/daemon/conversation-runtime-assembly.ts +128 -36
  454. package/src/daemon/conversation-slash.ts +36 -0
  455. package/src/daemon/conversation-surfaces.ts +131 -40
  456. package/src/daemon/conversation-tool-setup.ts +99 -8
  457. package/src/daemon/conversation-usage.ts +7 -4
  458. package/src/daemon/conversation-workspace.ts +12 -0
  459. package/src/daemon/conversation.ts +292 -16
  460. package/src/daemon/date-context.ts +10 -10
  461. package/src/daemon/first-greeting.ts +3 -2
  462. package/src/daemon/handlers/config-slack-channel.ts +269 -94
  463. package/src/daemon/handlers/conversations.ts +13 -141
  464. package/src/daemon/handlers/shared.ts +80 -0
  465. package/src/daemon/handlers/skills.ts +483 -44
  466. package/src/daemon/host-bash-proxy.ts +48 -13
  467. package/src/daemon/host-browser-proxy.ts +192 -0
  468. package/src/daemon/host-cu-proxy.ts +36 -11
  469. package/src/daemon/host-file-proxy.ts +57 -9
  470. package/src/daemon/lifecycle.ts +179 -28
  471. package/src/daemon/message-protocol.ts +13 -0
  472. package/src/daemon/message-types/conversations.ts +89 -14
  473. package/src/daemon/message-types/home.ts +40 -0
  474. package/src/daemon/message-types/host-browser.ts +100 -0
  475. package/src/daemon/message-types/meet.ts +143 -0
  476. package/src/daemon/message-types/messages.ts +19 -5
  477. package/src/daemon/message-types/schedules.ts +34 -2
  478. package/src/daemon/message-types/skills.ts +26 -0
  479. package/src/daemon/message-types/subagents.ts +2 -0
  480. package/src/daemon/message-types/surfaces.ts +2 -0
  481. package/src/daemon/server.ts +439 -14
  482. package/src/daemon/shutdown-handlers.ts +32 -4
  483. package/src/daemon/shutdown-registry.ts +40 -0
  484. package/src/daemon/tool-side-effects.ts +15 -0
  485. package/src/daemon/transport-hints.ts +5 -24
  486. package/src/email/html-renderer.ts +76 -0
  487. package/src/heartbeat/heartbeat-service.ts +93 -7
  488. package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
  489. package/src/home/__tests__/emit-feed-event.test.ts +169 -0
  490. package/src/home/__tests__/feed-scheduler.test.ts +194 -0
  491. package/src/home/__tests__/feed-types.test.ts +275 -0
  492. package/src/home/__tests__/feed-writer.test.ts +688 -0
  493. package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
  494. package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
  495. package/src/home/__tests__/progress-formula.test.ts +213 -0
  496. package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
  497. package/src/home/__tests__/rollup-producer.test.ts +398 -0
  498. package/src/home/assistant-feed-authoring.ts +124 -0
  499. package/src/home/emit-feed-event.ts +158 -0
  500. package/src/home/feed-scheduler.ts +247 -0
  501. package/src/home/feed-types.ts +181 -0
  502. package/src/home/feed-writer.ts +469 -0
  503. package/src/home/platform-gmail-digest.ts +163 -0
  504. package/src/home/progress-formula.ts +86 -0
  505. package/src/home/relationship-state-writer.ts +824 -0
  506. package/src/home/relationship-state.ts +143 -0
  507. package/src/home/rollup-producer.ts +384 -0
  508. package/src/hooks/runner.ts +7 -0
  509. package/src/inbound/platform-callback-registration.ts +30 -20
  510. package/src/inbound/public-ingress-urls.ts +12 -0
  511. package/src/instrument.ts +1 -1
  512. package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
  513. package/src/ipc/cli-client.ts +151 -0
  514. package/src/ipc/cli-server.ts +234 -0
  515. package/src/ipc/gateway-client.ts +180 -0
  516. package/src/ipc/routes/index.ts +5 -0
  517. package/src/ipc/routes/wake-conversation.ts +19 -0
  518. package/src/mcp/client.ts +59 -24
  519. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
  520. package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
  521. package/src/memory/__tests__/conversation-analyze-job.test.ts +232 -0
  522. package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
  523. package/src/memory/app-store.ts +31 -1
  524. package/src/memory/attachments-store.ts +70 -0
  525. package/src/memory/auto-analysis-enqueue.ts +127 -0
  526. package/src/memory/auto-analysis-guard.ts +27 -0
  527. package/src/memory/cleanup-schedule-state.ts +37 -0
  528. package/src/memory/conversation-analyze-job.ts +73 -0
  529. package/src/memory/conversation-crud.ts +122 -0
  530. package/src/memory/conversation-disk-view.ts +7 -0
  531. package/src/memory/conversation-group-migration.ts +34 -2
  532. package/src/memory/conversation-queries.ts +6 -5
  533. package/src/memory/conversation-starters-cadence.ts +76 -0
  534. package/src/memory/conversation-title-service.ts +5 -2
  535. package/src/memory/db-init.ts +18 -0
  536. package/src/memory/db-maintenance.ts +108 -0
  537. package/src/memory/db.ts +1 -0
  538. package/src/memory/embedding-backend.test.ts +75 -0
  539. package/src/memory/embedding-backend.ts +131 -5
  540. package/src/memory/embedding-gemini.test.ts +54 -0
  541. package/src/memory/embedding-gemini.ts +20 -9
  542. package/src/memory/embedding-local.ts +176 -17
  543. package/src/memory/graph/consolidation.ts +10 -23
  544. package/src/memory/graph/conversation-graph-memory.ts +15 -0
  545. package/src/memory/graph/extraction-job.ts +15 -0
  546. package/src/memory/graph/extraction.test.ts +23 -0
  547. package/src/memory/graph/extraction.ts +8 -0
  548. package/src/memory/graph/retriever.ts +67 -40
  549. package/src/memory/graph/scoring.test.ts +186 -0
  550. package/src/memory/graph/scoring.ts +31 -1
  551. package/src/memory/graph/store.test.ts +7 -3
  552. package/src/memory/graph/store.ts +47 -12
  553. package/src/memory/graph/tools.ts +1 -1
  554. package/src/memory/group-crud.ts +6 -1
  555. package/src/memory/indexer.ts +95 -16
  556. package/src/memory/job-handlers/cleanup.ts +11 -8
  557. package/src/memory/job-handlers/conversation-starters.ts +16 -10
  558. package/src/memory/jobs-store.ts +64 -4
  559. package/src/memory/jobs-worker.ts +22 -9
  560. package/src/memory/llm-usage-store.ts +137 -60
  561. package/src/memory/migrations/213-oauth-providers-scope-separator.ts +13 -0
  562. package/src/memory/migrations/214-oauth-providers-refresh-url.ts +11 -0
  563. package/src/memory/migrations/215-oauth-providers-revoke.ts +14 -0
  564. package/src/memory/migrations/216-oauth-providers-token-auth-method.ts +30 -0
  565. package/src/memory/migrations/217-conversation-host-access.ts +40 -0
  566. package/src/memory/migrations/218-oauth-providers-logo-url.ts +11 -0
  567. package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
  568. package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
  569. package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
  570. package/src/memory/migrations/index.ts +12 -0
  571. package/src/memory/migrations/registry.ts +16 -0
  572. package/src/memory/qdrant-manager.ts +43 -16
  573. package/src/memory/schema/conversations.ts +3 -0
  574. package/src/memory/schema/oauth.ts +21 -13
  575. package/src/memory/usage-buckets.ts +396 -0
  576. package/src/messaging/providers/gmail/client.ts +57 -6
  577. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
  578. package/src/messaging/providers/slack/adapter.ts +143 -38
  579. package/src/messaging/providers/slack/client.ts +16 -0
  580. package/src/messaging/providers/slack/types.ts +4 -0
  581. package/src/notifications/decision-engine.ts +3 -3
  582. package/src/notifications/signal.ts +5 -0
  583. package/src/oauth/AGENTS.md +76 -0
  584. package/src/oauth/__tests__/identity-verifier.test.ts +25 -19
  585. package/src/oauth/__tests__/seed-providers-managed.test.ts +32 -0
  586. package/src/oauth/byo-connection.test.ts +26 -9
  587. package/src/oauth/byo-connection.ts +10 -8
  588. package/src/oauth/connect-orchestrator.ts +25 -21
  589. package/src/oauth/connect-types.ts +3 -3
  590. package/src/oauth/connection-resolver.test.ts +17 -4
  591. package/src/oauth/connection-resolver.ts +22 -18
  592. package/src/oauth/connection.ts +3 -1
  593. package/src/oauth/manual-token-connection.ts +13 -13
  594. package/src/oauth/oauth-store.ts +223 -100
  595. package/src/oauth/platform-connection.test.ts +101 -3
  596. package/src/oauth/platform-connection.ts +56 -35
  597. package/src/oauth/provider-serializer.ts +31 -5
  598. package/src/oauth/revoke.ts +76 -0
  599. package/src/oauth/seed-providers.ts +133 -87
  600. package/src/oauth/token-persistence.ts +1 -1
  601. package/src/permissions/checker.ts +16 -6
  602. package/src/permissions/defaults.ts +49 -1
  603. package/src/permissions/permission-mode.ts +4 -11
  604. package/src/permissions/prompter.ts +13 -1
  605. package/src/permissions/trust-store.ts +3 -3
  606. package/src/permissions/v2-consent-policy.ts +87 -0
  607. package/src/permissions/workspace-policy.ts +3 -0
  608. package/src/platform/client.test.ts +10 -0
  609. package/src/platform/sync-identity.ts +129 -0
  610. package/src/prompts/persona-resolver.ts +126 -2
  611. package/src/prompts/system-prompt.ts +76 -38
  612. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +3 -65
  613. package/src/prompts/templates/BOOTSTRAP.md +59 -105
  614. package/src/prompts/templates/SOUL.md +3 -1
  615. package/src/prompts/templates/UPDATES.md +12 -0
  616. package/src/prompts/templates/channels/slack.md +20 -0
  617. package/src/prompts/update-bulletin-format.ts +26 -9
  618. package/src/prompts/update-bulletin.ts +34 -23
  619. package/src/prompts/user-reference.ts +20 -17
  620. package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
  621. package/src/providers/anthropic/client.ts +157 -60
  622. package/src/providers/fireworks/client.ts +2 -2
  623. package/src/providers/gemini/client.ts +9 -1
  624. package/src/providers/model-catalog.ts +6 -0
  625. package/src/providers/model-intents.ts +4 -4
  626. package/src/providers/ollama/client.ts +2 -2
  627. package/src/providers/openai/chat-completions-provider.ts +474 -0
  628. package/src/providers/openai/client.ts +25 -440
  629. package/src/providers/openai/responses-provider.ts +502 -0
  630. package/src/providers/openrouter/client.ts +101 -4
  631. package/src/providers/provider-secret-catalog.ts +139 -0
  632. package/src/providers/registry.ts +2 -2
  633. package/src/providers/retry.ts +14 -3
  634. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
  635. package/src/providers/speech-to-text/__tests__/resolve.test.ts +828 -0
  636. package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
  637. package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
  638. package/src/providers/speech-to-text/deepgram.test.ts +332 -0
  639. package/src/providers/speech-to-text/deepgram.ts +115 -0
  640. package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
  641. package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
  642. package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
  643. package/src/providers/speech-to-text/google-gemini.ts +101 -0
  644. package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
  645. package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
  646. package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
  647. package/src/providers/speech-to-text/openai-whisper.ts +63 -33
  648. package/src/providers/speech-to-text/provider-catalog.ts +306 -0
  649. package/src/providers/speech-to-text/resolve.ts +386 -6
  650. package/src/providers/types.ts +10 -1
  651. package/src/runtime/AGENTS.md +65 -0
  652. package/src/runtime/__tests__/agent-wake.test.ts +831 -0
  653. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +715 -0
  654. package/src/runtime/__tests__/capability-tokens.test.ts +258 -0
  655. package/src/runtime/__tests__/chrome-extension-registry.test.ts +518 -0
  656. package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
  657. package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
  658. package/src/runtime/agent-wake.ts +512 -0
  659. package/src/runtime/assistant-event-hub.ts +2 -2
  660. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -0
  661. package/src/runtime/auth/__tests__/middleware.test.ts +116 -1
  662. package/src/runtime/auth/__tests__/route-policy.test.ts +48 -0
  663. package/src/runtime/auth/middleware.ts +98 -0
  664. package/src/runtime/auth/route-policy.ts +33 -9
  665. package/src/runtime/auth/token-service.ts +56 -1
  666. package/src/runtime/btw-sidechain.ts +2 -0
  667. package/src/runtime/capability-tokens.ts +414 -0
  668. package/src/runtime/channel-approvals.ts +18 -5
  669. package/src/runtime/channel-invite-transport.ts +1 -1
  670. package/src/runtime/channel-invite-transports/email.ts +14 -6
  671. package/src/runtime/channel-readiness-service.ts +12 -22
  672. package/src/runtime/chrome-extension-registry.ts +368 -0
  673. package/src/runtime/confirmation-request-guardian-bridge.ts +6 -0
  674. package/src/runtime/guardian-decision-types.ts +7 -0
  675. package/src/runtime/http-server.ts +815 -75
  676. package/src/runtime/http-types.ts +6 -2
  677. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +172 -0
  678. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +276 -0
  679. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +198 -0
  680. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
  681. package/src/runtime/migrations/migration-transport.ts +7 -0
  682. package/src/runtime/migrations/migration-wizard.ts +23 -2
  683. package/src/runtime/migrations/rebind-secrets-screen.ts +76 -15
  684. package/src/runtime/migrations/vbundle-builder.ts +145 -38
  685. package/src/runtime/migrations/vbundle-import-analyzer.ts +96 -1
  686. package/src/runtime/migrations/vbundle-importer.ts +89 -5
  687. package/src/runtime/pending-interactions.ts +18 -13
  688. package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
  689. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +507 -0
  690. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +208 -0
  691. package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
  692. package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
  693. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
  694. package/src/runtime/routes/app-management-routes.ts +12 -18
  695. package/src/runtime/routes/approval-routes.ts +90 -16
  696. package/src/runtime/routes/attachment-routes.test.ts +9 -3
  697. package/src/runtime/routes/attachment-routes.ts +216 -17
  698. package/src/runtime/routes/backup-routes.ts +519 -0
  699. package/src/runtime/routes/browser-extension-pair-routes.ts +556 -0
  700. package/src/runtime/routes/btw-routes.ts +8 -6
  701. package/src/runtime/routes/contact-routes.test.ts +298 -0
  702. package/src/runtime/routes/contact-routes.ts +132 -5
  703. package/src/runtime/routes/conversation-analysis-routes.ts +22 -141
  704. package/src/runtime/routes/conversation-management-routes.ts +223 -0
  705. package/src/runtime/routes/conversation-routes.ts +598 -103
  706. package/src/runtime/routes/conversation-starter-routes.ts +78 -16
  707. package/src/runtime/routes/filing-routes.ts +93 -0
  708. package/src/runtime/routes/guardian-action-routes.ts +24 -13
  709. package/src/runtime/routes/home-feed-routes.ts +334 -0
  710. package/src/runtime/routes/home-state-routes.ts +138 -0
  711. package/src/runtime/routes/host-browser-routes.ts +268 -0
  712. package/src/runtime/routes/host-file-routes.ts +9 -1
  713. package/src/runtime/routes/identity-intro-cache.ts +7 -3
  714. package/src/runtime/routes/identity-routes.ts +262 -33
  715. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
  716. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
  717. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
  718. package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
  719. package/src/runtime/routes/integrations/slack/channel.ts +11 -3
  720. package/src/runtime/routes/integrations/slack/share.ts +45 -7
  721. package/src/runtime/routes/llm-context-normalization.ts +303 -0
  722. package/src/runtime/routes/log-export-routes.ts +42 -22
  723. package/src/runtime/routes/memory-item-routes.test.ts +3 -2
  724. package/src/runtime/routes/memory-item-routes.ts +1 -7
  725. package/src/runtime/routes/migration-routes.ts +122 -2
  726. package/src/runtime/routes/oauth-apps.ts +15 -17
  727. package/src/runtime/routes/oauth-providers.ts +4 -0
  728. package/src/runtime/routes/schedule-routes.ts +24 -11
  729. package/src/runtime/routes/settings-routes.ts +31 -102
  730. package/src/runtime/routes/skills-routes.ts +128 -9
  731. package/src/runtime/routes/stt-routes.ts +233 -0
  732. package/src/runtime/routes/subagents-routes.ts +14 -10
  733. package/src/runtime/routes/surface-action-routes.ts +41 -2
  734. package/src/runtime/routes/tts-routes.ts +108 -24
  735. package/src/runtime/routes/usage-routes.ts +38 -9
  736. package/src/runtime/routes/user-route-dispatcher.ts +50 -5
  737. package/src/runtime/routes/user-routes.ts +13 -1
  738. package/src/runtime/routes/work-items-routes.ts +8 -1
  739. package/src/runtime/routes/workspace-routes.test.ts +22 -0
  740. package/src/runtime/routes/workspace-routes.ts +8 -1
  741. package/src/runtime/routes/workspace-utils.ts +2 -0
  742. package/src/runtime/runtime-mode.ts +33 -0
  743. package/src/runtime/services/__tests__/analyze-conversation.test.ts +444 -0
  744. package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
  745. package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
  746. package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
  747. package/src/runtime/services/analyze-conversation.ts +344 -0
  748. package/src/runtime/services/analyze-deps-singleton.ts +32 -0
  749. package/src/runtime/services/auto-analysis-prompt.ts +55 -0
  750. package/src/runtime/skill-route-registry.ts +49 -0
  751. package/src/runtime/slack-block-formatting.ts +437 -10
  752. package/src/schedule/scheduler.ts +57 -5
  753. package/src/security/ces-credential-client.ts +20 -0
  754. package/src/security/ces-rpc-credential-backend.ts +17 -0
  755. package/src/security/credential-backend.ts +5 -0
  756. package/src/security/oauth2.ts +68 -29
  757. package/src/security/secure-keys.ts +143 -27
  758. package/src/security/token-manager.ts +31 -10
  759. package/src/sequence/engine.ts +23 -0
  760. package/src/sequence/types.ts +1 -1
  761. package/src/skills/catalog-files.ts +554 -0
  762. package/src/skills/category-inference.ts +122 -0
  763. package/src/skills/clawhub-files.ts +213 -0
  764. package/src/skills/clawhub.ts +84 -23
  765. package/src/skills/skill-file-provider.ts +40 -0
  766. package/src/skills/skillssh-files.ts +395 -0
  767. package/src/skills/skillssh-registry.ts +4 -4
  768. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +392 -0
  769. package/src/stt/__tests__/types.test.ts +89 -0
  770. package/src/stt/daemon-batch-transcriber.ts +195 -0
  771. package/src/stt/stt-stream-session.ts +499 -0
  772. package/src/stt/types.ts +330 -0
  773. package/src/stt/wav-encoder.test.ts +373 -0
  774. package/src/stt/wav-encoder.ts +175 -0
  775. package/src/subagent/manager.ts +169 -40
  776. package/src/subagent/types.ts +19 -0
  777. package/src/tools/apps/executors.ts +11 -2
  778. package/src/tools/browser/__tests__/auth-detector.test.ts +202 -108
  779. package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
  780. package/src/tools/browser/__tests__/browser-status.test.ts +123 -0
  781. package/src/tools/browser/auth-detector.ts +43 -12
  782. package/src/tools/browser/browser-execution.ts +1787 -342
  783. package/src/tools/browser/browser-manager.ts +81 -12
  784. package/src/tools/browser/browser-mode-constants.ts +12 -0
  785. package/src/tools/browser/browser-mode.ts +92 -0
  786. package/src/tools/browser/browser-status-constants.ts +33 -0
  787. package/src/tools/browser/cdp-client/__tests__/accessibility-snapshot.test.ts +318 -0
  788. package/src/tools/browser/cdp-client/__tests__/cdp-dom-helpers.test.ts +1175 -0
  789. package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +1263 -0
  790. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +359 -0
  791. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1993 -0
  792. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-nested-frames.json +64 -0
  793. package/src/tools/browser/cdp-client/__tests__/fixtures/ax-tree-simple.json +69 -0
  794. package/src/tools/browser/cdp-client/__tests__/local-cdp-client.test.ts +310 -0
  795. package/src/tools/browser/cdp-client/__tests__/types.test.ts +96 -0
  796. package/src/tools/browser/cdp-client/accessibility-snapshot.ts +387 -0
  797. package/src/tools/browser/cdp-client/cdp-dom-helpers.ts +695 -0
  798. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +1007 -0
  799. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +580 -0
  800. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +744 -0
  801. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +579 -0
  802. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +868 -0
  803. package/src/tools/browser/cdp-client/errors.ts +49 -0
  804. package/src/tools/browser/cdp-client/extension-cdp-client.ts +148 -0
  805. package/src/tools/browser/cdp-client/factory.ts +914 -0
  806. package/src/tools/browser/cdp-client/index.ts +28 -0
  807. package/src/tools/browser/cdp-client/local-cdp-client.ts +187 -0
  808. package/src/tools/browser/cdp-client/types.ts +120 -0
  809. package/src/tools/credentials/vault.ts +35 -6
  810. package/src/tools/filesystem/edit.ts +1 -1
  811. package/src/tools/filesystem/list.ts +1 -1
  812. package/src/tools/filesystem/read.ts +1 -1
  813. package/src/tools/filesystem/write.ts +2 -1
  814. package/src/tools/host-filesystem/edit.ts +1 -1
  815. package/src/tools/host-filesystem/read.ts +12 -15
  816. package/src/tools/host-filesystem/write.ts +1 -1
  817. package/src/tools/host-terminal/host-shell.ts +21 -16
  818. package/src/tools/network/web-fetch.ts +5 -2
  819. package/src/tools/network/web-search.ts +5 -2
  820. package/src/tools/permission-checker.ts +77 -82
  821. package/src/tools/registry.ts +0 -2
  822. package/src/tools/secret-detection-handler.ts +34 -0
  823. package/src/tools/shared/filesystem/image-read.ts +61 -40
  824. package/src/tools/shared/shell-output.ts +3 -1
  825. package/src/tools/side-effects.ts +2 -0
  826. package/src/tools/skills/sandbox-runner.ts +3 -2
  827. package/src/tools/subagent/spawn.ts +47 -3
  828. package/src/tools/subagent/status.ts +2 -0
  829. package/src/tools/system/register.ts +2 -16
  830. package/src/tools/terminal/safe-env.ts +15 -0
  831. package/src/tools/terminal/shell.ts +36 -20
  832. package/src/tools/tool-approval-handler.ts +48 -2
  833. package/src/tools/tool-manifest.ts +21 -0
  834. package/src/tools/types.ts +19 -0
  835. package/src/tools/ui-surface/definitions.ts +6 -1
  836. package/src/tts/__tests__/provider-adapters.test.ts +834 -0
  837. package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
  838. package/src/tts/__tests__/provider-catalog.test.ts +183 -0
  839. package/src/tts/__tests__/provider-registry.test.ts +90 -0
  840. package/src/tts/provider-catalog.ts +201 -0
  841. package/src/tts/provider-registry.ts +73 -0
  842. package/src/tts/providers/deepgram-provider.ts +219 -0
  843. package/src/tts/providers/elevenlabs-provider.ts +211 -0
  844. package/src/tts/providers/fish-audio-provider.ts +183 -0
  845. package/src/tts/providers/index.ts +42 -0
  846. package/src/tts/providers/register-builtins.ts +130 -0
  847. package/src/tts/synthesize-text.ts +110 -0
  848. package/src/tts/tts-config-resolver.ts +78 -0
  849. package/src/tts/types.ts +153 -0
  850. package/src/types/onboarding-context.ts +7 -0
  851. package/src/util/abort-reasons.ts +58 -0
  852. package/src/util/device-id.ts +32 -16
  853. package/src/util/errors.ts +9 -1
  854. package/src/util/platform.ts +63 -24
  855. package/src/util/pricing.ts +66 -3
  856. package/src/util/spawn.ts +1 -1
  857. package/src/util/truncate.ts +4 -2
  858. package/src/util/unicode.ts +201 -0
  859. package/src/version.ts +19 -24
  860. package/src/watcher/engine.ts +23 -0
  861. package/src/watcher/watcher-store.ts +31 -0
  862. package/src/workspace/migrations/003-seed-device-id.ts +9 -3
  863. package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
  864. package/src/workspace/migrations/029-seed-pkb.ts +1 -1
  865. package/src/workspace/migrations/031-drop-user-md.ts +317 -0
  866. package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
  867. package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
  868. package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
  869. package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
  870. package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
  871. package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
  872. package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
  873. package/src/workspace/migrations/registry.ts +16 -0
  874. package/src/workspace/top-level-renderer.ts +31 -1
  875. package/src/workspace/turn-commit.ts +31 -0
  876. package/src/__tests__/chrome-cdp.test.ts +0 -419
  877. package/src/__tests__/email-cli.test.ts +0 -297
  878. package/src/__tests__/email-service-config-fallback.test.ts +0 -102
  879. package/src/__tests__/permission-mode-sse.test.ts +0 -418
  880. package/src/__tests__/permission-mode-store.test.ts +0 -277
  881. package/src/browser-extension-relay/protocol.ts +0 -63
  882. package/src/browser-extension-relay/server.ts +0 -203
  883. package/src/cli/commands/browser-relay.ts +0 -536
  884. package/src/config/schemas/sandbox.ts +0 -14
  885. package/src/email/guardrails.ts +0 -221
  886. package/src/email/provider.ts +0 -117
  887. package/src/email/providers/agentmail.ts +0 -361
  888. package/src/email/providers/index.ts +0 -65
  889. package/src/email/service.ts +0 -384
  890. package/src/email/types.ts +0 -126
  891. package/src/permissions/permission-mode-store.ts +0 -180
  892. package/src/prompts/templates/USER.md +0 -13
  893. package/src/providers/speech-to-text/types.ts +0 -17
  894. package/src/tools/browser/chrome-cdp.ts +0 -239
  895. package/src/tools/system/set-permission-mode.ts +0 -103
@@ -0,0 +1,1993 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { HostBrowserProxy } from "../../../../daemon/host-browser-proxy.js";
4
+ import type { ToolContext } from "../../../types.js";
5
+ import { CdpError } from "../errors.js";
6
+
7
+ type FakeClient = {
8
+ kind: "extension" | "local" | "cdp-inspect";
9
+ conversationId: string;
10
+ send: ReturnType<typeof mock>;
11
+ dispose: ReturnType<typeof mock>;
12
+ };
13
+
14
+ function makeFakeExtensionClient(conversationId: string): FakeClient {
15
+ return {
16
+ kind: "extension",
17
+ conversationId,
18
+ send: mock(async () => ({ ok: true, via: "extension" })),
19
+ dispose: mock(() => {}),
20
+ };
21
+ }
22
+
23
+ function makeFakeLocalClient(conversationId: string): FakeClient {
24
+ return {
25
+ kind: "local",
26
+ conversationId,
27
+ send: mock(async () => ({ ok: true, via: "local" })),
28
+ dispose: mock(() => {}),
29
+ };
30
+ }
31
+
32
+ function makeFakeCdpInspectClient(conversationId: string): FakeClient {
33
+ return {
34
+ kind: "cdp-inspect",
35
+ conversationId,
36
+ send: mock(async () => ({ ok: true, via: "cdp-inspect" })),
37
+ dispose: mock(() => {}),
38
+ };
39
+ }
40
+
41
+ let lastExtensionClient: FakeClient | undefined;
42
+ let lastLocalClient: FakeClient | undefined;
43
+ let lastCdpInspectClient: FakeClient | undefined;
44
+
45
+ const createExtensionCdpClientMock = mock(
46
+ (_proxy: HostBrowserProxy, conversationId: string) => {
47
+ const client = makeFakeExtensionClient(conversationId);
48
+ lastExtensionClient = client;
49
+ return client;
50
+ },
51
+ );
52
+
53
+ const createLocalCdpClientMock = mock((conversationId: string) => {
54
+ const client = makeFakeLocalClient(conversationId);
55
+ lastLocalClient = client;
56
+ return client;
57
+ });
58
+
59
+ const createCdpInspectClientMock = mock(
60
+ (conversationId: string, _options: unknown) => {
61
+ const client = makeFakeCdpInspectClient(conversationId);
62
+ lastCdpInspectClient = client;
63
+ return client;
64
+ },
65
+ );
66
+
67
+ /**
68
+ * Mutable config state. Tests flip `cdpInspectEnabled` and
69
+ * `desktopAutoConfig` to control the factory's config-based selection
70
+ * without needing a real config file.
71
+ */
72
+ let cdpInspectEnabled = false;
73
+ let desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
74
+
75
+ /**
76
+ * Captured log calls for verifying fallback log payloads.
77
+ */
78
+ const logWarnCalls: Array<{ args: unknown[] }> = [];
79
+ const logDebugCalls: Array<{ args: unknown[] }> = [];
80
+
81
+ mock.module("../extension-cdp-client.js", () => ({
82
+ createExtensionCdpClient: createExtensionCdpClientMock,
83
+ }));
84
+ mock.module("../local-cdp-client.js", () => ({
85
+ createLocalCdpClient: createLocalCdpClientMock,
86
+ }));
87
+ mock.module("../cdp-inspect-client.js", () => ({
88
+ createCdpInspectClient: createCdpInspectClientMock,
89
+ }));
90
+ mock.module("../../../../config/loader.js", () => ({
91
+ getConfig: () => ({
92
+ hostBrowser: {
93
+ cdpInspect: {
94
+ enabled: cdpInspectEnabled,
95
+ host: "localhost",
96
+ port: 9222,
97
+ probeTimeoutMs: 500,
98
+ desktopAuto: desktopAutoConfig,
99
+ },
100
+ },
101
+ }),
102
+ }));
103
+ mock.module("../../../../util/logger.js", () => ({
104
+ getLogger: () => ({
105
+ debug: (...args: unknown[]) => {
106
+ logDebugCalls.push({ args });
107
+ },
108
+ warn: (...args: unknown[]) => {
109
+ logWarnCalls.push({ args });
110
+ },
111
+ info: () => {},
112
+ error: () => {},
113
+ }),
114
+ }));
115
+
116
+ // Import under test AFTER mock.module calls so that the factory's
117
+ // top-level imports resolve to our fakes.
118
+ const {
119
+ getCdpClient,
120
+ buildCandidateList,
121
+ buildChainedClient,
122
+ buildPinnedCandidateList,
123
+ _resetDesktopAutoCooldown,
124
+ _getDesktopAutoCooldownSince,
125
+ recordDesktopAutoCooldown,
126
+ isDesktopAutoCooldownActive,
127
+ } = await import("../factory.js");
128
+
129
+ /**
130
+ * Minimal ToolContext suitable for factory tests. Only the fields the
131
+ * factory reads (`conversationId` and `hostBrowserProxy`) need to be
132
+ * populated; other required fields are cast away.
133
+ */
134
+ function makeContext(
135
+ overrides: Partial<ToolContext> & { conversationId: string },
136
+ ): ToolContext {
137
+ return overrides as unknown as ToolContext;
138
+ }
139
+
140
+ /**
141
+ * Create a fake HostBrowserProxy that reports as available.
142
+ */
143
+ function makeAvailableProxy(): HostBrowserProxy {
144
+ return {
145
+ request: mock(async () => ({})),
146
+ isAvailable: () => true,
147
+ } as unknown as HostBrowserProxy;
148
+ }
149
+
150
+ /**
151
+ * Create a fake HostBrowserProxy that reports as unavailable
152
+ * (proxy exists but client is disconnected).
153
+ */
154
+ function makeUnavailableProxy(): HostBrowserProxy {
155
+ return {
156
+ request: mock(async () => ({})),
157
+ isAvailable: () => false,
158
+ } as unknown as HostBrowserProxy;
159
+ }
160
+
161
+ describe("getCdpClient", () => {
162
+ beforeEach(() => {
163
+ createExtensionCdpClientMock.mockClear();
164
+ createLocalCdpClientMock.mockClear();
165
+ createCdpInspectClientMock.mockClear();
166
+ lastExtensionClient = undefined;
167
+ lastLocalClient = undefined;
168
+ lastCdpInspectClient = undefined;
169
+ cdpInspectEnabled = false;
170
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
171
+ _resetDesktopAutoCooldown();
172
+ logWarnCalls.length = 0;
173
+ logDebugCalls.length = 0;
174
+ });
175
+
176
+ // ── Candidate selection (kind reported before first send) ────────────
177
+
178
+ test("routes to ExtensionCdpClient when hostBrowserProxy is set and available", async () => {
179
+ const fakeProxy = makeAvailableProxy();
180
+ const ctx = makeContext({
181
+ conversationId: "test-convo",
182
+ hostBrowserProxy: fakeProxy,
183
+ });
184
+
185
+ const client = getCdpClient(ctx);
186
+
187
+ // kind should reflect extension before first send (top candidate)
188
+ expect(client.kind).toBe("extension");
189
+ expect(client.conversationId).toBe("test-convo");
190
+
191
+ // Lazy creation: client is not created until first send
192
+ const result = await client.send<{ ok: boolean; via: string }>(
193
+ "Page.navigate",
194
+ { url: "https://example.com" },
195
+ );
196
+ expect(result).toEqual({ ok: true, via: "extension" });
197
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
198
+ expect(createExtensionCdpClientMock).toHaveBeenCalledWith(
199
+ fakeProxy,
200
+ "test-convo",
201
+ );
202
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
203
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
204
+ });
205
+
206
+ test("skips extension when hostBrowserProxy is present but unavailable", async () => {
207
+ const fakeProxy = makeUnavailableProxy();
208
+ const ctx = makeContext({
209
+ conversationId: "disconnected-proxy",
210
+ hostBrowserProxy: fakeProxy,
211
+ });
212
+
213
+ const client = getCdpClient(ctx);
214
+
215
+ // Should fall through to local since extension is not available
216
+ expect(client.kind).toBe("local");
217
+ expect(client.conversationId).toBe("disconnected-proxy");
218
+
219
+ const result = await client.send<{ ok: boolean; via: string }>(
220
+ "Page.navigate",
221
+ );
222
+ expect(result).toEqual({ ok: true, via: "local" });
223
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
224
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
225
+ });
226
+
227
+ test("skips extension but uses cdp-inspect when proxy unavailable and cdp-inspect enabled", async () => {
228
+ cdpInspectEnabled = true;
229
+ const fakeProxy = makeUnavailableProxy();
230
+ const ctx = makeContext({
231
+ conversationId: "disconnected-inspect",
232
+ hostBrowserProxy: fakeProxy,
233
+ });
234
+
235
+ const client = getCdpClient(ctx);
236
+
237
+ expect(client.kind).toBe("cdp-inspect");
238
+
239
+ const result = await client.send<{ ok: boolean; via: string }>(
240
+ "Page.navigate",
241
+ );
242
+ expect(result).toEqual({ ok: true, via: "cdp-inspect" });
243
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
244
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
245
+ });
246
+
247
+ test("extension wins even when cdpInspect is enabled", async () => {
248
+ cdpInspectEnabled = true;
249
+ const fakeProxy = makeAvailableProxy();
250
+ const ctx = makeContext({
251
+ conversationId: "ext-wins",
252
+ hostBrowserProxy: fakeProxy,
253
+ });
254
+
255
+ const client = getCdpClient(ctx);
256
+
257
+ expect(client.kind).toBe("extension");
258
+ const result = await client.send<{ ok: boolean; via: string }>(
259
+ "Page.navigate",
260
+ );
261
+ expect(result).toEqual({ ok: true, via: "extension" });
262
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
263
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
264
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
265
+ });
266
+
267
+ test("routes to CdpInspectClient when cdpInspect is enabled and extension is absent", async () => {
268
+ cdpInspectEnabled = true;
269
+ const ctx = makeContext({
270
+ conversationId: "inspect-convo",
271
+ hostBrowserProxy: undefined,
272
+ });
273
+
274
+ const client = getCdpClient(ctx);
275
+
276
+ expect(client.kind).toBe("cdp-inspect");
277
+ expect(client.conversationId).toBe("inspect-convo");
278
+
279
+ const result = await client.send<{ ok: boolean; via: string }>(
280
+ "Page.navigate",
281
+ { url: "https://example.com" },
282
+ );
283
+ expect(result).toEqual({ ok: true, via: "cdp-inspect" });
284
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
285
+ expect(createCdpInspectClientMock).toHaveBeenCalledWith("inspect-convo", {
286
+ host: "localhost",
287
+ port: 9222,
288
+ discoveryTimeoutMs: 500,
289
+ });
290
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
291
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
292
+ });
293
+
294
+ test("routes to LocalCdpClient when cdpInspect is disabled and extension is absent", async () => {
295
+ cdpInspectEnabled = false;
296
+ const ctx = makeContext({
297
+ conversationId: "local-convo",
298
+ hostBrowserProxy: undefined,
299
+ });
300
+
301
+ const client = getCdpClient(ctx);
302
+
303
+ expect(client.kind).toBe("local");
304
+ expect(client.conversationId).toBe("local-convo");
305
+
306
+ const result = await client.send<{ ok: boolean; via: string }>(
307
+ "Runtime.evaluate",
308
+ { expression: "1+1" },
309
+ );
310
+ expect(result).toEqual({ ok: true, via: "local" });
311
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
312
+ expect(createLocalCdpClientMock).toHaveBeenCalledWith("local-convo");
313
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
314
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
315
+ });
316
+
317
+ test("routes to LocalCdpClient when hostBrowserProxy key is omitted", async () => {
318
+ const ctx = makeContext({ conversationId: "another-convo" });
319
+
320
+ const client = getCdpClient(ctx);
321
+
322
+ expect(client.kind).toBe("local");
323
+ expect(client.conversationId).toBe("another-convo");
324
+
325
+ await client.send("Runtime.evaluate");
326
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
327
+ expect(createLocalCdpClientMock).toHaveBeenCalledWith("another-convo");
328
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
329
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
330
+ });
331
+
332
+ // ── Backwards compatibility: omitted mode behaves as auto ───────────
333
+
334
+ test("getCdpClient without options behaves identically to auto mode", async () => {
335
+ const fakeProxy = makeAvailableProxy();
336
+ const ctx = makeContext({
337
+ conversationId: "no-opts",
338
+ hostBrowserProxy: fakeProxy,
339
+ });
340
+
341
+ const client = getCdpClient(ctx);
342
+ expect(client.kind).toBe("extension");
343
+ const result = await client.send<{ ok: boolean; via: string }>(
344
+ "Page.navigate",
345
+ );
346
+ expect(result).toEqual({ ok: true, via: "extension" });
347
+ });
348
+
349
+ test("getCdpClient with explicit auto mode behaves identically to omitted mode", async () => {
350
+ const ctx = makeContext({ conversationId: "explicit-auto" });
351
+
352
+ const client = getCdpClient(ctx, { mode: "auto" });
353
+ expect(client.kind).toBe("local");
354
+ const result = await client.send<{ ok: boolean; via: string }>(
355
+ "Runtime.evaluate",
356
+ );
357
+ expect(result).toEqual({ ok: true, via: "local" });
358
+ });
359
+
360
+ // ── send() forwarding ────────────────────────────────────────────────
361
+
362
+ test("forwards send() through the manager to the extension-backed client", async () => {
363
+ const fakeProxy = makeAvailableProxy();
364
+ const ctx = makeContext({
365
+ conversationId: "send-ext",
366
+ hostBrowserProxy: fakeProxy,
367
+ });
368
+
369
+ const client = getCdpClient(ctx);
370
+ const result = await client.send<{ ok: boolean; via: string }>(
371
+ "Page.navigate",
372
+ { url: "https://example.com" },
373
+ );
374
+
375
+ expect(result).toEqual({ ok: true, via: "extension" });
376
+ expect(lastExtensionClient?.send).toHaveBeenCalledTimes(1);
377
+ expect(lastExtensionClient?.send).toHaveBeenCalledWith(
378
+ "Page.navigate",
379
+ { url: "https://example.com" },
380
+ undefined,
381
+ );
382
+ expect(lastLocalClient).toBeUndefined();
383
+ expect(lastCdpInspectClient).toBeUndefined();
384
+ });
385
+
386
+ test("forwards send() through the manager to the local-backed client", async () => {
387
+ const ctx = makeContext({ conversationId: "send-local" });
388
+
389
+ const client = getCdpClient(ctx);
390
+ const result = await client.send<{ ok: boolean; via: string }>(
391
+ "Runtime.evaluate",
392
+ { expression: "1+1" },
393
+ );
394
+
395
+ expect(result).toEqual({ ok: true, via: "local" });
396
+ expect(lastLocalClient?.send).toHaveBeenCalledTimes(1);
397
+ expect(lastLocalClient?.send).toHaveBeenCalledWith(
398
+ "Runtime.evaluate",
399
+ { expression: "1+1" },
400
+ undefined,
401
+ );
402
+ expect(lastExtensionClient).toBeUndefined();
403
+ expect(lastCdpInspectClient).toBeUndefined();
404
+ });
405
+
406
+ test("forwards send() through the manager to the cdp-inspect-backed client", async () => {
407
+ cdpInspectEnabled = true;
408
+ const ctx = makeContext({ conversationId: "send-inspect" });
409
+
410
+ const client = getCdpClient(ctx);
411
+ const result = await client.send<{ ok: boolean; via: string }>(
412
+ "Page.navigate",
413
+ { url: "https://example.com" },
414
+ );
415
+
416
+ expect(result).toEqual({ ok: true, via: "cdp-inspect" });
417
+ expect(lastCdpInspectClient?.send).toHaveBeenCalledTimes(1);
418
+ expect(lastCdpInspectClient?.send).toHaveBeenCalledWith(
419
+ "Page.navigate",
420
+ { url: "https://example.com" },
421
+ undefined,
422
+ );
423
+ expect(lastExtensionClient).toBeUndefined();
424
+ expect(lastLocalClient).toBeUndefined();
425
+ });
426
+
427
+ // ── Error propagation ────────────────────────────────────────────────
428
+
429
+ test("propagates CdpError (cdp_error) thrown by the underlying client without failover", async () => {
430
+ cdpInspectEnabled = true;
431
+ const ctx = makeContext({ conversationId: "err-no-failover" });
432
+ const client = getCdpClient(ctx);
433
+
434
+ // Override cdp-inspect client to throw a cdp_error
435
+ createCdpInspectClientMock.mockImplementationOnce(
436
+ (conversationId: string) => {
437
+ const c = makeFakeCdpInspectClient(conversationId);
438
+ c.send = mock(async () => {
439
+ throw new CdpError("cdp_error", "kaboom", {
440
+ cdpMethod: "Page.navigate",
441
+ });
442
+ });
443
+ lastCdpInspectClient = c;
444
+ return c;
445
+ },
446
+ );
447
+
448
+ await expect(
449
+ client.send("Page.navigate", { url: "https://example.com" }),
450
+ ).rejects.toMatchObject({ code: "cdp_error", message: "kaboom" });
451
+
452
+ // Should NOT have fallen through to local
453
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
454
+ });
455
+
456
+ test("propagates caller AbortSignal to the underlying client", async () => {
457
+ const ctx = makeContext({ conversationId: "abort-local" });
458
+ const client = getCdpClient(ctx);
459
+ const controller = new AbortController();
460
+
461
+ // First, do a normal send to establish the sticky backend
462
+ await client.send("Runtime.evaluate", { expression: "1" });
463
+
464
+ let sawSignal: AbortSignal | undefined;
465
+ lastLocalClient!.send = mock(
466
+ async (
467
+ _method: string,
468
+ _params?: Record<string, unknown>,
469
+ signal?: AbortSignal,
470
+ ) => {
471
+ sawSignal = signal;
472
+ if (signal?.aborted) {
473
+ throw new CdpError("aborted", "aborted before send");
474
+ }
475
+ return {};
476
+ },
477
+ );
478
+
479
+ controller.abort();
480
+ await expect(
481
+ client.send("Page.navigate", { url: "https://x" }, controller.signal),
482
+ ).rejects.toMatchObject({ code: "aborted" });
483
+ expect(sawSignal).toBe(controller.signal);
484
+ });
485
+
486
+ // ── Dispose ──────────────────────────────────────────────────────────
487
+
488
+ test("dispose() tears down the underlying client and rejects further sends", async () => {
489
+ const ctx = makeContext({ conversationId: "dispose-local" });
490
+ const client = getCdpClient(ctx);
491
+
492
+ // Trigger client creation via send
493
+ await client.send("Runtime.evaluate");
494
+ expect(lastLocalClient).toBeDefined();
495
+
496
+ client.dispose();
497
+ expect(lastLocalClient?.dispose).toHaveBeenCalledTimes(1);
498
+
499
+ // A second dispose is a no-op.
500
+ client.dispose();
501
+ expect(lastLocalClient?.dispose).toHaveBeenCalledTimes(1);
502
+
503
+ await expect(client.send("Runtime.evaluate")).rejects.toMatchObject({
504
+ code: "disposed",
505
+ });
506
+ });
507
+
508
+ test("dispose() on an extension-backed client tears down the extension client", async () => {
509
+ const fakeProxy = makeAvailableProxy();
510
+ const ctx = makeContext({
511
+ conversationId: "dispose-ext",
512
+ hostBrowserProxy: fakeProxy,
513
+ });
514
+
515
+ const client = getCdpClient(ctx);
516
+ await client.send("Page.navigate");
517
+ client.dispose();
518
+
519
+ expect(lastExtensionClient?.dispose).toHaveBeenCalledTimes(1);
520
+ });
521
+
522
+ test("dispose() on a cdp-inspect-backed client tears down the inspect client", async () => {
523
+ cdpInspectEnabled = true;
524
+ const ctx = makeContext({ conversationId: "dispose-inspect" });
525
+
526
+ const client = getCdpClient(ctx);
527
+ await client.send("Page.navigate");
528
+ client.dispose();
529
+
530
+ expect(lastCdpInspectClient?.dispose).toHaveBeenCalledTimes(1);
531
+ });
532
+
533
+ test("send() after dispose() on a cdp-inspect-backed client rejects with disposed", async () => {
534
+ cdpInspectEnabled = true;
535
+ const ctx = makeContext({ conversationId: "post-dispose-inspect" });
536
+
537
+ const client = getCdpClient(ctx);
538
+ await client.send("Page.navigate");
539
+ client.dispose();
540
+
541
+ // Double dispose is a no-op.
542
+ client.dispose();
543
+ expect(lastCdpInspectClient?.dispose).toHaveBeenCalledTimes(1);
544
+
545
+ await expect(client.send("Page.navigate")).rejects.toMatchObject({
546
+ code: "disposed",
547
+ });
548
+ });
549
+
550
+ test("dispose() before first send still rejects further sends", async () => {
551
+ const ctx = makeContext({ conversationId: "dispose-before-send" });
552
+ const client = getCdpClient(ctx);
553
+
554
+ client.dispose();
555
+
556
+ await expect(client.send("Runtime.evaluate")).rejects.toMatchObject({
557
+ code: "disposed",
558
+ });
559
+ // No clients should have been created
560
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
561
+ });
562
+
563
+ // ── transportInterface backwards compatibility ──────────────────────
564
+
565
+ test("context without transportInterface still routes to local backend", async () => {
566
+ const ctx = makeContext({ conversationId: "no-interface" });
567
+ expect(ctx.transportInterface).toBeUndefined();
568
+
569
+ const client = getCdpClient(ctx);
570
+
571
+ expect(client.kind).toBe("local");
572
+ expect(client.conversationId).toBe("no-interface");
573
+ await client.send("Runtime.evaluate");
574
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
575
+ });
576
+
577
+ test("context with transportInterface set routes normally to extension backend", async () => {
578
+ const fakeProxy = makeAvailableProxy();
579
+ const ctx = makeContext({
580
+ conversationId: "macos-ext",
581
+ hostBrowserProxy: fakeProxy,
582
+ transportInterface: "macos",
583
+ });
584
+
585
+ const client = getCdpClient(ctx);
586
+
587
+ expect(client.kind).toBe("extension");
588
+ expect(client.conversationId).toBe("macos-ext");
589
+ await client.send("Page.navigate");
590
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
591
+ });
592
+
593
+ test("context with transportInterface=macos routes to desktop-auto cdp-inspect when no proxy", async () => {
594
+ const ctx = makeContext({
595
+ conversationId: "macos-local",
596
+ transportInterface: "macos",
597
+ });
598
+
599
+ const client = getCdpClient(ctx);
600
+
601
+ // desktopAuto.enabled is true by default and no proxy is provisioned,
602
+ // so cdp-inspect is the first candidate (desktop-auto path).
603
+ expect(client.kind).toBe("cdp-inspect");
604
+ expect(client.conversationId).toBe("macos-local");
605
+ await client.send("Page.navigate");
606
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
607
+ });
608
+
609
+ test("context with transportInterface set routes to cdp-inspect when enabled", async () => {
610
+ cdpInspectEnabled = true;
611
+ const ctx = makeContext({
612
+ conversationId: "macos-inspect",
613
+ transportInterface: "macos",
614
+ });
615
+
616
+ const client = getCdpClient(ctx);
617
+
618
+ expect(client.kind).toBe("cdp-inspect");
619
+ expect(client.conversationId).toBe("macos-inspect");
620
+ await client.send("Page.navigate");
621
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
622
+ });
623
+ });
624
+
625
+ // ── buildCandidateList tests ─────────────────────────────────────────────
626
+
627
+ describe("buildCandidateList", () => {
628
+ beforeEach(() => {
629
+ cdpInspectEnabled = false;
630
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
631
+ _resetDesktopAutoCooldown();
632
+ });
633
+
634
+ test("includes extension candidate when proxy is present and available", () => {
635
+ const fakeProxy = makeAvailableProxy();
636
+ const ctx = makeContext({
637
+ conversationId: "candidates-ext",
638
+ hostBrowserProxy: fakeProxy,
639
+ });
640
+
641
+ const candidates = buildCandidateList(ctx);
642
+
643
+ expect(candidates.length).toBeGreaterThanOrEqual(2);
644
+ expect(candidates[0].kind).toBe("extension");
645
+ // Local is always present as fallback
646
+ expect(candidates[candidates.length - 1].kind).toBe("local");
647
+ });
648
+
649
+ test("excludes extension candidate when proxy is present but unavailable", () => {
650
+ const fakeProxy = makeUnavailableProxy();
651
+ const ctx = makeContext({
652
+ conversationId: "candidates-no-ext",
653
+ hostBrowserProxy: fakeProxy,
654
+ });
655
+
656
+ const candidates = buildCandidateList(ctx);
657
+
658
+ expect(candidates.every((c) => c.kind !== "extension")).toBe(true);
659
+ expect(candidates[0].kind).toBe("local");
660
+ });
661
+
662
+ test("includes cdp-inspect candidate when enabled in config", () => {
663
+ cdpInspectEnabled = true;
664
+ const ctx = makeContext({ conversationId: "candidates-inspect" });
665
+
666
+ const candidates = buildCandidateList(ctx);
667
+
668
+ expect(candidates[0].kind).toBe("cdp-inspect");
669
+ expect(candidates[1].kind).toBe("local");
670
+ });
671
+
672
+ test("candidate order: extension > cdp-inspect > local when all present", () => {
673
+ cdpInspectEnabled = true;
674
+ const fakeProxy = makeAvailableProxy();
675
+ const ctx = makeContext({
676
+ conversationId: "candidates-all",
677
+ hostBrowserProxy: fakeProxy,
678
+ });
679
+
680
+ const candidates = buildCandidateList(ctx);
681
+
682
+ expect(candidates.length).toBe(3);
683
+ expect(candidates[0].kind).toBe("extension");
684
+ expect(candidates[1].kind).toBe("cdp-inspect");
685
+ expect(candidates[2].kind).toBe("local");
686
+ });
687
+
688
+ test("local is always included as final candidate", () => {
689
+ const ctx = makeContext({ conversationId: "candidates-local-only" });
690
+
691
+ const candidates = buildCandidateList(ctx);
692
+
693
+ expect(candidates.length).toBe(1);
694
+ expect(candidates[0].kind).toBe("local");
695
+ });
696
+ });
697
+
698
+ // ── buildChainedClient failover tests ────────────────────────────────────
699
+
700
+ describe("buildChainedClient failover", () => {
701
+ beforeEach(() => {
702
+ createExtensionCdpClientMock.mockClear();
703
+ createLocalCdpClientMock.mockClear();
704
+ createCdpInspectClientMock.mockClear();
705
+ lastExtensionClient = undefined;
706
+ lastLocalClient = undefined;
707
+ lastCdpInspectClient = undefined;
708
+ cdpInspectEnabled = false;
709
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
710
+ _resetDesktopAutoCooldown();
711
+ logWarnCalls.length = 0;
712
+ logDebugCalls.length = 0;
713
+ });
714
+
715
+ test("fails over from extension to local on transport_error", async () => {
716
+ const fakeProxy = makeAvailableProxy();
717
+
718
+ // Make extension client fail with transport_error
719
+ createExtensionCdpClientMock.mockImplementationOnce(
720
+ (_proxy: HostBrowserProxy, conversationId: string) => {
721
+ const c = makeFakeExtensionClient(conversationId);
722
+ c.send = mock(async () => {
723
+ throw new CdpError(
724
+ "transport_error",
725
+ "Extension WebSocket disconnected",
726
+ {
727
+ cdpMethod: "Page.navigate",
728
+ },
729
+ );
730
+ });
731
+ lastExtensionClient = c;
732
+ return c;
733
+ },
734
+ );
735
+
736
+ const ctx = makeContext({
737
+ conversationId: "failover-ext-to-local",
738
+ hostBrowserProxy: fakeProxy,
739
+ });
740
+
741
+ const client = getCdpClient(ctx);
742
+ const result = await client.send<{ ok: boolean; via: string }>(
743
+ "Page.navigate",
744
+ { url: "https://example.com" },
745
+ );
746
+
747
+ expect(result).toEqual({ ok: true, via: "local" });
748
+ // Extension was tried first
749
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
750
+ // Then local was used as fallback
751
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
752
+ });
753
+
754
+ test("fails over from extension to cdp-inspect to local on transport errors", async () => {
755
+ cdpInspectEnabled = true;
756
+ const fakeProxy = makeAvailableProxy();
757
+
758
+ // Make extension fail with transport_error
759
+ createExtensionCdpClientMock.mockImplementationOnce(
760
+ (_proxy: HostBrowserProxy, conversationId: string) => {
761
+ const c = makeFakeExtensionClient(conversationId);
762
+ c.send = mock(async () => {
763
+ throw new CdpError("transport_error", "Extension disconnected", {
764
+ cdpMethod: "Page.navigate",
765
+ });
766
+ });
767
+ lastExtensionClient = c;
768
+ return c;
769
+ },
770
+ );
771
+
772
+ // Make cdp-inspect also fail with transport_error
773
+ createCdpInspectClientMock.mockImplementationOnce(
774
+ (conversationId: string) => {
775
+ const c = makeFakeCdpInspectClient(conversationId);
776
+ c.send = mock(async () => {
777
+ throw new CdpError("transport_error", "Chrome not running", {
778
+ cdpMethod: "Page.navigate",
779
+ });
780
+ });
781
+ lastCdpInspectClient = c;
782
+ return c;
783
+ },
784
+ );
785
+
786
+ const ctx = makeContext({
787
+ conversationId: "failover-chain",
788
+ hostBrowserProxy: fakeProxy,
789
+ });
790
+
791
+ const client = getCdpClient(ctx);
792
+ const result = await client.send<{ ok: boolean; via: string }>(
793
+ "Page.navigate",
794
+ { url: "https://example.com" },
795
+ );
796
+
797
+ expect(result).toEqual({ ok: true, via: "local" });
798
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
799
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
800
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
801
+ });
802
+
803
+ test("does NOT fail over on cdp_error -- propagates immediately", async () => {
804
+ cdpInspectEnabled = true;
805
+ const fakeProxy = makeAvailableProxy();
806
+
807
+ // Make extension fail with cdp_error (not transport_error)
808
+ createExtensionCdpClientMock.mockImplementationOnce(
809
+ (_proxy: HostBrowserProxy, conversationId: string) => {
810
+ const c = makeFakeExtensionClient(conversationId);
811
+ c.send = mock(async () => {
812
+ throw new CdpError("cdp_error", "Protocol error", {
813
+ cdpMethod: "Page.navigate",
814
+ });
815
+ });
816
+ lastExtensionClient = c;
817
+ return c;
818
+ },
819
+ );
820
+
821
+ const ctx = makeContext({
822
+ conversationId: "no-failover-cdp-error",
823
+ hostBrowserProxy: fakeProxy,
824
+ });
825
+
826
+ const client = getCdpClient(ctx);
827
+
828
+ await expect(
829
+ client.send("Page.navigate", { url: "https://example.com" }),
830
+ ).rejects.toMatchObject({
831
+ code: "cdp_error",
832
+ message: "Protocol error",
833
+ });
834
+
835
+ // cdp-inspect and local should NOT have been tried
836
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
837
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
838
+ });
839
+
840
+ test("transport_error on last candidate propagates the error", async () => {
841
+ // Only local is available (no extension, no cdp-inspect)
842
+ const ctx = makeContext({ conversationId: "last-candidate-fail" });
843
+
844
+ // Make local fail with transport_error
845
+ createLocalCdpClientMock.mockImplementationOnce(
846
+ (conversationId: string) => {
847
+ const c = makeFakeLocalClient(conversationId);
848
+ c.send = mock(async () => {
849
+ throw new CdpError("transport_error", "Playwright failed to launch", {
850
+ cdpMethod: "Page.navigate",
851
+ });
852
+ });
853
+ lastLocalClient = c;
854
+ return c;
855
+ },
856
+ );
857
+
858
+ const client = getCdpClient(ctx);
859
+
860
+ await expect(client.send("Page.navigate")).rejects.toMatchObject({
861
+ code: "transport_error",
862
+ message: "Playwright failed to launch",
863
+ });
864
+ });
865
+
866
+ // ── Sticky backend tests ─────────────────────────────────────────────
867
+
868
+ test("backend becomes sticky after first successful command", async () => {
869
+ cdpInspectEnabled = true;
870
+ const fakeProxy = makeAvailableProxy();
871
+
872
+ // Make extension fail on first call with transport_error
873
+ createExtensionCdpClientMock.mockImplementationOnce(
874
+ (_proxy: HostBrowserProxy, conversationId: string) => {
875
+ const c = makeFakeExtensionClient(conversationId);
876
+ c.send = mock(async () => {
877
+ throw new CdpError("transport_error", "Extension disconnected", {
878
+ cdpMethod: "Page.navigate",
879
+ });
880
+ });
881
+ lastExtensionClient = c;
882
+ return c;
883
+ },
884
+ );
885
+
886
+ const ctx = makeContext({
887
+ conversationId: "sticky-test",
888
+ hostBrowserProxy: fakeProxy,
889
+ });
890
+
891
+ const client = getCdpClient(ctx);
892
+
893
+ // First send fails over from extension to cdp-inspect
894
+ const result1 = await client.send<{ ok: boolean; via: string }>(
895
+ "Page.navigate",
896
+ { url: "https://example.com" },
897
+ );
898
+ expect(result1).toEqual({ ok: true, via: "cdp-inspect" });
899
+
900
+ // Second send should reuse cdp-inspect without trying extension again
901
+ const result2 = await client.send<{ ok: boolean; via: string }>(
902
+ "Runtime.evaluate",
903
+ { expression: "1+1" },
904
+ );
905
+ expect(result2).toEqual({ ok: true, via: "cdp-inspect" });
906
+
907
+ // Extension should only have been constructed once (during failover)
908
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
909
+ // cdp-inspect should only have been constructed once (sticky)
910
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
911
+ // Local should never have been constructed
912
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
913
+
914
+ // Verify the sticky client's send was called for both commands
915
+ // The first call is from failover, the second from sticky path
916
+ expect(lastCdpInspectClient?.send).toHaveBeenCalledTimes(2);
917
+ });
918
+
919
+ test("sticky backend does not change on subsequent transport errors", async () => {
920
+ const ctx = makeContext({ conversationId: "sticky-err" });
921
+
922
+ const client = getCdpClient(ctx);
923
+
924
+ // First send succeeds, establishing local as sticky
925
+ await client.send("Runtime.evaluate", { expression: "1" });
926
+ expect(client.kind).toBe("local");
927
+
928
+ // Now make local throw a transport error on second send
929
+ lastLocalClient!.send = mock(async () => {
930
+ throw new CdpError("transport_error", "Connection lost");
931
+ });
932
+
933
+ // The error should propagate without failover since backend is sticky
934
+ await expect(client.send("Page.navigate")).rejects.toMatchObject({
935
+ code: "transport_error",
936
+ });
937
+ });
938
+
939
+ // ── Edge cases ───────────────────────────────────────────────────────
940
+
941
+ test("buildChainedClient throws on empty candidate list", () => {
942
+ expect(() => buildChainedClient("test", [])).toThrow(
943
+ "CDP factory: no backend candidates available",
944
+ );
945
+ });
946
+
947
+ test("kind reflects the active backend after failover", async () => {
948
+ const fakeProxy = makeAvailableProxy();
949
+
950
+ // Make extension fail
951
+ createExtensionCdpClientMock.mockImplementationOnce(
952
+ (_proxy: HostBrowserProxy, conversationId: string) => {
953
+ const c = makeFakeExtensionClient(conversationId);
954
+ c.send = mock(async () => {
955
+ throw new CdpError("transport_error", "disconnected");
956
+ });
957
+ lastExtensionClient = c;
958
+ return c;
959
+ },
960
+ );
961
+
962
+ const ctx = makeContext({
963
+ conversationId: "kind-after-failover",
964
+ hostBrowserProxy: fakeProxy,
965
+ });
966
+
967
+ const client = getCdpClient(ctx);
968
+
969
+ // Before first send, kind reflects the first candidate
970
+ expect(client.kind).toBe("extension");
971
+
972
+ // After failover, kind should reflect the local backend
973
+ await client.send("Page.navigate");
974
+ expect(client.kind).toBe("local");
975
+ });
976
+
977
+ test("dispose cleans up failed backends from failover chain", async () => {
978
+ const fakeProxy = makeAvailableProxy();
979
+
980
+ // Make extension fail
981
+ createExtensionCdpClientMock.mockImplementationOnce(
982
+ (_proxy: HostBrowserProxy, conversationId: string) => {
983
+ const c = makeFakeExtensionClient(conversationId);
984
+ c.send = mock(async () => {
985
+ throw new CdpError("transport_error", "disconnected");
986
+ });
987
+ lastExtensionClient = c;
988
+ return c;
989
+ },
990
+ );
991
+
992
+ const ctx = makeContext({
993
+ conversationId: "dispose-failover",
994
+ hostBrowserProxy: fakeProxy,
995
+ });
996
+
997
+ const client = getCdpClient(ctx);
998
+ await client.send("Page.navigate");
999
+
1000
+ // Now dispose -- both the failed extension backend and the
1001
+ // successful local backend should be cleaned up.
1002
+ client.dispose();
1003
+
1004
+ // The extension client's dispose was already called during
1005
+ // failover (via manager.disposeAll()), and local's dispose should
1006
+ // be called now
1007
+ expect(lastLocalClient?.dispose).toHaveBeenCalled();
1008
+ });
1009
+ });
1010
+
1011
+ // ── Desktop-auto cdp-inspect for macOS ──────────────────────────────────
1012
+
1013
+ describe("desktop-auto cdp-inspect (macOS)", () => {
1014
+ beforeEach(() => {
1015
+ createExtensionCdpClientMock.mockClear();
1016
+ createLocalCdpClientMock.mockClear();
1017
+ createCdpInspectClientMock.mockClear();
1018
+ lastExtensionClient = undefined;
1019
+ lastLocalClient = undefined;
1020
+ lastCdpInspectClient = undefined;
1021
+ cdpInspectEnabled = false;
1022
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
1023
+ _resetDesktopAutoCooldown();
1024
+ logWarnCalls.length = 0;
1025
+ logDebugCalls.length = 0;
1026
+ });
1027
+
1028
+ // ── buildCandidateList with desktopAuto ─────────────────────────────
1029
+
1030
+ test("macOS turn includes cdp-inspect candidate even when enabled is false", () => {
1031
+ const ctx = makeContext({
1032
+ conversationId: "macos-auto",
1033
+ transportInterface: "macos",
1034
+ });
1035
+
1036
+ const candidates = buildCandidateList(ctx);
1037
+
1038
+ expect(candidates.length).toBe(2);
1039
+ expect(candidates[0].kind).toBe("cdp-inspect");
1040
+ expect(candidates[0].reason).toContain("desktopAuto");
1041
+ expect(candidates[1].kind).toBe("local");
1042
+ });
1043
+
1044
+ test("macOS turn with extension available: extension > cdp-inspect > local", () => {
1045
+ const fakeProxy = makeAvailableProxy();
1046
+ const ctx = makeContext({
1047
+ conversationId: "macos-all",
1048
+ hostBrowserProxy: fakeProxy,
1049
+ transportInterface: "macos",
1050
+ });
1051
+
1052
+ const candidates = buildCandidateList(ctx);
1053
+
1054
+ expect(candidates.length).toBe(3);
1055
+ expect(candidates[0].kind).toBe("extension");
1056
+ expect(candidates[1].kind).toBe("cdp-inspect");
1057
+ expect(candidates[1].reason).toContain("desktopAuto");
1058
+ expect(candidates[2].kind).toBe("local");
1059
+ });
1060
+
1061
+ test("macOS turn with proxy unavailable skips desktop-auto cdp-inspect (extension intent)", () => {
1062
+ const fakeProxy = makeUnavailableProxy();
1063
+ const ctx = makeContext({
1064
+ conversationId: "macos-proxy-unavailable-no-inspect",
1065
+ hostBrowserProxy: fakeProxy,
1066
+ transportInterface: "macos",
1067
+ });
1068
+
1069
+ const candidates = buildCandidateList(ctx);
1070
+
1071
+ // Should only include local -- cdp-inspect is suppressed because extension
1072
+ // transport is expected (proxy exists) but temporarily unavailable.
1073
+ expect(candidates.length).toBe(1);
1074
+ expect(candidates[0].kind).toBe("local");
1075
+ });
1076
+
1077
+ test("macOS turn with no proxy still includes desktop-auto cdp-inspect", () => {
1078
+ const ctx = makeContext({
1079
+ conversationId: "macos-no-proxy-inspect-allowed",
1080
+ transportInterface: "macos",
1081
+ });
1082
+
1083
+ const candidates = buildCandidateList(ctx);
1084
+
1085
+ // No proxy provisioned => cdp-inspect remains available as fallback
1086
+ expect(candidates.length).toBe(2);
1087
+ expect(candidates[0].kind).toBe("cdp-inspect");
1088
+ expect(candidates[0].reason).toContain("desktopAuto");
1089
+ expect(candidates[1].kind).toBe("local");
1090
+ });
1091
+
1092
+ test("macOS turn with extension available still includes cdp-inspect as fallback", () => {
1093
+ const fakeProxy = makeAvailableProxy();
1094
+ const ctx = makeContext({
1095
+ conversationId: "macos-ext-available-inspect-fallback",
1096
+ hostBrowserProxy: fakeProxy,
1097
+ transportInterface: "macos",
1098
+ });
1099
+
1100
+ const candidates = buildCandidateList(ctx);
1101
+
1102
+ // Extension is available => extension + cdp-inspect (desktop-auto) + local
1103
+ expect(candidates.length).toBe(3);
1104
+ expect(candidates[0].kind).toBe("extension");
1105
+ expect(candidates[1].kind).toBe("cdp-inspect");
1106
+ expect(candidates[1].reason).toContain("desktopAuto");
1107
+ expect(candidates[2].kind).toBe("local");
1108
+ });
1109
+
1110
+ test("macOS turn does NOT include cdp-inspect when desktopAuto.enabled is false", () => {
1111
+ desktopAutoConfig = { enabled: false, cooldownMs: 30_000 };
1112
+ const ctx = makeContext({
1113
+ conversationId: "macos-no-auto",
1114
+ transportInterface: "macos",
1115
+ });
1116
+
1117
+ const candidates = buildCandidateList(ctx);
1118
+
1119
+ expect(candidates.length).toBe(1);
1120
+ expect(candidates[0].kind).toBe("local");
1121
+ });
1122
+
1123
+ test("non-macOS turn does NOT include cdp-inspect when enabled is false", () => {
1124
+ const ctx = makeContext({
1125
+ conversationId: "cli-no-auto",
1126
+ transportInterface: "cli",
1127
+ });
1128
+
1129
+ const candidates = buildCandidateList(ctx);
1130
+
1131
+ expect(candidates.length).toBe(1);
1132
+ expect(candidates[0].kind).toBe("local");
1133
+ });
1134
+
1135
+ test("non-macOS turn without transportInterface does NOT include cdp-inspect", () => {
1136
+ const ctx = makeContext({
1137
+ conversationId: "no-interface-no-auto",
1138
+ });
1139
+
1140
+ const candidates = buildCandidateList(ctx);
1141
+
1142
+ expect(candidates.length).toBe(1);
1143
+ expect(candidates[0].kind).toBe("local");
1144
+ });
1145
+
1146
+ test("explicit cdpInspect.enabled takes precedence over desktopAuto on macOS", () => {
1147
+ cdpInspectEnabled = true;
1148
+ const ctx = makeContext({
1149
+ conversationId: "macos-explicit",
1150
+ transportInterface: "macos",
1151
+ });
1152
+
1153
+ const candidates = buildCandidateList(ctx);
1154
+
1155
+ // Should include cdp-inspect via the explicit path, not desktopAuto
1156
+ expect(candidates.length).toBe(2);
1157
+ expect(candidates[0].kind).toBe("cdp-inspect");
1158
+ expect(candidates[0].reason).toBe("cdpInspect enabled in config");
1159
+ expect(candidates[1].kind).toBe("local");
1160
+ });
1161
+
1162
+ // ── Cooldown behaviour ──────────────────────────────────────────────
1163
+
1164
+ test("macOS turn skips cdp-inspect when cooldown is active", () => {
1165
+ // Record a cooldown
1166
+ recordDesktopAutoCooldown();
1167
+
1168
+ const ctx = makeContext({
1169
+ conversationId: "macos-cooldown",
1170
+ transportInterface: "macos",
1171
+ });
1172
+
1173
+ const candidates = buildCandidateList(ctx);
1174
+
1175
+ // Should skip cdp-inspect and only include local
1176
+ expect(candidates.length).toBe(1);
1177
+ expect(candidates[0].kind).toBe("local");
1178
+ });
1179
+
1180
+ test("macOS turn includes cdp-inspect after cooldown expires", () => {
1181
+ // Set cooldown to 0 (disabled)
1182
+ desktopAutoConfig = { enabled: true, cooldownMs: 0 };
1183
+
1184
+ // Record a "cooldown" -- but with cooldownMs=0 it should be ignored
1185
+ recordDesktopAutoCooldown();
1186
+
1187
+ const ctx = makeContext({
1188
+ conversationId: "macos-expired-cooldown",
1189
+ transportInterface: "macos",
1190
+ });
1191
+
1192
+ const candidates = buildCandidateList(ctx);
1193
+
1194
+ // cooldownMs=0 means never suppress
1195
+ expect(candidates.length).toBe(2);
1196
+ expect(candidates[0].kind).toBe("cdp-inspect");
1197
+ expect(candidates[1].kind).toBe("local");
1198
+ });
1199
+
1200
+ // ── Cooldown recording on transport failures ───────────────────────
1201
+
1202
+ test("desktop-auto cdp-inspect transport failure records cooldown", async () => {
1203
+ // Make cdp-inspect fail with transport_error
1204
+ createCdpInspectClientMock.mockImplementationOnce(
1205
+ (conversationId: string) => {
1206
+ const c = makeFakeCdpInspectClient(conversationId);
1207
+ c.send = mock(async () => {
1208
+ throw new CdpError("transport_error", "Connection refused", {
1209
+ cdpMethod: "Page.navigate",
1210
+ });
1211
+ });
1212
+ lastCdpInspectClient = c;
1213
+ return c;
1214
+ },
1215
+ );
1216
+
1217
+ const ctx = makeContext({
1218
+ conversationId: "macos-cooldown-record",
1219
+ transportInterface: "macos",
1220
+ });
1221
+
1222
+ const client = getCdpClient(ctx);
1223
+
1224
+ // First send: cdp-inspect fails, falls over to local
1225
+ const result = await client.send<{ ok: boolean; via: string }>(
1226
+ "Page.navigate",
1227
+ );
1228
+ expect(result).toEqual({ ok: true, via: "local" });
1229
+
1230
+ // Cooldown should now be active
1231
+ expect(_getDesktopAutoCooldownSince()).toBeGreaterThan(0);
1232
+ expect(isDesktopAutoCooldownActive(30_000)).toBe(true);
1233
+
1234
+ // Subsequent buildCandidateList should skip cdp-inspect
1235
+ client.dispose();
1236
+ const ctx2 = makeContext({
1237
+ conversationId: "macos-after-cooldown",
1238
+ transportInterface: "macos",
1239
+ });
1240
+ const candidates = buildCandidateList(ctx2);
1241
+ expect(candidates.length).toBe(1);
1242
+ expect(candidates[0].kind).toBe("local");
1243
+ });
1244
+
1245
+ test("macOS turn with proxy unavailable routes to local without trying cdp-inspect", async () => {
1246
+ const fakeProxy = makeUnavailableProxy();
1247
+ const ctx = makeContext({
1248
+ conversationId: "macos-proxy-unavail-route",
1249
+ hostBrowserProxy: fakeProxy,
1250
+ transportInterface: "macos",
1251
+ });
1252
+
1253
+ const client = getCdpClient(ctx);
1254
+
1255
+ // Should go straight to local -- no cdp-inspect candidate inserted
1256
+ expect(client.kind).toBe("local");
1257
+ const result = await client.send<{ ok: boolean; via: string }>(
1258
+ "Page.navigate",
1259
+ );
1260
+ expect(result).toEqual({ ok: true, via: "local" });
1261
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
1262
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
1263
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
1264
+ client.dispose();
1265
+ });
1266
+
1267
+ test("explicit config cdp-inspect failure does NOT record desktop-auto cooldown", async () => {
1268
+ cdpInspectEnabled = true;
1269
+
1270
+ // Make cdp-inspect fail with transport_error
1271
+ createCdpInspectClientMock.mockImplementationOnce(
1272
+ (conversationId: string) => {
1273
+ const c = makeFakeCdpInspectClient(conversationId);
1274
+ c.send = mock(async () => {
1275
+ throw new CdpError("transport_error", "Connection refused", {
1276
+ cdpMethod: "Page.navigate",
1277
+ });
1278
+ });
1279
+ lastCdpInspectClient = c;
1280
+ return c;
1281
+ },
1282
+ );
1283
+
1284
+ const ctx = makeContext({
1285
+ conversationId: "explicit-no-cooldown",
1286
+ transportInterface: "macos",
1287
+ });
1288
+
1289
+ const client = getCdpClient(ctx);
1290
+ await client.send<{ ok: boolean; via: string }>("Page.navigate");
1291
+ client.dispose();
1292
+
1293
+ // Cooldown should NOT be recorded for explicit config candidates
1294
+ expect(_getDesktopAutoCooldownSince()).toBe(0);
1295
+ });
1296
+
1297
+ // ── Cooldown utility function tests ─────────────────────────────────
1298
+
1299
+ test("isDesktopAutoCooldownActive returns false when no cooldown recorded", () => {
1300
+ expect(isDesktopAutoCooldownActive(30_000)).toBe(false);
1301
+ });
1302
+
1303
+ test("isDesktopAutoCooldownActive returns false when cooldownMs is 0", () => {
1304
+ recordDesktopAutoCooldown();
1305
+ expect(isDesktopAutoCooldownActive(0)).toBe(false);
1306
+ });
1307
+
1308
+ test("isDesktopAutoCooldownActive returns true within the window", () => {
1309
+ recordDesktopAutoCooldown();
1310
+ expect(isDesktopAutoCooldownActive(30_000)).toBe(true);
1311
+ });
1312
+
1313
+ test("_resetDesktopAutoCooldown clears the cooldown", () => {
1314
+ recordDesktopAutoCooldown();
1315
+ expect(isDesktopAutoCooldownActive(30_000)).toBe(true);
1316
+ _resetDesktopAutoCooldown();
1317
+ expect(isDesktopAutoCooldownActive(30_000)).toBe(false);
1318
+ expect(_getDesktopAutoCooldownSince()).toBe(0);
1319
+ });
1320
+ });
1321
+
1322
+ // ── Pinned-mode tests ────────────────────────────────────────────────────
1323
+
1324
+ describe("pinned-mode selection", () => {
1325
+ beforeEach(() => {
1326
+ createExtensionCdpClientMock.mockClear();
1327
+ createLocalCdpClientMock.mockClear();
1328
+ createCdpInspectClientMock.mockClear();
1329
+ lastExtensionClient = undefined;
1330
+ lastLocalClient = undefined;
1331
+ lastCdpInspectClient = undefined;
1332
+ cdpInspectEnabled = false;
1333
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
1334
+ _resetDesktopAutoCooldown();
1335
+ logWarnCalls.length = 0;
1336
+ logDebugCalls.length = 0;
1337
+ });
1338
+
1339
+ // ── Pinned extension ────────────────────────────────────────────────
1340
+
1341
+ test("pinned extension mode routes to extension when proxy is available", async () => {
1342
+ const fakeProxy = makeAvailableProxy();
1343
+ const ctx = makeContext({
1344
+ conversationId: "pinned-ext",
1345
+ hostBrowserProxy: fakeProxy,
1346
+ });
1347
+
1348
+ const client = getCdpClient(ctx, { mode: "extension" });
1349
+ expect(client.kind).toBe("extension");
1350
+
1351
+ const result = await client.send<{ ok: boolean; via: string }>(
1352
+ "Page.navigate",
1353
+ );
1354
+ expect(result).toEqual({ ok: true, via: "extension" });
1355
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
1356
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
1357
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
1358
+ });
1359
+
1360
+ test("pinned extension mode throws when no proxy is provisioned", () => {
1361
+ const ctx = makeContext({ conversationId: "pinned-ext-no-proxy" });
1362
+
1363
+ expect(() => getCdpClient(ctx, { mode: "extension" })).toThrow(CdpError);
1364
+
1365
+ try {
1366
+ getCdpClient(ctx, { mode: "extension" });
1367
+ } catch (err) {
1368
+ expect(err).toBeInstanceOf(CdpError);
1369
+ const cdpErr = err as CdpError;
1370
+ expect(cdpErr.code).toBe("transport_error");
1371
+ expect(cdpErr.message).toContain('Pinned mode "extension" unavailable');
1372
+ expect(cdpErr.message).toContain("no host browser proxy provisioned");
1373
+ expect(cdpErr.attemptDiagnostics).toBeDefined();
1374
+ expect(cdpErr.attemptDiagnostics).toHaveLength(1);
1375
+ expect(cdpErr.attemptDiagnostics![0].candidateKind).toBe("extension");
1376
+ expect(cdpErr.attemptDiagnostics![0].stage).toBe("candidate_selection");
1377
+ }
1378
+ });
1379
+
1380
+ test("pinned extension mode throws when proxy is present but unavailable", () => {
1381
+ const fakeProxy = makeUnavailableProxy();
1382
+ const ctx = makeContext({
1383
+ conversationId: "pinned-ext-unavail",
1384
+ hostBrowserProxy: fakeProxy,
1385
+ });
1386
+
1387
+ expect(() => getCdpClient(ctx, { mode: "extension" })).toThrow(CdpError);
1388
+
1389
+ try {
1390
+ getCdpClient(ctx, { mode: "extension" });
1391
+ } catch (err) {
1392
+ const cdpErr = err as CdpError;
1393
+ expect(cdpErr.code).toBe("transport_error");
1394
+ expect(cdpErr.message).toContain("not connected");
1395
+ expect(cdpErr.attemptDiagnostics![0].stage).toBe("candidate_selection");
1396
+ }
1397
+ });
1398
+
1399
+ test("pinned extension mode does NOT fall back to local on transport error", async () => {
1400
+ const fakeProxy = makeAvailableProxy();
1401
+
1402
+ // Make extension fail with transport_error
1403
+ createExtensionCdpClientMock.mockImplementationOnce(
1404
+ (_proxy: HostBrowserProxy, conversationId: string) => {
1405
+ const c = makeFakeExtensionClient(conversationId);
1406
+ c.send = mock(async () => {
1407
+ throw new CdpError("transport_error", "WS disconnected");
1408
+ });
1409
+ lastExtensionClient = c;
1410
+ return c;
1411
+ },
1412
+ );
1413
+
1414
+ const ctx = makeContext({
1415
+ conversationId: "pinned-ext-no-fallback",
1416
+ hostBrowserProxy: fakeProxy,
1417
+ });
1418
+
1419
+ const client = getCdpClient(ctx, { mode: "extension" });
1420
+
1421
+ await expect(client.send("Page.navigate")).rejects.toMatchObject({
1422
+ code: "transport_error",
1423
+ message: "WS disconnected",
1424
+ });
1425
+
1426
+ // Local and cdp-inspect should NOT have been tried
1427
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
1428
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
1429
+ });
1430
+
1431
+ // ── Pinned cdp-inspect ──────────────────────────────────────────────
1432
+
1433
+ test("pinned cdp-inspect mode routes to cdp-inspect", async () => {
1434
+ const ctx = makeContext({ conversationId: "pinned-inspect" });
1435
+
1436
+ const client = getCdpClient(ctx, { mode: "cdp-inspect" });
1437
+ expect(client.kind).toBe("cdp-inspect");
1438
+
1439
+ const result = await client.send<{ ok: boolean; via: string }>(
1440
+ "Page.navigate",
1441
+ );
1442
+ expect(result).toEqual({ ok: true, via: "cdp-inspect" });
1443
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
1444
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
1445
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
1446
+ });
1447
+
1448
+ test("pinned cdp-inspect mode does NOT fall back to local on transport error", async () => {
1449
+ createCdpInspectClientMock.mockImplementationOnce(
1450
+ (conversationId: string) => {
1451
+ const c = makeFakeCdpInspectClient(conversationId);
1452
+ c.send = mock(async () => {
1453
+ throw new CdpError("transport_error", "Connection refused");
1454
+ });
1455
+ lastCdpInspectClient = c;
1456
+ return c;
1457
+ },
1458
+ );
1459
+
1460
+ const ctx = makeContext({ conversationId: "pinned-inspect-no-fb" });
1461
+ const client = getCdpClient(ctx, { mode: "cdp-inspect" });
1462
+
1463
+ await expect(client.send("Page.navigate")).rejects.toMatchObject({
1464
+ code: "transport_error",
1465
+ message: "Connection refused",
1466
+ });
1467
+
1468
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
1469
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
1470
+ });
1471
+
1472
+ test("pinned cdp-inspect uses config host/port", async () => {
1473
+ const ctx = makeContext({ conversationId: "pinned-inspect-cfg" });
1474
+
1475
+ const client = getCdpClient(ctx, { mode: "cdp-inspect" });
1476
+ await client.send("Page.navigate");
1477
+
1478
+ expect(createCdpInspectClientMock).toHaveBeenCalledWith(
1479
+ "pinned-inspect-cfg",
1480
+ {
1481
+ host: "localhost",
1482
+ port: 9222,
1483
+ discoveryTimeoutMs: 500,
1484
+ },
1485
+ );
1486
+ });
1487
+
1488
+ // ── Pinned local ────────────────────────────────────────────────────
1489
+
1490
+ test("pinned local mode routes to local", async () => {
1491
+ const fakeProxy = makeAvailableProxy();
1492
+ const ctx = makeContext({
1493
+ conversationId: "pinned-local",
1494
+ hostBrowserProxy: fakeProxy,
1495
+ });
1496
+
1497
+ // Even with proxy available, pinned local should skip extension
1498
+ const client = getCdpClient(ctx, { mode: "local" });
1499
+ expect(client.kind).toBe("local");
1500
+
1501
+ const result = await client.send<{ ok: boolean; via: string }>(
1502
+ "Runtime.evaluate",
1503
+ );
1504
+ expect(result).toEqual({ ok: true, via: "local" });
1505
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
1506
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
1507
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
1508
+ });
1509
+
1510
+ test("pinned local mode does NOT fall back on transport error", async () => {
1511
+ createLocalCdpClientMock.mockImplementationOnce(
1512
+ (conversationId: string) => {
1513
+ const c = makeFakeLocalClient(conversationId);
1514
+ c.send = mock(async () => {
1515
+ throw new CdpError("transport_error", "Playwright crashed");
1516
+ });
1517
+ lastLocalClient = c;
1518
+ return c;
1519
+ },
1520
+ );
1521
+
1522
+ const ctx = makeContext({ conversationId: "pinned-local-no-fb" });
1523
+ const client = getCdpClient(ctx, { mode: "local" });
1524
+
1525
+ await expect(client.send("Page.navigate")).rejects.toMatchObject({
1526
+ code: "transport_error",
1527
+ message: "Playwright crashed",
1528
+ });
1529
+
1530
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
1531
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
1532
+ });
1533
+ });
1534
+
1535
+ // ── buildPinnedCandidateList tests ───────────────────────────────────────
1536
+
1537
+ describe("buildPinnedCandidateList", () => {
1538
+ beforeEach(() => {
1539
+ createExtensionCdpClientMock.mockClear();
1540
+ createLocalCdpClientMock.mockClear();
1541
+ createCdpInspectClientMock.mockClear();
1542
+ lastExtensionClient = undefined;
1543
+ lastLocalClient = undefined;
1544
+ lastCdpInspectClient = undefined;
1545
+ cdpInspectEnabled = false;
1546
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
1547
+ _resetDesktopAutoCooldown();
1548
+ });
1549
+
1550
+ test("extension mode produces single extension candidate", () => {
1551
+ const fakeProxy = makeAvailableProxy();
1552
+ const ctx = makeContext({
1553
+ conversationId: "bpl-ext",
1554
+ hostBrowserProxy: fakeProxy,
1555
+ });
1556
+
1557
+ const candidates = buildPinnedCandidateList(ctx, "extension");
1558
+
1559
+ expect(candidates).toHaveLength(1);
1560
+ expect(candidates[0].kind).toBe("extension");
1561
+ expect(candidates[0].reason).toBe("pinned mode: extension");
1562
+ });
1563
+
1564
+ test("cdp-inspect mode produces single cdp-inspect candidate", () => {
1565
+ const ctx = makeContext({ conversationId: "bpl-inspect" });
1566
+
1567
+ const candidates = buildPinnedCandidateList(ctx, "cdp-inspect");
1568
+
1569
+ expect(candidates).toHaveLength(1);
1570
+ expect(candidates[0].kind).toBe("cdp-inspect");
1571
+ expect(candidates[0].reason).toBe("pinned mode: cdp-inspect");
1572
+ });
1573
+
1574
+ test("local mode produces single local candidate", () => {
1575
+ const ctx = makeContext({ conversationId: "bpl-local" });
1576
+
1577
+ const candidates = buildPinnedCandidateList(ctx, "local");
1578
+
1579
+ expect(candidates).toHaveLength(1);
1580
+ expect(candidates[0].kind).toBe("local");
1581
+ expect(candidates[0].reason).toBe("pinned mode: local");
1582
+ });
1583
+
1584
+ test("extension mode throws with diagnostics when proxy absent", () => {
1585
+ const ctx = makeContext({ conversationId: "bpl-ext-absent" });
1586
+
1587
+ try {
1588
+ buildPinnedCandidateList(ctx, "extension");
1589
+ expect(true).toBe(false); // should not reach
1590
+ } catch (err) {
1591
+ expect(err).toBeInstanceOf(CdpError);
1592
+ const cdpErr = err as CdpError;
1593
+ expect(cdpErr.code).toBe("transport_error");
1594
+ expect(cdpErr.attemptDiagnostics).toHaveLength(1);
1595
+ expect(cdpErr.attemptDiagnostics![0]).toMatchObject({
1596
+ candidateKind: "extension",
1597
+ inclusionReason: "pinned mode: extension",
1598
+ stage: "candidate_selection",
1599
+ errorCode: "transport_error",
1600
+ });
1601
+ }
1602
+ });
1603
+ });
1604
+
1605
+ // ── Attempt diagnostics & fallback log tests ─────────────────────────────
1606
+
1607
+ describe("attempt diagnostics", () => {
1608
+ beforeEach(() => {
1609
+ createExtensionCdpClientMock.mockClear();
1610
+ createLocalCdpClientMock.mockClear();
1611
+ createCdpInspectClientMock.mockClear();
1612
+ lastExtensionClient = undefined;
1613
+ lastLocalClient = undefined;
1614
+ lastCdpInspectClient = undefined;
1615
+ cdpInspectEnabled = false;
1616
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
1617
+ _resetDesktopAutoCooldown();
1618
+ logWarnCalls.length = 0;
1619
+ logDebugCalls.length = 0;
1620
+ });
1621
+
1622
+ test("exhausted candidates error includes full attempt diagnostics", async () => {
1623
+ cdpInspectEnabled = true;
1624
+ const fakeProxy = makeAvailableProxy();
1625
+
1626
+ // Make extension fail
1627
+ createExtensionCdpClientMock.mockImplementationOnce(
1628
+ (_proxy: HostBrowserProxy, conversationId: string) => {
1629
+ const c = makeFakeExtensionClient(conversationId);
1630
+ c.send = mock(async () => {
1631
+ throw new CdpError("transport_error", "ext disconnected");
1632
+ });
1633
+ lastExtensionClient = c;
1634
+ return c;
1635
+ },
1636
+ );
1637
+
1638
+ // Make cdp-inspect fail
1639
+ createCdpInspectClientMock.mockImplementationOnce(
1640
+ (conversationId: string) => {
1641
+ const c = makeFakeCdpInspectClient(conversationId);
1642
+ c.send = mock(async () => {
1643
+ throw new CdpError("transport_error", "inspect refused");
1644
+ });
1645
+ lastCdpInspectClient = c;
1646
+ return c;
1647
+ },
1648
+ );
1649
+
1650
+ // Make local fail too
1651
+ createLocalCdpClientMock.mockImplementationOnce(
1652
+ (conversationId: string) => {
1653
+ const c = makeFakeLocalClient(conversationId);
1654
+ c.send = mock(async () => {
1655
+ throw new CdpError("transport_error", "playwright dead");
1656
+ });
1657
+ lastLocalClient = c;
1658
+ return c;
1659
+ },
1660
+ );
1661
+
1662
+ const ctx = makeContext({
1663
+ conversationId: "diag-all-fail",
1664
+ hostBrowserProxy: fakeProxy,
1665
+ });
1666
+
1667
+ const client = getCdpClient(ctx);
1668
+
1669
+ try {
1670
+ await client.send("Page.navigate");
1671
+ expect(true).toBe(false); // should not reach
1672
+ } catch (err) {
1673
+ expect(err).toBeInstanceOf(CdpError);
1674
+ const cdpErr = err as CdpError;
1675
+ expect(cdpErr.code).toBe("transport_error");
1676
+ expect(cdpErr.attemptDiagnostics).toBeDefined();
1677
+ expect(cdpErr.attemptDiagnostics).toHaveLength(3);
1678
+
1679
+ // First attempt: extension
1680
+ expect(cdpErr.attemptDiagnostics![0]).toMatchObject({
1681
+ candidateKind: "extension",
1682
+ stage: "send",
1683
+ errorCode: "transport_error",
1684
+ errorMessage: expect.stringContaining("ext disconnected"),
1685
+ });
1686
+
1687
+ // Second attempt: cdp-inspect
1688
+ expect(cdpErr.attemptDiagnostics![1]).toMatchObject({
1689
+ candidateKind: "cdp-inspect",
1690
+ stage: "send",
1691
+ errorCode: "transport_error",
1692
+ errorMessage: expect.stringContaining("inspect refused"),
1693
+ });
1694
+
1695
+ // Third attempt: local
1696
+ expect(cdpErr.attemptDiagnostics![2]).toMatchObject({
1697
+ candidateKind: "local",
1698
+ stage: "send",
1699
+ errorCode: "transport_error",
1700
+ errorMessage: expect.stringContaining("playwright dead"),
1701
+ });
1702
+ }
1703
+ });
1704
+
1705
+ test("successful fallback still records diagnostics for failed candidates", async () => {
1706
+ const fakeProxy = makeAvailableProxy();
1707
+
1708
+ // Make extension fail
1709
+ createExtensionCdpClientMock.mockImplementationOnce(
1710
+ (_proxy: HostBrowserProxy, conversationId: string) => {
1711
+ const c = makeFakeExtensionClient(conversationId);
1712
+ c.send = mock(async () => {
1713
+ throw new CdpError("transport_error", "ext down");
1714
+ });
1715
+ lastExtensionClient = c;
1716
+ return c;
1717
+ },
1718
+ );
1719
+
1720
+ const ctx = makeContext({
1721
+ conversationId: "diag-partial",
1722
+ hostBrowserProxy: fakeProxy,
1723
+ });
1724
+
1725
+ const client = getCdpClient(ctx);
1726
+ const result = await client.send<{ ok: boolean; via: string }>(
1727
+ "Page.navigate",
1728
+ );
1729
+ expect(result).toEqual({ ok: true, via: "local" });
1730
+
1731
+ // The fallback log should have been emitted with attempt data
1732
+ const fallbackLogs = logWarnCalls.filter(
1733
+ (c) =>
1734
+ typeof c.args[1] === "string" &&
1735
+ c.args[1].includes("auto-mode fallback"),
1736
+ );
1737
+ expect(fallbackLogs.length).toBeGreaterThan(0);
1738
+ });
1739
+
1740
+ test("auto-mode fallback log includes candidate sequence and failure reasons", async () => {
1741
+ cdpInspectEnabled = true;
1742
+ const fakeProxy = makeAvailableProxy();
1743
+
1744
+ // Make extension fail
1745
+ createExtensionCdpClientMock.mockImplementationOnce(
1746
+ (_proxy: HostBrowserProxy, conversationId: string) => {
1747
+ const c = makeFakeExtensionClient(conversationId);
1748
+ c.send = mock(async () => {
1749
+ throw new CdpError("transport_error", "WS closed");
1750
+ });
1751
+ lastExtensionClient = c;
1752
+ return c;
1753
+ },
1754
+ );
1755
+
1756
+ // Make cdp-inspect fail
1757
+ createCdpInspectClientMock.mockImplementationOnce(
1758
+ (conversationId: string) => {
1759
+ const c = makeFakeCdpInspectClient(conversationId);
1760
+ c.send = mock(async () => {
1761
+ throw new CdpError("transport_error", "no debugger");
1762
+ });
1763
+ lastCdpInspectClient = c;
1764
+ return c;
1765
+ },
1766
+ );
1767
+
1768
+ const ctx = makeContext({
1769
+ conversationId: "diag-log-shape",
1770
+ hostBrowserProxy: fakeProxy,
1771
+ });
1772
+
1773
+ const client = getCdpClient(ctx);
1774
+ await client.send<{ ok: boolean; via: string }>("Page.navigate");
1775
+
1776
+ // Check that a warn-level log was emitted for the completed fallback
1777
+ const completedLogs = logWarnCalls.filter(
1778
+ (c) =>
1779
+ typeof c.args[1] === "string" &&
1780
+ c.args[1].includes("fallback completed"),
1781
+ );
1782
+ expect(completedLogs.length).toBe(1);
1783
+
1784
+ // Verify the log payload contains the expected structure
1785
+ const payload = completedLogs[0].args[0] as Record<string, unknown>;
1786
+ expect(payload.conversationId).toBe("diag-log-shape");
1787
+ expect(payload.stickyCandidate).toBe("local");
1788
+ expect(Array.isArray(payload.attemptSequence)).toBe(true);
1789
+ const seq = payload.attemptSequence as Array<Record<string, unknown>>;
1790
+ expect(seq.length).toBe(3); // extension, cdp-inspect, local
1791
+ expect(seq[0].kind).toBe("extension");
1792
+ expect(seq[0].errorCode).toBe("transport_error");
1793
+ expect(seq[1].kind).toBe("cdp-inspect");
1794
+ expect(seq[1].errorCode).toBe("transport_error");
1795
+ expect(seq[2].kind).toBe("local");
1796
+ expect(seq[2].stage).toBe("success");
1797
+ });
1798
+
1799
+ test("pinned mode transport error includes attempt diagnostics on the thrown error", async () => {
1800
+ createCdpInspectClientMock.mockImplementationOnce(
1801
+ (conversationId: string) => {
1802
+ const c = makeFakeCdpInspectClient(conversationId);
1803
+ c.send = mock(async () => {
1804
+ throw new CdpError("transport_error", "Connection refused");
1805
+ });
1806
+ lastCdpInspectClient = c;
1807
+ return c;
1808
+ },
1809
+ );
1810
+
1811
+ const ctx = makeContext({ conversationId: "pinned-diag" });
1812
+ const client = getCdpClient(ctx, { mode: "cdp-inspect" });
1813
+
1814
+ try {
1815
+ await client.send("Page.navigate");
1816
+ expect(true).toBe(false); // should not reach
1817
+ } catch (err) {
1818
+ expect(err).toBeInstanceOf(CdpError);
1819
+ const cdpErr = err as CdpError;
1820
+ expect(cdpErr.attemptDiagnostics).toBeDefined();
1821
+ expect(cdpErr.attemptDiagnostics).toHaveLength(1);
1822
+ expect(cdpErr.attemptDiagnostics![0]).toMatchObject({
1823
+ candidateKind: "cdp-inspect",
1824
+ inclusionReason: "pinned mode: cdp-inspect",
1825
+ stage: "send",
1826
+ errorCode: "transport_error",
1827
+ });
1828
+ }
1829
+ });
1830
+
1831
+ test("construction failure is recorded in attempt diagnostics", async () => {
1832
+ // Make the cdp-inspect client's create() throw
1833
+ createCdpInspectClientMock.mockImplementationOnce(() => {
1834
+ throw new Error("Config missing");
1835
+ });
1836
+
1837
+ cdpInspectEnabled = true;
1838
+ const ctx = makeContext({ conversationId: "diag-construction" });
1839
+ const client = getCdpClient(ctx);
1840
+
1841
+ // cdp-inspect construction fails, falls back to local
1842
+ const result = await client.send<{ ok: boolean; via: string }>(
1843
+ "Page.navigate",
1844
+ );
1845
+ expect(result).toEqual({ ok: true, via: "local" });
1846
+ });
1847
+
1848
+ test("cdp_error on single-candidate list includes diagnostics", async () => {
1849
+ createLocalCdpClientMock.mockImplementationOnce(
1850
+ (conversationId: string) => {
1851
+ const c = makeFakeLocalClient(conversationId);
1852
+ c.send = mock(async () => {
1853
+ throw new CdpError("cdp_error", "Protocol error -32000");
1854
+ });
1855
+ lastLocalClient = c;
1856
+ return c;
1857
+ },
1858
+ );
1859
+
1860
+ const ctx = makeContext({ conversationId: "diag-cdp-err" });
1861
+ const client = getCdpClient(ctx);
1862
+
1863
+ try {
1864
+ await client.send("Page.navigate");
1865
+ expect(true).toBe(false);
1866
+ } catch (err) {
1867
+ const cdpErr = err as CdpError;
1868
+ expect(cdpErr.code).toBe("cdp_error");
1869
+ expect(cdpErr.attemptDiagnostics).toBeDefined();
1870
+ expect(cdpErr.attemptDiagnostics).toHaveLength(1);
1871
+ expect(cdpErr.attemptDiagnostics![0]).toMatchObject({
1872
+ candidateKind: "local",
1873
+ stage: "send",
1874
+ errorCode: "cdp_error",
1875
+ });
1876
+ }
1877
+ });
1878
+ });
1879
+
1880
+ // ── No-fallback guarantees for pinned modes ──────────────────────────────
1881
+
1882
+ describe("no-fallback guarantees", () => {
1883
+ beforeEach(() => {
1884
+ createExtensionCdpClientMock.mockClear();
1885
+ createLocalCdpClientMock.mockClear();
1886
+ createCdpInspectClientMock.mockClear();
1887
+ lastExtensionClient = undefined;
1888
+ lastLocalClient = undefined;
1889
+ lastCdpInspectClient = undefined;
1890
+ cdpInspectEnabled = false;
1891
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
1892
+ _resetDesktopAutoCooldown();
1893
+ logWarnCalls.length = 0;
1894
+ });
1895
+
1896
+ test("pinned extension: only one candidate is ever constructed", async () => {
1897
+ const fakeProxy = makeAvailableProxy();
1898
+
1899
+ // Make extension fail
1900
+ createExtensionCdpClientMock.mockImplementationOnce(
1901
+ (_proxy: HostBrowserProxy, conversationId: string) => {
1902
+ const c = makeFakeExtensionClient(conversationId);
1903
+ c.send = mock(async () => {
1904
+ throw new CdpError("transport_error", "failed");
1905
+ });
1906
+ lastExtensionClient = c;
1907
+ return c;
1908
+ },
1909
+ );
1910
+
1911
+ const ctx = makeContext({
1912
+ conversationId: "nofb-ext",
1913
+ hostBrowserProxy: fakeProxy,
1914
+ });
1915
+ const client = getCdpClient(ctx, { mode: "extension" });
1916
+
1917
+ await expect(client.send("Page.navigate")).rejects.toThrow();
1918
+
1919
+ expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
1920
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
1921
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
1922
+ });
1923
+
1924
+ test("pinned cdp-inspect: only one candidate is ever constructed", async () => {
1925
+ createCdpInspectClientMock.mockImplementationOnce(
1926
+ (conversationId: string) => {
1927
+ const c = makeFakeCdpInspectClient(conversationId);
1928
+ c.send = mock(async () => {
1929
+ throw new CdpError("transport_error", "failed");
1930
+ });
1931
+ lastCdpInspectClient = c;
1932
+ return c;
1933
+ },
1934
+ );
1935
+
1936
+ const ctx = makeContext({ conversationId: "nofb-inspect" });
1937
+ const client = getCdpClient(ctx, { mode: "cdp-inspect" });
1938
+
1939
+ await expect(client.send("Page.navigate")).rejects.toThrow();
1940
+
1941
+ expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
1942
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
1943
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
1944
+ });
1945
+
1946
+ test("pinned local: only one candidate is ever constructed", async () => {
1947
+ createLocalCdpClientMock.mockImplementationOnce(
1948
+ (conversationId: string) => {
1949
+ const c = makeFakeLocalClient(conversationId);
1950
+ c.send = mock(async () => {
1951
+ throw new CdpError("transport_error", "failed");
1952
+ });
1953
+ lastLocalClient = c;
1954
+ return c;
1955
+ },
1956
+ );
1957
+
1958
+ const ctx = makeContext({ conversationId: "nofb-local" });
1959
+ const client = getCdpClient(ctx, { mode: "local" });
1960
+
1961
+ await expect(client.send("Page.navigate")).rejects.toThrow();
1962
+
1963
+ expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
1964
+ expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
1965
+ expect(createCdpInspectClientMock).not.toHaveBeenCalled();
1966
+ });
1967
+
1968
+ test("pinned modes do not emit auto-mode fallback logs", async () => {
1969
+ createLocalCdpClientMock.mockImplementationOnce(
1970
+ (conversationId: string) => {
1971
+ const c = makeFakeLocalClient(conversationId);
1972
+ c.send = mock(async () => {
1973
+ throw new CdpError("transport_error", "failed");
1974
+ });
1975
+ lastLocalClient = c;
1976
+ return c;
1977
+ },
1978
+ );
1979
+
1980
+ const ctx = makeContext({ conversationId: "nofb-no-log" });
1981
+ const client = getCdpClient(ctx, { mode: "local" });
1982
+
1983
+ await expect(client.send("Page.navigate")).rejects.toThrow();
1984
+
1985
+ // No warn-level fallback logs should have been emitted
1986
+ const fallbackLogs = logWarnCalls.filter(
1987
+ (c) =>
1988
+ typeof c.args[1] === "string" &&
1989
+ c.args[1].includes("auto-mode fallback"),
1990
+ );
1991
+ expect(fallbackLogs.length).toBe(0);
1992
+ });
1993
+ });