@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
|
@@ -698,6 +698,53 @@ describe("parseLiveVoiceClientTextFrame", () => {
|
|
|
698
698
|
});
|
|
699
699
|
});
|
|
700
700
|
|
|
701
|
+
test("keeps known session controls and drops unknown ones", () => {
|
|
702
|
+
const result = validateLiveVoiceClientFrame({
|
|
703
|
+
type: "start",
|
|
704
|
+
sessionControls: [
|
|
705
|
+
"mute",
|
|
706
|
+
"look_stop",
|
|
707
|
+
"look_camera",
|
|
708
|
+
"end",
|
|
709
|
+
"mute",
|
|
710
|
+
7,
|
|
711
|
+
"fly",
|
|
712
|
+
],
|
|
713
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
expect(result.ok).toBe(true);
|
|
717
|
+
if (!result.ok) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// A newer client's controls cost it nothing on an older daemon.
|
|
721
|
+
expect(result.frame).toMatchObject({
|
|
722
|
+
type: "start",
|
|
723
|
+
sessionControls: ["end", "mute", "look_camera", "look_stop"],
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test.each([
|
|
728
|
+
["absent", {}],
|
|
729
|
+
["not an array", { sessionControls: "end" }],
|
|
730
|
+
["all unknown", { sessionControls: ["fly"] }],
|
|
731
|
+
])(
|
|
732
|
+
"omits sessionControls from the start frame when %s",
|
|
733
|
+
(_label, extra: Record<string, unknown>) => {
|
|
734
|
+
const result = validateLiveVoiceClientFrame({
|
|
735
|
+
type: "start",
|
|
736
|
+
...extra,
|
|
737
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
expect(result.ok).toBe(true);
|
|
741
|
+
if (!result.ok) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
expect("sessionControls" in result.frame).toBe(false);
|
|
745
|
+
},
|
|
746
|
+
);
|
|
747
|
+
|
|
701
748
|
test("parses the textInput capability on the start frame", () => {
|
|
702
749
|
const result = validateLiveVoiceClientFrame({
|
|
703
750
|
type: "start",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { VoiceProgressConfig } from "../../config/schemas/voice.js";
|
|
4
|
+
import {
|
|
5
|
+
FEWER_UPDATES_INTERVAL_MS,
|
|
6
|
+
progressConfigForCadence,
|
|
7
|
+
requestedSessionControl,
|
|
8
|
+
sessionControlTeaching,
|
|
9
|
+
} from "../session-controls.js";
|
|
10
|
+
|
|
11
|
+
const CONFIG: VoiceProgressConfig = {
|
|
12
|
+
enabled: true,
|
|
13
|
+
opsThreshold: 3,
|
|
14
|
+
idleIntervalMs: 5_000,
|
|
15
|
+
maxSilenceMs: 35_000,
|
|
16
|
+
longOpMs: 15_000,
|
|
17
|
+
minGapMs: 6_000,
|
|
18
|
+
generationTimeoutMs: 1_500,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("progressConfigForCadence", () => {
|
|
22
|
+
test("normal is the configured cadence, untouched", () => {
|
|
23
|
+
expect(progressConfigForCadence(CONFIG, "normal")).toBe(CONFIG);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("fewer silences tool activity and stretches the silence tick", () => {
|
|
27
|
+
const fewer = progressConfigForCadence(CONFIG, "fewer");
|
|
28
|
+
|
|
29
|
+
expect(fewer.opsThreshold).toBe(Number.MAX_SAFE_INTEGER);
|
|
30
|
+
expect(fewer.longOpMs).toBe(Number.MAX_SAFE_INTEGER);
|
|
31
|
+
expect(fewer.idleIntervalMs).toBe(FEWER_UPDATES_INTERVAL_MS);
|
|
32
|
+
expect(fewer.maxSilenceMs).toBe(FEWER_UPDATES_INTERVAL_MS);
|
|
33
|
+
// Narration stays on: a very long silence still proves the call is alive.
|
|
34
|
+
expect(fewer.enabled).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("fewer never speaks more often than the configured heartbeat", () => {
|
|
38
|
+
const fewer = progressConfigForCadence(
|
|
39
|
+
{ ...CONFIG, idleIntervalMs: 90_000, maxSilenceMs: 120_000 },
|
|
40
|
+
"fewer",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(fewer.idleIntervalMs).toBe(120_000);
|
|
44
|
+
expect(fewer.maxSilenceMs).toBe(120_000);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("requestedSessionControl", () => {
|
|
49
|
+
test("the update cadence needs no client declaration", () => {
|
|
50
|
+
expect(requestedSessionControl("Sure. [UPDATES:FEWER]", [])).toEqual({
|
|
51
|
+
action: "updates",
|
|
52
|
+
cadence: "fewer",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a client control needs the client's declaration", () => {
|
|
57
|
+
expect(requestedSessionControl("Bye. [END_CALL]", [])).toBeNull();
|
|
58
|
+
expect(requestedSessionControl("Bye. [END_CALL]", ["end"])).toEqual({
|
|
59
|
+
action: "end",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("sessionControlTeaching", () => {
|
|
65
|
+
test("teaches the update cadence and only the declared client controls", () => {
|
|
66
|
+
const teaching = sessionControlTeaching(["mute"], {});
|
|
67
|
+
|
|
68
|
+
expect(teaching).toContain("[UPDATES:FEWER]");
|
|
69
|
+
expect(teaching).toContain("[MUTE]");
|
|
70
|
+
expect(teaching).not.toContain("[END_CALL]");
|
|
71
|
+
expect(teaching).toContain("Never emit any other bracketed marker.");
|
|
72
|
+
});
|
|
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
|
+
|
|
108
|
+
test("the front-door leg keeps its verdict tokens", () => {
|
|
109
|
+
expect(sessionControlTeaching(["end"], { frontDoor: true })).not.toContain(
|
|
110
|
+
"Never emit any other bracketed marker.",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -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
|
);
|
|
@@ -156,7 +156,14 @@ import {
|
|
|
156
156
|
type LiveVoiceClientUpdateConfigFrame,
|
|
157
157
|
LiveVoiceProtocolErrorCode,
|
|
158
158
|
type LiveVoiceServerFramePayload,
|
|
159
|
+
type LiveVoiceSessionControl,
|
|
159
160
|
} from "./protocol.js";
|
|
161
|
+
import {
|
|
162
|
+
type ClientSessionControlRequest,
|
|
163
|
+
progressConfigForCadence,
|
|
164
|
+
requestedSessionControl,
|
|
165
|
+
sessionControlTeaching,
|
|
166
|
+
} from "./session-controls.js";
|
|
160
167
|
|
|
161
168
|
const log = getLogger("live-voice-session");
|
|
162
169
|
|
|
@@ -610,6 +617,10 @@ interface ActiveAssistantTurn {
|
|
|
610
617
|
// Never set from anything the model says: the reveal is a consequence of
|
|
611
618
|
// showing a surface, not a token the model has to remember.
|
|
612
619
|
minimizeRequested: boolean;
|
|
620
|
+
// The session control the completed reply ended with (see
|
|
621
|
+
// session-controls.ts); consumed at TTS drain like the minimize, where the
|
|
622
|
+
// session_control frame goes out once the acknowledgement has been spoken.
|
|
623
|
+
sessionControlRequested: ClientSessionControlRequest | null;
|
|
613
624
|
// The activity label the client was last told about, so a run of tools that
|
|
614
625
|
// map to the same line sends one frame rather than one per call. Empty means
|
|
615
626
|
// the client believes nothing is running, which is also where a turn ends.
|
|
@@ -757,7 +768,7 @@ interface ActiveAssistantTurn {
|
|
|
757
768
|
// message. When a turn starts from a barge-in, the interruption merge note is
|
|
758
769
|
// appended to it (see buildInterruptionMergeNote) so the model reconciles the
|
|
759
770
|
// interrupted request with the new utterance.
|
|
760
|
-
const LIVE_VOICE_CONTROL_PROMPT_BASE = `You are speaking in a local live voice session. ${SPOKEN_REPLY_LENGTH_RULE} ${SPOKEN_REPLY_PLAIN_TEXT_RULE} 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); 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. `;
|
|
771
|
+
const LIVE_VOICE_CONTROL_PROMPT_BASE = `You are speaking in a local live voice session. ${SPOKEN_REPLY_LENGTH_RULE} ${SPOKEN_REPLY_PLAIN_TEXT_RULE} 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. `;
|
|
761
772
|
|
|
762
773
|
// Appended for the legs that can actually put something on screen: the main
|
|
763
774
|
// leg and the escalated leg. The front-door (fast) leg never receives it, for
|
|
@@ -773,7 +784,7 @@ const LIVE_VOICE_CONTROL_PROMPT_BASE = `You are speaking in a local live voice s
|
|
|
773
784
|
// model can get right, which is speaking as though the thing is already in
|
|
774
785
|
// front of the user, because by the time it stops talking it is.
|
|
775
786
|
const LIVE_VOICE_SCREEN_REVEAL_TEACHING =
|
|
776
|
-
"When the complete answer would run past a few sentences, say the short version out loud and put the detail on screen instead of reading it out. The call renders as a full-screen overlay covering the app. Whenever you put something on screen, the overlay minimizes by itself as soon as you finish speaking, and the user is looking at what you made. So speak as if you are showing it to them right now (for example, close with something like: take a look), and never say you cannot show it, that this is a voice call, or that they should check it later.
|
|
787
|
+
"When the complete answer would run past a few sentences, say the short version out loud and put the detail on screen instead of reading it out. The call renders as a full-screen overlay covering the app. Whenever you put something on screen, the overlay minimizes by itself as soon as you finish speaking, and the user is looking at what you made. So speak as if you are showing it to them right now (for example, close with something like: take a look), and never say you cannot show it, that this is a voice call, or that they should check it later. ";
|
|
777
788
|
|
|
778
789
|
// The setup-flow case, spelled out because it is the one the model gets wrong
|
|
779
790
|
// on its own: connecting an account reads as something a call cannot do, so it
|
|
@@ -854,12 +865,14 @@ function buildLiveDeliveryNote(request: string, answer: string): string {
|
|
|
854
865
|
function buildVoiceControlPrompt(
|
|
855
866
|
turn: ActiveAssistantTurn,
|
|
856
867
|
leg: { frontDoor?: boolean },
|
|
868
|
+
sessionControls: readonly LiveVoiceSessionControl[],
|
|
857
869
|
): string {
|
|
858
870
|
let prompt =
|
|
859
871
|
LIVE_VOICE_CONTROL_PROMPT_BASE +
|
|
860
872
|
(leg.frontDoor === true
|
|
861
873
|
? ""
|
|
862
|
-
: LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING)
|
|
874
|
+
: LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING) +
|
|
875
|
+
sessionControlTeaching(sessionControls, leg);
|
|
863
876
|
if (turn.language !== undefined) {
|
|
864
877
|
prompt = `${prompt}\n\nThe caller has been speaking the language with code "${turn.language}" this turn. Reply in that language unless they clearly switch to another.`;
|
|
865
878
|
}
|
|
@@ -1119,6 +1132,12 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1119
1132
|
// take a turn without the microphone. Governs one thing only: whether a
|
|
1120
1133
|
// missing speech-to-text leg is fatal to startup (see start()).
|
|
1121
1134
|
private readonly textInput: boolean;
|
|
1135
|
+
// The session controls the client declared it can carry out; the only ones
|
|
1136
|
+
// the model is taught and the only ones a reply's marker can trigger.
|
|
1137
|
+
private readonly sessionControls: readonly LiveVoiceSessionControl[];
|
|
1138
|
+
// How often progress updates are spoken, as the user last asked out loud.
|
|
1139
|
+
// Session-scoped: it applies from the next turn to the end of the call.
|
|
1140
|
+
private progressCadence: "fewer" | "normal" = "normal";
|
|
1122
1141
|
// Whether this session's speech-to-text leg came up. False only when the
|
|
1123
1142
|
// preflight found it missing and `textInput` let the session open anyway, in
|
|
1124
1143
|
// which case nothing arms a transcriber and typed turns are the only input.
|
|
@@ -1393,6 +1412,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1393
1412
|
2 *
|
|
1394
1413
|
SERVER_VAD_PENDING_AUDIO_MAX_SECONDS;
|
|
1395
1414
|
this.textInput = context.startFrame.textInput === true;
|
|
1415
|
+
this.sessionControls = context.startFrame.sessionControls ?? [];
|
|
1396
1416
|
}
|
|
1397
1417
|
|
|
1398
1418
|
get finalTranscriptText(): string {
|
|
@@ -5004,7 +5024,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5004
5024
|
handle: null,
|
|
5005
5025
|
launchedAtMs: Date.now(),
|
|
5006
5026
|
progress: createProgressCadence({
|
|
5007
|
-
config:
|
|
5027
|
+
config: progressConfigForCadence(
|
|
5028
|
+
this.frontModelConfig.progress,
|
|
5029
|
+
this.progressCadence,
|
|
5030
|
+
),
|
|
5008
5031
|
// Without TTS there is nothing to speak (the idle trigger's static
|
|
5009
5032
|
// fallback still needs a generation attempt to fall back from).
|
|
5010
5033
|
narrator: this.streamTtsAudio ? this.progressNarrator : null,
|
|
@@ -5039,6 +5062,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5039
5062
|
assistantCompleted: false,
|
|
5040
5063
|
ttsDone: false,
|
|
5041
5064
|
minimizeRequested: false,
|
|
5065
|
+
sessionControlRequested: null,
|
|
5042
5066
|
activityLabel: "",
|
|
5043
5067
|
publishedApprovalRequestId: null,
|
|
5044
5068
|
pendingApproval: null,
|
|
@@ -5298,6 +5322,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5298
5322
|
// as telemetry instead, on `voiceTelemetry` below.
|
|
5299
5323
|
userMessageInterface: "macos",
|
|
5300
5324
|
assistantMessageInterface: "macos",
|
|
5325
|
+
...(this.context.startFrame.client === "macos"
|
|
5326
|
+
? { macosDesktopSession: true }
|
|
5327
|
+
: {}),
|
|
5301
5328
|
voiceTelemetry: {
|
|
5302
5329
|
sessionId: this.context.sessionId,
|
|
5303
5330
|
...(this.context.startFrame.client
|
|
@@ -5307,9 +5334,15 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5307
5334
|
? { entry: this.context.startFrame.entry }
|
|
5308
5335
|
: {}),
|
|
5309
5336
|
},
|
|
5310
|
-
voiceControlPrompt: buildVoiceControlPrompt(
|
|
5311
|
-
|
|
5312
|
-
|
|
5337
|
+
voiceControlPrompt: buildVoiceControlPrompt(
|
|
5338
|
+
activeTurn,
|
|
5339
|
+
{
|
|
5340
|
+
...(leg.frontDoor !== undefined
|
|
5341
|
+
? { frontDoor: leg.frontDoor }
|
|
5342
|
+
: {}),
|
|
5343
|
+
},
|
|
5344
|
+
this.sessionControls,
|
|
5345
|
+
),
|
|
5313
5346
|
onApprovalPending: (requestId) => {
|
|
5314
5347
|
this.revealRoomForPendingApproval(activeTurn, requestId);
|
|
5315
5348
|
},
|
|
@@ -5414,6 +5447,26 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5414
5447
|
if (!leg.frontDoor && msg.type === "message_complete") {
|
|
5415
5448
|
flushLegText(rawText, { force: true });
|
|
5416
5449
|
}
|
|
5450
|
+
// Read off the leg that finished the reply: a front-door answer
|
|
5451
|
+
// or the escalated leg. A handed-off front-door leg returned
|
|
5452
|
+
// above, so its holding phrase can never end a call.
|
|
5453
|
+
if (msg.type === "message_complete") {
|
|
5454
|
+
const request = requestedSessionControl(
|
|
5455
|
+
rawText,
|
|
5456
|
+
this.sessionControls,
|
|
5457
|
+
);
|
|
5458
|
+
if (request?.action === "updates") {
|
|
5459
|
+
// The session's own control: nothing to send, and nothing to
|
|
5460
|
+
// wait for, since it shapes turns that have not started yet.
|
|
5461
|
+
this.progressCadence = request.cadence;
|
|
5462
|
+
log.info(
|
|
5463
|
+
{ turnId, cadence: request.cadence },
|
|
5464
|
+
"Live voice progress cadence changed",
|
|
5465
|
+
);
|
|
5466
|
+
} else {
|
|
5467
|
+
current.sessionControlRequested = request;
|
|
5468
|
+
}
|
|
5469
|
+
}
|
|
5417
5470
|
current.assistantCompleted = true;
|
|
5418
5471
|
if (msg.type === "generation_cancelled") {
|
|
5419
5472
|
void this.finalizeAssistantTurn(
|
|
@@ -5826,6 +5879,34 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5826
5879
|
// leave the last tool it touched on screen through the next silence.
|
|
5827
5880
|
this.publishActivity(currentTurn, "");
|
|
5828
5881
|
|
|
5882
|
+
// Drain-scoped session control, under the same terms as the minimize
|
|
5883
|
+
// below: after the acknowledgement has been spoken, never for a
|
|
5884
|
+
// barged-in turn (talking over "okay, bye" means they are not
|
|
5885
|
+
// leaving), at most once per turn. Ending the call makes revealing
|
|
5886
|
+
// the screen moot, so an end takes the minimize's place.
|
|
5887
|
+
const sessionControl = currentTurn.sessionControlRequested;
|
|
5888
|
+
currentTurn.sessionControlRequested = null;
|
|
5889
|
+
if (
|
|
5890
|
+
sessionControl !== null &&
|
|
5891
|
+
!currentTurn.abortController.signal.aborted
|
|
5892
|
+
) {
|
|
5893
|
+
log.info(
|
|
5894
|
+
{ turnId: currentTurn.turnId, action: sessionControl.action },
|
|
5895
|
+
"Live voice reply requested a session control",
|
|
5896
|
+
);
|
|
5897
|
+
if (sessionControl.action === "end") {
|
|
5898
|
+
currentTurn.minimizeRequested = false;
|
|
5899
|
+
}
|
|
5900
|
+
await this.sendFrame(
|
|
5901
|
+
{
|
|
5902
|
+
type: "session_control",
|
|
5903
|
+
turnId: currentTurn.turnId,
|
|
5904
|
+
...sessionControl,
|
|
5905
|
+
},
|
|
5906
|
+
() => !this.isClosed,
|
|
5907
|
+
);
|
|
5908
|
+
}
|
|
5909
|
+
|
|
5829
5910
|
// Drain-scoped minimize: the latched marker is consumed here, after
|
|
5830
5911
|
// the turn's speech has fully drained — never mid-speech, never for
|
|
5831
5912
|
// a barged-in turn, at most once per turn.
|
|
@@ -30,6 +30,7 @@ const _LIVE_VOICE_SERVER_FRAME_TYPES = [
|
|
|
30
30
|
"tts_done",
|
|
31
31
|
"turn_cancelled",
|
|
32
32
|
"minimize_room",
|
|
33
|
+
"session_control",
|
|
33
34
|
"metrics",
|
|
34
35
|
"archived",
|
|
35
36
|
"error",
|
|
@@ -152,6 +153,46 @@ export interface LiveVoiceClientStartFrame {
|
|
|
152
153
|
* malformed value costs a chart facet and never the session.
|
|
153
154
|
*/
|
|
154
155
|
readonly entry?: string;
|
|
156
|
+
/**
|
|
157
|
+
* The session controls this client can carry out when a reply asks for one
|
|
158
|
+
* (see {@link LiveVoiceSessionControlServerFrame}). The session teaches the
|
|
159
|
+
* model only the controls listed here, so a client that cannot hang up or
|
|
160
|
+
* mute is never told it can.
|
|
161
|
+
*
|
|
162
|
+
* Absent means none: a client that predates the field would ignore the
|
|
163
|
+
* frame, and a spoken "okay, ending the call" that ends nothing is worse
|
|
164
|
+
* than the model not offering. Values this daemon does not know are dropped
|
|
165
|
+
* rather than rejected, so a newer client can list controls an older daemon
|
|
166
|
+
* has never heard of.
|
|
167
|
+
*/
|
|
168
|
+
readonly sessionControls?: readonly LiveVoiceSessionControl[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const LIVE_VOICE_SESSION_CONTROLS = [
|
|
172
|
+
"end",
|
|
173
|
+
"mute",
|
|
174
|
+
"look_screen",
|
|
175
|
+
"look_camera",
|
|
176
|
+
"look_stop",
|
|
177
|
+
] as const;
|
|
178
|
+
|
|
179
|
+
/** A session control a client can carry out on the assistant's behalf. */
|
|
180
|
+
export type LiveVoiceSessionControl =
|
|
181
|
+
(typeof LIVE_VOICE_SESSION_CONTROLS)[number];
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A start frame's `sessionControls`, reduced to the known values with
|
|
185
|
+
* duplicates removed; empty when the field is absent or not an array.
|
|
186
|
+
*/
|
|
187
|
+
export function parseLiveVoiceSessionControls(
|
|
188
|
+
value: unknown,
|
|
189
|
+
): LiveVoiceSessionControl[] {
|
|
190
|
+
if (!Array.isArray(value)) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
return LIVE_VOICE_SESSION_CONTROLS.filter((control) =>
|
|
194
|
+
value.includes(control),
|
|
195
|
+
);
|
|
155
196
|
}
|
|
156
197
|
|
|
157
198
|
/**
|
|
@@ -586,6 +627,29 @@ export interface LiveVoiceMinimizeRoomServerFrame extends LiveVoiceServerFrameBa
|
|
|
586
627
|
readonly turnId: string;
|
|
587
628
|
}
|
|
588
629
|
|
|
630
|
+
/**
|
|
631
|
+
* A session control the just-completed reply asked for with a terminal marker
|
|
632
|
+
* (`[END_CALL]`, `[MUTE]`, `[MUTE:<seconds>]`). Sent only after the turn's TTS
|
|
633
|
+
* has fully drained, so the spoken acknowledgement is heard first, never for a
|
|
634
|
+
* turn the user barged in on, at most once per turn, and only for a control
|
|
635
|
+
* the client listed in the start frame's `sessionControls`.
|
|
636
|
+
*
|
|
637
|
+
* - `end`: end the session the way the client's own end control does.
|
|
638
|
+
* - `mute`: mute the microphone. With `durationMs`, unmute again once it
|
|
639
|
+
* elapses; without, stay muted until the user unmutes. The timer is the
|
|
640
|
+
* client's: a muted microphone sends silence, so the daemon cannot hear an
|
|
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.
|
|
645
|
+
*/
|
|
646
|
+
export interface LiveVoiceSessionControlServerFrame extends LiveVoiceServerFrameBase {
|
|
647
|
+
readonly type: "session_control";
|
|
648
|
+
readonly turnId: string;
|
|
649
|
+
readonly action: LiveVoiceSessionControl;
|
|
650
|
+
readonly durationMs?: number;
|
|
651
|
+
}
|
|
652
|
+
|
|
589
653
|
export interface LiveVoiceMetricsServerFrame extends LiveVoiceServerFrameBase {
|
|
590
654
|
readonly type: "metrics";
|
|
591
655
|
readonly event?: string;
|
|
@@ -707,6 +771,7 @@ export type LiveVoiceServerFrame =
|
|
|
707
771
|
| LiveVoiceTtsDoneServerFrame
|
|
708
772
|
| LiveVoiceTurnCancelledServerFrame
|
|
709
773
|
| LiveVoiceMinimizeRoomServerFrame
|
|
774
|
+
| LiveVoiceSessionControlServerFrame
|
|
710
775
|
| LiveVoiceMetricsServerFrame
|
|
711
776
|
| LiveVoiceArchivedServerFrame
|
|
712
777
|
| LiveVoiceErrorServerFrame;
|
|
@@ -728,6 +793,7 @@ export type LiveVoiceServerFramePayload =
|
|
|
728
793
|
| WithoutSeq<LiveVoiceTtsDoneServerFrame>
|
|
729
794
|
| WithoutSeq<LiveVoiceTurnCancelledServerFrame>
|
|
730
795
|
| WithoutSeq<LiveVoiceMinimizeRoomServerFrame>
|
|
796
|
+
| WithoutSeq<LiveVoiceSessionControlServerFrame>
|
|
731
797
|
| WithoutSeq<LiveVoiceMetricsServerFrame>
|
|
732
798
|
| WithoutSeq<LiveVoiceArchivedServerFrame>
|
|
733
799
|
| WithoutSeq<LiveVoiceErrorServerFrame>;
|
|
@@ -1218,6 +1284,8 @@ function validateStartFrame(
|
|
|
1218
1284
|
const client = parseClientOs(value.client);
|
|
1219
1285
|
// Same policy for the same reason: a dimension, not a capability.
|
|
1220
1286
|
const entry = parseLiveVoiceEntry(value.entry);
|
|
1287
|
+
// Same policy again: an unknown control is a newer client, not a bad frame.
|
|
1288
|
+
const sessionControls = parseLiveVoiceSessionControls(value.sessionControls);
|
|
1221
1289
|
|
|
1222
1290
|
return {
|
|
1223
1291
|
ok: true,
|
|
@@ -1239,6 +1307,7 @@ function validateStartFrame(
|
|
|
1239
1307
|
? { bargeInMinSpeechMs: value.bargeInMinSpeechMs }
|
|
1240
1308
|
: {}),
|
|
1241
1309
|
...(value.textInput === true ? { textInput: true } : {}),
|
|
1310
|
+
...(sessionControls.length > 0 ? { sessionControls } : {}),
|
|
1242
1311
|
},
|
|
1243
1312
|
};
|
|
1244
1313
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
END_CALL_MARKER,
|
|
3
|
+
FEWER_UPDATES_MARKER,
|
|
4
|
+
LOOK_CAMERA_MARKER,
|
|
5
|
+
LOOK_SCREEN_MARKER,
|
|
6
|
+
LOOK_STOP_MARKER,
|
|
7
|
+
MUTE_MARKER,
|
|
8
|
+
NORMAL_UPDATES_MARKER,
|
|
9
|
+
parseTerminalSessionControl,
|
|
10
|
+
type SessionControlRequest,
|
|
11
|
+
} from "../calls/voice-control-protocol.js";
|
|
12
|
+
import type { VoiceProgressConfig } from "../config/schemas/voice.js";
|
|
13
|
+
import type { LiveVoiceSessionControl } from "./protocol.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Spoken session controls: the user asks out loud to end the call, mute their
|
|
17
|
+
* microphone, or hear fewer progress updates, the reply acknowledges it and
|
|
18
|
+
* ends with a marker, and the session carries it out once that
|
|
19
|
+
* acknowledgement has been spoken.
|
|
20
|
+
*
|
|
21
|
+
* A marker rather than a tool, because these are the turns a front-door leg
|
|
22
|
+
* answers on its own: "okay, I'm gonna go" should not wait on an escalation to
|
|
23
|
+
* a stronger model before the call can end. The marker is judged by the
|
|
24
|
+
* model, never by matching the user's words, so "I'm all done with that
|
|
25
|
+
* email" does not hang up.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
|
|
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}.`,
|
|
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.`,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Always taught: narration is the session's own, so no client has to be able
|
|
37
|
+
// to carry it out.
|
|
38
|
+
const UPDATES_LINE = `- 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 ${FEWER_UPDATES_MARKER}. If they later want regular updates back, confirm and end with ${NORMAL_UPDATES_MARKER}.`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The control-prompt block that teaches the session controls: the ones the
|
|
42
|
+
* client declared plus the progress-update cadence. Every leg gets it,
|
|
43
|
+
* including the toolless front-door leg: these turns are its to answer.
|
|
44
|
+
*
|
|
45
|
+
* The no-other-markers rule is withheld from the front-door leg, whose
|
|
46
|
+
* routing rule teaches it leading verdict tokens and already confines
|
|
47
|
+
* everything else to speech.
|
|
48
|
+
*/
|
|
49
|
+
export function sessionControlTeaching(
|
|
50
|
+
controls: readonly LiveVoiceSessionControl[],
|
|
51
|
+
leg: { frontDoor?: boolean },
|
|
52
|
+
): string {
|
|
53
|
+
return [
|
|
54
|
+
"The user can also control this call by asking you. Only when they clearly ask:",
|
|
55
|
+
...controls.map((control) => CLIENT_CONTROL_LINES[control]),
|
|
56
|
+
...lookGuidance(controls),
|
|
57
|
+
UPDATES_LINE,
|
|
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."}`,
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
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
|
+
|
|
83
|
+
/** A session control the client carries out, sent as a `session_control` frame. */
|
|
84
|
+
export type ClientSessionControlRequest = Exclude<
|
|
85
|
+
SessionControlRequest,
|
|
86
|
+
{ action: "updates" }
|
|
87
|
+
>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The control a completed leg's raw text asks for; null when there is none or
|
|
91
|
+
* when it is a client control the client did not declare. An undeclared
|
|
92
|
+
* control is dropped rather than sent: the client said it cannot carry it out.
|
|
93
|
+
* The update cadence always passes, since the session carries it out itself.
|
|
94
|
+
*/
|
|
95
|
+
export function requestedSessionControl(
|
|
96
|
+
rawText: string,
|
|
97
|
+
controls: readonly LiveVoiceSessionControl[],
|
|
98
|
+
): SessionControlRequest | null {
|
|
99
|
+
const request = parseTerminalSessionControl(rawText);
|
|
100
|
+
if (request === null) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (request.action === "updates") {
|
|
104
|
+
return request;
|
|
105
|
+
}
|
|
106
|
+
return controls.includes(request.action) ? request : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Silence (ms) a session that asked for fewer updates waits through before a
|
|
111
|
+
* progress update. Long enough that a minute-long task runs without a word;
|
|
112
|
+
* short enough that a really long one still proves the call is alive.
|
|
113
|
+
*/
|
|
114
|
+
export const FEWER_UPDATES_INTERVAL_MS = 60_000;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The progress config a turn runs on under the session's update cadence.
|
|
118
|
+
*
|
|
119
|
+
* Fewer updates means no update for tool activity (a burst of ops or a long
|
|
120
|
+
* op finishing, each normally its own beat) and the silence tick stretched to
|
|
121
|
+
* {@link FEWER_UPDATES_INTERVAL_MS}, so the only thing that speaks is a long
|
|
122
|
+
* stretch of silence. Done is still said: the reply itself is the "it's done".
|
|
123
|
+
*/
|
|
124
|
+
export function progressConfigForCadence(
|
|
125
|
+
config: VoiceProgressConfig,
|
|
126
|
+
cadence: "fewer" | "normal",
|
|
127
|
+
): VoiceProgressConfig {
|
|
128
|
+
if (cadence === "normal") {
|
|
129
|
+
return config;
|
|
130
|
+
}
|
|
131
|
+
const intervalMs = Math.max(FEWER_UPDATES_INTERVAL_MS, config.maxSilenceMs);
|
|
132
|
+
return {
|
|
133
|
+
...config,
|
|
134
|
+
opsThreshold: Number.MAX_SAFE_INTEGER,
|
|
135
|
+
longOpMs: Number.MAX_SAFE_INTEGER,
|
|
136
|
+
idleIntervalMs: intervalMs,
|
|
137
|
+
maxSilenceMs: intervalMs,
|
|
138
|
+
};
|
|
139
|
+
}
|