@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 +1 -1
- package/src/live-voice/__tests__/activity-label.test.ts +27 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +125 -0
- package/src/live-voice/activity-label.ts +28 -0
- package/src/live-voice/live-voice-session.ts +128 -17
- package/src/live-voice/protocol.ts +12 -0
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
|
|
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(
|
|
2366
|
-
|
|
2433
|
+
private revealRoomForPendingApproval(
|
|
2434
|
+
turn: ActiveAssistantTurn,
|
|
2435
|
+
requestId: string,
|
|
2436
|
+
): void {
|
|
2437
|
+
if (turn.pendingApproval !== null) {
|
|
2367
2438
|
return;
|
|
2368
2439
|
}
|
|
2369
|
-
turn.
|
|
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.
|
|
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(
|
|
2399
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|