@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
@@ -553,7 +553,7 @@ describe("HostCuProxy", () => {
553
553
  });
554
554
  const result2 = await p2;
555
555
  // First unchanged: simple warning
556
- expect(result2.content).toContain("NO VISIBLE EFFECT");
556
+ expect(result2.content).toContain("tree did not change");
557
557
  expect(result2.content).not.toContain("2 consecutive");
558
558
 
559
559
  // Third request — still same AX tree, no diff (unchanged step 2)
@@ -571,7 +571,7 @@ describe("HostCuProxy", () => {
571
571
  const result3 = await p3;
572
572
  // Should now have the consecutive warning
573
573
  expect(result3.content).toContain(
574
- "2 consecutive actions had NO VISIBLE EFFECT",
574
+ "did not change across 2 consecutive actions",
575
575
  );
576
576
  });
577
577
 
@@ -592,7 +592,93 @@ describe("HostCuProxy", () => {
592
592
  // No axDiff on first observation — this is normal, not unchanged
593
593
  });
594
594
  const result1 = await p1;
595
- expect(result1.content).not.toContain("NO VISIBLE EFFECT");
595
+ expect(result1.content).not.toContain("tree did not change");
596
+ });
597
+
598
+ async function stepWithUnchangedTree(
599
+ toolName: string,
600
+ input: Record<string, unknown>,
601
+ step: number,
602
+ ) {
603
+ const pending = proxy.request(toolName, input, "session-1", step);
604
+ proxy.recordAction(toolName, input);
605
+ const sent = sentMessages[step - 1] as Record<string, unknown>;
606
+ proxy.processObservation(sent.requestId as string, {
607
+ axTree: "Button [1]",
608
+ });
609
+ return pending;
610
+ }
611
+
612
+ test("an AppleScript or an observation neither warns nor moves the streak", async () => {
613
+ for (const [toolName, input] of [
614
+ [
615
+ "computer_use_run_applescript",
616
+ { script: 'tell application "Finder" to activate' },
617
+ ],
618
+ ["computer_use_observe", {}],
619
+ ] as const) {
620
+ setup();
621
+ await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 1);
622
+
623
+ const result = await stepWithUnchangedTree(toolName, input, 2);
624
+
625
+ expect(result.content).not.toContain("tree did not change");
626
+ expect(proxy.consecutiveUnchangedSteps).toBe(0);
627
+ }
628
+ });
629
+
630
+ test("judges each concurrent response by the action it carried", async () => {
631
+ setup();
632
+ await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 1);
633
+
634
+ // A click and an observation dispatched from one model response, with
635
+ // the observation recorded last and the click answered last.
636
+ const clickPromise = proxy.request(
637
+ "computer_use_click",
638
+ { element_id: 1 },
639
+ "session-1",
640
+ 2,
641
+ );
642
+ proxy.recordAction("computer_use_click", { element_id: 1 });
643
+ const observePromise = proxy.request(
644
+ "computer_use_observe",
645
+ {},
646
+ "session-1",
647
+ 3,
648
+ );
649
+ proxy.recordAction("computer_use_observe", {});
650
+ const click = sentMessages[1] as Record<string, unknown>;
651
+ const observe = sentMessages[2] as Record<string, unknown>;
652
+
653
+ proxy.processObservation(observe.requestId as string, {
654
+ axTree: "Button [1]",
655
+ });
656
+ proxy.processObservation(click.requestId as string, {
657
+ axTree: "Button [1]",
658
+ });
659
+
660
+ expect((await observePromise).content).not.toContain(
661
+ "tree did not change",
662
+ );
663
+ expect((await clickPromise).content).toContain("tree did not change");
664
+ expect(proxy.consecutiveUnchangedSteps).toBe(1);
665
+ });
666
+
667
+ test("an observation between two unchanged clicks keeps the streak", async () => {
668
+ setup();
669
+ await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 1);
670
+ await stepWithUnchangedTree("computer_use_click", { element_id: 1 }, 2);
671
+ await stepWithUnchangedTree("computer_use_observe", {}, 3);
672
+
673
+ const result = await stepWithUnchangedTree(
674
+ "computer_use_click",
675
+ { element_id: 1 },
676
+ 4,
677
+ );
678
+
679
+ expect(result.content).toContain(
680
+ "did not change across 2 consecutive actions",
681
+ );
596
682
  });
597
683
 
598
684
  test("skips unchanged warning after computer_use_wait", async () => {
@@ -626,7 +712,7 @@ describe("HostCuProxy", () => {
626
712
  // No axDiff — screen unchanged, but that's expected after wait
627
713
  });
628
714
  const result2 = await p2;
629
- expect(result2.content).not.toContain("NO VISIBLE EFFECT");
715
+ expect(result2.content).not.toContain("tree did not change");
630
716
  });
631
717
 
632
718
  test("skips unchanged warning and counter after a selection-only key (cmd+a)", async () => {
@@ -661,7 +747,7 @@ describe("HostCuProxy", () => {
661
747
  // No axDiff — selection change is invisible in the AX tree
662
748
  });
663
749
  const result2 = await p2;
664
- expect(result2.content).not.toContain("NO VISIBLE EFFECT");
750
+ expect(result2.content).not.toContain("tree did not change");
665
751
  expect(proxy.consecutiveUnchangedSteps).toBe(0);
666
752
  });
667
753
 
@@ -707,7 +793,7 @@ describe("HostCuProxy", () => {
707
793
  // No axDiff — invisible-by-design change
708
794
  });
709
795
  const result2 = await p2;
710
- expect(result2.content).not.toContain("NO VISIBLE EFFECT");
796
+ expect(result2.content).not.toContain("tree did not change");
711
797
  expect(proxy.consecutiveUnchangedSteps).toBe(0);
712
798
  }
713
799
  });
@@ -807,7 +893,7 @@ describe("HostCuProxy", () => {
807
893
  axTree: "Button [1]",
808
894
  });
809
895
  const result2 = await p2;
810
- expect(result2.content).toContain("NO VISIBLE EFFECT");
896
+ expect(result2.content).toContain("tree did not change");
811
897
  expect(proxy.consecutiveUnchangedSteps).toBe(1);
812
898
  });
813
899
 
@@ -840,7 +926,7 @@ describe("HostCuProxy", () => {
840
926
  axTree: "Button [1]",
841
927
  });
842
928
  const result2 = await p2;
843
- expect(result2.content).toContain("NO VISIBLE EFFECT");
929
+ expect(result2.content).toContain("tree did not change");
844
930
  expect(proxy.consecutiveUnchangedSteps).toBe(1);
845
931
  });
846
932
 
@@ -860,7 +946,7 @@ describe("HostCuProxy", () => {
860
946
  expect(result.content).toContain(
861
947
  "WARNING: You've repeated the same action (computer_use_key) 3 times",
862
948
  );
863
- expect(result.content).not.toContain("NO VISIBLE EFFECT");
949
+ expect(result.content).not.toContain("tree did not change");
864
950
  });
865
951
 
866
952
  test("loop detection fires for the same exempt combo spelled differently", () => {
@@ -1232,6 +1318,38 @@ describe("HostCuProxy", () => {
1232
1318
  });
1233
1319
  });
1234
1320
 
1321
+ describe("endTask", () => {
1322
+ test("tells the desktop the task drove that it is over, once, then resets", async () => {
1323
+ setup();
1324
+
1325
+ const resultPromise = proxy.request(
1326
+ "computer_use_click",
1327
+ { element_id: 1 },
1328
+ "session-1",
1329
+ 1,
1330
+ );
1331
+ const requestId = (sentMessages[0] as Record<string, unknown>)
1332
+ .requestId as string;
1333
+ proxy.processObservation(requestId, { axTree: "Button [1]" });
1334
+ await resultPromise;
1335
+
1336
+ proxy.endTask("session-1");
1337
+ const cancels = sentMessages.filter(
1338
+ (m) => (m as Record<string, unknown>).type === "host_cu_cancel",
1339
+ ) as Array<Record<string, unknown>>;
1340
+ expect(cancels).toHaveLength(1);
1341
+ expect(cancels[0].conversationId).toBe("session-1");
1342
+ expect(cancels[0].requestId).not.toBe(requestId);
1343
+
1344
+ proxy.endTask("session-1");
1345
+ expect(
1346
+ sentMessages.filter(
1347
+ (m) => (m as Record<string, unknown>).type === "host_cu_cancel",
1348
+ ),
1349
+ ).toHaveLength(1);
1350
+ });
1351
+ });
1352
+
1235
1353
  describe("late resolve after abort", () => {
1236
1354
  test("resolve is a no-op after abort (entry already deleted)", async () => {
1237
1355
  setup();
@@ -1674,9 +1792,21 @@ describe("HostCuProxy", () => {
1674
1792
  async function finish(
1675
1793
  input: Record<string, unknown>,
1676
1794
  observation: Record<string, string>,
1795
+ // The step the unchanged streak is judged on. An observation leaves the
1796
+ // streak alone, so a test of how snapshots reset it stands in an action.
1797
+ toolName = "computer_use_observe",
1677
1798
  ) {
1678
- proxy.recordAction("computer_use_observe", input);
1679
- const pending = observe(input);
1799
+ proxy.recordAction(toolName, input);
1800
+ const pending = proxy.request(
1801
+ toolName,
1802
+ input,
1803
+ "session-1",
1804
+ 1,
1805
+ undefined,
1806
+ undefined,
1807
+ undefined,
1808
+ "user-1",
1809
+ );
1680
1810
  const sent = sentMessages.at(-1) as { requestId: string };
1681
1811
  proxy.processObservation(sent.requestId, observation);
1682
1812
  return await pending;
@@ -1745,8 +1875,8 @@ describe("HostCuProxy", () => {
1745
1875
  test("targeted snapshots reset history without no-effect warnings or cross-window diffs", async () => {
1746
1876
  setup();
1747
1877
  connect();
1748
- await finish({}, { axTree: "Private desktop" });
1749
- await finish({}, { axTree: "Private desktop" });
1878
+ await finish({}, { axTree: "Private desktop" }, "computer_use_click");
1879
+ await finish({}, { axTree: "Private desktop" }, "computer_use_click");
1750
1880
  expect(proxy.consecutiveUnchangedSteps).toBe(1);
1751
1881
  for (const id of [12, 12, 13]) {
1752
1882
  const result = await finish(
@@ -1757,16 +1887,16 @@ describe("HostCuProxy", () => {
1757
1887
  secondaryWindows: "Private secondary window",
1758
1888
  },
1759
1889
  );
1760
- expect(result.content).not.toContain("NO VISIBLE EFFECT");
1890
+ expect(result.content).not.toContain("tree did not change");
1761
1891
  expect(result.content).not.toContain("Private");
1762
1892
  expect(proxy.previousAXTree).toBeUndefined();
1763
1893
  expect(proxy.consecutiveUnchangedSteps).toBe(0);
1764
1894
  }
1765
1895
  const desktop = await finish({}, { axTree: "Returned desktop" });
1766
- expect(desktop.content).not.toContain("NO VISIBLE EFFECT");
1896
+ expect(desktop.content).not.toContain("tree did not change");
1767
1897
  expect(proxy.previousAXTree).toBe("Returned desktop");
1768
1898
  expect(proxy.consecutiveUnchangedSteps).toBe(0);
1769
- await finish({}, { axTree: "Returned desktop" });
1899
+ await finish({}, { axTree: "Returned desktop" }, "computer_use_click");
1770
1900
  expect(proxy.consecutiveUnchangedSteps).toBe(1);
1771
1901
  });
1772
1902
  test("failed targeted captures also break the desktop comparison baseline", async () => {
@@ -1783,7 +1913,7 @@ describe("HostCuProxy", () => {
1783
1913
  ).toBe(true);
1784
1914
  expect(proxy.previousAXTree).toBeUndefined();
1785
1915
  const result = await finish({}, { axTree: "Desktop" });
1786
- expect(result.content).not.toContain("NO VISIBLE EFFECT");
1916
+ expect(result.content).not.toContain("tree did not change");
1787
1917
  });
1788
1918
  test("legacy camel-case companion targets also reset observation history", async () => {
1789
1919
  setup();
@@ -1793,7 +1923,7 @@ describe("HostCuProxy", () => {
1793
1923
  { captureDisplayId: 1 },
1794
1924
  { axTree: "Display" },
1795
1925
  );
1796
- expect(result.content).not.toContain("NO VISIBLE EFFECT");
1926
+ expect(result.content).not.toContain("tree did not change");
1797
1927
  expect(proxy.previousAXTree).toBeUndefined();
1798
1928
  });
1799
1929
  });
@@ -1868,7 +1998,7 @@ describe("HostCuProxy", () => {
1868
1998
  });
1869
1999
  });
1870
2000
 
1871
- describe("screenshot on request", () => {
2001
+ describe("screenshot policy", () => {
1872
2002
  const TREE = 'Window: "Inbox" (Mail)\n [1] button "Reply" at (10, 10)';
1873
2003
  const OMITTED = "Screenshot omitted";
1874
2004
 
@@ -1881,15 +2011,20 @@ describe("HostCuProxy", () => {
1881
2011
  "host_cu",
1882
2012
  "host_cu_window_capture",
1883
2013
  "host_cu_annotate",
2014
+ "host_cu_sequence",
1884
2015
  ],
1885
2016
  },
1886
2017
  ];
1887
2018
  }
1888
2019
 
1889
- /** Dispatch one request and return what was sent, without answering it. */
2020
+ /**
2021
+ * Dispatch one request and return what was sent, without answering it. A
2022
+ * plain observation is the only step whose pixels depend on policy, so it
2023
+ * is the default.
2024
+ */
1890
2025
  function dispatch(
1891
- input: Record<string, unknown> = { element_id: 1 },
1892
- toolName = "computer_use_click",
2026
+ input: Record<string, unknown> = {},
2027
+ toolName = "computer_use_observe",
1893
2028
  ) {
1894
2029
  const pending = proxy.request(
1895
2030
  toolName,
@@ -1923,22 +2058,42 @@ describe("HostCuProxy", () => {
1923
2058
  return !Object.hasOwn(sent.input, "includeScreenshot");
1924
2059
  }
1925
2060
 
1926
- test("the first observed step attaches and the next unscoped step does not", async () => {
2061
+ test("the first observation attaches and the next plain one does not", async () => {
1927
2062
  setup();
1928
2063
  connect();
1929
2064
  const first = await step({ axTree: TREE, screenshot: "img" });
1930
- expect(first.sent.input).toEqual({ element_id: 1 });
2065
+ expect(first.sent.input).toEqual({});
1931
2066
  expect(first.result.content).not.toContain(OMITTED);
1932
2067
 
1933
2068
  const second = await step({ axTree: TREE });
1934
- expect(second.sent.input).toEqual({
1935
- element_id: 1,
1936
- includeScreenshot: false,
1937
- });
2069
+ expect(second.sent.input).toEqual({ includeScreenshot: false });
1938
2070
  expect(second.result.content).toContain(OMITTED);
1939
2071
  expect(second.result.isError).toBe(false);
1940
2072
  });
1941
2073
 
2074
+ test("every action attaches after the first look", async () => {
2075
+ setup();
2076
+ connect();
2077
+ await step({ axTree: TREE, screenshot: "img" });
2078
+ for (const [toolName, input] of [
2079
+ ["computer_use_click", { element_id: 1 }],
2080
+ ["computer_use_type_text", { text: "hi" }],
2081
+ ["computer_use_key", { key: "enter" }],
2082
+ ["computer_use_scroll", { direction: "down" }],
2083
+ ["computer_use_wait", { duration: 1 }],
2084
+ ["computer_use_sequence", { actions: [] }],
2085
+ ] as const) {
2086
+ const action = await step(
2087
+ { axTree: TREE, screenshot: "img" },
2088
+ { ...input, include_screenshot: false },
2089
+ toolName,
2090
+ );
2091
+ expect(action.sent.input).toEqual(input);
2092
+ expect(action.result.content).not.toContain(OMITTED);
2093
+ expect(action.result.contentBlocks).toHaveLength(1);
2094
+ }
2095
+ });
2096
+
1942
2097
  test("the first look is per desktop, so a newly targeted one attaches", async () => {
1943
2098
  setup();
1944
2099
  mockClients = [
@@ -1955,8 +2110,8 @@ describe("HostCuProxy", () => {
1955
2110
  ];
1956
2111
  const on = (clientId: string) => {
1957
2112
  const pending = proxy.request(
1958
- "computer_use_click",
1959
- { element_id: 1 },
2113
+ "computer_use_observe",
2114
+ {},
1960
2115
  "session-1",
1961
2116
  1,
1962
2117
  undefined,
@@ -22,18 +22,29 @@ function freshWorkspace(): string {
22
22
 
23
23
  /**
24
24
  * Materialize an installed plugin under `<workspace>/plugins/<name>/` with a
25
- * package.json manifest. Returns the plugin directory path.
25
+ * plugin manifest. Returns the plugin directory path.
26
26
  */
27
27
  function installPlugin(
28
28
  name: string,
29
- opts: { disabled?: boolean } = {},
29
+ opts: { disabled?: boolean; standard?: boolean } = {},
30
30
  ): string {
31
31
  const pluginDir = join(getWorkspacePluginsDir(), name);
32
32
  mkdirSync(pluginDir, { recursive: true });
33
- writeFileSync(
34
- join(pluginDir, "package.json"),
35
- JSON.stringify({ name, version: "1.0.0" }),
36
- );
33
+ if (opts.standard) {
34
+ writeFileSync(
35
+ join(pluginDir, "plugin.json"),
36
+ JSON.stringify({
37
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
38
+ name,
39
+ version: "1.0.0",
40
+ }),
41
+ );
42
+ } else {
43
+ writeFileSync(
44
+ join(pluginDir, "package.json"),
45
+ JSON.stringify({ name, version: "1.0.0" }),
46
+ );
47
+ }
37
48
  if (opts.disabled) {
38
49
  writeFileSync(join(pluginDir, ".disabled"), "");
39
50
  }
@@ -104,7 +115,16 @@ describe("listAllApps", () => {
104
115
  expect(all.map((a) => a.origin.kind)).toEqual(["workspace", "plugin"]);
105
116
  });
106
117
 
107
- test("ignores directories under plugins/ without a package.json manifest", () => {
118
+ test("discovers apps from a standard-only plugin", () => {
119
+ const pluginDir = installPlugin("standard-plugin", { standard: true });
120
+ bundleApp(pluginDir, "dashboard");
121
+
122
+ expect(listPluginApps().map((app) => app.id)).toEqual([
123
+ "plugins~standard-plugin~dashboard",
124
+ ]);
125
+ });
126
+
127
+ test("ignores directories under plugins/ without a root manifest", () => {
108
128
  // A stray directory that is not an installed plugin, even with an apps/ dir.
109
129
  const strayDir = join(getWorkspacePluginsDir(), "not-a-plugin");
110
130
  mkdirSync(join(strayDir, "apps", "ghost"), { recursive: true });
@@ -166,6 +186,18 @@ describe("resolveAppSource", () => {
166
186
  expect(source!.sourceDir).toBe(join(pluginDir, "apps", "acme-dashboard"));
167
187
  });
168
188
 
189
+ test("resolves an app from a standard-only plugin", () => {
190
+ const pluginDir = installPlugin("standard-plugin", { standard: true });
191
+ bundleApp(pluginDir, "dashboard");
192
+
193
+ const source = resolveAppSource("plugins~standard-plugin~dashboard");
194
+ expect(source?.sourceDir).toBe(join(pluginDir, "apps", "dashboard"));
195
+ expect(source?.origin).toEqual({
196
+ kind: "plugin",
197
+ pluginName: "standard-plugin",
198
+ });
199
+ });
200
+
169
201
  test("returns null for an unknown workspace id", () => {
170
202
  expect(resolveAppSource("11111111-1111-1111-1111-111111111111")).toBeNull();
171
203
  });
@@ -177,7 +209,7 @@ describe("resolveAppSource", () => {
177
209
  expect(resolveAppSource("plugins~acme~acme-dashboard")).toBeNull();
178
210
  });
179
211
 
180
- test("returns null for a plugin dir without a package.json manifest", () => {
212
+ test("returns null for a plugin dir without a root manifest", () => {
181
213
  // Stray directory shaped like a plugin app but not an installed plugin.
182
214
  const strayApp = join(
183
215
  getWorkspacePluginsDir(),
@@ -3,11 +3,29 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
3
3
  // ── Module mocks (must precede imports) ───────────────────────────────────────
4
4
 
5
5
  const mockReloadMcpServers = mock(async () => {});
6
+ let pluginServers: Array<{
7
+ id: string;
8
+ pluginName: string;
9
+ serverKey: string;
10
+ config: {
11
+ source: "plugin";
12
+ pluginName: string;
13
+ serverKey: string;
14
+ transport: {
15
+ type: "streamable-http";
16
+ url: string;
17
+ };
18
+ };
19
+ }> = [];
6
20
 
7
21
  mock.module("../daemon/mcp-reload-service.js", () => ({
8
22
  reloadMcpServers: () => mockReloadMcpServers(),
9
23
  }));
10
24
 
25
+ mock.module("../plugins/mcp-servers.js", () => ({
26
+ readPluginMcpServers: () => ({ servers: pluginServers, issues: [] }),
27
+ }));
28
+
11
29
  const mockOrchestrateConnect = mock(
12
30
  async (_args: { serverId: string; transport: unknown }) => ({
13
31
  auth_url: "https://provider.example.com/authorize?state=abc",
@@ -55,6 +73,7 @@ describe("mcp-auth-routes", () => {
55
73
  mockGetMcpAuthState.mockClear();
56
74
  mockGetMcpAuthState.mockImplementation(() => null);
57
75
  mockReloadMcpServers.mockClear(); // ← add this line
76
+ pluginServers = [];
58
77
  });
59
78
 
60
79
  describe("POST internal/mcp/auth/start", () => {
@@ -68,6 +87,86 @@ describe("mcp-auth-routes", () => {
68
87
  auth_url: "https://provider.example.com/authorize?state=abc",
69
88
  state: "my-server",
70
89
  });
90
+ expect(mockOrchestrateConnect).toHaveBeenCalledWith(
91
+ expect.objectContaining({
92
+ serverId: "my-server",
93
+ credentialTarget: {
94
+ source: "workspace",
95
+ serverId: "my-server",
96
+ },
97
+ }),
98
+ );
99
+ });
100
+
101
+ test("resolves an installed plugin server by its public id", async () => {
102
+ pluginServers = [
103
+ {
104
+ id: "plugin-auth__remote",
105
+ pluginName: "plugin-auth",
106
+ serverKey: "remote",
107
+ config: {
108
+ source: "plugin",
109
+ pluginName: "plugin-auth",
110
+ serverKey: "remote",
111
+ transport: {
112
+ type: "streamable-http",
113
+ url: "https://mcp.example.com/plugin",
114
+ },
115
+ },
116
+ },
117
+ ];
118
+
119
+ const startRoute = findRoute("internal_mcp_auth_start");
120
+ await startRoute.handler({
121
+ body: { serverId: "plugin-auth__remote" },
122
+ });
123
+
124
+ expect(mockOrchestrateConnect).toHaveBeenCalledWith(
125
+ expect.objectContaining({
126
+ serverId: "plugin-auth__remote",
127
+ credentialTarget: {
128
+ source: "plugin",
129
+ pluginName: "plugin-auth",
130
+ serverKey: "remote",
131
+ transportType: "streamable-http",
132
+ url: "https://mcp.example.com/plugin",
133
+ },
134
+ }),
135
+ );
136
+ });
137
+
138
+ test("keeps workspace precedence when a plugin declares the same id", async () => {
139
+ pluginServers = [
140
+ {
141
+ id: "my-server",
142
+ pluginName: "my-server",
143
+ serverKey: "my-server",
144
+ config: {
145
+ source: "plugin",
146
+ pluginName: "my-server",
147
+ serverKey: "my-server",
148
+ transport: {
149
+ type: "streamable-http",
150
+ url: "https://loses.example.com/mcp",
151
+ },
152
+ },
153
+ },
154
+ ];
155
+
156
+ const startRoute = findRoute("internal_mcp_auth_start");
157
+ await startRoute.handler({ body: { serverId: "my-server" } });
158
+
159
+ expect(mockOrchestrateConnect).toHaveBeenCalledWith(
160
+ expect.objectContaining({
161
+ transport: expect.objectContaining({
162
+ url: "https://mcp.example.com",
163
+ }),
164
+ credentialTarget: {
165
+ source: "workspace",
166
+ serverId: "my-server",
167
+ },
168
+ }),
169
+ );
71
170
  });
72
171
 
73
172
  test("rejects unknown serverId with BadRequestError", async () => {
@@ -130,6 +130,60 @@ describe("McpClient auth error detection", () => {
130
130
  });
131
131
  });
132
132
 
133
+ describe("McpClient connection lifecycle", () => {
134
+ test("a close during connect cannot restore a stale connected state", async () => {
135
+ const onUnexpectedClose = jest.fn();
136
+ const client = new McpClient("test-server", "workspace", onUnexpectedClose);
137
+ let finishConnect: (() => void) | undefined;
138
+ (client as any).createTransport = () => ({});
139
+ (client as any).client.connect = () =>
140
+ new Promise<void>((resolve) => {
141
+ finishConnect = resolve;
142
+ });
143
+
144
+ const connecting = client.connect(httpTransport);
145
+ while (!finishConnect) {
146
+ await Promise.resolve();
147
+ }
148
+ (client as any).client.onclose();
149
+ finishConnect!();
150
+ await connecting;
151
+
152
+ expect(client.isConnected).toBe(false);
153
+ expect(client.lastError?.message).toContain("closed during initialization");
154
+ expect(onUnexpectedClose).not.toHaveBeenCalled();
155
+ });
156
+
157
+ test("reports an unexpected close after connecting", async () => {
158
+ const onUnexpectedClose = jest.fn();
159
+ const client = new McpClient("test-server", "workspace", onUnexpectedClose);
160
+ (client as any).createTransport = () => ({});
161
+ (client as any).client.connect = async () => {};
162
+
163
+ await client.connect(httpTransport);
164
+ (client as any).client.onclose();
165
+
166
+ expect(client.isConnected).toBe(false);
167
+ expect(onUnexpectedClose).toHaveBeenCalledTimes(1);
168
+ });
169
+
170
+ test("does not report an intentional disconnect as a transport failure", async () => {
171
+ const onUnexpectedClose = jest.fn();
172
+ const client = new McpClient("test-server", "workspace", onUnexpectedClose);
173
+ (client as any).createTransport = () => ({});
174
+ (client as any).client.connect = async () => {};
175
+ (client as any).client.close = async () => {
176
+ (client as any).client.onclose();
177
+ };
178
+
179
+ await client.connect(httpTransport);
180
+ await client.disconnect();
181
+
182
+ expect(client.isConnected).toBe(false);
183
+ expect(onUnexpectedClose).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+
133
187
  describe("McpOAuthProvider redirectUrl", () => {
134
188
  test("redirectUrl is undefined until startCallbackServer() is called", () => {
135
189
  const nonInteractive = new McpOAuthProvider(