@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.3

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 (194) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/openapi.yaml +50 -4
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  10. package/src/__tests__/conversation-error.test.ts +4 -1
  11. package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
  12. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  13. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  14. package/src/__tests__/credential-routes.test.ts +38 -0
  15. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  16. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  17. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  18. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +185 -30
  20. package/src/__tests__/list-all-apps.test.ts +40 -8
  21. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  22. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  23. package/src/__tests__/mcp-health-check.test.ts +32 -32
  24. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  25. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  26. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  27. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  28. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  29. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  30. package/src/__tests__/skills.test.ts +10 -0
  31. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  32. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  33. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  34. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  35. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  36. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  37. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  38. package/src/acp/acp-claude-oauth.ts +328 -14
  39. package/src/acp/acp-credentials.ts +19 -0
  40. package/src/acp/claude-token-refresh.ts +150 -0
  41. package/src/acp/prepare-agent-env.ts +21 -6
  42. package/src/apps/app-store.ts +6 -5
  43. package/src/browser/operations.ts +2 -1
  44. package/src/browser/types.ts +7 -0
  45. package/src/browser/virtual-desktop-target.ts +34 -0
  46. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
  48. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
  49. package/src/calls/progress-narration.ts +15 -2
  50. package/src/calls/voice-control-protocol.ts +132 -0
  51. package/src/calls/voice-session-bridge.ts +56 -26
  52. package/src/calls/voice-triage-escalate.ts +3 -3
  53. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  54. package/src/cli/commands/browser.help.ts +43 -0
  55. package/src/cli/commands/browser.ts +52 -8
  56. package/src/cli/commands/oauth/status.ts +5 -0
  57. package/src/cli/commands/plugins.ts +16 -5
  58. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  59. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  60. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  62. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  63. package/src/cli/lib/bundled-marketplace.json +13 -0
  64. package/src/cli/lib/install-from-github.ts +17 -18
  65. package/src/cli/lib/install-from-platform.ts +14 -9
  66. package/src/cli/lib/list-installed-plugins.ts +21 -35
  67. package/src/cli/lib/plugin-details.ts +21 -8
  68. package/src/cli/lib/uninstall-plugin.ts +59 -1
  69. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  70. package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
  71. package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
  72. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  73. package/src/config/feature-flag-registry.json +11 -3
  74. package/src/config/loader.ts +1 -0
  75. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  76. package/src/config/schemas/mcp.ts +9 -7
  77. package/src/config/schemas/monitoring.ts +9 -0
  78. package/src/config/schemas/services.ts +10 -0
  79. package/src/config/schemas/voice.ts +2 -2
  80. package/src/config/skills.ts +13 -21
  81. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  82. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  83. package/src/daemon/conversation-client-surface.ts +26 -0
  84. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  85. package/src/daemon/conversation-surfaces.ts +6 -1
  86. package/src/daemon/conversation-tool-setup.ts +37 -43
  87. package/src/daemon/host-cu-proxy.ts +94 -31
  88. package/src/daemon/host-proxy-preactivation.ts +2 -0
  89. package/src/daemon/mcp-reload-service.ts +7 -0
  90. package/src/daemon/message-types/sync.ts +1 -0
  91. package/src/daemon/providers-setup.ts +4 -0
  92. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  93. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  94. package/src/desktop/desktop-automation-lease.ts +270 -0
  95. package/src/desktop/desktop-browser-client.test.ts +413 -0
  96. package/src/desktop/desktop-browser-client.ts +430 -0
  97. package/src/desktop/desktop-browser-cursor.ts +40 -0
  98. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  99. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  100. package/src/desktop/desktop-browser-operations.ts +130 -0
  101. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  102. package/src/desktop/desktop-chrome-session.ts +58 -8
  103. package/src/desktop/desktop-dependencies.test.ts +29 -0
  104. package/src/desktop/desktop-dependencies.ts +26 -5
  105. package/src/desktop/desktop-display.ts +9 -0
  106. package/src/desktop/desktop-panel-config.ts +14 -1
  107. package/src/desktop/desktop-session-manager.test.ts +86 -3
  108. package/src/desktop/desktop-session-manager.ts +144 -24
  109. package/src/desktop/desktop-stream-bridge.ts +1 -1
  110. package/src/desktop/virtual-desktop-feature.ts +18 -0
  111. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  112. package/src/i18n/__tests__/i18n.test.ts +9 -5
  113. package/src/i18n/messages.ts +13 -4
  114. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  115. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  116. package/src/ipc/assistant-server.ts +15 -1
  117. package/src/ipc/cli-client.ts +13 -5
  118. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
  119. package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
  120. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  121. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  122. package/src/live-voice/__tests__/protocol.test.ts +47 -0
  123. package/src/live-voice/__tests__/session-controls.test.ts +113 -0
  124. package/src/live-voice/live-voice-metrics.ts +9 -0
  125. package/src/live-voice/live-voice-session.ts +88 -7
  126. package/src/live-voice/protocol.ts +69 -0
  127. package/src/live-voice/session-controls.ts +139 -0
  128. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  129. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  130. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  131. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  132. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  133. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  134. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  135. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  136. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  137. package/src/mcp/client.ts +64 -16
  138. package/src/mcp/credential-target.ts +105 -0
  139. package/src/mcp/effective-config.ts +42 -15
  140. package/src/mcp/manager.ts +61 -8
  141. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  142. package/src/mcp/mcp-auth-state.ts +38 -6
  143. package/src/mcp/mcp-oauth-provider.ts +101 -33
  144. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  145. package/src/monitoring/mount-watch.ts +348 -0
  146. package/src/monitoring/worker.ts +9 -0
  147. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  148. package/src/oauth/connection-resolver.test.ts +27 -0
  149. package/src/oauth/connection-resolver.ts +25 -1
  150. package/src/oauth/provider-serializer.ts +9 -0
  151. package/src/oauth/seed-providers.ts +121 -2
  152. package/src/onboarding/checkin-event.ts +2 -1
  153. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  154. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  155. package/src/plugins/external-plugin-loader.ts +49 -86
  156. package/src/plugins/installed-plugin-dirs.ts +6 -4
  157. package/src/plugins/mcp-servers.ts +9 -15
  158. package/src/plugins/mtime-cache.ts +2 -1
  159. package/src/plugins/user-loader.ts +2 -1
  160. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  161. package/src/providers/inference/endpoint-probe.ts +27 -12
  162. package/src/providers/openai/responses-provider.ts +9 -2
  163. package/src/providers/opencode/client.test.ts +167 -0
  164. package/src/providers/opencode/client.ts +73 -4
  165. package/src/runtime/AGENTS.md +4 -0
  166. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  167. package/src/runtime/http-server.ts +6 -6
  168. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  169. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  170. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  171. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  172. package/src/runtime/routes/app-management-routes.ts +17 -2
  173. package/src/runtime/routes/browser-context.ts +39 -0
  174. package/src/runtime/routes/browser-routes.ts +16 -47
  175. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  176. package/src/runtime/routes/credential-routes.ts +1 -4
  177. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  178. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  179. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  180. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  181. package/src/runtime/routes/oauth-providers.ts +7 -0
  182. package/src/runtime/routes/plugins-routes.ts +14 -1
  183. package/src/runtime/sync/resource-sync-events.ts +4 -0
  184. package/src/tools/browser/browser-execution.ts +44 -13
  185. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  186. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  187. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  188. package/src/tools/computer-use/definitions.ts +3 -4
  189. package/src/tools/ui-surface/channel-variants.ts +10 -59
  190. package/src/util/abort-reasons.ts +12 -1
  191. package/src/util/plugin-manifest.ts +203 -0
  192. package/src/watch/watch-retro.ts +6 -8
  193. package/src/desktop/desktop-feature.test.ts +0 -18
  194. package/src/desktop/desktop-feature.ts +0 -19
@@ -6,8 +6,19 @@
6
6
  * state or the daemon conversation store.
7
7
  */
8
8
 
9
- import { afterEach, describe, expect, mock, test } from "bun:test";
10
-
9
+ import {
10
+ afterAll,
11
+ afterEach,
12
+ describe,
13
+ expect,
14
+ mock,
15
+ spyOn,
16
+ test,
17
+ } from "bun:test";
18
+
19
+ import { desktopDependencyInstaller } from "../../desktop/desktop-dependencies.js";
20
+ import * as desktopFeature from "../../desktop/virtual-desktop-feature.js";
21
+ import { browserManager } from "../../tools/browser/browser-manager.js";
11
22
  import type { ToolExecutionResult } from "../../tools/types.js";
12
23
 
13
24
  // ---------------------------------------------------------------------------
@@ -31,7 +42,10 @@ let mockOperationCalls: Array<{
31
42
  let mockConversation: {
32
43
  trustContext?: { trustClass: string };
33
44
  transportInterface?: string;
45
+ currentTurnClientOs?: string;
46
+ clientOs?: string;
34
47
  getTurnActorPrincipalId?: () => string | undefined;
48
+ abortController?: AbortController;
35
49
  } | null = null;
36
50
 
37
51
  let mockFindConversationCalls: string[] = [];
@@ -66,6 +80,47 @@ mock.module("../../daemon/conversation-registry.js", () => ({
66
80
  },
67
81
  }));
68
82
 
83
+ let desktopEnabled = false;
84
+ let desktopReady = false;
85
+ let desktopFailure = false;
86
+ const enabledSpy = spyOn(
87
+ desktopFeature,
88
+ "isVirtualDesktopEnabled",
89
+ ).mockImplementation(() => desktopEnabled);
90
+ const readySpy = spyOn(
91
+ desktopDependencyInstaller,
92
+ "getStatus",
93
+ ).mockImplementation(() => ({ state: desktopReady ? "ready" : "required" }));
94
+ afterAll(() => {
95
+ enabledSpy.mockRestore();
96
+ readySpy.mockRestore();
97
+ });
98
+
99
+ let desktopContext: import("../../tools/types.js").ToolContext | undefined;
100
+ mock.module("../../desktop/desktop-browser-operations.js", () => ({
101
+ executeDesktopBrowserTabs: async (
102
+ _params: unknown,
103
+ context: import("../../tools/types.js").ToolContext,
104
+ ) => {
105
+ desktopContext = context;
106
+ return {
107
+ content: JSON.stringify({ ok: true, tabs: [{ tabId: 1 }] }),
108
+ isError: false,
109
+ };
110
+ },
111
+ executeDesktopBrowserOperation: async (
112
+ _operation: string,
113
+ _input: Record<string, unknown>,
114
+ context: import("../../tools/types.js").ToolContext,
115
+ ) => {
116
+ desktopContext = context;
117
+ return {
118
+ content: desktopFailure ? "Desktop is busy" : "desktop",
119
+ isError: desktopFailure,
120
+ };
121
+ },
122
+ }));
123
+
69
124
  // Import after mocking — now from the shared routes location
70
125
  const { ROUTES, browserCliConversationKey } =
71
126
  await import("../../runtime/routes/browser-routes.js");
@@ -92,6 +147,11 @@ function callHandler(
92
147
  // ---------------------------------------------------------------------------
93
148
 
94
149
  afterEach(() => {
150
+ desktopEnabled = false;
151
+ desktopReady = false;
152
+ desktopFailure = false;
153
+ desktopContext = undefined;
154
+ browserManager.clearPreferredBackendKind("conv-default-browser");
95
155
  mockOperationResult = { content: "ok", isError: false };
96
156
  mockOperationCalls = [];
97
157
  mockConversation = null;
@@ -422,3 +482,171 @@ describe("browser_execute route", () => {
422
482
  expect(mockOperationCalls[0].input).toEqual({});
423
483
  });
424
484
  });
485
+
486
+ test("desktop route preserves guardian ownership and live-turn cancellation", async () => {
487
+ const abortController = new AbortController();
488
+ mockConversation = {
489
+ trustContext: { trustClass: "guardian" },
490
+ getTurnActorPrincipalId: () => "user-123",
491
+ abortController,
492
+ };
493
+ const result = await callHandler(
494
+ { operation: "snapshot", desktop: true, conversationId: "conv-desktop" },
495
+ { "x-vellum-actor-principal-id": "user-other" },
496
+ );
497
+ expect(result).toMatchObject({ content: "desktop" });
498
+ expect(mockOperationCalls).toHaveLength(0);
499
+ expect(desktopContext).toMatchObject({
500
+ conversationId: "conv-desktop",
501
+ sourceActorPrincipalId: "user-123",
502
+ trustClass: "guardian",
503
+ });
504
+ expect(desktopContext?.signal?.aborted).toBe(false);
505
+ abortController.abort();
506
+ expect(desktopContext?.signal?.aborted).toBe(true);
507
+ });
508
+
509
+ function webConversation(clientOs = "web") {
510
+ desktopEnabled = true;
511
+ desktopReady = true;
512
+ mockConversation = {
513
+ trustContext: { trustClass: "guardian" },
514
+ transportInterface: "web",
515
+ currentTurnClientOs: clientOs,
516
+ getTurnActorPrincipalId: () => "user-123",
517
+ };
518
+ }
519
+
520
+ test("web browser commands default to the installed streamed Chrome", async () => {
521
+ webConversation();
522
+ const result = await callHandler({
523
+ operation: "navigate",
524
+ input: { url: "https://example.com" },
525
+ conversationId: "conv-default-browser",
526
+ });
527
+ expect(result).toMatchObject({ content: "desktop", isError: false });
528
+ expect(mockOperationCalls).toHaveLength(0);
529
+ expect(desktopContext?.clientOs).toBe("web");
530
+ });
531
+
532
+ test.each(["macos", "windows"])(
533
+ "%s renderer uses the personal browser despite its web transport",
534
+ async (clientOs) => {
535
+ webConversation(clientOs);
536
+ await callHandler({
537
+ operation: "snapshot",
538
+ conversationId: "conv-default-browser",
539
+ });
540
+ expect(mockOperationCalls).toHaveLength(1);
541
+ expect(desktopContext).toBeUndefined();
542
+ },
543
+ );
544
+
545
+ test("browser selection uses the active turn rather than a queued message's OS", async () => {
546
+ webConversation("macos");
547
+ mockConversation!.clientOs = "web";
548
+ await callHandler({
549
+ operation: "snapshot",
550
+ conversationId: "conv-default-browser",
551
+ });
552
+ expect(mockOperationCalls).toHaveLength(1);
553
+ expect(desktopContext).toBeUndefined();
554
+ });
555
+
556
+ test.each(["flag", "guardian", "actor"])(
557
+ "web preserves the existing browser when %s is unavailable",
558
+ async (missing) => {
559
+ webConversation();
560
+ if (missing === "flag") {
561
+ desktopEnabled = false;
562
+ }
563
+ if (missing === "guardian") {
564
+ mockConversation!.trustContext = { trustClass: "unknown" };
565
+ }
566
+ if (missing === "actor") {
567
+ mockConversation!.getTurnActorPrincipalId = () => undefined;
568
+ }
569
+ await callHandler({
570
+ operation: "snapshot",
571
+ conversationId: "conv-default-browser",
572
+ });
573
+ expect(mockOperationCalls).toHaveLength(1);
574
+ expect(desktopContext).toBeUndefined();
575
+ },
576
+ );
577
+
578
+ test.each([
579
+ { browser_mode: "local" },
580
+ { browser_mode: "playwright" },
581
+ { browser_mode: "extension" },
582
+ { browser_mode: "cdp-inspect" },
583
+ { target_client_id: "client-123" },
584
+ { use_active_tab: true },
585
+ ])("explicit browser choice %j wins over the web default", async (input) => {
586
+ webConversation();
587
+ await callHandler({
588
+ operation: "snapshot",
589
+ input,
590
+ conversationId: "conv-default-browser",
591
+ });
592
+ expect(mockOperationCalls).toHaveLength(1);
593
+ expect(desktopContext).toBeUndefined();
594
+ });
595
+
596
+ test("follow-up web commands retain an existing personal browser session", async () => {
597
+ webConversation();
598
+ browserManager.setPreferredBackendKind("conv-default-browser", "extension");
599
+ await callHandler({
600
+ operation: "snapshot",
601
+ conversationId: "conv-default-browser",
602
+ });
603
+ expect(mockOperationCalls).toHaveLength(1);
604
+ expect(desktopContext).toBeUndefined();
605
+ });
606
+
607
+ test("native clients can explicitly select the streamed browser", async () => {
608
+ webConversation("windows");
609
+ const result = await callHandler({
610
+ operation: "snapshot",
611
+ desktop: true,
612
+ conversationId: "conv-default-browser",
613
+ });
614
+ expect(result).toMatchObject({ content: "desktop" });
615
+ expect(desktopContext?.clientOs).toBe("windows");
616
+ expect(mockOperationCalls).toHaveLength(0);
617
+ });
618
+
619
+ test("web tab commands share the streamed browser default", async () => {
620
+ webConversation();
621
+ const { ROUTES } =
622
+ await import("../../runtime/routes/browser-tabs-routes.js");
623
+ const result = await ROUTES[0].handler({
624
+ body: { command: "list", conversationId: "conv-default-browser" },
625
+ });
626
+ expect(result).toEqual({ ok: true, tabs: [{ tabId: 1 }] });
627
+ expect(desktopContext?.clientOs).toBe("web");
628
+ });
629
+
630
+ test("a streamed browser failure does not switch to Playwright or personal Chrome", async () => {
631
+ webConversation();
632
+ desktopFailure = true;
633
+ const result = await callHandler({
634
+ operation: "click",
635
+ input: { selector: "#submit" },
636
+ conversationId: "conv-default-browser",
637
+ });
638
+ expect(result).toMatchObject({ isError: true, content: "Desktop is busy" });
639
+ expect(mockOperationCalls).toHaveLength(0);
640
+ });
641
+
642
+ test("first web browser use routes to virtual desktop setup before installation", async () => {
643
+ webConversation();
644
+ desktopReady = false;
645
+ await callHandler({
646
+ operation: "navigate",
647
+ input: { url: "https://example.com" },
648
+ conversationId: "conv-default-browser",
649
+ });
650
+ expect(desktopContext?.clientOs).toBe("web");
651
+ expect(mockOperationCalls).toHaveLength(0);
652
+ });
@@ -0,0 +1,54 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+
3
+ import { setDbReady } from "../../daemon/daemon-readiness.js";
4
+ import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
5
+ import { AssistantIpcServer } from "../assistant-server.js";
6
+ import { cliIpcCall } from "../cli-client.js";
7
+
8
+ let server: AssistantIpcServer | undefined;
9
+ afterEach(() => server?.stop());
10
+
11
+ for (const cancelOnDisconnect of [true, false]) {
12
+ test(`timed-out IPC request cancels its action only when opted in: ${cancelOnDisconnect}`, async () => {
13
+ setDbReady(true);
14
+ server = new AssistantIpcServer();
15
+ const methods = (
16
+ server as unknown as {
17
+ methods: Map<string, (args: RouteHandlerArgs) => Promise<unknown>>;
18
+ }
19
+ ).methods;
20
+ const setup = Promise.withResolvers<void>();
21
+ const completed = Promise.withResolvers<void>();
22
+ let signal: AbortSignal | undefined;
23
+ let actions = 0;
24
+ methods.set("test_browser_setup", async (args) => {
25
+ signal = args.abortSignal;
26
+ await setup.promise;
27
+ if (!signal?.aborted) {
28
+ actions++;
29
+ }
30
+ completed.resolve();
31
+ return { ok: true };
32
+ });
33
+ await server.start();
34
+ try {
35
+ const result = await cliIpcCall(
36
+ "test_browser_setup",
37
+ {},
38
+ {
39
+ timeoutMs: 100,
40
+ cancelOnDisconnect,
41
+ },
42
+ );
43
+ expect(result.timedOut).toBe(true);
44
+ expect(signal).toBeDefined();
45
+ await Bun.sleep(20);
46
+ setup.resolve();
47
+ await completed.promise;
48
+ expect(signal?.aborted).toBe(cancelOnDisconnect);
49
+ expect(actions).toBe(cancelOnDisconnect ? 0 : 1);
50
+ } finally {
51
+ setup.resolve();
52
+ }
53
+ });
54
+ }
@@ -78,6 +78,7 @@ const log = getLogger("assistant-ipc-server");
78
78
  // ---------------------------------------------------------------------------
79
79
 
80
80
  export type IpcRequest = {
81
+ cancelOnDisconnect?: boolean;
81
82
  id: string;
82
83
  method: string;
83
84
  params?: Record<string, unknown>;
@@ -404,6 +405,16 @@ export class AssistantIpcServer {
404
405
  this.abortControllers.set(req.id, abortController);
405
406
  }
406
407
 
408
+ const onDisconnect = () => abortController?.abort();
409
+ if (req.cancelOnDisconnect === true) {
410
+ socket.once("close", onDisconnect);
411
+ }
412
+ const removeDisconnectListener = () => {
413
+ if (req.cancelOnDisconnect === true) {
414
+ socket.off("close", onDisconnect);
415
+ }
416
+ };
417
+
407
418
  try {
408
419
  const handlerArgs = {
409
420
  ...injectLocalActorHeader(req.params),
@@ -434,14 +445,17 @@ export class AssistantIpcServer {
434
445
  reader,
435
446
  this.buildErrorResponse(req.id, err),
436
447
  );
437
- });
448
+ })
449
+ .finally(removeDisconnectListener);
438
450
  } else {
451
+ removeDisconnectListener();
439
452
  if (!isIpcStreamingResponse(result)) {
440
453
  this.abortControllers.delete(req.id);
441
454
  }
442
455
  this.sendResult(socket, reader, req.id, result);
443
456
  }
444
457
  } catch (err) {
458
+ removeDisconnectListener();
445
459
  this.abortControllers.delete(req.id);
446
460
  log.warn({ err, method: req.method }, "IPC handler error");
447
461
  this.sendResponse(socket, reader, this.buildErrorResponse(req.id, err));
@@ -60,9 +60,8 @@ export interface CliIpcCallResult<T = unknown> {
60
60
  errorDetails?: unknown;
61
61
  /**
62
62
  * Set when the call was abandoned because `timeoutMs` elapsed with no
63
- * response. Distinct from every other `ok: false` shape: the request WAS
64
- * delivered and the daemon may still be executing it — closing the client
65
- * socket does not abort the handler. Callers that would otherwise retry a
63
+ * response. The request was delivered and may still be executing unless
64
+ * `cancelOnDisconnect` was requested. Callers that would otherwise retry a
66
65
  * transport failure must not retry this one until the original can no
67
66
  * longer be in flight.
68
67
  */
@@ -79,7 +78,11 @@ export interface CliIpcCallResult<T = unknown> {
79
78
  export async function cliIpcCall<T = unknown>(
80
79
  method: string,
81
80
  params?: Record<string, unknown>,
82
- options?: { timeoutMs?: number; signal?: AbortSignal },
81
+ options?: {
82
+ timeoutMs?: number;
83
+ signal?: AbortSignal;
84
+ cancelOnDisconnect?: boolean;
85
+ },
83
86
  ): Promise<CliIpcCallResult<T>> {
84
87
  if (options?.signal?.aborted) {
85
88
  throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
@@ -180,7 +183,12 @@ export async function cliIpcCall<T = unknown>(
180
183
 
181
184
  socket.on("connect", () => {
182
185
  clearTimeout(connectTimer);
183
- writeMessage(socket, { id: reqId, method, params });
186
+ writeMessage(socket, {
187
+ id: reqId,
188
+ method,
189
+ params,
190
+ ...(opts?.cancelOnDisconnect ? { cancelOnDisconnect: true } : {}),
191
+ });
184
192
 
185
193
  callTimer = setTimeout(() => {
186
194
  log.debug(
@@ -980,3 +980,146 @@ describe("LiveVoiceSession room reveal", () => {
980
980
  expect(ttsTexts.join(" ")).not.toContain("ASK_GUARDIAN_APPROVAL");
981
981
  });
982
982
  });
983
+
984
+ describe("LiveVoiceSession spoken session controls", () => {
985
+ function createControlsHarness(
986
+ sessionControls?: LiveVoiceClientStartFrame["sessionControls"],
987
+ ) {
988
+ const { startVoiceTurn, getCallbacks } = createCapturingTurnStarter();
989
+ const { streamTtsAudio, ttsTexts } = createRecordingTtsStreamer();
990
+ const harness = createSessionHarness({
991
+ startVoiceTurn,
992
+ streamTtsAudio,
993
+ startFrame: {
994
+ ...START_FRAME,
995
+ ...(sessionControls ? { sessionControls } : {}),
996
+ },
997
+ });
998
+ return { ...harness, getCallbacks, ttsTexts };
999
+ }
1000
+
1001
+ test("a reply ending with [END_CALL] sends an end control after tts_done", async () => {
1002
+ const { frames, session, getCallbacks, ttsTexts } = createControlsHarness([
1003
+ "end",
1004
+ "mute",
1005
+ ]);
1006
+
1007
+ await startReleasedTurn(session, getCallbacks);
1008
+ emitTextDelta(getCallbacks, "Okay, talk soon. [END_CALL]");
1009
+ emitMessageComplete(getCallbacks);
1010
+ await waitFor(() =>
1011
+ frames.some((frame) => frame.type === "session_control"),
1012
+ );
1013
+
1014
+ const ttsDoneIndex = frames.findIndex((frame) => frame.type === "tts_done");
1015
+ const controlIndex = frames.findIndex(
1016
+ (frame) => frame.type === "session_control",
1017
+ );
1018
+ // The goodbye is heard before the call ends.
1019
+ expect(controlIndex).toBeGreaterThan(ttsDoneIndex);
1020
+ expect(frames[controlIndex]).toMatchObject({
1021
+ type: "session_control",
1022
+ turnId: "live-turn-1",
1023
+ action: "end",
1024
+ });
1025
+ expect(assistantDeltaTexts(frames).join("").trim()).toBe(
1026
+ "Okay, talk soon.",
1027
+ );
1028
+ expect(ttsTexts.join(" ")).not.toContain("[END_CALL]");
1029
+ });
1030
+
1031
+ test("a timed mute carries its duration", async () => {
1032
+ const { frames, session, getCallbacks } = createControlsHarness(["mute"]);
1033
+
1034
+ await startReleasedTurn(session, getCallbacks);
1035
+ emitTextDelta(getCallbacks, "Muting you for thirty seconds. [MUTE:30]");
1036
+ emitMessageComplete(getCallbacks);
1037
+ await waitFor(() =>
1038
+ frames.some((frame) => frame.type === "session_control"),
1039
+ );
1040
+
1041
+ expect(
1042
+ frames.find((frame) => frame.type === "session_control"),
1043
+ ).toMatchObject({ action: "mute", durationMs: 30_000 });
1044
+ });
1045
+
1046
+ test("a declared look sends its look control after the acknowledgement", async () => {
1047
+ const { frames, session, getCallbacks } = createControlsHarness([
1048
+ "look_screen",
1049
+ ]);
1050
+
1051
+ await startReleasedTurn(session, getCallbacks);
1052
+ emitTextDelta(
1053
+ getCallbacks,
1054
+ "Taking a look. What should I focus on? [LOOK:SCREEN]",
1055
+ );
1056
+ emitMessageComplete(getCallbacks);
1057
+ await waitFor(() =>
1058
+ frames.some((frame) => frame.type === "session_control"),
1059
+ );
1060
+
1061
+ expect(
1062
+ frames.find((frame) => frame.type === "session_control"),
1063
+ ).toMatchObject({ action: "look_screen" });
1064
+ // The marker is control, never caption text.
1065
+ expect(assistantDeltaTexts(frames).join("").includes("[LOOK")).toBe(false);
1066
+ });
1067
+
1068
+ test("a control the client did not declare is never sent", async () => {
1069
+ const { frames, session, getCallbacks } = createControlsHarness(["mute"]);
1070
+
1071
+ await startReleasedTurn(session, getCallbacks);
1072
+ emitTextDelta(getCallbacks, "Bye. [END_CALL]");
1073
+ emitMessageComplete(getCallbacks);
1074
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
1075
+ await flushAsyncCallbacks();
1076
+
1077
+ expect(frames.some((frame) => frame.type === "session_control")).toBe(
1078
+ false,
1079
+ );
1080
+ });
1081
+
1082
+ test("a marker mid-reply controls nothing", async () => {
1083
+ const { frames, session, getCallbacks } = createControlsHarness(["end"]);
1084
+
1085
+ await startReleasedTurn(session, getCallbacks);
1086
+ emitTextDelta(getCallbacks, "Say [END_CALL] and I would hang up, but no.");
1087
+ emitMessageComplete(getCallbacks);
1088
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
1089
+ await flushAsyncCallbacks();
1090
+
1091
+ expect(frames.some((frame) => frame.type === "session_control")).toBe(
1092
+ false,
1093
+ );
1094
+ });
1095
+
1096
+ // Talking over "okay, bye" means they are not leaving.
1097
+ test("an interrupted goodbye does not end the call", async () => {
1098
+ const { frames, session, getCallbacks } = createControlsHarness(["end"]);
1099
+
1100
+ await startReleasedTurn(session, getCallbacks);
1101
+ emitTextDelta(getCallbacks, "Okay, talk soon. [END_CALL]");
1102
+ await flushAsyncCallbacks();
1103
+ await session.handleClientFrame({ type: "interrupt" });
1104
+ emitMessageComplete(getCallbacks);
1105
+ await new Promise((resolve) => setTimeout(resolve, 20));
1106
+
1107
+ expect(frames.some((frame) => frame.type === "session_control")).toBe(
1108
+ false,
1109
+ );
1110
+ });
1111
+
1112
+ test("the prompt teaches only the declared controls", async () => {
1113
+ const { session, getCallbacks, startVoiceTurn } = createControlsHarness([
1114
+ "mute",
1115
+ ]);
1116
+
1117
+ await startReleasedTurn(session, getCallbacks);
1118
+ const prompt =
1119
+ (startVoiceTurn as ReturnType<typeof mock>).mock.calls[0]?.[0]
1120
+ ?.voiceControlPrompt ?? "";
1121
+
1122
+ expect(prompt).toContain("[MUTE]");
1123
+ expect(prompt).not.toContain("[END_CALL]");
1124
+ });
1125
+ });
@@ -253,14 +253,16 @@ describe("LiveVoiceSession archive and metrics events", () => {
253
253
  userMessageInterface: "macos",
254
254
  assistantMessageInterface: "macos",
255
255
  // Pins the full production control prompt for the FRONT-DOOR leg (the
256
- // first leg of every routed turn): the speech-first rule, and nothing
257
- // else. This leg is toolless, so both the screen-reveal and setup-flow
258
- // teachings are deliberately absent. It has nothing to show and no
259
- // connection to run, and its capability digest already routes anything
260
- // needing a tool to the escalated leg (see
261
- // live-voice-triage-escalate.test.ts).
256
+ // first leg of every routed turn): the speech-first rule and the session
257
+ // controls, and nothing else. This leg is toolless, so both the
258
+ // screen-reveal and setup-flow teachings are deliberately absent. It
259
+ // has nothing to show and no connection to run, and its capability
260
+ // digest already routes anything needing a tool to the escalated leg
261
+ // (see live-voice-triage-escalate.test.ts). The start frame declares no
262
+ // client controls, so only the session's own update cadence and the
263
+ // no-looks line are taught.
262
264
  voiceControlPrompt:
263
- "You are speaking in a local live voice session. Keep each reply to one to three spoken sentences. The caller is listening, not reading: lead with the answer, say it in the fewest words that are still complete, and stop. No preamble, no recap of the question, no closing offer of more. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links), bulleted or numbered lists, or emojis. Write plain conversational text only; if something is naturally a list, say it as one sentence. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. Reply in the language the caller is speaking; if they switch languages, switch with them. ",
265
+ 'You are speaking in a local live voice session. Keep each reply to one to three spoken sentences. The caller is listening, not reading: lead with the answer, say it in the fewest words that are still complete, and stop. No preamble, no recap of the question, no closing offer of more. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links), bulleted or numbered lists, or emojis. Write plain conversational text only; if something is naturally a list, say it as one sentence. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work, or a card with an image or a link instead of reading an address out); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. Reply in the language the caller is speaking; if they switch languages, switch with them. The user can also control this call by asking you. Only when they clearly ask:\n- This call cannot turn on a screen share or the camera. If they ask you to look at their screen or at something and you have no other way to see it, say so briefly instead of pretending to look.\n- To hear fewer spoken progress updates while you work (for example "don\'t give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with [UPDATES:FEWER]. If they later want regular updates back, confirm and end with [UPDATES:NORMAL].\nThe marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.',
264
266
  });
265
267
  callbacks?.assistant_text_delta?.(makeTextDelta("Hello there."));
266
268
  callbacks?.message_complete?.(makeMessageComplete());
@@ -927,3 +927,82 @@ describe("LiveVoiceSession progress narration", () => {
927
927
  emitMessageComplete(getCallbacks);
928
928
  });
929
929
  });
930
+
931
+ describe("LiveVoiceSession spoken update cadence", () => {
932
+ async function runTurnAskingFor(
933
+ session: LiveVoiceSession,
934
+ getCallbacks: () => VoiceTurnCallbacks | undefined,
935
+ frames: { type: string }[],
936
+ reply: string,
937
+ ): Promise<void> {
938
+ emitTextDelta(getCallbacks, reply);
939
+ emitMessageComplete(getCallbacks);
940
+ await waitFor(
941
+ () => frames.some((frame) => frame.type === "tts_done"),
942
+ "first turn never finished",
943
+ );
944
+ // Let the post-turn re-arm run before releasing the next turn.
945
+ await sleep(20);
946
+ const previous = getCallbacks();
947
+ // Typed, because a manual session's microphone re-arm is not what this
948
+ // exercises: the cadence is the session's, however the next turn arrives.
949
+ await session.handleClientFrame({ type: "text", text: "go ahead" });
950
+ await waitFor(() => getCallbacks() !== previous, "next turn never started");
951
+ }
952
+
953
+ test("after the user asks for fewer updates, tool activity no longer narrates", async () => {
954
+ const generateProgressText = mock(async () => GENERATED_NARRATION);
955
+ const { frames, session, getCallbacks, ttsTexts } = createProgressHarness({
956
+ frontModelConfig: progressConfig({ opsThreshold: 3, minGapMs: 10 }),
957
+ progressNarrator: makeProgressNarrator(generateProgressText),
958
+ });
959
+
960
+ await startReleasedTurn(session, getCallbacks);
961
+ await runTurnAskingFor(
962
+ session,
963
+ getCallbacks,
964
+ frames,
965
+ "Sure, I'll only check in now and then. [UPDATES:FEWER]",
966
+ );
967
+ emitToolStart(getCallbacks, "web_search", "tool-1");
968
+ emitToolResult(getCallbacks, "web_search", "tool-1");
969
+ emitToolStart(getCallbacks, "file_read", "tool-2");
970
+ emitToolResult(getCallbacks, "file_read", "tool-2");
971
+ emitToolStart(getCallbacks, "web_search", "tool-3");
972
+ await sleep(40);
973
+
974
+ expect(generateProgressText).not.toHaveBeenCalled();
975
+ expect(ttsTexts.join(" ")).not.toContain("[UPDATES");
976
+ emitMessageComplete(getCallbacks);
977
+ });
978
+
979
+ test("asking for updates back restores the normal cadence", async () => {
980
+ const generateProgressText = mock(async () => GENERATED_NARRATION);
981
+ const { frames, session, getCallbacks, ttsTexts } = createProgressHarness({
982
+ frontModelConfig: progressConfig({ opsThreshold: 3, minGapMs: 10 }),
983
+ progressNarrator: makeProgressNarrator(generateProgressText),
984
+ });
985
+
986
+ await startReleasedTurn(session, getCallbacks);
987
+ await runTurnAskingFor(
988
+ session,
989
+ getCallbacks,
990
+ frames,
991
+ "Okay, fewer updates. [UPDATES:FEWER]",
992
+ );
993
+ frames.length = 0;
994
+ await runTurnAskingFor(
995
+ session,
996
+ getCallbacks,
997
+ frames,
998
+ "Got it, I'll keep you posted. [UPDATES:NORMAL]",
999
+ );
1000
+ emitToolStart(getCallbacks, "web_search", "tool-1");
1001
+ emitToolStart(getCallbacks, "file_read", "tool-2");
1002
+ emitToolStart(getCallbacks, "web_search", "tool-3");
1003
+ await waitFor(() => ttsTexts.includes(GENERATED_NARRATION));
1004
+
1005
+ expect(generateProgressText).toHaveBeenCalledTimes(1);
1006
+ emitMessageComplete(getCallbacks);
1007
+ });
1008
+ });
@@ -350,6 +350,25 @@ describe("live-voice turn attribution", () => {
350
350
  expect(options.assistantMessageInterface).toBe("macos");
351
351
  });
352
352
 
353
+ test("a session opened from the macOS desktop client asks for the desktop skills", async () => {
354
+ const options = await captureTurnOptions({
355
+ ...START_FRAME,
356
+ client: "macos",
357
+ });
358
+
359
+ expect(options.macosDesktopSession).toBe(true);
360
+ });
361
+
362
+ test("a session from any other client does not ask for the desktop skills", async () => {
363
+ for (const startFrame of [{ ...START_FRAME, client: "ios" }, START_FRAME]) {
364
+ const options = await captureTurnOptions(
365
+ startFrame as LiveVoiceClientStartFrame,
366
+ );
367
+
368
+ expect(options.macosDesktopSession).toBeUndefined();
369
+ }
370
+ });
371
+
353
372
  test("omits the client when the start frame declares none", async () => {
354
373
  const options = await captureTurnOptions(START_FRAME);
355
374