@vellumai/assistant 0.12.2-staging.2 → 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/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/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/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/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__/plugins-routes.test.ts +25 -3
- 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/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.
|
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" },
|
|
@@ -2146,6 +2146,68 @@ describe("front-door leg tool suppression", () => {
|
|
|
2146
2146
|
});
|
|
2147
2147
|
});
|
|
2148
2148
|
|
|
2149
|
+
describe("desktop skill preactivation", () => {
|
|
2150
|
+
async function preactivatedFor(turn: Record<string, unknown>): Promise<{
|
|
2151
|
+
skillIds: string[];
|
|
2152
|
+
proxyInterfaces: unknown[];
|
|
2153
|
+
skillIdsDuringLoop: string[];
|
|
2154
|
+
}> {
|
|
2155
|
+
const skillIds: string[] = [];
|
|
2156
|
+
const proxyInterfaces: unknown[] = [];
|
|
2157
|
+
let skillIdsDuringLoop: string[] = [];
|
|
2158
|
+
const fake = makeFakeConversation({
|
|
2159
|
+
processing: false,
|
|
2160
|
+
runAgentLoop: async () => {
|
|
2161
|
+
skillIdsDuringLoop = [...skillIds];
|
|
2162
|
+
},
|
|
2163
|
+
});
|
|
2164
|
+
Object.assign(fake.conversation, {
|
|
2165
|
+
addPreactivatedSkillId: (id: string) => {
|
|
2166
|
+
skillIds.push(id);
|
|
2167
|
+
},
|
|
2168
|
+
ensureHostProxiesForTurn: (sourceInterface: unknown) => {
|
|
2169
|
+
proxyInterfaces.push(sourceInterface);
|
|
2170
|
+
},
|
|
2171
|
+
});
|
|
2172
|
+
fakeConversation = fake.conversation;
|
|
2173
|
+
|
|
2174
|
+
await startVoiceTurn({
|
|
2175
|
+
...makeTurnOptions(undefined, "conv-desktop-skills"),
|
|
2176
|
+
userMessageInterface: "macos",
|
|
2177
|
+
...turn,
|
|
2178
|
+
});
|
|
2179
|
+
await flushMicrotasks();
|
|
2180
|
+
return { skillIds, proxyInterfaces, skillIdsDuringLoop };
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
test("an escalated leg of a macOS desktop session starts with computer use active", async () => {
|
|
2184
|
+
const result = await preactivatedFor({
|
|
2185
|
+
routingLeg: "escalated",
|
|
2186
|
+
macosDesktopSession: true,
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
expect(result.skillIdsDuringLoop).toContain("computer-use");
|
|
2190
|
+
expect(result.proxyInterfaces).toEqual(["macos"]);
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
test("a session from any other client leaves the desktop skills to be loaded", async () => {
|
|
2194
|
+
const result = await preactivatedFor({ routingLeg: "escalated" });
|
|
2195
|
+
|
|
2196
|
+
expect(result.skillIds).toEqual([]);
|
|
2197
|
+
expect(result.proxyInterfaces).toEqual([]);
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
test("the toolless front-door leg is never preactivated", async () => {
|
|
2201
|
+
const result = await preactivatedFor({
|
|
2202
|
+
routingLeg: "front-door",
|
|
2203
|
+
macosDesktopSession: true,
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
expect(result.skillIds).toEqual([]);
|
|
2207
|
+
expect(result.proxyInterfaces).toEqual([]);
|
|
2208
|
+
});
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2149
2211
|
describe("cutFrontDoorContentAtVerdict", () => {
|
|
2150
2212
|
test("null when the content carries no verdict token (committed answer)", () => {
|
|
2151
2213
|
expect(
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
ProviderResponse,
|
|
17
17
|
ToolDefinition,
|
|
18
18
|
} from "../providers/types.js";
|
|
19
|
-
import { createAbortReason } from "../util/abort-reasons.js";
|
|
19
|
+
import { createAbortReason, isAbortReason } from "../util/abort-reasons.js";
|
|
20
20
|
import { getLogger } from "../util/logger.js";
|
|
21
21
|
|
|
22
22
|
const log = getLogger("voice-progress-narration");
|
|
@@ -164,7 +164,10 @@ async function requestBoundedResponse(args: {
|
|
|
164
164
|
const timeoutTimer = setTimeout(
|
|
165
165
|
() =>
|
|
166
166
|
timeoutController.abort(
|
|
167
|
-
createAbortReason(
|
|
167
|
+
createAbortReason(
|
|
168
|
+
"voice_progress_narration_timeout",
|
|
169
|
+
"voice-progress-narration",
|
|
170
|
+
),
|
|
168
171
|
),
|
|
169
172
|
args.timeoutMs,
|
|
170
173
|
);
|
|
@@ -260,9 +263,19 @@ export function createVoiceProgressNarrator(options: {
|
|
|
260
263
|
}
|
|
261
264
|
return trimmed;
|
|
262
265
|
} catch (error) {
|
|
266
|
+
// Why the beat went unspoken, as one greppable field. A narrator whose
|
|
267
|
+
// budget is below the model's real roundtrip drops every update at the
|
|
268
|
+
// deadline and reads in the logs as a run of unrelated failures; naming
|
|
269
|
+
// the outcome is what makes that run legible as "the feature is off".
|
|
270
|
+
const outcome = isAbortReason(error)
|
|
271
|
+
? error.kind === "voice_progress_narration_timeout"
|
|
272
|
+
? "timed_out"
|
|
273
|
+
: "session_ended"
|
|
274
|
+
: "error";
|
|
263
275
|
log.info(
|
|
264
276
|
{
|
|
265
277
|
error,
|
|
278
|
+
outcome,
|
|
266
279
|
providerResolveMs,
|
|
267
280
|
totalMs: Math.round(performance.now() - startedAt),
|
|
268
281
|
timeoutMs: options.config.generationTimeoutMs,
|
|
@@ -60,6 +60,18 @@ export const FEWER_UPDATES_MARKER = "[UPDATES:FEWER]";
|
|
|
60
60
|
export const NORMAL_UPDATES_MARKER = "[UPDATES:NORMAL]";
|
|
61
61
|
const UPDATES_MARKER_PREFIX = "[UPDATES:";
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Look session controls for live voice: turn on a screen share
|
|
65
|
+
* ({@link LOOK_SCREEN_MARKER}) or the camera ({@link LOOK_CAMERA_MARKER}) so
|
|
66
|
+
* the call can see what the user means by "take a look". Carried out by the
|
|
67
|
+
* client, like mute and end.
|
|
68
|
+
*/
|
|
69
|
+
export const LOOK_SCREEN_MARKER = "[LOOK:SCREEN]";
|
|
70
|
+
export const LOOK_CAMERA_MARKER = "[LOOK:CAMERA]";
|
|
71
|
+
/** Stop showing the call the screen and the camera, whichever is on. */
|
|
72
|
+
export const LOOK_STOP_MARKER = "[LOOK:STOP]";
|
|
73
|
+
const LOOK_MARKER_PREFIX = "[LOOK:";
|
|
74
|
+
|
|
63
75
|
// ---------------------------------------------------------------------------
|
|
64
76
|
// Regexes
|
|
65
77
|
// ---------------------------------------------------------------------------
|
|
@@ -82,6 +94,7 @@ const ESCALATE_VERDICT_TOKEN_REGEX = /\[1\]/g;
|
|
|
82
94
|
const MINIMIZE_ROOM_MARKER_REGEX = /\[-1\]/g;
|
|
83
95
|
const MUTE_MARKER_REGEX = /\[MUTE(?::\s*[^\]]*)?\]/g;
|
|
84
96
|
const UPDATES_MARKER_REGEX = /\[UPDATES:\s*[^\]]*\]/g;
|
|
97
|
+
const LOOK_MARKER_REGEX = /\[LOOK:\s*[^\]]*\]/g;
|
|
85
98
|
const GUARDIAN_TIMEOUT_MARKER_REGEX = /\[GUARDIAN_TIMEOUT\]/g;
|
|
86
99
|
const GUARDIAN_UNAVAILABLE_MARKER_REGEX = /\[GUARDIAN_UNAVAILABLE\]/g;
|
|
87
100
|
|
|
@@ -209,6 +222,7 @@ export function stripInternalSpeechMarkers(text: string): string {
|
|
|
209
222
|
.replace(MINIMIZE_ROOM_MARKER_REGEX, "")
|
|
210
223
|
.replace(MUTE_MARKER_REGEX, "")
|
|
211
224
|
.replace(UPDATES_MARKER_REGEX, "")
|
|
225
|
+
.replace(LOOK_MARKER_REGEX, "")
|
|
212
226
|
.replace(GUARDIAN_TIMEOUT_MARKER_REGEX, "")
|
|
213
227
|
.replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, "");
|
|
214
228
|
return result;
|
|
@@ -237,6 +251,7 @@ const CONTROL_MARKER_STRINGS = [
|
|
|
237
251
|
MUTE_MARKER,
|
|
238
252
|
MUTE_MARKER_PREFIX,
|
|
239
253
|
UPDATES_MARKER_PREFIX,
|
|
254
|
+
LOOK_MARKER_PREFIX,
|
|
240
255
|
"[GUARDIAN_TIMEOUT]",
|
|
241
256
|
"[GUARDIAN_UNAVAILABLE]",
|
|
242
257
|
];
|
|
@@ -252,6 +267,7 @@ const FIRST_BRACKET_TERMINATED_PREFIXES = [
|
|
|
252
267
|
"[USER_INSTRUCTION:",
|
|
253
268
|
MUTE_MARKER_PREFIX,
|
|
254
269
|
UPDATES_MARKER_PREFIX,
|
|
270
|
+
LOOK_MARKER_PREFIX,
|
|
255
271
|
];
|
|
256
272
|
|
|
257
273
|
const GUARDIAN_APPROVAL_PREFIX = "[ASK_GUARDIAN_APPROVAL:";
|
|
@@ -339,15 +355,19 @@ export function createControlMarkerHoldback(
|
|
|
339
355
|
/**
|
|
340
356
|
* A session control a live-voice reply asked for with a terminal marker:
|
|
341
357
|
* `end` from {@link END_CALL_MARKER}, `mute` from {@link MUTE_MARKER} or its
|
|
342
|
-
* timed form, `updates` from the progress-cadence markers
|
|
358
|
+
* timed form, `updates` from the progress-cadence markers, `look_screen` and
|
|
359
|
+
* `look_camera` and `look_stop` from the look markers.
|
|
343
360
|
*/
|
|
344
361
|
export type SessionControlRequest =
|
|
345
362
|
| { readonly action: "end" }
|
|
346
363
|
| { readonly action: "mute"; readonly durationMs?: number }
|
|
347
|
-
| { readonly action: "updates"; readonly cadence: "fewer" | "normal" }
|
|
364
|
+
| { readonly action: "updates"; readonly cadence: "fewer" | "normal" }
|
|
365
|
+
| { readonly action: "look_screen" }
|
|
366
|
+
| { readonly action: "look_camera" }
|
|
367
|
+
| { readonly action: "look_stop" };
|
|
348
368
|
|
|
349
369
|
const TERMINAL_SESSION_CONTROL_REGEX =
|
|
350
|
-
/(\[END_CALL\]|\[UPDATES:(FEWER|NORMAL)\]|\[MUTE\]|\[MUTE:\s*([^\]]*)\])\s*$/;
|
|
370
|
+
/(\[END_CALL\]|\[UPDATES:(FEWER|NORMAL)\]|\[LOOK:(SCREEN|CAMERA|STOP)\]|\[MUTE\]|\[MUTE:\s*([^\]]*)\])\s*$/;
|
|
351
371
|
|
|
352
372
|
/**
|
|
353
373
|
* The session control a reply ends with, or null.
|
|
@@ -378,7 +398,17 @@ export function parseTerminalSessionControl(
|
|
|
378
398
|
cadence: match[2] === "FEWER" ? "fewer" : "normal",
|
|
379
399
|
};
|
|
380
400
|
}
|
|
381
|
-
|
|
401
|
+
if (match[3] !== undefined) {
|
|
402
|
+
return {
|
|
403
|
+
action:
|
|
404
|
+
match[3] === "SCREEN"
|
|
405
|
+
? "look_screen"
|
|
406
|
+
: match[3] === "CAMERA"
|
|
407
|
+
? "look_camera"
|
|
408
|
+
: "look_stop",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const seconds = match[4] === undefined ? NaN : Number(match[4].trim());
|
|
382
412
|
if (
|
|
383
413
|
Number.isFinite(seconds) &&
|
|
384
414
|
seconds > 0 &&
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { CONVERSATION_BUSY_MESSAGE } from "../daemon/conversation-messaging.js";
|
|
32
32
|
import { resolveChannelCapabilities } from "../daemon/conversation-runtime-assembly.js";
|
|
33
33
|
import { getOrCreateConversation } from "../daemon/conversation-store.js";
|
|
34
|
+
import { preactivateHostProxySkills } from "../daemon/host-proxy-preactivation.js";
|
|
34
35
|
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
35
36
|
import {
|
|
36
37
|
newestPersistedSightFrame,
|
|
@@ -464,6 +465,14 @@ export interface VoiceTurnOptions {
|
|
|
464
465
|
* path has always used.
|
|
465
466
|
*/
|
|
466
467
|
actorFallbackSuppressed?: boolean;
|
|
468
|
+
/**
|
|
469
|
+
* Whether the session was opened from the macOS desktop client. A leg that
|
|
470
|
+
* can run tools then starts with the desktop skills (computer use, app
|
|
471
|
+
* control, screen annotation) already active, as a typed turn from that
|
|
472
|
+
* client does. The turn interface cannot answer this: every live-voice
|
|
473
|
+
* session reports `macos` for its channel capabilities, iOS included.
|
|
474
|
+
*/
|
|
475
|
+
macosDesktopSession?: boolean;
|
|
467
476
|
/** Whether this is an inbound call (no outbound task). */
|
|
468
477
|
isInbound: boolean;
|
|
469
478
|
/** The outbound call task, if any. */
|
|
@@ -2016,6 +2025,20 @@ export async function startVoiceTurn(
|
|
|
2016
2025
|
(needsImagePin
|
|
2017
2026
|
? VOICE_IMAGE_PROFILE
|
|
2018
2027
|
: (conversationProfile?.profile ?? null));
|
|
2028
|
+
if (opts.macosDesktopSession === true && !frontDoorToolsSuppressed) {
|
|
2029
|
+
const sourceInterface = turnInterfaceContext.userMessageInterface;
|
|
2030
|
+
const sourceActorPrincipalId =
|
|
2031
|
+
voiceTurnValues.actorPrincipalId ?? undefined;
|
|
2032
|
+
conversation.ensureHostProxiesForTurn(
|
|
2033
|
+
sourceInterface,
|
|
2034
|
+
sourceActorPrincipalId,
|
|
2035
|
+
);
|
|
2036
|
+
preactivateHostProxySkills(
|
|
2037
|
+
conversation,
|
|
2038
|
+
sourceInterface,
|
|
2039
|
+
sourceActorPrincipalId,
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2019
2042
|
await conversation.runAgentLoop(persistedContent, messageId, {
|
|
2020
2043
|
onEvent: (msg: AssistantEvent) => {
|
|
2021
2044
|
if (msg.type === "assistant_turn_start") {
|