@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/acp/session-snapshot.ts +260 -0
  21. package/src/apps/app-store.ts +6 -5
  22. package/src/browser/operations.ts +2 -1
  23. package/src/browser/types.ts +7 -0
  24. package/src/browser/virtual-desktop-target.ts +34 -0
  25. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  26. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  28. package/src/calls/progress-narration.ts +15 -2
  29. package/src/calls/voice-control-protocol.ts +34 -4
  30. package/src/calls/voice-session-bridge.ts +23 -0
  31. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  32. package/src/cli/commands/browser.help.ts +43 -0
  33. package/src/cli/commands/browser.ts +52 -8
  34. package/src/cli/commands/plugins.ts +16 -5
  35. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  36. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  37. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  38. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  39. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  40. package/src/cli/lib/bundled-marketplace.json +13 -0
  41. package/src/cli/lib/install-from-github.ts +17 -18
  42. package/src/cli/lib/install-from-platform.ts +14 -9
  43. package/src/cli/lib/list-installed-plugins.ts +21 -35
  44. package/src/cli/lib/plugin-details.ts +21 -8
  45. package/src/cli/lib/uninstall-plugin.ts +59 -1
  46. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  47. package/src/config/bundled-skills/acp/SKILL.md +1 -1
  48. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  49. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  50. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  51. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  52. package/src/config/feature-flag-registry.json +2 -2
  53. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  54. package/src/config/schemas/mcp.ts +9 -7
  55. package/src/config/schemas/monitoring.ts +9 -0
  56. package/src/config/schemas/voice.ts +2 -2
  57. package/src/config/skills.ts +13 -21
  58. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  59. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  60. package/src/daemon/conversation-client-surface.ts +26 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  62. package/src/daemon/conversation-tool-setup.ts +31 -29
  63. package/src/daemon/host-cu-proxy.ts +51 -26
  64. package/src/daemon/host-proxy-preactivation.ts +2 -0
  65. package/src/daemon/mcp-reload-service.ts +7 -0
  66. package/src/daemon/message-types/sync.ts +1 -0
  67. package/src/daemon/providers-setup.ts +4 -0
  68. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  69. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  70. package/src/desktop/desktop-automation-lease.ts +270 -0
  71. package/src/desktop/desktop-browser-client.test.ts +413 -0
  72. package/src/desktop/desktop-browser-client.ts +430 -0
  73. package/src/desktop/desktop-browser-cursor.ts +40 -0
  74. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  75. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  76. package/src/desktop/desktop-browser-operations.ts +130 -0
  77. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  78. package/src/desktop/desktop-chrome-session.ts +58 -8
  79. package/src/desktop/desktop-dependencies.test.ts +29 -0
  80. package/src/desktop/desktop-dependencies.ts +26 -5
  81. package/src/desktop/desktop-display.ts +9 -0
  82. package/src/desktop/desktop-panel-config.ts +14 -1
  83. package/src/desktop/desktop-session-manager.test.ts +86 -3
  84. package/src/desktop/desktop-session-manager.ts +144 -24
  85. package/src/desktop/desktop-stream-bridge.ts +1 -1
  86. package/src/desktop/virtual-desktop-feature.ts +18 -0
  87. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  88. package/src/i18n/__tests__/i18n.test.ts +9 -5
  89. package/src/i18n/messages.ts +13 -4
  90. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  91. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  92. package/src/ipc/assistant-server.ts +15 -1
  93. package/src/ipc/cli-client.ts +13 -5
  94. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  95. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  97. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  98. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  99. package/src/live-voice/live-voice-metrics.ts +9 -0
  100. package/src/live-voice/live-voice-session.ts +3 -0
  101. package/src/live-voice/protocol.ts +10 -1
  102. package/src/live-voice/session-controls.ts +28 -0
  103. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  104. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  105. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  106. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  107. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  108. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  109. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  110. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  111. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  112. package/src/mcp/client.ts +64 -16
  113. package/src/mcp/credential-target.ts +105 -0
  114. package/src/mcp/effective-config.ts +42 -15
  115. package/src/mcp/manager.ts +61 -8
  116. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  117. package/src/mcp/mcp-auth-state.ts +38 -6
  118. package/src/mcp/mcp-oauth-provider.ts +101 -33
  119. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  120. package/src/monitoring/mount-watch.ts +348 -0
  121. package/src/monitoring/worker.ts +9 -0
  122. package/src/notifications/__tests__/decision-engine.test.ts +175 -0
  123. package/src/notifications/decision-engine.ts +43 -8
  124. package/src/oauth/seed-providers.ts +11 -0
  125. package/src/onboarding/checkin-event.ts +2 -1
  126. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  127. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  128. package/src/plugins/external-plugin-loader.ts +49 -86
  129. package/src/plugins/installed-plugin-dirs.ts +6 -4
  130. package/src/plugins/mcp-servers.ts +9 -15
  131. package/src/plugins/mtime-cache.ts +2 -1
  132. package/src/plugins/user-loader.ts +2 -1
  133. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  134. package/src/providers/inference/endpoint-probe.ts +27 -12
  135. package/src/providers/openai/responses-provider.ts +9 -2
  136. package/src/providers/opencode/client.test.ts +167 -0
  137. package/src/providers/opencode/client.ts +73 -4
  138. package/src/runtime/AGENTS.md +4 -0
  139. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  140. package/src/runtime/http-server.ts +6 -6
  141. package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  143. package/src/runtime/routes/acp-routes.ts +17 -161
  144. package/src/runtime/routes/browser-context.ts +39 -0
  145. package/src/runtime/routes/browser-routes.ts +16 -47
  146. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  147. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  148. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  149. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  150. package/src/runtime/routes/plugins-routes.ts +14 -1
  151. package/src/runtime/sync/resource-sync-events.ts +4 -0
  152. package/src/tools/acp/status.test.ts +276 -21
  153. package/src/tools/acp/status.ts +98 -32
  154. package/src/tools/browser/browser-execution.ts +44 -13
  155. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  156. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  157. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  158. package/src/tools/computer-use/definitions.ts +2 -3
  159. package/src/util/abort-reasons.ts +12 -1
  160. package/src/util/plugin-manifest.ts +203 -0
  161. package/src/desktop/desktop-feature.test.ts +0 -18
  162. package/src/desktop/desktop-feature.ts +0 -19
@@ -0,0 +1,270 @@
1
+ import { getConfig } from "../config/loader.js";
2
+ import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
3
+ import { getLogger } from "../util/logger.js";
4
+ import { desktopDependencyInstaller } from "./desktop-dependencies.js";
5
+ import {
6
+ type DesktopSessionManager,
7
+ type DesktopViewer,
8
+ getDesktopSessionManager,
9
+ } from "./desktop-session-manager.js";
10
+ import { isVirtualDesktopEnabled } from "./virtual-desktop-feature.js";
11
+
12
+ const log = getLogger("desktop-automation-lease");
13
+ const IDLE_TIMEOUT_MS = 5 * 60_000;
14
+ const MAX_ACTIONS = 100;
15
+ type Owner = {
16
+ conversationId: string;
17
+ actorId: string;
18
+ abort: AbortController;
19
+ holder: DesktopViewer;
20
+ removeAbortListener: () => void;
21
+ actions: number;
22
+ lastActivity: number;
23
+ desktopLost: boolean;
24
+ };
25
+
26
+ export class DesktopAutomationLease {
27
+ private owner: Owner | null = null;
28
+ private generation = 0;
29
+ private tail: Promise<unknown> = Promise.resolve();
30
+ private watchdog: ReturnType<typeof setInterval> | undefined;
31
+
32
+ constructor(
33
+ private readonly deps: {
34
+ enabled: () => boolean;
35
+ ready: () => boolean;
36
+ ensureReady: (signal: AbortSignal) => Promise<void>;
37
+ manager: () => DesktopSessionManager;
38
+ } = {
39
+ enabled: () => isVirtualDesktopEnabled(getConfig()),
40
+ ready: () => desktopDependencyInstaller.getStatus().state === "ready",
41
+ ensureReady: (signal) => desktopDependencyInstaller.ensureReady(signal),
42
+ manager: getDesktopSessionManager,
43
+ },
44
+ ) {}
45
+
46
+ private exclusive<T>(run: () => Promise<T>): Promise<T> {
47
+ const next = this.tail.then(run, run);
48
+ this.tail = next.catch(() => {});
49
+ return next;
50
+ }
51
+
52
+ private assertAvailable(): void {
53
+ if (!this.deps.enabled()) {
54
+ throw new Error(
55
+ "Virtual desktop browser automation is available only on enabled platform-hosted assistants",
56
+ );
57
+ }
58
+ if (!this.deps.ready()) {
59
+ throw new Error(
60
+ "Open the Virtual desktop panel and wait for automatic installation to finish before using desktop browser automation",
61
+ );
62
+ }
63
+ }
64
+
65
+ private async release(): Promise<void> {
66
+ const owner = this.owner;
67
+ if (!owner) {
68
+ return;
69
+ }
70
+ owner.abort.abort();
71
+ owner.removeAbortListener();
72
+ clearInterval(this.watchdog);
73
+ this.watchdog = undefined;
74
+ if (!owner.desktopLost) {
75
+ await this.deps.manager().browser.release();
76
+ }
77
+ this.deps.manager().releaseAutomationSlot(owner.holder);
78
+ this.owner = null;
79
+ }
80
+
81
+ private cancel(owner: Owner): void {
82
+ if (this.owner !== owner) {
83
+ return;
84
+ }
85
+ this.generation += 1;
86
+ owner.abort.abort();
87
+ void this.exclusive(() => this.release()).catch((err) =>
88
+ log.warn({ err }, "Desktop browser session cleanup failed"),
89
+ );
90
+ }
91
+
92
+ private bindCancellation(owner: Owner, signal?: AbortSignal): void {
93
+ owner.removeAbortListener();
94
+ const cancel = () => this.cancel(owner);
95
+ signal?.addEventListener("abort", cancel, { once: true });
96
+ owner.removeAbortListener = () =>
97
+ signal?.removeEventListener("abort", cancel);
98
+ if (signal?.aborted) {
99
+ cancel();
100
+ }
101
+ }
102
+
103
+ private async claim(context: ToolContext): Promise<Owner> {
104
+ const holder: DesktopViewer = {
105
+ onDesktopLost: () => {
106
+ owner.desktopLost = true;
107
+ this.cancel(owner);
108
+ },
109
+ };
110
+ const owner: Owner = {
111
+ conversationId: context.conversationId,
112
+ actorId: context.sourceActorPrincipalId!,
113
+ abort: new AbortController(),
114
+ holder,
115
+ actions: 0,
116
+ lastActivity: Date.now(),
117
+ desktopLost: false,
118
+ removeAbortListener: () => {},
119
+ };
120
+ const slot = this.deps.manager().acquireAutomationSlot(holder);
121
+ if (!slot.ok) {
122
+ throw new Error("The desktop is busy or shutting down");
123
+ }
124
+ this.owner = owner;
125
+ this.bindCancellation(owner, context.signal);
126
+ const cancel = () => this.cancel(owner);
127
+ this.watchdog = setInterval(() => {
128
+ try {
129
+ if (
130
+ Date.now() - owner.lastActivity > IDLE_TIMEOUT_MS ||
131
+ !this.deps.enabled() ||
132
+ !this.deps.ready()
133
+ ) {
134
+ cancel();
135
+ }
136
+ } catch (err) {
137
+ log.warn({ err }, "Desktop browser session availability check failed");
138
+ cancel();
139
+ }
140
+ }, 1_000);
141
+ this.watchdog.unref?.();
142
+ try {
143
+ await this.deps.manager().ensureDesktopRunning();
144
+ owner.abort.signal.throwIfAborted();
145
+ this.assertAvailable();
146
+ await this.deps.manager().browser.release();
147
+ return owner;
148
+ } catch (err) {
149
+ await this.release().catch((cleanupError) =>
150
+ log.warn(
151
+ { err: cleanupError },
152
+ "Desktop browser session cleanup failed",
153
+ ),
154
+ );
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ runBrowser(
160
+ context: ToolContext,
161
+ operation: (signal: AbortSignal) => Promise<ToolExecutionResult>,
162
+ done = false,
163
+ ): Promise<ToolExecutionResult> {
164
+ const generation = this.generation;
165
+ return this.exclusive(async () => {
166
+ if (
167
+ context.trustClass !== "guardian" ||
168
+ !context.sourceActorPrincipalId ||
169
+ !context.conversationId
170
+ ) {
171
+ throw new Error(
172
+ "Desktop browser session requires an identified guardian conversation",
173
+ );
174
+ }
175
+ if (
176
+ this.owner &&
177
+ (this.owner.conversationId !== context.conversationId ||
178
+ this.owner.actorId !== context.sourceActorPrincipalId)
179
+ ) {
180
+ throw new Error("Another conversation is controlling the desktop");
181
+ }
182
+ if (done) {
183
+ await this.release();
184
+ return { content: "Desktop browser session released.", isError: false };
185
+ }
186
+ context.signal?.throwIfAborted();
187
+ try {
188
+ if (
189
+ !this.owner &&
190
+ generation === this.generation &&
191
+ this.deps.enabled() &&
192
+ !this.deps.ready()
193
+ ) {
194
+ const abort = new AbortController();
195
+ const signal = AbortSignal.any([
196
+ abort.signal,
197
+ ...(context.signal ? [context.signal] : []),
198
+ ]);
199
+ const timeout = setTimeout(
200
+ () =>
201
+ abort.abort(
202
+ new Error(
203
+ "Virtual desktop setup is taking too long. Check installation progress in the Virtual desktop panel. No browser action was performed.",
204
+ ),
205
+ ),
206
+ 8 * 60_000,
207
+ );
208
+ const watchdog = setInterval(() => {
209
+ if (!this.deps.enabled()) {
210
+ abort.abort(
211
+ new Error("Virtual desktop was disabled during setup"),
212
+ );
213
+ }
214
+ }, 1_000);
215
+ try {
216
+ await this.deps.ensureReady(signal);
217
+ signal.throwIfAborted();
218
+ } finally {
219
+ clearTimeout(timeout);
220
+ clearInterval(watchdog);
221
+ }
222
+ }
223
+ this.assertAvailable();
224
+ } catch (error) {
225
+ await this.release();
226
+ throw error;
227
+ }
228
+ if (generation !== this.generation) {
229
+ return {
230
+ content:
231
+ "Desktop browser session was interrupted. Take a fresh snapshot before continuing.",
232
+ isError: true,
233
+ };
234
+ }
235
+ if (this.owner) {
236
+ this.bindCancellation(this.owner, context.signal);
237
+ }
238
+ const owner = this.owner ?? (await this.claim(context));
239
+ const signal = context.signal
240
+ ? AbortSignal.any([context.signal, owner.abort.signal])
241
+ : owner.abort.signal;
242
+ owner.lastActivity = Date.now();
243
+ signal.throwIfAborted();
244
+ try {
245
+ this.assertAvailable();
246
+ if (++owner.actions > MAX_ACTIONS) {
247
+ throw new Error(
248
+ "Desktop action limit reached. Finish this session before continuing.",
249
+ );
250
+ }
251
+ const result = await operation(signal);
252
+ signal.throwIfAborted();
253
+ if (result.isError) {
254
+ await this.release();
255
+ }
256
+ return result;
257
+ } catch (err) {
258
+ await this.release().catch((cleanupError) =>
259
+ log.warn(
260
+ { err: cleanupError },
261
+ "Desktop browser session cleanup failed",
262
+ ),
263
+ );
264
+ throw err;
265
+ }
266
+ });
267
+ }
268
+ }
269
+
270
+ export const desktopAutomationLease = new DesktopAutomationLease();
@@ -0,0 +1,413 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+
3
+ import { waitFor } from "../__tests__/helpers/wait-for.js";
4
+ import { executeBrowserOperation } from "../browser/operations.js";
5
+ import { browserManager } from "../tools/browser/browser-manager.js";
6
+ import {
7
+ type CdpTransportEvent,
8
+ type CdpWsTransport,
9
+ CdpWsTransportError,
10
+ } from "../tools/browser/cdp-client/cdp-inspect/ws-transport.js";
11
+ import { DesktopBrowserClient } from "./desktop-browser-client.js";
12
+
13
+ function fixture() {
14
+ const calls: {
15
+ connection: number;
16
+ method: string;
17
+ params: Record<string, unknown>;
18
+ session?: string;
19
+ }[] = [];
20
+ const targets = [
21
+ {
22
+ targetId: "page-1",
23
+ type: "page",
24
+ url: "https://example.com",
25
+ title: "Example",
26
+ },
27
+ ];
28
+ const listeners: ((event: CdpTransportEvent) => void)[] = [];
29
+ let connections = 0;
30
+ let disconnect = () => {};
31
+ let reject:
32
+ | ((
33
+ method: string,
34
+ params: Record<string, unknown>,
35
+ ) => boolean | Promise<boolean>)
36
+ | undefined;
37
+ const browser = new DesktopBrowserClient(async () => {
38
+ const connection = ++connections;
39
+ let closed = false;
40
+ disconnect = () => {
41
+ closed = true;
42
+ };
43
+ return {
44
+ get closed() {
45
+ return closed;
46
+ },
47
+ async send<T>(
48
+ method: string,
49
+ params: Record<string, unknown> = {},
50
+ options?: { sessionId?: string; signal?: AbortSignal },
51
+ ): Promise<T> {
52
+ options?.signal?.throwIfAborted();
53
+ if (closed) {
54
+ throw new CdpWsTransportError("closed");
55
+ }
56
+ calls.push({ connection, method, params, session: options?.sessionId });
57
+ if (await reject?.(method, params)) {
58
+ throw new CdpWsTransportError("closed");
59
+ }
60
+ let result: unknown = {};
61
+ if (method === "Target.getTargets") {
62
+ result = { targetInfos: targets };
63
+ }
64
+ if (method === "Target.attachToTarget") {
65
+ result = { sessionId: `session-${connection}-${params.targetId}` };
66
+ }
67
+ if (method === "Target.createTarget") {
68
+ const targetId = `page-${targets.length + 1}`;
69
+ targets.push({
70
+ targetId,
71
+ type: "page",
72
+ url: "about:blank",
73
+ title: "New",
74
+ });
75
+ result = { targetId };
76
+ }
77
+ if (method === "Runtime.evaluate") {
78
+ result = { result: { value: "Example" } };
79
+ }
80
+ if (method === "DOM.getBoxModel") {
81
+ result = { model: { content: [10, 10, 110, 10, 110, 50, 10, 50] } };
82
+ }
83
+ if (method === "Accessibility.getFullAXTree") {
84
+ result = {
85
+ nodes: [
86
+ {
87
+ nodeId: "1",
88
+ backendDOMNodeId: 42,
89
+ role: { value: "button" },
90
+ name: { value: "Save" },
91
+ properties: [],
92
+ },
93
+ ],
94
+ };
95
+ }
96
+ return result as T;
97
+ },
98
+ addEventListener(listener) {
99
+ listeners.push(listener);
100
+ return () => {};
101
+ },
102
+ dispose() {
103
+ closed = true;
104
+ },
105
+ } satisfies CdpWsTransport;
106
+ });
107
+ return {
108
+ browser,
109
+ calls,
110
+ targets,
111
+ disconnect: () => disconnect(),
112
+ emit: (method: string, params = {}, sessionId?: string) =>
113
+ listeners.forEach((listener) => listener({ method, params, sessionId })),
114
+ fail: (predicate?: typeof reject) => {
115
+ reject = predicate;
116
+ },
117
+ };
118
+ }
119
+ const cleanups: (() => void)[] = [];
120
+ afterEach(() => {
121
+ for (const cleanup of cleanups.splice(0)) {
122
+ cleanup();
123
+ }
124
+ });
125
+
126
+ async function session() {
127
+ const f = fixture();
128
+ cleanups.push(() => f.browser.dispose());
129
+ const abort = new AbortController();
130
+ const cdp = await f.browser.client("conv-123", abort.signal);
131
+ return { ...f, cdp, abort };
132
+ }
133
+
134
+ test("shared snapshot and click use desktop element IDs without personal browser state", async () => {
135
+ const f = await session();
136
+ browserManager.storeSnapshotBackendNodeMap(
137
+ "conv-123",
138
+ new Map([["e1", 999]]),
139
+ );
140
+ cleanups.push(() => browserManager.clearSnapshotBackendNodeMap("conv-123"));
141
+ const context = {
142
+ workingDir: "/tmp",
143
+ trustClass: "guardian" as const,
144
+ conversationId: "conv-123",
145
+ cdpClient: f.cdp,
146
+ signal: f.abort.signal,
147
+ };
148
+ const snapshot = await executeBrowserOperation("snapshot", {}, context);
149
+ expect(snapshot.isError).toBe(false);
150
+ expect(snapshot.content).toContain("Save");
151
+ const element = /\b(e\d+)\b/.exec(snapshot.content)?.[1];
152
+ expect(element).toBeDefined();
153
+ const result = await executeBrowserOperation(
154
+ "click",
155
+ { element_id: element },
156
+ context,
157
+ );
158
+ expect(result.isError).toBe(false);
159
+ expect(
160
+ f.calls.find((call) => call.method === "DOM.getBoxModel")?.params
161
+ .backendNodeId,
162
+ ).toBe(42);
163
+ expect(browserManager.resolveSnapshotBackendNodeId("conv-123", "e1")).toBe(
164
+ 999,
165
+ );
166
+ const pressIndex = f.calls.findIndex(
167
+ (call) => call.params.type === "mousePressed",
168
+ );
169
+ expect(pressIndex).toBeGreaterThan(0);
170
+ expect(f.calls[pressIndex - 1]?.params.expression).toContain(
171
+ "data-vellum-desktop-cursor",
172
+ );
173
+ expect(
174
+ f.calls.filter((call) => call.params.type === "mousePressed"),
175
+ ).toHaveLength(1);
176
+ f.emit("DOM.documentUpdated");
177
+ const stale = await executeBrowserOperation(
178
+ "click",
179
+ { element_id: element },
180
+ context,
181
+ );
182
+ expect(stale.isError).toBe(true);
183
+ expect(
184
+ f.calls.filter((call) => call.params.type === "mousePressed"),
185
+ ).toHaveLength(1);
186
+ });
187
+
188
+ test("release invalidates borrowed clients and releases uncertain input through a fresh connection", async () => {
189
+ const f = await session();
190
+ f.fail(
191
+ (method, params) =>
192
+ method === "Input.dispatchKeyEvent" && params.type === "keyDown",
193
+ );
194
+ await expect(
195
+ f.cdp.send("Input.dispatchKeyEvent", {
196
+ type: "keyDown",
197
+ key: "Shift",
198
+ code: "ShiftLeft",
199
+ }),
200
+ ).rejects.toThrow("do not retry");
201
+ f.abort.abort();
202
+ f.fail();
203
+ await f.browser.release();
204
+ const releases = f.calls.filter((call) => call.params.type === "keyUp");
205
+ expect(releases).toHaveLength(1);
206
+ expect(releases[0]?.connection).toBe(2);
207
+ expect(releases[0]?.session).toBe("session-2-page-1");
208
+ expect(f.calls.filter((call) => call.params.type === "keyDown")).toHaveLength(
209
+ 1,
210
+ );
211
+ await expect(f.cdp.listTabs()).rejects.toBeDefined();
212
+ await expect(
213
+ f.cdp.send("Input.insertText", { text: "late" }),
214
+ ).rejects.toBeDefined();
215
+ });
216
+
217
+ test("failed cleanup retains held input for a later attempt", async () => {
218
+ const f = await session();
219
+ await f.cdp.send("Input.dispatchMouseEvent", {
220
+ type: "mousePressed",
221
+ button: "left",
222
+ x: 10,
223
+ y: 20,
224
+ });
225
+ f.fail((_method, params) => params.type === "mouseReleased");
226
+ await expect(f.browser.release()).rejects.toBeDefined();
227
+ f.fail();
228
+ await f.browser.release();
229
+ expect(
230
+ f.calls
231
+ .filter((call) => call.params.type === "mouseReleased")
232
+ .map((call) => call.connection),
233
+ ).toEqual([2, 3]);
234
+ });
235
+
236
+ test("detachment preserves uncertain key state until target cleanup", async () => {
237
+ const f = await session();
238
+ await f.cdp.send("Input.dispatchKeyEvent", {
239
+ type: "rawKeyDown",
240
+ key: "Alt",
241
+ code: "AltLeft",
242
+ });
243
+ f.emit("Target.detachedFromTarget", { sessionId: "session-1-page-1" });
244
+ await f.browser.release();
245
+ expect(f.calls.filter((call) => call.params.type === "keyUp")).toHaveLength(
246
+ 1,
247
+ );
248
+ });
249
+
250
+ test("tab changes clear element references and aliases are not reused across leases", async () => {
251
+ const f = await session();
252
+ const first = (await f.cdp.listTabs())[0]!.tabId!;
253
+ browserManager.storeSnapshotBackendNodeMap(
254
+ f.cdp.conversationId,
255
+ new Map([["e1", 42]]),
256
+ );
257
+ const created = await f.cdp.send<{ tabId: number }>("Vellum.createTab");
258
+ expect(
259
+ browserManager.resolveSnapshotBackendNodeId(f.cdp.conversationId, "e1"),
260
+ ).toBeNull();
261
+ await f.cdp.selectTab(first);
262
+ expect((await f.cdp.listTabs()).find((tab) => tab.active)?.tabId).toBe(first);
263
+ f.targets.splice(0, 1);
264
+ f.emit("Target.detachedFromTarget", { sessionId: "session-1-page-1" });
265
+ await expect(
266
+ f.cdp.send("Input.insertText", { text: "wrong page" }),
267
+ ).rejects.toThrow("tab closed");
268
+ expect(f.calls.some((call) => call.params.text === "wrong page")).toBe(false);
269
+ await f.browser.release();
270
+ const next = await f.browser.client("conv-456", new AbortController().signal);
271
+ expect(
272
+ (await next.listTabs()).every((tab) => tab.tabId! > created.tabId),
273
+ ).toBe(true);
274
+ });
275
+
276
+ test("navigation during cursor animation prevents input on the replacement page", async () => {
277
+ const f = await session();
278
+ f.fail((method, params) => {
279
+ if (
280
+ method === "Runtime.evaluate" &&
281
+ String(params.expression).includes("data-vellum-desktop-cursor")
282
+ ) {
283
+ f.emit("Page.frameNavigated");
284
+ }
285
+ return false;
286
+ });
287
+ await expect(
288
+ f.cdp.send("Input.dispatchMouseEvent", {
289
+ type: "mousePressed",
290
+ x: 50,
291
+ y: 60,
292
+ button: "left",
293
+ }),
294
+ ).rejects.toThrow("page changed");
295
+ expect(f.calls.some((call) => call.params.type === "mousePressed")).toBe(
296
+ false,
297
+ );
298
+ });
299
+
300
+ test("navigation restores the cursor in its tab without replaying mouse input", async () => {
301
+ const f = await session();
302
+ await f.cdp.send("Input.dispatchMouseEvent", {
303
+ type: "mouseMoved",
304
+ x: 50,
305
+ y: 60,
306
+ });
307
+ f.calls.length = 0;
308
+ f.emit("Page.domContentEventFired", {}, "session-1-page-2");
309
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
310
+ await Bun.sleep(0);
311
+ expect(f.calls).toHaveLength(1);
312
+ expect(f.calls[0]).toMatchObject({
313
+ method: "Runtime.evaluate",
314
+ session: "session-1-page-1",
315
+ });
316
+ expect(f.calls[0]?.params.expression).toContain("translate(50px, 60px)");
317
+ });
318
+
319
+ test("keyboard input restores an existing cursor without synthesizing mouse input", async () => {
320
+ const f = await session();
321
+ await f.cdp.send("Input.dispatchMouseEvent", {
322
+ type: "mouseMoved",
323
+ x: 50,
324
+ y: 60,
325
+ });
326
+ f.calls.length = 0;
327
+ await f.cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter" });
328
+ expect(f.calls.map((call) => call.method)).toEqual([
329
+ "Input.dispatchKeyEvent",
330
+ "Runtime.evaluate",
331
+ ]);
332
+ expect(f.calls[1]?.params.expression).toContain("translate(50px, 60px)");
333
+ });
334
+
335
+ test("release suppresses pending cursor restoration and clears it before detaching", async () => {
336
+ const f = await session();
337
+ await f.cdp.send("Input.dispatchMouseEvent", {
338
+ type: "mouseMoved",
339
+ x: 50,
340
+ y: 60,
341
+ });
342
+ f.calls.length = 0;
343
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
344
+ await f.browser.release();
345
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
346
+ await Bun.sleep(0);
347
+ const expressions = f.calls.filter(
348
+ (call) => call.method === "Runtime.evaluate",
349
+ );
350
+ expect(expressions).toHaveLength(1);
351
+ expect(expressions[0]?.params.expression).toContain("?.remove()");
352
+ });
353
+
354
+ test("release waits for an in-flight cursor restoration before removing it", async () => {
355
+ const f = await session();
356
+ await f.cdp.send("Input.dispatchMouseEvent", {
357
+ type: "mouseMoved",
358
+ x: 50,
359
+ y: 60,
360
+ });
361
+ let finish!: (reject: boolean) => void;
362
+ const restoring = new Promise<boolean>((resolve) => {
363
+ finish = resolve;
364
+ });
365
+ let started = false;
366
+ f.fail((method, params) => {
367
+ if (
368
+ method === "Runtime.evaluate" &&
369
+ String(params.expression).includes("translate(")
370
+ ) {
371
+ started = true;
372
+ return restoring;
373
+ }
374
+ return false;
375
+ });
376
+ f.emit("Page.domContentEventFired", {}, "session-1-page-1");
377
+ await waitFor(() => started);
378
+ const released = f.browser.release();
379
+ try {
380
+ await Bun.sleep(0);
381
+ expect(f.calls.some((call) => call.connection === 2)).toBe(false);
382
+ } finally {
383
+ finish(false);
384
+ await released;
385
+ }
386
+ expect(f.calls.at(-1)?.params.expression).toContain("?.remove()");
387
+ });
388
+
389
+ test("closed connections reconnect after cleanup without retrying input or reusing old clients", async () => {
390
+ const f = await session();
391
+ await f.cdp.send("Input.dispatchKeyEvent", {
392
+ type: "keyDown",
393
+ key: "Shift",
394
+ code: "ShiftLeft",
395
+ });
396
+ let previous = f.cdp;
397
+ for (let round = 0; round < 2; round++) {
398
+ f.disconnect();
399
+ const current = await f.browser.client("conv-123", f.abort.signal);
400
+ await expect(previous.listTabs()).rejects.toThrow("session expired");
401
+ await current.send("Runtime.evaluate", { expression: "document.title" });
402
+ expect(f.calls.at(-1)?.connection).toBe(3 + round * 2);
403
+ previous = current;
404
+ }
405
+ expect(f.calls.filter((call) => call.params.type === "keyDown")).toHaveLength(
406
+ 1,
407
+ );
408
+ expect(
409
+ f.calls
410
+ .filter((call) => call.params.type === "keyUp")
411
+ .map((call) => call.connection),
412
+ ).toEqual([2]);
413
+ });