@vellumai/assistant 0.12.2-staging.3 → 0.12.2-staging.5

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.
@@ -1873,6 +1873,40 @@ describe("LiveVoiceSession server VAD", () => {
1873
1873
  expect(announcement?.voiceControlPrompt).toContain("first question");
1874
1874
  });
1875
1875
 
1876
+ // Deepgram Flux sends interim updates through silence, each an empty
1877
+ // partial. A call nobody is talking on is still idle.
1878
+ test("an empty partial on an idle call does not hold the announcement back", async () => {
1879
+ const continuation = makeControlledContinuation();
1880
+ const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
1881
+ const { frames, session, transcribers } = createHarness({
1882
+ finals: ["first question", ""],
1883
+ startVoiceTurn,
1884
+ streamTtsAudio: makeImmediateTts(),
1885
+ spawnBackgroundContinuation: continuation.spawnBackgroundContinuation,
1886
+ continuationAnnounceSilenceMs: 20,
1887
+ });
1888
+
1889
+ await session.start();
1890
+ await session.handleBinaryAudio(LOUD_CHUNK);
1891
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
1892
+ await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
1893
+ await waitFor(
1894
+ () => continuation.spawnBackgroundContinuation.mock.calls.length === 1,
1895
+ );
1896
+ await waitFor(() =>
1897
+ frames.some((frame) => frame.type === "utterance_discarded"),
1898
+ );
1899
+ await waitFor(() => transcribers.some((t) => !t.stopped));
1900
+ for (const transcriber of transcribers) {
1901
+ if (!transcriber.stopped) {
1902
+ transcriber.emit({ type: "partial", text: "" });
1903
+ }
1904
+ }
1905
+
1906
+ continuation.finish("THE_RESULT");
1907
+ await waitFor(() => announcementOf(calls) !== undefined);
1908
+ });
1909
+
1876
1910
  test("an announcement persists hidden and is never delivered twice", async () => {
1877
1911
  const continuation = makeControlledContinuation();
1878
1912
  const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
@@ -800,6 +800,54 @@ describe("parseLiveVoiceClientTextFrame", () => {
800
800
  });
801
801
  });
802
802
 
803
+ test("parses the lookFrames capability on the start frame", () => {
804
+ const result = validateLiveVoiceClientFrame({
805
+ type: "start",
806
+ lookFrames: true,
807
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
808
+ });
809
+
810
+ expect(result.ok).toBe(true);
811
+ if (!result.ok) {
812
+ return;
813
+ }
814
+ expect(result.frame).toMatchObject({ type: "start", lookFrames: true });
815
+ });
816
+
817
+ test("omits lookFrames from the start frame when false", () => {
818
+ // False and absent mean the same thing: no look frame is coming, so the
819
+ // session must not wait for one.
820
+ const result = validateLiveVoiceClientFrame({
821
+ type: "start",
822
+ lookFrames: false,
823
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
824
+ });
825
+
826
+ expect(result.ok).toBe(true);
827
+ if (!result.ok) {
828
+ return;
829
+ }
830
+ expect("lookFrames" in result.frame).toBe(false);
831
+ });
832
+
833
+ test("returns a typed protocol error for a non-boolean lookFrames", () => {
834
+ const result = validateLiveVoiceClientFrame({
835
+ type: "start",
836
+ lookFrames: 1,
837
+ audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
838
+ });
839
+
840
+ expect(result.ok).toBe(false);
841
+ if (result.ok) {
842
+ return;
843
+ }
844
+ expect(result.error).toMatchObject({
845
+ code: "invalid_field",
846
+ field: "lookFrames",
847
+ frameType: "start",
848
+ });
849
+ });
850
+
803
851
  test("returns typed protocol errors for missing audio configuration fields", () => {
804
852
  const result = validateLiveVoiceClientFrame({
805
853
  type: "start",
@@ -105,6 +105,31 @@ describe("sessionControlTeaching", () => {
105
105
  );
106
106
  });
107
107
 
108
+ // A client that sends a fresh frame for every look lets the session answer
109
+ // it, so the reply asking for the look only acknowledges, and asking again is
110
+ // how a share already running gets looked at as it is now.
111
+ test("a client that sends look frames is taught the look is answered for it", () => {
112
+ const teaching = sessionControlTeaching(
113
+ ["look_screen", "look_camera"],
114
+ {},
115
+ { lookFrames: true },
116
+ );
117
+
118
+ expect(teaching).toContain("[LOOK:SCREEN]");
119
+ expect(teaching).toContain(
120
+ "Use it even when their screen is already shared with you",
121
+ );
122
+ expect(teaching).toContain("Use it even when the camera is already on");
123
+ expect(teaching).not.toContain("take it from their next words");
124
+ });
125
+
126
+ test("a client that sends no look frames keeps the next-words look", () => {
127
+ const teaching = sessionControlTeaching(["look_screen"], {});
128
+
129
+ expect(teaching).toContain("take it from their next words");
130
+ expect(teaching).not.toContain("Use it even when");
131
+ });
132
+
108
133
  test("the front-door leg keeps its verdict tokens", () => {
109
134
  expect(sessionControlTeaching(["end"], { frontDoor: true })).not.toContain(
110
135
  "Never emit any other bracketed marker.",
@@ -160,6 +160,11 @@ import {
160
160
  } from "./protocol.js";
161
161
  import {
162
162
  type ClientSessionControlRequest,
163
+ isLookSessionControl,
164
+ LOOK_FOLLOW_UP_CONTENT,
165
+ LOOK_FRAME_REASON,
166
+ lookFollowUpNote,
167
+ type LookSessionControl,
163
168
  progressConfigForCadence,
164
169
  requestedSessionControl,
165
170
  sessionControlTeaching,
@@ -270,6 +275,18 @@ const CONTINUATION_ANNOUNCE_MAX_DRAIN_REARMS = 3;
270
275
  // persisted on the user side is this marker, and it persists hidden.
271
276
  export const CONTINUATION_DELIVERY_CONTENT =
272
277
  "(background work finished — deliver it now)";
278
+ // How long a look control waits for the fresh frame the client takes for it.
279
+ // Long enough for a camera that has to open and warm up before its first keep;
280
+ // past it the look is dropped rather than answered into a later, unrelated
281
+ // silence.
282
+ const LOOK_FRAME_WAIT_MS = 10_000;
283
+ // How long after the look was asked for its answer may still start. Past it
284
+ // the floor has been taken for so long (a user talking through noise that never
285
+ // became a turn, say) that the look no longer answers what is on screen.
286
+ const LOOK_ANSWER_DEADLINE_MS = 15_000;
287
+ // The shortest wait before checking the floor again, for a blocker with no
288
+ // known end time (the look's own turn clearing, an utterance in capture).
289
+ const LOOK_FOLLOW_UP_REARM_MS = 250;
273
290
 
274
291
  export type LiveVoiceStreamingTranscriberResolver = (
275
292
  options: ResolveStreamingTranscriberOptions,
@@ -697,6 +714,10 @@ interface ActiveAssistantTurn {
697
714
  // no user utterance behind it — `content` is CONTINUATION_DELIVERY_CONTENT and
698
715
  // the answer rides the control prompt (buildLiveDeliveryNote).
699
716
  continuationDelivery: ContinuationDelivery | null;
717
+ // Set only on the turn that answers a look: which look it answers. The turn
718
+ // has no user utterance behind it; the instruction rides the control prompt
719
+ // (lookFollowUpNote).
720
+ lookFollowUp: LookSessionControl | null;
700
721
  // The turn's content is an internal instruction rather than user speech (the
701
722
  // greeting that opens a session, say). The row still persists and the model
702
723
  // still sees it; `hiddenSyntheticPrompt` keeps it out of the transcript.
@@ -838,6 +859,14 @@ function describeInterruptedRequest(request: string): string {
838
859
  : "their earlier request";
839
860
  }
840
861
 
862
+ // A look control waiting on its fresh frame: which look, when it was asked
863
+ // for, and the wait's bound.
864
+ interface PendingLook {
865
+ action: LookSessionControl;
866
+ armedAtMs: number;
867
+ timer: ReturnType<typeof setTimeout>;
868
+ }
869
+
841
870
  // A finished background continuation waiting to be delivered: the request it
842
871
  // took over and the answer it produced.
843
872
  interface ContinuationDelivery {
@@ -866,13 +895,14 @@ function buildVoiceControlPrompt(
866
895
  turn: ActiveAssistantTurn,
867
896
  leg: { frontDoor?: boolean },
868
897
  sessionControls: readonly LiveVoiceSessionControl[],
898
+ client: { lookFrames: boolean },
869
899
  ): string {
870
900
  let prompt =
871
901
  LIVE_VOICE_CONTROL_PROMPT_BASE +
872
902
  (leg.frontDoor === true
873
903
  ? ""
874
904
  : LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING) +
875
- sessionControlTeaching(sessionControls, leg);
905
+ sessionControlTeaching(sessionControls, leg, client);
876
906
  if (turn.language !== undefined) {
877
907
  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.`;
878
908
  }
@@ -885,6 +915,9 @@ function buildVoiceControlPrompt(
885
915
  if (turn.handedOffRequest) {
886
916
  prompt = `${prompt}\n\n${buildHandoffAnnouncementNote(turn.handedOffRequest)}`;
887
917
  }
918
+ if (turn.lookFollowUp !== null) {
919
+ prompt = `${prompt}\n\n${lookFollowUpNote(turn.lookFollowUp)}`;
920
+ }
888
921
  if (turn.continuationDelivery) {
889
922
  prompt = `${prompt}\n\n${buildLiveDeliveryNote(
890
923
  turn.continuationDelivery.request,
@@ -1135,6 +1168,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1135
1168
  // The session controls the client declared it can carry out; the only ones
1136
1169
  // the model is taught and the only ones a reply's marker can trigger.
1137
1170
  private readonly sessionControls: readonly LiveVoiceSessionControl[];
1171
+ // The client declared `lookFrames`: it sends a fresh frame for every look
1172
+ // control it carries out, so the session answers a look on its own turn and
1173
+ // teaches the model that the reply asking for one is only the acknowledgement.
1174
+ private readonly lookFrames: boolean;
1138
1175
  // How often progress updates are spoken, as the user last asked out loud.
1139
1176
  // Session-scoped: it applies from the next turn to the end of the call.
1140
1177
  private progressCadence: "fewer" | "normal" = "normal";
@@ -1222,6 +1259,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1222
1259
  // answer.
1223
1260
  private pendingAnnouncement: ContinuationDelivery | null = null;
1224
1261
  private announcementTimer: ReturnType<typeof setTimeout> | null = null;
1262
+ // A look control sent to a client that declared `lookFrames`, waiting for
1263
+ // the fresh frame the client takes for it. The session answers the look on a
1264
+ // turn of its own once that frame is in the conversation (see
1265
+ // answerLookWhenFloorIsFree). Cleared when the frame lands, when the wait
1266
+ // runs out, and when the session closes.
1267
+ private pendingLook: PendingLook | null = null;
1268
+ private lookFollowUpTimer: ReturnType<typeof setTimeout> | null = null;
1225
1269
  // Set when a continuation actually spawns: the request it took over, so the
1226
1270
  // NEXT turn can tell the user the work is still running. Consumed by that
1227
1271
  // turn; cleared when the continuation finishes (by then the result note
@@ -1236,6 +1280,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1236
1280
  // the distance from it to a keep arriving is the client leg of the frame the
1237
1281
  // onset asked for, measured from the daemon's own clock.
1238
1282
  private lastSpeechStartedAtMs: number | null = null;
1283
+ // How many assistant turns have launched, so a look can tell whether one has
1284
+ // started since its frame landed. A count rather than a time: two launches
1285
+ // and a frame can share a millisecond.
1286
+ private turnsLaunched = 0;
1239
1287
  private readonly maxPendingAudioBytes: number;
1240
1288
  // Set on VAD speech onset; consumed when the first speech chunk is routed
1241
1289
  // to an utterance so the metric lands on the right turn.
@@ -1413,6 +1461,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1413
1461
  SERVER_VAD_PENDING_AUDIO_MAX_SECONDS;
1414
1462
  this.textInput = context.startFrame.textInput === true;
1415
1463
  this.sessionControls = context.startFrame.sessionControls ?? [];
1464
+ this.lookFrames = context.startFrame.lookFrames === true;
1416
1465
  }
1417
1466
 
1418
1467
  get finalTranscriptText(): string {
@@ -1661,6 +1710,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1661
1710
  },
1662
1711
  "Sight frame timing",
1663
1712
  );
1713
+ // Only once the row is in: the turn that answers the look reads the
1714
+ // conversation, so the frame has to be there before that turn starts.
1715
+ if (result.ok && frame.timing?.reason === LOOK_FRAME_REASON) {
1716
+ this.lookFrameLanded();
1717
+ }
1664
1718
  if (!result.ok && !this.isClosed) {
1665
1719
  void this.sendFrame({
1666
1720
  type: "error",
@@ -1866,6 +1920,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1866
1920
  // cleared: the announcement is dead either way.
1867
1921
  await this.deliverPendingContinuationToConversation();
1868
1922
  this.clearContinuationAnnouncement();
1923
+ this.clearPendingLook();
1869
1924
  this.stopSessionTranscriber();
1870
1925
  // Detached continuations outlive the call. A deliberate `interrupt()`
1871
1926
  // aborts them; ending the session leaves them running. With no next voice
@@ -2909,16 +2964,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2909
2964
  // already finished, so bargeIn returns it to the stash instead.
2910
2965
  turn.continuationDelivery !== null
2911
2966
  ? "announcement_turn"
2912
- : // The model already finished generating (barge-in during TTS playback
2913
- // of a complete reply): there is nothing to continue, so a
2914
- // continuation would just re-do a finished answer.
2915
- turn.assistantCompleted
2916
- ? "assistant_already_completed"
2917
- : // A stop (interrupt/close) or a superseding invalidation landed
2918
- // during the barge-in teardown: honor it.
2919
- this.detachStopGeneration !== stopGeneration
2920
- ? "invalidated_during_barge_teardown"
2921
- : null;
2967
+ : // Nor over the answer to a look: there is no request behind it
2968
+ // either, and the user talking over it is them moving on.
2969
+ turn.lookFollowUp !== null
2970
+ ? "look_follow_up"
2971
+ : // The model already finished generating (barge-in during TTS playback
2972
+ // of a complete reply): there is nothing to continue, so a
2973
+ // continuation would just re-do a finished answer.
2974
+ turn.assistantCompleted
2975
+ ? "assistant_already_completed"
2976
+ : // A stop (interrupt/close) or a superseding invalidation landed
2977
+ // during the barge-in teardown: honor it.
2978
+ this.detachStopGeneration !== stopGeneration
2979
+ ? "invalidated_during_barge_teardown"
2980
+ : null;
2922
2981
  if (skipReason !== null) {
2923
2982
  log.info(
2924
2983
  { turnId: turn.turnId, skipReason },
@@ -3565,6 +3624,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3565
3624
  // own it leaves manual mode blind to a user who is talking right now and
3566
3625
  // has no text yet — hence the captured-audio flag, which manual ingress
3567
3626
  // sets from the first chunk.
3627
+ //
3628
+ // A partial counts only when it has words. Deepgram Flux sends interim
3629
+ // updates through silence too, each an empty partial, and one of those
3630
+ // would otherwise hold the floor until the user next spoke.
3568
3631
  if (
3569
3632
  utterance !== null &&
3570
3633
  !utterance.completed &&
@@ -3572,13 +3635,129 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3572
3635
  utterance.assistantTurnStarted ||
3573
3636
  (utterance.manualAudioCaptured && opts?.ignoreManualCapture !== true) ||
3574
3637
  utterance.finalTranscriptSegments.length > 0 ||
3575
- utterance.latestPartialText !== null)
3638
+ (utterance.latestPartialText?.trim() ?? "").length > 0)
3576
3639
  ) {
3577
3640
  return "utterance_in_flight";
3578
3641
  }
3579
3642
  return null;
3580
3643
  }
3581
3644
 
3645
+ /**
3646
+ * Wait for the fresh frame a look control asks the client for.
3647
+ *
3648
+ * Only for a client that declared `lookFrames`: any other sends no such
3649
+ * frame, and its look keeps the old shape, where the user's next words are
3650
+ * what the frame gets answered on. A newer look replaces an older one still
3651
+ * waiting, and the wait is bounded so a frame that never comes (a camera
3652
+ * that would not open, a share the desktop refused) cannot turn up minutes
3653
+ * later as a reply to nothing.
3654
+ */
3655
+ private awaitLookFrame(action: LookSessionControl): void {
3656
+ if (!this.lookFrames) {
3657
+ return;
3658
+ }
3659
+ this.clearPendingLook();
3660
+ const timer = setTimeout(() => {
3661
+ if (this.pendingLook?.timer !== timer) {
3662
+ return;
3663
+ }
3664
+ this.pendingLook = null;
3665
+ log.info(
3666
+ { conversationId: this.conversationId, action },
3667
+ "Live voice look dropped: no frame arrived",
3668
+ );
3669
+ }, LOOK_FRAME_WAIT_MS);
3670
+ this.pendingLook = { action, armedAtMs: Date.now(), timer };
3671
+ }
3672
+
3673
+ private clearPendingLook(): void {
3674
+ if (this.pendingLook !== null) {
3675
+ clearTimeout(this.pendingLook.timer);
3676
+ this.pendingLook = null;
3677
+ }
3678
+ if (this.lookFollowUpTimer !== null) {
3679
+ clearTimeout(this.lookFollowUpTimer);
3680
+ this.lookFollowUpTimer = null;
3681
+ }
3682
+ }
3683
+
3684
+ /**
3685
+ * The frame a look asked for is in the conversation: answer the look.
3686
+ *
3687
+ * A look frame with no look waiting (the wait ran out, or the session never
3688
+ * armed one) is only a frame, like any other keep.
3689
+ */
3690
+ private lookFrameLanded(): void {
3691
+ const pending = this.pendingLook;
3692
+ if (pending === null || this.isClosed) {
3693
+ return;
3694
+ }
3695
+ clearTimeout(pending.timer);
3696
+ this.pendingLook = null;
3697
+ this.answerLookWhenFloorIsFree(pending, this.turnsLaunched, 0);
3698
+ }
3699
+
3700
+ /**
3701
+ * Start the turn that answers a look, once nothing else holds the floor.
3702
+ *
3703
+ * The acknowledgement ("taking a look") can still be playing when the frame
3704
+ * lands, and the look's own turn can still be clearing, so both are waited
3705
+ * out, as is the user mid-utterance. A turn that starts once the frame is in
3706
+ * the conversation is not: it reads the frame, so answering the look as well
3707
+ * would answer it twice. A turn that started before the frame landed did not
3708
+ * see it, so the look is still answered once that turn is done.
3709
+ *
3710
+ * `turnsAtFrame` is how many turns had launched when the frame landed.
3711
+ */
3712
+ private answerLookWhenFloorIsFree(
3713
+ look: PendingLook,
3714
+ turnsAtFrame: number,
3715
+ rearms: number,
3716
+ ): void {
3717
+ if (this.lookFollowUpTimer !== null) {
3718
+ clearTimeout(this.lookFollowUpTimer);
3719
+ this.lookFollowUpTimer = null;
3720
+ }
3721
+ const { action, armedAtMs } = look;
3722
+ // A turn launched since the frame landed read it already. Speech that
3723
+ // never became a turn (a cough, noise that transcribed to nothing) is only
3724
+ // waited out.
3725
+ const blockedBy =
3726
+ this.turnsLaunched > turnsAtFrame
3727
+ ? "turn_since_look"
3728
+ : this.sessionTurnFloorBlocker();
3729
+ if (blockedBy === null) {
3730
+ void this.launchAssistantTurn(
3731
+ createSyntheticUtterance(),
3732
+ LOOK_FOLLOW_UP_CONTENT,
3733
+ { lookFollowUp: action, hiddenPrompt: true },
3734
+ ).catch((err: unknown) => {
3735
+ log.warn(
3736
+ { err, conversationId: this.conversationId, action },
3737
+ "Live voice look follow-up failed to start",
3738
+ );
3739
+ });
3740
+ return;
3741
+ }
3742
+ const waitable =
3743
+ blockedBy !== "turn_since_look" && blockedBy !== "session_unavailable";
3744
+ if (!waitable || Date.now() - armedAtMs >= LOOK_ANSWER_DEADLINE_MS) {
3745
+ log.info(
3746
+ { conversationId: this.conversationId, action, blockedBy, rearms },
3747
+ "Live voice look follow-up skipped",
3748
+ );
3749
+ return;
3750
+ }
3751
+ const drainMs = Math.max(0, this.assistantPlaybackTailUntilMs - Date.now());
3752
+ this.lookFollowUpTimer = setTimeout(
3753
+ () => {
3754
+ this.lookFollowUpTimer = null;
3755
+ this.answerLookWhenFloorIsFree(look, turnsAtFrame, rearms + 1);
3756
+ },
3757
+ Math.max(drainMs, LOOK_FOLLOW_UP_REARM_MS),
3758
+ );
3759
+ }
3760
+
3582
3761
  // Speak the finished continuation's result on a turn the session starts
3583
3762
  // itself. The turn rides a synthetic cycle rather than the armed VAD one —
3584
3763
  // `currentUtterance` belongs to the capture loop, and overwriting it would
@@ -4770,6 +4949,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4770
4949
  this.pendingInterruptedRequest = null;
4771
4950
  // ...and it hard-stops any detached background continuations.
4772
4951
  this.abortDetachedRuns({ reason: "client_interrupt" });
4952
+ // ...and a look still waiting to be answered: the user stopped the call
4953
+ // talking, and a reply starting on its own a moment later is not a stop.
4954
+ this.clearPendingLook();
4773
4955
  const utterance = this.currentUtterance;
4774
4956
  this.stopSessionTranscriber();
4775
4957
  if (utterance) {
@@ -4991,6 +5173,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4991
5173
  // Set on an announcement turn: the finished continuation this turn exists
4992
5174
  // to deliver. Its answer goes in the control prompt, not in `content`.
4993
5175
  continuationDelivery?: ContinuationDelivery | null;
5176
+ // Set on the turn that answers a look: which look. Its instruction goes
5177
+ // in the control prompt, not in `content`.
5178
+ lookFollowUp?: LookSessionControl;
4994
5179
  // Unified front-door: dispatch without releasing the utterance. The
4995
5180
  // thinking frame and floor-holding timers are deferred until the leg's
4996
5181
  // leading verdict commits the turn (see commitSpeculativeTurn); a hold
@@ -5085,6 +5270,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5085
5270
  consumedAnnouncement: pending?.announcement ?? null,
5086
5271
  pendingContextStopGeneration: this.detachStopGeneration,
5087
5272
  continuationDelivery: opts?.continuationDelivery ?? null,
5273
+ lookFollowUp: opts?.lookFollowUp ?? null,
5088
5274
  hiddenPrompt: opts?.hiddenPrompt === true,
5089
5275
  deltaEpoch: 0,
5090
5276
  frontDoor: null,
@@ -5100,6 +5286,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5100
5286
  assistantAudioMimeType: "audio/pcm",
5101
5287
  };
5102
5288
  this.activeAssistantTurn = activeTurn;
5289
+ this.turnsLaunched += 1;
5103
5290
 
5104
5291
  // A speculative turn defers the thinking frame and both floor-holding
5105
5292
  // timers to commitSpeculativeTurn: until the verdict arrives, the pause
@@ -5342,6 +5529,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5342
5529
  : {}),
5343
5530
  },
5344
5531
  this.sessionControls,
5532
+ { lookFrames: this.lookFrames },
5345
5533
  ),
5346
5534
  onApprovalPending: (requestId) => {
5347
5535
  this.revealRoomForPendingApproval(activeTurn, requestId);
@@ -5897,6 +6085,16 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
5897
6085
  if (sessionControl.action === "end") {
5898
6086
  currentTurn.minimizeRequested = false;
5899
6087
  }
6088
+ // Armed before the send, so a client quick enough to answer with
6089
+ // its frame before this await resolves still finds the look waiting.
6090
+ // Never from the turn that answers a look: that turn is the answer,
6091
+ // and a marker it emits anyway must not chain another.
6092
+ if (
6093
+ isLookSessionControl(sessionControl.action) &&
6094
+ currentTurn.lookFollowUp === null
6095
+ ) {
6096
+ this.awaitLookFrame(sessionControl.action);
6097
+ }
5900
6098
  await this.sendFrame(
5901
6099
  {
5902
6100
  type: "session_control",
@@ -166,6 +166,16 @@ export interface LiveVoiceClientStartFrame {
166
166
  * has never heard of.
167
167
  */
168
168
  readonly sessionControls?: readonly LiveVoiceSessionControl[];
169
+ /**
170
+ * This client sends a fresh `sight_frame` right after it carries out a look
171
+ * control, with timing reason `look`, whether or not a share or the camera
172
+ * was already running. The session answers the look from that frame on a
173
+ * turn of its own, so the reply that asked for the look only acknowledges it.
174
+ *
175
+ * Absent means false: a client that predates the field sends no such frame,
176
+ * and a session waiting on one would promise a look that never comes.
177
+ */
178
+ readonly lookFrames?: boolean;
169
179
  }
170
180
 
171
181
  const LIVE_VOICE_SESSION_CONTROLS = [
@@ -1269,6 +1279,15 @@ function validateStartFrame(
1269
1279
  );
1270
1280
  }
1271
1281
 
1282
+ if ("lookFrames" in value && typeof value.lookFrames !== "boolean") {
1283
+ return protocolError(
1284
+ "invalid_field",
1285
+ "start frame field lookFrames must be a boolean",
1286
+ "lookFrames",
1287
+ "start",
1288
+ );
1289
+ }
1290
+
1272
1291
  if ("textInput" in value && typeof value.textInput !== "boolean") {
1273
1292
  return protocolError(
1274
1293
  "invalid_field",
@@ -1308,6 +1327,7 @@ function validateStartFrame(
1308
1327
  : {}),
1309
1328
  ...(value.textInput === true ? { textInput: true } : {}),
1310
1329
  ...(sessionControls.length > 0 ? { sessionControls } : {}),
1330
+ ...(value.lookFrames === true ? { lookFrames: true } : {}),
1311
1331
  },
1312
1332
  };
1313
1333
  }
@@ -33,6 +33,55 @@ const CLIENT_CONTROL_LINES: Record<LiveVoiceSessionControl, string> = {
33
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
34
  };
35
35
 
36
+ /**
37
+ * The look lines for a client that declared `lookFrames`: one that sends a
38
+ * fresh frame the moment it carries out a look, which the session answers on
39
+ * a turn of its own (see {@link LOOK_FRAME_REASON}). The reply that asks for
40
+ * the look is therefore only the acknowledgement, and asking to look again is
41
+ * how the model gets a current view of a share that is already running.
42
+ */
43
+ const LOOK_FRAME_CONTROL_LINES: Partial<
44
+ Record<LiveVoiceSessionControl, string>
45
+ > = {
46
+ look_screen: `- To look at their screen (for example "take a look at my screen", or "can you see it now?" after something on it changed), say in a few words that you are taking a look, then end your reply with ${LOOK_SCREEN_MARKER}. Use it even when their screen is already shared with you, to see it as it is now. You get a fresh view right after you finish speaking and answer from that, so do not describe the screen yet and do not ask them to say anything more.`,
47
+ look_camera: `- To look through their camera (for example "look at this" or "can you see this?" while they hold something up), say in a few words that you are taking a look, then end your reply with ${LOOK_CAMERA_MARKER}. Use it even when the camera is already on, to see what it shows now. You get a fresh view right after you finish speaking and answer from that, so do not describe it yet and do not ask them to say anything more.`,
48
+ };
49
+
50
+ /**
51
+ * The `reason` a client's sight frame timing carries when the frame is the
52
+ * fresh view it took for a look control. Only a client that declared
53
+ * `lookFrames` sends one, and the session answers the look from it.
54
+ */
55
+ export const LOOK_FRAME_REASON = "look";
56
+
57
+ /** The controls that end in a fresh frame the session answers from. */
58
+ export type LookSessionControl = Extract<
59
+ LiveVoiceSessionControl,
60
+ "look_screen" | "look_camera"
61
+ >;
62
+
63
+ export function isLookSessionControl(
64
+ action: string,
65
+ ): action is LookSessionControl {
66
+ return action === "look_screen" || action === "look_camera";
67
+ }
68
+
69
+ /**
70
+ * The persisted `content` of the turn that answers a look. Hidden: there is no
71
+ * user utterance behind the turn, and the instruction rides the control prompt
72
+ * ({@link lookFollowUpNote}).
73
+ */
74
+ export const LOOK_FOLLOW_UP_CONTENT = "(fresh view taken; answer from it now)";
75
+
76
+ /**
77
+ * Appended to the control prompt of the turn that answers a look: the reply
78
+ * that asked for it only acknowledged, and the frame it asked for has landed.
79
+ */
80
+ export function lookFollowUpNote(action: LookSessionControl): string {
81
+ const what = action === "look_screen" ? "their screen" : "their camera";
82
+ return `You just took a fresh look at ${what}, and the newest image in the conversation is what it shows right now. Answer what they wanted you to look at, out loud, in a few spoken sentences. If the image does not show what they meant, say briefly what you do see and ask. You have already said you were taking a look, so do not say it again and do not end with a look marker.`;
83
+ }
84
+
36
85
  // Always taught: narration is the session's own, so no client has to be able
37
86
  // to carry it out.
38
87
  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}.`;
@@ -49,10 +98,15 @@ const UPDATES_LINE = `- To hear fewer spoken progress updates while you work (fo
49
98
  export function sessionControlTeaching(
50
99
  controls: readonly LiveVoiceSessionControl[],
51
100
  leg: { frontDoor?: boolean },
101
+ client: { lookFrames?: boolean } = {},
52
102
  ): string {
103
+ const lines =
104
+ client.lookFrames === true
105
+ ? { ...CLIENT_CONTROL_LINES, ...LOOK_FRAME_CONTROL_LINES }
106
+ : CLIENT_CONTROL_LINES;
53
107
  return [
54
108
  "The user can also control this call by asking you. Only when they clearly ask:",
55
- ...controls.map((control) => CLIENT_CONTROL_LINES[control]),
109
+ ...controls.map((control) => lines[control]),
56
110
  ...lookGuidance(controls),
57
111
  UPDATES_LINE,
58
112
  `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."}`,