@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa

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 (295) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +26 -3
  3. package/docs/automatic-done.md +32 -0
  4. package/docs/desktop-browser-cli.md +3 -2
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
  10. package/node_modules/@vellumai/environments/src/shell.ts +22 -1
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  15. package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
  17. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  18. package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  21. package/openapi.yaml +270 -10
  22. package/package.json +1 -1
  23. package/src/__tests__/agent-loop.test.ts +76 -0
  24. package/src/__tests__/anthropic-provider.test.ts +2 -0
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
  26. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  27. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  28. package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
  29. package/src/__tests__/conversation-evictor.test.ts +54 -0
  30. package/src/__tests__/conversation-pairing.test.ts +112 -0
  31. package/src/__tests__/document-append-idempotency.test.ts +13 -0
  32. package/src/__tests__/document-create-dedupe.test.ts +13 -0
  33. package/src/__tests__/document-find-replace.test.ts +13 -0
  34. package/src/__tests__/document-revision-history.test.ts +390 -0
  35. package/src/__tests__/document-revision.test.ts +425 -0
  36. package/src/__tests__/document-tool-security.test.ts +13 -0
  37. package/src/__tests__/document-update-default-surface.test.ts +13 -0
  38. package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
  39. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  40. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  41. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  42. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  43. package/src/__tests__/host-shell-tool.test.ts +6 -2
  44. package/src/__tests__/identity-routes.test.ts +4 -3
  45. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  46. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  47. package/src/__tests__/llm-schema.test.ts +1 -1
  48. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  49. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  50. package/src/__tests__/openai-provider.test.ts +38 -11
  51. package/src/__tests__/pricing.test.ts +11 -0
  52. package/src/__tests__/public-ingress-urls.test.ts +26 -0
  53. package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
  54. package/src/__tests__/skill-execute-input.test.ts +11 -0
  55. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  56. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  57. package/src/__tests__/usage-attribution.test.ts +31 -0
  58. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  59. package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
  60. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  61. package/src/acp/agent-process.ts +6 -1
  62. package/src/acp/client-handler.ts +6 -1
  63. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  64. package/src/agent/loop.ts +161 -17
  65. package/src/api/events/document-editor-show.ts +5 -0
  66. package/src/api/events/document-editor-update.ts +10 -0
  67. package/src/api/events/tool-result.ts +1 -0
  68. package/src/calls/__tests__/call-controller.test.ts +273 -1
  69. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  70. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
  72. package/src/calls/call-constants.ts +10 -0
  73. package/src/calls/call-controller.ts +359 -25
  74. package/src/calls/channel-admission-reader.ts +15 -9
  75. package/src/calls/media-stream-server.ts +18 -0
  76. package/src/calls/media-stream-stt-session.ts +30 -9
  77. package/src/calls/twilio-provider.ts +14 -31
  78. package/src/calls/voice-session-bridge.ts +18 -1
  79. package/src/cli/commands/browser.ts +1 -2
  80. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  81. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
  82. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  83. package/src/cli/lib/bundled-marketplace.json +54 -2
  84. package/src/cli/lib/inspect-plugin.ts +15 -15
  85. package/src/cli/lib/list-installed-plugins.ts +29 -28
  86. package/src/cli/lib/plugin-artifact.ts +74 -63
  87. package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
  88. package/src/cli/lib/plugin-details.ts +27 -42
  89. package/src/cli/lib/plugin-marketplace.ts +6 -8
  90. package/src/cli/lib/plugin-surfaces.ts +8 -7
  91. package/src/cli.ts +1 -3
  92. package/src/config/__tests__/chat-settings.test.ts +121 -0
  93. package/src/config/assistant-feature-flags.ts +28 -23
  94. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  95. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  96. package/src/config/chat-settings.ts +100 -0
  97. package/src/config/default-profile-catalog.ts +1 -1
  98. package/src/config/feature-flag-cache.ts +7 -25
  99. package/src/config/feature-flag-registry.json +9 -9
  100. package/src/config/loader.ts +5 -1
  101. package/src/config/profile-text-generation.ts +25 -21
  102. package/src/config/schemas/conversations.ts +14 -0
  103. package/src/config/schemas/llm.ts +1 -0
  104. package/src/config/schemas/notifications.ts +8 -1
  105. package/src/contacts/gateway-channel-read.ts +1 -1
  106. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  107. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  108. package/src/conversations/auto-archive-activity.ts +61 -0
  109. package/src/conversations/auto-archive.ts +201 -0
  110. package/src/daemon/computer-use-observation.test.ts +81 -0
  111. package/src/daemon/computer-use-observation.ts +23 -0
  112. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  113. package/src/daemon/conversation-agent-loop.ts +14 -6
  114. package/src/daemon/conversation-evictor.ts +50 -16
  115. package/src/daemon/conversation-messaging.ts +4 -0
  116. package/src/daemon/conversation-process.ts +15 -0
  117. package/src/daemon/conversation-queue-manager.ts +2 -0
  118. package/src/daemon/conversation.ts +2 -0
  119. package/src/daemon/doordash-steps.ts +1 -3
  120. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  121. package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
  122. package/src/daemon/handlers/config-a2a.ts +75 -2
  123. package/src/daemon/handlers/shared.ts +13 -2
  124. package/src/daemon/host-cu-proxy.ts +34 -3
  125. package/src/daemon/lifecycle.ts +15 -2
  126. package/src/daemon/process-message.ts +5 -0
  127. package/src/daemon/shutdown-handlers.ts +3 -0
  128. package/src/daemon/turn-finalization.ts +4 -0
  129. package/src/desktop/desktop-browser-endpoint.ts +11 -2
  130. package/src/desktop/desktop-chrome-policy.test.ts +1 -0
  131. package/src/desktop/desktop-chrome-policy.ts +7 -1
  132. package/src/desktop/desktop-computer-use.test.ts +14 -4
  133. package/src/desktop/desktop-session-manager.ts +5 -3
  134. package/src/documents/document-revisions-store.ts +153 -0
  135. package/src/documents/document-store.ts +471 -133
  136. package/src/followups/followup-store.ts +0 -4
  137. package/src/i18n/__tests__/i18n.test.ts +25 -0
  138. package/src/i18n/assistant-text.ts +18 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/messages.ts +11 -0
  141. package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
  142. package/src/inbound/oauth-callback-url.ts +30 -1
  143. package/src/inbound/public-ingress-urls.ts +12 -2
  144. package/src/ipc/gateway-client.test.ts +40 -0
  145. package/src/ipc/gateway-client.ts +12 -29
  146. package/src/ipc/gateway-flag-listener.ts +7 -10
  147. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
  148. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  149. package/src/live-voice/__tests__/protocol.test.ts +77 -0
  150. package/src/live-voice/__tests__/share-targets.test.ts +163 -0
  151. package/src/live-voice/live-voice-session.ts +23 -6
  152. package/src/live-voice/protocol.ts +22 -0
  153. package/src/live-voice/share-targets.ts +225 -0
  154. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
  155. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
  156. package/src/mcp/client.ts +1 -19
  157. package/src/mcp/mcp-oauth-provider.ts +66 -49
  158. package/src/media/openrouter-image-service.ts +39 -22
  159. package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
  160. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
  161. package/src/monitoring/control.ts +2 -1
  162. package/src/monitoring/oom-inheritance-sweep.ts +96 -0
  163. package/src/monitoring/oom-kill-reporter.ts +469 -0
  164. package/src/monitoring/resource-sampler.ts +30 -0
  165. package/src/notifications/README.md +53 -4
  166. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  167. package/src/notifications/__tests__/broadcaster.test.ts +558 -6
  168. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  169. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  170. package/src/notifications/adapters/platform.ts +14 -0
  171. package/src/notifications/assistant-initiated-thread.ts +101 -0
  172. package/src/notifications/broadcaster.ts +94 -16
  173. package/src/notifications/chat-reply-policy.ts +11 -0
  174. package/src/notifications/completion-policy.ts +12 -2
  175. package/src/notifications/conversation-pairing.ts +15 -70
  176. package/src/notifications/emit-signal.ts +34 -3
  177. package/src/notifications/types.ts +2 -0
  178. package/src/oauth/connect-orchestrator.ts +1 -2
  179. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  180. package/src/persistence/conversation-auto-archive.ts +170 -0
  181. package/src/persistence/conversation-crud.ts +2 -1
  182. package/src/persistence/conversation-queries.ts +15 -8
  183. package/src/persistence/embeddings/embed.test.ts +96 -0
  184. package/src/persistence/embeddings/embed.ts +20 -2
  185. package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
  186. package/src/persistence/embeddings/embedding-gemini.ts +39 -1
  187. package/src/persistence/embeddings/embedding-local.ts +6 -0
  188. package/src/persistence/embeddings/qdrant-manager.ts +5 -0
  189. package/src/persistence/job-utils.ts +10 -0
  190. package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
  191. package/src/persistence/migrations/384-add-document-revision.ts +21 -0
  192. package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
  193. package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
  194. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  195. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  196. package/src/persistence/schema/conversations.ts +1 -0
  197. package/src/persistence/schema/documents.ts +20 -0
  198. package/src/persistence/steps.ts +6 -0
  199. package/src/platform/managed-speech.ts +42 -46
  200. package/src/plugin-api/vision-support.test.ts +21 -21
  201. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
  202. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  203. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  204. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  205. package/src/providers/anthropic/client.ts +1 -1
  206. package/src/providers/fireworks/client.test.ts +168 -0
  207. package/src/providers/fireworks/client.ts +24 -0
  208. package/src/providers/inference/adapter-factory.ts +6 -0
  209. package/src/providers/ionet/client.test.ts +143 -0
  210. package/src/providers/ionet/client.ts +37 -0
  211. package/src/providers/model-catalog.ts +141 -84
  212. package/src/providers/model-intents.ts +4 -4
  213. package/src/providers/openai/chat-completions-provider.ts +33 -2
  214. package/src/providers/retry.ts +56 -4
  215. package/src/providers/search-provider-catalog.ts +10 -0
  216. package/src/providers/types.ts +10 -2
  217. package/src/providers/unparseable-tool-args.ts +29 -5
  218. package/src/providers/usage-tracking.ts +5 -3
  219. package/src/runtime/assistant-event-hub.ts +23 -0
  220. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  221. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  222. package/src/runtime/channel-reply-delivery.ts +2 -0
  223. package/src/runtime/channel-retry-sweep.ts +1 -2
  224. package/src/runtime/http-types.ts +3 -0
  225. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  226. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  227. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  228. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  229. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  230. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  231. package/src/runtime/pending-interactions.ts +20 -2
  232. package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
  233. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
  234. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  235. package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
  236. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
  237. package/src/runtime/routes/contact-routes.ts +2 -2
  238. package/src/runtime/routes/conversation-list-routes.ts +10 -2
  239. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  240. package/src/runtime/routes/conversation-routes.ts +7 -0
  241. package/src/runtime/routes/documents-routes.ts +185 -47
  242. package/src/runtime/routes/events-routes.ts +1 -3
  243. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  244. package/src/runtime/routes/host-bash-routes.ts +1 -0
  245. package/src/runtime/routes/host-browser-routes.ts +15 -0
  246. package/src/runtime/routes/host-cu-routes.ts +4 -1
  247. package/src/runtime/routes/host-file-routes.ts +1 -0
  248. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  249. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  250. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  251. package/src/runtime/routes/identity-routes.ts +3 -3
  252. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  253. package/src/runtime/routes/integrations/a2a.ts +4 -1
  254. package/src/runtime/routes/migration-routes.ts +3 -1
  255. package/src/runtime/routes/notification-routes.ts +1 -2
  256. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  257. package/src/runtime/routes/user-route-dispatcher.ts +5 -1
  258. package/src/runtime/verification-templates.ts +1 -2
  259. package/src/sequence/analytics.ts +0 -4
  260. package/src/sequence/engine.ts +1 -11
  261. package/src/sequence/guardrails.ts +1 -6
  262. package/src/sequence/importer.ts +0 -8
  263. package/src/sequence/store.ts +0 -10
  264. package/src/signals/user-message.ts +0 -6
  265. package/src/skills/catalog-install.ts +52 -23
  266. package/src/tools/browser/browser-execution.ts +20 -23
  267. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
  268. package/src/tools/document/document-tool.ts +13 -11
  269. package/src/tools/executor.ts +1 -2
  270. package/src/tools/host-terminal/host-shell.ts +4 -2
  271. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  272. package/src/tools/network/web-fetch.ts +2 -4
  273. package/src/tools/network/web-search.ts +239 -12
  274. package/src/tools/shared/filesystem/format-diff.ts +1 -2
  275. package/src/tools/shared/shell-output.test.ts +42 -0
  276. package/src/tools/shared/shell-output.ts +27 -4
  277. package/src/tools/skills/execute.ts +10 -7
  278. package/src/tools/terminal/shell.ts +3 -3
  279. package/src/tools/tool-approval-handler.ts +9 -1
  280. package/src/tools/ui-surface/visual-validation.ts +1 -2
  281. package/src/usage/attribution.ts +18 -1
  282. package/src/util/__tests__/oom-priority.test.ts +55 -0
  283. package/src/util/__tests__/worker-memory.test.ts +16 -0
  284. package/src/util/debounce.ts +0 -4
  285. package/src/util/oom-priority.ts +79 -0
  286. package/src/util/platform.ts +0 -2
  287. package/src/util/plugin-manifest.ts +5 -7
  288. package/src/util/pricing.ts +25 -0
  289. package/src/util/worker-memory.ts +9 -3
  290. package/src/util/worker-process.ts +8 -0
  291. package/src/workspace/byok-default-profile-ensure.ts +11 -0
  292. package/src/workspace/hatched-date.ts +11 -4
  293. package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
  294. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  295. package/src/workspace/migrations/registry.ts +4 -0
@@ -1,5 +1,26 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { saveRawConfig } from "../config/loader.js";
4
+ import type { NotificationSignal } from "../notifications/signal.js";
5
+ import type { NotificationChannel } from "../notifications/types.js";
6
+
7
+ let assistantInitiatedCandidate = false;
8
+ const resolveAssistantInitiatedThreadMock = mock(
9
+ (signal: NotificationSignal) => ({
10
+ isCandidate: assistantInitiatedCandidate,
11
+ vellumSignal: assistantInitiatedCandidate
12
+ ? {
13
+ ...signal,
14
+ requiresConversation: true,
15
+ conversationMetadata: { source: "assistant_initiated" },
16
+ }
17
+ : signal,
18
+ }),
19
+ );
20
+ mock.module("../notifications/assistant-initiated-thread.js", () => ({
21
+ resolveAssistantInitiatedThread: resolveAssistantInitiatedThreadMock,
22
+ }));
23
+
3
24
  const evaluateSignalMock = mock();
4
25
  const enforceRoutingIntentMock = mock();
5
26
  const updateDecisionMock = mock();
@@ -8,6 +29,14 @@ const createEventMock = mock();
8
29
  const setEventDedupeKeyMock = mock();
9
30
  const dispatchDecisionMock = mock();
10
31
  const isPlatformClientConfiguredMock = mock();
32
+ const writeHomeFeedItemForSignalMock = mock(
33
+ async (..._args: unknown[]) => null,
34
+ );
35
+
36
+ mock.module("../notifications/home-feed-side-effect.js", () => ({
37
+ writeHomeFeedItemForSignal: (...args: unknown[]) =>
38
+ writeHomeFeedItemForSignalMock(...args),
39
+ }));
11
40
 
12
41
  mock.module("../channels/config.js", () => ({
13
42
  getDeliverableChannels: () => ["vellum", "telegram"],
@@ -101,6 +130,10 @@ import {
101
130
  } from "../notifications/emit-signal.js";
102
131
 
103
132
  beforeEach(() => {
133
+ saveRawConfig({});
134
+ assistantInitiatedCandidate = false;
135
+ resolveAssistantInitiatedThreadMock.mockClear();
136
+ writeHomeFeedItemForSignalMock.mockClear();
104
137
  evaluateSignalMock.mockReset();
105
138
  enforceRoutingIntentMock.mockReset();
106
139
  updateDecisionMock.mockReset();
@@ -121,6 +154,104 @@ beforeEach(() => {
121
154
  enforceRoutingIntentMock.mockImplementation((decision: unknown) => decision);
122
155
  });
123
156
 
157
+ describe("emitNotificationSignal chat reply preference", () => {
158
+ test.each([
159
+ [false, false],
160
+ [true, true],
161
+ [false, true],
162
+ [true, false],
163
+ ])(
164
+ "preserves routing candidates when the setting changes %s -> %s",
165
+ async (initial, enabled) => {
166
+ saveRawConfig({ notifications: { newMessageEnabled: initial } });
167
+ const selectedChannels: NotificationChannel[] = [
168
+ "vellum",
169
+ "platform",
170
+ "telegram",
171
+ ];
172
+ const decision = {
173
+ shouldNotify: true,
174
+ selectedChannels,
175
+ reasoningSummary: "Selected reply delivery",
176
+ renderedCopy: {
177
+ vellum: { title: "Answer", body: "The result is ready." },
178
+ },
179
+ dedupeKey: "reply-1",
180
+ confidence: 1,
181
+ fallbackUsed: false,
182
+ persistedDecisionId: "decision-1",
183
+ };
184
+ evaluateSignalMock.mockResolvedValue(decision);
185
+ enforceRoutingIntentMock.mockReturnValue(decision);
186
+ runDeterministicChecksMock.mockImplementation(async () => {
187
+ await Promise.resolve();
188
+ saveRawConfig({ notifications: { newMessageEnabled: enabled } });
189
+ return { passed: true };
190
+ });
191
+ const result = await emitNotificationSignal({
192
+ sourceEventName: "chat.assistant_reply",
193
+ sourceChannel: "vellum",
194
+ sourceContextId: "conv-1",
195
+ attentionHints: {
196
+ urgency: "high",
197
+ requiresAction: false,
198
+ isAsyncBackground: false,
199
+ visibleInSourceNow: false,
200
+ },
201
+ routingIntent: "all_channels",
202
+ });
203
+ expect(runDeterministicChecksMock.mock.calls[0]?.[1]).toMatchObject({
204
+ selectedChannels: decision.selectedChannels,
205
+ });
206
+ expect(result.selectedChannels).toEqual(selectedChannels);
207
+ expect(result.dispatched).toBe(true);
208
+ expect(dispatchDecisionMock.mock.calls[0]?.[1]).toMatchObject({
209
+ shouldNotify: true,
210
+ selectedChannels,
211
+ });
212
+ expect(writeHomeFeedItemForSignalMock).toHaveBeenCalledTimes(1);
213
+ expect(writeHomeFeedItemForSignalMock.mock.calls[0]?.[1]).toMatchObject({
214
+ shouldNotify: true,
215
+ selectedChannels,
216
+ });
217
+ expect(updateDecisionMock).not.toHaveBeenCalled();
218
+ },
219
+ );
220
+
221
+ test.each([
222
+ "schedule.result",
223
+ "guardian.question",
224
+ "ingress.access_request",
225
+ "activity.failed",
226
+ "chat.assistant_reply.extra",
227
+ ])("preserves platform selection for %s", async (sourceEventName) => {
228
+ saveRawConfig({ notifications: { newMessageEnabled: false } });
229
+ evaluateSignalMock.mockResolvedValue({
230
+ shouldNotify: true,
231
+ selectedChannels: ["vellum", "platform"],
232
+ reasoningSummary: "Urgent delivery",
233
+ renderedCopy: { vellum: { title: "Alert", body: "Action required." } },
234
+ dedupeKey: "alert-1",
235
+ confidence: 1,
236
+ fallbackUsed: false,
237
+ persistedDecisionId: "decision-1",
238
+ });
239
+ const result = await emitNotificationSignal({
240
+ sourceEventName,
241
+ sourceChannel: "vellum",
242
+ sourceContextId: "conv-1",
243
+ attentionHints: {
244
+ urgency: "critical",
245
+ requiresAction: true,
246
+ isAsyncBackground: false,
247
+ visibleInSourceNow: false,
248
+ },
249
+ });
250
+ expect(result.selectedChannels).toEqual(["vellum", "platform"]);
251
+ expect(updateDecisionMock).not.toHaveBeenCalled();
252
+ });
253
+ });
254
+
124
255
  describe("emitNotificationSignal routing intent re-persistence", () => {
125
256
  test("re-persists selectedChannels/reasoningSummary when enforcement changes the decision", async () => {
126
257
  const preDecision = {
@@ -909,3 +1040,142 @@ describe("classifyNotificationReceipt", () => {
909
1040
  ).toBe("unknown");
910
1041
  });
911
1042
  });
1043
+
1044
+ describe("emitNotificationSignal From me push defaults", () => {
1045
+ const params = {
1046
+ sourceEventName: "assistant.share",
1047
+ sourceChannel: "assistant_tool" as const,
1048
+ sourceContextId: "conv-background",
1049
+ attentionHints: {
1050
+ urgency: "low" as const,
1051
+ requiresAction: false,
1052
+ isAsyncBackground: true,
1053
+ visibleInSourceNow: false,
1054
+ },
1055
+ };
1056
+ const decision = (selectedChannels: NotificationChannel[] = ["vellum"]) => ({
1057
+ shouldNotify: true,
1058
+ selectedChannels,
1059
+ reasoningSummary: "Assistant-authored share",
1060
+ renderedCopy: { vellum: { title: "An update", body: "A useful result." } },
1061
+ dedupeKey: "share-1",
1062
+ confidence: 1,
1063
+ fallbackUsed: false,
1064
+ persistedDecisionId: "decision-share",
1065
+ });
1066
+
1067
+ beforeEach(() => {
1068
+ assistantInitiatedCandidate = true;
1069
+ evaluateSignalMock.mockResolvedValue(decision());
1070
+ });
1071
+
1072
+ test("adds configured platform after routing and persists the final decision", async () => {
1073
+ const result = await emitNotificationSignal(params);
1074
+ expect(result.selectedChannels).toEqual(["vellum", "platform"]);
1075
+ expect(resolveAssistantInitiatedThreadMock).toHaveBeenCalledTimes(1);
1076
+ expect(updateDecisionMock).toHaveBeenCalledWith(
1077
+ "decision-share",
1078
+ expect.objectContaining({
1079
+ selectedChannels: ["vellum", "platform"],
1080
+ }),
1081
+ );
1082
+ expect(dispatchDecisionMock.mock.calls[0]?.[0]).toMatchObject({
1083
+ attentionHints: { urgency: "low" },
1084
+ });
1085
+ expect(dispatchDecisionMock.mock.calls[0]?.[3]).toMatchObject({
1086
+ assistantInitiatedThread: {
1087
+ platformAdded: true,
1088
+ resolution: {
1089
+ isCandidate: true,
1090
+ vellumSignal: {
1091
+ requiresConversation: true,
1092
+ conversationMetadata: { source: "assistant_initiated" },
1093
+ },
1094
+ },
1095
+ },
1096
+ });
1097
+ });
1098
+
1099
+ test.each([
1100
+ "quiet",
1101
+ "unconfigured",
1102
+ "noncandidate",
1103
+ "suppressed",
1104
+ "single_channel",
1105
+ "external_only",
1106
+ "vellum_allowlist",
1107
+ ])("respects %s policy without adding platform", async (scenario) => {
1108
+ assistantInitiatedCandidate = scenario !== "noncandidate";
1109
+ isPlatformClientConfiguredMock.mockResolvedValue(
1110
+ scenario !== "unconfigured",
1111
+ );
1112
+ evaluateSignalMock.mockResolvedValue({
1113
+ ...decision(scenario === "external_only" ? ["telegram"] : ["vellum"]),
1114
+ shouldNotify: scenario !== "suppressed",
1115
+ });
1116
+ const result = await emitNotificationSignal({
1117
+ ...params,
1118
+ routingIntent:
1119
+ scenario === "single_channel" ? "single_channel" : undefined,
1120
+ contextPayload: {
1121
+ quiet: scenario === "quiet",
1122
+ ...(scenario === "vellum_allowlist"
1123
+ ? { channelAllowlist: ["vellum"] }
1124
+ : {}),
1125
+ },
1126
+ });
1127
+ expect(result.selectedChannels).not.toContain("platform");
1128
+ expect(dispatchDecisionMock.mock.calls[0]?.[3]).toMatchObject({
1129
+ assistantInitiatedThread: { platformAdded: false },
1130
+ });
1131
+ });
1132
+
1133
+ test("keeps existing platform and external selections independent of this feature", async () => {
1134
+ evaluateSignalMock.mockResolvedValue(
1135
+ decision(["vellum", "platform", "telegram"]),
1136
+ );
1137
+ const result = await emitNotificationSignal({
1138
+ ...params,
1139
+ contextPayload: { quiet: true },
1140
+ });
1141
+ expect(result.selectedChannels).toEqual(["vellum", "platform", "telegram"]);
1142
+ expect(isPlatformClientConfiguredMock).not.toHaveBeenCalled();
1143
+ expect(dispatchDecisionMock.mock.calls[0]?.[3]).toMatchObject({
1144
+ assistantInitiatedThread: { platformAdded: false },
1145
+ });
1146
+ });
1147
+
1148
+ test("probes an unbound platform once across urgency and From me policy", async () => {
1149
+ isPlatformClientConfiguredMock.mockResolvedValue(false);
1150
+ const result = await emitNotificationSignal({
1151
+ ...params,
1152
+ attentionHints: { ...params.attentionHints, urgency: "high" },
1153
+ });
1154
+ expect(result.selectedChannels).toEqual(["vellum"]);
1155
+ expect(isPlatformClientConfiguredMock).toHaveBeenCalledTimes(1);
1156
+ });
1157
+
1158
+ test("preserves preferred external selections while adding the platform default", async () => {
1159
+ evaluateSignalMock.mockResolvedValue(decision(["vellum", "telegram"]));
1160
+ const result = await emitNotificationSignal(params);
1161
+ expect(result.selectedChannels).toEqual(["vellum", "telegram", "platform"]);
1162
+ });
1163
+
1164
+ test.each(["deduplicated", "source_active"])(
1165
+ "does not resolve candidates for %s signals",
1166
+ async (scenario) => {
1167
+ if (scenario === "deduplicated") {
1168
+ createEventMock.mockReturnValue(null);
1169
+ }
1170
+ await emitNotificationSignal({
1171
+ ...params,
1172
+ attentionHints: {
1173
+ ...params.attentionHints,
1174
+ visibleInSourceNow: scenario === "source_active",
1175
+ },
1176
+ });
1177
+ expect(resolveAssistantInitiatedThreadMock).not.toHaveBeenCalled();
1178
+ expect(dispatchDecisionMock).not.toHaveBeenCalled();
1179
+ },
1180
+ );
1181
+ });
@@ -26,11 +26,12 @@ mock.module("../ipc/socket-path.js", () => ({
26
26
  // ---------------------------------------------------------------------------
27
27
  let refreshCallCount = 0;
28
28
  let refreshReturnsLoaded = true;
29
+ let pendingRefresh: Promise<boolean> | undefined;
29
30
 
30
31
  mock.module("../config/assistant-feature-flags.js", () => ({
31
32
  refreshOverridesFromGateway: async () => {
32
33
  refreshCallCount++;
33
- return refreshReturnsLoaded;
34
+ return pendingRefresh ?? refreshReturnsLoaded;
34
35
  },
35
36
  initFeatureFlagOverrides: async () => true,
36
37
  clearFeatureFlagOverridesCache: () => {},
@@ -136,6 +137,7 @@ describe("gateway-flag-listener", () => {
136
137
  mkdirSync(testRoot, { recursive: true });
137
138
  refreshCallCount = 0;
138
139
  refreshReturnsLoaded = true;
140
+ pendingRefresh = undefined;
139
141
  publishedTagSets = [];
140
142
  reconcileCallCount = 0;
141
143
  reconcileReturns = false;
@@ -177,7 +179,7 @@ describe("gateway-flag-listener", () => {
177
179
  expect(refreshCallCount).toBe(2);
178
180
  });
179
181
 
180
- test("broadcasts feature-flags sync_changed when flags change", async () => {
182
+ test("broadcasts feature-flags sync_changed after connect and flag changes", async () => {
181
183
  await new Promise<void>((resolve) => {
182
184
  testServer.server.listen(socketPath, resolve);
183
185
  });
@@ -186,19 +188,59 @@ describe("gateway-flag-listener", () => {
186
188
  await testServer.waitForClient();
187
189
  await new Promise((r) => setTimeout(r, 100));
188
190
 
189
- // Connect refresh should not broadcast — only an actual change does.
190
- expect(publishedTagSets.length).toBe(0);
191
+ expect(publishedTagSets.length).toBe(1);
191
192
 
192
193
  testServer.emit("feature_flags_changed");
193
194
  await new Promise((r) => setTimeout(r, 100));
194
195
 
195
- expect(publishedTagSets.length).toBe(1);
196
- expect(publishedTagSets[0]).toEqual([
196
+ expect(publishedTagSets.length).toBe(2);
197
+ expect(publishedTagSets[1]).toEqual([
197
198
  "feature-flags:client",
198
199
  "feature-flags:assistant",
199
200
  ]);
200
201
  });
201
202
 
203
+ test("defers invalidation until the flag-change refresh settles", async () => {
204
+ await new Promise<void>((resolve) => {
205
+ testServer.server.listen(socketPath, resolve);
206
+ });
207
+ startGatewayFlagListener();
208
+ await testServer.waitForClient();
209
+ await new Promise((resolve) => setTimeout(resolve, 100));
210
+ publishedTagSets = [];
211
+ const refresh = Promise.withResolvers<boolean>();
212
+ pendingRefresh = refresh.promise;
213
+ testServer.emit("feature_flags_changed");
214
+ await new Promise((resolve) => setTimeout(resolve, 100));
215
+ expect(refreshCallCount).toBe(2);
216
+ expect(publishedTagSets).toEqual([]);
217
+ expect(reconcileCallCount).toBe(1);
218
+ refresh.resolve(true);
219
+ await new Promise((resolve) => setTimeout(resolve, 0));
220
+ expect(publishedTagSets).toEqual([
221
+ ["feature-flags:client", "feature-flags:assistant"],
222
+ ]);
223
+ expect(reconcileCallCount).toBe(2);
224
+ });
225
+
226
+ test("defers connection invalidation until the refreshed cache is available", async () => {
227
+ const refresh = Promise.withResolvers<boolean>();
228
+ pendingRefresh = refresh.promise;
229
+ await new Promise<void>((resolve) => {
230
+ testServer.server.listen(socketPath, resolve);
231
+ });
232
+ startGatewayFlagListener();
233
+ await testServer.waitForClient();
234
+ await new Promise((resolve) => setTimeout(resolve, 100));
235
+ expect(refreshCallCount).toBe(1);
236
+ expect(publishedTagSets).toEqual([]);
237
+ refresh.resolve(true);
238
+ await new Promise((resolve) => setTimeout(resolve, 0));
239
+ expect(publishedTagSets).toEqual([
240
+ ["feature-flags:client", "feature-flags:assistant"],
241
+ ]);
242
+ });
243
+
202
244
  test("reconciles flag-gated profiles and broadcasts config_changed when the profile set changes", async () => {
203
245
  reconcileReturns = true;
204
246
  await new Promise<void>((resolve) => {
@@ -251,12 +293,22 @@ describe("gateway-flag-listener", () => {
251
293
  // The profile reconcile/broadcast are gated on flags actually loading.
252
294
  expect(reconcileCallCount).toBe(0);
253
295
  expect(configChangedCount).toBe(0);
296
+ expect(publishedTagSets).toEqual([]);
254
297
 
255
298
  testServer.emit("feature_flags_changed");
256
299
  await new Promise((r) => setTimeout(r, 100));
257
300
 
258
301
  expect(reconcileCallCount).toBe(0);
259
302
  expect(configChangedCount).toBe(0);
303
+ expect(publishedTagSets).toEqual([]);
304
+ refreshReturnsLoaded = true;
305
+ testServer.emit("feature_flags_changed");
306
+ await new Promise((resolve) => setTimeout(resolve, 100));
307
+ expect(reconcileCallCount).toBe(1);
308
+ expect(configChangedCount).toBe(1);
309
+ expect(publishedTagSets).toEqual([
310
+ ["feature-flags:client", "feature-flags:assistant"],
311
+ ]);
260
312
  });
261
313
 
262
314
  test("reconciles profiles when the refresh reports flags loaded", async () => {
@@ -318,6 +370,7 @@ describe("gateway-flag-listener", () => {
318
370
  await new Promise((r) => setTimeout(r, 100));
319
371
  const countAfterReconnect = refreshCallCount;
320
372
  expect(countAfterReconnect).toBeGreaterThan(0);
373
+ expect(publishedTagSets).toHaveLength(countAfterReconnect);
321
374
 
322
375
  const payload = JSON.stringify({ event: "feature_flags_changed" }) + "\n";
323
376
  secondClient!.write(payload);
@@ -233,6 +233,7 @@ describe("executeBrowserNavigate", () => {
233
233
  resetMockPage();
234
234
  resetCdp();
235
235
  __resetPinnedTabsForTests();
236
+ mockExtensionAvailable = false;
236
237
  });
237
238
 
238
239
  // ── Input validation ───────────────────────────────────────────
@@ -606,6 +607,13 @@ describe("executeBrowserNavigate", () => {
606
607
  const routeCallsBefore = mockPage.route.mock.calls.length;
607
608
  const unrouteCallsBefore = mockPage.unroute.mock.calls.length;
608
609
 
610
+ cdpSendHandler = (method, params) => {
611
+ if (method === "Vellum.createTab") {
612
+ return { tabId: "42" };
613
+ }
614
+ return defaultCdpHandler(method, params);
615
+ };
616
+
609
617
  const result = await executeBrowserNavigate(
610
618
  { url: "https://example.com/page" },
611
619
  extensionCtx,
@@ -643,6 +651,9 @@ describe("executeBrowserNavigate", () => {
643
651
  // navigateAndWait returns a private final URL (simulating a
644
652
  // server-side redirect).
645
653
  cdpSendHandler = (method, params) => {
654
+ if (method === "Vellum.createTab") {
655
+ return { tabId: "42" };
656
+ }
646
657
  if (method === "Page.navigate") {
647
658
  return { frameId: "f1" };
648
659
  }
@@ -745,13 +756,7 @@ describe("executeBrowserNavigate", () => {
745
756
  expect(cdpDisposed).toBe(true);
746
757
  });
747
758
 
748
- test("new_tab: true on extension path with no tabId in response clears live session and still continues", async () => {
749
- // Defensive: dispatcher returns success but no tabId. The
750
- // executor logs a warn, resets the live cdp session to undefined
751
- // (so the follow-on Page.navigate routes to the active tab
752
- // instead of any stale pin the cdp instance was constructed
753
- // with), and proceeds. The navigate still runs (degraded
754
- // behaviour but not a hard failure).
759
+ test("new_tab: true on extension path with no tabId in response fails closed", async () => {
755
760
  parseUrlResult = new URL("https://example.com/page");
756
761
  mockExtensionAvailable = true;
757
762
 
@@ -767,25 +772,13 @@ describe("executeBrowserNavigate", () => {
767
772
  { ...ctx },
768
773
  );
769
774
 
770
- expect(result.isError).toBe(false);
771
- // No new pin was set, BUT the live session was reset to undefined
772
- // so the follow-on Page.navigate falls back to active-tab routing
773
- // instead of any stale pin the cdp instance held at construction.
774
- expect(cdpSetSessionIdCalls).toEqual([undefined]);
775
- // Page.navigate still ran.
776
- expect(cdpSendCalls.some((c) => c.method === "Page.navigate")).toBe(true);
775
+ expect(result.isError).toBe(true);
776
+ expect(String(result.content)).toContain("returned no tabId");
777
+ expect(cdpSetSessionIdCalls).toEqual([]);
778
+ expect(cdpSendCalls.some((c) => c.method === "Page.navigate")).toBe(false);
777
779
  });
778
780
 
779
- test("new_tab: true with no tabId in response clears a pre-existing stale pin AND live session (regression)", async () => {
780
- // Regression for the Codex round-2 findings (P2 + round-3 P1):
781
- // when Vellum.createTab returns a malformed response with no
782
- // tabId, the executor falls back to active-tab routing — but
783
- // (a) the pin store still held the stale pin (round-2 P2 fix
784
- // added clearPinnedTab), and (b) the LIVE cdp instance was
785
- // already constructed with that stale cdpSessionId, so the
786
- // follow-on Page.navigate would still route to the dead tab
787
- // unless we reset the session on the cdp instance too (round-3
788
- // P1 fix added cdp.setCdpSessionId(undefined)).
781
+ test("new_tab: true with no tabId in response clears a pre-existing stale pin and fails closed", async () => {
789
782
  const { setPinnedTab, getPinnedTab } =
790
783
  await import("../tools/browser/pinned-tabs.js");
791
784
  setPinnedTab(ctx.conversationId, "stale-pinned-tab-id");
@@ -806,12 +799,9 @@ describe("executeBrowserNavigate", () => {
806
799
  { ...ctx },
807
800
  );
808
801
 
809
- expect(result.isError).toBe(false);
810
- // (a) Pin store cleared.
802
+ expect(result.isError).toBe(true);
811
803
  expect(getPinnedTab(ctx.conversationId)).toBeUndefined();
812
- // (b) Live cdp session reset (the fake records every
813
- // setCdpSessionId arg; expect exactly one call with undefined).
814
- expect(cdpSetSessionIdCalls).toEqual([undefined]);
804
+ expect(cdpSetSessionIdCalls).toEqual([]);
815
805
  });
816
806
 
817
807
  test("new_tab: true on LOCAL path is a no-op (Playwright manages its own isolated browser)", async () => {
@@ -935,7 +925,7 @@ describe("executeBrowserNavigate", () => {
935
925
  false,
936
926
  );
937
927
  expect(getPinnedTab(ctx.conversationId)).toBeUndefined();
938
- expect(cdpSetSessionIdCalls).toEqual([undefined]);
928
+ expect(cdpSetSessionIdCalls).toEqual(["active"]);
939
929
  expect(cdpSendCalls.some((c) => c.method === "Page.navigate")).toBe(true);
940
930
  });
941
931
 
@@ -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", () => {
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
19
19
 
20
+ import sharp from "sharp";
21
+
20
22
  // ── Module mocks ─────────────────────────────────────────────────────────────
21
23
  // Must be registered before the host-cu-routes module is loaded.
22
24
 
@@ -487,7 +489,14 @@ describe("handleHostCuResult: request body validation", () => {
487
489
  expect(pendingStore.has(requestId)).toBe(true);
488
490
  });
489
491
 
490
- test("forwards a full observation payload unchanged", async () => {
492
+ test("forwards observation metadata with dimensions from the screenshot bytes", async () => {
493
+ const screenshot = (
494
+ await sharp({
495
+ create: { width: 835, height: 540, channels: 3, background: "white" },
496
+ })
497
+ .jpeg()
498
+ .toBuffer()
499
+ ).toString("base64");
491
500
  const requestId = "req-cu-full-observation";
492
501
  registerPending(requestId);
493
502
 
@@ -496,7 +505,7 @@ describe("handleHostCuResult: request body validation", () => {
496
505
  requestId,
497
506
  axTree: "Button [1]",
498
507
  axDiff: "+Button [1]",
499
- screenshot: "base64png",
508
+ screenshot,
500
509
  screenshotWidthPx: 1600,
501
510
  screenshotHeightPx: 1000,
502
511
  screenWidthPt: 800,
@@ -512,9 +521,9 @@ describe("handleHostCuResult: request body validation", () => {
512
521
  expect(cuResolveSpy[0].payload).toMatchObject({
513
522
  axTree: "Button [1]",
514
523
  axDiff: "+Button [1]",
515
- screenshot: "base64png",
516
- screenshotWidthPx: 1600,
517
- screenshotHeightPx: 1000,
524
+ screenshot,
525
+ screenshotWidthPx: 835,
526
+ screenshotHeightPx: 540,
518
527
  screenWidthPt: 800,
519
528
  screenHeightPt: 500,
520
529
  executionResult: "Clicked",
@@ -45,6 +45,8 @@ mock.module("../daemon/host-bash-proxy.js", () => ({
45
45
  },
46
46
  }));
47
47
 
48
+ import { buildShellInvocation } from "@vellumai/environments/shell";
49
+
48
50
  import { hostShellTool } from "../tools/host-terminal/host-shell.js";
49
51
  import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
50
52
 
@@ -139,7 +141,7 @@ describe("host_bash tool", () => {
139
141
  // Verify spawn was called with 'bash' directly — not 'bwrap' or 'sandbox-exec'
140
142
  expect(spawnCalls.length).toBe(1);
141
143
  expect(spawnCalls[0].command).toBe("bash");
142
- expect(spawnCalls[0].args).toEqual(["-c", "--", "echo hello"]);
144
+ expect(spawnCalls[0].args).toEqual(buildShellInvocation("echo hello").args);
143
145
  });
144
146
  });
145
147
 
@@ -205,7 +207,9 @@ describe("host_bash — baseline: no sandbox isolation", () => {
205
207
  expect(spawnCalls[0].command).toBe("bash");
206
208
  expect(spawnCalls[0].args[0]).toBe("-c");
207
209
  expect(spawnCalls[0].args[1]).toBe("--");
208
- expect(spawnCalls[0].args[2]).toBe("ls -la /tmp");
210
+ expect(spawnCalls[0].args[2]).toBe(
211
+ buildShellInvocation("ls -la /tmp").args[2],
212
+ );
209
213
  });
210
214
 
211
215
  test("host_bash always spawns plain bash without wrapCommand", async () => {