@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
|
@@ -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(
|
|
@@ -1043,6 +1043,28 @@ describe("LiveVoiceSession spoken session controls", () => {
|
|
|
1043
1043
|
).toMatchObject({ action: "mute", durationMs: 30_000 });
|
|
1044
1044
|
});
|
|
1045
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
|
+
|
|
1046
1068
|
test("a control the client did not declare is never sent", async () => {
|
|
1047
1069
|
const { frames, session, getCallbacks } = createControlsHarness(["mute"]);
|
|
1048
1070
|
|
|
@@ -259,9 +259,10 @@ describe("LiveVoiceSession archive and metrics events", () => {
|
|
|
259
259
|
// has nothing to show and no connection to run, and its capability
|
|
260
260
|
// digest already routes anything needing a tool to the escalated leg
|
|
261
261
|
// (see live-voice-triage-escalate.test.ts). The start frame declares no
|
|
262
|
-
// client controls, so only the session's own update cadence
|
|
262
|
+
// client controls, so only the session's own update cadence and the
|
|
263
|
+
// no-looks line are taught.
|
|
263
264
|
voiceControlPrompt:
|
|
264
|
-
'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- 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.',
|
|
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.',
|
|
265
266
|
});
|
|
266
267
|
callbacks?.assistant_text_delta?.(makeTextDelta("Hello there."));
|
|
267
268
|
callbacks?.message_complete?.(makeMessageComplete());
|
|
@@ -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
|
|
|
@@ -701,7 +701,15 @@ describe("parseLiveVoiceClientTextFrame", () => {
|
|
|
701
701
|
test("keeps known session controls and drops unknown ones", () => {
|
|
702
702
|
const result = validateLiveVoiceClientFrame({
|
|
703
703
|
type: "start",
|
|
704
|
-
sessionControls: [
|
|
704
|
+
sessionControls: [
|
|
705
|
+
"mute",
|
|
706
|
+
"look_stop",
|
|
707
|
+
"look_camera",
|
|
708
|
+
"end",
|
|
709
|
+
"mute",
|
|
710
|
+
7,
|
|
711
|
+
"fly",
|
|
712
|
+
],
|
|
705
713
|
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
706
714
|
});
|
|
707
715
|
|
|
@@ -712,14 +720,14 @@ describe("parseLiveVoiceClientTextFrame", () => {
|
|
|
712
720
|
// A newer client's controls cost it nothing on an older daemon.
|
|
713
721
|
expect(result.frame).toMatchObject({
|
|
714
722
|
type: "start",
|
|
715
|
-
sessionControls: ["end", "mute"],
|
|
723
|
+
sessionControls: ["end", "mute", "look_camera", "look_stop"],
|
|
716
724
|
});
|
|
717
725
|
});
|
|
718
726
|
|
|
719
727
|
test.each([
|
|
720
728
|
["absent", {}],
|
|
721
729
|
["not an array", { sessionControls: "end" }],
|
|
722
|
-
["all unknown", { sessionControls: ["
|
|
730
|
+
["all unknown", { sessionControls: ["fly"] }],
|
|
723
731
|
])(
|
|
724
732
|
"omits sessionControls from the start frame when %s",
|
|
725
733
|
(_label, extra: Record<string, unknown>) => {
|
|
@@ -71,6 +71,40 @@ describe("sessionControlTeaching", () => {
|
|
|
71
71
|
expect(teaching).toContain("Never emit any other bracketed marker.");
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
test("a device that can show both asks which one a bare look means", () => {
|
|
75
|
+
const teaching = sessionControlTeaching(["look_screen", "look_camera"], {});
|
|
76
|
+
|
|
77
|
+
expect(teaching).toContain("[LOOK:SCREEN]");
|
|
78
|
+
expect(teaching).toContain("[LOOK:CAMERA]");
|
|
79
|
+
expect(teaching).toContain("ask which one instead of guessing");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a device that can look can be told to stop", () => {
|
|
83
|
+
const teaching = sessionControlTeaching(["look_screen", "look_stop"], {});
|
|
84
|
+
|
|
85
|
+
expect(teaching).toContain("[LOOK:STOP]");
|
|
86
|
+
expect(
|
|
87
|
+
requestedSessionControl("Stopping. [LOOK:STOP]", ["look_stop"]),
|
|
88
|
+
).toEqual({ action: "look_stop" });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a device that can show one look needs no question", () => {
|
|
92
|
+
const teaching = sessionControlTeaching(["look_camera"], {});
|
|
93
|
+
|
|
94
|
+
expect(teaching).toContain("[LOOK:CAMERA]");
|
|
95
|
+
expect(teaching).not.toContain("[LOOK:SCREEN]");
|
|
96
|
+
expect(teaching).not.toContain("ask which one");
|
|
97
|
+
expect(teaching).not.toContain("cannot turn on a screen share");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The silent failure in the report this exists for: asked to look, the
|
|
101
|
+
// assistant neither looked nor said it could not.
|
|
102
|
+
test("a device that can show neither says so rather than pretending", () => {
|
|
103
|
+
expect(sessionControlTeaching(["end"], {})).toContain(
|
|
104
|
+
"This call cannot turn on a screen share or the camera.",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
74
108
|
test("the front-door leg keeps its verdict tokens", () => {
|
|
75
109
|
expect(sessionControlTeaching(["end"], { frontDoor: true })).not.toContain(
|
|
76
110
|
"Never emit any other bracketed marker.",
|
|
@@ -511,6 +511,15 @@ export class LiveVoiceMetricsCollector {
|
|
|
511
511
|
turnId: turn.turnId,
|
|
512
512
|
finishReason,
|
|
513
513
|
...aggregateFieldsForTurn(snapshotTurn(turn)),
|
|
514
|
+
// Stated even at zero, unlike the telemetry field this overrides.
|
|
515
|
+
// Zero is the single most informative value this count takes: it is
|
|
516
|
+
// what a turn asked for fewer updates looks like, and equally what a
|
|
517
|
+
// narrator that is failing every attempt looks like. Left absent, the
|
|
518
|
+
// two read identically to anyone reading the log, and "the feature is
|
|
519
|
+
// off" becomes indistinguishable from "the feature did its job".
|
|
520
|
+
// The wire payload keeps the omission (see optionalTurnFields), so
|
|
521
|
+
// turns that never engage narration are unchanged for telemetry.
|
|
522
|
+
progressUpdatesSpoken: turn.progressUpdatesSpoken,
|
|
514
523
|
},
|
|
515
524
|
"Live voice turn latency",
|
|
516
525
|
);
|
|
@@ -5322,6 +5322,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5322
5322
|
// as telemetry instead, on `voiceTelemetry` below.
|
|
5323
5323
|
userMessageInterface: "macos",
|
|
5324
5324
|
assistantMessageInterface: "macos",
|
|
5325
|
+
...(this.context.startFrame.client === "macos"
|
|
5326
|
+
? { macosDesktopSession: true }
|
|
5327
|
+
: {}),
|
|
5325
5328
|
voiceTelemetry: {
|
|
5326
5329
|
sessionId: this.context.sessionId,
|
|
5327
5330
|
...(this.context.startFrame.client
|
|
@@ -168,7 +168,13 @@ export interface LiveVoiceClientStartFrame {
|
|
|
168
168
|
readonly sessionControls?: readonly LiveVoiceSessionControl[];
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
const LIVE_VOICE_SESSION_CONTROLS = [
|
|
171
|
+
const LIVE_VOICE_SESSION_CONTROLS = [
|
|
172
|
+
"end",
|
|
173
|
+
"mute",
|
|
174
|
+
"look_screen",
|
|
175
|
+
"look_camera",
|
|
176
|
+
"look_stop",
|
|
177
|
+
] as const;
|
|
172
178
|
|
|
173
179
|
/** A session control a client can carry out on the assistant's behalf. */
|
|
174
180
|
export type LiveVoiceSessionControl =
|
|
@@ -633,6 +639,9 @@ export interface LiveVoiceMinimizeRoomServerFrame extends LiveVoiceServerFrameBa
|
|
|
633
639
|
* elapses; without, stay muted until the user unmutes. The timer is the
|
|
634
640
|
* client's: a muted microphone sends silence, so the daemon cannot hear an
|
|
635
641
|
* "unmute".
|
|
642
|
+
* - `look_screen`: start showing the call the user's screen.
|
|
643
|
+
* - `look_camera`: start showing the call what the camera sees.
|
|
644
|
+
* - `look_stop`: stop showing the call the screen and the camera.
|
|
636
645
|
*/
|
|
637
646
|
export interface LiveVoiceSessionControlServerFrame extends LiveVoiceServerFrameBase {
|
|
638
647
|
readonly type: "session_control";
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
END_CALL_MARKER,
|
|
3
3
|
FEWER_UPDATES_MARKER,
|
|
4
|
+
LOOK_CAMERA_MARKER,
|
|
5
|
+
LOOK_SCREEN_MARKER,
|
|
6
|
+
LOOK_STOP_MARKER,
|
|
4
7
|
MUTE_MARKER,
|
|
5
8
|
NORMAL_UPDATES_MARKER,
|
|
6
9
|
parseTerminalSessionControl,
|
|
@@ -24,6 +27,9 @@ import type { LiveVoiceSessionControl } from "./protocol.js";
|
|
|
24
27
|
|
|
25
28
|
const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
|
|
26
29
|
end: `- To end the call (for example "I'm all done" or "okay, I'm gonna go"), say a brief goodbye, then end your reply with ${END_CALL_MARKER}. Being done with a task is not the same as leaving the call; end only when they are leaving.`,
|
|
30
|
+
look_screen: `- To look at their screen (for example "take a look at my screen" or "can you see what I'm looking at?"), say you are taking a look, then end your reply with ${LOOK_SCREEN_MARKER}. Their screen starts being shared with you once you finish speaking, so you cannot describe it yet: ask what they want you to look at, or say you will take it from their next words.`,
|
|
31
|
+
look_camera: `- To look through their camera (for example "look at this" or "can you see this?" while they hold something up), say you are taking a look, then end your reply with ${LOOK_CAMERA_MARKER}. The camera turns on once you finish speaking, so you cannot describe what it sees yet: ask them to show you, or say you will take it from their next words.`,
|
|
32
|
+
look_stop: `- To stop showing you their screen or camera (for example "stop sharing" or "you can stop looking now"), confirm in a few words, then end your reply with ${LOOK_STOP_MARKER}.`,
|
|
27
33
|
mute: `- To mute their microphone (for example "mute for 30 seconds" or "mute yourself, I need to take this"), confirm in a few words, then end your reply with [MUTE:<seconds>] when they gave a duration or ${MUTE_MARKER} when they did not. While muted you cannot hear them, so mention they can unmute from the call controls unless the mute is timed.`,
|
|
28
34
|
};
|
|
29
35
|
|
|
@@ -47,11 +53,33 @@ export function sessionControlTeaching(
|
|
|
47
53
|
return [
|
|
48
54
|
"The user can also control this call by asking you. Only when they clearly ask:",
|
|
49
55
|
...controls.map((control) => CLIENT_CONTROL_LINES[control]),
|
|
56
|
+
...lookGuidance(controls),
|
|
50
57
|
UPDATES_LINE,
|
|
51
58
|
`The marker must be the very last thing in your reply. It is never spoken and does nothing anywhere else.${leg.frontDoor === true ? "" : " Never emit any other bracketed marker."}`,
|
|
52
59
|
].join("\n");
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
/**
|
|
63
|
+
* What to say about looking beyond the per-control lines: ask which when the
|
|
64
|
+
* device can do both and the request does not say, and say so plainly when it
|
|
65
|
+
* can do neither, rather than failing silently.
|
|
66
|
+
*/
|
|
67
|
+
function lookGuidance(controls: readonly LiveVoiceSessionControl[]): string[] {
|
|
68
|
+
const screen = controls.includes("look_screen");
|
|
69
|
+
const camera = controls.includes("look_camera");
|
|
70
|
+
if (screen && camera) {
|
|
71
|
+
return [
|
|
72
|
+
`- If they just say "take a look" and it is not clear whether they mean their screen or their camera, ask which one instead of guessing, and use no marker until they answer.`,
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
if (!screen && !camera) {
|
|
76
|
+
return [
|
|
77
|
+
"- 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.",
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
/** A session control the client carries out, sent as a `session_control` frame. */
|
|
56
84
|
export type ClientSessionControlRequest = Exclude<
|
|
57
85
|
SessionControlRequest,
|