@vellumai/assistant 0.11.6 → 0.11.7-staging.2

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 (478) hide show
  1. package/ARCHITECTURE.md +31 -19
  2. package/Dockerfile +1 -0
  3. package/docs/architecture/security.md +3 -3
  4. package/docs/flux-turn-detection-spike.md +8 -7
  5. package/docs/runbook-trusted-contacts.md +14 -0
  6. package/docs/stt-provider-onboarding.md +8 -4
  7. package/knip.json +1 -0
  8. package/node_modules/@vellumai/avatar-manifest/package.json +18 -0
  9. package/node_modules/@vellumai/avatar-manifest/src/__tests__/manifest.test.ts +173 -0
  10. package/node_modules/@vellumai/avatar-manifest/src/__tests__/read.test.ts +114 -0
  11. package/node_modules/@vellumai/avatar-manifest/src/index.ts +29 -0
  12. package/node_modules/@vellumai/avatar-manifest/src/layout.ts +18 -0
  13. package/node_modules/@vellumai/avatar-manifest/src/manifest.ts +156 -0
  14. package/node_modules/@vellumai/avatar-manifest/src/read.ts +109 -0
  15. package/node_modules/@vellumai/avatar-manifest/tsconfig.json +20 -0
  16. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  17. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  18. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  19. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  20. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  21. package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
  22. package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
  23. package/node_modules/@vellumai/environments/package.json +2 -1
  24. package/node_modules/@vellumai/environments/src/shell.test.ts +56 -0
  25. package/node_modules/@vellumai/environments/src/shell.ts +65 -0
  26. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  27. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  28. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  29. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  30. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  31. package/node_modules/@vellumai/gateway-client/src/__tests__/contact-read-contracts.test.ts +32 -0
  32. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -0
  33. package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +82 -0
  34. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +56 -0
  35. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +18 -0
  36. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -11
  37. package/node_modules/@vellumai/gateway-client/src/channel-permission-contract.ts +3 -1
  38. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +9 -0
  39. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +4 -0
  40. package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +75 -0
  41. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -0
  42. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +8 -0
  43. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +47 -0
  44. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +11 -0
  45. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +37 -0
  46. package/node_modules/@vellumai/service-contracts/src/__tests__/remote-web-pairing.test.ts +389 -0
  47. package/node_modules/@vellumai/service-contracts/src/ingress.ts +22 -0
  48. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +508 -18
  49. package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
  50. package/openapi.yaml +419 -55
  51. package/package.json +4 -1
  52. package/scripts/sync-llm-catalog.ts +6 -0
  53. package/src/__tests__/approval-card-edit-inbound-handoff.test.ts +199 -0
  54. package/src/__tests__/approval-card-edit.test.ts +153 -0
  55. package/src/__tests__/attachments-store-heic-normalize.test.ts +1 -1
  56. package/src/__tests__/available-skills.test.ts +54 -0
  57. package/src/__tests__/avatar-identity-sync.test.ts +5 -1
  58. package/src/__tests__/catalog-install-normalize.test.ts +9 -1
  59. package/src/__tests__/channel-policy.test.ts +1 -3
  60. package/src/__tests__/channel-readiness-discord.test.ts +9 -70
  61. package/src/__tests__/compactor-call-site-logging.test.ts +61 -0
  62. package/src/__tests__/config-loader-backfill.test.ts +69 -0
  63. package/src/__tests__/config-schema.test.ts +2 -3
  64. package/src/__tests__/conversation-agent-loop.test.ts +238 -0
  65. package/src/__tests__/conversation-attachments.test.ts +0 -1
  66. package/src/__tests__/conversation-error.test.ts +44 -0
  67. package/src/__tests__/conversation-evictor.test.ts +19 -0
  68. package/src/__tests__/conversation-fork-referential.test.ts +73 -63
  69. package/src/__tests__/conversation-fork-retrospective.test.ts +99 -74
  70. package/src/__tests__/conversation-runtime-assembly.test.ts +23 -0
  71. package/src/__tests__/conversation-slash.test.ts +9 -0
  72. package/src/__tests__/conversation-title-service.test.ts +97 -0
  73. package/src/__tests__/conversation-tool-setup-attribution.test.ts +16 -0
  74. package/src/__tests__/credential-broker-server-use.test.ts +0 -218
  75. package/src/__tests__/credential-broker.test.ts +43 -420
  76. package/src/__tests__/credential-record-write-through.test.ts +78 -0
  77. package/src/__tests__/credential-routes.test.ts +17 -1
  78. package/src/__tests__/credential-security-invariants.test.ts +8 -9
  79. package/src/__tests__/default-profile-catalog-fallback.test.ts +289 -0
  80. package/src/__tests__/delete-propagation.test.ts +92 -2
  81. package/src/__tests__/discord-callback-round-trip.test.ts +93 -0
  82. package/src/__tests__/discord-channel-config.test.ts +237 -0
  83. package/src/__tests__/edit-propagation.test.ts +42 -2
  84. package/src/__tests__/evict-conversations-for-reload.test.ts +51 -1
  85. package/src/__tests__/fallback-breaker.test.ts +1335 -0
  86. package/src/__tests__/gateway-threshold-reader-mock.ts +6 -0
  87. package/src/__tests__/guardian-card-withdrawal.test.ts +30 -0
  88. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -2
  89. package/src/__tests__/image-conversion.test.ts +38 -4
  90. package/src/__tests__/install-skill-routing.test.ts +25 -0
  91. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  92. package/src/__tests__/llm-resolver.test.ts +74 -0
  93. package/src/__tests__/llm-schema-fallback-profile.test.ts +359 -0
  94. package/src/__tests__/managed-fallback-dispatch.test.ts +430 -0
  95. package/src/__tests__/managed-profile-guard.test.ts +28 -0
  96. package/src/__tests__/managed-speech-defaults.test.ts +196 -1
  97. package/src/__tests__/mcp-cli.test.ts +23 -1
  98. package/src/__tests__/mcp-tool-annotations-risk.test.ts +43 -34
  99. package/src/__tests__/media-stream-stt-session.test.ts +2 -0
  100. package/src/__tests__/notification-decision-fallback.test.ts +106 -0
  101. package/src/__tests__/notification-discord-adapter.test.ts +162 -0
  102. package/src/__tests__/oauth-connect-orchestrator.test.ts +95 -0
  103. package/src/__tests__/persist-unsendable-image-downscale.test.ts +6 -8
  104. package/src/__tests__/persist-unsendable-image.test.ts +3 -3
  105. package/src/__tests__/platform.test.ts +19 -1
  106. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -1
  107. package/src/__tests__/provider-catalog-visibility.test.ts +17 -0
  108. package/src/__tests__/provider-platform-proxy-integration.test.ts +8 -1
  109. package/src/__tests__/provider-usage-tracking.test.ts +56 -0
  110. package/src/__tests__/require-fresh-approval.test.ts +5 -2
  111. package/src/__tests__/retry-fallback-escalation.test.ts +1093 -0
  112. package/src/__tests__/schedule-retry.test.ts +183 -4
  113. package/src/__tests__/scheduler-reuse-conversation.test.ts +6 -6
  114. package/src/__tests__/secret-routes-platform-proxy.test.ts +55 -0
  115. package/src/__tests__/skill-load-tool.test.ts +34 -0
  116. package/src/__tests__/skill-tool-factory.test.ts +6 -1
  117. package/src/__tests__/skill-tool-manifest.test.ts +20 -0
  118. package/src/__tests__/skills.test.ts +25 -0
  119. package/src/__tests__/subagent-manager-notify.test.ts +38 -0
  120. package/src/__tests__/terminal-tools.test.ts +2 -0
  121. package/src/__tests__/tool-approval-handler.test.ts +146 -2
  122. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +108 -2
  123. package/src/__tests__/tool-audit.test.ts +111 -40
  124. package/src/__tests__/tool-executor-lifecycle-events.test.ts +228 -18
  125. package/src/__tests__/ui-shape-teaching.test.ts +1 -1
  126. package/src/__tests__/verification-control-plane-policy.test.ts +6 -2
  127. package/src/__tests__/voice-config-update.test.ts +43 -0
  128. package/src/__tests__/workspace-migration-147-rename-colliding-backup-profile-names.test.ts +480 -0
  129. package/src/__tests__/workspace-migration-148-strip-unsupported-fallback-profiles.test.ts +167 -0
  130. package/src/__tests__/workspace-migration-149-repoint-backup-profile-selections.test.ts +144 -0
  131. package/src/agent/loop.ts +31 -2
  132. package/src/approvals/guardian-card-withdrawal.ts +22 -0
  133. package/src/avatar/__tests__/ensure-raster.test.ts +291 -0
  134. package/src/avatar/avatar-manifest.ts +48 -100
  135. package/src/avatar/avatar-store.ts +7 -7
  136. package/src/avatar/ensure-raster.ts +146 -0
  137. package/src/avatar/resvg-lazy.test.ts +0 -1
  138. package/src/avatar/traits-png-sync.ts +9 -10
  139. package/src/background-wake/wake-intent-hooks.test.ts +0 -1
  140. package/src/backup/__tests__/paths.test.ts +6 -2
  141. package/src/backup/paths.ts +6 -4
  142. package/src/bundler/app-compiler.ts +2 -2
  143. package/src/bundler/compiler-tools.test.ts +36 -0
  144. package/src/bundler/compiler-tools.ts +54 -18
  145. package/src/calls/__tests__/telephony-synthesis-language.test.ts +81 -0
  146. package/src/calls/__tests__/voice-session-bridge.test.ts +57 -0
  147. package/src/calls/media-stream-stt-session.ts +2 -1
  148. package/src/calls/telephony-synthesis-language.ts +13 -5
  149. package/src/calls/voice-session-bridge.ts +19 -4
  150. package/src/channels/config.ts +23 -12
  151. package/src/cli/AGENTS.md +3 -1
  152. package/src/cli/commands/backup.help.ts +10 -13
  153. package/src/cli/commands/contacts.help.ts +16 -4
  154. package/src/cli/commands/contacts.ts +5 -1
  155. package/src/cli/commands/credentials.help.ts +1 -1
  156. package/src/cli/commands/inference-providers.ts +18 -53
  157. package/src/cli/commands/mcp.help.ts +6 -5
  158. package/src/cli/commands/mcp.ts +1 -1
  159. package/src/cli/commands/memory/memory-retrospective.ts +1 -0
  160. package/src/cli/commands/stt.help.ts +2 -2
  161. package/src/config/__tests__/backup-schema.test.ts +1 -1
  162. package/src/config/__tests__/default-profile-catalog.test.ts +83 -2
  163. package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +1 -1
  164. package/src/config/__tests__/memory-retrospective-schema.test.ts +12 -15
  165. package/src/config/bundled-skills/computer-use/SKILL.md +6 -6
  166. package/src/config/bundled-skills/computer-use/TOOLS.json +9 -6
  167. package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +3 -1
  168. package/src/config/bundled-skills/media-processing/services/preprocess.ts +1 -1
  169. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  170. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +16 -15
  171. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +2 -1
  172. package/src/config/bundled-skills/settings/TOOLS.json +11 -5
  173. package/src/config/bundled-skills/settings/tools/open-system-settings.test.ts +85 -0
  174. package/src/config/bundled-skills/settings/tools/open-system-settings.ts +33 -8
  175. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +54 -3
  176. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +1 -1
  177. package/src/config/default-profile-catalog.ts +195 -10
  178. package/src/config/default-profile-names.ts +41 -0
  179. package/src/config/feature-flag-registry.json +34 -2
  180. package/src/config/inference-profile-validation.ts +11 -2
  181. package/src/config/llm-resolver.ts +25 -0
  182. package/src/config/loader.ts +3 -0
  183. package/src/config/managed-speech-defaults.ts +160 -10
  184. package/src/config/schema.ts +2 -0
  185. package/src/config/schemas/__tests__/live-voice.test.ts +7 -6
  186. package/src/config/schemas/__tests__/stt.test.ts +94 -0
  187. package/src/config/schemas/backup.ts +1 -1
  188. package/src/config/schemas/channels.ts +32 -0
  189. package/src/config/schemas/live-voice.ts +13 -4
  190. package/src/config/schemas/llm.ts +263 -15
  191. package/src/config/schemas/mcp.ts +10 -2
  192. package/src/config/schemas/memory-retrospective.ts +1 -19
  193. package/src/config/schemas/services.ts +24 -0
  194. package/src/config/schemas/stt.ts +128 -5
  195. package/src/config/seed-inference-profiles.ts +32 -3
  196. package/src/config/skill-state.ts +5 -0
  197. package/src/config/skills.ts +21 -0
  198. package/src/contacts/notify-contacts-changed.ts +5 -2
  199. package/src/context/compactor.ts +74 -1
  200. package/src/daemon/__tests__/conversation-tool-setup.test.ts +138 -1
  201. package/src/daemon/config-watcher.ts +2 -1
  202. package/src/daemon/conversation-agent-loop-handlers.ts +27 -0
  203. package/src/daemon/conversation-agent-loop.ts +86 -10
  204. package/src/daemon/conversation-error.ts +25 -0
  205. package/src/daemon/conversation-evictor.ts +2 -4
  206. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  207. package/src/daemon/conversation-skill-tools.ts +5 -1
  208. package/src/daemon/conversation-slash.ts +2 -2
  209. package/src/daemon/conversation-store.ts +8 -1
  210. package/src/daemon/conversation-tool-setup.ts +37 -0
  211. package/src/daemon/conversation.ts +0 -4
  212. package/src/daemon/handlers/channel-config-result.ts +30 -0
  213. package/src/daemon/handlers/config-discord-channel.ts +301 -0
  214. package/src/daemon/handlers/config-ingress.ts +9 -0
  215. package/src/daemon/handlers/config-slack-channel.ts +13 -15
  216. package/src/daemon/handlers/config-telegram.ts +2 -5
  217. package/src/daemon/handlers/conversations.ts +0 -2
  218. package/src/daemon/handlers/skills.ts +25 -7
  219. package/src/daemon/lifecycle.ts +3 -1
  220. package/src/daemon/message-types/messages.ts +0 -2
  221. package/src/daemon/tool-setup-types.ts +5 -3
  222. package/src/daemon/trust-context-types.test.ts +33 -0
  223. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  224. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +58 -4
  225. package/src/live-voice/__tests__/live-voice-managed-speech-fallback.test.ts +8 -3
  226. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +73 -11
  227. package/src/live-voice/__tests__/live-voice-session-preflight.test.ts +148 -2
  228. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +64 -0
  229. package/src/live-voice/__tests__/live-voice-stt.test.ts +3 -0
  230. package/src/live-voice/__tests__/live-voice-text-turn.test.ts +391 -0
  231. package/src/live-voice/__tests__/protocol.test.ts +224 -0
  232. package/src/live-voice/live-voice-credential-preflight.ts +5 -1
  233. package/src/live-voice/live-voice-session-manager.ts +39 -2
  234. package/src/live-voice/live-voice-session.ts +224 -9
  235. package/src/live-voice/protocol.ts +187 -1
  236. package/src/messaging/providers/discord/api.ts +2 -1
  237. package/src/messaging/providers/discord/withdraw.ts +62 -0
  238. package/src/messaging/read-provider-metadata.ts +32 -0
  239. package/src/monitoring/__tests__/file-descriptors.test.ts +32 -0
  240. package/src/monitoring/__tests__/process-memory.test.ts +34 -1
  241. package/src/monitoring/db-integrity-sample.ts +1 -0
  242. package/src/monitoring/file-descriptors.ts +39 -6
  243. package/src/monitoring/process-memory.ts +37 -5
  244. package/src/monitoring/resource-sampler.ts +25 -8
  245. package/src/notifications/__tests__/activity-failed-dedupe.test.ts +161 -0
  246. package/src/notifications/__tests__/background-failure-signal.test.ts +107 -0
  247. package/src/notifications/__tests__/connected-channels.test.ts +12 -0
  248. package/src/notifications/__tests__/copy-composer.test.ts +83 -0
  249. package/src/notifications/__tests__/destination-resolver.test.ts +38 -0
  250. package/src/notifications/activity-failed-dedupe.ts +95 -0
  251. package/src/notifications/adapters/discord.ts +132 -0
  252. package/src/notifications/adapters/shared.ts +16 -0
  253. package/src/notifications/adapters/slack.ts +6 -1
  254. package/src/notifications/adapters/telegram.ts +5 -8
  255. package/src/notifications/approval-card-data.ts +94 -1
  256. package/src/notifications/background-failure-signal.ts +116 -0
  257. package/src/notifications/broadcaster.ts +3 -9
  258. package/src/notifications/copy-composer.ts +41 -8
  259. package/src/notifications/decision-engine.ts +55 -0
  260. package/src/notifications/destination-resolver.ts +45 -5
  261. package/src/notifications/emit-signal.ts +19 -5
  262. package/src/notifications/guardian-question-mode.ts +57 -0
  263. package/src/notifications/types.ts +2 -8
  264. package/src/oauth/__tests__/seed-providers-managed.test.ts +18 -0
  265. package/src/oauth/connect-orchestrator.ts +16 -0
  266. package/src/oauth/seed-providers.ts +262 -0
  267. package/src/permissions/checker.test.ts +21 -0
  268. package/src/permissions/checker.ts +6 -0
  269. package/src/permissions/gateway-threshold-reader.test.ts +184 -0
  270. package/src/permissions/gateway-threshold-reader.ts +144 -10
  271. package/src/permissions/prompter.ts +0 -6
  272. package/src/permissions/types.ts +5 -4
  273. package/src/persistence/__tests__/bulk-write-gate.test.ts +0 -38
  274. package/src/persistence/bulk-write-gate.ts +9 -10
  275. package/src/persistence/conversation-crud.ts +88 -134
  276. package/src/persistence/conversation-title-service.ts +43 -29
  277. package/src/persistence/embeddings/embedding-local.ts +14 -85
  278. package/src/persistence/embeddings/embedding-runtime-manager.ts +1 -0
  279. package/src/persistence/lifecycle-events-store.test.ts +50 -0
  280. package/src/persistence/lifecycle-events-store.ts +75 -5
  281. package/src/platform/platform-patch-queue.test.ts +368 -0
  282. package/src/platform/platform-patch-queue.ts +190 -0
  283. package/src/platform/sync-avatar.test.ts +527 -0
  284. package/src/platform/sync-avatar.ts +209 -0
  285. package/src/platform/sync-identity.test.ts +75 -0
  286. package/src/platform/sync-identity.ts +17 -110
  287. package/src/plugin-api/model-profiles.test.ts +2 -2
  288. package/src/plugin-api/model-profiles.ts +7 -7
  289. package/src/plugin-api/transcription-session.ts +1 -1
  290. package/src/plugins/defaults/compaction/window-manager.ts +4 -0
  291. package/src/plugins/defaults/image-recovery/recover.ts +2 -2
  292. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +133 -34
  293. package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +21 -31
  294. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +13 -9
  295. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +31 -4
  296. package/src/plugins/defaults/memory/memory-retrospective-constants.ts +0 -11
  297. package/src/plugins/defaults/memory/memory-retrospective-fork-boundary.ts +27 -13
  298. package/src/plugins/defaults/memory/memory-retrospective-job.ts +183 -113
  299. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +13 -13
  300. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +2 -2
  301. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +1 -1
  302. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +4 -2
  303. package/src/plugins/defaults/memory/substrate/sweep-job.ts +9 -42
  304. package/src/plugins/defaults/memory/v2/rerank-local.ts +1 -0
  305. package/src/plugins/defaults/memory/worker.ts +7 -0
  306. package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
  307. package/src/providers/__tests__/retry-callsite.test.ts +55 -0
  308. package/src/providers/__tests__/vellum-connection-routing.test.ts +21 -3
  309. package/src/providers/connection-resolution.ts +2 -17
  310. package/src/providers/fallback-breaker.ts +389 -0
  311. package/src/providers/inference/__tests__/adapter-factory-opencode.test.ts +92 -0
  312. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +16 -0
  313. package/src/providers/inference/__tests__/base-url-security.test.ts +12 -0
  314. package/src/providers/inference/adapter-factory.ts +246 -3
  315. package/src/providers/inference/auth.ts +15 -14
  316. package/src/providers/inference/chatgpt-subscription-auth.ts +70 -0
  317. package/src/providers/inference/chatgpt-subscription-credentials.ts +84 -0
  318. package/src/providers/inference/resolve-auth.ts +1 -1
  319. package/src/providers/media-resolve.ts +2 -3
  320. package/src/providers/model-catalog.ts +100 -0
  321. package/src/providers/openai/__tests__/api-error-normalization.test.ts +22 -0
  322. package/src/providers/openai/__tests__/chat-template-flatten.test.ts +198 -0
  323. package/src/providers/openai/api-error-normalization.ts +13 -0
  324. package/src/providers/openai/chat-completions-provider.ts +84 -0
  325. package/src/providers/opencode/client.test.ts +150 -0
  326. package/src/providers/opencode/client.ts +61 -0
  327. package/src/providers/platform-proxy/constants.ts +5 -0
  328. package/src/providers/provider-resolvability.ts +20 -0
  329. package/src/providers/provider-secret-catalog.ts +3 -2
  330. package/src/providers/retry.ts +956 -68
  331. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +42 -0
  332. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +136 -0
  333. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +120 -0
  334. package/src/providers/speech-to-text/__tests__/resolve.test.ts +316 -12
  335. package/src/providers/speech-to-text/deepgram-flux-frames.ts +83 -3
  336. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +132 -4
  337. package/src/providers/speech-to-text/provider-catalog.ts +191 -3
  338. package/src/providers/speech-to-text/resolve.ts +132 -24
  339. package/src/providers/speech-to-text/vellum-managed-flux-realtime.ts +296 -0
  340. package/src/providers/types.ts +39 -0
  341. package/src/providers/usage-tracking.ts +12 -1
  342. package/src/providers/vellum/client.ts +29 -0
  343. package/src/providers/vellum-model-routing.test.ts +2 -0
  344. package/src/providers/vellum-model-routing.ts +3 -3
  345. package/src/runtime/AGENTS.md +1 -2
  346. package/src/runtime/__tests__/agent-wake.test.ts +105 -3
  347. package/src/runtime/__tests__/background-job-runner.test.ts +89 -3
  348. package/src/runtime/agent-wake.ts +44 -5
  349. package/src/runtime/background-job-runner.ts +68 -50
  350. package/src/runtime/channel-readiness-service.ts +0 -39
  351. package/src/runtime/http-server.ts +12 -4
  352. package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +18 -18
  353. package/src/runtime/routes/__tests__/backup-routes.test.ts +9 -5
  354. package/src/runtime/routes/__tests__/chatgpt-subscription-auth-routes.test.ts +372 -0
  355. package/src/runtime/routes/__tests__/contact-routes.test.ts +7 -0
  356. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +96 -0
  357. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +37 -0
  358. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +2 -1
  359. package/src/runtime/routes/__tests__/mcp-add-default-risk.test.ts +87 -0
  360. package/src/runtime/routes/acp-claude-auth-routes.ts +25 -54
  361. package/src/runtime/routes/approval-routes.ts +0 -12
  362. package/src/runtime/routes/avatar-routes.ts +17 -30
  363. package/src/runtime/routes/backup-routes.ts +2 -2
  364. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +296 -113
  365. package/src/runtime/routes/contact-prompt-routes.ts +65 -6
  366. package/src/runtime/routes/contact-routes.ts +20 -15
  367. package/src/runtime/routes/conversation-query-routes.ts +98 -10
  368. package/src/runtime/routes/conversation-routes.ts +2 -2
  369. package/src/runtime/routes/credential-routes.ts +2 -1
  370. package/src/runtime/routes/guardian-approval-interception.ts +12 -9
  371. package/src/runtime/routes/inbound-message-handler.ts +47 -48
  372. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +18 -13
  373. package/src/runtime/routes/inbound-stages/admission-policy.ts +2 -2
  374. package/src/runtime/routes/inbound-stages/edit-intercept.ts +20 -2
  375. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +9 -8
  376. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +1 -1
  377. package/src/runtime/routes/index.ts +2 -0
  378. package/src/runtime/routes/inference-profile-session-handler.ts +2 -2
  379. package/src/runtime/routes/inference-profiles-routes.ts +12 -7
  380. package/src/runtime/routes/inference-provider-connection-routes.ts +2 -2
  381. package/src/runtime/routes/inference-send-routes.ts +2 -2
  382. package/src/runtime/routes/ingress-status-routes.ts +2 -2
  383. package/src/runtime/routes/integrations/discord.ts +119 -0
  384. package/src/runtime/routes/llm-call-sites-routes.ts +2 -2
  385. package/src/runtime/routes/mcp-auth-routes.ts +12 -6
  386. package/src/runtime/routes/migration-routes.ts +3 -1
  387. package/src/runtime/routes/oauth-pending-flows.ts +136 -0
  388. package/src/runtime/routes/secret-routes.ts +7 -0
  389. package/src/runtime/routes/stt-routes.ts +38 -6
  390. package/src/runtime/routes/wake-conversation-routes.ts +8 -1
  391. package/src/runtime/routes/watch-routes.ts +9 -5
  392. package/src/runtime/sync/resource-sync-events.ts +2 -0
  393. package/src/runtime/trust-verdict-consumer.ts +3 -3
  394. package/src/schedule/__tests__/worker-feature-flags.test.ts +58 -0
  395. package/src/schedule/retry-policy.ts +8 -8
  396. package/src/schedule/run-script.ts +14 -23
  397. package/src/schedule/schedule-recovery.ts +0 -1
  398. package/src/schedule/scheduler.ts +89 -51
  399. package/src/schedule/worker.ts +57 -0
  400. package/src/security/__tests__/openai-device-auth.test.ts +355 -0
  401. package/src/security/ces-rpc-record-backend.ts +123 -0
  402. package/src/security/oauth2.ts +7 -0
  403. package/src/security/openai-device-auth.ts +364 -0
  404. package/src/security/secure-keys.ts +15 -0
  405. package/src/skills/available-skills.ts +10 -1
  406. package/src/skills/catalog-install.ts +19 -1
  407. package/src/skills/inline-command-runner.ts +6 -2
  408. package/src/skills/platform-compatibility.test.ts +49 -0
  409. package/src/skills/platform-compatibility.ts +65 -0
  410. package/src/skills/tool-manifest.ts +20 -0
  411. package/src/stt/__tests__/roles.test.ts +152 -0
  412. package/src/stt/daemon-batch-transcriber.ts +4 -1
  413. package/src/stt/roles.ts +180 -0
  414. package/src/stt/stt-stream-session.ts +3 -2
  415. package/src/stt/types.ts +33 -3
  416. package/src/subagent/manager.ts +12 -0
  417. package/src/telemetry/__tests__/live-voice-funnel.test.ts +34 -0
  418. package/src/telemetry/live-voice-funnel.ts +12 -2
  419. package/src/telemetry/telemetry-wire.generated.ts +6 -0
  420. package/src/telemetry/tool-audit.ts +83 -30
  421. package/src/telemetry/tool-executed-events-store.test.ts +0 -1
  422. package/src/telemetry/turn-outcome.ts +75 -4
  423. package/src/tools/client-os.ts +115 -0
  424. package/src/tools/computer-use/definitions.ts +11 -7
  425. package/src/tools/computer-use/skill-proxy-bridge.ts +2 -2
  426. package/src/tools/credentials/broker-types.ts +0 -71
  427. package/src/tools/credentials/broker.ts +6 -201
  428. package/src/tools/credentials/metadata-store.ts +65 -16
  429. package/src/tools/credentials/store.ts +2 -0
  430. package/src/tools/executor.ts +2 -8
  431. package/src/tools/host-shell.test.ts +75 -0
  432. package/src/tools/host-shell.ts +24 -0
  433. package/src/tools/host-terminal/host-shell.ts +15 -44
  434. package/src/tools/mcp/mcp-tool-factory.ts +44 -9
  435. package/src/tools/permission-checker.ts +73 -21
  436. package/src/tools/policy-context.ts +1 -0
  437. package/src/tools/shared/filesystem/image-read.ts +1 -1
  438. package/src/tools/skills/find-similar-skills.ts +10 -2
  439. package/src/tools/skills/load.ts +41 -6
  440. package/src/tools/skills/sandbox-runner.ts +8 -16
  441. package/src/tools/skills/skill-tool-factory.ts +1 -0
  442. package/src/tools/terminal/__tests__/safe-env.test.ts +64 -1
  443. package/src/tools/terminal/safe-env.ts +39 -9
  444. package/src/tools/terminal/shell.ts +12 -31
  445. package/src/tools/tool-approval-handler.ts +64 -10
  446. package/src/tools/tool-types.ts +10 -0
  447. package/src/tools/types.ts +19 -3
  448. package/src/tools/ui-surface/surface-shape-docs.ts +4 -3
  449. package/src/tools/workflows/manage-workflows.ts +2 -2
  450. package/src/tools/workflows/run-workflow.test.ts +2 -1
  451. package/src/util/__tests__/file-use.test.ts +61 -0
  452. package/src/util/__tests__/process-table.test.ts +66 -0
  453. package/src/util/__tests__/process-tree.test.ts +46 -0
  454. package/src/util/errors.ts +1 -0
  455. package/src/util/file-use.ts +91 -0
  456. package/src/util/host-process.test.ts +17 -0
  457. package/src/util/host-process.ts +75 -0
  458. package/src/util/image-conversion.ts +39 -7
  459. package/src/util/platform.ts +32 -13
  460. package/src/util/process-table.ts +249 -0
  461. package/src/util/process-tree.ts +85 -129
  462. package/src/util/provider-error-patterns.ts +20 -0
  463. package/src/util/spawn.ts +9 -2
  464. package/src/watch/__tests__/watch-retro.test.ts +48 -1
  465. package/src/watch/watch-retro.ts +46 -4
  466. package/src/workspace/git-service.ts +4 -14
  467. package/src/workspace/migrations/147-rename-colliding-backup-profile-names.ts +360 -0
  468. package/src/workspace/migrations/148-strip-unsupported-fallback-profiles.ts +117 -0
  469. package/src/workspace/migrations/149-repoint-backup-profile-selections.ts +204 -0
  470. package/src/workspace/migrations/150-stt-flux-provider-to-model-family.ts +139 -0
  471. package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +160 -0
  472. package/src/workspace/migrations/registry.ts +8 -0
  473. package/src/__tests__/permission-types.test.ts +0 -17
  474. package/src/channels/gateway-discord-admission.ts +0 -32
  475. package/src/persistence/fork-message-copy.ts +0 -239
  476. package/src/plugins/defaults/memory/__tests__/fork-message-copy.test.ts +0 -221
  477. package/src/security/__tests__/oauth2-device-code.test.ts +0 -431
  478. package/src/security/oauth2-device-code.ts +0 -312
@@ -0,0 +1,1335 @@
1
+ /**
2
+ * The fallback circuit breaker: its own state machine under an injected clock,
3
+ * and the `RetryProvider` behavior it drives.
4
+ *
5
+ * Deliberately mock-free. The breaker takes `now` on every entry point, so the
6
+ * state machine is tested against explicit timestamps instead of a faked
7
+ * global clock, and the `RetryProvider` cases use stub providers whose call
8
+ * counts are the assertion.
9
+ */
10
+
11
+ import { beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import {
14
+ type BreakerRoute,
15
+ recordFallbackServed,
16
+ recordPrimaryFailure,
17
+ recordPrimarySuccess,
18
+ releaseRecoveryProbe,
19
+ resetFallbackBreaker,
20
+ shouldSkipPrimary,
21
+ tryAcquireRecoveryProbe,
22
+ } from "../providers/fallback-breaker.js";
23
+ import { RetryProvider } from "../providers/retry.js";
24
+ import type {
25
+ Message,
26
+ Provider,
27
+ ProviderResponse,
28
+ SendMessageOptions,
29
+ } from "../providers/types.js";
30
+ import { UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE } from "../providers/unparseable-tool-args.js";
31
+ import { ProviderError } from "../util/errors.js";
32
+ import { DEFAULT_MAX_RETRIES } from "../util/retry.js";
33
+ import { setConfig } from "./helpers/set-config.js";
34
+
35
+ // ── Fixtures ────────────────────────────────────────────────────────────────
36
+
37
+ const UPSTREAM = "openai";
38
+ const T0 = 1_700_000_000_000;
39
+
40
+ const FAILURE_WINDOW_MS = 5 * 60_000;
41
+ const BASE_COOLDOWN_MS = 120_000;
42
+ const MAX_COOLDOWN_MS = 10 * 60_000;
43
+ const JITTER = 0.2;
44
+
45
+ /** The whole upstream, as an outage marks it. */
46
+ const UPSTREAM_ROUTE: BreakerRoute = { upstream: UPSTREAM };
47
+ /** The primary profile's own model, as a retirement marks it. */
48
+ const PRIMARY_ROUTE: BreakerRoute = {
49
+ upstream: UPSTREAM,
50
+ model: "primary-model",
51
+ };
52
+ /** A second, healthy model on the same upstream. */
53
+ const OTHER_ROUTE: BreakerRoute = { upstream: UPSTREAM, model: "other-model" };
54
+
55
+ const MESSAGES: Message[] = [
56
+ { role: "user", content: [{ type: "text", text: "hi" }] },
57
+ ];
58
+
59
+ const LLM_FIXTURE = {
60
+ profiles: {
61
+ "primary-profile": {
62
+ source: "user",
63
+ provider: "openai",
64
+ model: "primary-model",
65
+ maxTokens: 1111,
66
+ },
67
+ // A second profile on the same upstream, so a model-scoped trip can be
68
+ // shown to leave healthy models alone.
69
+ "other-profile": {
70
+ source: "user",
71
+ provider: "openai",
72
+ model: "other-model",
73
+ maxTokens: 1111,
74
+ },
75
+ "backup-profile": {
76
+ source: "user",
77
+ provider: "anthropic",
78
+ model: "backup-model",
79
+ maxTokens: 2222,
80
+ },
81
+ },
82
+ activeProfile: "primary-profile",
83
+ };
84
+
85
+ /** Per-call config that resolves to `other-model` on the same upstream. */
86
+ const OTHER_MODEL_CALL = {
87
+ callSite: "mainAgent",
88
+ overrideProfile: "other-profile",
89
+ forceOverrideProfile: true,
90
+ } as const;
91
+
92
+ beforeEach(() => {
93
+ resetFallbackBreaker();
94
+ setConfig("llm", LLM_FIXTURE);
95
+ });
96
+
97
+ /**
98
+ * The cooldown the breaker actually picked, recovered by bisecting the first
99
+ * offset from `trippedAt` at which it stops skipping the primary. Jitter makes
100
+ * the value unpredictable by design, so tests assert on the band it lands in
101
+ * rather than on a number the test itself computed.
102
+ */
103
+ function observedCooldownMs(route: BreakerRoute, trippedAt: number): number {
104
+ let lo = 0;
105
+ let hi = MAX_COOLDOWN_MS + 1;
106
+ while (lo < hi) {
107
+ const mid = Math.floor((lo + hi) / 2);
108
+ if (shouldSkipPrimary(route, trippedAt + mid)) {
109
+ lo = mid + 1;
110
+ } else {
111
+ hi = mid;
112
+ }
113
+ }
114
+ return lo;
115
+ }
116
+
117
+ function expectWithinJitterBand(cooldown: number, base: number): void {
118
+ expect(cooldown).toBeGreaterThanOrEqual(Math.floor(base * (1 - JITTER)));
119
+ expect(cooldown).toBeLessThanOrEqual(Math.ceil(base * (1 + JITTER)));
120
+ expect(cooldown).toBeLessThanOrEqual(MAX_COOLDOWN_MS);
121
+ }
122
+
123
+ function okResponse(model: string): ProviderResponse {
124
+ return {
125
+ content: [{ type: "text", text: "ok" }],
126
+ model,
127
+ usage: { inputTokens: 1, outputTokens: 1 },
128
+ stopReason: "end_turn",
129
+ } as ProviderResponse;
130
+ }
131
+
132
+ /**
133
+ * Primary stub that answers with whatever model the resolved options carry, or
134
+ * throws the next error from `errors` while any remain.
135
+ */
136
+ function primaryProvider(...errors: (() => unknown)[]): {
137
+ provider: Provider;
138
+ calls: () => number;
139
+ seenModels: () => (string | undefined)[];
140
+ seenMessages: () => Message[][];
141
+ } {
142
+ const seen: (string | undefined)[] = [];
143
+ const seenMessages: Message[][] = [];
144
+ let calls = 0;
145
+ const provider: Provider = {
146
+ name: UPSTREAM,
147
+ sendMessage: async (messages: Message[], options?: SendMessageOptions) => {
148
+ calls += 1;
149
+ seen.push(options?.config?.model);
150
+ seenMessages.push(messages);
151
+ const next = errors.shift();
152
+ if (next) {
153
+ throw next();
154
+ }
155
+ return okResponse(options?.config?.model ?? "unresolved");
156
+ },
157
+ };
158
+ return {
159
+ provider,
160
+ calls: () => calls,
161
+ seenModels: () => seen,
162
+ seenMessages: () => seenMessages,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Backup stub that serves whatever model the re-normalized options resolved,
168
+ * after throwing the next error from `errors` while any remain.
169
+ */
170
+ function backupProvider(...errors: (() => unknown)[]): {
171
+ provider: Provider;
172
+ calls: () => number;
173
+ } {
174
+ let calls = 0;
175
+ const provider: Provider = {
176
+ name: "anthropic",
177
+ sendMessage: async (_messages: Message[], options?: SendMessageOptions) => {
178
+ calls += 1;
179
+ const next = errors.shift();
180
+ if (next) {
181
+ throw next();
182
+ }
183
+ return okResponse(options?.config?.model ?? "unresolved");
184
+ },
185
+ };
186
+ return { provider, calls: () => calls };
187
+ }
188
+
189
+ /**
190
+ * A transient upstream failure that names a zero wait, so a retry budget can be
191
+ * exercised without the test sitting through real exponential backoff.
192
+ */
193
+ function transient(status = 503): ProviderError {
194
+ return new ProviderError("Service Unavailable", "anthropic", status, {
195
+ retryAfterMs: 0,
196
+ });
197
+ }
198
+
199
+ /**
200
+ * A mid-stream corruption: the upstream accepted the request and streamed, so
201
+ * the error carries no HTTP status. `retryAfterMs` is a test convenience only,
202
+ * standing in for a Retry-After header this shape would not really carry, so a
203
+ * retry budget can be counted without sitting through real backoff.
204
+ */
205
+ function corruptedStream(): ProviderError {
206
+ return new ProviderError(
207
+ "stream ended without producing a message",
208
+ UPSTREAM,
209
+ undefined,
210
+ { retryAfterMs: 0 },
211
+ );
212
+ }
213
+
214
+ function makeRoute(provider: Provider): {
215
+ resolveFallbackRoute: (
216
+ failedOptions: SendMessageOptions | undefined,
217
+ ) => Promise<{
218
+ provider: Provider;
219
+ overrideProfile: string;
220
+ forwardUsageAttributionHeaders: boolean;
221
+ } | null>;
222
+ calls: () => number;
223
+ } {
224
+ let calls = 0;
225
+ return {
226
+ resolveFallbackRoute: async () => {
227
+ calls += 1;
228
+ return {
229
+ provider,
230
+ overrideProfile: "backup-profile",
231
+ forwardUsageAttributionHeaders: true,
232
+ };
233
+ },
234
+ calls: () => calls,
235
+ };
236
+ }
237
+
238
+ async function captureError(promise: Promise<unknown>): Promise<unknown> {
239
+ try {
240
+ await promise;
241
+ } catch (error) {
242
+ return error;
243
+ }
244
+ throw new Error("expected the promise to reject");
245
+ }
246
+
247
+ // ── The breaker state machine ───────────────────────────────────────────────
248
+
249
+ describe("fallback breaker state machine", () => {
250
+ test("two eligible failures inside the window trip the breaker", () => {
251
+ recordPrimaryFailure(UPSTREAM_ROUTE, T0);
252
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0)).toBe(false);
253
+
254
+ recordPrimaryFailure(UPSTREAM_ROUTE, T0 + 1_000);
255
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0 + 1_000)).toBe(true);
256
+ });
257
+
258
+ test("failures spread beyond the window never accumulate into a trip", () => {
259
+ recordPrimaryFailure(UPSTREAM_ROUTE, T0);
260
+ recordPrimaryFailure(UPSTREAM_ROUTE, T0 + FAILURE_WINDOW_MS + 1);
261
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0 + FAILURE_WINDOW_MS + 1)).toBe(
262
+ false,
263
+ );
264
+ });
265
+
266
+ test("a success between failures resets the count, so isolated blips never trip", () => {
267
+ recordPrimaryFailure(UPSTREAM_ROUTE, T0);
268
+ recordPrimarySuccess(UPSTREAM_ROUTE, T0 + 1_000);
269
+ recordPrimaryFailure(UPSTREAM_ROUTE, T0 + 2_000);
270
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0 + 2_000)).toBe(false);
271
+
272
+ recordPrimaryFailure(UPSTREAM_ROUTE, T0 + 3_000);
273
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0 + 3_000)).toBe(true);
274
+ });
275
+
276
+ test("a completed backup serve trips the breaker on its own", () => {
277
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
278
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0)).toBe(true);
279
+ });
280
+
281
+ test("a success closes an open breaker", () => {
282
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
283
+ recordPrimarySuccess(UPSTREAM_ROUTE, T0 + 1_000);
284
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0 + 1_000)).toBe(false);
285
+ });
286
+
287
+ test("a stale fallback serve cannot re-trip a recovered route", () => {
288
+ const observation = recordPrimaryFailure(UPSTREAM_ROUTE, T0);
289
+ recordPrimarySuccess(UPSTREAM_ROUTE, T0 + 1_000);
290
+
291
+ recordFallbackServed(UPSTREAM_ROUTE, T0 + 2_000, observation);
292
+
293
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0 + 2_000)).toBe(false);
294
+ });
295
+
296
+ test("the cooldown lands inside the jitter band and varies between trips", () => {
297
+ const seen = new Set<number>();
298
+ for (let i = 0; i < 40; i += 1) {
299
+ resetFallbackBreaker();
300
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
301
+ const cooldown = observedCooldownMs(UPSTREAM_ROUTE, T0);
302
+ expectWithinJitterBand(cooldown, BASE_COOLDOWN_MS);
303
+ seen.add(cooldown);
304
+ }
305
+ // Jitter exists so a fleet of daemons does not probe a recovering upstream
306
+ // in the same second; a constant cooldown would defeat that.
307
+ expect(seen.size).toBeGreaterThan(1);
308
+ });
309
+
310
+ test("after the cooldown exactly one caller wins the probe, the rest keep skipping", () => {
311
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
312
+ const cooldown = observedCooldownMs(UPSTREAM_ROUTE, T0);
313
+ const ready = T0 + cooldown;
314
+
315
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, ready)).toBe(false);
316
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(true);
317
+
318
+ // While that probe is deciding recovery, everyone else stays on the backup
319
+ // rather than piling onto a route that may still be down.
320
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(false);
321
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, ready)).toBe(true);
322
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, ready + 60_000)).toBe(true);
323
+ });
324
+
325
+ test("a successful probe closes the breaker", () => {
326
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
327
+ const ready = T0 + observedCooldownMs(UPSTREAM_ROUTE, T0);
328
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(true);
329
+
330
+ releaseRecoveryProbe(UPSTREAM_ROUTE, { verdict: "recovered" }, ready + 500);
331
+
332
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, ready + 500)).toBe(false);
333
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready + 500)).toBe(false);
334
+ });
335
+
336
+ test("a failed probe re-trips with a doubled cooldown, capped at 10 minutes", () => {
337
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
338
+ let at = T0;
339
+ expectWithinJitterBand(
340
+ observedCooldownMs(UPSTREAM_ROUTE, at),
341
+ BASE_COOLDOWN_MS,
342
+ );
343
+
344
+ for (const expectedBase of [
345
+ BASE_COOLDOWN_MS * 2,
346
+ BASE_COOLDOWN_MS * 4,
347
+ MAX_COOLDOWN_MS,
348
+ MAX_COOLDOWN_MS,
349
+ ]) {
350
+ at += observedCooldownMs(UPSTREAM_ROUTE, at);
351
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, at)).toBe(true);
352
+ releaseRecoveryProbe(
353
+ UPSTREAM_ROUTE,
354
+ { verdict: "failing", failedRoute: UPSTREAM_ROUTE },
355
+ at,
356
+ );
357
+ expectWithinJitterBand(
358
+ observedCooldownMs(UPSTREAM_ROUTE, at),
359
+ expectedBase,
360
+ );
361
+ }
362
+ });
363
+
364
+ test("resetFallbackBreaker drops every remembered route", () => {
365
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
366
+ resetFallbackBreaker();
367
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0)).toBe(false);
368
+ });
369
+ });
370
+
371
+ // ── Scope: one model versus the whole upstream ──────────────────────────────
372
+
373
+ describe("fallback breaker scope", () => {
374
+ test("a retired model is remembered for that model alone", () => {
375
+ recordFallbackServed(PRIMARY_ROUTE, T0);
376
+
377
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, T0)).toBe(true);
378
+ // A healthy profile on the same upstream keeps its own primary: the
379
+ // upstream never failed, one model on it did.
380
+ expect(shouldSkipPrimary(OTHER_ROUTE, T0)).toBe(false);
381
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, T0)).toBe(false);
382
+ });
383
+
384
+ test("an upstream outage is remembered for every model on it", () => {
385
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
386
+
387
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, T0)).toBe(true);
388
+ expect(shouldSkipPrimary(OTHER_ROUTE, T0)).toBe(true);
389
+ });
390
+
391
+ test("probing a healthy model does not close another model's outage", () => {
392
+ recordFallbackServed(PRIMARY_ROUTE, T0);
393
+ // Remembered long enough ago that its cooldown has expired and a probe is
394
+ // due, unlike the retired model's.
395
+ recordFallbackServed(OTHER_ROUTE, T0 - MAX_COOLDOWN_MS - 1);
396
+
397
+ expect(tryAcquireRecoveryProbe(OTHER_ROUTE, T0)).toBe(true);
398
+ releaseRecoveryProbe(OTHER_ROUTE, { verdict: "recovered" }, T0);
399
+
400
+ expect(shouldSkipPrimary(OTHER_ROUTE, T0)).toBe(false);
401
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, T0)).toBe(true);
402
+ });
403
+
404
+ test("a success on one model leaves another model's outage remembered", () => {
405
+ recordFallbackServed(PRIMARY_ROUTE, T0);
406
+ recordPrimarySuccess(OTHER_ROUTE, T0 + 1_000);
407
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, T0 + 1_000)).toBe(true);
408
+ });
409
+
410
+ test("a success on any model closes an upstream-wide outage", () => {
411
+ // The upstream answered, which is what an upstream-wide trip was claiming
412
+ // it would not do.
413
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
414
+ recordPrimarySuccess(OTHER_ROUTE, T0 + 1_000);
415
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, T0 + 1_000)).toBe(false);
416
+ });
417
+
418
+ test("a probe needs every outage covering the route to have cooled down", () => {
419
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
420
+ // The model's own outage is remembered later, so its cooldown outlasts the
421
+ // upstream's.
422
+ recordFallbackServed(PRIMARY_ROUTE, T0 + MAX_COOLDOWN_MS);
423
+ const upstreamReady = T0 + observedCooldownMs(UPSTREAM_ROUTE, T0);
424
+
425
+ expect(tryAcquireRecoveryProbe(PRIMARY_ROUTE, upstreamReady)).toBe(false);
426
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, upstreamReady)).toBe(true);
427
+ });
428
+
429
+ test("a probe failing on one model narrows an upstream outage to that model", () => {
430
+ // The upstream answered the probe, so it is no longer the thing that is
431
+ // down; what it answered with is about a single model.
432
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
433
+ const ready = T0 + observedCooldownMs(UPSTREAM_ROUTE, T0);
434
+ expect(tryAcquireRecoveryProbe(PRIMARY_ROUTE, ready)).toBe(true);
435
+
436
+ releaseRecoveryProbe(
437
+ PRIMARY_ROUTE,
438
+ { verdict: "failing", failedRoute: PRIMARY_ROUTE },
439
+ ready,
440
+ );
441
+
442
+ // Every healthy model on the upstream is servable again.
443
+ expect(shouldSkipPrimary(OTHER_ROUTE, ready)).toBe(false);
444
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, ready)).toBe(false);
445
+ // The model the probe indicted is still skipped, on the escalated cooldown
446
+ // the failed probe earned rather than a fresh base wait.
447
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, ready)).toBe(true);
448
+ expectWithinJitterBand(
449
+ observedCooldownMs(PRIMARY_ROUTE, ready),
450
+ BASE_COOLDOWN_MS * 2,
451
+ );
452
+
453
+ // And it is still clearable: nothing is stranded by the narrowing.
454
+ const modelReady = ready + observedCooldownMs(PRIMARY_ROUTE, ready);
455
+ expect(tryAcquireRecoveryProbe(PRIMARY_ROUTE, modelReady)).toBe(true);
456
+ releaseRecoveryProbe(PRIMARY_ROUTE, { verdict: "recovered" }, modelReady);
457
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, modelReady)).toBe(false);
458
+ });
459
+
460
+ test("a probe failing with an outage widens a model's outage to the upstream", () => {
461
+ recordFallbackServed(PRIMARY_ROUTE, T0);
462
+ const ready = T0 + observedCooldownMs(PRIMARY_ROUTE, T0);
463
+ expect(tryAcquireRecoveryProbe(PRIMARY_ROUTE, ready)).toBe(true);
464
+
465
+ releaseRecoveryProbe(
466
+ PRIMARY_ROUTE,
467
+ { verdict: "failing", failedRoute: UPSTREAM_ROUTE },
468
+ ready,
469
+ );
470
+
471
+ // The fresh evidence is upstream-wide, so it governs every model on it.
472
+ expect(shouldSkipPrimary(OTHER_ROUTE, ready)).toBe(true);
473
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, ready)).toBe(true);
474
+ expectWithinJitterBand(
475
+ observedCooldownMs(UPSTREAM_ROUTE, ready),
476
+ BASE_COOLDOWN_MS * 2,
477
+ );
478
+ });
479
+
480
+ test("a probe failure the breaker cannot name closes what it tested", () => {
481
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
482
+ const ready = T0 + observedCooldownMs(UPSTREAM_ROUTE, T0);
483
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(true);
484
+
485
+ releaseRecoveryProbe(
486
+ UPSTREAM_ROUTE,
487
+ { verdict: "failing", failedRoute: null },
488
+ ready,
489
+ );
490
+
491
+ // Better to rediscover the outage on the next request than to keep an
492
+ // entry open that names something no future probe can clear.
493
+ expect(shouldSkipPrimary(PRIMARY_ROUTE, ready)).toBe(false);
494
+ });
495
+
496
+ test("an abandoned probe hands the claim back and changes nothing else", () => {
497
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
498
+ const cooldown = observedCooldownMs(UPSTREAM_ROUTE, T0);
499
+ const ready = T0 + cooldown;
500
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(true);
501
+
502
+ releaseRecoveryProbe(UPSTREAM_ROUTE, { verdict: "abandoned" }, ready);
503
+
504
+ // The entry survives with the same deadline: a cancelled probe neither
505
+ // recovers the route nor restarts its wait.
506
+ expect(observedCooldownMs(UPSTREAM_ROUTE, T0)).toBe(cooldown);
507
+ // Concurrent traffic during the cancelled probe was skipping the primary;
508
+ // now the claim is free, so the next request can probe instead.
509
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(true);
510
+ releaseRecoveryProbe(UPSTREAM_ROUTE, { verdict: "recovered" }, ready);
511
+ expect(shouldSkipPrimary(UPSTREAM_ROUTE, ready)).toBe(false);
512
+ });
513
+
514
+ test("an abandoned probe does not count toward the escalation", () => {
515
+ recordFallbackServed(UPSTREAM_ROUTE, T0);
516
+ const ready = T0 + observedCooldownMs(UPSTREAM_ROUTE, T0);
517
+
518
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(true);
519
+ releaseRecoveryProbe(UPSTREAM_ROUTE, { verdict: "abandoned" }, ready);
520
+ expect(tryAcquireRecoveryProbe(UPSTREAM_ROUTE, ready)).toBe(true);
521
+ releaseRecoveryProbe(
522
+ UPSTREAM_ROUTE,
523
+ { verdict: "failing", failedRoute: UPSTREAM_ROUTE },
524
+ ready,
525
+ );
526
+
527
+ // The first real failure earns the first doubling. Counting the
528
+ // cancellation would have skipped a rung and made the route wait twice as
529
+ // long for no evidence.
530
+ expectWithinJitterBand(
531
+ observedCooldownMs(UPSTREAM_ROUTE, ready),
532
+ BASE_COOLDOWN_MS * 2,
533
+ );
534
+ });
535
+ });
536
+
537
+ // ── What RetryProvider does with it ─────────────────────────────────────────
538
+
539
+ describe("RetryProvider under an open breaker", () => {
540
+ test("a tripped route goes straight to the backup without calling the primary", async () => {
541
+ const primary = primaryProvider(
542
+ () => new ProviderError("Service Unavailable", UPSTREAM, 503),
543
+ );
544
+ const backup = backupProvider();
545
+ const route = makeRoute(backup.provider);
546
+ const wrapped = new RetryProvider(primary.provider, {
547
+ resolveFallbackRoute: route.resolveFallbackRoute,
548
+ });
549
+ recordFallbackServed(UPSTREAM_ROUTE);
550
+
551
+ const startedAt = Date.now();
552
+ const result = await wrapped.sendMessage(MESSAGES, {
553
+ config: { callSite: "mainAgent" },
554
+ });
555
+
556
+ expect(result.model).toBe("backup-model");
557
+ expect(primary.calls()).toBe(0);
558
+ expect(backup.calls()).toBe(1);
559
+ // Skipping the primary also skips its retry budget, so no backoff delay is
560
+ // spent on a route already known to be down.
561
+ expect(Date.now() - startedAt).toBeLessThan(1_000);
562
+ });
563
+
564
+ test("an elapsed cooldown admits one probe, and its success restores the primary", async () => {
565
+ const primary = primaryProvider();
566
+ const backup = backupProvider();
567
+ const route = makeRoute(backup.provider);
568
+ const wrapped = new RetryProvider(primary.provider, {
569
+ resolveFallbackRoute: route.resolveFallbackRoute,
570
+ });
571
+ // Tripped long enough ago that the longest possible cooldown has expired.
572
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
573
+
574
+ const result = await wrapped.sendMessage(MESSAGES, {
575
+ config: { callSite: "mainAgent" },
576
+ });
577
+
578
+ expect(result.model).toBe("primary-model");
579
+ expect(primary.calls()).toBe(1);
580
+ expect(backup.calls()).toBe(0);
581
+ // The breaker is closed, so the next request takes the primary directly.
582
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(false);
583
+
584
+ const second = await wrapped.sendMessage(MESSAGES, {
585
+ config: { callSite: "mainAgent" },
586
+ });
587
+ expect(second.model).toBe("primary-model");
588
+ expect(primary.calls()).toBe(2);
589
+ });
590
+
591
+ test("a failed probe re-trips the breaker and the backup serves that request", async () => {
592
+ const primary = primaryProvider(
593
+ () => new ProviderError("Service Unavailable", UPSTREAM, 503),
594
+ );
595
+ const backup = backupProvider();
596
+ const route = makeRoute(backup.provider);
597
+ const wrapped = new RetryProvider(primary.provider, {
598
+ resolveFallbackRoute: route.resolveFallbackRoute,
599
+ });
600
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
601
+
602
+ const result = await wrapped.sendMessage(MESSAGES, {
603
+ config: { callSite: "mainAgent" },
604
+ });
605
+
606
+ // One probe attempt, no retry loop behind it, then the backup.
607
+ expect(primary.calls()).toBe(1);
608
+ expect(result.model).toBe("backup-model");
609
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(true);
610
+ });
611
+
612
+ test("a probe refreshes an expired managed credential before judging the route down", async () => {
613
+ // The credential expired during the outage. Without the refresh the probe
614
+ // reads its own 401 as the outage continuing, re-trips, and the route can
615
+ // never come back.
616
+ const primary = primaryProvider(
617
+ () =>
618
+ new ProviderError("Unauthorized", UPSTREAM, 401, {
619
+ reason: "invalid_credentials",
620
+ }),
621
+ );
622
+ const refreshed = primaryProvider();
623
+ const backup = backupProvider();
624
+ const route = makeRoute(backup.provider);
625
+ let refreshCalls = 0;
626
+ const wrapped = new RetryProvider(primary.provider, {
627
+ credentialSource: "vellum-managed",
628
+ refreshCredentialProvider: async () => {
629
+ refreshCalls += 1;
630
+ return refreshed.provider;
631
+ },
632
+ resolveFallbackRoute: route.resolveFallbackRoute,
633
+ });
634
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
635
+
636
+ const result = await wrapped.sendMessage(MESSAGES, {
637
+ config: { callSite: "mainAgent" },
638
+ });
639
+
640
+ expect(refreshCalls).toBe(1);
641
+ expect(primary.calls()).toBe(1);
642
+ // Still one probe: the refreshed adapter serves the same single attempt.
643
+ expect(refreshed.calls()).toBe(1);
644
+ expect(backup.calls()).toBe(0);
645
+ expect(result.model).toBe("primary-model");
646
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(false);
647
+ });
648
+
649
+ test("a probe rejected by the primary itself closes the breaker and surfaces the error", async () => {
650
+ // A plain 400 is the route answering the request, which is all the probe
651
+ // asked: the upstream is up, so the breaker closes and the caller sees the
652
+ // real error instead of a backup answer to a malformed request.
653
+ const requestError = new ProviderError("invalid request", UPSTREAM, 400);
654
+ const primary = primaryProvider(() => requestError);
655
+ const backup = backupProvider();
656
+ const route = makeRoute(backup.provider);
657
+ const wrapped = new RetryProvider(primary.provider, {
658
+ resolveFallbackRoute: route.resolveFallbackRoute,
659
+ });
660
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
661
+
662
+ const thrown = await captureError(
663
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
664
+ );
665
+
666
+ expect(thrown).toBe(requestError);
667
+ expect(primary.calls()).toBe(1);
668
+ expect(backup.calls()).toBe(0);
669
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(false);
670
+ });
671
+
672
+ test("a cancelled probe leaves the breaker tripped instead of reporting recovery", async () => {
673
+ const cancelled = new ProviderError(
674
+ "Request was aborted.",
675
+ UPSTREAM,
676
+ undefined,
677
+ { abortReason: "user_cancelled" },
678
+ );
679
+ const primary = primaryProvider(
680
+ () => cancelled,
681
+ () => new ProviderError("Service Unavailable", UPSTREAM, 503),
682
+ );
683
+ const backup = backupProvider();
684
+ const route = makeRoute(backup.provider);
685
+ const wrapped = new RetryProvider(primary.provider, {
686
+ resolveFallbackRoute: route.resolveFallbackRoute,
687
+ });
688
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
689
+
690
+ const thrown = await captureError(
691
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
692
+ );
693
+
694
+ // The cancellation surfaces as itself and nothing is re-routed: a request
695
+ // the caller stopped must stay stopped.
696
+ expect(thrown).toBe(cancelled);
697
+ expect(primary.calls()).toBe(1);
698
+ expect(backup.calls()).toBe(0);
699
+
700
+ // The entry survived the cancellation, so the next request probes once and
701
+ // escalates. Had the cancellation been read as recovery, this request would
702
+ // have paid the primary's whole retry budget instead.
703
+ const next = await wrapped.sendMessage(MESSAGES, {
704
+ config: { callSite: "mainAgent" },
705
+ });
706
+ expect(next.model).toBe("backup-model");
707
+ expect(primary.calls()).toBe(2);
708
+ expect(backup.calls()).toBe(1);
709
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(true);
710
+ });
711
+
712
+ test("a route without resolveFallbackRoute never consults the breaker", async () => {
713
+ // BYOK and every other route with no escalation hook keep today's
714
+ // behavior: a remembered outage on the same upstream name is irrelevant to
715
+ // them, and their traffic must not close it either.
716
+ const primary = primaryProvider();
717
+ const wrapped = new RetryProvider(primary.provider);
718
+ recordFallbackServed(UPSTREAM_ROUTE);
719
+
720
+ const result = await wrapped.sendMessage(MESSAGES, {
721
+ config: { callSite: "mainAgent" },
722
+ });
723
+
724
+ expect(result.model).toBe("primary-model");
725
+ expect(primary.calls()).toBe(1);
726
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(true);
727
+ });
728
+
729
+ test("a request pinned to an explicit model ignores an open breaker and cannot close it", async () => {
730
+ // A pinned request can never re-route (the pin outranks any backup
731
+ // profile), so skipping the primary would only lose it the only route it
732
+ // has. By the same token its success says nothing about the traffic the
733
+ // breaker was opened for, so it must not clear the trip.
734
+ const primary = primaryProvider();
735
+ const backup = backupProvider();
736
+ const route = makeRoute(backup.provider);
737
+ const wrapped = new RetryProvider(primary.provider, {
738
+ resolveFallbackRoute: route.resolveFallbackRoute,
739
+ });
740
+ recordFallbackServed(UPSTREAM_ROUTE);
741
+
742
+ const result = await wrapped.sendMessage(MESSAGES, {
743
+ config: { callSite: "mainAgent", model: "pinned-model" },
744
+ });
745
+
746
+ expect(result.model).toBe("pinned-model");
747
+ expect(primary.calls()).toBe(1);
748
+ expect(backup.calls()).toBe(0);
749
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(true);
750
+ });
751
+
752
+ test("a fallback serve trips the breaker for the next request", async () => {
753
+ const primary = primaryProvider(
754
+ () => new ProviderError("model not found", UPSTREAM, 404),
755
+ () => new ProviderError("model not found", UPSTREAM, 404),
756
+ );
757
+ const backup = backupProvider();
758
+ const route = makeRoute(backup.provider);
759
+ const wrapped = new RetryProvider(primary.provider, {
760
+ resolveFallbackRoute: route.resolveFallbackRoute,
761
+ });
762
+
763
+ await wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } });
764
+ expect(primary.calls()).toBe(1);
765
+
766
+ await wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } });
767
+ expect(primary.calls()).toBe(1);
768
+ expect(backup.calls()).toBe(2);
769
+ });
770
+
771
+ test("a late fallback completion cannot reopen a recovered primary", async () => {
772
+ const primary = primaryProvider(
773
+ () => new ProviderError("model not found", UPSTREAM, 404),
774
+ );
775
+ let resolveBackupStarted!: () => void;
776
+ const backupStarted = new Promise<void>((resolve) => {
777
+ resolveBackupStarted = resolve;
778
+ });
779
+ let resolveBackupResponse!: (response: ProviderResponse) => void;
780
+ const backupResponse = new Promise<ProviderResponse>((resolve) => {
781
+ resolveBackupResponse = resolve;
782
+ });
783
+ const backup: Provider = {
784
+ name: "anthropic",
785
+ sendMessage: async () => {
786
+ resolveBackupStarted();
787
+ return backupResponse;
788
+ },
789
+ };
790
+ const route = makeRoute(backup);
791
+ const wrapped = new RetryProvider(primary.provider, {
792
+ resolveFallbackRoute: route.resolveFallbackRoute,
793
+ });
794
+
795
+ const first = wrapped.sendMessage(MESSAGES, {
796
+ config: { callSite: "mainAgent" },
797
+ });
798
+ await backupStarted;
799
+
800
+ // A concurrent recovery probe can prove the primary is healthy while the
801
+ // earlier request is still waiting for its backup response.
802
+ recordPrimarySuccess(PRIMARY_ROUTE);
803
+ resolveBackupResponse(okResponse("backup-model"));
804
+
805
+ await first;
806
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(false);
807
+ });
808
+
809
+ test("a retired model diverts only itself, not a healthy model on the same upstream", async () => {
810
+ // The retired-model incident this feature exists for must not take the
811
+ // whole upstream down with it.
812
+ const primary = primaryProvider(
813
+ () => new ProviderError("model not found", UPSTREAM, 404),
814
+ );
815
+ const backup = backupProvider();
816
+ const route = makeRoute(backup.provider);
817
+ const wrapped = new RetryProvider(primary.provider, {
818
+ resolveFallbackRoute: route.resolveFallbackRoute,
819
+ });
820
+
821
+ await wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } });
822
+ expect(backup.calls()).toBe(1);
823
+
824
+ // A profile pinned to another model on the same upstream still goes to its
825
+ // own primary.
826
+ const healthy = await wrapped.sendMessage(MESSAGES, {
827
+ config: { ...OTHER_MODEL_CALL },
828
+ });
829
+ expect(healthy.model).toBe("other-model");
830
+ expect(primary.seenModels()).toEqual(["primary-model", "other-model"]);
831
+ expect(backup.calls()).toBe(1);
832
+
833
+ // The retired model itself is still remembered.
834
+ await wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } });
835
+ expect(backup.calls()).toBe(2);
836
+ expect(primary.calls()).toBe(2);
837
+ });
838
+
839
+ test("a probe that comes back with a retired model frees the rest of the upstream", async () => {
840
+ const primary = primaryProvider(
841
+ () => new ProviderError("model not found", UPSTREAM, 404),
842
+ );
843
+ const backup = backupProvider();
844
+ const route = makeRoute(backup.provider);
845
+ const wrapped = new RetryProvider(primary.provider, {
846
+ resolveFallbackRoute: route.resolveFallbackRoute,
847
+ });
848
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
849
+
850
+ // The probe reaches the upstream and comes back with a retired model, so
851
+ // the outage that opened the entry is over and the retirement replaces it.
852
+ const probed = await wrapped.sendMessage(MESSAGES, {
853
+ config: { callSite: "mainAgent" },
854
+ });
855
+ expect(primary.calls()).toBe(1);
856
+ expect(probed.model).toBe("backup-model");
857
+
858
+ // A healthy model on the same upstream is servable again...
859
+ const healthy = await wrapped.sendMessage(MESSAGES, {
860
+ config: { ...OTHER_MODEL_CALL },
861
+ });
862
+ expect(healthy.model).toBe("other-model");
863
+
864
+ // ...while the retired model keeps going to the backup.
865
+ await wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } });
866
+ expect(backup.calls()).toBe(2);
867
+ expect(primary.seenModels()).toEqual(["primary-model", "other-model"]);
868
+ });
869
+
870
+ test("an upstream outage diverts every model on it", async () => {
871
+ // A managed credential rejection indicts the upstream, not one model, so
872
+ // the healthy model is skipped too.
873
+ const primary = primaryProvider(
874
+ () =>
875
+ new ProviderError("Unauthorized", UPSTREAM, 401, {
876
+ reason: "invalid_credentials",
877
+ }),
878
+ );
879
+ const backup = backupProvider();
880
+ const route = makeRoute(backup.provider);
881
+ const wrapped = new RetryProvider(primary.provider, {
882
+ credentialSource: "vellum-managed",
883
+ refreshCredentialProvider: async () => null,
884
+ resolveFallbackRoute: route.resolveFallbackRoute,
885
+ });
886
+
887
+ await wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } });
888
+ expect(backup.calls()).toBe(1);
889
+
890
+ await wrapped.sendMessage(MESSAGES, { config: { ...OTHER_MODEL_CALL } });
891
+ expect(backup.calls()).toBe(2);
892
+ // Only the first request ever reached the upstream.
893
+ expect(primary.calls()).toBe(1);
894
+ });
895
+ });
896
+
897
+ // ── The retry budget a breaker-driven send gets on the backup ───────────────
898
+
899
+ describe("the backup's retry budget", () => {
900
+ test("a breaker-open request retries a transient failure on the backup instead of failing the turn", async () => {
901
+ // The breaker trips after a single successful fallback serve, so for the
902
+ // whole cooldown every request skips the primary. Those requests spend no
903
+ // retry budget on the way, so they get one on the backup: on a single
904
+ // attempt a lone 429 or mid-stream cut fails the turn.
905
+ const primary = primaryProvider();
906
+ const backup = backupProvider(() => transient());
907
+ const route = makeRoute(backup.provider);
908
+ const wrapped = new RetryProvider(primary.provider, {
909
+ resolveFallbackRoute: route.resolveFallbackRoute,
910
+ });
911
+ recordFallbackServed(UPSTREAM_ROUTE);
912
+
913
+ const result = await wrapped.sendMessage(MESSAGES, {
914
+ config: { callSite: "mainAgent" },
915
+ });
916
+
917
+ expect(result.model).toBe("backup-model");
918
+ expect(backup.calls()).toBe(2);
919
+ // The primary is still skipped entirely: the retry budget moved to the
920
+ // backup, it was not restored to a route known to be down.
921
+ expect(primary.calls()).toBe(0);
922
+ });
923
+
924
+ test("a retried backup never escalates to a second fallback", async () => {
925
+ // One hop is structural, not a budget: the route callback is consulted
926
+ // exactly once however many attempts the backup takes.
927
+ const primary = primaryProvider();
928
+ const backup = backupProvider(
929
+ () => transient(),
930
+ () => transient(),
931
+ () => transient(),
932
+ () => transient(),
933
+ );
934
+ const route = makeRoute(backup.provider);
935
+ const wrapped = new RetryProvider(primary.provider, {
936
+ resolveFallbackRoute: route.resolveFallbackRoute,
937
+ });
938
+ recordFallbackServed(UPSTREAM_ROUTE);
939
+
940
+ const thrown = await captureError(
941
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
942
+ );
943
+
944
+ expect(backup.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
945
+ expect(route.calls()).toBe(1);
946
+ expect(primary.calls()).toBe(0);
947
+ // Tagged like the primary loop's own exhaustion, so Sentry capture reads a
948
+ // flapping backup as noise rather than an engineering signal.
949
+ expect((thrown as { retriesExhausted?: boolean }).retriesExhausted).toBe(
950
+ true,
951
+ );
952
+ });
953
+
954
+ test("a failed probe hands its request a retry budget on the backup too", async () => {
955
+ // A probe is one attempt on the primary, not a retry loop, so its request
956
+ // has spent no budget either by the time it reaches the backup.
957
+ const primary = primaryProvider(
958
+ () => new ProviderError("Service Unavailable", UPSTREAM, 503),
959
+ );
960
+ const backup = backupProvider(() => transient());
961
+ const route = makeRoute(backup.provider);
962
+ const wrapped = new RetryProvider(primary.provider, {
963
+ resolveFallbackRoute: route.resolveFallbackRoute,
964
+ });
965
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
966
+
967
+ const result = await wrapped.sendMessage(MESSAGES, {
968
+ config: { callSite: "mainAgent" },
969
+ });
970
+
971
+ expect(primary.calls()).toBe(1);
972
+ expect(backup.calls()).toBe(2);
973
+ expect(result.model).toBe("backup-model");
974
+ });
975
+
976
+ test("an escalation after the primary burned its budget still gets one attempt", async () => {
977
+ // The asymmetry worth keeping: this request already waited out a full
978
+ // retry loop, so the backup answers once or the turn fails.
979
+ const primary = primaryProvider(
980
+ () =>
981
+ new ProviderError("Service Unavailable", UPSTREAM, 503, {
982
+ retryAfterMs: 0,
983
+ }),
984
+ () =>
985
+ new ProviderError("Service Unavailable", UPSTREAM, 503, {
986
+ retryAfterMs: 0,
987
+ }),
988
+ () =>
989
+ new ProviderError("Service Unavailable", UPSTREAM, 503, {
990
+ retryAfterMs: 0,
991
+ }),
992
+ () =>
993
+ new ProviderError("Service Unavailable", UPSTREAM, 503, {
994
+ retryAfterMs: 0,
995
+ }),
996
+ );
997
+ const backup = backupProvider(
998
+ () => transient(),
999
+ () => transient(),
1000
+ );
1001
+ const route = makeRoute(backup.provider);
1002
+ const wrapped = new RetryProvider(primary.provider, {
1003
+ resolveFallbackRoute: route.resolveFallbackRoute,
1004
+ });
1005
+
1006
+ await captureError(
1007
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
1008
+ );
1009
+
1010
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
1011
+ expect(backup.calls()).toBe(1);
1012
+ });
1013
+ });
1014
+
1015
+ // ── What a failed probe is allowed to conclude ──────────────────────────────
1016
+
1017
+ describe("recovery probe verdicts", () => {
1018
+ test("a probe that fails with a corrupted stream closes the breaker and still completes the turn", async () => {
1019
+ // Every stream-corruption pattern requires an absent HTTP status, which
1020
+ // means the upstream accepted the request, returned 200, and streamed. The
1021
+ // failure is in the bytes it produced, not in its ability to serve, so it
1022
+ // is evidence the primary is healthy. The verdict must not cost the request
1023
+ // that established it: this is exactly the failure the main loop repairs by
1024
+ // resending, so the probe hands the request back to that loop.
1025
+ const primary = primaryProvider(() => corruptedStream());
1026
+ const backup = backupProvider();
1027
+ const route = makeRoute(backup.provider);
1028
+ const wrapped = new RetryProvider(primary.provider, {
1029
+ resolveFallbackRoute: route.resolveFallbackRoute,
1030
+ });
1031
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
1032
+
1033
+ const result = await wrapped.sendMessage(MESSAGES, {
1034
+ config: { callSite: "mainAgent" },
1035
+ });
1036
+
1037
+ // The turn completed on the route the probe just cleared.
1038
+ expect(result.model).toBe("primary-model");
1039
+ expect(primary.calls()).toBe(2);
1040
+ expect(backup.calls()).toBe(0);
1041
+ // The breaker closed rather than re-tripping with a doubled cooldown, so
1042
+ // the next request takes the primary and gets its full retry budget.
1043
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(false);
1044
+ });
1045
+
1046
+ test("a corrupted-stream probe leaves the request the same total budget as one that never probes", async () => {
1047
+ // The probe's send counts as this request's first attempt, so continuing
1048
+ // into the retry loop must not hand it a wider budget than a request that
1049
+ // never probes. Four sends total, then the backup as the loop's ordinary
1050
+ // last resort, so the turn finishes even when the primary never stops
1051
+ // corrupting.
1052
+ const primary = primaryProvider(
1053
+ () => corruptedStream(),
1054
+ () => corruptedStream(),
1055
+ () => corruptedStream(),
1056
+ () => corruptedStream(),
1057
+ () => corruptedStream(),
1058
+ );
1059
+ const backup = backupProvider();
1060
+ const route = makeRoute(backup.provider);
1061
+ const wrapped = new RetryProvider(primary.provider, {
1062
+ resolveFallbackRoute: route.resolveFallbackRoute,
1063
+ });
1064
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
1065
+
1066
+ const result = await wrapped.sendMessage(MESSAGES, {
1067
+ config: { callSite: "mainAgent" },
1068
+ });
1069
+
1070
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
1071
+ expect(result.model).toBe("backup-model");
1072
+ // A backup that had to serve is proof the route is down after all, so the
1073
+ // breaker is remembered again on the way out.
1074
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(true);
1075
+ });
1076
+
1077
+ test("a probe that spent a corrective resend keeps the same total budget", async () => {
1078
+ // The repaired path: the probe sends twice (the original plus the
1079
+ // corrective resend) before the stream corruption hands the request to the
1080
+ // retry loop. The seed has to count both, or this request makes one more
1081
+ // primary send than a request that never probes.
1082
+ const primary = primaryProvider(
1083
+ () =>
1084
+ new ProviderError(
1085
+ `Anthropic: ${UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE}`,
1086
+ UPSTREAM,
1087
+ undefined,
1088
+ ),
1089
+ () => corruptedStream(),
1090
+ () => corruptedStream(),
1091
+ () => corruptedStream(),
1092
+ () => corruptedStream(),
1093
+ () => corruptedStream(),
1094
+ );
1095
+ const backup = backupProvider();
1096
+ const route = makeRoute(backup.provider);
1097
+ const wrapped = new RetryProvider(primary.provider, {
1098
+ resolveFallbackRoute: route.resolveFallbackRoute,
1099
+ });
1100
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
1101
+
1102
+ const result = await wrapped.sendMessage(MESSAGES, {
1103
+ config: { callSite: "mainAgent" },
1104
+ });
1105
+
1106
+ // Two probe sends plus two loop sends, not two plus three.
1107
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
1108
+ expect(result.model).toBe("backup-model");
1109
+ });
1110
+
1111
+ test("a probe repairs malformed tool arguments instead of reporting an outage", async () => {
1112
+ // The main retry loop repairs this deterministically with a corrective
1113
+ // note; the probe gets the same one-shot repair rather than reading a
1114
+ // request-conditioned failure as the route still being down.
1115
+ const primary = primaryProvider(
1116
+ () =>
1117
+ new ProviderError(
1118
+ `Anthropic: ${UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE}`,
1119
+ UPSTREAM,
1120
+ undefined,
1121
+ ),
1122
+ );
1123
+ const backup = backupProvider();
1124
+ const route = makeRoute(backup.provider);
1125
+ const wrapped = new RetryProvider(primary.provider, {
1126
+ resolveFallbackRoute: route.resolveFallbackRoute,
1127
+ });
1128
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
1129
+
1130
+ const result = await wrapped.sendMessage(MESSAGES, {
1131
+ config: { callSite: "mainAgent" },
1132
+ });
1133
+
1134
+ expect(result.model).toBe("primary-model");
1135
+ expect(primary.calls()).toBe(2);
1136
+ expect(backup.calls()).toBe(0);
1137
+ // The resend carried the corrective note, which is the whole point of it.
1138
+ const resent = primary.seenMessages()[1];
1139
+ const tail = resent[resent.length - 1].content;
1140
+ expect(JSON.stringify(tail)).toContain("[assistant runtime]");
1141
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(false);
1142
+ });
1143
+
1144
+ test("a probe rate-limited by the primary still reads the outage as continuing", async () => {
1145
+ // Deliberately not treated like a stream corruption. A 429 is the route
1146
+ // refusing to do the work: no resend repairs it, only waiting does, and
1147
+ // that is exactly what the cooldown provides. Reading it as recovery would
1148
+ // send every request back to a primary that rejects all of them.
1149
+ const primary = primaryProvider(
1150
+ () => new ProviderError("Too Many Requests", UPSTREAM, 429),
1151
+ );
1152
+ const backup = backupProvider();
1153
+ const route = makeRoute(backup.provider);
1154
+ const wrapped = new RetryProvider(primary.provider, {
1155
+ resolveFallbackRoute: route.resolveFallbackRoute,
1156
+ });
1157
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
1158
+
1159
+ const result = await wrapped.sendMessage(MESSAGES, {
1160
+ config: { callSite: "mainAgent" },
1161
+ });
1162
+
1163
+ // Exactly one attempt on a route just judged down: the request goes
1164
+ // straight to the backup rather than spending a retry budget on it.
1165
+ expect(primary.calls()).toBe(1);
1166
+ expect(backup.calls()).toBe(1);
1167
+ expect(result.model).toBe("backup-model");
1168
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(true);
1169
+ });
1170
+
1171
+ test("a probe that fails with a 5xx still extends the outage", async () => {
1172
+ // The control for the two cases above: a genuine server error is what the
1173
+ // breaker exists to remember, and it must keep re-tripping.
1174
+ const primary = primaryProvider(
1175
+ () => new ProviderError("Service Unavailable", UPSTREAM, 503),
1176
+ );
1177
+ const backup = backupProvider();
1178
+ const route = makeRoute(backup.provider);
1179
+ const wrapped = new RetryProvider(primary.provider, {
1180
+ resolveFallbackRoute: route.resolveFallbackRoute,
1181
+ });
1182
+ const trippedAt = Date.now() - MAX_COOLDOWN_MS - 1;
1183
+ recordFallbackServed(UPSTREAM_ROUTE, trippedAt);
1184
+
1185
+ const result = await wrapped.sendMessage(MESSAGES, {
1186
+ config: { callSite: "mainAgent" },
1187
+ });
1188
+
1189
+ expect(result.model).toBe("backup-model");
1190
+ // No extra attempts on the primary either: an outage verdict fails fast
1191
+ // into the backup instead of continuing into the retry loop.
1192
+ expect(primary.calls()).toBe(1);
1193
+ expect(shouldSkipPrimary(PRIMARY_ROUTE)).toBe(true);
1194
+ // A failed probe doubles the wait, so the re-trip lands above the base.
1195
+ expectWithinJitterBand(
1196
+ observedCooldownMs(UPSTREAM_ROUTE, Date.now()),
1197
+ BASE_COOLDOWN_MS * 2,
1198
+ );
1199
+ });
1200
+ });
1201
+
1202
+ // ── What counts as having spent the retry budget ────────────────────────────
1203
+
1204
+ describe("retry exhaustion accounting", () => {
1205
+ /**
1206
+ * The primary of a probe that uses both of its repairs before failing for
1207
+ * real. The first send is rejected as an expired managed credential, the
1208
+ * refreshed adapter answers the resend with malformed tool arguments, and the
1209
+ * corrective resend after that comes back as a stream corruption. Three sends
1210
+ * are spent by the time the request reaches the ordinary loop, leaving it
1211
+ * exactly one, which it spends on `lastError`.
1212
+ */
1213
+ function repairedProbeAdapters(lastError: () => unknown): {
1214
+ primary: ReturnType<typeof primaryProvider>;
1215
+ refreshed: ReturnType<typeof primaryProvider>;
1216
+ } {
1217
+ return {
1218
+ primary: primaryProvider(
1219
+ () =>
1220
+ new ProviderError("Unauthorized", UPSTREAM, 401, {
1221
+ reason: "invalid_credentials",
1222
+ }),
1223
+ ),
1224
+ refreshed: primaryProvider(
1225
+ () =>
1226
+ new ProviderError(
1227
+ `Anthropic: ${UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE}`,
1228
+ UPSTREAM,
1229
+ undefined,
1230
+ ),
1231
+ () => corruptedStream(),
1232
+ lastError,
1233
+ ),
1234
+ };
1235
+ }
1236
+
1237
+ test("a probe that spends the whole budget on repairs still reaches the backup", async () => {
1238
+ // Exhaustion decides escalation eligibility, not just Sentry suppression,
1239
+ // so a request whose budget was consumed inside the probe has to be seen as
1240
+ // exhausted. Otherwise the turn this feature exists to rescue fails while a
1241
+ // healthy backup sits unused.
1242
+ const { primary, refreshed } = repairedProbeAdapters(() =>
1243
+ corruptedStream(),
1244
+ );
1245
+ const backup = backupProvider();
1246
+ const route = makeRoute(backup.provider);
1247
+ const wrapped = new RetryProvider(primary.provider, {
1248
+ credentialSource: "vellum-managed",
1249
+ refreshCredentialProvider: async () => refreshed.provider,
1250
+ resolveFallbackRoute: route.resolveFallbackRoute,
1251
+ });
1252
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
1253
+
1254
+ const result = await wrapped.sendMessage(MESSAGES, {
1255
+ config: { callSite: "mainAgent" },
1256
+ });
1257
+
1258
+ expect(result.model).toBe("backup-model");
1259
+ expect(backup.calls()).toBe(1);
1260
+ // One send on the original adapter plus three on the refreshed one: the
1261
+ // budget, spent exactly once.
1262
+ expect(primary.calls() + refreshed.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
1263
+ expect(refreshed.calls()).toBe(3);
1264
+ });
1265
+
1266
+ test("a probe that spends the whole budget on repairs tags the error as exhausted", async () => {
1267
+ // The same sequence with no backup available, so the error surfaces and its
1268
+ // tag can be read. Untagged, this is a Sentry capture for a flap the retry
1269
+ // loop already did everything about.
1270
+ const { primary, refreshed } = repairedProbeAdapters(() =>
1271
+ corruptedStream(),
1272
+ );
1273
+ const wrapped = new RetryProvider(primary.provider, {
1274
+ credentialSource: "vellum-managed",
1275
+ refreshCredentialProvider: async () => refreshed.provider,
1276
+ resolveFallbackRoute: async () => null,
1277
+ });
1278
+ recordFallbackServed(UPSTREAM_ROUTE, Date.now() - MAX_COOLDOWN_MS - 1);
1279
+
1280
+ const thrown = await captureError(
1281
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
1282
+ );
1283
+
1284
+ expect((thrown as { retriesExhausted?: boolean }).retriesExhausted).toBe(
1285
+ true,
1286
+ );
1287
+ expect(primary.calls() + refreshed.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
1288
+ });
1289
+
1290
+ test("a request that spends its budget in the ordinary loop is exhausted", async () => {
1291
+ // The control for a request that never probes: unchanged behavior, tagged
1292
+ // and escalated after the full budget.
1293
+ const primary = primaryProvider(
1294
+ () => transient(),
1295
+ () => transient(),
1296
+ () => transient(),
1297
+ () => transient(),
1298
+ );
1299
+ const backup = backupProvider();
1300
+ const route = makeRoute(backup.provider);
1301
+ const wrapped = new RetryProvider(primary.provider, {
1302
+ resolveFallbackRoute: route.resolveFallbackRoute,
1303
+ });
1304
+
1305
+ const result = await wrapped.sendMessage(MESSAGES, {
1306
+ config: { callSite: "mainAgent" },
1307
+ });
1308
+
1309
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
1310
+ expect(result.model).toBe("backup-model");
1311
+ });
1312
+
1313
+ test("a request that fails on a non-retryable error is not exhausted", async () => {
1314
+ // The other control: one attempt, no budget spent, so nothing is tagged and
1315
+ // a plain request failure never counts as an outage.
1316
+ const primary = primaryProvider(
1317
+ () => new ProviderError("invalid request", UPSTREAM, 400),
1318
+ );
1319
+ const backup = backupProvider();
1320
+ const route = makeRoute(backup.provider);
1321
+ const wrapped = new RetryProvider(primary.provider, {
1322
+ resolveFallbackRoute: route.resolveFallbackRoute,
1323
+ });
1324
+
1325
+ const thrown = await captureError(
1326
+ wrapped.sendMessage(MESSAGES, { config: { callSite: "mainAgent" } }),
1327
+ );
1328
+
1329
+ expect(
1330
+ (thrown as { retriesExhausted?: boolean }).retriesExhausted,
1331
+ ).toBeUndefined();
1332
+ expect(primary.calls()).toBe(1);
1333
+ expect(backup.calls()).toBe(0);
1334
+ });
1335
+ });