@vellumai/assistant 0.11.2-staging.1 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-staging.1",
3
+ "version": "0.11.2-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,6 +12,7 @@ import { describe, expect, test } from "bun:test";
12
12
 
13
13
  import {
14
14
  activityLabelForTool,
15
+ approvalActivityLabel,
15
16
  GENERIC_ACTIVITY_LABEL,
16
17
  } from "../activity-label.js";
17
18
 
@@ -66,3 +67,29 @@ describe("activityLabelForTool", () => {
66
67
  }
67
68
  });
68
69
  });
70
+
71
+ describe("approvalActivityLabel", () => {
72
+ // The turn is not running the tool, it is waiting to be allowed to. Saying
73
+ // the former is the misstatement this exists to stop — and the line has to
74
+ // name *something*, or the island asks the user to approve a blank.
75
+ test("keeps the tool's phrase and says who is being waited on", () => {
76
+ expect(approvalActivityLabel("bash")).toBe(
77
+ "Running a command — needs your okay",
78
+ );
79
+ expect(approvalActivityLabel("web_search")).toBe(
80
+ "Searching the web — needs your okay",
81
+ );
82
+ });
83
+
84
+ test("an unrecognized tool still reads as a wait", () => {
85
+ expect(approvalActivityLabel("some_vendor_tool")).toBe(
86
+ `${GENERIC_ACTIVITY_LABEL} — needs your okay`,
87
+ );
88
+ });
89
+
90
+ // A confirmation from a proxy or network prompter has no tool behind it, and
91
+ // "Working on it — needs your okay" would claim work that is not happening.
92
+ test("drops the phrase entirely when there is no tool to name", () => {
93
+ expect(approvalActivityLabel("")).toBe("Needs your okay");
94
+ });
95
+ });
@@ -154,6 +154,21 @@ async function waitFor(
154
154
  throw new Error(message);
155
155
  }
156
156
 
157
+ /**
158
+ * The most recent `activity` frame, or `undefined` when none has been sent.
159
+ * `findLast` is past this project's lib target, so the filter-and-take-last
160
+ * form stands in for it.
161
+ */
162
+ function lastActivityFrame(
163
+ frames: LiveVoiceServerFrame[],
164
+ ): Extract<LiveVoiceServerFrame, { type: "activity" }> | undefined {
165
+ const activity = frames.filter(
166
+ (frame): frame is Extract<LiveVoiceServerFrame, { type: "activity" }> =>
167
+ frame.type === "activity",
168
+ );
169
+ return activity[activity.length - 1];
170
+ }
171
+
157
172
  function createCapturingTurnStarter(): {
158
173
  startVoiceTurn: LiveVoiceTurnStarter;
159
174
  getCallbacks: () => VoiceTurnCallbacks | undefined;
@@ -819,6 +834,116 @@ describe("LiveVoiceSession room reveal", () => {
819
834
  await flushAsyncCallbacks();
820
835
  });
821
836
 
837
+ // `tool_use_start` fires before the approval gate blocks, so without this the
838
+ // island keeps saying "Running a command" for the whole wait — a claim about
839
+ // work in flight made at the one moment nothing is in flight. The request id
840
+ // is what turns that line from accurate into answerable: it is what the Live
841
+ // Activity's Approve and Deny send back.
842
+ test("a pending approval publishes an answerable activity line", async () => {
843
+ const { startVoiceTurn, getCallbacks, announceApprovalPending } =
844
+ createCapturingTurnStarter();
845
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
846
+
847
+ await session.start();
848
+ await session.handleClientFrame({ type: "ptt_release" });
849
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
850
+
851
+ getCallbacks()?.tool_use_start?.("bash");
852
+ await waitFor(() =>
853
+ frames.some(
854
+ (frame) => frame.type === "activity" && frame.label.length > 0,
855
+ ),
856
+ );
857
+ announceApprovalPending();
858
+ await waitFor(() =>
859
+ frames.some(
860
+ (frame) =>
861
+ frame.type === "activity" && frame.approvalRequestId === "req-1",
862
+ ),
863
+ );
864
+
865
+ const waiting = lastActivityFrame(frames);
866
+ // The tool's own phrase, kept, plus who is being waited on — so the line
867
+ // beside the buttons names what is being approved without naming the tool
868
+ // or its arguments to a Lock Screen.
869
+ expect(waiting).toMatchObject({
870
+ label: "Running a command — needs your okay",
871
+ approvalRequestId: "req-1",
872
+ });
873
+ });
874
+
875
+ // However the decision is made — the card in the app, the daemon's own
876
+ // timeout, a superseding message — the buttons must go with it. They are
877
+ // rendered from the id, so retiring the id is what retires them.
878
+ test("resolving the approval retires the request id", async () => {
879
+ const {
880
+ startVoiceTurn,
881
+ getCallbacks,
882
+ announceApprovalPending,
883
+ announceApprovalsResolved,
884
+ } = createCapturingTurnStarter();
885
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
886
+
887
+ await session.start();
888
+ await session.handleClientFrame({ type: "ptt_release" });
889
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
890
+
891
+ getCallbacks()?.tool_use_start?.("bash");
892
+ announceApprovalPending();
893
+ await waitFor(() =>
894
+ frames.some(
895
+ (frame) =>
896
+ frame.type === "activity" && frame.approvalRequestId === "req-1",
897
+ ),
898
+ );
899
+
900
+ announceApprovalsResolved();
901
+ await waitFor(
902
+ () => lastActivityFrame(frames)?.approvalRequestId === undefined,
903
+ );
904
+
905
+ const resumed = lastActivityFrame(frames);
906
+ // Back to the tool that is now genuinely running, with nothing to answer.
907
+ expect(resumed).toMatchObject({ label: "Running a command" });
908
+ expect(resumed?.approvalRequestId).toBeUndefined();
909
+ });
910
+
911
+ // A turn can start and finish other work while it is blocked. Each of those
912
+ // events republishes the activity line, and a republish composed as if
913
+ // nothing were pending would take the request id down with it — retiring the
914
+ // island's buttons while the turn was still waiting on them.
915
+ test("a parallel tool event mid-wait does not retire the buttons", async () => {
916
+ const { startVoiceTurn, getCallbacks, announceApprovalPending } =
917
+ createCapturingTurnStarter();
918
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
919
+
920
+ await session.start();
921
+ await session.handleClientFrame({ type: "ptt_release" });
922
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
923
+
924
+ getCallbacks()?.tool_use_start?.("bash");
925
+ announceApprovalPending();
926
+ await waitFor(() =>
927
+ frames.some(
928
+ (frame) =>
929
+ frame.type === "activity" && frame.approvalRequestId === "req-1",
930
+ ),
931
+ );
932
+
933
+ getCallbacks()?.tool_use_start?.("web_search");
934
+ getCallbacks()?.tool_result?.({
935
+ toolName: "web_search",
936
+ resultPreview: "",
937
+ });
938
+ await flushAsyncCallbacks();
939
+
940
+ const latest = lastActivityFrame(frames);
941
+ expect(latest?.approvalRequestId).toBe("req-1");
942
+ // And still naming the tool the decision is about, not the one that ran
943
+ // alongside it.
944
+ expect(latest?.label).toBe("Running a command — needs your okay");
945
+ });
946
+
822
947
  test("dismissing a surface does not reveal the screen", async () => {
823
948
  const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
824
949
 
@@ -139,3 +139,31 @@ export function activityLabelForTool(toolName: string): string {
139
139
  }
140
140
  return GENERIC_ACTIVITY_LABEL;
141
141
  }
142
+
143
+ /**
144
+ * The line to show while the turn is *waiting* on the user for `toolName`,
145
+ * rather than running it.
146
+ *
147
+ * The distinction matters more here than anywhere else this module is read.
148
+ * `tool_use_start` fires before the approval gate blocks, so a surface that
149
+ * showed only {@link activityLabelForTool} would say "Running a command" for
150
+ * the whole time the turn was in fact doing nothing but waiting — the same
151
+ * misstatement the spoken narration was fixed to stop making. This is the
152
+ * island's version of that fix.
153
+ *
154
+ * It keeps the tool's own phrase and appends who is being waited on, because
155
+ * the alternative — a bare "Waiting for your approval" — asks the user to
156
+ * approve something the surface will not name. That phrase is as specific as
157
+ * the island's vocabulary gets: no tool names, no arguments, nothing a
158
+ * passer-by reading a Lock Screen should not see (see the module header).
159
+ * Anyone wanting the detail has the card itself, one tap away.
160
+ */
161
+ export function approvalActivityLabel(toolName: string): string {
162
+ // A confirmation raised outside the tool pipeline (a proxy or network
163
+ // prompter) has no tool to name, and "Working on it — needs your okay" would
164
+ // be a worse sentence than the bare one: the turn is not working.
165
+ if (toolName.length === 0) {
166
+ return "Needs your okay";
167
+ }
168
+ return `${activityLabelForTool(toolName)} — needs your okay`;
169
+ }
@@ -60,6 +60,7 @@ import { createAbortReason } from "../util/abort-reasons.js";
60
60
  import { getLogger } from "../util/logger.js";
61
61
  import {
62
62
  activityLabelForTool,
63
+ approvalActivityLabel,
63
64
  dismissesUiSurface,
64
65
  revealsUiSurface,
65
66
  } from "./activity-label.js";
@@ -491,10 +492,29 @@ interface ActiveAssistantTurn {
491
492
  // map to the same line sends one frame rather than one per call. Empty means
492
493
  // the client believes nothing is running, which is also where a turn ends.
493
494
  activityLabel: string;
494
- // Set while the turn is blocked on a decision the user has to make. Suppresses
495
- // progress narration, whose entire vocabulary ("still on it", "almost there")
496
- // describes work in flight and would be false here.
497
- awaitingApproval: boolean;
495
+ // The approval id that went out with it, so the de-duplication covers the
496
+ // whole frame rather than its wording. What the CLIENT believes, as against
497
+ // `pendingApproval`, which is what is true.
498
+ publishedApprovalRequestId: string | null;
499
+ // Set while the turn is blocked on a decision the user has to make, and null
500
+ // when it is not. Suppresses progress narration, whose entire vocabulary
501
+ // ("still on it", "almost there") describes work in flight and would be
502
+ // false here.
503
+ //
504
+ // Carries the request id so a surface that is not the app — the Live
505
+ // Activity's buttons — can answer *that* request rather than whatever is
506
+ // pending by the time the tap arrives, and the wording that named it, which
507
+ // is captured once at the reveal rather than recomputed: a parallel op
508
+ // starting mid-wait moves `currentActivityLabel` on, and the line beside an
509
+ // Approve button must keep naming the thing being approved.
510
+ //
511
+ // The FIRST one, on a turn that leaves two decisions pending at once: the
512
+ // wait is announced once (see `revealRoomForPendingApproval`) and the pair
513
+ // resolves as one. A tap answering the first after it has already been
514
+ // decided is dropped client-side by the id check, which is the safe end of
515
+ // a rare case — the island never silently answers a request other than the
516
+ // one it named.
517
+ pendingApproval: { requestId: string; label: string } | null;
498
518
  // A tts_audio frame actually went out to the client — latches on the first
499
519
  // forwarded chunk so the firstTtsAudio metric is marked exactly once per turn.
500
520
  ttsAudioStarted: boolean;
@@ -2349,6 +2369,54 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2349
2369
  return "";
2350
2370
  }
2351
2371
 
2372
+ /**
2373
+ * The line for a turn that is waiting on a decision about its newest running
2374
+ * op.
2375
+ *
2376
+ * The approval gate sits behind `tool_use_start`, so the tool being waited on
2377
+ * is the one the turn last said it was running — which is precisely why the
2378
+ * wait has to be published at all: without it the surfaces keep showing that
2379
+ * tool as *running* for the whole time it is doing nothing of the kind.
2380
+ * Falls back to the tool-less phrase when no op is open, which is the case
2381
+ * for a confirmation raised by a prompter outside the tool pipeline.
2382
+ *
2383
+ * Read once, at the reveal — see `pendingApproval` for why it is then held
2384
+ * rather than recomputed.
2385
+ */
2386
+ private pendingApprovalLabel(turn: ActiveAssistantTurn): string {
2387
+ for (let i = turn.progress.ops.length - 1; i >= 0; i -= 1) {
2388
+ const op = turn.progress.ops[i];
2389
+ if (op !== undefined && op.completedAtMs === undefined) {
2390
+ return approvalActivityLabel(op.toolName);
2391
+ }
2392
+ }
2393
+ return approvalActivityLabel("");
2394
+ }
2395
+
2396
+ /**
2397
+ * Publish whatever the turn's activity line should be right now: the
2398
+ * decision it is waiting on if it is waiting, and its newest running tool if
2399
+ * it is not.
2400
+ *
2401
+ * The single entry point for every caller that would otherwise reach for
2402
+ * `currentActivityLabel` directly. A turn can start and finish other ops
2403
+ * while it is blocked on an approval — a parallel `tool_use_start` or
2404
+ * `tool_result` lands mid-wait — and each of those would otherwise publish a
2405
+ * line composed as if nothing were pending, taking the request id down with
2406
+ * it and retiring the island's buttons while the turn was still waiting.
2407
+ */
2408
+ private refreshActivity(turn: ActiveAssistantTurn): void {
2409
+ if (turn.pendingApproval !== null) {
2410
+ this.publishActivity(
2411
+ turn,
2412
+ turn.pendingApproval.label,
2413
+ turn.pendingApproval.requestId,
2414
+ );
2415
+ return;
2416
+ }
2417
+ this.publishActivity(turn, this.currentActivityLabel(turn));
2418
+ }
2419
+
2352
2420
  /**
2353
2421
  * Open the room because a decision is waiting behind it.
2354
2422
  *
@@ -2362,12 +2430,25 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2362
2430
  * The latch is cleared as well, so a turn that also showed a surface does
2363
2431
  * not send a second minimize once its speech ends; the room is already open.
2364
2432
  */
2365
- private revealRoomForPendingApproval(turn: ActiveAssistantTurn): void {
2366
- if (turn.awaitingApproval) {
2433
+ private revealRoomForPendingApproval(
2434
+ turn: ActiveAssistantTurn,
2435
+ requestId: string,
2436
+ ): void {
2437
+ if (turn.pendingApproval !== null) {
2367
2438
  return;
2368
2439
  }
2369
- turn.awaitingApproval = true;
2440
+ turn.pendingApproval = {
2441
+ requestId,
2442
+ label: this.pendingApprovalLabel(turn),
2443
+ };
2370
2444
  turn.minimizeRequested = false;
2445
+ // Say what the turn is actually doing on the surfaces that are not the
2446
+ // app. Without this the island keeps showing the tool as running for the
2447
+ // whole wait — and it is the surface most likely to be the only one the
2448
+ // user can see, since the case this exists for is a phone put down.
2449
+ // Carrying the request id is what makes the line answerable there rather
2450
+ // than merely accurate.
2451
+ this.refreshActivity(turn);
2371
2452
  void this.sendFrame(
2372
2453
  { type: "minimize_room", turnId: turn.turnId },
2373
2454
  () => !this.isClosed,
@@ -2381,7 +2462,13 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2381
2462
 
2382
2463
  /** Clear the wait once a decision lands, so the turn narrates normally again. */
2383
2464
  private clearAwaitingApproval(turn: ActiveAssistantTurn): void {
2384
- turn.awaitingApproval = false;
2465
+ turn.pendingApproval = null;
2466
+ // Put the activity line back to whatever the turn resumed doing, and
2467
+ // retire the request id with it, so the island's Approve/Deny buttons go
2468
+ // away the moment the decision is no longer the user's to make — including
2469
+ // when it was made somewhere else entirely (the card in the app, the
2470
+ // 45-second fallback, a superseding message).
2471
+ this.refreshActivity(turn);
2385
2472
  }
2386
2473
 
2387
2474
  /**
@@ -2394,14 +2481,37 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2394
2481
  *
2395
2482
  * Fire-and-forget. An activity label is a flourish, and nothing about the
2396
2483
  * conversation may wait on one.
2484
+ *
2485
+ * Callers whose line depends on turn state go through
2486
+ * {@link refreshActivity}; this is called directly only to clear the line
2487
+ * outright, which a cancelled or finished turn does regardless of what it
2488
+ * was waiting on.
2397
2489
  */
2398
- private publishActivity(turn: ActiveAssistantTurn, label: string): void {
2399
- if (turn.activityLabel === label) {
2490
+ private publishActivity(
2491
+ turn: ActiveAssistantTurn,
2492
+ label: string,
2493
+ approvalRequestId?: string,
2494
+ ): void {
2495
+ // De-duplicated on the request id as well as the wording. The two move
2496
+ // independently: a wait can be entered and left without the tool line
2497
+ // changing at all, and a label-only check would swallow the frame that
2498
+ // retires the approval — leaving the island's buttons up with nothing
2499
+ // behind them.
2500
+ if (
2501
+ turn.activityLabel === label &&
2502
+ turn.publishedApprovalRequestId === (approvalRequestId ?? null)
2503
+ ) {
2400
2504
  return;
2401
2505
  }
2402
2506
  turn.activityLabel = label;
2507
+ turn.publishedApprovalRequestId = approvalRequestId ?? null;
2403
2508
  void this.sendFrame(
2404
- { type: "activity", turnId: turn.turnId, label },
2509
+ {
2510
+ type: "activity",
2511
+ turnId: turn.turnId,
2512
+ label,
2513
+ ...(approvalRequestId !== undefined ? { approvalRequestId } : {}),
2514
+ },
2405
2515
  () => !this.isClosed,
2406
2516
  );
2407
2517
  }
@@ -3499,7 +3609,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3499
3609
  ttsDone: false,
3500
3610
  minimizeRequested: false,
3501
3611
  activityLabel: "",
3502
- awaitingApproval: false,
3612
+ publishedApprovalRequestId: null,
3613
+ pendingApproval: null,
3503
3614
  ttsAudioStarted: false,
3504
3615
  finalized: false,
3505
3616
  speculativePending: opts?.speculative === true,
@@ -3701,8 +3812,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3701
3812
  voiceControlPrompt: buildVoiceControlPrompt(activeTurn, {
3702
3813
  ...(leg.frontDoor !== undefined ? { frontDoor: leg.frontDoor } : {}),
3703
3814
  }),
3704
- onApprovalPending: () => {
3705
- this.revealRoomForPendingApproval(activeTurn);
3815
+ onApprovalPending: (requestId) => {
3816
+ this.revealRoomForPendingApproval(activeTurn, requestId);
3706
3817
  },
3707
3818
  onApprovalsResolved: () => {
3708
3819
  this.clearAwaitingApproval(activeTurn);
@@ -3932,7 +4043,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
3932
4043
  });
3933
4044
  current.progress.opsSinceNarration += 1;
3934
4045
  current.progress.stateEpoch += 1;
3935
- this.publishActivity(current, this.currentActivityLabel(current));
4046
+ this.refreshActivity(current);
3936
4047
  log.debug({ turnId, toolName }, "Live voice turn started tool use");
3937
4048
  // Definitive tool use means the turn is guaranteed slow: speak
3938
4049
  // the floor-holding ack now instead of waiting out the
@@ -4007,7 +4118,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4007
4118
  current.minimizeRequested = false;
4008
4119
  }
4009
4120
  }
4010
- this.publishActivity(current, this.currentActivityLabel(current));
4121
+ this.refreshActivity(current);
4011
4122
  this.maybeNarrateProgress(current, trigger);
4012
4123
  },
4013
4124
  },
@@ -4382,7 +4493,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4382
4493
  // Nothing is in flight while a decision is pending, so every phrase
4383
4494
  // narration has would be a lie about who the call is waiting on. The
4384
4495
  // turn says so once, when it starts waiting, and is quiet after that.
4385
- !turn.awaitingApproval &&
4496
+ turn.pendingApproval === null &&
4386
4497
  this.turnAudioIdle(turn)
4387
4498
  );
4388
4499
  }
@@ -236,6 +236,18 @@ export interface LiveVoiceActivityServerFrame extends LiveVoiceServerFrameBase {
236
236
  readonly type: "activity";
237
237
  readonly turnId: string;
238
238
  readonly label: string;
239
+ /**
240
+ * The confirmation this turn is blocked on, when the label describes a wait
241
+ * rather than work in flight. Absent otherwise.
242
+ *
243
+ * It travels so that a surface outside the app — the Live Activity's
244
+ * Approve/Deny buttons — can answer the request it was drawn against rather
245
+ * than whatever is pending by the time the press lands. Content on a Lock
246
+ * Screen can be seconds old, and a decision is the one thing that must not
247
+ * be re-pointed when it is: the id lets a client drop a press aimed at a
248
+ * request already answered, timed out, or superseded.
249
+ */
250
+ readonly approvalRequestId?: string;
239
251
  }
240
252
 
241
253
  export interface LiveVoiceAssistantTextDeltaServerFrame extends LiveVoiceServerFrameBase {