@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.2

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.
Files changed (64) hide show
  1. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  2. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  3. package/openapi.yaml +35 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  6. package/src/__tests__/conversation-runtime-assembly.test.ts +28 -0
  7. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  8. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  9. package/src/__tests__/credential-routes.test.ts +38 -0
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  11. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  12. package/src/__tests__/host-cu-proxy.test.ts +69 -12
  13. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  14. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  15. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  16. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  17. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  18. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  19. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  20. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  21. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  22. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  23. package/src/acp/acp-claude-oauth.ts +328 -14
  24. package/src/acp/acp-credentials.ts +19 -0
  25. package/src/acp/claude-token-refresh.ts +150 -0
  26. package/src/acp/prepare-agent-env.ts +21 -6
  27. package/src/calls/__tests__/voice-control-protocol.test.ts +62 -0
  28. package/src/calls/__tests__/voice-session-bridge.test.ts +19 -0
  29. package/src/calls/voice-control-protocol.ts +102 -0
  30. package/src/calls/voice-session-bridge.ts +33 -26
  31. package/src/calls/voice-triage-escalate.ts +3 -3
  32. package/src/cli/commands/oauth/status.ts +5 -0
  33. package/src/config/bundled-skills/computer-use/SKILL.md +13 -4
  34. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  35. package/src/config/feature-flag-registry.json +9 -1
  36. package/src/config/loader.ts +1 -0
  37. package/src/config/schemas/services.ts +10 -0
  38. package/src/daemon/conversation-runtime-assembly.ts +18 -6
  39. package/src/daemon/conversation-surfaces.ts +6 -1
  40. package/src/daemon/conversation-tool-setup.ts +8 -16
  41. package/src/daemon/host-cu-proxy.ts +43 -5
  42. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +121 -0
  43. package/src/live-voice/__tests__/live-voice-events.test.ts +8 -7
  44. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  45. package/src/live-voice/__tests__/protocol.test.ts +39 -0
  46. package/src/live-voice/__tests__/session-controls.test.ts +79 -0
  47. package/src/live-voice/live-voice-session.ts +85 -7
  48. package/src/live-voice/protocol.ts +60 -0
  49. package/src/live-voice/session-controls.ts +111 -0
  50. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  51. package/src/oauth/connection-resolver.test.ts +27 -0
  52. package/src/oauth/connection-resolver.ts +25 -1
  53. package/src/oauth/provider-serializer.ts +9 -0
  54. package/src/oauth/seed-providers.ts +110 -2
  55. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  56. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  57. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  58. package/src/runtime/routes/app-management-routes.ts +17 -2
  59. package/src/runtime/routes/credential-routes.ts +1 -4
  60. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  61. package/src/runtime/routes/oauth-providers.ts +7 -0
  62. package/src/tools/computer-use/definitions.ts +1 -1
  63. package/src/tools/ui-surface/channel-variants.ts +10 -59
  64. package/src/watch/watch-retro.ts +6 -8
@@ -980,3 +980,124 @@ 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 control the client did not declare is never sent", async () => {
1047
+ const { frames, session, getCallbacks } = createControlsHarness(["mute"]);
1048
+
1049
+ await startReleasedTurn(session, getCallbacks);
1050
+ emitTextDelta(getCallbacks, "Bye. [END_CALL]");
1051
+ emitMessageComplete(getCallbacks);
1052
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
1053
+ await flushAsyncCallbacks();
1054
+
1055
+ expect(frames.some((frame) => frame.type === "session_control")).toBe(
1056
+ false,
1057
+ );
1058
+ });
1059
+
1060
+ test("a marker mid-reply controls nothing", async () => {
1061
+ const { frames, session, getCallbacks } = createControlsHarness(["end"]);
1062
+
1063
+ await startReleasedTurn(session, getCallbacks);
1064
+ emitTextDelta(getCallbacks, "Say [END_CALL] and I would hang up, but no.");
1065
+ emitMessageComplete(getCallbacks);
1066
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
1067
+ await flushAsyncCallbacks();
1068
+
1069
+ expect(frames.some((frame) => frame.type === "session_control")).toBe(
1070
+ false,
1071
+ );
1072
+ });
1073
+
1074
+ // Talking over "okay, bye" means they are not leaving.
1075
+ test("an interrupted goodbye does not end the call", async () => {
1076
+ const { frames, session, getCallbacks } = createControlsHarness(["end"]);
1077
+
1078
+ await startReleasedTurn(session, getCallbacks);
1079
+ emitTextDelta(getCallbacks, "Okay, talk soon. [END_CALL]");
1080
+ await flushAsyncCallbacks();
1081
+ await session.handleClientFrame({ type: "interrupt" });
1082
+ emitMessageComplete(getCallbacks);
1083
+ await new Promise((resolve) => setTimeout(resolve, 20));
1084
+
1085
+ expect(frames.some((frame) => frame.type === "session_control")).toBe(
1086
+ false,
1087
+ );
1088
+ });
1089
+
1090
+ test("the prompt teaches only the declared controls", async () => {
1091
+ const { session, getCallbacks, startVoiceTurn } = createControlsHarness([
1092
+ "mute",
1093
+ ]);
1094
+
1095
+ await startReleasedTurn(session, getCallbacks);
1096
+ const prompt =
1097
+ (startVoiceTurn as ReturnType<typeof mock>).mock.calls[0]?.[0]
1098
+ ?.voiceControlPrompt ?? "";
1099
+
1100
+ expect(prompt).toContain("[MUTE]");
1101
+ expect(prompt).not.toContain("[END_CALL]");
1102
+ });
1103
+ });
@@ -253,14 +253,15 @@ 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, and nothing
257
- // else. This leg is toolless, so both the screen-reveal and setup-flow
258
- // teachings are deliberately absent. It has nothing to show and no
259
- // connection to run, and its capability digest already routes anything
260
- // needing a tool to the escalated leg (see
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 is taught.
262
263
  voiceControlPrompt:
263
- "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); 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. ",
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.',
264
265
  });
265
266
  callbacks?.assistant_text_delta?.(makeTextDelta("Hello there."));
266
267
  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
+ });
@@ -698,6 +698,45 @@ 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: ["mute", "look_screen", "end", "mute", 7],
705
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
706
+ });
707
+
708
+ expect(result.ok).toBe(true);
709
+ if (!result.ok) {
710
+ return;
711
+ }
712
+ // A newer client's controls cost it nothing on an older daemon.
713
+ expect(result.frame).toMatchObject({
714
+ type: "start",
715
+ sessionControls: ["end", "mute"],
716
+ });
717
+ });
718
+
719
+ test.each([
720
+ ["absent", {}],
721
+ ["not an array", { sessionControls: "end" }],
722
+ ["all unknown", { sessionControls: ["look_screen"] }],
723
+ ])(
724
+ "omits sessionControls from the start frame when %s",
725
+ (_label, extra: Record<string, unknown>) => {
726
+ const result = validateLiveVoiceClientFrame({
727
+ type: "start",
728
+ ...extra,
729
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
730
+ });
731
+
732
+ expect(result.ok).toBe(true);
733
+ if (!result.ok) {
734
+ return;
735
+ }
736
+ expect("sessionControls" in result.frame).toBe(false);
737
+ },
738
+ );
739
+
701
740
  test("parses the textInput capability on the start frame", () => {
702
741
  const result = validateLiveVoiceClientFrame({
703
742
  type: "start",
@@ -0,0 +1,79 @@
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("the front-door leg keeps its verdict tokens", () => {
75
+ expect(sessionControlTeaching(["end"], { frontDoor: true })).not.toContain(
76
+ "Never emit any other bracketed marker.",
77
+ );
78
+ });
79
+ });
@@ -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. Never emit bracketed markers of any kind. ";
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: this.frontModelConfig.progress,
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,
@@ -5307,9 +5331,15 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5307
5331
  ? { entry: this.context.startFrame.entry }
5308
5332
  : {}),
5309
5333
  },
5310
- voiceControlPrompt: buildVoiceControlPrompt(activeTurn, {
5311
- ...(leg.frontDoor !== undefined ? { frontDoor: leg.frontDoor } : {}),
5312
- }),
5334
+ voiceControlPrompt: buildVoiceControlPrompt(
5335
+ activeTurn,
5336
+ {
5337
+ ...(leg.frontDoor !== undefined
5338
+ ? { frontDoor: leg.frontDoor }
5339
+ : {}),
5340
+ },
5341
+ this.sessionControls,
5342
+ ),
5313
5343
  onApprovalPending: (requestId) => {
5314
5344
  this.revealRoomForPendingApproval(activeTurn, requestId);
5315
5345
  },
@@ -5414,6 +5444,26 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5414
5444
  if (!leg.frontDoor && msg.type === "message_complete") {
5415
5445
  flushLegText(rawText, { force: true });
5416
5446
  }
5447
+ // Read off the leg that finished the reply: a front-door answer
5448
+ // or the escalated leg. A handed-off front-door leg returned
5449
+ // above, so its holding phrase can never end a call.
5450
+ if (msg.type === "message_complete") {
5451
+ const request = requestedSessionControl(
5452
+ rawText,
5453
+ this.sessionControls,
5454
+ );
5455
+ if (request?.action === "updates") {
5456
+ // The session's own control: nothing to send, and nothing to
5457
+ // wait for, since it shapes turns that have not started yet.
5458
+ this.progressCadence = request.cadence;
5459
+ log.info(
5460
+ { turnId, cadence: request.cadence },
5461
+ "Live voice progress cadence changed",
5462
+ );
5463
+ } else {
5464
+ current.sessionControlRequested = request;
5465
+ }
5466
+ }
5417
5467
  current.assistantCompleted = true;
5418
5468
  if (msg.type === "generation_cancelled") {
5419
5469
  void this.finalizeAssistantTurn(
@@ -5826,6 +5876,34 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5826
5876
  // leave the last tool it touched on screen through the next silence.
5827
5877
  this.publishActivity(currentTurn, "");
5828
5878
 
5879
+ // Drain-scoped session control, under the same terms as the minimize
5880
+ // below: after the acknowledgement has been spoken, never for a
5881
+ // barged-in turn (talking over "okay, bye" means they are not
5882
+ // leaving), at most once per turn. Ending the call makes revealing
5883
+ // the screen moot, so an end takes the minimize's place.
5884
+ const sessionControl = currentTurn.sessionControlRequested;
5885
+ currentTurn.sessionControlRequested = null;
5886
+ if (
5887
+ sessionControl !== null &&
5888
+ !currentTurn.abortController.signal.aborted
5889
+ ) {
5890
+ log.info(
5891
+ { turnId: currentTurn.turnId, action: sessionControl.action },
5892
+ "Live voice reply requested a session control",
5893
+ );
5894
+ if (sessionControl.action === "end") {
5895
+ currentTurn.minimizeRequested = false;
5896
+ }
5897
+ await this.sendFrame(
5898
+ {
5899
+ type: "session_control",
5900
+ turnId: currentTurn.turnId,
5901
+ ...sessionControl,
5902
+ },
5903
+ () => !this.isClosed,
5904
+ );
5905
+ }
5906
+
5829
5907
  // Drain-scoped minimize: the latched marker is consumed here, after
5830
5908
  // the turn's speech has fully drained — never mid-speech, never for
5831
5909
  // 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,40 @@ 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 = ["end", "mute"] as const;
172
+
173
+ /** A session control a client can carry out on the assistant's behalf. */
174
+ export type LiveVoiceSessionControl =
175
+ (typeof LIVE_VOICE_SESSION_CONTROLS)[number];
176
+
177
+ /**
178
+ * A start frame's `sessionControls`, reduced to the known values with
179
+ * duplicates removed; empty when the field is absent or not an array.
180
+ */
181
+ export function parseLiveVoiceSessionControls(
182
+ value: unknown,
183
+ ): LiveVoiceSessionControl[] {
184
+ if (!Array.isArray(value)) {
185
+ return [];
186
+ }
187
+ return LIVE_VOICE_SESSION_CONTROLS.filter((control) =>
188
+ value.includes(control),
189
+ );
155
190
  }
156
191
 
157
192
  /**
@@ -586,6 +621,26 @@ export interface LiveVoiceMinimizeRoomServerFrame extends LiveVoiceServerFrameBa
586
621
  readonly turnId: string;
587
622
  }
588
623
 
624
+ /**
625
+ * A session control the just-completed reply asked for with a terminal marker
626
+ * (`[END_CALL]`, `[MUTE]`, `[MUTE:<seconds>]`). Sent only after the turn's TTS
627
+ * has fully drained, so the spoken acknowledgement is heard first, never for a
628
+ * turn the user barged in on, at most once per turn, and only for a control
629
+ * the client listed in the start frame's `sessionControls`.
630
+ *
631
+ * - `end`: end the session the way the client's own end control does.
632
+ * - `mute`: mute the microphone. With `durationMs`, unmute again once it
633
+ * elapses; without, stay muted until the user unmutes. The timer is the
634
+ * client's: a muted microphone sends silence, so the daemon cannot hear an
635
+ * "unmute".
636
+ */
637
+ export interface LiveVoiceSessionControlServerFrame extends LiveVoiceServerFrameBase {
638
+ readonly type: "session_control";
639
+ readonly turnId: string;
640
+ readonly action: LiveVoiceSessionControl;
641
+ readonly durationMs?: number;
642
+ }
643
+
589
644
  export interface LiveVoiceMetricsServerFrame extends LiveVoiceServerFrameBase {
590
645
  readonly type: "metrics";
591
646
  readonly event?: string;
@@ -707,6 +762,7 @@ export type LiveVoiceServerFrame =
707
762
  | LiveVoiceTtsDoneServerFrame
708
763
  | LiveVoiceTurnCancelledServerFrame
709
764
  | LiveVoiceMinimizeRoomServerFrame
765
+ | LiveVoiceSessionControlServerFrame
710
766
  | LiveVoiceMetricsServerFrame
711
767
  | LiveVoiceArchivedServerFrame
712
768
  | LiveVoiceErrorServerFrame;
@@ -728,6 +784,7 @@ export type LiveVoiceServerFramePayload =
728
784
  | WithoutSeq<LiveVoiceTtsDoneServerFrame>
729
785
  | WithoutSeq<LiveVoiceTurnCancelledServerFrame>
730
786
  | WithoutSeq<LiveVoiceMinimizeRoomServerFrame>
787
+ | WithoutSeq<LiveVoiceSessionControlServerFrame>
731
788
  | WithoutSeq<LiveVoiceMetricsServerFrame>
732
789
  | WithoutSeq<LiveVoiceArchivedServerFrame>
733
790
  | WithoutSeq<LiveVoiceErrorServerFrame>;
@@ -1218,6 +1275,8 @@ function validateStartFrame(
1218
1275
  const client = parseClientOs(value.client);
1219
1276
  // Same policy for the same reason: a dimension, not a capability.
1220
1277
  const entry = parseLiveVoiceEntry(value.entry);
1278
+ // Same policy again: an unknown control is a newer client, not a bad frame.
1279
+ const sessionControls = parseLiveVoiceSessionControls(value.sessionControls);
1221
1280
 
1222
1281
  return {
1223
1282
  ok: true,
@@ -1239,6 +1298,7 @@ function validateStartFrame(
1239
1298
  ? { bargeInMinSpeechMs: value.bargeInMinSpeechMs }
1240
1299
  : {}),
1241
1300
  ...(value.textInput === true ? { textInput: true } : {}),
1301
+ ...(sessionControls.length > 0 ? { sessionControls } : {}),
1242
1302
  },
1243
1303
  };
1244
1304
  }