@runtypelabs/persona 4.28.2 → 4.29.1
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/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/approval-ui.cjs +3 -3
- package/dist/approval-ui.d.cts +1 -1
- package/dist/approval-ui.d.ts +1 -1
- package/dist/approval-ui.js +3 -3
- package/dist/artifacts-ui.d.cts +2 -2
- package/dist/artifacts-ui.d.ts +2 -2
- package/dist/chunk-B1-_pp-Y.js +4 -0
- package/dist/{chunk-DrYAfpvk.js → chunk-BcLIgi1h.js} +1 -1
- package/dist/chunk-BkW8s3fj.js +2 -0
- package/dist/chunk-Bm2-bemv.js +1 -0
- package/dist/chunk-BvhTpG6k.js +1 -0
- package/dist/chunk-Csks3Qw1.js +1 -0
- package/dist/chunk-D5B629Mj.js +1 -0
- package/dist/chunk-DGlFKA0r.js +9 -0
- package/dist/chunk-DO9_WSpy.js +1 -0
- package/dist/chunk-DTDHeUsG.js +1 -0
- package/dist/chunk-DjFpGFGA.js +1 -0
- package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
- package/dist/chunk-lA5CJCgK.js +1 -0
- package/dist/client-history.js +1 -1
- package/dist/client-stream.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/{composer-input-C39npJaz.d.cts → composer-input-B0P4OtNi.d.cts} +1 -1
- package/dist/{composer-input-C39npJaz.d.ts → composer-input-B0P4OtNi.d.ts} +1 -1
- package/dist/{context-mention-manager-DLCB1l9E.d.cts → context-mention-manager-DmLI_RM6.d.cts} +1 -1
- package/dist/{context-mention-manager-DLCB1l9E.d.ts → context-mention-manager-DmLI_RM6.d.ts} +1 -1
- package/dist/context-mentions-inline.d.cts +2 -2
- package/dist/context-mentions-inline.d.ts +2 -2
- package/dist/context-mentions.d.cts +3 -3
- package/dist/context-mentions.d.ts +3 -3
- package/dist/{defaults-DiAgWHXy.d.cts → defaults-DVo1wbZs.d.cts} +1 -1
- package/dist/{defaults-DiAgWHXy.d.ts → defaults-DVo1wbZs.d.ts} +1 -1
- package/dist/event-stream-view.d.cts +1 -1
- package/dist/event-stream-view.d.ts +1 -1
- package/dist/{fallback-speech-engine-C5_PUZjH.d.cts → fallback-speech-engine-BMnlmKnY.d.cts} +1 -1
- package/dist/{fallback-speech-engine-C5_PUZjH.d.ts → fallback-speech-engine-BMnlmKnY.d.ts} +1 -1
- package/dist/forms-ui.d.cts +2 -2
- package/dist/forms-ui.d.ts +2 -2
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/index.cjs +50 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -7
- package/dist/index.d.ts +7 -7
- package/dist/index.global.js +2 -3
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +38 -37
- package/dist/index.js.map +1 -1
- package/dist/{markdown-parsers-loader-BBdyIEU7.d.cts → markdown-parsers-loader-CeovcyYf.d.cts} +1 -1
- package/dist/{markdown-parsers-loader-BBdyIEU7.d.ts → markdown-parsers-loader-CeovcyYf.d.ts} +1 -1
- package/dist/{preview-Chjb0bmI.d.cts → preview-Bkb4UrQC.d.cts} +2 -2
- package/dist/{preview-Chjb0bmI.d.ts → preview-Bkb4UrQC.d.ts} +2 -2
- package/dist/session-actions.js +1 -1
- package/dist/{session-a_qIp4xp.d.ts → session-iLyKipoM.d.cts} +28 -10
- package/dist/{session-a_qIp4xp.d.cts → session-iLyKipoM.d.ts} +28 -10
- package/dist/session-reconnect.cjs +1 -1
- package/dist/session-reconnect.d.cts +8 -2
- package/dist/session-reconnect.d.ts +8 -2
- package/dist/session-reconnect.js +1 -1
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +61 -60
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +40 -40
- package/dist/theme-editor.d.cts +2 -2
- package/dist/theme-editor.d.ts +2 -2
- package/dist/theme-reference.d.cts +2 -2
- package/dist/theme-reference.d.ts +2 -2
- package/dist/{types-BdO1jNRm.d.cts → types-BuDirJkQ.d.cts} +200 -23
- package/dist/{types-BdO1jNRm.d.ts → types-BuDirJkQ.d.ts} +200 -23
- package/dist/voice-client-tools.cjs +1 -0
- package/dist/voice-client-tools.d.cts +13 -0
- package/dist/voice-client-tools.d.ts +13 -0
- package/dist/voice-client-tools.js +1 -0
- package/dist/voice-runtime.cjs +3 -3
- package/dist/voice-runtime.d.cts +15 -5
- package/dist/voice-runtime.d.ts +15 -5
- package/dist/voice-runtime.js +5 -5
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-B6bpVWSa.d.ts → webmcp-runtime-entry-CXLjRt1k.d.cts} +26 -5
- package/dist/{webmcp-runtime-entry-B6bpVWSa.d.cts → webmcp-runtime-entry-CXLjRt1k.d.ts} +26 -5
- package/dist/webmcp-runtime.cjs +2 -1
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/dist/webmcp-runtime.js +2 -1
- package/dist/widget.css +1 -1
- package/package.json +6 -1
- package/src/client-approve.test.ts +17 -1
- package/src/client-resume.ts +15 -3
- package/src/client.interrupt.test.ts +21 -0
- package/src/client.ts +61 -4
- package/src/client.voice-tools.test.ts +65 -0
- package/src/components/activity-row.ts +3 -2
- package/src/components/approval-actions.test.ts +8 -0
- package/src/components/approval-actions.ts +6 -2
- package/src/components/approval-bubble.test.ts +14 -0
- package/src/components/approval-bubble.ts +8 -1
- package/src/components/tool-bubble.ts +8 -5
- package/src/generated/runtype-openapi-contract.ts +39 -1
- package/src/index-core.ts +11 -0
- package/src/index-global.ts +6 -0
- package/src/resume-dedupe-local-only.test.ts +273 -0
- package/src/runtime/persist-state.test.ts +86 -0
- package/src/session-actions.ts +20 -6
- package/src/session-reconnect.ts +18 -1
- package/src/session.restored-browser-tools.test.ts +272 -0
- package/src/session.test.ts +26 -0
- package/src/session.ts +185 -31
- package/src/session.voice.test.ts +212 -0
- package/src/styles/widget.css +4 -2
- package/src/types.ts +178 -22
- package/src/ui.restored-browser-tools.test.ts +128 -0
- package/src/ui.ts +32 -18
- package/src/utils/formatting.test.ts +9 -1
- package/src/utils/formatting.ts +4 -0
- package/src/utils/local-only-message.ts +15 -0
- package/src/utils/restored-browser-state.ts +50 -0
- package/src/utils/storage.ts +12 -1
- package/src/utils/voice-prior-tool-results.ts +37 -0
- package/src/voice/runtype-voice-provider.ts +94 -7
- package/src/voice/session-voice-wiring.ts +27 -3
- package/src/voice/voice-client-tools-session.ts +116 -0
- package/src/voice/voice-delegation.ts +19 -3
- package/src/voice/voice.test.ts +396 -0
- package/src/voice-client-tools-bundle.test.ts +37 -0
- package/src/voice-client-tools-loader.ts +24 -0
- package/src/voice-client-tools.ts +9 -0
- package/src/webmcp-bridge.test.ts +152 -1
- package/src/webmcp-bridge.ts +5 -6
- package/src/webmcp-polyfill.integration.test.ts +5 -1
- package/src/webmcp-runtime-entry.ts +151 -26
- package/dist/chunk-B6zZsT6F.js +0 -1
- package/dist/chunk-CztSjKHK.js +0 -9
- package/dist/chunk-DbhxjyOB.js +0 -4
- package/dist/chunk-DsdqLStH.js +0 -1
- package/dist/chunk-GIqbL2Eg.js +0 -1
- package/dist/chunk-HUOUif-P.js +0 -1
- package/dist/chunk-UlcxdHef.js +0 -1
package/src/utils/storage.ts
CHANGED
|
@@ -30,8 +30,19 @@ const sanitizeArtifacts = (artifacts: PersonaArtifactRecord[]) =>
|
|
|
30
30
|
status: "complete" as const
|
|
31
31
|
}));
|
|
32
32
|
|
|
33
|
+
const DEFAULT_PERSIST_KEY_PREFIX = "persona-";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The widget's default transcript key, scoped by `persistState.keyPrefix` so
|
|
37
|
+
* two widgets on one origin with distinct prefixes keep separate transcripts.
|
|
38
|
+
* The default prefix yields the legacy `"persona-state"` key, so unprefixed
|
|
39
|
+
* widgets keep their stored conversation; prefixed widgets never adopt it.
|
|
40
|
+
*/
|
|
41
|
+
export const transcriptStorageKey = (keyPrefix?: string): string =>
|
|
42
|
+
`${keyPrefix ?? DEFAULT_PERSIST_KEY_PREFIX}state`;
|
|
43
|
+
|
|
33
44
|
export const createLocalStorageAdapter = (
|
|
34
|
-
key =
|
|
45
|
+
key = transcriptStorageKey()
|
|
35
46
|
): AgentWidgetStorageAdapter => {
|
|
36
47
|
const getStorage = () => {
|
|
37
48
|
if (typeof window === "undefined" || !window.localStorage) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AgentWidgetMessage } from "../types";
|
|
2
|
+
|
|
3
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
4
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tool rows for the page tools a voice server already ran before handing the
|
|
8
|
+
* turn to the chat (`delegation_started.input.priorToolResults`). Each row is
|
|
9
|
+
* a completed call carrying `clientToolAnswer`, so proxy/agent payloads replay
|
|
10
|
+
* it to the model as an assistant `toolCalls` + `tool` `toolResults` pair under
|
|
11
|
+
* the same `toolCallId`. Nothing marks it awaiting, so it never runs again.
|
|
12
|
+
* Malformed entries and repeats of a `toolCallId` are skipped.
|
|
13
|
+
*/
|
|
14
|
+
export const voicePriorToolRows = (value: unknown, createdAt: string): AgentWidgetMessage[] => {
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
return (Array.isArray(value) ? value : []).flatMap((entry): AgentWidgetMessage[] => {
|
|
17
|
+
if (!isRecord(entry)) return [];
|
|
18
|
+
const { toolCallId, name } = entry;
|
|
19
|
+
if (typeof toolCallId !== "string" || !toolCallId || typeof name !== "string" || !name) return [];
|
|
20
|
+
if (seen.has(toolCallId)) return [];
|
|
21
|
+
seen.add(toolCallId);
|
|
22
|
+
const args = isRecord(entry.arguments) ? entry.arguments : {};
|
|
23
|
+
const result = entry.result ?? null;
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
id: `voice-tool-${toolCallId}`,
|
|
27
|
+
role: "assistant",
|
|
28
|
+
variant: "tool",
|
|
29
|
+
content: "",
|
|
30
|
+
createdAt,
|
|
31
|
+
streaming: false,
|
|
32
|
+
toolCall: { id: toolCallId, name, status: "complete", args, result },
|
|
33
|
+
agentMetadata: { clientToolAnswer: { toolCallId, toolName: name, args, result } },
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
});
|
|
37
|
+
};
|
|
@@ -83,8 +83,29 @@
|
|
|
83
83
|
// completed), and that phase's completion doesn't fold another utterance.
|
|
84
84
|
// Delegated sends carry `voice: { spoken: true }` on the chat request, so the
|
|
85
85
|
// agent answers in speakable sentences.
|
|
86
|
+
//
|
|
87
|
+
// Fast tier (`client_tools`, declared only with client delegation and WebMCP
|
|
88
|
+
// enabled; the session ships in the lazy voice-client-tools chunk): after
|
|
89
|
+
// `session_config` the provider sends `client_tools{fingerprint, tools}`, the
|
|
90
|
+
// same WebMCP snapshot and fingerprint the chat path sends as `clientTools[]`
|
|
91
|
+
// but with each page tool's FULL name (`webmcp:<name>`; sdk tools stay bare),
|
|
92
|
+
// and again whenever the fingerprint changes. The server may then answer a
|
|
93
|
+
// delegation itself, with no `delegation_started`: it sends
|
|
94
|
+
// `client_tool_call{delegationId, toolCallId, name, arguments, fingerprint,
|
|
95
|
+
// timeoutMs}`, which runs through the WebMCP execute path (the confirm gate is
|
|
96
|
+
// skipped only for a tool whose registry entry says `readOnlyHint: true`) and
|
|
97
|
+
// is answered by one `client_tool_result{status}`: `completed`, `denied` (gate
|
|
98
|
+
// declined), `stale` (unknown tool, or a fingerprint other than the last one
|
|
99
|
+
// sent), `failed`, or `timeout` (past `timeoutMs`, which bounds the gate too).
|
|
100
|
+
// Then `delegation_completed{route:'direct', text}` for a delegation that never
|
|
101
|
+
// started here writes the heard user turn and `text` into the chat. When the
|
|
102
|
+
// server falls through, the delegation arrives as today's `delegation_started`;
|
|
103
|
+
// its `input.priorToolResults` (page tools the server already ran; sent only
|
|
104
|
+
// with `prior_tool_results`, declared beside `client_tools`) ride along to the
|
|
105
|
+
// chat turn as completed calls, never run again here.
|
|
86
106
|
|
|
87
107
|
import type {
|
|
108
|
+
VoiceClientToolsSession,
|
|
88
109
|
VoiceProvider,
|
|
89
110
|
VoiceResult,
|
|
90
111
|
VoiceStatus,
|
|
@@ -327,6 +348,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
327
348
|
#foldReadback = false;
|
|
328
349
|
#assistantTurns = new Set<string>();
|
|
329
350
|
#foldedTurns = new Set<string>();
|
|
351
|
+
// Fast tier (see header): the call's session, from the lazy voice-client-tools chunk.
|
|
352
|
+
#fastTier: Promise<VoiceClientToolsSession | null> | null = null;
|
|
353
|
+
// Delegations this call saw start, and the last final user utterance (a direct turn's request).
|
|
354
|
+
#started = new Set<string>();
|
|
355
|
+
#heard: { id: string; text: string } | null = null;
|
|
356
|
+
// Direct turns in flight: the utterance each answers, captured at its first client_tool_call.
|
|
357
|
+
#direct = new Map<string, { id: string; text: string } | null>();
|
|
330
358
|
|
|
331
359
|
#resultCallbacks: ((result: VoiceResult) => void)[] = [];
|
|
332
360
|
#errorCallbacks: ((error: Error) => void)[] = [];
|
|
@@ -494,15 +522,17 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
494
522
|
* the URL, and these params are not secrets.
|
|
495
523
|
*/
|
|
496
524
|
#voiceSocketUrl(host: string, agentId: string, attach?: URLSearchParams): string {
|
|
497
|
-
|
|
498
|
-
if (this.bridge && this.config?.clientDelegation !== false)
|
|
499
|
-
capabilities
|
|
500
|
-
|
|
525
|
+
let capabilities = "partial_transcript,context";
|
|
526
|
+
if (this.bridge && this.config?.clientDelegation !== false) {
|
|
527
|
+
capabilities += ",client_delegation,delegation_update,delegation_read_back,delegation_stream";
|
|
528
|
+
if (this.bridge.clientTools?.()) capabilities += ",client_tools,prior_tool_results";
|
|
529
|
+
}
|
|
530
|
+
if (attach) capabilities = `attach,${capabilities}`;
|
|
501
531
|
const params = new URLSearchParams({
|
|
502
532
|
voiceProtocol: VOICE_PROTOCOL,
|
|
503
533
|
clientVersion: `persona/${VERSION}`,
|
|
504
534
|
voiceCapabilities: FULL_DUPLEX_CAPABILITY,
|
|
505
|
-
clientCapabilities: capabilities
|
|
535
|
+
clientCapabilities: capabilities,
|
|
506
536
|
});
|
|
507
537
|
attach?.forEach((value, key) => params.set(key, value));
|
|
508
538
|
return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice?${params}`;
|
|
@@ -849,8 +879,35 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
849
879
|
this.#contextSent = true;
|
|
850
880
|
this.#pendingContext = this.#buildContextText();
|
|
851
881
|
}
|
|
882
|
+
if (this.#clientDelegation && this.#capabilities.has("client_tools") && !this.#fastTier) {
|
|
883
|
+
// INVARIANT: #clientDelegation implies a bridge.
|
|
884
|
+
this.#fastTier =
|
|
885
|
+
this.bridge!
|
|
886
|
+
.clientTools?.()
|
|
887
|
+
?.open({
|
|
888
|
+
send: (frame) => {
|
|
889
|
+
const ws = this.#ws;
|
|
890
|
+
if (generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return false;
|
|
891
|
+
ws.send(JSON.stringify(frame));
|
|
892
|
+
return true;
|
|
893
|
+
},
|
|
894
|
+
cancelled: (id) => this.#cancelledDelegations.has(id),
|
|
895
|
+
})
|
|
896
|
+
.catch(() => null) ?? null;
|
|
897
|
+
}
|
|
852
898
|
break;
|
|
853
899
|
|
|
900
|
+
case "client_tool_call": {
|
|
901
|
+
// A direct turn: keep the request it answers (speech may follow) and fold its read-back.
|
|
902
|
+
const id = String(msg.delegationId);
|
|
903
|
+
if (this.#fastTier && !this.#direct.has(id)) {
|
|
904
|
+
this.#direct.set(id, this.#heard);
|
|
905
|
+
this.#answered.add(id);
|
|
906
|
+
}
|
|
907
|
+
void this.#fastTier?.then((session) => session?.call(msg));
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
|
|
854
911
|
case "transcript_update": {
|
|
855
912
|
const role = msg.role === "assistant" ? "assistant" : "user";
|
|
856
913
|
// A reply the client cancelled keeps streaming until the server clears it.
|
|
@@ -860,7 +917,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
860
917
|
if (role === "user") {
|
|
861
918
|
// Release on a final user transcript, not a partial: a mid-utterance
|
|
862
919
|
// context append makes the voice model answer early (or not delegate).
|
|
863
|
-
if (msg.final === true)
|
|
920
|
+
if (msg.final === true) {
|
|
921
|
+
this.#flushCallContext(generation);
|
|
922
|
+
this.#heard = { id: turnId, text: String(msg.text ?? "") };
|
|
923
|
+
}
|
|
864
924
|
this.#foldReadback = false;
|
|
865
925
|
} else {
|
|
866
926
|
// Read-back of a delegated result the chat already renders. A server
|
|
@@ -902,6 +962,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
902
962
|
const delegationId = String(msg.delegationId ?? msg.turnId);
|
|
903
963
|
if (msg.type === "delegation_started") {
|
|
904
964
|
this.#flushCallContext(generation);
|
|
965
|
+
this.#started.add(delegationId);
|
|
966
|
+
// A direct attempt that fell through: its answer is the chat turn's now, and its tool must not act late.
|
|
967
|
+
this.#answered.delete(delegationId);
|
|
968
|
+
if (this.#direct.delete(delegationId)) void this.#fastTier?.then((session) => session?.abort(delegationId));
|
|
905
969
|
// `input`: run this turn through the chat pipeline (client delegation).
|
|
906
970
|
const input = msg.input;
|
|
907
971
|
if (this.#clientDelegation && input) {
|
|
@@ -914,11 +978,26 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
914
978
|
? input.userUtteranceIds.filter((id: unknown): id is string => typeof id === "string")
|
|
915
979
|
: [],
|
|
916
980
|
messages: Array.isArray(input.messages) ? input.messages : [],
|
|
981
|
+
priorToolResults: input.priorToolResults,
|
|
917
982
|
},
|
|
918
983
|
generation,
|
|
919
984
|
);
|
|
920
985
|
}
|
|
921
|
-
} else if (msg.
|
|
986
|
+
} else if (msg.route === "direct" && this.#fastTier && !this.#started.has(delegationId)) {
|
|
987
|
+
// The server answered it itself: write the request it answered and its
|
|
988
|
+
// answer into the chat, as the chat pipeline would have.
|
|
989
|
+
const heard = this.#direct.get(delegationId) ?? this.#heard;
|
|
990
|
+
this.#direct.delete(delegationId);
|
|
991
|
+
this.#started.add(delegationId);
|
|
992
|
+
this.#answered.add(delegationId);
|
|
993
|
+
this.bridge?.recordDirectTurn?.({
|
|
994
|
+
delegationId,
|
|
995
|
+
userText: heard?.text ?? "",
|
|
996
|
+
userUtteranceIds: heard ? [heard.id] : [],
|
|
997
|
+
assistantText: typeof msg.text === "string" ? msg.text : "",
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
if (msg.type === "delegation_completed" && msg.speak !== false && this.#answered.has(delegationId)) {
|
|
922
1001
|
// Every spoken phase (the approval ask, the late result) of an answer
|
|
923
1002
|
// the chat shows is read back. A refusal of one that never started
|
|
924
1003
|
// here has no chat answer: it renders. `final` may never come. A
|
|
@@ -1086,6 +1165,9 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
1086
1165
|
// unrelated utterance: disarm it.
|
|
1087
1166
|
if (this.#foldArmedBy === delegationId) this.#foldReadback = false;
|
|
1088
1167
|
this.#progressed.delete(delegationId);
|
|
1168
|
+
// A direct answer that never completed has no chat message to fold onto.
|
|
1169
|
+
if (!this.#started.has(delegationId)) this.#answered.delete(delegationId);
|
|
1170
|
+
void this.#fastTier?.then((session) => session?.abort(delegationId));
|
|
1089
1171
|
this.bridge?.dropDelegation?.(delegationId, expired);
|
|
1090
1172
|
this.#followUps.get(delegationId)?.abort();
|
|
1091
1173
|
}
|
|
@@ -1255,6 +1337,11 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
1255
1337
|
this.#foldReadback = false;
|
|
1256
1338
|
this.#assistantTurns.clear();
|
|
1257
1339
|
this.#foldedTurns.clear();
|
|
1340
|
+
void this.#fastTier?.then((session) => session?.close());
|
|
1341
|
+
this.#fastTier = null;
|
|
1342
|
+
this.#started.clear();
|
|
1343
|
+
this.#direct.clear();
|
|
1344
|
+
this.#heard = null;
|
|
1258
1345
|
this.#finishedArmed = false;
|
|
1259
1346
|
this.#cancelledUntil = 0;
|
|
1260
1347
|
this.#playbackEndsAt = 0;
|
|
@@ -16,7 +16,7 @@ import type { VoiceTurnTracker } from "./voice-turn-tracker";
|
|
|
16
16
|
/** The session members the wiring touches (some are TS-private on the class). */
|
|
17
17
|
export type VoiceWiringSession = Pick<
|
|
18
18
|
AgentWidgetSession,
|
|
19
|
-
"injectMessage" | "sendMessage" | "resolveWebMcpApproval" | "resolveApproval"
|
|
19
|
+
"injectMessage" | "sendMessage" | "resolveWebMcpApproval" | "resolveApproval" | "getClient"
|
|
20
20
|
> & {
|
|
21
21
|
messages: AgentWidgetMessage[];
|
|
22
22
|
callbacks: AgentWidgetSession["callbacks"];
|
|
@@ -81,8 +81,7 @@ export const wireSessionVoice = (
|
|
|
81
81
|
busy: () => p.busy(),
|
|
82
82
|
parked: () => s.webMcpApprovalResolvers.size > 0,
|
|
83
83
|
claim: (text, userUtteranceIds) => p.keyed?.claimUserTurn(text, userUtteranceIds) ?? null,
|
|
84
|
-
send: (text,
|
|
85
|
-
s.sendMessage(text, { viaVoice: true, voiceTurn: { userMessageId } }),
|
|
84
|
+
send: (text, voiceTurn) => s.sendMessage(text, { viaVoice: true, voiceTurn }),
|
|
86
85
|
track: (capture) => {
|
|
87
86
|
p.setDelegation(capture);
|
|
88
87
|
},
|
|
@@ -92,6 +91,31 @@ export const wireSessionVoice = (
|
|
|
92
91
|
// now. A stream still running reads it when it ends, once.
|
|
93
92
|
if (!s.streaming) s.speakLatestAssistantMessage(ids);
|
|
94
93
|
},
|
|
94
|
+
clientTools: () => s.getClient().getVoiceClientTools(),
|
|
95
|
+
recordDirect: ({ delegationId, userText, userUtteranceIds, assistantText }) => {
|
|
96
|
+
// The heard transcript bubble becomes the conversation turn, as a
|
|
97
|
+
// delegated send would make it; without one, the request text is.
|
|
98
|
+
const bubbleId = p.keyed?.claimUserTurn(userText, userUtteranceIds);
|
|
99
|
+
let claimed = false;
|
|
100
|
+
// upsertMessage merges, so it cannot drop the caption flag: replace the bubble.
|
|
101
|
+
s.messages = s.messages.map((m) => {
|
|
102
|
+
if (m.id !== bubbleId || m.role !== 'user') return m;
|
|
103
|
+
claimed = true;
|
|
104
|
+
const { voiceCaption: _caption, ...turn } = m;
|
|
105
|
+
return { ...turn, viaVoice: true };
|
|
106
|
+
});
|
|
107
|
+
if (claimed) {
|
|
108
|
+
s.callbacks.onMessagesChanged([...s.messages]);
|
|
109
|
+
} else if (userText.trim()) {
|
|
110
|
+
s.injectMessage({ role: 'user', content: userText.trim() });
|
|
111
|
+
}
|
|
112
|
+
const answer = assistantText.trim();
|
|
113
|
+
if (!answer) return;
|
|
114
|
+
// The voice model speaks this answer: browser TTS must not read it again.
|
|
115
|
+
const id = `voice-direct-${delegationId}`;
|
|
116
|
+
s.ttsSpokenMessageIds.add(id);
|
|
117
|
+
s.injectMessage({ id, role: 'assistant', content: answer });
|
|
118
|
+
},
|
|
95
119
|
decide: (id) => {
|
|
96
120
|
if (s.webMcpApprovalResolvers.has(id)) return s.resolveWebMcpApproval(id, 'denied');
|
|
97
121
|
const approval = s.messages.find((m) => m.id === id)?.approval;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClientToolCallFrame,
|
|
3
|
+
ClientToolDefinition,
|
|
4
|
+
ClientToolsFrame,
|
|
5
|
+
VoiceClientToolOutcome,
|
|
6
|
+
VoiceClientTools,
|
|
7
|
+
VoiceClientToolsChannel,
|
|
8
|
+
VoiceClientToolsSession,
|
|
9
|
+
} from "../types";
|
|
10
|
+
|
|
11
|
+
const WEBMCP_PREFIX = "webmcp:";
|
|
12
|
+
|
|
13
|
+
/** The name the server calls a client tool back with: `webmcp:` prefixed for page tools. */
|
|
14
|
+
export function voiceClientToolName(tool: ClientToolDefinition): string {
|
|
15
|
+
return tool.origin === "webmcp" && !tool.name.startsWith(WEBMCP_PREFIX) ? WEBMCP_PREFIX + tool.name : tool.name;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The fast-tier half of a voice call (see runtype-voice-provider.ts): keeps the
|
|
20
|
+
* server's `client_tools` snapshot current and answers each `client_tool_call`
|
|
21
|
+
* with exactly one `client_tool_result`. Ships in the lazy
|
|
22
|
+
* `voice-client-tools` chunk, loaded only when the server negotiates it.
|
|
23
|
+
*/
|
|
24
|
+
export function createVoiceClientToolsSession(
|
|
25
|
+
surface: VoiceClientTools,
|
|
26
|
+
channel: VoiceClientToolsChannel,
|
|
27
|
+
): VoiceClientToolsSession {
|
|
28
|
+
let closed = false;
|
|
29
|
+
let queue = Promise.resolve();
|
|
30
|
+
let sentFingerprint: string | null = null;
|
|
31
|
+
let sent = new Map<string, ClientToolDefinition>();
|
|
32
|
+
const calls = new Map<string, { delegationId: string; abort: AbortController }>();
|
|
33
|
+
|
|
34
|
+
// Serialized, so snapshots go out in order; only a changed fingerprint is resent.
|
|
35
|
+
const sync = () => {
|
|
36
|
+
queue = queue.then(async () => {
|
|
37
|
+
const snapshot = closed ? null : await surface.snapshot().catch(() => null);
|
|
38
|
+
if (!snapshot || closed || snapshot.fingerprint === sentFingerprint) return;
|
|
39
|
+
const tools = snapshot.tools.map((tool) => ({ ...tool, name: voiceClientToolName(tool) }));
|
|
40
|
+
const frame: ClientToolsFrame = { type: "client_tools", fingerprint: snapshot.fingerprint, tools };
|
|
41
|
+
if (!channel.send(frame)) return;
|
|
42
|
+
sentFingerprint = snapshot.fingerprint;
|
|
43
|
+
sent = new Map(tools.map((tool) => [tool.name, tool]));
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const abort = (delegationId?: string) => {
|
|
48
|
+
for (const [id, call] of calls) {
|
|
49
|
+
if (delegationId !== undefined && call.delegationId !== delegationId) continue;
|
|
50
|
+
calls.delete(id);
|
|
51
|
+
call.abort.abort();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const stopWatch = surface.onChange(sync);
|
|
56
|
+
sync();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
abort,
|
|
60
|
+
close() {
|
|
61
|
+
closed = true;
|
|
62
|
+
stopWatch();
|
|
63
|
+
abort();
|
|
64
|
+
},
|
|
65
|
+
call(msg: Partial<ClientToolCallFrame>) {
|
|
66
|
+
const { delegationId, toolCallId } = msg;
|
|
67
|
+
if (closed || typeof delegationId !== "string" || !delegationId || typeof toolCallId !== "string" || !toolCallId)
|
|
68
|
+
return;
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
calls.get(toolCallId)?.abort.abort();
|
|
71
|
+
calls.set(toolCallId, { delegationId, abort: controller });
|
|
72
|
+
// Exactly one answer, unless the call was aborted (cancelled, handed back, hang-up).
|
|
73
|
+
const reply = ({ status, result, error }: VoiceClientToolOutcome) => {
|
|
74
|
+
// A replacement call with the same id owns the slot now: leave it alone.
|
|
75
|
+
if (calls.get(toolCallId)?.abort !== controller) return;
|
|
76
|
+
calls.delete(toolCallId);
|
|
77
|
+
if (closed || channel.cancelled(delegationId)) return;
|
|
78
|
+
channel.send({
|
|
79
|
+
type: "client_tool_result",
|
|
80
|
+
delegationId,
|
|
81
|
+
toolCallId,
|
|
82
|
+
status,
|
|
83
|
+
...(result !== undefined && { result }),
|
|
84
|
+
...(error && { error: { message: error.message } }),
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
89
|
+
const tool = sent.get(name) ?? sent.get(WEBMCP_PREFIX + name);
|
|
90
|
+
if (!tool || msg.fingerprint !== sentFingerprint) {
|
|
91
|
+
return reply({ status: "stale", error: { message: tool ? "The page's tools changed." : `Unknown tool: ${name}` } });
|
|
92
|
+
}
|
|
93
|
+
const args = msg.arguments && typeof msg.arguments === "object" && !Array.isArray(msg.arguments) ? msg.arguments : {};
|
|
94
|
+
const timeoutMs = typeof msg.timeoutMs === "number" && msg.timeoutMs > 0 ? msg.timeoutMs : 0;
|
|
95
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
96
|
+
const run = surface
|
|
97
|
+
.execute(tool.name, args, { signal: controller.signal, autoConfirmReadOnly: true })
|
|
98
|
+
.catch((error: unknown): VoiceClientToolOutcome => ({
|
|
99
|
+
status: "failed",
|
|
100
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
101
|
+
}));
|
|
102
|
+
// The deadline also stops a gate still waiting: a late approval must not run the tool.
|
|
103
|
+
const deadline = new Promise<VoiceClientToolOutcome>((resolve) => {
|
|
104
|
+
if (timeoutMs)
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
controller.abort();
|
|
107
|
+
resolve({ status: "timeout", error: { message: `Page tool exceeded ${timeoutMs}ms.` } });
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
});
|
|
110
|
+
void Promise.race([run, deadline]).then((outcome) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
reply(outcome);
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -4,6 +4,8 @@ import type {
|
|
|
4
4
|
VoiceDelegationFollowUp,
|
|
5
5
|
VoiceDelegationRequest,
|
|
6
6
|
VoiceDelegationResult,
|
|
7
|
+
VoiceClientTools,
|
|
8
|
+
VoiceDirectTurn,
|
|
7
9
|
VoiceSessionBridge,
|
|
8
10
|
} from "../types";
|
|
9
11
|
|
|
@@ -43,14 +45,21 @@ export interface VoiceDelegationHost {
|
|
|
43
45
|
parked(): boolean;
|
|
44
46
|
/** The transcript bubble the request came from (see KeyedVoiceTranscript.claimUserTurn). */
|
|
45
47
|
claim(userText: string, userUtteranceIds: string[]): string | null;
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* sendMessage as a voice turn, submitting `userMessageId`'s bubble when
|
|
50
|
+
* given and recording `priorToolResults` as already-run tool calls.
|
|
51
|
+
*/
|
|
52
|
+
send(userText: string, voiceTurn: { userMessageId?: string | null; priorToolResults?: unknown }): Promise<void>;
|
|
48
53
|
/** Route the chat stream's assistant messages and failures into `capture` (or stop). */
|
|
49
54
|
track(capture: VoiceDelegationCapture | null): void;
|
|
50
55
|
/** Deny a pending approval (server gate or WebMCP) by its message id. */
|
|
51
56
|
decide(approvalMessageId: string): void;
|
|
52
57
|
/** Let browser TTS read these messages after all (their spoken result was dropped). */
|
|
53
58
|
unspoken(messageIds: string[]): void;
|
|
59
|
+
/** WebMCP access for the voice fast tier; `null` when WebMCP is off. */
|
|
60
|
+
clientTools?(): VoiceClientTools | null;
|
|
61
|
+
/** Write a server-answered (`route: 'direct'`) turn into the chat. */
|
|
62
|
+
recordDirect?(turn: VoiceDirectTurn): void;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
const SETTLE_POLL_MS = 50;
|
|
@@ -185,6 +194,9 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
185
194
|
};
|
|
186
195
|
|
|
187
196
|
return {
|
|
197
|
+
clientTools: host.clientTools,
|
|
198
|
+
recordDirectTurn: host.recordDirect,
|
|
199
|
+
|
|
188
200
|
// Read once at call start, before this call has any captions, so the
|
|
189
201
|
// captions here are earlier calls' (e.g. on the previous page of a
|
|
190
202
|
// multi-page site): spoken conversation the new voice session never heard.
|
|
@@ -231,6 +243,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
231
243
|
userText,
|
|
232
244
|
userUtteranceIds,
|
|
233
245
|
stream,
|
|
246
|
+
priorToolResults,
|
|
234
247
|
}: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
|
|
235
248
|
const live = parks.filter((park) => pendingOf(park).length);
|
|
236
249
|
const pendingIds = live.flatMap(pendingOf);
|
|
@@ -297,7 +310,10 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
|
|
|
297
310
|
active = capture;
|
|
298
311
|
retrack();
|
|
299
312
|
try {
|
|
300
|
-
await host.send(userText,
|
|
313
|
+
await host.send(userText, {
|
|
314
|
+
userMessageId: host.claim(userText, userUtteranceIds),
|
|
315
|
+
priorToolResults,
|
|
316
|
+
});
|
|
301
317
|
// Local tools and approvals may continue the turn past the first stream.
|
|
302
318
|
await settled(capture);
|
|
303
319
|
} catch {
|