@runtypelabs/persona 4.24.0 → 4.26.0
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/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
- package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +14 -0
- package/dist/animations-extra.d.ts +14 -0
- package/dist/approval-ui-QAKWNFNM.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +243 -8
- package/dist/approval-ui.d.ts +243 -8
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
- package/dist/artifacts-ui.cjs +4 -4
- package/dist/artifacts-ui.d.cts +243 -8
- package/dist/artifacts-ui.d.ts +243 -8
- package/dist/artifacts-ui.js +5 -5
- package/dist/chunk-2ANTRKTV.js +1 -0
- package/dist/chunk-4HPTPUC6.js +1 -0
- package/dist/chunk-6V3MNEKB.js +1 -0
- package/dist/chunk-HGUCGM3K.js +9 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-J4J3Y2P2.js +1 -0
- package/dist/chunk-K6HKIP5K.js +1 -0
- package/dist/chunk-K7NA7G7J.js +1 -0
- package/dist/chunk-MXUTS3LB.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-QPPJUSZH.js +1 -0
- package/dist/chunk-QRKGQPU7.js +1 -0
- package/dist/chunk-S6PBWE75.js +1 -0
- package/dist/chunk-T7QM7YMF.js +1 -0
- package/dist/chunk-TR2H475E.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/chunk-ZC66NAIR.js +1 -0
- package/dist/client-history-YPEG45Q4.js +1 -0
- package/dist/client-history.js +1 -0
- package/dist/client-stream-42FOI3EC.js +4 -0
- package/dist/client-stream.js +5 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions-E7HJYWO6.js +161 -0
- package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
- package/dist/context-mentions.cjs +7 -7
- package/dist/context-mentions.d.cts +243 -8
- package/dist/context-mentions.d.ts +243 -8
- package/dist/context-mentions.js +8 -8
- package/dist/event-stream-view-FOIJHFQO.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +246 -31
- package/dist/event-stream-view.d.ts +246 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +295 -667
- package/dist/forms-ui.d.ts +295 -667
- package/dist/history-shell-QDQ44PSE.js +3 -0
- package/dist/history-shell.js +3 -0
- package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
- package/dist/history-view.cjs +2 -2
- package/dist/history-view.d.cts +35 -1
- package/dist/history-view.d.ts +35 -1
- package/dist/history-view.js +2 -2
- package/dist/index.cjs +80 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +301 -738
- package/dist/index.d.ts +301 -738
- package/dist/index.global.js +3 -215
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +81 -75
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -67
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +22 -22
- package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-actions-ISSKSI66.js +1 -0
- package/dist/session-actions.js +1 -0
- package/dist/session-reconnect.d.cts +243 -8
- package/dist/session-reconnect.d.ts +243 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +243 -8
- package/dist/smart-dom-reader.d.ts +243 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +245 -8
- package/dist/theme-editor-preview.d.ts +245 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +244 -23
- package/dist/theme-editor.d.ts +244 -23
- package/dist/theme-editor.js +2 -2
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
- package/dist/ui-extras.js +72 -0
- package/dist/voice-runtime-UOQOFAFY.js +17 -0
- package/dist/voice-runtime.cjs +17 -1
- package/dist/voice-runtime.d.cts +11643 -192
- package/dist/voice-runtime.d.ts +11643 -192
- package/dist/voice-runtime.js +17 -1
- package/dist/voice-worklet-player.cjs +19 -4
- package/dist/voice-worklet-player.d.cts +9 -10
- package/dist/voice-worklet-player.d.ts +9 -10
- package/dist/voice-worklet-player.js +19 -4
- package/dist/webmcp-polyfill.js +1 -1
- package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +1 -33
- package/dist/webmcp-runtime.d.ts +1 -33
- package/dist/webmcp-runtime.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +11 -5
- package/src/approval-paused-tool-settle.test.ts +221 -0
- package/src/client-approve.test.ts +311 -0
- package/src/client-history-eager.ts +9 -0
- package/src/client-history-loader.ts +23 -0
- package/src/client-history.ts +579 -0
- package/src/client-resume.ts +177 -0
- package/src/client-stream-eager.ts +12 -0
- package/src/client-stream-loader.ts +25 -0
- package/src/client-stream.ts +1880 -0
- package/src/client.test.ts +31 -1
- package/src/client.ts +293 -2607
- package/src/components/approval-actions.test.ts +27 -0
- package/src/components/approval-actions.ts +27 -3
- package/src/components/message-bubble.test.ts +19 -0
- package/src/components/message-bubble.ts +2 -0
- package/src/components/registry.ts +6 -6
- package/src/components/tool-bubble.test.ts +9 -0
- package/src/components/tool-bubble.ts +1 -0
- package/src/history-shell-bundle.test.ts +54 -0
- package/src/history-shell-eager.ts +12 -0
- package/src/history-shell-loader.ts +27 -0
- package/src/history-shell.ts +2362 -0
- package/src/history-view-loader.ts +2 -0
- package/src/history-view.ts +3 -0
- package/src/index-core.ts +8 -0
- package/src/index-global.ts +90 -248
- package/src/index.ts +13 -0
- package/src/install.ts +6 -4
- package/src/internal/runtype-history-provider.test.ts +1 -0
- package/src/internal/runtype-history-provider.ts +28 -13
- package/src/plugins/registry.ts +8 -8
- package/src/session-actions-chunk.test.ts +55 -0
- package/src/session-actions-eager.ts +11 -0
- package/src/session-actions-loader.ts +37 -0
- package/src/session-actions.ts +909 -0
- package/src/session-history.test.ts +3 -1
- package/src/session.ts +875 -1530
- package/src/session.voice.test.ts +1241 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +269 -5
- package/src/ui-ask-user-sheet.ts +382 -0
- package/src/ui-extras-eager.ts +10 -0
- package/src/ui-extras-entry.ts +9 -0
- package/src/ui-extras-loader.ts +26 -0
- package/src/ui.artifact-rail-resize.test.ts +2 -1
- package/src/ui.ask-user-chunk.test.ts +93 -0
- package/src/ui.history-chunk.test.ts +2 -1
- package/src/ui.history-config-provider.test.ts +2 -1
- package/src/ui.history-confirm-fallback.test.ts +142 -0
- package/src/ui.history-controller.test.ts +2 -1
- package/src/ui.history-lifecycle.test.ts +2 -1
- package/src/ui.history-render-hooks.test.ts +2 -1
- package/src/ui.history-shell.test.ts +2 -1
- package/src/ui.rail-overlay.test.ts +2 -1
- package/src/ui.rail-resize.test.ts +2 -1
- package/src/ui.rail-trigger-pending.test.ts +2 -1
- package/src/ui.ts +399 -2770
- package/src/ui.voice-prewarm.test.ts +222 -0
- package/src/utils/attachment-manager.ts +67 -67
- package/src/utils/constants.ts +3 -0
- package/src/utils/context-mention-controller.ts +323 -323
- package/src/utils/context-mention-manager.ts +40 -40
- package/src/utils/context-mention-orchestrator.ts +28 -0
- package/src/utils/event-stream-buffer.ts +50 -50
- package/src/utils/event-stream-store.ts +43 -43
- package/src/utils/throughput-tracker.ts +34 -34
- package/src/utils/virtual-scroller.ts +67 -67
- package/src/utils/visitor-store.test.ts +50 -0
- package/src/utils/visitor-store.ts +23 -5
- package/src/voice/__fixtures__/voice-wire/README.md +1 -0
- package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
- package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
- package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
- package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
- package/src/voice/audio-playback-manager.test.ts +57 -0
- package/src/voice/audio-playback-manager.ts +120 -98
- package/src/voice/browser-voice-provider.ts +37 -37
- package/src/voice/fallback-speech-engine.ts +22 -16
- package/src/voice/keyed-voice-transcript.ts +314 -0
- package/src/voice/mic-state-styles.ts +182 -0
- package/src/voice/read-aloud-controller.ts +46 -44
- package/src/voice/runtype-speech-engine.ts +35 -33
- package/src/voice/runtype-voice-provider.ts +856 -164
- package/src/voice/session-voice-wiring.ts +266 -0
- package/src/voice/voice-delegation.test.ts +71 -0
- package/src/voice/voice-delegation.ts +364 -0
- package/src/voice/voice-turn-tracker.ts +6 -6
- package/src/voice/voice.test.ts +1250 -7
- package/src/voice/worklet-playback-engine.test.ts +81 -0
- package/src/voice/worklet-playback-engine.ts +21 -3
- package/src/voice-runtime-loader.ts +4 -1
- package/src/voice-runtime.ts +7 -0
- package/src/webmcp-runtime-entry.ts +41 -39
- package/dist/approval-ui-3NMYNXBD.js +0 -1
- package/dist/chunk-5EIIHQLQ.js +0 -1
- package/dist/chunk-7CO4T5KB.js +0 -1
- package/dist/chunk-EFJBUH5F.js +0 -1
- package/dist/chunk-R2TMIMDI.js +0 -3
- package/dist/chunk-UTVLOTWB.js +0 -9
- package/dist/context-mentions-45MODWOY.js +0 -161
- package/dist/event-stream-view-GL3ADRY2.js +0 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
- package/dist/voice-runtime-EFR3R5ES.js +0 -1
- package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentWidgetApproval,
|
|
3
|
+
AgentWidgetMessage,
|
|
4
|
+
VoiceDelegationFollowUp,
|
|
5
|
+
VoiceDelegationRequest,
|
|
6
|
+
VoiceDelegationResult,
|
|
7
|
+
VoiceSessionBridge,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a delegated turn streamed: its assistant messages, whether it failed,
|
|
12
|
+
* and whether the server dropped its result (then browser TTS may read it).
|
|
13
|
+
*/
|
|
14
|
+
export type VoiceDelegationCapture = { ids: string[]; failed: boolean; dropped?: boolean };
|
|
15
|
+
|
|
16
|
+
/** The session internals the delegation bridge drives. */
|
|
17
|
+
export interface VoiceDelegationHost {
|
|
18
|
+
messages(): AgentWidgetMessage[];
|
|
19
|
+
/** A chat turn is streaming, resuming, or running local (WebMCP) tools. */
|
|
20
|
+
busy(): boolean;
|
|
21
|
+
/** A local (WebMCP) tool is waiting on the visitor's approval. */
|
|
22
|
+
parked(): boolean;
|
|
23
|
+
/** The transcript bubble the request came from (see KeyedVoiceTranscript.claimUserTurn). */
|
|
24
|
+
claim(userText: string, userUtteranceIds: string[]): string | null;
|
|
25
|
+
/** sendMessage as a voice turn, submitting `userMessageId`'s bubble when given. */
|
|
26
|
+
send(userText: string, userMessageId: string | undefined): Promise<void>;
|
|
27
|
+
/** Route the chat stream's assistant messages and failures into `capture` (or stop). */
|
|
28
|
+
track(capture: VoiceDelegationCapture | null): void;
|
|
29
|
+
/** Deny a pending approval (server gate or WebMCP) by its message id. */
|
|
30
|
+
decide(approvalMessageId: string): void;
|
|
31
|
+
/** Let browser TTS read these messages after all (their spoken result was dropped). */
|
|
32
|
+
unspoken(messageIds: string[]): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SETTLE_POLL_MS = 50;
|
|
36
|
+
const WAITING_FOR_INPUT = "I need your answer in the chat before I can continue.";
|
|
37
|
+
const APPROVAL_SCRIPT_MAX_CHARS = 1000;
|
|
38
|
+
/** How long a voice-originated approval waits for the visitor (Amendment 4). */
|
|
39
|
+
const APPROVAL_TTL_MS = 5 * 60_000;
|
|
40
|
+
// Unambiguous spoken declines only (on normalized text); anything else runs as
|
|
41
|
+
// a normal turn. Nothing is ever approved by voice.
|
|
42
|
+
const DECLINE_PHRASE =
|
|
43
|
+
"no|nope|never ?mind|decline(?: it| that)?|cancel(?: it| that| this)?(?: (?:the|my|that|this) (?:[a-z]+ )?(?:order|request))?|don'?t (?:do|place) (?:it|that|the (?:[a-z]+ )?order)";
|
|
44
|
+
const VOICE_DECLINE = new RegExp(`^(?:${DECLINE_PHRASE})(?: (?:${DECLINE_PHRASE}))?(?: thanks| thank you)?$`);
|
|
45
|
+
/** Whether `userText` is an unambiguous spoken decline ("cancel that", "never mind"). */
|
|
46
|
+
export const isVoiceDecline = (userText: string) =>
|
|
47
|
+
VOICE_DECLINE.test(
|
|
48
|
+
userText
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^a-z' ]+/g, " ")
|
|
51
|
+
.replace(/\s+/g, " ")
|
|
52
|
+
.trim(),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const APPROVAL_GUIDANCE =
|
|
56
|
+
"Briefly tell the user what you're about to do and ask them to approve or decline it in the chat. Don't claim it's done.";
|
|
57
|
+
|
|
58
|
+
const sleep = () => new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
|
|
59
|
+
const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text);
|
|
60
|
+
/** `place_pickup_order` / `webmcp:addToCart` → "place pickup order" / "add to cart". */
|
|
61
|
+
const humanize = (name: string) =>
|
|
62
|
+
name
|
|
63
|
+
.replace(/^webmcp:/, "")
|
|
64
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
65
|
+
.replace(/[_\-.]+/g, " ")
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
|
|
69
|
+
/** A compact spoken summary of a tool argument ("2 almond croissants, 1 sourdough loaf"). */
|
|
70
|
+
function brief(value: unknown, depth = 0): string {
|
|
71
|
+
if (value == null || value === "") return "";
|
|
72
|
+
if (typeof value !== "object") return String(value);
|
|
73
|
+
if (depth > 2) return "";
|
|
74
|
+
if (Array.isArray(value)) return value.map((item) => brief(item, depth + 1)).filter(Boolean).join(", ");
|
|
75
|
+
const record = value as Record<string, unknown>;
|
|
76
|
+
const quantity = record.quantity ?? record.qty ?? record.count;
|
|
77
|
+
const name = record.name ?? record.item ?? record.product ?? record.title;
|
|
78
|
+
if (quantity != null && name != null && typeof name !== "object") return `${quantity} ${name}`;
|
|
79
|
+
return Object.entries(record)
|
|
80
|
+
// `_approvalReason` and other reserved keys are not arguments.
|
|
81
|
+
.filter(([key]) => !key.startsWith("_"))
|
|
82
|
+
.map(([key, item]) => {
|
|
83
|
+
const text = brief(item, depth + 1);
|
|
84
|
+
return text && (depth ? `${humanize(key)} ${text}` : `${humanize(key)}: ${text}`);
|
|
85
|
+
})
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(depth ? ", " : "; ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The `delegation_result` text for a turn parked on approvals: what is about
|
|
92
|
+
* to happen, so the voice model can ask for the decision in its own words.
|
|
93
|
+
*/
|
|
94
|
+
export function buildApprovalScript(approvals: AgentWidgetApproval[]): string {
|
|
95
|
+
const header =
|
|
96
|
+
approvals.length > 1
|
|
97
|
+
? "These actions need the user's approval in the chat before they happen:"
|
|
98
|
+
: "This action needs the user's approval in the chat before it happens:";
|
|
99
|
+
// Header, lines and guidance, with a newline after each line and one blank line.
|
|
100
|
+
const budget = APPROVAL_SCRIPT_MAX_CHARS - header.length - APPROVAL_GUIDANCE.length - 2;
|
|
101
|
+
const lines = approvals.map((approval) => {
|
|
102
|
+
const parts = [humanize(approval.toolName)];
|
|
103
|
+
if (approval.description) parts.push(`(${clip(approval.description.trim(), 160)})`);
|
|
104
|
+
const args = brief(approval.parameters);
|
|
105
|
+
if (args) parts.push(`with ${args}`);
|
|
106
|
+
if (approval.reason) parts.push(`because: ${clip(approval.reason.trim(), 160)}`);
|
|
107
|
+
return clip(`- ${parts.join(" ")}`, Math.floor(budget / approvals.length) - 1);
|
|
108
|
+
});
|
|
109
|
+
return `${header}\n${lines.join("\n")}\n\n${APPROVAL_GUIDANCE}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isPendingApproval = (m: AgentWidgetMessage) => m.variant === "approval" && m.approval?.status === "pending";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Builds the {@link VoiceSessionBridge} a full-duplex provider uses to run
|
|
116
|
+
* delegated turns through the widget's chat pipeline. Ships in the lazy
|
|
117
|
+
* voice-runtime chunk.
|
|
118
|
+
*/
|
|
119
|
+
export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessionBridge {
|
|
120
|
+
// One tracking slot: a running delegated turn, else a parked turn's follow-up.
|
|
121
|
+
let active: VoiceDelegationCapture | null = null;
|
|
122
|
+
let following: VoiceDelegationCapture | null = null;
|
|
123
|
+
const retrack = () => host.track(active ?? following);
|
|
124
|
+
// Voice-originated approval parks still awaiting a decision or follow-up.
|
|
125
|
+
// `replaced`: approvals this bridge declined because a newer request for the same tool came in.
|
|
126
|
+
type Parked = {
|
|
127
|
+
delegationId: string;
|
|
128
|
+
approvals: string[];
|
|
129
|
+
at: number;
|
|
130
|
+
replaced: Set<string>;
|
|
131
|
+
outcome?: VoiceDelegationFollowUp;
|
|
132
|
+
};
|
|
133
|
+
const parks: Parked[] = [];
|
|
134
|
+
// Per delegation: the captures of its turn and follow-up, so a result the
|
|
135
|
+
// server dropped can be handed back to browser TTS. Bounded: recent calls only.
|
|
136
|
+
const captures = new Map<string, VoiceDelegationCapture[]>();
|
|
137
|
+
// Delegations the server cancelled, and whether that expired their approvals.
|
|
138
|
+
const cancelled = new Map<string, boolean>();
|
|
139
|
+
const remember = (delegationId: string, capture: VoiceDelegationCapture) => {
|
|
140
|
+
captures.set(delegationId, [...(captures.get(delegationId) ?? []), capture]);
|
|
141
|
+
if (captures.size > 32) captures.delete(captures.keys().next().value!);
|
|
142
|
+
};
|
|
143
|
+
const message = (id: string) => host.messages().find((m) => m.id === id);
|
|
144
|
+
const pendingOf = (park: Parked) => park.approvals.filter((id) => message(id)?.approval?.status === "pending");
|
|
145
|
+
const toolOf = (id: string) => humanize(message(id)?.approval?.toolName ?? "");
|
|
146
|
+
/** Decide a park's outcome now, denying whatever of it is still pending. */
|
|
147
|
+
const settle = (park: Parked, status: VoiceDelegationFollowUp["status"], text: string) => {
|
|
148
|
+
park.outcome = { status, text };
|
|
149
|
+
for (const id of pendingOf(park)) host.decide(id);
|
|
150
|
+
};
|
|
151
|
+
const answerOf = (ids: string[]) =>
|
|
152
|
+
ids
|
|
153
|
+
.flatMap((id) => host.messages().find((m) => m.id === id) ?? [])
|
|
154
|
+
.filter((m) => !m.variant && m.content.trim())
|
|
155
|
+
.map((m) => m.content.trim())
|
|
156
|
+
.join("\n\n");
|
|
157
|
+
|
|
158
|
+
// Turns end on many paths (stream end, WebMCP resumes, reconnects): poll.
|
|
159
|
+
// A turn parked on the visitor stays busy, and a replaced one is done.
|
|
160
|
+
const settled = async (capture?: VoiceDelegationCapture) => {
|
|
161
|
+
do {
|
|
162
|
+
await sleep();
|
|
163
|
+
} while (host.busy() && !(capture && (host.parked() || capture.failed)));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
getHistory: () =>
|
|
168
|
+
host.messages().flatMap((m) =>
|
|
169
|
+
(m.role === "user" || m.role === "assistant") &&
|
|
170
|
+
!m.variant &&
|
|
171
|
+
!m.voiceProcessing &&
|
|
172
|
+
!m.voiceCaption &&
|
|
173
|
+
m.content
|
|
174
|
+
? [{ role: m.role, content: m.content }]
|
|
175
|
+
: [],
|
|
176
|
+
),
|
|
177
|
+
|
|
178
|
+
dropDelegation(delegationId, expired) {
|
|
179
|
+
// Remembered for a turn still running: when it parks, the same rule applies.
|
|
180
|
+
cancelled.set(delegationId, !!expired);
|
|
181
|
+
if (cancelled.size > 32) cancelled.delete(cancelled.keys().next().value!);
|
|
182
|
+
// Past the server's deadline: decline the card, as the approval TTL would.
|
|
183
|
+
const park = expired && parks.find((p) => p.delegationId === delegationId);
|
|
184
|
+
if (park && !park.outcome) settle(park, "timeout", "");
|
|
185
|
+
for (const capture of captures.get(delegationId) ?? []) {
|
|
186
|
+
capture.dropped = true;
|
|
187
|
+
if (capture.ids.length) host.unspoken(capture.ids);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async runDelegatedTurn({
|
|
192
|
+
delegationId,
|
|
193
|
+
userText,
|
|
194
|
+
userUtteranceIds,
|
|
195
|
+
}: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
|
|
196
|
+
const live = parks.filter((park) => pendingOf(park).length);
|
|
197
|
+
const pendingIds = live.flatMap(pendingOf);
|
|
198
|
+
// "Cancel that" while exactly one voice approval waits: deny it here; its
|
|
199
|
+
// own follow-up stays silent, since this answer says so.
|
|
200
|
+
if (pendingIds.length === 1 && isVoiceDecline(userText)) {
|
|
201
|
+
const tool = toolOf(pendingIds[0]);
|
|
202
|
+
// The parked delegation still gets its one terminal result, silently.
|
|
203
|
+
settle(live[0], "denied", "");
|
|
204
|
+
return { status: "denied", text: `Okay, I cancelled the ${tool} request. Nothing was done.` };
|
|
205
|
+
}
|
|
206
|
+
// A voice-originated WebMCP approval holds the chat turn open: this turn
|
|
207
|
+
// replaces it (a new send declines it), so don't wait on it. Anything
|
|
208
|
+
// else in flight finishes first.
|
|
209
|
+
const replacing =
|
|
210
|
+
host.parked() && host.messages().every((m) => !isPendingApproval(m) || pendingIds.includes(m.id));
|
|
211
|
+
if (host.busy() && !replacing) await settled();
|
|
212
|
+
const before = new Set(host.messages().map((m) => m.id));
|
|
213
|
+
const raised = () => host.messages().filter((m) => !before.has(m.id) && m.variant === "approval");
|
|
214
|
+
const capture: VoiceDelegationCapture = { ids: [], failed: false };
|
|
215
|
+
remember(delegationId, capture);
|
|
216
|
+
active = capture;
|
|
217
|
+
retrack();
|
|
218
|
+
try {
|
|
219
|
+
await host.send(userText, host.claim(userText, userUtteranceIds) ?? undefined);
|
|
220
|
+
// Local tools and approvals may continue the turn past the first stream.
|
|
221
|
+
await settled(capture);
|
|
222
|
+
} catch {
|
|
223
|
+
capture.failed = true;
|
|
224
|
+
} finally {
|
|
225
|
+
active = null;
|
|
226
|
+
retrack();
|
|
227
|
+
}
|
|
228
|
+
const replies = capture.ids.flatMap((id) => host.messages().find((m) => m.id === id) ?? []);
|
|
229
|
+
let text = answerOf(capture.ids);
|
|
230
|
+
const pending = capture.failed ? [] : raised().filter(isPendingApproval);
|
|
231
|
+
|
|
232
|
+
// An earlier voice approval for the same tool is replaced by this one; a
|
|
233
|
+
// WebMCP one this turn displaced (and so declined) is reported as such.
|
|
234
|
+
const tools = new Set(pending.map((m) => humanize(m.approval!.toolName)));
|
|
235
|
+
for (const park of live) {
|
|
236
|
+
if (park.outcome) continue;
|
|
237
|
+
// Only the approvals for the same tool are replaced; the park's others stand.
|
|
238
|
+
for (const id of pendingOf(park).filter((id) => tools.has(toolOf(id)))) {
|
|
239
|
+
park.replaced.add(id);
|
|
240
|
+
host.decide(id);
|
|
241
|
+
}
|
|
242
|
+
const tool = toolOf(park.approvals[0]);
|
|
243
|
+
if (park.approvals.every((id) => park.replaced.has(id))) {
|
|
244
|
+
park.outcome = { status: "cancelled", text: `The earlier ${tool} request was replaced by the new one; it was not done.` };
|
|
245
|
+
} else if (replacing && park.approvals.every((id) => message(id)?.approval?.status === "denied")) {
|
|
246
|
+
park.outcome = { status: "cancelled", text: `The earlier ${tool} request was replaced by the new one; it was not done.` };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (pending.length) {
|
|
251
|
+
const own = new Set(pending.map((m) => m.id));
|
|
252
|
+
const park: Parked = { delegationId, approvals: [...own], at: Date.now(), replaced: new Set() };
|
|
253
|
+
parks.push(park);
|
|
254
|
+
// Cancelled while it ran: no ask will go out and no TTL will run, so
|
|
255
|
+
// expire the card now (deadline), or leave it to the chat.
|
|
256
|
+
if (cancelled.get(delegationId)) settle(park, "timeout", "");
|
|
257
|
+
// Parked on approvals: answer now, so the voice model asks for the
|
|
258
|
+
// decision, then read the outcome back once the visitor decides.
|
|
259
|
+
text = `${text}\n\n${buildApprovalScript(pending.map((m) => m.approval!))}`.trim();
|
|
260
|
+
return {
|
|
261
|
+
status: "pending_approval",
|
|
262
|
+
text,
|
|
263
|
+
followUp: async ({ signal, approvalTimeoutMs = APPROVAL_TTL_MS, readBack = true, onUpdate }) => {
|
|
264
|
+
const follow: VoiceDelegationCapture = { ids: [], failed: false };
|
|
265
|
+
// Called once the ask went out: it carried this turn's reply to the
|
|
266
|
+
// voice model, so a later drop releases only the follow-up's answer.
|
|
267
|
+
captures.delete(delegationId);
|
|
268
|
+
remember(delegationId, follow);
|
|
269
|
+
// This turn's approvals, and any its resumed stream chains into
|
|
270
|
+
// (not another turn's).
|
|
271
|
+
const mine = () => raised().filter((m) => own.has(m.id) || follow.ids.includes(m.id));
|
|
272
|
+
// A gated tool the resumed turn chains into: a new approval of this
|
|
273
|
+
// park (server gate: captured; WebMCP: the one pending while this
|
|
274
|
+
// follow-up owns the stream), asked for with another update.
|
|
275
|
+
const chained = () =>
|
|
276
|
+
raised().filter(
|
|
277
|
+
(m) =>
|
|
278
|
+
isPendingApproval(m) &&
|
|
279
|
+
!own.has(m.id) &&
|
|
280
|
+
!parks.some((other) => other.approvals.includes(m.id)) &&
|
|
281
|
+
(follow.ids.includes(m.id) || (m.approval?.toolType === "webmcp" && following === follow)),
|
|
282
|
+
);
|
|
283
|
+
const claim = () => {
|
|
284
|
+
following = follow;
|
|
285
|
+
retrack();
|
|
286
|
+
};
|
|
287
|
+
if (readBack) claim();
|
|
288
|
+
try {
|
|
289
|
+
// Every approval of this turn decided (or replaced, cancelled,
|
|
290
|
+
// expired), and the resumed turn finished.
|
|
291
|
+
while (!signal.aborted && !park.outcome && (active || host.busy() || mine().some(isPendingApproval))) {
|
|
292
|
+
// With several turns parked, the one whose approvals were just
|
|
293
|
+
// decided takes the capture slot back: its resumed stream is next.
|
|
294
|
+
if (readBack && following !== follow && !pendingOf(park).length) claim();
|
|
295
|
+
const next = chained();
|
|
296
|
+
if (next.length) {
|
|
297
|
+
for (const m of next) {
|
|
298
|
+
own.add(m.id);
|
|
299
|
+
park.approvals.push(m.id);
|
|
300
|
+
}
|
|
301
|
+
park.at = Date.now();
|
|
302
|
+
onUpdate?.(buildApprovalScript(next.map((m) => m.approval!)));
|
|
303
|
+
}
|
|
304
|
+
// Unanswered too long: decline it, but never by aborting a
|
|
305
|
+
// turn in flight (a parked WebMCP turn is the one waiting).
|
|
306
|
+
if (Date.now() - park.at >= approvalTimeoutMs && pendingOf(park).length && (!host.busy() || host.parked())) {
|
|
307
|
+
settle(park, "timeout", `That ${toolOf(park.approvals[0])} request expired, so nothing was done.`);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
await sleep();
|
|
311
|
+
}
|
|
312
|
+
} finally {
|
|
313
|
+
if (following === follow) {
|
|
314
|
+
following = null;
|
|
315
|
+
retrack();
|
|
316
|
+
}
|
|
317
|
+
// Hang-up stops voice follow-ups; the approval card stays usable.
|
|
318
|
+
parks.splice(parks.indexOf(park), 1);
|
|
319
|
+
}
|
|
320
|
+
if (signal.aborted) return null;
|
|
321
|
+
if (park.outcome) return park.outcome;
|
|
322
|
+
if (follow.failed) return { status: "failed", text: `The ${toolOf(park.approvals[0])} request didn't complete.` };
|
|
323
|
+
const decided = mine();
|
|
324
|
+
const names = (pick: (m: AgentWidgetMessage) => boolean) =>
|
|
325
|
+
decided.filter(pick).map((m) => humanize(m.approval!.toolName)).join(", ");
|
|
326
|
+
const approved = decided.some((m) => m.approval?.status === "approved");
|
|
327
|
+
const declined = names((m) => m.approval?.status === "denied" && !park.replaced.has(m.id));
|
|
328
|
+
const replaced = names((m) => park.replaced.has(m.id));
|
|
329
|
+
const timedOut = names((m) => m.approval?.status === "timeout");
|
|
330
|
+
// An approved action's result is read back; a decline is stated
|
|
331
|
+
// plainly, since the agent's own reply to it often re-asks for
|
|
332
|
+
// confirmation, which the voice model would repeat.
|
|
333
|
+
const text = [
|
|
334
|
+
approved ? answerOf(follow.ids) : "",
|
|
335
|
+
declined && `The user declined the ${declined} request in the chat, so ${approved ? "that part was not done" : "nothing was done"}.`,
|
|
336
|
+
replaced && `The earlier ${replaced} request was replaced by the new one; it was not done.`,
|
|
337
|
+
timedOut && `The ${timedOut} request timed out, so it was not done.`,
|
|
338
|
+
]
|
|
339
|
+
.filter(Boolean)
|
|
340
|
+
.join("\n\n");
|
|
341
|
+
return {
|
|
342
|
+
status: approved ? "completed" : declined ? "denied" : replaced ? "cancelled" : timedOut ? "timeout" : "completed",
|
|
343
|
+
text,
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// A turn parked on the visitor's answer still answers now, so the voice
|
|
349
|
+
// model can tell them to finish it in the chat.
|
|
350
|
+
if (
|
|
351
|
+
(!capture.failed && host.parked()) ||
|
|
352
|
+
replies.some(
|
|
353
|
+
(m) =>
|
|
354
|
+
m.agentMetadata?.awaitingLocalTool &&
|
|
355
|
+
!m.agentMetadata.askUserQuestionAnswered &&
|
|
356
|
+
!m.toolCall?.name?.startsWith("webmcp:"),
|
|
357
|
+
)
|
|
358
|
+
) {
|
|
359
|
+
return { status: capture.failed ? "failed" : "completed", text: `${text}\n\n${WAITING_FOR_INPUT}`.trim() };
|
|
360
|
+
}
|
|
361
|
+
return { status: !capture.failed && text ? "completed" : "failed", text };
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/** Correlate provider transcripts across cancellation and overlapping turns. */
|
|
2
2
|
export class VoiceTurnTracker {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
#turnId?: string;
|
|
4
|
+
#cancelled = false;
|
|
5
5
|
|
|
6
6
|
start(turnId?: string): void {
|
|
7
|
-
this
|
|
8
|
-
this
|
|
7
|
+
this.#turnId = turnId;
|
|
8
|
+
this.#cancelled = false;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
cancel(): void {
|
|
12
|
-
this
|
|
12
|
+
this.#cancelled = true;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
accepts(turnId?: string): boolean {
|
|
16
|
-
return !this
|
|
16
|
+
return !this.#cancelled && (!turnId || !this.#turnId || turnId === this.#turnId);
|
|
17
17
|
}
|
|
18
18
|
}
|