@vellumai/assistant 0.12.4 → 0.12.5-dev.202609251017.e467a6c

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 (281) hide show
  1. package/docs/architecture/memory.md +9 -1
  2. package/docs/architecture/scheduling.md +13 -5
  3. package/docs/desktop-browser-cli.md +3 -2
  4. package/docs/guardian-request-flow.md +1 -1
  5. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  6. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  11. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +2 -4
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  13. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  14. package/openapi.yaml +152 -3
  15. package/package.json +1 -1
  16. package/src/__tests__/agent-loop.test.ts +76 -0
  17. package/src/__tests__/anthropic-error-formatting.test.ts +9 -0
  18. package/src/__tests__/background-shell-bash.test.ts +34 -0
  19. package/src/__tests__/background-shell-host-bash.test.ts +98 -1
  20. package/src/__tests__/background-tool-registry.test.ts +39 -0
  21. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  22. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  23. package/src/__tests__/client-os-metadata-persistence.test.ts +4 -20
  24. package/src/__tests__/conversation-agent-loop.test.ts +529 -1
  25. package/src/__tests__/conversation-error.test.ts +18 -0
  26. package/src/__tests__/conversation-pairing.test.ts +69 -0
  27. package/src/__tests__/conversation-queue.test.ts +257 -4
  28. package/src/__tests__/conversation-runtime-assembly.test.ts +27 -0
  29. package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
  30. package/src/__tests__/evict-conversations-for-reload.test.ts +21 -0
  31. package/src/__tests__/gemini-provider.test.ts +9 -0
  32. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  33. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  34. package/src/__tests__/introduction-card-resolver.test.ts +53 -8
  35. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  36. package/src/__tests__/non-member-access-request.test.ts +48 -0
  37. package/src/__tests__/notification-events-source-context-probe.test.ts +48 -1
  38. package/src/__tests__/notification-platform-adapter.test.ts +97 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +103 -3
  40. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  41. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  42. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  43. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -0
  44. package/src/__tests__/scheduler-result-notification.test.ts +521 -7
  45. package/src/__tests__/scheduler-wake.test.ts +1 -0
  46. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  47. package/src/__tests__/subagent-cron-run-attribution.test.ts +41 -2
  48. package/src/__tests__/subagent-manager-notify.test.ts +48 -0
  49. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
  50. package/src/__tests__/usage-attribution.test.ts +31 -0
  51. package/src/__tests__/wake-schedule-escalation.test.ts +1 -0
  52. package/src/agent/loop.ts +23 -0
  53. package/src/api/ask-question-tool.ts +50 -0
  54. package/src/api/events/generation-handoff.ts +4 -0
  55. package/src/api/events/message-complete.ts +4 -0
  56. package/src/api/events/tool-result.ts +11 -0
  57. package/src/api/index.ts +11 -0
  58. package/src/api/requests/guardian-actions.test.ts +45 -0
  59. package/src/api/requests/guardian-actions.ts +20 -0
  60. package/src/api/responses/conversation-message.ts +4 -0
  61. package/src/api/responses/home.ts +27 -0
  62. package/src/approvals/guardian-decision-primitive.ts +6 -4
  63. package/src/approvals/guardian-request-resolvers.ts +35 -30
  64. package/src/avatar/notification-avatar.test.ts +42 -34
  65. package/src/avatar/notification-avatar.ts +1 -0
  66. package/src/calls/__tests__/call-controller.test.ts +18 -0
  67. package/src/calls/__tests__/voice-control-protocol.test.ts +6 -1
  68. package/src/calls/__tests__/voice-leg-coordinator.test.ts +70 -0
  69. package/src/calls/__tests__/voice-session-bridge.test.ts +116 -4
  70. package/src/calls/__tests__/voice-triage-escalate.test.ts +77 -11
  71. package/src/calls/call-controller.ts +3 -1
  72. package/src/calls/voice-control-protocol.ts +12 -4
  73. package/src/calls/voice-escalation-judge.ts +2 -2
  74. package/src/calls/voice-leg-coordinator.ts +23 -5
  75. package/src/calls/voice-screen-annotation.ts +44 -0
  76. package/src/calls/voice-session-bridge.ts +63 -20
  77. package/src/calls/voice-triage-escalate.ts +82 -27
  78. package/src/cli/commands/__tests__/conversations-clear.test.ts +156 -0
  79. package/src/cli/commands/conversations.help.ts +7 -2
  80. package/src/cli/commands/conversations.ts +36 -30
  81. package/src/cli/commands/oauth/index.help.ts +8 -2
  82. package/src/cli/commands/oauth/request.test.ts +32 -0
  83. package/src/cli/commands/platform/index.help.ts +2 -1
  84. package/src/cli/commands/platform/index.ts +8 -2
  85. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +60 -0
  86. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  87. package/src/cli/lib/bundled-marketplace.json +35 -0
  88. package/src/cli/lib/bundled-plugin-packages.json +21 -0
  89. package/src/cli/lib/plugin-catalog-cache.ts +17 -2
  90. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  91. package/src/config/__tests__/default-profile-catalog.test.ts +28 -0
  92. package/src/config/__tests__/platform-identity.test.ts +59 -0
  93. package/src/config/__tests__/profile-order.test.ts +19 -0
  94. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  95. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  96. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  97. package/src/config/bundled-skills/screen-annotation/SKILL.md +41 -29
  98. package/src/config/bundled-skills/screen-annotation/TOOLS.json +5 -5
  99. package/src/config/call-site-defaults.ts +6 -0
  100. package/src/config/default-profile-catalog.ts +30 -1
  101. package/src/config/default-profile-names.ts +36 -2
  102. package/src/config/feature-flag-registry.json +9 -1
  103. package/src/config/platform-identity.ts +22 -5
  104. package/src/config/profile-order.ts +7 -1
  105. package/src/config/schemas/call-site-catalog.ts +7 -0
  106. package/src/config/schemas/llm.ts +21 -5
  107. package/src/config/sync-gated-profiles.ts +96 -44
  108. package/src/daemon/__tests__/abort-null-controller.test.ts +135 -1
  109. package/src/daemon/__tests__/auto-profile-router.test.ts +380 -0
  110. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +16 -0
  111. package/src/daemon/activity-trail.test.ts +206 -0
  112. package/src/daemon/activity-trail.ts +190 -0
  113. package/src/daemon/auto-profile-router.ts +431 -0
  114. package/src/daemon/computer-use-observation.test.ts +81 -0
  115. package/src/daemon/computer-use-observation.ts +23 -0
  116. package/src/daemon/conversation-agent-loop-handlers.ts +11 -0
  117. package/src/daemon/conversation-agent-loop.ts +157 -32
  118. package/src/daemon/conversation-error.ts +19 -0
  119. package/src/daemon/conversation-lifecycle.ts +70 -7
  120. package/src/daemon/conversation-media-retry.ts +1 -2
  121. package/src/daemon/conversation-messaging.ts +31 -2
  122. package/src/daemon/conversation-process.ts +107 -16
  123. package/src/daemon/conversation-queue-manager.ts +7 -0
  124. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  125. package/src/daemon/conversation-turn-finalize.ts +14 -0
  126. package/src/daemon/conversation.ts +25 -6
  127. package/src/daemon/event-loop-watchdog.test.ts +201 -2
  128. package/src/daemon/event-loop-watchdog.ts +182 -6
  129. package/src/daemon/host-cu-proxy.ts +34 -3
  130. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  131. package/src/daemon/pkb-reminder-builder.ts +1 -1
  132. package/src/daemon/wake-conversation-ops.ts +4 -2
  133. package/src/desktop/desktop-computer-use.test.ts +14 -4
  134. package/src/home/feed-types.ts +2 -0
  135. package/src/inbound/platform-callback-registration.ts +19 -6
  136. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  137. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  138. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +48 -1
  139. package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -2
  140. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  141. package/src/live-voice/continuation-judge.ts +1 -1
  142. package/src/live-voice/live-voice-session.ts +25 -19
  143. package/src/live-voice/protocol.ts +16 -5
  144. package/src/messaging/providers/discord/withdraw.ts +4 -5
  145. package/src/messaging/providers/gmail/client.ts +4 -0
  146. package/src/messaging/providers/slack/withdraw.ts +5 -5
  147. package/src/messaging/providers/telegram-bot/withdraw.ts +4 -5
  148. package/src/monitoring/__tests__/proc-fixtures.ts +28 -0
  149. package/src/monitoring/__tests__/proc-wait-state.test.ts +232 -0
  150. package/src/monitoring/__tests__/process-usage.test.ts +243 -0
  151. package/src/monitoring/proc-wait-state.ts +366 -0
  152. package/src/monitoring/process-memory.ts +4 -3
  153. package/src/monitoring/process-usage.ts +215 -0
  154. package/src/monitoring/resource-sample-types.ts +8 -0
  155. package/src/monitoring/resource-sampler.ts +17 -2
  156. package/src/monitoring/stall-capture.ts +12 -0
  157. package/src/notifications/README.md +88 -9
  158. package/src/notifications/__tests__/assistant-reply-producer.test.ts +390 -119
  159. package/src/notifications/__tests__/background-result-producer.test.ts +1272 -0
  160. package/src/notifications/__tests__/broadcaster.test.ts +292 -1
  161. package/src/notifications/__tests__/completion-policy.test.ts +243 -0
  162. package/src/notifications/__tests__/decision-engine.test.ts +51 -4
  163. package/src/notifications/__tests__/has-pending-background-work.test.ts +281 -0
  164. package/src/notifications/__tests__/home-feed-side-effect.test.ts +161 -3
  165. package/src/notifications/__tests__/resolve-visible-in-source.test.ts +110 -1
  166. package/src/notifications/__tests__/schedule-result-producer.test.ts +11 -4
  167. package/src/notifications/adapters/macos.ts +24 -12
  168. package/src/notifications/adapters/platform.ts +16 -5
  169. package/src/notifications/assistant-reply-producer.ts +96 -71
  170. package/src/notifications/background-result-producer.ts +369 -0
  171. package/src/notifications/broadcaster.ts +28 -8
  172. package/src/notifications/completion-policy.ts +115 -0
  173. package/src/notifications/completion-work.ts +46 -0
  174. package/src/notifications/conversation-pairing.ts +32 -13
  175. package/src/notifications/decision-engine.ts +18 -24
  176. package/src/notifications/emit-signal.ts +16 -1
  177. package/src/notifications/events-store.ts +4 -0
  178. package/src/notifications/has-pending-background-work.ts +91 -0
  179. package/src/notifications/home-feed-mirror.ts +44 -0
  180. package/src/notifications/home-feed-side-effect.ts +54 -28
  181. package/src/notifications/resolve-visible-in-source.ts +56 -2
  182. package/src/notifications/result-output.ts +154 -0
  183. package/src/notifications/schedule-result-producer.ts +6 -187
  184. package/src/notifications/types.ts +2 -0
  185. package/src/oauth/AGENTS.md +9 -1
  186. package/src/oauth/byo-connection.test.ts +28 -0
  187. package/src/persistence/__tests__/recent-conversation-messages.test.ts +41 -0
  188. package/src/persistence/conversation-crud.ts +26 -0
  189. package/src/persistence/conversation-types.ts +15 -37
  190. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +85 -14
  191. package/src/persistence/jobs-store.ts +229 -0
  192. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  193. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  194. package/src/persistence/steps.ts +12 -0
  195. package/src/platform/sync-avatar.test.ts +29 -0
  196. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  197. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  198. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  199. package/src/plugins/defaults/memory/__tests__/turn-retrieval.test.ts +21 -0
  200. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  201. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  202. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +7 -3
  203. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  204. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  205. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  206. package/src/plugins/defaults/memory/turn-retrieval.ts +8 -0
  207. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +24 -0
  208. package/src/plugins/defaults/memory/v3/injector.ts +4 -4
  209. package/src/plugins/types.ts +2 -0
  210. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  211. package/src/providers/anthropic/client.ts +4 -0
  212. package/src/providers/gemini/client.ts +7 -1
  213. package/src/providers/inference/auth.ts +25 -0
  214. package/src/providers/inference/profile-probe.ts +1 -0
  215. package/src/{calls/__tests__/typesafe-noul.test.ts → providers/jev/ask.test.ts} +4 -4
  216. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +67 -34
  217. package/src/providers/openai/__tests__/api-error-normalization.test.ts +12 -0
  218. package/src/providers/openai/api-error-normalization.ts +4 -0
  219. package/src/providers/retry.ts +2 -0
  220. package/src/providers/search-provider-catalog.ts +10 -0
  221. package/src/providers/types.ts +7 -0
  222. package/src/providers/usage-tracking.ts +5 -3
  223. package/src/runtime/__tests__/agent-wake.test.ts +308 -0
  224. package/src/runtime/access-request-helper.ts +16 -9
  225. package/src/runtime/agent-wake-queue.ts +52 -0
  226. package/src/runtime/agent-wake.ts +111 -18
  227. package/src/runtime/channel-approval-types.ts +6 -38
  228. package/src/runtime/channel-approvals.ts +4 -5
  229. package/src/runtime/http-server.ts +4 -1
  230. package/src/runtime/introduction-policy.test.ts +17 -1
  231. package/src/runtime/introduction-policy.ts +25 -7
  232. package/src/runtime/migrations/vbundle-builder.ts +9 -0
  233. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +21 -1
  234. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  235. package/src/runtime/routes/__tests__/oauth-proxy-routes.test.ts +61 -3
  236. package/src/runtime/routes/auto-profile-routes.ts +83 -0
  237. package/src/runtime/routes/conversation-management-routes.ts +1 -3
  238. package/src/runtime/routes/conversation-routes.ts +9 -0
  239. package/src/runtime/routes/guardian-action-routes.ts +8 -23
  240. package/src/runtime/routes/host-cu-routes.ts +3 -1
  241. package/src/runtime/routes/index.ts +2 -0
  242. package/src/runtime/routes/inference-profiles-routes.ts +5 -2
  243. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  244. package/src/runtime/routes/migration-routes.ts +8 -2
  245. package/src/runtime/routes/oauth-proxy-passthrough.test.ts +32 -0
  246. package/src/runtime/routes/platform-routes.ts +6 -1
  247. package/src/runtime/routes/user-route-dispatcher.ts +1 -2
  248. package/src/runtime/web-presence.ts +3 -4
  249. package/src/schedule/schedule-store.ts +10 -0
  250. package/src/schedule/scheduler.ts +92 -24
  251. package/src/subagent/manager.ts +32 -12
  252. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  253. package/src/telemetry/telemetry-wire-source.json +1 -1
  254. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  255. package/src/tools/background-tool-registry.ts +30 -3
  256. package/src/tools/browser/cdp-client/local-cdp-client.ts +1 -2
  257. package/src/tools/host-terminal/host-shell.ts +7 -0
  258. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  259. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  260. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  261. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  262. package/src/tools/network/web-fetch.ts +30 -9
  263. package/src/tools/network/web-search.ts +235 -4
  264. package/src/tools/skills/load.ts +13 -17
  265. package/src/tools/skills/scaffold-managed.ts +86 -71
  266. package/src/tools/terminal/shell-launch.test.ts +27 -1
  267. package/src/tools/terminal/shell.ts +4 -0
  268. package/src/tools/workspace-tools/loader.ts +1 -2
  269. package/src/usage/attribution.ts +18 -1
  270. package/src/util/abort-reasons.ts +2 -0
  271. package/src/util/errors.ts +1 -0
  272. package/src/util/oauth-request-body.test.ts +5 -1
  273. package/src/util/provider-error-patterns.ts +11 -0
  274. package/src/util/single-flight.test.ts +6 -0
  275. package/src/util/single-flight.ts +2 -0
  276. package/src/util/worker-pipe.test.ts +56 -10
  277. package/src/util/worker-pipe.ts +9 -4
  278. package/src/workspace/git-service.ts +6 -1
  279. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  280. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  281. package/src/workspace/migrations/registry.ts +2 -0
@@ -15,11 +15,16 @@ import type { LoopToolExecutor } from "../agent/loop.js";
15
15
  import type { AssistantEvent } from "../api/index.js";
16
16
  import { stripInjectionsForCompaction } from "../context/strip-injections.js";
17
17
  import type { AttachmentResolutionResult } from "../daemon/conversation-attachments.js";
18
+ import { abortScheduledRun } from "../daemon/conversation-lifecycle.js";
18
19
  import {
19
20
  queueConversationNotice,
20
21
  resetConversationNoticesForTests,
21
22
  } from "../daemon/conversation-notices.js";
23
+ import { MessageQueue } from "../daemon/conversation-queue-manager.js";
22
24
  import { desktopAutomationLease } from "../desktop/desktop-automation-lease.js";
25
+ import type { EmitSignalParams } from "../notifications/emit-signal.js";
26
+ import type { AttentionState } from "../persistence/conversation-attention-store.js";
27
+ import type { MessageRow } from "../persistence/conversation-crud.js";
23
28
  import { getConversationDirName } from "../persistence/conversation-directories.js";
24
29
  import type { UserPromptSubmitContext } from "../plugin-api/types.js";
25
30
  import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
@@ -36,6 +41,7 @@ import {
36
41
  type UsageAttributionInput,
37
42
  } from "../usage/attribution.js";
38
43
  import { createAbortReason } from "../util/abort-reasons.js";
44
+ import { getLogger } from "../util/logger.js";
39
45
  import { getWorkspaceDir } from "../util/platform.js";
40
46
  import { setConfig } from "./helpers/set-config.js";
41
47
 
@@ -98,6 +104,14 @@ function seedLlmConfig(options?: {
98
104
 
99
105
  // ── Module mocks (must precede imports of the module under test) ─────
100
106
 
107
+ let routeAutoProfileForTest: (() => Promise<void>) | undefined;
108
+ mock.module("../daemon/auto-profile-router.js", () => ({
109
+ routeAutoProfile: async () => {
110
+ await routeAutoProfileForTest?.();
111
+ return { profile: "balanced", outcome: "fallback", latencyMs: 0 };
112
+ },
113
+ }));
114
+
101
115
  // The real AgentLoop resolves the per-conversation ContextWindowManager from
102
116
  // the compaction store keyed by conversationId. These orchestrator tests build
103
117
  // fake conversations whose manager is a canned stub, so register each stub in a
@@ -309,6 +323,13 @@ let mockConversationRow: MockConversationRow = {
309
323
  title: null,
310
324
  };
311
325
  let mockMessageById: Record<string, unknown> | null = null;
326
+ let backgroundRecoveryRows: MessageRow[] | undefined;
327
+ let backgroundRecoveryAttention: AttentionState | undefined;
328
+ let backgroundRecoveryPending = () => false;
329
+ const backgroundRecoverySignals: EmitSignalParams[] = [];
330
+ const emitBackgroundSignalMock = mock(async (signal: EmitSignalParams) => {
331
+ backgroundRecoverySignals.push(signal);
332
+ });
312
333
 
313
334
  // The in-flight delta files the writers create for the (unmocked-path)
314
335
  // test conversation. Files are uuid-named at reserve time, so tests locate
@@ -384,7 +405,38 @@ mock.module("../persistence/conversation-crud.js", () => ({
384
405
  updateConversationSlackContextWatermarkMock,
385
406
  updateConversationTitle: () => {},
386
407
  getConversationOriginChannel: () => null,
387
- getMessageById: () => mockMessageById,
408
+ getMessageById: (id: string) =>
409
+ backgroundRecoveryRows
410
+ ? (backgroundRecoveryRows.find((row) => row.id === id) ?? null)
411
+ : mockMessageById,
412
+ getRecentConversationMessages: (
413
+ _conversationId: string,
414
+ limit: number,
415
+ beforeMessageId?: string,
416
+ ) => {
417
+ const rows = backgroundRecoveryRows ?? [];
418
+ const end = beforeMessageId
419
+ ? rows.findIndex((row) => row.id === beforeMessageId)
420
+ : rows.length;
421
+ return rows.slice(Math.max(0, end - limit), end);
422
+ },
423
+ getAssistantMessageIdsInTurn: (id: string) => {
424
+ const rows = backgroundRecoveryRows ?? [];
425
+ const ids: string[] = [];
426
+ for (
427
+ let index = rows.findIndex((row) => row.id === id);
428
+ index >= 0;
429
+ index--
430
+ ) {
431
+ if (rows[index].role === "user") {
432
+ break;
433
+ }
434
+ if (rows[index].role === "assistant") {
435
+ ids.unshift(rows[index].id);
436
+ }
437
+ }
438
+ return ids;
439
+ },
388
440
  getLastUserTimestampBefore: () => 0,
389
441
  reserveMessage: reserveMessageMock,
390
442
  updateMessageContent: updateMessageContentMock,
@@ -417,11 +469,46 @@ mock.module("../plugins/defaults/memory/indexer.js", () => ({
417
469
  }));
418
470
  mock.module("../persistence/conversation-attention-store.js", () => ({
419
471
  projectAssistantMessage: projectAssistantMessageMock,
472
+ getAttentionStateByConversationIds: () =>
473
+ new Map(
474
+ backgroundRecoveryAttention
475
+ ? [["test-conv", backgroundRecoveryAttention]]
476
+ : [],
477
+ ),
420
478
  }));
421
479
  mock.module("../runtime/sync/sync-publisher.js", () => ({
422
480
  publishSyncInvalidation: publishSyncInvalidationMock,
423
481
  }));
424
482
 
483
+ mock.module("../notifications/has-pending-background-work.js", () => ({
484
+ hasPendingBackgroundWork: () => backgroundRecoveryPending(),
485
+ }));
486
+ mock.module("../notifications/events-store.js", () => ({
487
+ hasNotifiedSourceContextSince: () => false,
488
+ }));
489
+ mock.module("../notifications/emit-signal.js", () => ({
490
+ emitNotificationSignal: emitBackgroundSignalMock,
491
+ }));
492
+ mock.module("../notifications/resolve-visible-in-source.js", () => ({
493
+ resolveCompletionRecipientPrincipalId: async () => "principal-owner",
494
+ resolveCompletionVisibleInSourceNow: async () => false,
495
+ }));
496
+ const {
497
+ emitBackgroundResultNotification: emitBackgroundResultNotificationReal,
498
+ } = await import("../notifications/background-result-producer.js");
499
+ const emitBackgroundResultNotificationMock = mock(
500
+ async (
501
+ params: Parameters<typeof emitBackgroundResultNotificationReal>[0],
502
+ ) => {
503
+ if (backgroundRecoveryRows) {
504
+ await emitBackgroundResultNotificationReal(params);
505
+ }
506
+ },
507
+ );
508
+ mock.module("../notifications/background-result-producer.js", () => ({
509
+ emitBackgroundResultNotification: emitBackgroundResultNotificationMock,
510
+ }));
511
+
425
512
  const emitAssistantReplyNotificationMock = mock(async () => {});
426
513
  mock.module("../notifications/assistant-reply-producer.js", () => ({
427
514
  emitAssistantReplyNotification: emitAssistantReplyNotificationMock,
@@ -1044,6 +1131,7 @@ function overflowAfterToolTurnScenario(): NonNullable<
1044
1131
  // ── Tests ────────────────────────────────────────────────────────────
1045
1132
 
1046
1133
  beforeEach(() => {
1134
+ routeAutoProfileForTest = undefined;
1047
1135
  setConfig("ui", {});
1048
1136
  seedLlmConfig();
1049
1137
  raceWithTimeoutOutcome = "completed";
@@ -1063,6 +1151,12 @@ beforeEach(() => {
1063
1151
  mockTurnReplyMessageId = undefined;
1064
1152
  resolveTurnReplyMessageIdMock.mockClear();
1065
1153
  emitAssistantReplyNotificationMock.mockClear();
1154
+ emitBackgroundResultNotificationMock.mockClear();
1155
+ backgroundRecoveryRows = undefined;
1156
+ backgroundRecoveryAttention = undefined;
1157
+ backgroundRecoveryPending = () => false;
1158
+ backgroundRecoverySignals.length = 0;
1159
+ emitBackgroundSignalMock.mockClear();
1066
1160
  updateMessageMetadataMock.mockClear();
1067
1161
  updateMessageMetadataMock.mockImplementation(() => {});
1068
1162
  updateConversationSlackContextWatermarkMock.mockClear();
@@ -1508,6 +1602,27 @@ describe("session-agent-loop", () => {
1508
1602
  });
1509
1603
 
1510
1604
  describe("user-prompt-submit hook failures", () => {
1605
+ test.each([true, false, undefined])(
1606
+ "fresh memory policy is scoped to the turn (%s)",
1607
+ async (skipMemoryRetrieval) => {
1608
+ const ctx = makeCtx({ providerResponses: [textResponse("ok")] });
1609
+ ctx.currentTurnSkipMemoryRetrieval = true;
1610
+ const seen: Array<boolean | undefined> = [];
1611
+ registerPlugin({
1612
+ manifest: { name: "test-memory-turn-policy", version: "1.0.0" },
1613
+ hooks: {
1614
+ "user-prompt-submit": async () => {
1615
+ seen.push(ctx.currentTurnSkipMemoryRetrieval);
1616
+ },
1617
+ },
1618
+ });
1619
+ await runAgentLoopImpl(ctx, "circle that", "msg-1", () => {}, {
1620
+ skipMemoryRetrieval,
1621
+ });
1622
+ expect(seen).toEqual([skipMemoryRetrieval === true]);
1623
+ expect(ctx.currentTurnSkipMemoryRetrieval).toBeUndefined();
1624
+ },
1625
+ );
1511
1626
  test("passes the effective profile to hooks even when it was already announced", async () => {
1512
1627
  // Both profiles are complete (provider + model) so each is a usable
1513
1628
  // winner: the conversation's pinned "balanced" must win selection over
@@ -2919,6 +3034,51 @@ describe("session-agent-loop", () => {
2919
3034
  });
2920
3035
 
2921
3036
  describe("user cancellation", () => {
3037
+ test("schedule timeout aborts a continuation during Auto profile setup", async () => {
3038
+ seedLlmConfig({
3039
+ profiles: {
3040
+ auto: {
3041
+ source: "managed",
3042
+ provider: "anthropic",
3043
+ model: "test-model",
3044
+ },
3045
+ },
3046
+ activeProfile: "auto",
3047
+ });
3048
+ const enteredRouter = Promise.withResolvers<void>();
3049
+ const releaseRouter = Promise.withResolvers<void>();
3050
+ routeAutoProfileForTest = async () => {
3051
+ enteredRouter.resolve();
3052
+ await releaseRouter.promise;
3053
+ };
3054
+ const controller = new AbortController();
3055
+ const provider = createMockProvider([textResponse("Should not run")]);
3056
+ const ctx = makeCtx({
3057
+ abortController: controller,
3058
+ loopProvider: provider.provider,
3059
+ queue: new MessageQueue(),
3060
+ prompter: { dispose: () => {} } as Conversation["prompter"],
3061
+ secretPrompter: { dispose: () => {} } as Conversation["secretPrompter"],
3062
+ accumulatedSurfaceState: new Map(),
3063
+ });
3064
+ const turn = runAgentLoopImpl(ctx, "Continue report", "msg-1", () => {}, {
3065
+ cronRunId: "run-scheduled",
3066
+ overrideProfile: "auto",
3067
+ });
3068
+ try {
3069
+ await enteredRouter.promise;
3070
+ expect(ctx.currentTurnCronRunId).toBe("run-scheduled");
3071
+ abortScheduledRun(ctx, "run-scheduled");
3072
+ expect(controller.signal.aborted).toBe(true);
3073
+ } finally {
3074
+ releaseRouter.resolve();
3075
+ await turn;
3076
+ }
3077
+ expect(provider.calls).toHaveLength(0);
3078
+ expect(ctx.isProcessing()).toBe(false);
3079
+ expect(ctx.currentTurnCronRunId).toBeUndefined();
3080
+ });
3081
+
2922
3082
  test("emits generation_cancelled when abort signal fires", async () => {
2923
3083
  const events: AssistantEvent[] = [];
2924
3084
  const abortController = new AbortController();
@@ -3958,10 +4118,378 @@ describe("session-agent-loop", () => {
3958
4118
 
3959
4119
  expect(addMessageMock).toHaveBeenCalled();
3960
4120
  expect(emitAssistantReplyNotificationMock).not.toHaveBeenCalled();
4121
+ expect(emitBackgroundResultNotificationMock.mock.calls).toMatchObject([
4122
+ [{ recoverOnly: true, assistantMessageId: undefined }],
4123
+ ]);
3961
4124
  });
3962
4125
  });
3963
4126
 
3964
4127
  describe("assistant-reply notification wiring", () => {
4128
+ function seedDeferredCommandResult(options?: {
4129
+ privateResult?: boolean;
4130
+ noEarlierSuccess?: boolean;
4131
+ userTrigger?: boolean;
4132
+ }): void {
4133
+ const startedAt = 1_700_000_000_000;
4134
+ const row = (
4135
+ id: string,
4136
+ role: "user" | "assistant",
4137
+ text: string,
4138
+ offset: number,
4139
+ metadata?: Record<string, unknown>,
4140
+ ): MessageRow => ({
4141
+ id,
4142
+ conversationId: "test-conv",
4143
+ role,
4144
+ content: [{ type: "text", text }],
4145
+ createdAt: startedAt + offset,
4146
+ metadata: metadata ? JSON.stringify(metadata) : null,
4147
+ clientMessageId: null,
4148
+ finalized: 1,
4149
+ });
4150
+ backgroundRecoveryRows = [
4151
+ row("prior-trigger", "user", "INTERNAL COMMAND RESULT", 1, {
4152
+ backgroundEventSource: "background-tool",
4153
+ backgroundToolCompletion: {
4154
+ id: "tool-success",
4155
+ toolName: "bash",
4156
+ conversationId: "test-conv",
4157
+ command: "example-command",
4158
+ startedAt,
4159
+ completedAt: startedAt + 1,
4160
+ status: options?.noEarlierSuccess ? "failed" : "completed",
4161
+ exitCode: options?.noEarlierSuccess ? 1 : 0,
4162
+ output: "raw command output",
4163
+ },
4164
+ }),
4165
+ row(
4166
+ "prior-result",
4167
+ "assistant",
4168
+ "The requested report is ready.",
4169
+ 2,
4170
+ options?.privateResult
4171
+ ? { assistantTextVisibility: "private" }
4172
+ : undefined,
4173
+ ),
4174
+ row(
4175
+ "final-trigger",
4176
+ "user",
4177
+ "INTERNAL SIBLING RESULT",
4178
+ 3,
4179
+ options?.userTrigger
4180
+ ? undefined
4181
+ : {
4182
+ scripted: true,
4183
+ subagentNotification: {
4184
+ subagentId: "task-final",
4185
+ conversationId: "conv-final",
4186
+ label: "Research",
4187
+ status: "completed",
4188
+ },
4189
+ },
4190
+ ),
4191
+ ];
4192
+ backgroundRecoveryAttention = {
4193
+ conversationId: "test-conv",
4194
+ latestAssistantMessageId: "prior-result",
4195
+ latestAssistantMessageAt: startedAt + 2,
4196
+ lastSeenAssistantMessageAt: null,
4197
+ } as AttentionState;
4198
+ mockConversationRow = {
4199
+ ...mockConversationRow,
4200
+ id: "test-conv",
4201
+ source: "web",
4202
+ conversationType: "standard",
4203
+ };
4204
+ updateMessageMetadataMock.mockImplementation((id, updates) => {
4205
+ const persisted = backgroundRecoveryRows?.find((row) => row.id === id);
4206
+ if (persisted) {
4207
+ persisted.metadata = JSON.stringify({
4208
+ ...JSON.parse(persisted.metadata ?? "{}"),
4209
+ ...updates,
4210
+ });
4211
+ }
4212
+ });
4213
+ }
4214
+
4215
+ test.each(["failed", "cancelled"] as const)(
4216
+ "an escaping %s continuation recovers an earlier result after release and queue settlement",
4217
+ async (outcome) => {
4218
+ seedDeferredCommandResult();
4219
+ const { promise: queueDrain, resolve: releaseQueue } =
4220
+ Promise.withResolvers<void>();
4221
+ let queued = true;
4222
+ const ctx = makeCtx({
4223
+ hasQueuedMessages: () => queued,
4224
+ drainQueue: async () => {
4225
+ await queueDrain;
4226
+ queued = false;
4227
+ },
4228
+ });
4229
+ backgroundRecoveryPending = () =>
4230
+ ctx.isProcessing() || ctx.hasQueuedMessages();
4231
+ await emitBackgroundResultNotificationReal({
4232
+ conversationId: "test-conv",
4233
+ assistantMessageId: "prior-result",
4234
+ userMessageId: "prior-trigger",
4235
+ rlog: getLogger("failed-continuation-test"),
4236
+ });
4237
+ expect(backgroundRecoverySignals).toHaveLength(0);
4238
+ const abortController = ctx.abortController!;
4239
+ const run = spyOn(ctx.agentLoop, "run").mockImplementationOnce(
4240
+ async () => {
4241
+ if (outcome === "cancelled") {
4242
+ abortController.abort();
4243
+ throw new DOMException("Cancelled", "AbortError");
4244
+ }
4245
+ throw new Error("runtime failure outside the provider retry loop");
4246
+ },
4247
+ );
4248
+ const events: AssistantEvent[] = [];
4249
+ await runAgentLoopImpl(ctx, "continue", "final-trigger", (event) =>
4250
+ events.push(event),
4251
+ );
4252
+ run.mockRestore();
4253
+
4254
+ expect(ctx.isProcessing()).toBe(false);
4255
+ expect(emitBackgroundResultNotificationMock).not.toHaveBeenCalled();
4256
+ expect(backgroundRecoverySignals).toHaveLength(0);
4257
+ expect(events.some((event) => event.type === "message_complete")).toBe(
4258
+ false,
4259
+ );
4260
+ expect(
4261
+ events.some(
4262
+ (event) =>
4263
+ event.type ===
4264
+ (outcome === "cancelled" ? "generation_cancelled" : "error"),
4265
+ ),
4266
+ ).toBe(true);
4267
+ releaseQueue();
4268
+ await settleTurnTail(ctx.conversationId);
4269
+ await Promise.all(
4270
+ emitBackgroundResultNotificationMock.mock.results.map(
4271
+ (result) => result.value,
4272
+ ),
4273
+ );
4274
+
4275
+ expect(emitAssistantReplyNotificationMock).not.toHaveBeenCalled();
4276
+ expect(emitBackgroundResultNotificationMock.mock.calls).toMatchObject([
4277
+ [
4278
+ {
4279
+ userMessageId: "final-trigger",
4280
+ recoverOnly: true,
4281
+ assistantMessageId: undefined,
4282
+ },
4283
+ ],
4284
+ ]);
4285
+ expect(backgroundRecoverySignals).toMatchObject([
4286
+ {
4287
+ sourceEventName: "activity.complete",
4288
+ dedupeKey: "activity.complete:test-conv:tool:tool-success",
4289
+ contextPayload: {
4290
+ requestedMessage: "The requested report is ready.",
4291
+ },
4292
+ },
4293
+ ]);
4294
+ expect(updateMessageMetadataMock).toHaveBeenCalledWith(
4295
+ "final-trigger",
4296
+ expect.objectContaining({ turnOutcome: outcome }),
4297
+ );
4298
+ },
4299
+ );
4300
+
4301
+ test.each([
4302
+ { name: "a user turn", userTrigger: true },
4303
+ { name: "private earlier output", privateResult: true },
4304
+ { name: "failure-only work", noEarlierSuccess: true },
4305
+ { name: "scheduled work", cronRunId: "run-schedule" },
4306
+ ])("an escaping exception stays silent for $name", async (scenario) => {
4307
+ seedDeferredCommandResult(scenario);
4308
+ const ctx = makeCtx();
4309
+ backgroundRecoveryPending = () => ctx.isProcessing();
4310
+ const run = spyOn(ctx.agentLoop, "run").mockRejectedValueOnce(
4311
+ new Error("runtime failure outside the provider retry loop"),
4312
+ );
4313
+ await runAgentLoopImpl(ctx, "continue", "final-trigger", () => {}, {
4314
+ cronRunId: scenario.cronRunId,
4315
+ });
4316
+ run.mockRestore();
4317
+ await settleTurnTail(ctx.conversationId);
4318
+ await Promise.all(
4319
+ emitBackgroundResultNotificationMock.mock.results.map(
4320
+ (result) => result.value,
4321
+ ),
4322
+ );
4323
+
4324
+ expect(emitBackgroundResultNotificationMock.mock.calls).toMatchObject([
4325
+ [
4326
+ {
4327
+ userMessageId: "final-trigger",
4328
+ recoverOnly: true,
4329
+ cronRunId: scenario.cronRunId ?? null,
4330
+ },
4331
+ ],
4332
+ ]);
4333
+ expect(emitAssistantReplyNotificationMock).not.toHaveBeenCalled();
4334
+ expect(backgroundRecoverySignals).toHaveLength(0);
4335
+ });
4336
+
4337
+ test("a failed continuation draining a newer public result emits only that result", async () => {
4338
+ seedDeferredCommandResult();
4339
+ const rows = backgroundRecoveryRows!;
4340
+ const startedAt = rows[0].createdAt;
4341
+ const { promise: queuedTurnReady, resolve: releaseQueuedTurn } =
4342
+ Promise.withResolvers<void>();
4343
+ const { promise: projectionReady, resolve: releaseProjection } =
4344
+ Promise.withResolvers<void>();
4345
+ const { promise: indexingStarted, resolve: markIndexingStarted } =
4346
+ Promise.withResolvers<void>();
4347
+ const { promise: deliveryReady, resolve: releaseDelivery } =
4348
+ Promise.withResolvers<void>();
4349
+ const { promise: deliveryStarted, resolve: markDeliveryStarted } =
4350
+ Promise.withResolvers<void>();
4351
+ let queued = true;
4352
+ const drainQueue = mock(async () => {
4353
+ if (!queued) {
4354
+ return;
4355
+ }
4356
+ await queuedTurnReady;
4357
+ queued = false;
4358
+ const metadata = JSON.parse(rows[0].metadata!);
4359
+ rows.push({
4360
+ ...rows[0],
4361
+ id: "queued-trigger",
4362
+ createdAt: startedAt + 10,
4363
+ metadata: JSON.stringify({
4364
+ ...metadata,
4365
+ backgroundToolCompletion: {
4366
+ ...metadata.backgroundToolCompletion,
4367
+ id: "tool-queued",
4368
+ completedAt: startedAt + 10,
4369
+ },
4370
+ }),
4371
+ });
4372
+ ctx.abortController = new AbortController();
4373
+ ctx.setProcessing(true);
4374
+ await runAgentLoopImpl(
4375
+ ctx,
4376
+ "queued result",
4377
+ "queued-trigger",
4378
+ () => {},
4379
+ );
4380
+ });
4381
+ const ctx = makeCtx({
4382
+ providerResponses: [textResponse("The complete report is ready.")],
4383
+ hasQueuedMessages: () => queued,
4384
+ drainQueue,
4385
+ });
4386
+ backgroundRecoveryPending = () =>
4387
+ ctx.isProcessing() || ctx.hasQueuedMessages();
4388
+ reserveMessageMock.mockImplementationOnce(async () => {
4389
+ rows.push({
4390
+ ...rows[1],
4391
+ id: "queued-result",
4392
+ createdAt: startedAt + 11,
4393
+ content: [],
4394
+ finalized: 0,
4395
+ });
4396
+ return { id: "queued-result", createdAt: startedAt + 11 };
4397
+ });
4398
+ finalizeMessageContentMock.mockImplementationOnce(
4399
+ (...args: unknown[]) => {
4400
+ const [id, content] = args as [string, string];
4401
+ const row = rows.find((candidate) => candidate.id === id)!;
4402
+ row.content = JSON.parse(content);
4403
+ row.finalized = 1;
4404
+ },
4405
+ );
4406
+ indexMessageNowMock.mockImplementationOnce(async () => {
4407
+ markIndexingStarted();
4408
+ await projectionReady;
4409
+ return { indexedSegments: 0, enqueuedJobs: 0 };
4410
+ });
4411
+ projectAssistantMessageMock.mockImplementationOnce(
4412
+ (...args: unknown[]) => {
4413
+ const [projection] = args as [
4414
+ { messageId: string; messageAt: number },
4415
+ ];
4416
+ backgroundRecoveryAttention!.latestAssistantMessageId =
4417
+ projection.messageId;
4418
+ backgroundRecoveryAttention!.latestAssistantMessageAt =
4419
+ projection.messageAt;
4420
+ return true;
4421
+ },
4422
+ );
4423
+ emitBackgroundSignalMock.mockImplementationOnce(async (signal) => {
4424
+ markDeliveryStarted();
4425
+ await deliveryReady;
4426
+ backgroundRecoverySignals.push(signal);
4427
+ });
4428
+ const run = spyOn(ctx.agentLoop, "run").mockRejectedValueOnce(
4429
+ new Error("runtime failure outside the provider retry loop"),
4430
+ );
4431
+ await runAgentLoopImpl(ctx, "continue", "final-trigger", () => {});
4432
+ run.mockRestore();
4433
+ expect(backgroundRecoverySignals).toHaveLength(0);
4434
+ releaseQueuedTurn();
4435
+ await indexingStarted;
4436
+ await drainQueue.mock.results[0].value;
4437
+ expect(ctx.isProcessing()).toBe(false);
4438
+ expect(backgroundRecoverySignals).toHaveLength(0);
4439
+ releaseProjection();
4440
+ await deliveryStarted;
4441
+ await settleTurnTail(ctx.conversationId);
4442
+ expect(emitBackgroundSignalMock).toHaveBeenCalledTimes(1);
4443
+ expect(backgroundRecoverySignals).toHaveLength(0);
4444
+ releaseDelivery();
4445
+ await Promise.all(
4446
+ emitBackgroundResultNotificationMock.mock.results.map(
4447
+ (result) => result.value,
4448
+ ),
4449
+ );
4450
+
4451
+ expect(emitBackgroundResultNotificationMock).toHaveBeenCalledTimes(2);
4452
+ expect(backgroundRecoverySignals).toMatchObject([
4453
+ {
4454
+ dedupeKey: "activity.complete:test-conv:tool:tool-queued",
4455
+ contextPayload: {
4456
+ requestedMessage: "The complete report is ready.",
4457
+ },
4458
+ },
4459
+ ]);
4460
+ });
4461
+
4462
+ test("finalized continuations pass their persisted rows and scheduled owner to the background producer", async () => {
4463
+ mockMessageById = {
4464
+ id: "msg-reserve",
4465
+ conversationId: "test-conv",
4466
+ createdAt: 1234567,
4467
+ role: "assistant",
4468
+ content: "[]",
4469
+ metadata: null,
4470
+ };
4471
+ const ctx = makeCtx({
4472
+ providerResponses: [textResponse("The delegated result is ready.")],
4473
+ });
4474
+ await runAgentLoopImpl(
4475
+ ctx,
4476
+ "internal task completion",
4477
+ "msg-completion",
4478
+ () => {},
4479
+ { cronRunId: "run-schedule" },
4480
+ );
4481
+ expect(projectAssistantMessageMock).toHaveBeenCalled();
4482
+ expect(emitBackgroundResultNotificationMock).toHaveBeenCalledTimes(1);
4483
+ expect(emitBackgroundResultNotificationMock.mock.calls[0]).toMatchObject([
4484
+ {
4485
+ conversationId: "test-conv",
4486
+ assistantMessageId: "msg-reserve",
4487
+ userMessageId: "msg-completion",
4488
+ cronRunId: "run-schedule",
4489
+ },
4490
+ ]);
4491
+ });
4492
+
3965
4493
  test("a completed turn notifies with the row that opened it", async () => {
3966
4494
  mockMessageById = {
3967
4495
  id: "msg-reserve",
@@ -1356,6 +1356,24 @@ describe("classifyConversationError", () => {
1356
1356
  expect(result.userMessage).toContain("Billing settings");
1357
1357
  });
1358
1358
 
1359
+ it("routes reason=free_tier_daily_limit_reached to PROVIDER_BILLING/free_tier_daily_limit_reached", () => {
1360
+ providerRoutingSources.openai = "user-key";
1361
+ const err = new ProviderError(
1362
+ 'OpenAI API error (402): {"code":"free_tier_daily_limit_reached","detail":"free usage"}',
1363
+ "openai",
1364
+ 402,
1365
+ { reason: "free_tier_daily_limit_reached" },
1366
+ );
1367
+
1368
+ const result = classifyConversationError(err, baseCtx);
1369
+
1370
+ expect(result.code).toBe("PROVIDER_BILLING");
1371
+ expect(result.errorCategory).toBe("free_tier_daily_limit_reached");
1372
+ expect(result.retryable).toBe(false);
1373
+ expect(result.userMessage).toContain("today's free usage");
1374
+ expect(result.userMessage).toContain("midnight UTC");
1375
+ });
1376
+
1359
1377
  it("classifies reason=daily_limit_reached as daily_limit_reached even when the routing map says user-key", () => {
1360
1378
  // Per-connection platform-auth routes can leave the global routing map
1361
1379
  // at user-key; the stamped reason comes only from the platform proxy's