@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
@@ -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", () => {
@@ -1706,9 +1792,21 @@ describe("HostCuProxy", () => {
1706
1792
  async function finish(
1707
1793
  input: Record<string, unknown>,
1708
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",
1709
1798
  ) {
1710
- proxy.recordAction("computer_use_observe", input);
1711
- 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
+ );
1712
1810
  const sent = sentMessages.at(-1) as { requestId: string };
1713
1811
  proxy.processObservation(sent.requestId, observation);
1714
1812
  return await pending;
@@ -1777,8 +1875,8 @@ describe("HostCuProxy", () => {
1777
1875
  test("targeted snapshots reset history without no-effect warnings or cross-window diffs", async () => {
1778
1876
  setup();
1779
1877
  connect();
1780
- await finish({}, { axTree: "Private desktop" });
1781
- await finish({}, { axTree: "Private desktop" });
1878
+ await finish({}, { axTree: "Private desktop" }, "computer_use_click");
1879
+ await finish({}, { axTree: "Private desktop" }, "computer_use_click");
1782
1880
  expect(proxy.consecutiveUnchangedSteps).toBe(1);
1783
1881
  for (const id of [12, 12, 13]) {
1784
1882
  const result = await finish(
@@ -1789,16 +1887,16 @@ describe("HostCuProxy", () => {
1789
1887
  secondaryWindows: "Private secondary window",
1790
1888
  },
1791
1889
  );
1792
- expect(result.content).not.toContain("NO VISIBLE EFFECT");
1890
+ expect(result.content).not.toContain("tree did not change");
1793
1891
  expect(result.content).not.toContain("Private");
1794
1892
  expect(proxy.previousAXTree).toBeUndefined();
1795
1893
  expect(proxy.consecutiveUnchangedSteps).toBe(0);
1796
1894
  }
1797
1895
  const desktop = await finish({}, { axTree: "Returned desktop" });
1798
- expect(desktop.content).not.toContain("NO VISIBLE EFFECT");
1896
+ expect(desktop.content).not.toContain("tree did not change");
1799
1897
  expect(proxy.previousAXTree).toBe("Returned desktop");
1800
1898
  expect(proxy.consecutiveUnchangedSteps).toBe(0);
1801
- await finish({}, { axTree: "Returned desktop" });
1899
+ await finish({}, { axTree: "Returned desktop" }, "computer_use_click");
1802
1900
  expect(proxy.consecutiveUnchangedSteps).toBe(1);
1803
1901
  });
1804
1902
  test("failed targeted captures also break the desktop comparison baseline", async () => {
@@ -1815,7 +1913,7 @@ describe("HostCuProxy", () => {
1815
1913
  ).toBe(true);
1816
1914
  expect(proxy.previousAXTree).toBeUndefined();
1817
1915
  const result = await finish({}, { axTree: "Desktop" });
1818
- expect(result.content).not.toContain("NO VISIBLE EFFECT");
1916
+ expect(result.content).not.toContain("tree did not change");
1819
1917
  });
1820
1918
  test("legacy camel-case companion targets also reset observation history", async () => {
1821
1919
  setup();
@@ -1825,7 +1923,7 @@ describe("HostCuProxy", () => {
1825
1923
  { captureDisplayId: 1 },
1826
1924
  { axTree: "Display" },
1827
1925
  );
1828
- expect(result.content).not.toContain("NO VISIBLE EFFECT");
1926
+ expect(result.content).not.toContain("tree did not change");
1829
1927
  expect(proxy.previousAXTree).toBeUndefined();
1830
1928
  });
1831
1929
  });
@@ -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(
@@ -1,23 +1,19 @@
1
1
  import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
2
2
 
3
- const mockConnect = jest.fn();
4
- const mockDisconnect = jest.fn();
5
- let mockIsConnected = true;
6
- let mockLastError: Error | null = null;
3
+ const getServerState = jest.fn();
7
4
 
8
5
  mock.module("../mcp/client.js", () => ({
9
6
  McpClient: class {
10
- get isConnected() {
11
- return mockIsConnected;
7
+ constructor() {
8
+ throw new Error("list route must not construct an MCP client");
12
9
  }
13
- get lastError() {
14
- return mockLastError;
15
- }
16
- connect = mockConnect;
17
- disconnect = mockDisconnect;
18
10
  },
19
11
  }));
20
12
 
13
+ mock.module("../mcp/manager.js", () => ({
14
+ getMcpServerManager: () => ({ getServerState }),
15
+ }));
16
+
21
17
  import { setWorkspaceMcp } from "./helpers/set-workspace-mcp.js";
22
18
 
23
19
  // Seed the MCP server the list route reads from workspace mcp.json.
@@ -56,28 +52,22 @@ const listHandler = ROUTES.find(
56
52
  (r: { operationId: string }) => r.operationId === "internal_mcp_list",
57
53
  )!.handler;
58
54
 
59
- describe("checkServerHealth (via internal_mcp_list route)", () => {
55
+ describe("internal_mcp_list runtime status", () => {
60
56
  beforeEach(() => {
61
- mockConnect.mockReset();
62
- mockDisconnect.mockReset();
63
- mockIsConnected = true;
64
- mockLastError = null;
57
+ getServerState.mockReset();
65
58
  });
66
59
 
67
- test("returns connected when server connects successfully", async () => {
68
- mockConnect.mockResolvedValue(undefined);
69
- mockDisconnect.mockResolvedValue(undefined);
60
+ test("returns connected from the manager's recorded state", async () => {
61
+ getServerState.mockReturnValue("connected");
70
62
 
71
63
  const result = (await listHandler({})) as {
72
64
  servers: { status: string }[];
73
65
  };
74
66
  expect(result.servers[0].status).toBe("connected");
75
- expect(mockDisconnect).toHaveBeenCalled();
76
67
  });
77
68
 
78
- test("returns needs-auth when isConnected is false and no lastError", async () => {
79
- mockConnect.mockResolvedValue(undefined);
80
- mockIsConnected = false;
69
+ test("returns needs-auth from the manager's recorded state", async () => {
70
+ getServerState.mockReturnValue("needs-auth");
81
71
 
82
72
  const result = (await listHandler({})) as {
83
73
  servers: { status: string }[];
@@ -85,15 +75,25 @@ describe("checkServerHealth (via internal_mcp_list route)", () => {
85
75
  expect(result.servers[0].status).toBe("needs-auth");
86
76
  });
87
77
 
88
- test("returns error when connect fails with lastError", async () => {
89
- mockConnect.mockResolvedValue(undefined);
90
- mockIsConnected = false;
91
- mockLastError = new Error("Connection refused");
92
- mockDisconnect.mockResolvedValue(undefined);
78
+ test.each([undefined, "connecting", "error"])(
79
+ "returns the legacy error status for runtime state %s",
80
+ async (runtimeState) => {
81
+ getServerState.mockReturnValue(runtimeState);
93
82
 
94
- const result = (await listHandler({})) as {
95
- servers: { status: string }[];
96
- };
97
- expect(result.servers[0].status).toBe("error");
83
+ const result = (await listHandler({})) as {
84
+ servers: { status: string }[];
85
+ };
86
+ expect(result.servers[0].status).toBe("error");
87
+ },
88
+ );
89
+
90
+ test("repeated reads do not connect or spawn a configured server", async () => {
91
+ getServerState.mockReturnValue("connected");
92
+
93
+ await listHandler({});
94
+ await listHandler({});
95
+
96
+ expect(getServerState).toHaveBeenCalledTimes(2);
97
+ expect(getServerState).toHaveBeenCalledWith("test", "workspace");
98
98
  });
99
99
  });