@vellumai/assistant 0.6.3 → 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 (667) hide show
  1. package/ARCHITECTURE.md +273 -10
  2. package/Dockerfile +2 -3
  3. package/bun.lock +5 -13
  4. package/docs/backup-troubleshooting.md +52 -0
  5. package/docs/browser-use-architecture-phase2.md +174 -0
  6. package/docs/stt-provider-onboarding.md +120 -0
  7. package/knip.json +12 -2
  8. package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
  9. package/node_modules/@vellumai/ces-contracts/package.json +3 -3
  10. package/openapi.yaml +982 -72
  11. package/package.json +4 -6
  12. package/scripts/generate-openapi.ts +0 -1
  13. package/scripts/test.sh +73 -18
  14. package/src/__tests__/agent-image-optimize.test.ts +28 -0
  15. package/src/__tests__/agent-loop.test.ts +123 -0
  16. package/src/__tests__/anthropic-provider.test.ts +263 -10
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
  18. package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
  19. package/src/__tests__/browser-fill-credential.test.ts +11 -0
  20. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  21. package/src/__tests__/browser-skill-endstate.test.ts +31 -7
  22. package/src/__tests__/btw-routes.test.ts +7 -0
  23. package/src/__tests__/call-controller.test.ts +581 -20
  24. package/src/__tests__/catalog-files.test.ts +138 -0
  25. package/src/__tests__/channel-invite-transport.test.ts +2 -2
  26. package/src/__tests__/channel-readiness-routes.test.ts +16 -20
  27. package/src/__tests__/channel-readiness-service.test.ts +12 -7
  28. package/src/__tests__/checker.test.ts +157 -10
  29. package/src/__tests__/clawhub-files.test.ts +347 -0
  30. package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
  31. package/src/__tests__/config-analysis.test.ts +100 -0
  32. package/src/__tests__/config-schema.test.ts +1013 -66
  33. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
  34. package/src/__tests__/config-watcher.test.ts +43 -8
  35. package/src/__tests__/contact-store-user-file.test.ts +512 -0
  36. package/src/__tests__/contacts-write.test.ts +197 -0
  37. package/src/__tests__/context-window-manager.test.ts +88 -0
  38. package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
  39. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  40. package/src/__tests__/conversation-agent-loop.test.ts +98 -2
  41. package/src/__tests__/conversation-confirmation-signals.test.ts +135 -0
  42. package/src/__tests__/conversation-error.test.ts +70 -0
  43. package/src/__tests__/conversation-history-web-search.test.ts +11 -4
  44. package/src/__tests__/conversation-init.benchmark.test.ts +6 -1
  45. package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
  46. package/src/__tests__/conversation-list-source.test.ts +145 -0
  47. package/src/__tests__/conversation-pre-run-repair.test.ts +2 -0
  48. package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
  49. package/src/__tests__/conversation-queue.test.ts +901 -60
  50. package/src/__tests__/conversation-routes-disk-view.test.ts +270 -0
  51. package/src/__tests__/conversation-runtime-assembly.test.ts +55 -0
  52. package/src/__tests__/conversation-skill-tools.test.ts +7 -4
  53. package/src/__tests__/conversation-slash-commands.test.ts +33 -0
  54. package/src/__tests__/conversation-slash-queue.test.ts +89 -18
  55. package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
  56. package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
  57. package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
  58. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
  59. package/src/__tests__/credential-health-service.test.ts +352 -0
  60. package/src/__tests__/credential-security-invariants.test.ts +5 -3
  61. package/src/__tests__/credential-vault-unit.test.ts +379 -3
  62. package/src/__tests__/credentials-cli.test.ts +40 -16
  63. package/src/__tests__/cross-provider-web-search.test.ts +146 -35
  64. package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
  65. package/src/__tests__/device-id.test.ts +112 -0
  66. package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
  67. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
  68. package/src/__tests__/email-html-renderer.test.ts +71 -0
  69. package/src/__tests__/email-invite-adapter.test.ts +36 -32
  70. package/src/__tests__/emit-event-signal.test.ts +71 -0
  71. package/src/__tests__/extension-id-sync-guard.test.ts +75 -8
  72. package/src/__tests__/fixtures/mock-chrome-extension.ts +11 -0
  73. package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
  74. package/src/__tests__/gateway-only-guard.test.ts +0 -1
  75. package/src/__tests__/gemini-provider.test.ts +64 -0
  76. package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
  77. package/src/__tests__/gmail-archive-fallback.test.ts +193 -0
  78. package/src/__tests__/gmail-archive-gate.test.ts +246 -0
  79. package/src/__tests__/gmail-preferences.test.ts +117 -0
  80. package/src/__tests__/headless-browser-interactions.test.ts +43 -0
  81. package/src/__tests__/headless-browser-mode.test.ts +614 -0
  82. package/src/__tests__/headless-browser-navigate.test.ts +142 -5
  83. package/src/__tests__/headless-browser-read-tools.test.ts +11 -0
  84. package/src/__tests__/headless-browser-snapshot.test.ts +10 -0
  85. package/src/__tests__/heartbeat-service.test.ts +70 -17
  86. package/src/__tests__/home-state-routes.test.ts +162 -0
  87. package/src/__tests__/host-bash-proxy.test.ts +0 -5
  88. package/src/__tests__/host-browser-e2e-cloud.test.ts +138 -4
  89. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +4 -4
  90. package/src/__tests__/host-browser-ws-events-e2e.test.ts +103 -0
  91. package/src/__tests__/host-cu-proxy.test.ts +0 -5
  92. package/src/__tests__/identity-intro-cache.test.ts +40 -10
  93. package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
  94. package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
  95. package/src/__tests__/llm-context-normalization.test.ts +488 -0
  96. package/src/__tests__/llm-context-route-provider.test.ts +86 -5
  97. package/src/__tests__/llm-usage-store.test.ts +363 -0
  98. package/src/__tests__/media-stream-output.test.ts +555 -0
  99. package/src/__tests__/media-stream-parser.test.ts +374 -0
  100. package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
  101. package/src/__tests__/media-stream-stt-session.test.ts +588 -0
  102. package/src/__tests__/media-turn-detector.test.ts +440 -0
  103. package/src/__tests__/message-queue.test.ts +125 -0
  104. package/src/__tests__/migration-export-http.test.ts +6 -6
  105. package/src/__tests__/migration-import-commit-http.test.ts +8 -6
  106. package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
  107. package/src/__tests__/migration-validate-http.test.ts +3 -3
  108. package/src/__tests__/mock-gateway-ipc.ts +151 -0
  109. package/src/__tests__/model-intents.test.ts +2 -2
  110. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  111. package/src/__tests__/oauth-cli.test.ts +2 -0
  112. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  113. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  114. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  115. package/src/__tests__/oauth-store.test.ts +85 -0
  116. package/src/__tests__/oauth2-gateway-transport.test.ts +249 -6
  117. package/src/__tests__/onboarding-template-contract.test.ts +6 -13
  118. package/src/__tests__/openai-provider.test.ts +176 -0
  119. package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
  120. package/src/__tests__/openai-responses-provider.test.ts +1105 -0
  121. package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
  122. package/src/__tests__/outlook-unsubscribe.test.ts +31 -2
  123. package/src/__tests__/persona-resolver.test.ts +251 -0
  124. package/src/__tests__/platform-bash-auto-approve.test.ts +4 -0
  125. package/src/__tests__/platform.test.ts +92 -1
  126. package/src/__tests__/post-turn-tool-result-truncation.test.ts +47 -0
  127. package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
  128. package/src/__tests__/pricing.test.ts +174 -0
  129. package/src/__tests__/qdrant-manager.test.ts +29 -8
  130. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
  131. package/src/__tests__/relationship-state-contract.test.ts +175 -0
  132. package/src/__tests__/relay-server.test.ts +423 -5
  133. package/src/__tests__/search-skills-unified.test.ts +118 -0
  134. package/src/__tests__/secret-scanner-executor.test.ts +4 -0
  135. package/src/__tests__/secure-keys.test.ts +107 -0
  136. package/src/__tests__/send-endpoint-busy.test.ts +5 -1
  137. package/src/__tests__/sequence-store.test.ts +1 -1
  138. package/src/__tests__/server-history-render.test.ts +49 -0
  139. package/src/__tests__/settings-routes.test.ts +201 -0
  140. package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
  141. package/src/__tests__/skills-file-content-endpoint.test.ts +276 -145
  142. package/src/__tests__/skills-files-catalog-fallback.test.ts +381 -93
  143. package/src/__tests__/skills.test.ts +5 -2
  144. package/src/__tests__/skillssh-files.test.ts +446 -0
  145. package/src/__tests__/slack-block-formatting.test.ts +110 -0
  146. package/src/__tests__/slack-channel-config.test.ts +564 -1
  147. package/src/__tests__/stt-catalog-parity.test.ts +282 -0
  148. package/src/__tests__/stt-stream-session.test.ts +535 -0
  149. package/src/__tests__/system-prompt.test.ts +112 -26
  150. package/src/__tests__/telephony-stt-routing.test.ts +329 -0
  151. package/src/__tests__/terminal-tools.test.ts +18 -7
  152. package/src/__tests__/test-preload.ts +18 -0
  153. package/src/__tests__/test-support/browser-skill-harness.ts +4 -1
  154. package/src/__tests__/tool-executor-lifecycle-events.test.ts +9 -5
  155. package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
  156. package/src/__tests__/tool-executor.test.ts +33 -24
  157. package/src/__tests__/tool-result-truncation.test.ts +36 -0
  158. package/src/__tests__/trust-store.test.ts +7 -1
  159. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
  160. package/src/__tests__/tts-catalog-parity.test.ts +345 -0
  161. package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
  162. package/src/__tests__/twilio-routes.test.ts +376 -0
  163. package/src/__tests__/unicode.test.ts +293 -0
  164. package/src/__tests__/update-bulletin-format.test.ts +59 -0
  165. package/src/__tests__/update-bulletin.test.ts +206 -5
  166. package/src/__tests__/usage-routes.test.ts +25 -4
  167. package/src/__tests__/user-reference.test.ts +46 -61
  168. package/src/__tests__/verification-control-plane-policy.test.ts +4 -0
  169. package/src/__tests__/voice-config-update.test.ts +403 -0
  170. package/src/__tests__/voice-quality.test.ts +434 -19
  171. package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
  172. package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
  173. package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
  174. package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
  175. package/src/__tests__/workspace-migration-meets.test.ts +244 -0
  176. package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
  177. package/src/__tests__/workspace-policy.test.ts +2 -0
  178. package/src/agent/image-optimize.ts +24 -12
  179. package/src/agent/loop.ts +43 -3
  180. package/src/backup/__tests__/backup-key.test.ts +152 -0
  181. package/src/backup/__tests__/backup-worker.test.ts +767 -0
  182. package/src/backup/__tests__/list-snapshots.test.ts +87 -0
  183. package/src/backup/__tests__/local-writer.test.ts +218 -0
  184. package/src/backup/__tests__/offsite-writer.test.ts +641 -0
  185. package/src/backup/__tests__/paths.test.ts +300 -0
  186. package/src/backup/__tests__/restore.test.ts +498 -0
  187. package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
  188. package/src/backup/__tests__/stream-crypt.test.ts +228 -0
  189. package/src/backup/backup-key.ts +137 -0
  190. package/src/backup/backup-worker.ts +459 -0
  191. package/src/backup/list-snapshots.ts +147 -0
  192. package/src/backup/local-writer.ts +133 -0
  193. package/src/backup/offsite-writer.ts +222 -0
  194. package/src/backup/paths.ts +226 -0
  195. package/src/backup/restore.ts +322 -0
  196. package/src/backup/snapshot-lock.ts +431 -0
  197. package/src/backup/stream-crypt.ts +263 -0
  198. package/src/bundler/package-resolver.ts +4 -0
  199. package/src/calls/audio-store.ts +11 -5
  200. package/src/calls/call-controller.ts +226 -71
  201. package/src/calls/call-domain.ts +9 -0
  202. package/src/calls/call-speech-output.ts +190 -0
  203. package/src/calls/call-transport.ts +77 -0
  204. package/src/calls/media-stream-audio-transcode.ts +173 -0
  205. package/src/calls/media-stream-output.ts +660 -0
  206. package/src/calls/media-stream-parser.ts +300 -0
  207. package/src/calls/media-stream-protocol.ts +166 -0
  208. package/src/calls/media-stream-server.ts +592 -0
  209. package/src/calls/media-stream-stt-session.ts +460 -0
  210. package/src/calls/media-turn-detector.ts +230 -0
  211. package/src/calls/relay-server.ts +90 -75
  212. package/src/calls/resolve-call-tts-provider.ts +136 -0
  213. package/src/calls/telephony-stt-routing.ts +145 -0
  214. package/src/calls/tts-call-strategy.ts +161 -0
  215. package/src/calls/tts-text-sanitizer.ts +32 -16
  216. package/src/calls/twilio-routes.ts +281 -17
  217. package/src/calls/voice-quality.ts +78 -35
  218. package/src/calls/voice-session-bridge.ts +8 -1
  219. package/src/channels/types.ts +16 -0
  220. package/src/cli/__tests__/run-assistant-command.ts +11 -1
  221. package/src/cli/commands/__tests__/backup.test.ts +1165 -0
  222. package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
  223. package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
  224. package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
  225. package/src/cli/commands/__tests__/email-download.test.ts +16 -1
  226. package/src/cli/commands/__tests__/email-list.test.ts +22 -4
  227. package/src/cli/commands/__tests__/email-register.test.ts +4 -4
  228. package/src/cli/commands/__tests__/email-send.test.ts +37 -4
  229. package/src/cli/commands/__tests__/email-status.test.ts +5 -1
  230. package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
  231. package/src/cli/commands/backup.ts +993 -0
  232. package/src/cli/commands/conversations.ts +77 -0
  233. package/src/cli/commands/credentials.ts +0 -1
  234. package/src/cli/commands/domain.ts +210 -0
  235. package/src/cli/commands/email.ts +255 -3
  236. package/src/cli/commands/oauth/__tests__/connect.test.ts +12 -0
  237. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +1 -0
  238. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -0
  239. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -0
  240. package/src/cli/commands/oauth/mode.ts +12 -3
  241. package/src/cli/commands/oauth/providers.ts +15 -0
  242. package/src/cli/commands/oauth/shared.ts +2 -1
  243. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +4 -9
  244. package/src/cli/commands/platform/__tests__/connect.test.ts +6 -0
  245. package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
  246. package/src/cli/commands/platform/__tests__/status.test.ts +6 -0
  247. package/src/cli/program.ts +30 -4
  248. package/src/config/__tests__/backup-schema.test.ts +134 -0
  249. package/src/config/assistant-feature-flags.ts +61 -62
  250. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +37 -1
  251. package/src/config/bundled-skills/browser/SKILL.md +30 -5
  252. package/src/config/bundled-skills/browser/TOOLS.json +123 -0
  253. package/src/config/bundled-skills/browser/tools/browser-attach.ts +12 -0
  254. package/src/config/bundled-skills/browser/tools/browser-detach.ts +12 -0
  255. package/src/config/bundled-skills/browser/tools/browser-status.ts +12 -0
  256. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +17 -0
  257. package/src/config/bundled-skills/contacts/SKILL.md +2 -2
  258. package/src/config/bundled-skills/gmail/SKILL.md +53 -7
  259. package/src/config/bundled-skills/gmail/TOOLS.json +33 -3
  260. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +116 -9
  261. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +138 -11
  262. package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +59 -0
  263. package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +82 -0
  264. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +113 -17
  265. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -2
  266. package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
  267. package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
  268. package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
  269. package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
  270. package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
  271. package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
  272. package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
  273. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
  274. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  275. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
  276. package/src/config/bundled-skills/outlook/SKILL.md +2 -2
  277. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +2 -2
  278. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  279. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +27 -18
  280. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  281. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  282. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
  283. package/src/config/bundled-skills/slack/SKILL.md +1 -0
  284. package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
  285. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
  286. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
  287. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
  288. package/src/config/bundled-tool-registry.ts +8 -0
  289. package/src/config/env-registry.ts +24 -0
  290. package/src/config/env.ts +34 -10
  291. package/src/config/feature-flag-registry.json +46 -14
  292. package/src/config/loader.ts +26 -12
  293. package/src/config/schema.ts +35 -10
  294. package/src/config/schemas/__tests__/stt.test.ts +43 -0
  295. package/src/config/schemas/analysis.ts +51 -0
  296. package/src/config/schemas/backup.ts +72 -0
  297. package/src/config/schemas/calls.ts +1 -26
  298. package/src/config/schemas/elevenlabs.ts +0 -59
  299. package/src/config/schemas/filing.ts +47 -7
  300. package/src/config/schemas/heartbeat.ts +27 -5
  301. package/src/config/schemas/host-browser.ts +47 -1
  302. package/src/config/schemas/inference.ts +1 -1
  303. package/src/config/schemas/memory-lifecycle.ts +14 -2
  304. package/src/config/schemas/services.ts +44 -0
  305. package/src/config/schemas/stt.ts +59 -0
  306. package/src/config/schemas/tts.ts +230 -0
  307. package/src/config/schemas/updates.ts +14 -0
  308. package/src/config/skills.ts +4 -0
  309. package/src/config/types.ts +4 -0
  310. package/src/contacts/contact-store.ts +56 -11
  311. package/src/contacts/contacts-write.ts +38 -1
  312. package/src/context/post-turn-tool-result-truncation.ts +3 -2
  313. package/src/context/tool-result-truncation.ts +2 -1
  314. package/src/context/window-manager.ts +45 -12
  315. package/src/credential-execution/executable-discovery.ts +12 -2
  316. package/src/credential-execution/process-manager.ts +33 -2
  317. package/src/credential-health/credential-health-service.ts +366 -0
  318. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
  319. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
  320. package/src/daemon/__tests__/conversation-tool-setup.test.ts +17 -8
  321. package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
  322. package/src/daemon/config-watcher.ts +99 -5
  323. package/src/daemon/conversation-agent-loop-handlers.ts +6 -0
  324. package/src/daemon/conversation-agent-loop.ts +101 -24
  325. package/src/daemon/conversation-error.ts +11 -0
  326. package/src/daemon/conversation-history.ts +40 -6
  327. package/src/daemon/conversation-launch.ts +220 -0
  328. package/src/daemon/conversation-lifecycle.ts +59 -9
  329. package/src/daemon/conversation-messaging.ts +37 -3
  330. package/src/daemon/conversation-notifiers.ts +5 -0
  331. package/src/daemon/conversation-process.ts +581 -19
  332. package/src/daemon/conversation-queue-manager.ts +24 -0
  333. package/src/daemon/conversation-runtime-assembly.ts +11 -1
  334. package/src/daemon/conversation-slash.ts +36 -0
  335. package/src/daemon/conversation-surfaces.ts +94 -4
  336. package/src/daemon/conversation-tool-setup.ts +25 -0
  337. package/src/daemon/conversation-usage.ts +7 -4
  338. package/src/daemon/conversation.ts +86 -28
  339. package/src/daemon/handlers/config-slack-channel.ts +269 -94
  340. package/src/daemon/handlers/conversations.ts +4 -1
  341. package/src/daemon/handlers/shared.ts +22 -0
  342. package/src/daemon/handlers/skills.ts +321 -77
  343. package/src/daemon/host-browser-proxy.ts +2 -1
  344. package/src/daemon/lifecycle.ts +122 -25
  345. package/src/daemon/message-protocol.ts +6 -0
  346. package/src/daemon/message-types/conversations.ts +34 -1
  347. package/src/daemon/message-types/home.ts +40 -0
  348. package/src/daemon/message-types/meet.ts +143 -0
  349. package/src/daemon/message-types/messages.ts +14 -0
  350. package/src/daemon/message-types/schedules.ts +34 -2
  351. package/src/daemon/message-types/skills.ts +16 -0
  352. package/src/daemon/message-types/surfaces.ts +2 -0
  353. package/src/daemon/server.ts +347 -2
  354. package/src/daemon/shutdown-handlers.ts +32 -4
  355. package/src/daemon/shutdown-registry.ts +40 -0
  356. package/src/daemon/tool-side-effects.ts +9 -0
  357. package/src/email/html-renderer.ts +76 -0
  358. package/src/heartbeat/heartbeat-service.ts +93 -7
  359. package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
  360. package/src/home/__tests__/emit-feed-event.test.ts +169 -0
  361. package/src/home/__tests__/feed-scheduler.test.ts +194 -0
  362. package/src/home/__tests__/feed-types.test.ts +275 -0
  363. package/src/home/__tests__/feed-writer.test.ts +688 -0
  364. package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
  365. package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
  366. package/src/home/__tests__/progress-formula.test.ts +213 -0
  367. package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
  368. package/src/home/__tests__/rollup-producer.test.ts +398 -0
  369. package/src/home/assistant-feed-authoring.ts +124 -0
  370. package/src/home/emit-feed-event.ts +158 -0
  371. package/src/home/feed-scheduler.ts +247 -0
  372. package/src/home/feed-types.ts +181 -0
  373. package/src/home/feed-writer.ts +469 -0
  374. package/src/home/platform-gmail-digest.ts +163 -0
  375. package/src/home/progress-formula.ts +86 -0
  376. package/src/home/relationship-state-writer.ts +824 -0
  377. package/src/home/relationship-state.ts +143 -0
  378. package/src/home/rollup-producer.ts +384 -0
  379. package/src/hooks/runner.ts +7 -0
  380. package/src/inbound/platform-callback-registration.ts +12 -3
  381. package/src/inbound/public-ingress-urls.ts +12 -0
  382. package/src/instrument.ts +1 -1
  383. package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
  384. package/src/ipc/cli-client.ts +151 -0
  385. package/src/ipc/cli-server.ts +234 -0
  386. package/src/ipc/gateway-client.ts +180 -0
  387. package/src/ipc/routes/index.ts +5 -0
  388. package/src/ipc/routes/wake-conversation.ts +19 -0
  389. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
  390. package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
  391. package/src/memory/__tests__/conversation-analyze-job.test.ts +232 -0
  392. package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
  393. package/src/memory/app-store.ts +1 -1
  394. package/src/memory/attachments-store.ts +70 -0
  395. package/src/memory/auto-analysis-enqueue.ts +127 -0
  396. package/src/memory/auto-analysis-guard.ts +27 -0
  397. package/src/memory/cleanup-schedule-state.ts +37 -0
  398. package/src/memory/conversation-analyze-job.ts +73 -0
  399. package/src/memory/conversation-crud.ts +99 -0
  400. package/src/memory/conversation-disk-view.ts +7 -0
  401. package/src/memory/conversation-group-migration.ts +34 -2
  402. package/src/memory/conversation-queries.ts +6 -5
  403. package/src/memory/db-init.ts +6 -0
  404. package/src/memory/db-maintenance.ts +108 -0
  405. package/src/memory/db.ts +1 -0
  406. package/src/memory/graph/conversation-graph-memory.ts +15 -0
  407. package/src/memory/graph/extraction.test.ts +23 -0
  408. package/src/memory/graph/extraction.ts +8 -0
  409. package/src/memory/graph/retriever.ts +27 -18
  410. package/src/memory/graph/scoring.test.ts +186 -0
  411. package/src/memory/graph/scoring.ts +31 -1
  412. package/src/memory/graph/tools.ts +1 -1
  413. package/src/memory/group-crud.ts +6 -1
  414. package/src/memory/indexer.ts +95 -16
  415. package/src/memory/job-handlers/cleanup.ts +11 -8
  416. package/src/memory/job-handlers/conversation-starters.ts +16 -10
  417. package/src/memory/jobs-store.ts +64 -4
  418. package/src/memory/jobs-worker.ts +22 -9
  419. package/src/memory/llm-usage-store.ts +92 -56
  420. package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
  421. package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
  422. package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
  423. package/src/memory/migrations/index.ts +6 -0
  424. package/src/memory/migrations/registry.ts +8 -0
  425. package/src/memory/qdrant-manager.ts +43 -16
  426. package/src/memory/schema/conversations.ts +2 -0
  427. package/src/memory/schema/oauth.ts +3 -0
  428. package/src/memory/usage-buckets.ts +396 -0
  429. package/src/messaging/providers/gmail/client.ts +57 -6
  430. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
  431. package/src/messaging/providers/slack/adapter.ts +143 -38
  432. package/src/messaging/providers/slack/client.ts +16 -0
  433. package/src/messaging/providers/slack/types.ts +4 -0
  434. package/src/notifications/decision-engine.ts +3 -3
  435. package/src/notifications/signal.ts +5 -0
  436. package/src/oauth/__tests__/identity-verifier.test.ts +1 -0
  437. package/src/oauth/byo-connection.test.ts +18 -1
  438. package/src/oauth/byo-connection.ts +3 -1
  439. package/src/oauth/connect-orchestrator.ts +2 -0
  440. package/src/oauth/connection-resolver.ts +6 -2
  441. package/src/oauth/connection.ts +2 -0
  442. package/src/oauth/oauth-store.ts +9 -0
  443. package/src/oauth/platform-connection.test.ts +98 -0
  444. package/src/oauth/platform-connection.ts +52 -31
  445. package/src/oauth/seed-providers.ts +7 -0
  446. package/src/permissions/checker.ts +16 -6
  447. package/src/permissions/defaults.ts +49 -1
  448. package/src/permissions/trust-store.ts +3 -3
  449. package/src/permissions/workspace-policy.ts +3 -0
  450. package/src/platform/client.test.ts +10 -0
  451. package/src/platform/sync-identity.ts +129 -0
  452. package/src/prompts/persona-resolver.ts +126 -2
  453. package/src/prompts/system-prompt.ts +59 -18
  454. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  455. package/src/prompts/templates/SOUL.md +3 -1
  456. package/src/prompts/templates/UPDATES.md +12 -0
  457. package/src/prompts/templates/channels/slack.md +20 -0
  458. package/src/prompts/update-bulletin-format.ts +26 -9
  459. package/src/prompts/update-bulletin.ts +34 -23
  460. package/src/prompts/user-reference.ts +20 -17
  461. package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
  462. package/src/providers/anthropic/client.ts +157 -61
  463. package/src/providers/fireworks/client.ts +2 -2
  464. package/src/providers/gemini/client.ts +9 -1
  465. package/src/providers/model-catalog.ts +6 -0
  466. package/src/providers/model-intents.ts +4 -4
  467. package/src/providers/ollama/client.ts +2 -2
  468. package/src/providers/openai/chat-completions-provider.ts +474 -0
  469. package/src/providers/openai/client.ts +25 -440
  470. package/src/providers/openai/responses-provider.ts +502 -0
  471. package/src/providers/openrouter/client.ts +101 -4
  472. package/src/providers/provider-secret-catalog.ts +139 -0
  473. package/src/providers/registry.ts +2 -2
  474. package/src/providers/retry.ts +14 -3
  475. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
  476. package/src/providers/speech-to-text/__tests__/resolve.test.ts +828 -0
  477. package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
  478. package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
  479. package/src/providers/speech-to-text/deepgram.test.ts +332 -0
  480. package/src/providers/speech-to-text/deepgram.ts +115 -0
  481. package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
  482. package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
  483. package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
  484. package/src/providers/speech-to-text/google-gemini.ts +101 -0
  485. package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
  486. package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
  487. package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
  488. package/src/providers/speech-to-text/openai-whisper.ts +63 -33
  489. package/src/providers/speech-to-text/provider-catalog.ts +306 -0
  490. package/src/providers/speech-to-text/resolve.ts +386 -6
  491. package/src/providers/types.ts +9 -0
  492. package/src/runtime/AGENTS.md +43 -1
  493. package/src/runtime/__tests__/agent-wake.test.ts +831 -0
  494. package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
  495. package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
  496. package/src/runtime/agent-wake.ts +512 -0
  497. package/src/runtime/auth/__tests__/route-policy.test.ts +40 -0
  498. package/src/runtime/auth/route-policy.ts +30 -5
  499. package/src/runtime/auth/token-service.ts +56 -1
  500. package/src/runtime/btw-sidechain.ts +2 -0
  501. package/src/runtime/capability-tokens.ts +10 -10
  502. package/src/runtime/channel-invite-transport.ts +1 -1
  503. package/src/runtime/channel-invite-transports/email.ts +14 -6
  504. package/src/runtime/channel-readiness-service.ts +12 -22
  505. package/src/runtime/chrome-extension-registry.ts +38 -2
  506. package/src/runtime/http-server.ts +395 -10
  507. package/src/runtime/http-types.ts +6 -2
  508. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +36 -0
  509. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
  510. package/src/runtime/migrations/migration-transport.ts +1 -0
  511. package/src/runtime/migrations/migration-wizard.ts +1 -0
  512. package/src/runtime/migrations/vbundle-import-analyzer.ts +77 -1
  513. package/src/runtime/migrations/vbundle-importer.ts +34 -0
  514. package/src/runtime/pending-interactions.ts +0 -11
  515. package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
  516. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +507 -0
  517. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +208 -0
  518. package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
  519. package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
  520. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
  521. package/src/runtime/routes/app-management-routes.ts +12 -18
  522. package/src/runtime/routes/attachment-routes.test.ts +9 -3
  523. package/src/runtime/routes/attachment-routes.ts +216 -17
  524. package/src/runtime/routes/backup-routes.ts +519 -0
  525. package/src/runtime/routes/browser-extension-pair-routes.ts +82 -23
  526. package/src/runtime/routes/btw-routes.ts +8 -6
  527. package/src/runtime/routes/contact-routes.test.ts +298 -0
  528. package/src/runtime/routes/contact-routes.ts +132 -5
  529. package/src/runtime/routes/conversation-analysis-routes.ts +22 -142
  530. package/src/runtime/routes/conversation-management-routes.ts +115 -0
  531. package/src/runtime/routes/conversation-routes.ts +367 -146
  532. package/src/runtime/routes/filing-routes.ts +93 -0
  533. package/src/runtime/routes/home-feed-routes.ts +334 -0
  534. package/src/runtime/routes/home-state-routes.ts +138 -0
  535. package/src/runtime/routes/host-browser-routes.ts +3 -14
  536. package/src/runtime/routes/identity-intro-cache.ts +7 -3
  537. package/src/runtime/routes/identity-routes.ts +3 -17
  538. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
  539. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
  540. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
  541. package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
  542. package/src/runtime/routes/integrations/slack/channel.ts +11 -3
  543. package/src/runtime/routes/integrations/slack/share.ts +45 -7
  544. package/src/runtime/routes/llm-context-normalization.ts +303 -0
  545. package/src/runtime/routes/memory-item-routes.test.ts +3 -2
  546. package/src/runtime/routes/migration-routes.ts +40 -5
  547. package/src/runtime/routes/settings-routes.ts +22 -5
  548. package/src/runtime/routes/skills-routes.ts +76 -7
  549. package/src/runtime/routes/stt-routes.ts +233 -0
  550. package/src/runtime/routes/surface-action-routes.ts +41 -2
  551. package/src/runtime/routes/tts-routes.ts +108 -24
  552. package/src/runtime/routes/usage-routes.ts +30 -2
  553. package/src/runtime/routes/user-route-dispatcher.ts +50 -5
  554. package/src/runtime/routes/user-routes.ts +13 -1
  555. package/src/runtime/routes/work-items-routes.ts +8 -1
  556. package/src/runtime/runtime-mode.ts +33 -0
  557. package/src/runtime/services/__tests__/analyze-conversation.test.ts +444 -0
  558. package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
  559. package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
  560. package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
  561. package/src/runtime/services/analyze-conversation.ts +344 -0
  562. package/src/runtime/services/analyze-deps-singleton.ts +32 -0
  563. package/src/runtime/services/auto-analysis-prompt.ts +55 -0
  564. package/src/runtime/skill-route-registry.ts +49 -0
  565. package/src/runtime/slack-block-formatting.ts +437 -10
  566. package/src/schedule/scheduler.ts +50 -0
  567. package/src/security/oauth2.ts +26 -4
  568. package/src/security/secure-keys.ts +25 -2
  569. package/src/security/token-manager.ts +8 -0
  570. package/src/sequence/engine.ts +23 -0
  571. package/src/sequence/types.ts +1 -1
  572. package/src/skills/catalog-files.ts +64 -2
  573. package/src/skills/category-inference.ts +122 -0
  574. package/src/skills/clawhub-files.ts +213 -0
  575. package/src/skills/clawhub.ts +84 -23
  576. package/src/skills/skill-file-provider.ts +40 -0
  577. package/src/skills/skillssh-files.ts +395 -0
  578. package/src/skills/skillssh-registry.ts +4 -4
  579. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +392 -0
  580. package/src/stt/__tests__/types.test.ts +89 -0
  581. package/src/stt/daemon-batch-transcriber.ts +195 -0
  582. package/src/stt/stt-stream-session.ts +499 -0
  583. package/src/stt/types.ts +330 -0
  584. package/src/stt/wav-encoder.test.ts +373 -0
  585. package/src/stt/wav-encoder.ts +175 -0
  586. package/src/subagent/manager.ts +38 -14
  587. package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
  588. package/src/tools/browser/__tests__/browser-status.test.ts +123 -0
  589. package/src/tools/browser/browser-execution.ts +1163 -23
  590. package/src/tools/browser/browser-manager.ts +45 -0
  591. package/src/tools/browser/browser-mode-constants.ts +12 -0
  592. package/src/tools/browser/browser-mode.ts +92 -0
  593. package/src/tools/browser/browser-status-constants.ts +33 -0
  594. package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +393 -0
  595. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +29 -0
  596. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1648 -32
  597. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +264 -0
  598. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +183 -17
  599. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +254 -21
  600. package/src/tools/browser/cdp-client/errors.ts +15 -0
  601. package/src/tools/browser/cdp-client/extension-cdp-client.ts +39 -16
  602. package/src/tools/browser/cdp-client/factory.ts +797 -87
  603. package/src/tools/browser/cdp-client/index.ts +16 -2
  604. package/src/tools/browser/cdp-client/types.ts +68 -0
  605. package/src/tools/credentials/vault.ts +35 -6
  606. package/src/tools/network/web-fetch.ts +5 -2
  607. package/src/tools/network/web-search.ts +5 -2
  608. package/src/tools/shared/shell-output.ts +3 -1
  609. package/src/tools/side-effects.ts +2 -0
  610. package/src/tools/skills/sandbox-runner.ts +3 -2
  611. package/src/tools/terminal/safe-env.ts +10 -2
  612. package/src/tools/terminal/shell.ts +15 -4
  613. package/src/tools/tool-manifest.ts +21 -0
  614. package/src/tools/types.ts +17 -0
  615. package/src/tools/ui-surface/definitions.ts +6 -1
  616. package/src/tts/__tests__/provider-adapters.test.ts +834 -0
  617. package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
  618. package/src/tts/__tests__/provider-catalog.test.ts +183 -0
  619. package/src/tts/__tests__/provider-registry.test.ts +90 -0
  620. package/src/tts/provider-catalog.ts +201 -0
  621. package/src/tts/provider-registry.ts +73 -0
  622. package/src/tts/providers/deepgram-provider.ts +219 -0
  623. package/src/tts/providers/elevenlabs-provider.ts +211 -0
  624. package/src/tts/providers/fish-audio-provider.ts +183 -0
  625. package/src/tts/providers/index.ts +42 -0
  626. package/src/tts/providers/register-builtins.ts +130 -0
  627. package/src/tts/synthesize-text.ts +110 -0
  628. package/src/tts/tts-config-resolver.ts +78 -0
  629. package/src/tts/types.ts +153 -0
  630. package/src/types/onboarding-context.ts +7 -0
  631. package/src/util/abort-reasons.ts +58 -0
  632. package/src/util/device-id.ts +32 -16
  633. package/src/util/errors.ts +9 -1
  634. package/src/util/platform.ts +54 -10
  635. package/src/util/pricing.ts +66 -3
  636. package/src/util/spawn.ts +1 -1
  637. package/src/util/truncate.ts +4 -2
  638. package/src/util/unicode.ts +201 -0
  639. package/src/version.ts +19 -24
  640. package/src/watcher/engine.ts +23 -0
  641. package/src/watcher/watcher-store.ts +31 -0
  642. package/src/workspace/migrations/003-seed-device-id.ts +9 -3
  643. package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
  644. package/src/workspace/migrations/029-seed-pkb.ts +1 -1
  645. package/src/workspace/migrations/031-drop-user-md.ts +317 -0
  646. package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
  647. package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
  648. package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
  649. package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
  650. package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
  651. package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
  652. package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
  653. package/src/workspace/migrations/registry.ts +16 -0
  654. package/src/workspace/top-level-renderer.ts +13 -1
  655. package/src/workspace/turn-commit.ts +31 -0
  656. package/src/__tests__/email-cli.test.ts +0 -297
  657. package/src/__tests__/email-service-config-fallback.test.ts +0 -102
  658. package/src/cli/commands/browser-relay.ts +0 -466
  659. package/src/email/guardrails.ts +0 -221
  660. package/src/email/provider.ts +0 -117
  661. package/src/email/providers/agentmail.ts +0 -361
  662. package/src/email/providers/index.ts +0 -65
  663. package/src/email/service.ts +0 -384
  664. package/src/email/types.ts +0 -126
  665. package/src/prompts/templates/USER.md +0 -13
  666. package/src/providers/speech-to-text/types.ts +0 -17
  667. package/src/runtime/routes/browser-cdp-routes.ts +0 -229
@@ -65,11 +65,18 @@ const createCdpInspectClientMock = mock(
65
65
  );
66
66
 
67
67
  /**
68
- * Mutable config state. Tests flip `cdpInspectEnabled` to control
69
- * the factory's config-based selection without needing a real config
70
- * file.
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
71
  */
72
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[] }> = [];
73
80
 
74
81
  mock.module("../extension-cdp-client.js", () => ({
75
82
  createExtensionCdpClient: createExtensionCdpClientMock,
@@ -88,14 +95,36 @@ mock.module("../../../../config/loader.js", () => ({
88
95
  host: "localhost",
89
96
  port: 9222,
90
97
  probeTimeoutMs: 500,
98
+ desktopAuto: desktopAutoConfig,
91
99
  },
92
100
  },
93
101
  }),
94
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
+ }));
95
115
 
96
116
  // Import under test AFTER mock.module calls so that the factory's
97
117
  // top-level imports resolve to our fakes.
98
- const { getCdpClient } = await import("../factory.js");
118
+ const {
119
+ getCdpClient,
120
+ buildCandidateList,
121
+ buildChainedClient,
122
+ buildPinnedCandidateList,
123
+ _resetDesktopAutoCooldown,
124
+ _getDesktopAutoCooldownSince,
125
+ recordDesktopAutoCooldown,
126
+ isDesktopAutoCooldownActive,
127
+ } = await import("../factory.js");
99
128
 
100
129
  /**
101
130
  * Minimal ToolContext suitable for factory tests. Only the fields the
@@ -108,6 +137,27 @@ function makeContext(
108
137
  return overrides as unknown as ToolContext;
109
138
  }
110
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
+
111
161
  describe("getCdpClient", () => {
112
162
  beforeEach(() => {
113
163
  createExtensionCdpClientMock.mockClear();
@@ -117,12 +167,16 @@ describe("getCdpClient", () => {
117
167
  lastLocalClient = undefined;
118
168
  lastCdpInspectClient = undefined;
119
169
  cdpInspectEnabled = false;
170
+ desktopAutoConfig = { enabled: true, cooldownMs: 30_000 };
171
+ _resetDesktopAutoCooldown();
172
+ logWarnCalls.length = 0;
173
+ logDebugCalls.length = 0;
120
174
  });
121
175
 
122
- test("routes to ExtensionCdpClient when hostBrowserProxy is set", () => {
123
- const fakeProxy = {
124
- request: mock(async () => ({})),
125
- } as unknown as HostBrowserProxy;
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();
126
180
  const ctx = makeContext({
127
181
  conversationId: "test-convo",
128
182
  hostBrowserProxy: fakeProxy,
@@ -130,8 +184,16 @@ describe("getCdpClient", () => {
130
184
 
131
185
  const client = getCdpClient(ctx);
132
186
 
187
+ // kind should reflect extension before first send (top candidate)
133
188
  expect(client.kind).toBe("extension");
134
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" });
135
197
  expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
136
198
  expect(createExtensionCdpClientMock).toHaveBeenCalledWith(
137
199
  fakeProxy,
@@ -141,11 +203,50 @@ describe("getCdpClient", () => {
141
203
  expect(createCdpInspectClientMock).not.toHaveBeenCalled();
142
204
  });
143
205
 
144
- test("extension wins even when cdpInspect is enabled", () => {
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 () => {
145
248
  cdpInspectEnabled = true;
146
- const fakeProxy = {
147
- request: mock(async () => ({})),
148
- } as unknown as HostBrowserProxy;
249
+ const fakeProxy = makeAvailableProxy();
149
250
  const ctx = makeContext({
150
251
  conversationId: "ext-wins",
151
252
  hostBrowserProxy: fakeProxy,
@@ -154,12 +255,16 @@ describe("getCdpClient", () => {
154
255
  const client = getCdpClient(ctx);
155
256
 
156
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" });
157
262
  expect(createExtensionCdpClientMock).toHaveBeenCalledTimes(1);
158
263
  expect(createCdpInspectClientMock).not.toHaveBeenCalled();
159
264
  expect(createLocalCdpClientMock).not.toHaveBeenCalled();
160
265
  });
161
266
 
162
- test("routes to CdpInspectClient when cdpInspect is enabled and extension is absent", () => {
267
+ test("routes to CdpInspectClient when cdpInspect is enabled and extension is absent", async () => {
163
268
  cdpInspectEnabled = true;
164
269
  const ctx = makeContext({
165
270
  conversationId: "inspect-convo",
@@ -170,6 +275,12 @@ describe("getCdpClient", () => {
170
275
 
171
276
  expect(client.kind).toBe("cdp-inspect");
172
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" });
173
284
  expect(createCdpInspectClientMock).toHaveBeenCalledTimes(1);
174
285
  expect(createCdpInspectClientMock).toHaveBeenCalledWith("inspect-convo", {
175
286
  host: "localhost",
@@ -180,7 +291,7 @@ describe("getCdpClient", () => {
180
291
  expect(createLocalCdpClientMock).not.toHaveBeenCalled();
181
292
  });
182
293
 
183
- test("routes to LocalCdpClient when cdpInspect is disabled and extension is absent", () => {
294
+ test("routes to LocalCdpClient when cdpInspect is disabled and extension is absent", async () => {
184
295
  cdpInspectEnabled = false;
185
296
  const ctx = makeContext({
186
297
  conversationId: "local-convo",
@@ -191,29 +302,65 @@ describe("getCdpClient", () => {
191
302
 
192
303
  expect(client.kind).toBe("local");
193
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" });
194
311
  expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
195
312
  expect(createLocalCdpClientMock).toHaveBeenCalledWith("local-convo");
196
313
  expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
197
314
  expect(createCdpInspectClientMock).not.toHaveBeenCalled();
198
315
  });
199
316
 
200
- test("routes to LocalCdpClient when hostBrowserProxy key is omitted", () => {
317
+ test("routes to LocalCdpClient when hostBrowserProxy key is omitted", async () => {
201
318
  const ctx = makeContext({ conversationId: "another-convo" });
202
319
 
203
320
  const client = getCdpClient(ctx);
204
321
 
205
322
  expect(client.kind).toBe("local");
206
323
  expect(client.conversationId).toBe("another-convo");
324
+
325
+ await client.send("Runtime.evaluate");
207
326
  expect(createLocalCdpClientMock).toHaveBeenCalledTimes(1);
208
327
  expect(createLocalCdpClientMock).toHaveBeenCalledWith("another-convo");
209
328
  expect(createExtensionCdpClientMock).not.toHaveBeenCalled();
210
329
  expect(createCdpInspectClientMock).not.toHaveBeenCalled();
211
330
  });
212
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
+
213
362
  test("forwards send() through the manager to the extension-backed client", async () => {
214
- const fakeProxy = {
215
- request: mock(async () => ({})),
216
- } as unknown as HostBrowserProxy;
363
+ const fakeProxy = makeAvailableProxy();
217
364
  const ctx = makeContext({
218
365
  conversationId: "send-ext",
219
366
  hostBrowserProxy: fakeProxy,
@@ -277,25 +424,43 @@ describe("getCdpClient", () => {
277
424
  expect(lastLocalClient).toBeUndefined();
278
425
  });
279
426
 
280
- test("propagates CdpError thrown by the underlying client", async () => {
281
- const ctx = makeContext({ conversationId: "err-local" });
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" });
282
432
  const client = getCdpClient(ctx);
283
- const thrown = new CdpError("cdp_error", "kaboom", {
284
- cdpMethod: "Page.navigate",
285
- });
286
- lastLocalClient!.send = mock(async () => {
287
- throw thrown;
288
- });
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
+ );
289
447
 
290
448
  await expect(
291
449
  client.send("Page.navigate", { url: "https://example.com" }),
292
- ).rejects.toBe(thrown);
450
+ ).rejects.toMatchObject({ code: "cdp_error", message: "kaboom" });
451
+
452
+ // Should NOT have fallen through to local
453
+ expect(createLocalCdpClientMock).not.toHaveBeenCalled();
293
454
  });
294
455
 
295
456
  test("propagates caller AbortSignal to the underlying client", async () => {
296
457
  const ctx = makeContext({ conversationId: "abort-local" });
297
458
  const client = getCdpClient(ctx);
298
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
+
299
464
  let sawSignal: AbortSignal | undefined;
300
465
  lastLocalClient!.send = mock(
301
466
  async (
@@ -318,10 +483,16 @@ describe("getCdpClient", () => {
318
483
  expect(sawSignal).toBe(controller.signal);
319
484
  });
320
485
 
486
+ // ── Dispose ──────────────────────────────────────────────────────────
487
+
321
488
  test("dispose() tears down the underlying client and rejects further sends", async () => {
322
489
  const ctx = makeContext({ conversationId: "dispose-local" });
323
490
  const client = getCdpClient(ctx);
324
491
 
492
+ // Trigger client creation via send
493
+ await client.send("Runtime.evaluate");
494
+ expect(lastLocalClient).toBeDefined();
495
+
325
496
  client.dispose();
326
497
  expect(lastLocalClient?.dispose).toHaveBeenCalledTimes(1);
327
498
 
@@ -334,26 +505,26 @@ describe("getCdpClient", () => {
334
505
  });
335
506
  });
336
507
 
337
- test("dispose() on an extension-backed client tears down the extension client", () => {
338
- const fakeProxy = {
339
- request: mock(async () => ({})),
340
- } as unknown as HostBrowserProxy;
508
+ test("dispose() on an extension-backed client tears down the extension client", async () => {
509
+ const fakeProxy = makeAvailableProxy();
341
510
  const ctx = makeContext({
342
511
  conversationId: "dispose-ext",
343
512
  hostBrowserProxy: fakeProxy,
344
513
  });
345
514
 
346
515
  const client = getCdpClient(ctx);
516
+ await client.send("Page.navigate");
347
517
  client.dispose();
348
518
 
349
519
  expect(lastExtensionClient?.dispose).toHaveBeenCalledTimes(1);
350
520
  });
351
521
 
352
- test("dispose() on a cdp-inspect-backed client tears down the inspect client", () => {
522
+ test("dispose() on a cdp-inspect-backed client tears down the inspect client", async () => {
353
523
  cdpInspectEnabled = true;
354
524
  const ctx = makeContext({ conversationId: "dispose-inspect" });
355
525
 
356
526
  const client = getCdpClient(ctx);
527
+ await client.send("Page.navigate");
357
528
  client.dispose();
358
529
 
359
530
  expect(lastCdpInspectClient?.dispose).toHaveBeenCalledTimes(1);
@@ -364,6 +535,7 @@ describe("getCdpClient", () => {
364
535
  const ctx = makeContext({ conversationId: "post-dispose-inspect" });
365
536
 
366
537
  const client = getCdpClient(ctx);
538
+ await client.send("Page.navigate");
367
539
  client.dispose();
368
540
 
369
541
  // Double dispose is a no-op.
@@ -374,4 +546,1448 @@ describe("getCdpClient", () => {
374
546
  code: "disposed",
375
547
  });
376
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
+ });
377
1993
  });