@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
@@ -927,6 +927,65 @@ describe("pairDeliveryWithConversation", () => {
927
927
  expect(messagesInvalidated).toEqual([]);
928
928
  });
929
929
 
930
+ test("explicit background result keeps its persisted result without another preview row", async () => {
931
+ mockExistingConversations["conv-user-chat"] = {
932
+ id: "conv-user-chat",
933
+ source: "user",
934
+ title: "An ordinary chat",
935
+ };
936
+ const result = await pairDeliveryWithConversation(
937
+ makeSignal({
938
+ requiresConversation: undefined,
939
+ sourceContextId: "task-1",
940
+ sourceEventName: "activity.complete",
941
+ contextPayload: {
942
+ completion: {
943
+ workId: "task-1",
944
+ conversationId: "conv-user-chat",
945
+ recipientPrincipalId: "principal-1",
946
+ owner: "parent_continuation",
947
+ },
948
+ },
949
+ }),
950
+ "vellum",
951
+ makeCopy({ body: "The report is ready." }),
952
+ );
953
+
954
+ expect(result.conversationId).toBe("conv-user-chat");
955
+ expect(result.messageId).toBeNull();
956
+ expect(createConversationMock).not.toHaveBeenCalled();
957
+ expect(addMessageMock).not.toHaveBeenCalled();
958
+ });
959
+
960
+ test("a missing declared completion conversation does not fall back to unrelated source content", async () => {
961
+ mockExistingConversations["conv-source"] = {
962
+ id: "conv-source",
963
+ source: "user",
964
+ title: "Another chat",
965
+ };
966
+ const result = await pairDeliveryWithConversation(
967
+ makeSignal({
968
+ requiresConversation: undefined,
969
+ sourceContextId: "conv-source",
970
+ sourceEventName: "activity.complete",
971
+ contextPayload: {
972
+ completion: {
973
+ workId: "task-1",
974
+ conversationId: "conv-missing",
975
+ recipientPrincipalId: "principal-1",
976
+ owner: "parent_continuation",
977
+ },
978
+ },
979
+ }),
980
+ "vellum",
981
+ makeCopy({ body: "The report is ready." }),
982
+ );
983
+
984
+ expect(result.conversationId).toBeNull();
985
+ expect(createConversationMock).not.toHaveBeenCalled();
986
+ expect(addMessageMock).not.toHaveBeenCalled();
987
+ });
988
+
930
989
  test("passive vellum signal appends the body to the producing conversation", async () => {
931
990
  mockExistingConversations["conv-producer"] = {
932
991
  id: "conv-producer",
@@ -996,6 +1055,12 @@ describe("pairDeliveryWithConversation", () => {
996
1055
  expect(createConversationMock).not.toHaveBeenCalled();
997
1056
  expect(addMessageMock).toHaveBeenCalledTimes(1);
998
1057
  expect(messagesInvalidated).toContain("conv-foreground");
1058
+ // The transcript is this notification's only home, so a chat marked Done
1059
+ // comes back for it.
1060
+ const options = addMessageMock.mock.calls[0]![3] as {
1061
+ skipResurface?: boolean;
1062
+ };
1063
+ expect(options.skipResurface).toBe(false);
999
1064
  });
1000
1065
 
1001
1066
  test("appended notification body skips indexing", async () => {
@@ -1017,8 +1082,12 @@ describe("pairDeliveryWithConversation", () => {
1017
1082
 
1018
1083
  const options = addMessageMock.mock.calls[0]![3] as {
1019
1084
  skipIndexing?: boolean;
1085
+ skipResurface?: boolean;
1020
1086
  };
1021
1087
  expect(options.skipIndexing).toBe(true);
1088
+ // The bell carries this one (async-background), so the append is
1089
+ // bookkeeping and never resurfaces a chat the user marked Done.
1090
+ expect(options.skipResurface).toBe(true);
1022
1091
  });
1023
1092
 
1024
1093
  test("passive vellum signal appends regardless of the producing conversation's source", async () => {
@@ -46,6 +46,7 @@ const capturedPersistedSeqs: Array<{ id: string; seq: number }> = [];
46
46
  * tail message while its siblings succeed).
47
47
  */
48
48
  const addMessageShouldThrowForContent = new Set<string>();
49
+ let beforeMessageInsert: ((requestId?: string) => Promise<void>) | undefined;
49
50
 
50
51
  mock.module("../prompts/system-prompt.js", () => ({
51
52
  buildSystemPrompt: () => "system prompt",
@@ -101,12 +102,20 @@ mock.module("../persistence/conversation-crud.js", () => ({
101
102
  totalEstimatedCost: 0,
102
103
  }),
103
104
  createConversation: () => ({ id: "conv-1" }),
104
- addMessage: (
105
+ addMessage: async (
105
106
  _convId: string,
106
107
  role: string,
107
108
  content: string,
108
- options?: { metadata?: Record<string, unknown> },
109
+ options?: {
110
+ id?: string;
111
+ metadata?: Record<string, unknown>;
112
+ insertPrecondition?: () => boolean;
113
+ },
109
114
  ) => {
115
+ await beforeMessageInsert?.(options?.id);
116
+ if (options?.insertPrecondition && !options.insertPrecondition()) {
117
+ throw new Error("Message insert cancelled");
118
+ }
110
119
  // Simulate a persist failure for tests that need to exercise the
111
120
  // tail-persist-failed path in drainBatch. Triggered by matching any
112
121
  // registered substring against the serialized content payload.
@@ -115,7 +124,7 @@ mock.module("../persistence/conversation-crud.js", () => ({
115
124
  throw new Error(`Simulated addMessage failure for content: ${needle}`);
116
125
  }
117
126
  }
118
- const id = `msg-${Date.now()}-${capturedAddMessages.length}`;
127
+ const id = options?.id ?? `msg-${Date.now()}-${capturedAddMessages.length}`;
119
128
  capturedAddMessages.push({
120
129
  id,
121
130
  role,
@@ -126,7 +135,8 @@ mock.module("../persistence/conversation-crud.js", () => ({
126
135
  },
127
136
  updateConversationUsage: () => {},
128
137
  updateConversationTitle: () => {},
129
- getMessageById: () => null,
138
+ getMessageById: (id: string) =>
139
+ capturedAddMessages.find((message) => message.id === id) ?? null,
130
140
  getLastUserTimestampBefore: () => 0,
131
141
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
132
142
  updateMessageContent: mock(() => {}),
@@ -481,10 +491,12 @@ async function resolveRun(index: number) {
481
491
  }
482
492
 
483
493
  beforeEach(() => {
494
+ capturedAddMessages.length = 0;
484
495
  turnCommitCalls.length = 0;
485
496
  turnCommitHangForever = false;
486
497
  linkAttachmentShouldThrow = false;
487
498
  addMessageShouldThrowForContent.clear();
499
+ beforeMessageInsert = undefined;
488
500
  });
489
501
 
490
502
  afterAll(() => {
@@ -1049,6 +1061,247 @@ describe("Conversation message queue", () => {
1049
1061
  expect(pendingRuns.length).toBe(3);
1050
1062
  });
1051
1063
 
1064
+ for (const cronRunId of ["run-scheduled", null]) {
1065
+ for (const batchSize of [1, 2]) {
1066
+ for (const cancel of [
1067
+ false,
1068
+ "schedule",
1069
+ "stop",
1070
+ "signal",
1071
+ "dispose",
1072
+ ] as const) {
1073
+ if (cronRunId === null && cancel === "schedule") {
1074
+ continue;
1075
+ }
1076
+ for (const claimed of [false, true]) {
1077
+ test(`queued dispatch stays tracked during persistence (owner=${cronRunId}, batch=${batchSize}, cancel=${cancel}, claimed=${claimed})`, async () => {
1078
+ const conversation = makeConversation();
1079
+ await conversation.loadFromDb();
1080
+ const persistedBefore = capturedAddMessages.length;
1081
+ const entered = Promise.withResolvers<void>();
1082
+ const release = Promise.withResolvers<void>();
1083
+ const acquire =
1084
+ conversation.acquireProcessingFenced.bind(conversation);
1085
+ conversation.acquireProcessingFenced = async () => {
1086
+ const owner = claimed ? await acquire() : undefined;
1087
+ entered.resolve();
1088
+ await release.promise;
1089
+ return owner === undefined ? acquire() : owner;
1090
+ };
1091
+ const events: AssistantEvent[] = [];
1092
+ conversation.setProcessing(true);
1093
+ for (let i = 0; i < batchSize; i++) {
1094
+ conversation.enqueueMessage({
1095
+ content: `Queued continuation ${i}`,
1096
+ requestId: `queued-${i}`,
1097
+ clientMessageId: `client-queued-${i}`,
1098
+ cronRunId,
1099
+ onEvent: (event) => events.push(event),
1100
+ });
1101
+ }
1102
+ conversation.setProcessing(false);
1103
+ const drain = conversation.drainQueue();
1104
+ try {
1105
+ await entered.promise;
1106
+ expect(conversation.getQueueDepth()).toBe(0);
1107
+ expect(conversation.isProcessing()).toBe(claimed);
1108
+ expect(
1109
+ conversation.pendingQueuedDispatches.get(cronRunId)?.size,
1110
+ ).toBe(1);
1111
+ expect(conversation.hasInFlightWork()).toBe(true);
1112
+ if (cancel === "schedule") {
1113
+ conversation.abortScheduledRun("run-scheduled");
1114
+ } else if (cancel === "stop" || cancel === "signal") {
1115
+ conversation.abort(
1116
+ createAbortReason(
1117
+ cancel === "stop" ? "user_cancel" : "signal_cancel",
1118
+ "test",
1119
+ ),
1120
+ );
1121
+ } else if (cancel === "dispose") {
1122
+ conversation.dispose();
1123
+ }
1124
+ } finally {
1125
+ release.resolve();
1126
+ await drain;
1127
+ }
1128
+ expect(conversation.pendingQueuedDispatches.size).toBe(0);
1129
+ const preserved =
1130
+ cronRunId === null && (cancel === "stop" || cancel === "signal");
1131
+ if (cancel && !preserved) {
1132
+ expect(pendingRuns).toHaveLength(0);
1133
+ expect(capturedAddMessages).toHaveLength(persistedBefore);
1134
+ expect(conversation.isProcessing()).toBe(false);
1135
+ expect(conversation.currentTurnCronRunId).toBeUndefined();
1136
+ expect(conversation.getQueueDepth()).toBe(0);
1137
+ expect(
1138
+ events.filter((event) => event.type === "generation_cancelled"),
1139
+ ).toHaveLength(batchSize);
1140
+ for (let i = 0; i < batchSize; i++) {
1141
+ const queueEvents = events.filter(
1142
+ (event) =>
1143
+ "requestId" in event && event.requestId === `queued-${i}`,
1144
+ );
1145
+ expect(queueEvents.map((event) => event.type)).toEqual(
1146
+ i === 0
1147
+ ? [
1148
+ "message_queued",
1149
+ "message_dequeued",
1150
+ "message_requeued",
1151
+ "message_queued_deleted",
1152
+ ]
1153
+ : ["message_queued", "message_queued_deleted"],
1154
+ );
1155
+ expect(queueEvents.at(-1)).toMatchObject({
1156
+ clientMessageId: `client-queued-${i}`,
1157
+ });
1158
+ }
1159
+ } else {
1160
+ await waitForPendingRun(1);
1161
+ expect(conversation.currentTurnCronRunId).toBe(cronRunId);
1162
+ expect(conversation.currentTurnWorkOrigins).toHaveLength(
1163
+ batchSize,
1164
+ );
1165
+ if (preserved) {
1166
+ expect(capturedAddMessages.length).toBe(
1167
+ persistedBefore + batchSize,
1168
+ );
1169
+ expect(
1170
+ events.filter((event) => event.type === "user_message_echo"),
1171
+ ).toHaveLength(batchSize);
1172
+ expect(
1173
+ events.some(
1174
+ (event) => event.type === "message_queued_deleted",
1175
+ ),
1176
+ ).toBe(false);
1177
+ }
1178
+ await resolveRun(0);
1179
+ }
1180
+ });
1181
+ }
1182
+ }
1183
+ }
1184
+ }
1185
+
1186
+ for (const cancel of ["schedule", "dispose"] as const) {
1187
+ test(`${cancel} cancels an unpersisted batch tail without deleting its persisted head`, async () => {
1188
+ const conversation = makeConversation();
1189
+ await conversation.loadFromDb();
1190
+ const entered = Promise.withResolvers<void>();
1191
+ const release = Promise.withResolvers<void>();
1192
+ beforeMessageInsert = async (requestId) => {
1193
+ if (requestId === "queued-tail") {
1194
+ entered.resolve();
1195
+ await release.promise;
1196
+ }
1197
+ };
1198
+ const events: AssistantEvent[] = [];
1199
+ conversation.setProcessing(true);
1200
+ for (const requestId of ["queued-head", "queued-tail"]) {
1201
+ conversation.enqueueMessage({
1202
+ content: requestId,
1203
+ requestId,
1204
+ clientMessageId: `client-${requestId}`,
1205
+ cronRunId: "run-scheduled",
1206
+ onEvent: (event) => events.push(event),
1207
+ });
1208
+ }
1209
+ conversation.setProcessing(false);
1210
+ const drain = conversation.drainQueue();
1211
+ try {
1212
+ await entered.promise;
1213
+ expect(
1214
+ events.some(
1215
+ (event) =>
1216
+ event.type === "user_message_echo" &&
1217
+ event.requestId === "queued-head",
1218
+ ),
1219
+ ).toBe(true);
1220
+ if (cancel === "schedule") {
1221
+ conversation.abortScheduledRun("run-scheduled");
1222
+ conversation.abortScheduledRun("run-scheduled");
1223
+ } else {
1224
+ conversation.dispose();
1225
+ }
1226
+ expect(
1227
+ events.filter((event) => event.type === "generation_cancelled"),
1228
+ ).toHaveLength(2);
1229
+ } finally {
1230
+ release.resolve();
1231
+ await drain;
1232
+ }
1233
+ expect(
1234
+ events
1235
+ .filter((event) => event.type === "message_queued_deleted")
1236
+ .map((event) => event.requestId),
1237
+ ).toEqual(["queued-tail"]);
1238
+ expect(capturedAddMessages.map((message) => message.id)).toEqual([
1239
+ "queued-head",
1240
+ ]);
1241
+ expect(conversation.getQueueDepth()).toBe(0);
1242
+ expect(conversation.pendingQueuedDispatches.size).toBe(0);
1243
+ if (pendingRuns.length > 0) {
1244
+ await resolveRun(0);
1245
+ }
1246
+ });
1247
+ }
1248
+
1249
+ for (const cancel of ["user_cancel", "signal_cancel"] as const) {
1250
+ for (const batchSize of [1, 2]) {
1251
+ test(`${cancel} preserves an accepted queued prompt after persistence (batch=${batchSize})`, async () => {
1252
+ const conversation = makeConversation();
1253
+ await conversation.loadFromDb();
1254
+ const persistedBefore = capturedAddMessages.length;
1255
+ const entered = Promise.withResolvers<void>();
1256
+ const release = Promise.withResolvers<void>();
1257
+ const persist = conversation.persistUserMessage.bind(conversation);
1258
+ let paused = false;
1259
+ conversation.persistUserMessage = async (options) => {
1260
+ const result = await persist(options);
1261
+ if (!paused) {
1262
+ paused = true;
1263
+ entered.resolve();
1264
+ await release.promise;
1265
+ }
1266
+ return result;
1267
+ };
1268
+ const events: AssistantEvent[] = [];
1269
+ conversation.setProcessing(true);
1270
+ for (let i = 0; i < batchSize; i++) {
1271
+ conversation.enqueueMessage({
1272
+ content: `User prompt ${i}`,
1273
+ requestId: `request-${i}`,
1274
+ clientMessageId: `client-${i}`,
1275
+ onEvent: (event) => events.push(event),
1276
+ });
1277
+ }
1278
+ conversation.setProcessing(false);
1279
+ const drain = conversation.drainQueue();
1280
+ try {
1281
+ await entered.promise;
1282
+ expect(capturedAddMessages.length).toBe(persistedBefore + 1);
1283
+ conversation.abort(createAbortReason(cancel, "test"));
1284
+ expect(conversation.abortController?.signal.aborted).toBe(false);
1285
+ } finally {
1286
+ release.resolve();
1287
+ await drain;
1288
+ }
1289
+ await waitForPendingRun(1);
1290
+ expect(
1291
+ events.filter((event) => event.type === "user_message_echo"),
1292
+ ).toHaveLength(batchSize);
1293
+ expect(
1294
+ events.some((event) => event.type === "message_queued_deleted"),
1295
+ ).toBe(false);
1296
+ expect(capturedAddMessages.length).toBe(persistedBefore + batchSize);
1297
+ expect(conversation.pendingQueuedDispatches.size).toBe(0);
1298
+ conversation.abort(createAbortReason(cancel, "test:active-turn"));
1299
+ expect(conversation.abortController?.signal.aborted).toBe(true);
1300
+ await resolveRun(0);
1301
+ });
1302
+ }
1303
+ }
1304
+
1052
1305
  test("[experimental] batched siblings run under their firing's cron run id", async () => {
1053
1306
  // A batched drain runs after the enqueuing turn has ended, so the firing's
1054
1307
  // attribution has to travel on the queued messages. Without it the batch's
@@ -4088,6 +4088,33 @@ describe("Slack channel chronological rendering — multi-thread", () => {
4088
4088
  };
4089
4089
  }
4090
4090
 
4091
+ test("fresh memory policy reaches injectors on initial assembly and re-entry", async () => {
4092
+ const seen = { ctx: null as TurnContext | null, commits: 0 };
4093
+ registerPluginInjectors(V3_PROBE_PLUGIN, [probeMemoryV3Injector(seen)]);
4094
+ try {
4095
+ setConversation(
4096
+ FALLBACK_CONVERSATION_ID,
4097
+ asConversation({
4098
+ conversationId: FALLBACK_CONVERSATION_ID,
4099
+ currentCallSite: "callAgent",
4100
+ currentTurnSkipMemoryRetrieval: true,
4101
+ }),
4102
+ );
4103
+ for (const isReInjection of [false, true]) {
4104
+ await applyRuntimeInjections(
4105
+ [{ role: "user", content: [{ type: "text", text: "circle that" }] }],
4106
+ {
4107
+ conversationId: FALLBACK_CONVERSATION_ID,
4108
+ reinjection: isReInjection,
4109
+ },
4110
+ );
4111
+ expect(seen.ctx?.skipMemoryRetrieval).toBe(true);
4112
+ }
4113
+ } finally {
4114
+ unregisterPluginInjectors(V3_PROBE_PLUGIN);
4115
+ }
4116
+ });
4117
+
4091
4118
  test("slack replacement is stated on the turn context and attaches the memory-v3 block in memory only: uncaptured, uncommitted", async () => {
4092
4119
  const seen = { ctx: null as TurnContext | null, commits: 0 };
4093
4120
  registerPluginInjectors(V3_PROBE_PLUGIN, [probeMemoryV3Injector(seen)]);
@@ -359,6 +359,68 @@ describe("access-request vellum floor", () => {
359
359
  );
360
360
  });
361
361
 
362
+ test("an all_channels access request the decision engine suppressed still fans out", async () => {
363
+ evaluateSignalMock.mockResolvedValue({
364
+ shouldNotify: false,
365
+ selectedChannels: [],
366
+ reasoningSummary: "LLM suppressed",
367
+ renderedCopy: {},
368
+ dedupeKey: "dedupe-ar-email",
369
+ confidence: 0.9,
370
+ fallbackUsed: false,
371
+ persistedDecisionId: "dec-ar-email",
372
+ });
373
+ // The real enforcement leaves a suppressed decision alone and replaces
374
+ // the selection with every connected channel otherwise.
375
+ enforceRoutingIntentMock.mockImplementation(
376
+ (
377
+ decision: { shouldNotify: boolean; selectedChannels: string[] },
378
+ intent: string | undefined,
379
+ connected: string[],
380
+ ) =>
381
+ intent === "all_channels" && decision.shouldNotify
382
+ ? { ...decision, selectedChannels: [...connected] }
383
+ : decision,
384
+ );
385
+
386
+ await emitNotificationSignal({
387
+ sourceEventName: "ingress.access_request",
388
+ sourceChannel: "email",
389
+ sourceContextId: "access-req-email-stranger",
390
+ requiresConversation: true,
391
+ routingIntent: "all_channels",
392
+ attentionHints: {
393
+ requiresAction: true,
394
+ urgency: "high",
395
+ isAsyncBackground: false,
396
+ visibleInSourceNow: false,
397
+ },
398
+ contextPayload: {
399
+ requestId: "req-email",
400
+ requestCode: "EM12CD",
401
+ sourceChannel: "email",
402
+ conversationExternalId: "<thread-1@example.com>",
403
+ actorExternalId: "stranger@example.com",
404
+ actorDisplayName: null,
405
+ actorUsername: null,
406
+ senderIdentifier: "stranger@example.com",
407
+ guardianBindingChannel: null,
408
+ guardianResolutionSource: "vellum-anchor",
409
+ previousMemberStatus: null,
410
+ messagePreview: null,
411
+ },
412
+ });
413
+
414
+ const enforced = enforceRoutingIntentMock.mock.calls[0][0] as {
415
+ shouldNotify: boolean;
416
+ };
417
+ expect(enforced.shouldNotify).toBe(true);
418
+ const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
419
+ selectedChannels: string[];
420
+ };
421
+ expect(dispatched.selectedChannels).toContain("vellum");
422
+ });
423
+
362
424
  test("re-adds vellum when single_channel routing enforcement strips it", async () => {
363
425
  evaluateSignalMock.mockResolvedValue({
364
426
  shouldNotify: true,
@@ -49,6 +49,7 @@ function register(
49
49
  processing: boolean;
50
50
  queued: boolean;
51
51
  modeSessionWork?: boolean;
52
+ scheduledDispatch?: boolean;
52
53
  stale?: boolean;
53
54
  },
54
55
  ): FakeConversation {
@@ -57,6 +58,14 @@ function register(
57
58
  markedStale: false,
58
59
  conversationId: id,
59
60
  liveVoiceResidencyLeases: 0,
61
+ pendingQueuedDispatches: state.scheduledDispatch
62
+ ? new Map([
63
+ [
64
+ "run-scheduled",
65
+ new Set([{ controller: new AbortController(), messages: [] }]),
66
+ ],
67
+ ])
68
+ : new Map(),
60
69
  isProcessing: () => state.processing,
61
70
  hasQueuedMessages: () => state.queued,
62
71
  modeSessions: {
@@ -111,6 +120,18 @@ describe("evictConversationsForReload", () => {
111
120
  expect(abortedParents).toEqual([]);
112
121
  });
113
122
 
123
+ test("keeps a scheduled dispatch whose processing claim is pending", () => {
124
+ const pending = register("reload-scheduled", {
125
+ processing: false,
126
+ queued: false,
127
+ scheduledDispatch: true,
128
+ });
129
+ evictConversationsForReload();
130
+ expect(pending.disposed).toBe(false);
131
+ expect(pending.markedStale).toBe(true);
132
+ expect(abortedParents).toEqual([]);
133
+ });
134
+
114
135
  test("marks a mid-turn conversation stale instead of disposing it", () => {
115
136
  const busy = register("reload-busy", { processing: true, queued: false });
116
137
 
@@ -1748,6 +1748,15 @@ describe("GeminiProvider", () => {
1748
1748
  }
1749
1749
  });
1750
1750
 
1751
+ test("maps a free-tier daily-limit 402 body code to free_tier_daily_limit_reached", async () => {
1752
+ expect(
1753
+ await reasonForApiError(
1754
+ 402,
1755
+ '{"code":"free_tier_daily_limit_reached","detail":"free usage"}',
1756
+ ),
1757
+ ).toBe("free_tier_daily_limit_reached");
1758
+ });
1759
+
1751
1760
  test("maps a daily-limit 402 body code to daily_limit_reached", async () => {
1752
1761
  expect(
1753
1762
  await reasonForApiError(
@@ -1186,6 +1186,85 @@ describe("HostCuProxy", () => {
1186
1186
 
1187
1187
  expect(result.content).toContain("2560x1440 px");
1188
1188
  expect(result.content).toContain("1280x720 pt");
1189
+ expect(result.content).toContain("x = round(image_x * 0.500000)");
1190
+ expect(result.content).toContain("y = round(image_y * 0.500000)");
1191
+ });
1192
+
1193
+ test("derives separate screen-point scales for a downscaled screenshot", () => {
1194
+ setup();
1195
+
1196
+ const result = proxy.formatObservation({
1197
+ screenshot: "base64data",
1198
+ screenshotWidthPx: 835,
1199
+ screenshotHeightPx: 540,
1200
+ screenWidthPt: 1728,
1201
+ screenHeightPt: 1117,
1202
+ });
1203
+
1204
+ const xScale = Number(result.content.match(/image_x \* ([\d.]+)/)?.[1]);
1205
+ const yScale = Number(result.content.match(/image_y \* ([\d.]+)/)?.[1]);
1206
+ expect(Math.round(675 * xScale)).toBe(1397);
1207
+ expect(Math.round(330 * yScale)).toBe(683);
1208
+ expect(result.content).toContain("screen points, not screenshot pixels");
1209
+ });
1210
+
1211
+ test.each([
1212
+ { capture_window_id: 12 },
1213
+ { captureWindowId: 12 },
1214
+ { captureDisplayId: 2 },
1215
+ ])(
1216
+ "does not scale a scoped screenshot by desktop dimensions: %j",
1217
+ (input) => {
1218
+ setup();
1219
+
1220
+ const result = proxy.formatObservation(
1221
+ {
1222
+ screenshot: "base64data",
1223
+ screenshotWidthPx: 800,
1224
+ screenshotHeightPx: 600,
1225
+ screenWidthPt: 1600,
1226
+ screenHeightPt: 1200,
1227
+ },
1228
+ undefined,
1229
+ false,
1230
+ { toolName: "computer_use_observe", input },
1231
+ );
1232
+
1233
+ expect(result.content).toContain("screenshot is capture-relative");
1234
+ expect(result.content).not.toContain("round(image_");
1235
+ },
1236
+ );
1237
+
1238
+ test.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
1239
+ "does not invent a mapping for an unavailable or invalid dimension: %s",
1240
+ (screenWidthPt) => {
1241
+ setup();
1242
+
1243
+ const result = proxy.formatObservation({
1244
+ screenshot: "base64data",
1245
+ screenshotWidthPx: 800,
1246
+ screenshotHeightPx: 600,
1247
+ screenWidthPt,
1248
+ screenHeightPt: 1200,
1249
+ });
1250
+
1251
+ expect(result.content).toContain("coordinate mapping is unavailable");
1252
+ expect(result.content).not.toContain("round(image_");
1253
+ },
1254
+ );
1255
+
1256
+ test("omits coordinate guidance when no screenshot accompanies dimensions", () => {
1257
+ setup();
1258
+
1259
+ const result = proxy.formatObservation({
1260
+ axTree: "Button [1]",
1261
+ screenshotWidthPx: 800,
1262
+ screenshotHeightPx: 600,
1263
+ screenWidthPt: 1600,
1264
+ screenHeightPt: 1200,
1265
+ });
1266
+
1267
+ expect(result.content).not.toContain("coordinate");
1189
1268
  });
1190
1269
 
1191
1270
  test("escapes </ax-tree> in AX tree content", () => {