@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.
- package/docs/desktop-browser-cli.md +33 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
- package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
- package/openapi.yaml +50 -4
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-sounds-sync.test.ts +23 -0
- package/src/__tests__/conversation-error.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
- package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
- package/src/__tests__/credential-routes.test.ts +38 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +6 -2
- package/src/__tests__/external-plugin-loader.test.ts +52 -0
- package/src/__tests__/headless-browser-mode.test.ts +48 -0
- package/src/__tests__/host-cu-proxy.test.ts +185 -30
- package/src/__tests__/list-all-apps.test.ts +40 -8
- package/src/__tests__/mcp-auth-routes.test.ts +99 -0
- package/src/__tests__/mcp-client-auth.test.ts +54 -0
- package/src/__tests__/mcp-health-check.test.ts +32 -32
- package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
- package/src/__tests__/oauth-commands-routes.test.ts +55 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
- package/src/__tests__/oauth-providers-routes.test.ts +1 -0
- package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
- package/src/__tests__/skills.test.ts +10 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
- package/src/__tests__/ui-channel-variants.test.ts +1 -56
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
- package/src/acp/__tests__/acp-credentials.test.ts +13 -0
- package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
- package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
- package/src/acp/acp-claude-oauth.ts +328 -14
- package/src/acp/acp-credentials.ts +19 -0
- package/src/acp/claude-token-refresh.ts +150 -0
- package/src/acp/prepare-agent-env.ts +21 -6
- package/src/apps/app-store.ts +6 -5
- package/src/browser/operations.ts +2 -1
- package/src/browser/types.ts +7 -0
- package/src/browser/virtual-desktop-target.ts +34 -0
- package/src/calls/__tests__/progress-narration.test.ts +23 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +132 -0
- package/src/calls/voice-session-bridge.ts +56 -26
- package/src/calls/voice-triage-escalate.ts +3 -3
- package/src/cli/commands/__tests__/browser.test.ts +84 -3
- package/src/cli/commands/browser.help.ts +43 -0
- package/src/cli/commands/browser.ts +52 -8
- package/src/cli/commands/oauth/status.ts +5 -0
- package/src/cli/commands/plugins.ts +16 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
- package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
- package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/install-from-github.ts +17 -18
- package/src/cli/lib/install-from-platform.ts +14 -9
- package/src/cli/lib/list-installed-plugins.ts +21 -35
- package/src/cli/lib/plugin-details.ts +21 -8
- package/src/cli/lib/uninstall-plugin.ts +59 -1
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
- package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +11 -3
- package/src/config/loader.ts +1 -0
- package/src/config/schemas/__tests__/voice.test.ts +2 -2
- package/src/config/schemas/mcp.ts +9 -7
- package/src/config/schemas/monitoring.ts +9 -0
- package/src/config/schemas/services.ts +10 -0
- package/src/config/schemas/voice.ts +2 -2
- package/src/config/skills.ts +13 -21
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
- package/src/daemon/conversation-client-surface.ts +26 -0
- package/src/daemon/conversation-runtime-assembly.ts +19 -7
- package/src/daemon/conversation-surfaces.ts +6 -1
- package/src/daemon/conversation-tool-setup.ts +37 -43
- package/src/daemon/host-cu-proxy.ts +94 -31
- package/src/daemon/host-proxy-preactivation.ts +2 -0
- package/src/daemon/mcp-reload-service.ts +7 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/providers-setup.ts +4 -0
- package/src/desktop/__tests__/fake-desktop.ts +1 -0
- package/src/desktop/desktop-automation-lease.test.ts +208 -0
- package/src/desktop/desktop-automation-lease.ts +270 -0
- package/src/desktop/desktop-browser-client.test.ts +413 -0
- package/src/desktop/desktop-browser-client.ts +430 -0
- package/src/desktop/desktop-browser-cursor.ts +40 -0
- package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
- package/src/desktop/desktop-browser-endpoint.ts +145 -0
- package/src/desktop/desktop-browser-operations.ts +130 -0
- package/src/desktop/desktop-chrome-session.test.ts +48 -0
- package/src/desktop/desktop-chrome-session.ts +58 -8
- package/src/desktop/desktop-dependencies.test.ts +29 -0
- package/src/desktop/desktop-dependencies.ts +26 -5
- package/src/desktop/desktop-display.ts +9 -0
- package/src/desktop/desktop-panel-config.ts +14 -1
- package/src/desktop/desktop-session-manager.test.ts +86 -3
- package/src/desktop/desktop-session-manager.ts +144 -24
- package/src/desktop/desktop-stream-bridge.ts +1 -1
- package/src/desktop/virtual-desktop-feature.ts +18 -0
- package/src/desktop/virtual-desktop-platform.test.ts +61 -0
- package/src/i18n/__tests__/i18n.test.ts +9 -5
- package/src/i18n/messages.ts +13 -4
- package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
- package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
- package/src/ipc/assistant-server.ts +15 -1
- package/src/ipc/cli-client.ts +13 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
- package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +47 -0
- package/src/live-voice/__tests__/session-controls.test.ts +113 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +88 -7
- package/src/live-voice/protocol.ts +69 -0
- package/src/live-voice/session-controls.ts +139 -0
- package/src/mcp/__tests__/credential-target.test.ts +59 -0
- package/src/mcp/__tests__/effective-config.test.ts +11 -0
- package/src/mcp/__tests__/manager-state.test.ts +128 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
- package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
- package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
- package/src/mcp/client.ts +64 -16
- package/src/mcp/credential-target.ts +105 -0
- package/src/mcp/effective-config.ts +42 -15
- package/src/mcp/manager.ts +61 -8
- package/src/mcp/mcp-auth-orchestrator.ts +17 -4
- package/src/mcp/mcp-auth-state.ts +38 -6
- package/src/mcp/mcp-oauth-provider.ts +101 -33
- package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
- package/src/monitoring/mount-watch.ts +348 -0
- package/src/monitoring/worker.ts +9 -0
- package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
- package/src/oauth/connection-resolver.test.ts +27 -0
- package/src/oauth/connection-resolver.ts +25 -1
- package/src/oauth/provider-serializer.ts +9 -0
- package/src/oauth/seed-providers.ts +121 -2
- package/src/onboarding/checkin-event.ts +2 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
- package/src/plugins/external-plugin-loader.ts +49 -86
- package/src/plugins/installed-plugin-dirs.ts +6 -4
- package/src/plugins/mcp-servers.ts +9 -15
- package/src/plugins/mtime-cache.ts +2 -1
- package/src/plugins/user-loader.ts +2 -1
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
- package/src/providers/inference/endpoint-probe.ts +27 -12
- package/src/providers/openai/responses-provider.ts +9 -2
- package/src/providers/opencode/client.test.ts +167 -0
- package/src/providers/opencode/client.ts +73 -4
- package/src/runtime/AGENTS.md +4 -0
- package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
- package/src/runtime/http-server.ts +6 -6
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
- package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
- package/src/runtime/routes/app-management-routes.ts +17 -2
- package/src/runtime/routes/browser-context.ts +39 -0
- package/src/runtime/routes/browser-routes.ts +16 -47
- package/src/runtime/routes/browser-tabs-routes.ts +72 -29
- package/src/runtime/routes/credential-routes.ts +1 -4
- package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
- package/src/runtime/routes/desktop-setup-routes.ts +7 -5
- package/src/runtime/routes/mcp-auth-routes.ts +106 -94
- package/src/runtime/routes/oauth-commands-routes.ts +14 -0
- package/src/runtime/routes/oauth-providers.ts +7 -0
- package/src/runtime/routes/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/browser/browser-execution.ts +44 -13
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
- package/src/tools/computer-use/definitions.ts +3 -4
- package/src/tools/ui-surface/channel-variants.ts +10 -59
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/watch/watch-retro.ts +6 -8
- package/src/desktop/desktop-feature.test.ts +0 -18
- 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("
|
|
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
|
-
"
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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(
|
|
1679
|
-
const pending =
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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
|
-
/**
|
|
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> = {
|
|
1892
|
-
toolName = "
|
|
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
|
|
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({
|
|
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
|
-
"
|
|
1959
|
-
{
|
|
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
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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("
|
|
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
|
|
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(
|