@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.
- package/docs/desktop-browser-cli.md +33 -0
- package/openapi.yaml +17 -5
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- 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 +6 -4
- 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 +116 -18
- 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__/skills.test.ts +10 -0
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/acp/session-snapshot.ts +260 -0
- 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 +7 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +34 -4
- package/src/calls/voice-session-bridge.ts +23 -0
- 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/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/acp/SKILL.md +1 -1
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +2 -2
- 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/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 +1 -1
- package/src/daemon/conversation-tool-setup.ts +31 -29
- package/src/daemon/host-cu-proxy.ts +51 -26
- 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 +22 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +11 -3
- package/src/live-voice/__tests__/session-controls.test.ts +34 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +3 -0
- package/src/live-voice/protocol.ts +10 -1
- package/src/live-voice/session-controls.ts +28 -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/notifications/__tests__/decision-engine.test.ts +175 -0
- package/src/notifications/decision-engine.ts +43 -8
- package/src/oauth/seed-providers.ts +11 -0
- 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-routes.test.ts +19 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/acp-routes.ts +17 -161
- 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/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/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/acp/status.test.ts +276 -21
- package/src/tools/acp/status.ts +98 -32
- 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 +2 -3
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/desktop/desktop-feature.test.ts +0 -18
- package/src/desktop/desktop-feature.ts +0 -19
|
@@ -8,38 +8,35 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 1. A workspace entry of the same id wins. Getting precedence backwards
|
|
10
10
|
* would let a plugin redirect a server the user configured by hand.
|
|
11
|
-
* 2. A plugin server is never health-checked
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* URL, so probing one would send a workspace credential to an endpoint
|
|
15
|
-
* the plugin chose whenever an id happens to match a stored key.
|
|
11
|
+
* 2. A plugin server is never health-checked as a side effect of listing.
|
|
12
|
+
* OAuth status is read from the plugin and endpoint-scoped credential
|
|
13
|
+
* identity, while workspace static headers remain isolated.
|
|
16
14
|
*/
|
|
17
15
|
|
|
18
16
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
19
17
|
import { join } from "node:path";
|
|
20
18
|
import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
|
|
21
19
|
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
const getServerState = jest.fn();
|
|
21
|
+
const hasMcpOAuthTokens = jest.fn(async (target: any) => {
|
|
22
|
+
if (target.source === "plugin" && target.url === "not a url") {
|
|
23
|
+
throw new TypeError("invalid URL");
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
26
27
|
|
|
27
28
|
mock.module("../mcp/client.js", () => ({
|
|
28
29
|
McpClient: class {
|
|
29
|
-
constructor(
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
get isConnected() {
|
|
33
|
-
return true;
|
|
30
|
+
constructor() {
|
|
31
|
+
throw new Error("list route must not construct an MCP client");
|
|
34
32
|
}
|
|
35
|
-
get lastError() {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
connect = mockConnect;
|
|
39
|
-
disconnect = mockDisconnect;
|
|
40
33
|
},
|
|
41
34
|
}));
|
|
42
35
|
|
|
36
|
+
mock.module("../mcp/manager.js", () => ({
|
|
37
|
+
getMcpServerManager: () => ({ getServerState }),
|
|
38
|
+
}));
|
|
39
|
+
|
|
43
40
|
mock.module("../mcp/mcp-auth-orchestrator.js", () => ({
|
|
44
41
|
orchestrateMcpOAuthConnect: async () => ({
|
|
45
42
|
auth_url: "",
|
|
@@ -52,9 +49,7 @@ mock.module("../mcp/mcp-auth-state.js", () => ({
|
|
|
52
49
|
}));
|
|
53
50
|
|
|
54
51
|
mock.module("../mcp/mcp-oauth-provider.js", () => ({
|
|
55
|
-
|
|
56
|
-
// is the condition under which a leak would be observable.
|
|
57
|
-
hasMcpOAuthTokens: async () => true,
|
|
52
|
+
hasMcpOAuthTokens,
|
|
58
53
|
deleteMcpOAuthCredentials: async () => ({ ok: true, failedKeys: [] }),
|
|
59
54
|
}));
|
|
60
55
|
|
|
@@ -108,6 +103,19 @@ function writePlugin(name: string, mcpJson: unknown): void {
|
|
|
108
103
|
writeFileSync(join(dir, "mcp.json"), JSON.stringify(mcpJson));
|
|
109
104
|
}
|
|
110
105
|
|
|
106
|
+
function writeStandardPlugin(name: string, mcpJson: unknown): void {
|
|
107
|
+
const dir = join(getWorkspacePluginsDir(), name);
|
|
108
|
+
mkdirSync(dir, { recursive: true });
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(dir, "plugin.json"),
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
113
|
+
name,
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
writeFileSync(join(dir, "mcp.json"), JSON.stringify(mcpJson));
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
function unabyssManifest(): unknown {
|
|
112
120
|
return {
|
|
113
121
|
mcpServers: {
|
|
@@ -123,9 +131,8 @@ async function listServers(): Promise<ListedServer[]> {
|
|
|
123
131
|
|
|
124
132
|
describe("internal_mcp_list, plugin-declared servers", () => {
|
|
125
133
|
beforeEach(() => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
connectedServerIds.length = 0;
|
|
134
|
+
getServerState.mockReset();
|
|
135
|
+
hasMcpOAuthTokens.mockClear();
|
|
129
136
|
rmSync(getWorkspacePluginsDir(), { recursive: true, force: true });
|
|
130
137
|
mkdirSync(getWorkspacePluginsDir(), { recursive: true });
|
|
131
138
|
});
|
|
@@ -138,6 +145,14 @@ describe("internal_mcp_list, plugin-declared servers", () => {
|
|
|
138
145
|
expect(ids).toContain("unabyss");
|
|
139
146
|
});
|
|
140
147
|
|
|
148
|
+
test("a standard-only plugin keeps its server identity", async () => {
|
|
149
|
+
writeStandardPlugin("unabyss", unabyssManifest());
|
|
150
|
+
|
|
151
|
+
const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
|
|
152
|
+
expect(plugin.source).toBe("plugin");
|
|
153
|
+
expect(plugin.pluginName).toBe("unabyss");
|
|
154
|
+
});
|
|
155
|
+
|
|
141
156
|
test("plugin servers are labelled with their origin, workspace servers are not", async () => {
|
|
142
157
|
writePlugin("unabyss", unabyssManifest());
|
|
143
158
|
|
|
@@ -171,20 +186,36 @@ describe("internal_mcp_list, plugin-declared servers", () => {
|
|
|
171
186
|
const servers = await listServers();
|
|
172
187
|
|
|
173
188
|
expect(servers.find((s) => s.id === "unabyss")!.status).toEqual("declared");
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// would ship them to the plugin-declared URL.
|
|
177
|
-
expect(connectedServerIds).toContain("from-workspace");
|
|
178
|
-
expect(connectedServerIds).not.toContain("unabyss");
|
|
189
|
+
expect(getServerState).toHaveBeenCalledWith("from-workspace", "workspace");
|
|
190
|
+
expect(getServerState).toHaveBeenCalledWith("unabyss", "plugin");
|
|
179
191
|
});
|
|
180
192
|
|
|
181
|
-
test("plugin
|
|
193
|
+
test("plugin status uses only state recorded for the plugin source", async () => {
|
|
194
|
+
writePlugin("unabyss", unabyssManifest());
|
|
195
|
+
getServerState.mockImplementation(
|
|
196
|
+
(serverId: string, source: "workspace" | "plugin") =>
|
|
197
|
+
serverId === "unabyss" && source === "plugin" ? "connected" : undefined,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
|
|
201
|
+
expect(plugin.status).toBe("connected");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("plugin servers report endpoint-scoped OAuth without static auth", async () => {
|
|
182
205
|
writePlugin("unabyss", unabyssManifest());
|
|
183
206
|
|
|
184
207
|
const plugin = (await listServers()).find((s) => s.id === "unabyss")!;
|
|
185
|
-
expect(plugin.hasOAuth).toBe(
|
|
208
|
+
expect(plugin.hasOAuth).toBe(true);
|
|
186
209
|
expect(plugin.hasStaticAuth).toBe(false);
|
|
187
210
|
expect(plugin.authType).toEqual("none");
|
|
211
|
+
expect(hasMcpOAuthTokens).toHaveBeenCalledWith(
|
|
212
|
+
expect.objectContaining({
|
|
213
|
+
source: "plugin",
|
|
214
|
+
pluginName: "unabyss",
|
|
215
|
+
serverKey: "unabyss",
|
|
216
|
+
url: "https://mcp.unabyss.com",
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
188
219
|
});
|
|
189
220
|
|
|
190
221
|
test("workspace servers keep reporting their auth state", async () => {
|
|
@@ -225,6 +256,20 @@ describe("internal_mcp_list, plugin-declared servers", () => {
|
|
|
225
256
|
expect(ids).toContain("from-workspace");
|
|
226
257
|
});
|
|
227
258
|
|
|
259
|
+
test("an invalid plugin transport URL does not break the listing", async () => {
|
|
260
|
+
writePlugin("bad-url", {
|
|
261
|
+
mcpServers: {
|
|
262
|
+
remote: { type: "streamable-http", url: "not a url" },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const servers = await listServers();
|
|
267
|
+
expect(servers.find((s) => s.id === "bad-url__remote")?.hasOAuth).toBe(
|
|
268
|
+
false,
|
|
269
|
+
);
|
|
270
|
+
expect(servers.some((s) => s.id === "from-workspace")).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
228
273
|
test("no plugins installed leaves the listing unchanged", async () => {
|
|
229
274
|
const servers = await listServers();
|
|
230
275
|
expect(servers.every((s) => s.source === "workspace")).toBe(true);
|
|
@@ -32,10 +32,12 @@ const { RiskLevel } = await import("../permissions/types.js");
|
|
|
32
32
|
type ServerSource = "workspace" | "plugin";
|
|
33
33
|
|
|
34
34
|
function serverConfig(source: ServerSource) {
|
|
35
|
-
|
|
35
|
+
const base = {
|
|
36
36
|
transport: { type: "stdio" as const, command: "echo", args: [] },
|
|
37
|
-
source,
|
|
38
37
|
};
|
|
38
|
+
return source === "workspace"
|
|
39
|
+
? { ...base, source }
|
|
40
|
+
: { ...base, source, pluginName: "plugin", serverKey: "server" };
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
interface RiskAnnotations {
|
|
@@ -1054,6 +1054,16 @@ describe("ingress-dependent setup skills declare public-ingress intentionally",
|
|
|
1054
1054
|
expect(includes ?? []).not.toContain("public-ingress");
|
|
1055
1055
|
});
|
|
1056
1056
|
|
|
1057
|
+
test("telegram-setup documents the private-chat limitation", () => {
|
|
1058
|
+
const content = readFileSync(
|
|
1059
|
+
join(FIRST_PARTY_SKILLS_DIR, "telegram-setup", "SKILL.md"),
|
|
1060
|
+
"utf-8",
|
|
1061
|
+
);
|
|
1062
|
+
expect(content).toMatch(/private-chat only/i);
|
|
1063
|
+
expect(content).toMatch(/group, supergroup, and channel/i);
|
|
1064
|
+
expect(content).toMatch(/button taps/i);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1057
1067
|
test("twilio-setup includes public-ingress", () => {
|
|
1058
1068
|
const includes = readSkillIncludes(FIRST_PARTY_SKILLS_DIR, "twilio-setup");
|
|
1059
1069
|
expect(includes).toBeDefined();
|
|
@@ -93,6 +93,27 @@ describe("user plugin loader", () => {
|
|
|
93
93
|
expect(initHooks).toHaveLength(1);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
test("loads a plugin via a standard plugin.json manifest", async () => {
|
|
97
|
+
const pluginDir = join(PLUGINS_DIR, "standard-plugin");
|
|
98
|
+
mkdirSync(join(pluginDir, "hooks"), { recursive: true });
|
|
99
|
+
writeFileSync(
|
|
100
|
+
join(pluginDir, "plugin.json"),
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
103
|
+
name: "standard-plugin",
|
|
104
|
+
version: "1.0.0",
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
writeFileSync(
|
|
108
|
+
join(pluginDir, "hooks", "init.ts"),
|
|
109
|
+
"export default async function init(_ctx: unknown): Promise<void> {}\n",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await loadUserPlugins();
|
|
113
|
+
|
|
114
|
+
expect(await getUserHooksFor("init")).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
96
117
|
test("per-plugin failure is isolated: other plugins still load", async () => {
|
|
97
118
|
// Plugin A has a malformed package.json; the loader must isolate the
|
|
98
119
|
// failure and still load the healthy sibling.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { desc, eq } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
4
|
+
import { acpSessionHistory } from "../persistence/schema/index.js";
|
|
5
|
+
import { getLogger } from "../util/logger.js";
|
|
6
|
+
import { acpAuthMarkerStillCurrent } from "./acp-auth-marker-store.js";
|
|
7
|
+
import { getAcpSessionManager } from "./index.js";
|
|
8
|
+
import type { AcpSessionManager } from "./session-manager.js";
|
|
9
|
+
import type { AcpSessionState } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const log = getLogger("acp:session-snapshot");
|
|
12
|
+
|
|
13
|
+
export interface AcpSessionSnapshot {
|
|
14
|
+
id: string;
|
|
15
|
+
agentId: string;
|
|
16
|
+
acpSessionId: string;
|
|
17
|
+
parentConversationId: string;
|
|
18
|
+
status: string;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
completedAt?: number | null;
|
|
21
|
+
error?: string | null;
|
|
22
|
+
stopReason?: string | null;
|
|
23
|
+
task?: string;
|
|
24
|
+
parentToolUseId?: string;
|
|
25
|
+
authErrorCode?: string;
|
|
26
|
+
authErrorCredential?: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
availableModels?: AcpSessionState["availableModels"];
|
|
29
|
+
modelRevisionEpoch?: string;
|
|
30
|
+
modelRevision?: number;
|
|
31
|
+
usedTokens?: number;
|
|
32
|
+
contextSize?: number;
|
|
33
|
+
costAmount?: number;
|
|
34
|
+
costCurrency?: string;
|
|
35
|
+
inputTokens?: number;
|
|
36
|
+
outputTokens?: number;
|
|
37
|
+
eventLog?: unknown[];
|
|
38
|
+
source: "live" | "history";
|
|
39
|
+
resumable: boolean;
|
|
40
|
+
cwd?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AcpSessionSnapshotPage {
|
|
44
|
+
sessions: AcpSessionSnapshot[];
|
|
45
|
+
sawEveryHistoryRow: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SnapshotOptions {
|
|
49
|
+
includeEventLog?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fromLiveState(
|
|
53
|
+
state: AcpSessionState,
|
|
54
|
+
manager: AcpSessionManager,
|
|
55
|
+
opts: SnapshotOptions,
|
|
56
|
+
): AcpSessionSnapshot {
|
|
57
|
+
return {
|
|
58
|
+
id: state.id,
|
|
59
|
+
agentId: state.agentId,
|
|
60
|
+
acpSessionId: state.acpSessionId,
|
|
61
|
+
parentConversationId: state.parentConversationId,
|
|
62
|
+
status: state.status,
|
|
63
|
+
startedAt: state.startedAt,
|
|
64
|
+
completedAt: state.completedAt ?? null,
|
|
65
|
+
error: state.error ?? null,
|
|
66
|
+
stopReason: state.stopReason ?? null,
|
|
67
|
+
task: state.task,
|
|
68
|
+
parentToolUseId: state.parentToolUseId,
|
|
69
|
+
authErrorCode: state.authErrorCode,
|
|
70
|
+
authErrorCredential: state.authErrorCredential,
|
|
71
|
+
model: state.model,
|
|
72
|
+
availableModels: state.availableModels,
|
|
73
|
+
modelRevisionEpoch: state.modelRevisionEpoch,
|
|
74
|
+
modelRevision: state.modelRevision,
|
|
75
|
+
usedTokens: state.latestUsage?.usedTokens,
|
|
76
|
+
contextSize: state.latestUsage?.contextSize,
|
|
77
|
+
costAmount: state.latestUsage?.costAmount,
|
|
78
|
+
costCurrency: state.latestUsage?.costCurrency,
|
|
79
|
+
inputTokens: state.latestUsage?.inputTokens,
|
|
80
|
+
outputTokens: state.latestUsage?.outputTokens,
|
|
81
|
+
eventLog: opts.includeEventLog
|
|
82
|
+
? manager.getBufferedUpdates(state.id)
|
|
83
|
+
: undefined,
|
|
84
|
+
source: "live",
|
|
85
|
+
resumable: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isResumableHistoryRow(
|
|
90
|
+
row: typeof acpSessionHistory.$inferSelect,
|
|
91
|
+
): boolean {
|
|
92
|
+
return Boolean(row.cwd && row.acpSessionId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function snapshotHistoryRow(
|
|
96
|
+
row: typeof acpSessionHistory.$inferSelect,
|
|
97
|
+
opts: SnapshotOptions = { includeEventLog: true },
|
|
98
|
+
): AcpSessionSnapshot {
|
|
99
|
+
let eventLog: unknown[] | undefined;
|
|
100
|
+
if (opts.includeEventLog !== false) {
|
|
101
|
+
eventLog = [];
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(row.eventLogJson) as unknown;
|
|
104
|
+
if (Array.isArray(parsed)) {
|
|
105
|
+
eventLog = parsed;
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log.warn(
|
|
109
|
+
{ id: row.id, err },
|
|
110
|
+
"Failed to parse event_log_json for ACP session history row",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id: row.id,
|
|
117
|
+
agentId: row.agentId,
|
|
118
|
+
acpSessionId: row.acpSessionId,
|
|
119
|
+
parentConversationId: row.parentConversationId,
|
|
120
|
+
status: row.status,
|
|
121
|
+
startedAt: row.startedAt,
|
|
122
|
+
completedAt: row.completedAt,
|
|
123
|
+
error: row.error,
|
|
124
|
+
stopReason: row.stopReason,
|
|
125
|
+
task: row.task ?? undefined,
|
|
126
|
+
parentToolUseId: row.parentToolUseId ?? undefined,
|
|
127
|
+
authErrorCode: row.authErrorCode ?? undefined,
|
|
128
|
+
authErrorCredential: row.authErrorCredential ?? undefined,
|
|
129
|
+
usedTokens: row.usedTokens ?? undefined,
|
|
130
|
+
contextSize: row.contextSize ?? undefined,
|
|
131
|
+
costAmount: row.costAmount ?? undefined,
|
|
132
|
+
costCurrency: row.costCurrency ?? undefined,
|
|
133
|
+
inputTokens: row.inputTokens ?? undefined,
|
|
134
|
+
outputTokens: row.outputTokens ?? undefined,
|
|
135
|
+
eventLog,
|
|
136
|
+
source: "history",
|
|
137
|
+
resumable: isResumableHistoryRow(row),
|
|
138
|
+
cwd: row.cwd,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Blank `authErrorCode` on any session whose marker no longer describes the
|
|
144
|
+
* credential its agent would resolve.
|
|
145
|
+
*
|
|
146
|
+
* This comparison is what retires a Connect card: the marker no longer
|
|
147
|
+
* describing the credential in use. Applied after merging rather than inside
|
|
148
|
+
* the query, so live sessions and history rows are judged by the same rule.
|
|
149
|
+
*
|
|
150
|
+
* Resolved per agent and memoised across the batch, because precedence is per
|
|
151
|
+
* agent and each resolution costs a vault read.
|
|
152
|
+
*/
|
|
153
|
+
export async function withCurrentAuthMarkers<
|
|
154
|
+
T extends {
|
|
155
|
+
agentId: string;
|
|
156
|
+
authErrorCode?: string;
|
|
157
|
+
authErrorCredential?: string;
|
|
158
|
+
},
|
|
159
|
+
>(
|
|
160
|
+
sessions: readonly T[],
|
|
161
|
+
resolvedFor: (agentId: string) => Promise<string | undefined>,
|
|
162
|
+
): Promise<T[]> {
|
|
163
|
+
if (!sessions.some((session) => session.authErrorCode !== undefined)) {
|
|
164
|
+
return [...sessions];
|
|
165
|
+
}
|
|
166
|
+
const resolvedByAgent = new Map<string, string | undefined>();
|
|
167
|
+
const resolve = async (agentId: string) => {
|
|
168
|
+
if (!resolvedByAgent.has(agentId)) {
|
|
169
|
+
resolvedByAgent.set(agentId, await resolvedFor(agentId));
|
|
170
|
+
}
|
|
171
|
+
return resolvedByAgent.get(agentId);
|
|
172
|
+
};
|
|
173
|
+
const judged: T[] = [];
|
|
174
|
+
for (const session of sessions) {
|
|
175
|
+
if (session.authErrorCode === undefined) {
|
|
176
|
+
judged.push(session);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const current = acpAuthMarkerStillCurrent(
|
|
180
|
+
session.authErrorCredential,
|
|
181
|
+
await resolve(session.agentId),
|
|
182
|
+
);
|
|
183
|
+
judged.push(
|
|
184
|
+
current ? session : { ...session, authErrorCode: undefined },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return judged;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getAcpSessionSnapshot(
|
|
191
|
+
acpSessionId: string,
|
|
192
|
+
opts: SnapshotOptions = {},
|
|
193
|
+
): AcpSessionSnapshot | undefined {
|
|
194
|
+
const manager = getAcpSessionManager();
|
|
195
|
+
const snapshotOptions = {
|
|
196
|
+
includeEventLog: opts.includeEventLog ?? true,
|
|
197
|
+
};
|
|
198
|
+
const live = (manager.getStatus() as AcpSessionState[]).find(
|
|
199
|
+
(state) => state.id === acpSessionId,
|
|
200
|
+
);
|
|
201
|
+
if (live) {
|
|
202
|
+
return fromLiveState(live, manager, snapshotOptions);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const row = getDb()
|
|
206
|
+
.select()
|
|
207
|
+
.from(acpSessionHistory)
|
|
208
|
+
.where(eq(acpSessionHistory.id, acpSessionId))
|
|
209
|
+
.get();
|
|
210
|
+
return row ? snapshotHistoryRow(row, snapshotOptions) : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function listAcpSessionSnapshots(opts: {
|
|
214
|
+
limit: number;
|
|
215
|
+
conversationId?: string;
|
|
216
|
+
includeEventLog?: boolean;
|
|
217
|
+
}): AcpSessionSnapshotPage {
|
|
218
|
+
const manager = getAcpSessionManager();
|
|
219
|
+
const inMemory = manager.getStatus() as AcpSessionState[];
|
|
220
|
+
const snapshotOptions = {
|
|
221
|
+
includeEventLog: opts.includeEventLog ?? true,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const merged = new Map<string, AcpSessionSnapshot>();
|
|
225
|
+
for (const state of inMemory) {
|
|
226
|
+
if (
|
|
227
|
+
opts.conversationId &&
|
|
228
|
+
state.parentConversationId !== opts.conversationId
|
|
229
|
+
) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
merged.set(state.id, fromLiveState(state, manager, snapshotOptions));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const db = getDb();
|
|
236
|
+
const baseQuery = db.select().from(acpSessionHistory);
|
|
237
|
+
const filtered = opts.conversationId
|
|
238
|
+
? baseQuery.where(
|
|
239
|
+
eq(acpSessionHistory.parentConversationId, opts.conversationId),
|
|
240
|
+
)
|
|
241
|
+
: baseQuery;
|
|
242
|
+
const historyLimit = opts.limit + merged.size;
|
|
243
|
+
const historyRows = filtered
|
|
244
|
+
.orderBy(desc(acpSessionHistory.startedAt))
|
|
245
|
+
.limit(historyLimit)
|
|
246
|
+
.all();
|
|
247
|
+
|
|
248
|
+
for (const row of historyRows) {
|
|
249
|
+
if (!merged.has(row.id)) {
|
|
250
|
+
merged.set(row.id, snapshotHistoryRow(row, snapshotOptions));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
sessions: Array.from(merged.values()).sort(
|
|
256
|
+
(a, b) => b.startedAt - a.startedAt,
|
|
257
|
+
),
|
|
258
|
+
sawEveryHistoryRow: historyRows.length < historyLimit,
|
|
259
|
+
};
|
|
260
|
+
}
|
package/src/apps/app-store.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type { EditEngineResult } from "../tools/shared/filesystem/edit-engine.js
|
|
|
43
43
|
import { applyEdit } from "../tools/shared/filesystem/edit-engine.js";
|
|
44
44
|
import { getLogger } from "../util/logger.js";
|
|
45
45
|
import { getDataDir, getWorkspacePluginsDir } from "../util/platform.js";
|
|
46
|
+
import { hasPluginManifest } from "../util/plugin-manifest.js";
|
|
46
47
|
|
|
47
48
|
const log = getLogger("app-store");
|
|
48
49
|
|
|
@@ -787,8 +788,8 @@ function listAppsForPlugin(
|
|
|
787
788
|
* `<workspace>/plugins/<name>/apps/`.
|
|
788
789
|
*
|
|
789
790
|
* Plugin discovery mirrors the plugin loader's `scanPlugins`: a plugin is an
|
|
790
|
-
* entry that resolves to a directory (following symlinks) and carries a
|
|
791
|
-
*
|
|
791
|
+
* entry that resolves to a directory (following symlinks) and carries a root
|
|
792
|
+
* plugin manifest. Stray directories without a manifest are ignored,
|
|
792
793
|
* and disabled plugins (those with a `.disabled` sentinel) contribute nothing,
|
|
793
794
|
* matching how their other surfaces (tools, hooks, routes) are gated.
|
|
794
795
|
*/
|
|
@@ -813,7 +814,7 @@ export function listPluginApps(): EnumeratedApp[] {
|
|
|
813
814
|
} catch {
|
|
814
815
|
continue;
|
|
815
816
|
}
|
|
816
|
-
if (!
|
|
817
|
+
if (!hasPluginManifest(pluginDir)) {
|
|
817
818
|
continue;
|
|
818
819
|
}
|
|
819
820
|
if (isPluginDisabled(name)) {
|
|
@@ -874,7 +875,7 @@ function isSafeIdSegment(segment: string): boolean {
|
|
|
874
875
|
* (`plugins~<name>~<app>`, resolved by direct path build). Returns null when
|
|
875
876
|
* the app does not exist, when the id is not a safe path segment, or when a
|
|
876
877
|
* plugin id fails the same installed-plugin gates as discovery (directory,
|
|
877
|
-
*
|
|
878
|
+
* root manifest, not disabled).
|
|
878
879
|
*/
|
|
879
880
|
export function resolveAppSource(id: string): ResolvedAppSource | null {
|
|
880
881
|
if (id.startsWith(PLUGIN_APP_ID_PREFIX)) {
|
|
@@ -900,7 +901,7 @@ export function resolveAppSource(id: string): ResolvedAppSource | null {
|
|
|
900
901
|
} catch {
|
|
901
902
|
return null;
|
|
902
903
|
}
|
|
903
|
-
if (!
|
|
904
|
+
if (!hasPluginManifest(pluginDir)) {
|
|
904
905
|
return null;
|
|
905
906
|
}
|
|
906
907
|
if (isPluginDisabled(pluginName)) {
|
|
@@ -32,7 +32,8 @@ import {
|
|
|
32
32
|
} from "../tools/browser/browser-execution.js";
|
|
33
33
|
import { browserManager } from "../tools/browser/browser-manager.js";
|
|
34
34
|
import { normalizeBrowserMode } from "../tools/browser/browser-mode.js";
|
|
35
|
-
import type {
|
|
35
|
+
import type { ToolExecutionResult } from "../tools/types.js";
|
|
36
|
+
import type { BrowserOperationContext as ToolContext } from "./types.js";
|
|
36
37
|
import type { BrowserOperation } from "./types.js";
|
|
37
38
|
|
|
38
39
|
// ── Dispatch handlers ────────────────────────────────────────────────
|
package/src/browser/types.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import type { ScopedCdpClient } from "../tools/browser/cdp-client/types.js";
|
|
2
|
+
import type { ToolContext } from "../tools/types.js";
|
|
3
|
+
|
|
4
|
+
export interface BrowserOperationContext extends ToolContext {
|
|
5
|
+
cdpClient?: ScopedCdpClient;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* Canonical browser operation identifiers and typed metadata.
|
|
3
10
|
*
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getConfig } from "../config/loader.js";
|
|
2
|
+
import { isVirtualDesktopEnabled } from "../desktop/virtual-desktop-feature.js";
|
|
3
|
+
import { browserManager } from "../tools/browser/browser-manager.js";
|
|
4
|
+
import { normalizeBrowserMode } from "../tools/browser/browser-mode.js";
|
|
5
|
+
import { getPinnedTab } from "../tools/browser/pinned-tabs.js";
|
|
6
|
+
import type { ToolContext } from "../tools/types.js";
|
|
7
|
+
|
|
8
|
+
export function shouldUseVirtualDesktopBrowser(
|
|
9
|
+
desktop: boolean | undefined,
|
|
10
|
+
input: Record<string, unknown>,
|
|
11
|
+
context: ToolContext,
|
|
12
|
+
): boolean {
|
|
13
|
+
if (desktop !== undefined) {
|
|
14
|
+
return desktop;
|
|
15
|
+
}
|
|
16
|
+
const mode = normalizeBrowserMode(input.browser_mode);
|
|
17
|
+
if (
|
|
18
|
+
"error" in mode ||
|
|
19
|
+
mode.mode !== "auto" ||
|
|
20
|
+
input.target_client_id ||
|
|
21
|
+
input.use_active_tab ||
|
|
22
|
+
context.transportInterface !== "web" ||
|
|
23
|
+
context.clientOs === "macos" ||
|
|
24
|
+
context.clientOs === "windows" ||
|
|
25
|
+
context.clientOs === "linux" ||
|
|
26
|
+
context.trustClass !== "guardian" ||
|
|
27
|
+
!context.sourceActorPrincipalId ||
|
|
28
|
+
browserManager.getPreferredBackendKind(context.conversationId) !== null ||
|
|
29
|
+
getPinnedTab(context.conversationId)
|
|
30
|
+
) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return isVirtualDesktopEnabled(getConfig());
|
|
34
|
+
}
|
|
@@ -185,6 +185,29 @@ describe("createVoiceProgressNarrator", () => {
|
|
|
185
185
|
expect(Date.now() - startedAt).toBeLessThan(1000);
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
test("an update that runs out of budget aborts as a narration timeout, not a session abort", async () => {
|
|
189
|
+
// The budget lapsing means this beat goes unspoken; it does not mean the
|
|
190
|
+
// call ended. Sharing `voice_session_aborted` made a narrator whose budget
|
|
191
|
+
// sat below the model's roundtrip read in the logs as a session dying once
|
|
192
|
+
// per update, which is what hid a turn that had gone silent for minutes.
|
|
193
|
+
let seen: AbortSignal | undefined;
|
|
194
|
+
const narrator = createVoiceProgressNarrator({
|
|
195
|
+
config: VoiceProgressConfigSchema.parse({ generationTimeoutMs: 20 }),
|
|
196
|
+
getProvider: async () =>
|
|
197
|
+
stubProvider((_messages, options) => {
|
|
198
|
+
seen = options?.signal;
|
|
199
|
+
return new Promise<ProviderResponse>(() => {});
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(await narrator.generateProgressText(progressInput)).toBeNull();
|
|
204
|
+
expect(seen?.aborted).toBe(true);
|
|
205
|
+
expect(seen?.reason).toMatchObject({
|
|
206
|
+
kind: "voice_progress_narration_timeout",
|
|
207
|
+
source: "voice-progress-narration",
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
188
211
|
test("a caller abort settles promptly", async () => {
|
|
189
212
|
const narrator = createVoiceProgressNarrator({
|
|
190
213
|
config: VoiceProgressConfigSchema.parse({
|
|
@@ -154,11 +154,18 @@ describe("session control markers", () => {
|
|
|
154
154
|
expect(isIncompleteControlMarkerTail("[MUTE:30]")).toBe(false);
|
|
155
155
|
expect(isIncompleteControlMarkerTail("[UPDATES:FEW")).toBe(true);
|
|
156
156
|
expect(isIncompleteControlMarkerTail("[UPDATES:FEWER]")).toBe(false);
|
|
157
|
+
expect(isIncompleteControlMarkerTail("[LOOK:SCR")).toBe(true);
|
|
158
|
+
expect(
|
|
159
|
+
stripInternalSpeechMarkers("Taking a look. [LOOK:SCREEN]").trim(),
|
|
160
|
+
).toBe("Taking a look.");
|
|
157
161
|
});
|
|
158
162
|
|
|
159
163
|
test.each([
|
|
160
164
|
["Okay, talk soon. [END_CALL]", { action: "end" }],
|
|
161
165
|
["Muted. [MUTE]", { action: "mute" }],
|
|
166
|
+
["Taking a look. [LOOK:SCREEN]", { action: "look_screen" }],
|
|
167
|
+
["Show me. [LOOK:CAMERA]", { action: "look_camera" }],
|
|
168
|
+
["Okay, I'll stop looking. [LOOK:STOP]", { action: "look_stop" }],
|
|
162
169
|
[
|
|
163
170
|
"I'll check in less. [UPDATES:FEWER]",
|
|
164
171
|
{ action: "updates", cadence: "fewer" },
|