@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
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
* state or the daemon conversation store.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
afterAll,
|
|
11
|
+
afterEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
mock,
|
|
15
|
+
spyOn,
|
|
16
|
+
test,
|
|
17
|
+
} from "bun:test";
|
|
18
|
+
|
|
19
|
+
import { desktopDependencyInstaller } from "../../desktop/desktop-dependencies.js";
|
|
20
|
+
import * as desktopFeature from "../../desktop/virtual-desktop-feature.js";
|
|
21
|
+
import { browserManager } from "../../tools/browser/browser-manager.js";
|
|
11
22
|
import type { ToolExecutionResult } from "../../tools/types.js";
|
|
12
23
|
|
|
13
24
|
// ---------------------------------------------------------------------------
|
|
@@ -31,7 +42,10 @@ let mockOperationCalls: Array<{
|
|
|
31
42
|
let mockConversation: {
|
|
32
43
|
trustContext?: { trustClass: string };
|
|
33
44
|
transportInterface?: string;
|
|
45
|
+
currentTurnClientOs?: string;
|
|
46
|
+
clientOs?: string;
|
|
34
47
|
getTurnActorPrincipalId?: () => string | undefined;
|
|
48
|
+
abortController?: AbortController;
|
|
35
49
|
} | null = null;
|
|
36
50
|
|
|
37
51
|
let mockFindConversationCalls: string[] = [];
|
|
@@ -66,6 +80,47 @@ mock.module("../../daemon/conversation-registry.js", () => ({
|
|
|
66
80
|
},
|
|
67
81
|
}));
|
|
68
82
|
|
|
83
|
+
let desktopEnabled = false;
|
|
84
|
+
let desktopReady = false;
|
|
85
|
+
let desktopFailure = false;
|
|
86
|
+
const enabledSpy = spyOn(
|
|
87
|
+
desktopFeature,
|
|
88
|
+
"isVirtualDesktopEnabled",
|
|
89
|
+
).mockImplementation(() => desktopEnabled);
|
|
90
|
+
const readySpy = spyOn(
|
|
91
|
+
desktopDependencyInstaller,
|
|
92
|
+
"getStatus",
|
|
93
|
+
).mockImplementation(() => ({ state: desktopReady ? "ready" : "required" }));
|
|
94
|
+
afterAll(() => {
|
|
95
|
+
enabledSpy.mockRestore();
|
|
96
|
+
readySpy.mockRestore();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
let desktopContext: import("../../tools/types.js").ToolContext | undefined;
|
|
100
|
+
mock.module("../../desktop/desktop-browser-operations.js", () => ({
|
|
101
|
+
executeDesktopBrowserTabs: async (
|
|
102
|
+
_params: unknown,
|
|
103
|
+
context: import("../../tools/types.js").ToolContext,
|
|
104
|
+
) => {
|
|
105
|
+
desktopContext = context;
|
|
106
|
+
return {
|
|
107
|
+
content: JSON.stringify({ ok: true, tabs: [{ tabId: 1 }] }),
|
|
108
|
+
isError: false,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
executeDesktopBrowserOperation: async (
|
|
112
|
+
_operation: string,
|
|
113
|
+
_input: Record<string, unknown>,
|
|
114
|
+
context: import("../../tools/types.js").ToolContext,
|
|
115
|
+
) => {
|
|
116
|
+
desktopContext = context;
|
|
117
|
+
return {
|
|
118
|
+
content: desktopFailure ? "Desktop is busy" : "desktop",
|
|
119
|
+
isError: desktopFailure,
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
|
|
69
124
|
// Import after mocking — now from the shared routes location
|
|
70
125
|
const { ROUTES, browserCliConversationKey } =
|
|
71
126
|
await import("../../runtime/routes/browser-routes.js");
|
|
@@ -92,6 +147,11 @@ function callHandler(
|
|
|
92
147
|
// ---------------------------------------------------------------------------
|
|
93
148
|
|
|
94
149
|
afterEach(() => {
|
|
150
|
+
desktopEnabled = false;
|
|
151
|
+
desktopReady = false;
|
|
152
|
+
desktopFailure = false;
|
|
153
|
+
desktopContext = undefined;
|
|
154
|
+
browserManager.clearPreferredBackendKind("conv-default-browser");
|
|
95
155
|
mockOperationResult = { content: "ok", isError: false };
|
|
96
156
|
mockOperationCalls = [];
|
|
97
157
|
mockConversation = null;
|
|
@@ -422,3 +482,171 @@ describe("browser_execute route", () => {
|
|
|
422
482
|
expect(mockOperationCalls[0].input).toEqual({});
|
|
423
483
|
});
|
|
424
484
|
});
|
|
485
|
+
|
|
486
|
+
test("desktop route preserves guardian ownership and live-turn cancellation", async () => {
|
|
487
|
+
const abortController = new AbortController();
|
|
488
|
+
mockConversation = {
|
|
489
|
+
trustContext: { trustClass: "guardian" },
|
|
490
|
+
getTurnActorPrincipalId: () => "user-123",
|
|
491
|
+
abortController,
|
|
492
|
+
};
|
|
493
|
+
const result = await callHandler(
|
|
494
|
+
{ operation: "snapshot", desktop: true, conversationId: "conv-desktop" },
|
|
495
|
+
{ "x-vellum-actor-principal-id": "user-other" },
|
|
496
|
+
);
|
|
497
|
+
expect(result).toMatchObject({ content: "desktop" });
|
|
498
|
+
expect(mockOperationCalls).toHaveLength(0);
|
|
499
|
+
expect(desktopContext).toMatchObject({
|
|
500
|
+
conversationId: "conv-desktop",
|
|
501
|
+
sourceActorPrincipalId: "user-123",
|
|
502
|
+
trustClass: "guardian",
|
|
503
|
+
});
|
|
504
|
+
expect(desktopContext?.signal?.aborted).toBe(false);
|
|
505
|
+
abortController.abort();
|
|
506
|
+
expect(desktopContext?.signal?.aborted).toBe(true);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
function webConversation(clientOs = "web") {
|
|
510
|
+
desktopEnabled = true;
|
|
511
|
+
desktopReady = true;
|
|
512
|
+
mockConversation = {
|
|
513
|
+
trustContext: { trustClass: "guardian" },
|
|
514
|
+
transportInterface: "web",
|
|
515
|
+
currentTurnClientOs: clientOs,
|
|
516
|
+
getTurnActorPrincipalId: () => "user-123",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
test("web browser commands default to the installed streamed Chrome", async () => {
|
|
521
|
+
webConversation();
|
|
522
|
+
const result = await callHandler({
|
|
523
|
+
operation: "navigate",
|
|
524
|
+
input: { url: "https://example.com" },
|
|
525
|
+
conversationId: "conv-default-browser",
|
|
526
|
+
});
|
|
527
|
+
expect(result).toMatchObject({ content: "desktop", isError: false });
|
|
528
|
+
expect(mockOperationCalls).toHaveLength(0);
|
|
529
|
+
expect(desktopContext?.clientOs).toBe("web");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test.each(["macos", "windows"])(
|
|
533
|
+
"%s renderer uses the personal browser despite its web transport",
|
|
534
|
+
async (clientOs) => {
|
|
535
|
+
webConversation(clientOs);
|
|
536
|
+
await callHandler({
|
|
537
|
+
operation: "snapshot",
|
|
538
|
+
conversationId: "conv-default-browser",
|
|
539
|
+
});
|
|
540
|
+
expect(mockOperationCalls).toHaveLength(1);
|
|
541
|
+
expect(desktopContext).toBeUndefined();
|
|
542
|
+
},
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
test("browser selection uses the active turn rather than a queued message's OS", async () => {
|
|
546
|
+
webConversation("macos");
|
|
547
|
+
mockConversation!.clientOs = "web";
|
|
548
|
+
await callHandler({
|
|
549
|
+
operation: "snapshot",
|
|
550
|
+
conversationId: "conv-default-browser",
|
|
551
|
+
});
|
|
552
|
+
expect(mockOperationCalls).toHaveLength(1);
|
|
553
|
+
expect(desktopContext).toBeUndefined();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test.each(["flag", "guardian", "actor"])(
|
|
557
|
+
"web preserves the existing browser when %s is unavailable",
|
|
558
|
+
async (missing) => {
|
|
559
|
+
webConversation();
|
|
560
|
+
if (missing === "flag") {
|
|
561
|
+
desktopEnabled = false;
|
|
562
|
+
}
|
|
563
|
+
if (missing === "guardian") {
|
|
564
|
+
mockConversation!.trustContext = { trustClass: "unknown" };
|
|
565
|
+
}
|
|
566
|
+
if (missing === "actor") {
|
|
567
|
+
mockConversation!.getTurnActorPrincipalId = () => undefined;
|
|
568
|
+
}
|
|
569
|
+
await callHandler({
|
|
570
|
+
operation: "snapshot",
|
|
571
|
+
conversationId: "conv-default-browser",
|
|
572
|
+
});
|
|
573
|
+
expect(mockOperationCalls).toHaveLength(1);
|
|
574
|
+
expect(desktopContext).toBeUndefined();
|
|
575
|
+
},
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
test.each([
|
|
579
|
+
{ browser_mode: "local" },
|
|
580
|
+
{ browser_mode: "playwright" },
|
|
581
|
+
{ browser_mode: "extension" },
|
|
582
|
+
{ browser_mode: "cdp-inspect" },
|
|
583
|
+
{ target_client_id: "client-123" },
|
|
584
|
+
{ use_active_tab: true },
|
|
585
|
+
])("explicit browser choice %j wins over the web default", async (input) => {
|
|
586
|
+
webConversation();
|
|
587
|
+
await callHandler({
|
|
588
|
+
operation: "snapshot",
|
|
589
|
+
input,
|
|
590
|
+
conversationId: "conv-default-browser",
|
|
591
|
+
});
|
|
592
|
+
expect(mockOperationCalls).toHaveLength(1);
|
|
593
|
+
expect(desktopContext).toBeUndefined();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("follow-up web commands retain an existing personal browser session", async () => {
|
|
597
|
+
webConversation();
|
|
598
|
+
browserManager.setPreferredBackendKind("conv-default-browser", "extension");
|
|
599
|
+
await callHandler({
|
|
600
|
+
operation: "snapshot",
|
|
601
|
+
conversationId: "conv-default-browser",
|
|
602
|
+
});
|
|
603
|
+
expect(mockOperationCalls).toHaveLength(1);
|
|
604
|
+
expect(desktopContext).toBeUndefined();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("native clients can explicitly select the streamed browser", async () => {
|
|
608
|
+
webConversation("windows");
|
|
609
|
+
const result = await callHandler({
|
|
610
|
+
operation: "snapshot",
|
|
611
|
+
desktop: true,
|
|
612
|
+
conversationId: "conv-default-browser",
|
|
613
|
+
});
|
|
614
|
+
expect(result).toMatchObject({ content: "desktop" });
|
|
615
|
+
expect(desktopContext?.clientOs).toBe("windows");
|
|
616
|
+
expect(mockOperationCalls).toHaveLength(0);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("web tab commands share the streamed browser default", async () => {
|
|
620
|
+
webConversation();
|
|
621
|
+
const { ROUTES } =
|
|
622
|
+
await import("../../runtime/routes/browser-tabs-routes.js");
|
|
623
|
+
const result = await ROUTES[0].handler({
|
|
624
|
+
body: { command: "list", conversationId: "conv-default-browser" },
|
|
625
|
+
});
|
|
626
|
+
expect(result).toEqual({ ok: true, tabs: [{ tabId: 1 }] });
|
|
627
|
+
expect(desktopContext?.clientOs).toBe("web");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("a streamed browser failure does not switch to Playwright or personal Chrome", async () => {
|
|
631
|
+
webConversation();
|
|
632
|
+
desktopFailure = true;
|
|
633
|
+
const result = await callHandler({
|
|
634
|
+
operation: "click",
|
|
635
|
+
input: { selector: "#submit" },
|
|
636
|
+
conversationId: "conv-default-browser",
|
|
637
|
+
});
|
|
638
|
+
expect(result).toMatchObject({ isError: true, content: "Desktop is busy" });
|
|
639
|
+
expect(mockOperationCalls).toHaveLength(0);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("first web browser use routes to virtual desktop setup before installation", async () => {
|
|
643
|
+
webConversation();
|
|
644
|
+
desktopReady = false;
|
|
645
|
+
await callHandler({
|
|
646
|
+
operation: "navigate",
|
|
647
|
+
input: { url: "https://example.com" },
|
|
648
|
+
conversationId: "conv-default-browser",
|
|
649
|
+
});
|
|
650
|
+
expect(desktopContext?.clientOs).toBe("web");
|
|
651
|
+
expect(mockOperationCalls).toHaveLength(0);
|
|
652
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { setDbReady } from "../../daemon/daemon-readiness.js";
|
|
4
|
+
import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
|
|
5
|
+
import { AssistantIpcServer } from "../assistant-server.js";
|
|
6
|
+
import { cliIpcCall } from "../cli-client.js";
|
|
7
|
+
|
|
8
|
+
let server: AssistantIpcServer | undefined;
|
|
9
|
+
afterEach(() => server?.stop());
|
|
10
|
+
|
|
11
|
+
for (const cancelOnDisconnect of [true, false]) {
|
|
12
|
+
test(`timed-out IPC request cancels its action only when opted in: ${cancelOnDisconnect}`, async () => {
|
|
13
|
+
setDbReady(true);
|
|
14
|
+
server = new AssistantIpcServer();
|
|
15
|
+
const methods = (
|
|
16
|
+
server as unknown as {
|
|
17
|
+
methods: Map<string, (args: RouteHandlerArgs) => Promise<unknown>>;
|
|
18
|
+
}
|
|
19
|
+
).methods;
|
|
20
|
+
const setup = Promise.withResolvers<void>();
|
|
21
|
+
const completed = Promise.withResolvers<void>();
|
|
22
|
+
let signal: AbortSignal | undefined;
|
|
23
|
+
let actions = 0;
|
|
24
|
+
methods.set("test_browser_setup", async (args) => {
|
|
25
|
+
signal = args.abortSignal;
|
|
26
|
+
await setup.promise;
|
|
27
|
+
if (!signal?.aborted) {
|
|
28
|
+
actions++;
|
|
29
|
+
}
|
|
30
|
+
completed.resolve();
|
|
31
|
+
return { ok: true };
|
|
32
|
+
});
|
|
33
|
+
await server.start();
|
|
34
|
+
try {
|
|
35
|
+
const result = await cliIpcCall(
|
|
36
|
+
"test_browser_setup",
|
|
37
|
+
{},
|
|
38
|
+
{
|
|
39
|
+
timeoutMs: 100,
|
|
40
|
+
cancelOnDisconnect,
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
expect(result.timedOut).toBe(true);
|
|
44
|
+
expect(signal).toBeDefined();
|
|
45
|
+
await Bun.sleep(20);
|
|
46
|
+
setup.resolve();
|
|
47
|
+
await completed.promise;
|
|
48
|
+
expect(signal?.aborted).toBe(cancelOnDisconnect);
|
|
49
|
+
expect(actions).toBe(cancelOnDisconnect ? 0 : 1);
|
|
50
|
+
} finally {
|
|
51
|
+
setup.resolve();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -78,6 +78,7 @@ const log = getLogger("assistant-ipc-server");
|
|
|
78
78
|
// ---------------------------------------------------------------------------
|
|
79
79
|
|
|
80
80
|
export type IpcRequest = {
|
|
81
|
+
cancelOnDisconnect?: boolean;
|
|
81
82
|
id: string;
|
|
82
83
|
method: string;
|
|
83
84
|
params?: Record<string, unknown>;
|
|
@@ -404,6 +405,16 @@ export class AssistantIpcServer {
|
|
|
404
405
|
this.abortControllers.set(req.id, abortController);
|
|
405
406
|
}
|
|
406
407
|
|
|
408
|
+
const onDisconnect = () => abortController?.abort();
|
|
409
|
+
if (req.cancelOnDisconnect === true) {
|
|
410
|
+
socket.once("close", onDisconnect);
|
|
411
|
+
}
|
|
412
|
+
const removeDisconnectListener = () => {
|
|
413
|
+
if (req.cancelOnDisconnect === true) {
|
|
414
|
+
socket.off("close", onDisconnect);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
407
418
|
try {
|
|
408
419
|
const handlerArgs = {
|
|
409
420
|
...injectLocalActorHeader(req.params),
|
|
@@ -434,14 +445,17 @@ export class AssistantIpcServer {
|
|
|
434
445
|
reader,
|
|
435
446
|
this.buildErrorResponse(req.id, err),
|
|
436
447
|
);
|
|
437
|
-
})
|
|
448
|
+
})
|
|
449
|
+
.finally(removeDisconnectListener);
|
|
438
450
|
} else {
|
|
451
|
+
removeDisconnectListener();
|
|
439
452
|
if (!isIpcStreamingResponse(result)) {
|
|
440
453
|
this.abortControllers.delete(req.id);
|
|
441
454
|
}
|
|
442
455
|
this.sendResult(socket, reader, req.id, result);
|
|
443
456
|
}
|
|
444
457
|
} catch (err) {
|
|
458
|
+
removeDisconnectListener();
|
|
445
459
|
this.abortControllers.delete(req.id);
|
|
446
460
|
log.warn({ err, method: req.method }, "IPC handler error");
|
|
447
461
|
this.sendResponse(socket, reader, this.buildErrorResponse(req.id, err));
|
package/src/ipc/cli-client.ts
CHANGED
|
@@ -60,9 +60,8 @@ export interface CliIpcCallResult<T = unknown> {
|
|
|
60
60
|
errorDetails?: unknown;
|
|
61
61
|
/**
|
|
62
62
|
* Set when the call was abandoned because `timeoutMs` elapsed with no
|
|
63
|
-
* response.
|
|
64
|
-
*
|
|
65
|
-
* socket does not abort the handler. Callers that would otherwise retry a
|
|
63
|
+
* response. The request was delivered and may still be executing unless
|
|
64
|
+
* `cancelOnDisconnect` was requested. Callers that would otherwise retry a
|
|
66
65
|
* transport failure must not retry this one until the original can no
|
|
67
66
|
* longer be in flight.
|
|
68
67
|
*/
|
|
@@ -79,7 +78,11 @@ export interface CliIpcCallResult<T = unknown> {
|
|
|
79
78
|
export async function cliIpcCall<T = unknown>(
|
|
80
79
|
method: string,
|
|
81
80
|
params?: Record<string, unknown>,
|
|
82
|
-
options?: {
|
|
81
|
+
options?: {
|
|
82
|
+
timeoutMs?: number;
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
cancelOnDisconnect?: boolean;
|
|
85
|
+
},
|
|
83
86
|
): Promise<CliIpcCallResult<T>> {
|
|
84
87
|
if (options?.signal?.aborted) {
|
|
85
88
|
throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
@@ -180,7 +183,12 @@ export async function cliIpcCall<T = unknown>(
|
|
|
180
183
|
|
|
181
184
|
socket.on("connect", () => {
|
|
182
185
|
clearTimeout(connectTimer);
|
|
183
|
-
writeMessage(socket, {
|
|
186
|
+
writeMessage(socket, {
|
|
187
|
+
id: reqId,
|
|
188
|
+
method,
|
|
189
|
+
params,
|
|
190
|
+
...(opts?.cancelOnDisconnect ? { cancelOnDisconnect: true } : {}),
|
|
191
|
+
});
|
|
184
192
|
|
|
185
193
|
callTimer = setTimeout(() => {
|
|
186
194
|
log.debug(
|
|
@@ -980,3 +980,146 @@ describe("LiveVoiceSession room reveal", () => {
|
|
|
980
980
|
expect(ttsTexts.join(" ")).not.toContain("ASK_GUARDIAN_APPROVAL");
|
|
981
981
|
});
|
|
982
982
|
});
|
|
983
|
+
|
|
984
|
+
describe("LiveVoiceSession spoken session controls", () => {
|
|
985
|
+
function createControlsHarness(
|
|
986
|
+
sessionControls?: LiveVoiceClientStartFrame["sessionControls"],
|
|
987
|
+
) {
|
|
988
|
+
const { startVoiceTurn, getCallbacks } = createCapturingTurnStarter();
|
|
989
|
+
const { streamTtsAudio, ttsTexts } = createRecordingTtsStreamer();
|
|
990
|
+
const harness = createSessionHarness({
|
|
991
|
+
startVoiceTurn,
|
|
992
|
+
streamTtsAudio,
|
|
993
|
+
startFrame: {
|
|
994
|
+
...START_FRAME,
|
|
995
|
+
...(sessionControls ? { sessionControls } : {}),
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
return { ...harness, getCallbacks, ttsTexts };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
test("a reply ending with [END_CALL] sends an end control after tts_done", async () => {
|
|
1002
|
+
const { frames, session, getCallbacks, ttsTexts } = createControlsHarness([
|
|
1003
|
+
"end",
|
|
1004
|
+
"mute",
|
|
1005
|
+
]);
|
|
1006
|
+
|
|
1007
|
+
await startReleasedTurn(session, getCallbacks);
|
|
1008
|
+
emitTextDelta(getCallbacks, "Okay, talk soon. [END_CALL]");
|
|
1009
|
+
emitMessageComplete(getCallbacks);
|
|
1010
|
+
await waitFor(() =>
|
|
1011
|
+
frames.some((frame) => frame.type === "session_control"),
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
const ttsDoneIndex = frames.findIndex((frame) => frame.type === "tts_done");
|
|
1015
|
+
const controlIndex = frames.findIndex(
|
|
1016
|
+
(frame) => frame.type === "session_control",
|
|
1017
|
+
);
|
|
1018
|
+
// The goodbye is heard before the call ends.
|
|
1019
|
+
expect(controlIndex).toBeGreaterThan(ttsDoneIndex);
|
|
1020
|
+
expect(frames[controlIndex]).toMatchObject({
|
|
1021
|
+
type: "session_control",
|
|
1022
|
+
turnId: "live-turn-1",
|
|
1023
|
+
action: "end",
|
|
1024
|
+
});
|
|
1025
|
+
expect(assistantDeltaTexts(frames).join("").trim()).toBe(
|
|
1026
|
+
"Okay, talk soon.",
|
|
1027
|
+
);
|
|
1028
|
+
expect(ttsTexts.join(" ")).not.toContain("[END_CALL]");
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test("a timed mute carries its duration", async () => {
|
|
1032
|
+
const { frames, session, getCallbacks } = createControlsHarness(["mute"]);
|
|
1033
|
+
|
|
1034
|
+
await startReleasedTurn(session, getCallbacks);
|
|
1035
|
+
emitTextDelta(getCallbacks, "Muting you for thirty seconds. [MUTE:30]");
|
|
1036
|
+
emitMessageComplete(getCallbacks);
|
|
1037
|
+
await waitFor(() =>
|
|
1038
|
+
frames.some((frame) => frame.type === "session_control"),
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
expect(
|
|
1042
|
+
frames.find((frame) => frame.type === "session_control"),
|
|
1043
|
+
).toMatchObject({ action: "mute", durationMs: 30_000 });
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("a declared look sends its look control after the acknowledgement", async () => {
|
|
1047
|
+
const { frames, session, getCallbacks } = createControlsHarness([
|
|
1048
|
+
"look_screen",
|
|
1049
|
+
]);
|
|
1050
|
+
|
|
1051
|
+
await startReleasedTurn(session, getCallbacks);
|
|
1052
|
+
emitTextDelta(
|
|
1053
|
+
getCallbacks,
|
|
1054
|
+
"Taking a look. What should I focus on? [LOOK:SCREEN]",
|
|
1055
|
+
);
|
|
1056
|
+
emitMessageComplete(getCallbacks);
|
|
1057
|
+
await waitFor(() =>
|
|
1058
|
+
frames.some((frame) => frame.type === "session_control"),
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
expect(
|
|
1062
|
+
frames.find((frame) => frame.type === "session_control"),
|
|
1063
|
+
).toMatchObject({ action: "look_screen" });
|
|
1064
|
+
// The marker is control, never caption text.
|
|
1065
|
+
expect(assistantDeltaTexts(frames).join("").includes("[LOOK")).toBe(false);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test("a control the client did not declare is never sent", async () => {
|
|
1069
|
+
const { frames, session, getCallbacks } = createControlsHarness(["mute"]);
|
|
1070
|
+
|
|
1071
|
+
await startReleasedTurn(session, getCallbacks);
|
|
1072
|
+
emitTextDelta(getCallbacks, "Bye. [END_CALL]");
|
|
1073
|
+
emitMessageComplete(getCallbacks);
|
|
1074
|
+
await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
|
|
1075
|
+
await flushAsyncCallbacks();
|
|
1076
|
+
|
|
1077
|
+
expect(frames.some((frame) => frame.type === "session_control")).toBe(
|
|
1078
|
+
false,
|
|
1079
|
+
);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
test("a marker mid-reply controls nothing", async () => {
|
|
1083
|
+
const { frames, session, getCallbacks } = createControlsHarness(["end"]);
|
|
1084
|
+
|
|
1085
|
+
await startReleasedTurn(session, getCallbacks);
|
|
1086
|
+
emitTextDelta(getCallbacks, "Say [END_CALL] and I would hang up, but no.");
|
|
1087
|
+
emitMessageComplete(getCallbacks);
|
|
1088
|
+
await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
|
|
1089
|
+
await flushAsyncCallbacks();
|
|
1090
|
+
|
|
1091
|
+
expect(frames.some((frame) => frame.type === "session_control")).toBe(
|
|
1092
|
+
false,
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// Talking over "okay, bye" means they are not leaving.
|
|
1097
|
+
test("an interrupted goodbye does not end the call", async () => {
|
|
1098
|
+
const { frames, session, getCallbacks } = createControlsHarness(["end"]);
|
|
1099
|
+
|
|
1100
|
+
await startReleasedTurn(session, getCallbacks);
|
|
1101
|
+
emitTextDelta(getCallbacks, "Okay, talk soon. [END_CALL]");
|
|
1102
|
+
await flushAsyncCallbacks();
|
|
1103
|
+
await session.handleClientFrame({ type: "interrupt" });
|
|
1104
|
+
emitMessageComplete(getCallbacks);
|
|
1105
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1106
|
+
|
|
1107
|
+
expect(frames.some((frame) => frame.type === "session_control")).toBe(
|
|
1108
|
+
false,
|
|
1109
|
+
);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("the prompt teaches only the declared controls", async () => {
|
|
1113
|
+
const { session, getCallbacks, startVoiceTurn } = createControlsHarness([
|
|
1114
|
+
"mute",
|
|
1115
|
+
]);
|
|
1116
|
+
|
|
1117
|
+
await startReleasedTurn(session, getCallbacks);
|
|
1118
|
+
const prompt =
|
|
1119
|
+
(startVoiceTurn as ReturnType<typeof mock>).mock.calls[0]?.[0]
|
|
1120
|
+
?.voiceControlPrompt ?? "";
|
|
1121
|
+
|
|
1122
|
+
expect(prompt).toContain("[MUTE]");
|
|
1123
|
+
expect(prompt).not.toContain("[END_CALL]");
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
@@ -253,14 +253,16 @@ describe("LiveVoiceSession archive and metrics events", () => {
|
|
|
253
253
|
userMessageInterface: "macos",
|
|
254
254
|
assistantMessageInterface: "macos",
|
|
255
255
|
// Pins the full production control prompt for the FRONT-DOOR leg (the
|
|
256
|
-
// first leg of every routed turn): the speech-first rule
|
|
257
|
-
// else. This leg is toolless, so both the
|
|
258
|
-
// teachings are deliberately absent. It
|
|
259
|
-
// connection to run, and its capability
|
|
260
|
-
// needing a tool to the escalated leg
|
|
261
|
-
// live-voice-triage-escalate.test.ts).
|
|
256
|
+
// first leg of every routed turn): the speech-first rule and the session
|
|
257
|
+
// controls, and nothing else. This leg is toolless, so both the
|
|
258
|
+
// screen-reveal and setup-flow teachings are deliberately absent. It
|
|
259
|
+
// has nothing to show and no connection to run, and its capability
|
|
260
|
+
// digest already routes anything needing a tool to the escalated leg
|
|
261
|
+
// (see live-voice-triage-escalate.test.ts). The start frame declares no
|
|
262
|
+
// client controls, so only the session's own update cadence and the
|
|
263
|
+
// no-looks line are taught.
|
|
262
264
|
voiceControlPrompt:
|
|
263
|
-
|
|
265
|
+
'You are speaking in a local live voice session. Keep each reply to one to three spoken sentences. The caller is listening, not reading: lead with the answer, say it in the fewest words that are still complete, and stop. No preamble, no recap of the question, no closing offer of more. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links), bulleted or numbered lists, or emojis. Write plain conversational text only; if something is naturally a list, say it as one sentence. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work, or a card with an image or a link instead of reading an address out); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. Reply in the language the caller is speaking; if they switch languages, switch with them. The user can also control this call by asking you. Only when they clearly ask:\n- This call cannot turn on a screen share or the camera. If they ask you to look at their screen or at something and you have no other way to see it, say so briefly instead of pretending to look.\n- To hear fewer spoken progress updates while you work (for example "don\'t give me updates so often"), confirm that you will only check in now and then and will tell them when it is done, then end your reply with [UPDATES:FEWER]. If they later want regular updates back, confirm and end with [UPDATES:NORMAL].\nThe marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.',
|
|
264
266
|
});
|
|
265
267
|
callbacks?.assistant_text_delta?.(makeTextDelta("Hello there."));
|
|
266
268
|
callbacks?.message_complete?.(makeMessageComplete());
|
|
@@ -927,3 +927,82 @@ describe("LiveVoiceSession progress narration", () => {
|
|
|
927
927
|
emitMessageComplete(getCallbacks);
|
|
928
928
|
});
|
|
929
929
|
});
|
|
930
|
+
|
|
931
|
+
describe("LiveVoiceSession spoken update cadence", () => {
|
|
932
|
+
async function runTurnAskingFor(
|
|
933
|
+
session: LiveVoiceSession,
|
|
934
|
+
getCallbacks: () => VoiceTurnCallbacks | undefined,
|
|
935
|
+
frames: { type: string }[],
|
|
936
|
+
reply: string,
|
|
937
|
+
): Promise<void> {
|
|
938
|
+
emitTextDelta(getCallbacks, reply);
|
|
939
|
+
emitMessageComplete(getCallbacks);
|
|
940
|
+
await waitFor(
|
|
941
|
+
() => frames.some((frame) => frame.type === "tts_done"),
|
|
942
|
+
"first turn never finished",
|
|
943
|
+
);
|
|
944
|
+
// Let the post-turn re-arm run before releasing the next turn.
|
|
945
|
+
await sleep(20);
|
|
946
|
+
const previous = getCallbacks();
|
|
947
|
+
// Typed, because a manual session's microphone re-arm is not what this
|
|
948
|
+
// exercises: the cadence is the session's, however the next turn arrives.
|
|
949
|
+
await session.handleClientFrame({ type: "text", text: "go ahead" });
|
|
950
|
+
await waitFor(() => getCallbacks() !== previous, "next turn never started");
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
test("after the user asks for fewer updates, tool activity no longer narrates", async () => {
|
|
954
|
+
const generateProgressText = mock(async () => GENERATED_NARRATION);
|
|
955
|
+
const { frames, session, getCallbacks, ttsTexts } = createProgressHarness({
|
|
956
|
+
frontModelConfig: progressConfig({ opsThreshold: 3, minGapMs: 10 }),
|
|
957
|
+
progressNarrator: makeProgressNarrator(generateProgressText),
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
await startReleasedTurn(session, getCallbacks);
|
|
961
|
+
await runTurnAskingFor(
|
|
962
|
+
session,
|
|
963
|
+
getCallbacks,
|
|
964
|
+
frames,
|
|
965
|
+
"Sure, I'll only check in now and then. [UPDATES:FEWER]",
|
|
966
|
+
);
|
|
967
|
+
emitToolStart(getCallbacks, "web_search", "tool-1");
|
|
968
|
+
emitToolResult(getCallbacks, "web_search", "tool-1");
|
|
969
|
+
emitToolStart(getCallbacks, "file_read", "tool-2");
|
|
970
|
+
emitToolResult(getCallbacks, "file_read", "tool-2");
|
|
971
|
+
emitToolStart(getCallbacks, "web_search", "tool-3");
|
|
972
|
+
await sleep(40);
|
|
973
|
+
|
|
974
|
+
expect(generateProgressText).not.toHaveBeenCalled();
|
|
975
|
+
expect(ttsTexts.join(" ")).not.toContain("[UPDATES");
|
|
976
|
+
emitMessageComplete(getCallbacks);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("asking for updates back restores the normal cadence", async () => {
|
|
980
|
+
const generateProgressText = mock(async () => GENERATED_NARRATION);
|
|
981
|
+
const { frames, session, getCallbacks, ttsTexts } = createProgressHarness({
|
|
982
|
+
frontModelConfig: progressConfig({ opsThreshold: 3, minGapMs: 10 }),
|
|
983
|
+
progressNarrator: makeProgressNarrator(generateProgressText),
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
await startReleasedTurn(session, getCallbacks);
|
|
987
|
+
await runTurnAskingFor(
|
|
988
|
+
session,
|
|
989
|
+
getCallbacks,
|
|
990
|
+
frames,
|
|
991
|
+
"Okay, fewer updates. [UPDATES:FEWER]",
|
|
992
|
+
);
|
|
993
|
+
frames.length = 0;
|
|
994
|
+
await runTurnAskingFor(
|
|
995
|
+
session,
|
|
996
|
+
getCallbacks,
|
|
997
|
+
frames,
|
|
998
|
+
"Got it, I'll keep you posted. [UPDATES:NORMAL]",
|
|
999
|
+
);
|
|
1000
|
+
emitToolStart(getCallbacks, "web_search", "tool-1");
|
|
1001
|
+
emitToolStart(getCallbacks, "file_read", "tool-2");
|
|
1002
|
+
emitToolStart(getCallbacks, "web_search", "tool-3");
|
|
1003
|
+
await waitFor(() => ttsTexts.includes(GENERATED_NARRATION));
|
|
1004
|
+
|
|
1005
|
+
expect(generateProgressText).toHaveBeenCalledTimes(1);
|
|
1006
|
+
emitMessageComplete(getCallbacks);
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
@@ -350,6 +350,25 @@ describe("live-voice turn attribution", () => {
|
|
|
350
350
|
expect(options.assistantMessageInterface).toBe("macos");
|
|
351
351
|
});
|
|
352
352
|
|
|
353
|
+
test("a session opened from the macOS desktop client asks for the desktop skills", async () => {
|
|
354
|
+
const options = await captureTurnOptions({
|
|
355
|
+
...START_FRAME,
|
|
356
|
+
client: "macos",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(options.macosDesktopSession).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a session from any other client does not ask for the desktop skills", async () => {
|
|
363
|
+
for (const startFrame of [{ ...START_FRAME, client: "ios" }, START_FRAME]) {
|
|
364
|
+
const options = await captureTurnOptions(
|
|
365
|
+
startFrame as LiveVoiceClientStartFrame,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
expect(options.macosDesktopSession).toBeUndefined();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
353
372
|
test("omits the client when the start frame declares none", async () => {
|
|
354
373
|
const options = await captureTurnOptions(START_FRAME);
|
|
355
374
|
|