@runtypelabs/persona 4.27.0 → 4.28.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/README.md +25 -0
- package/dist/{approval-ui-QAKWNFNM.js → approval-ui-OBBI62ES.js} +1 -1
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +20 -5
- package/dist/approval-ui.d.ts +20 -5
- package/dist/approval-ui.js +2 -2
- package/dist/artifacts-ui.d.cts +20 -5
- package/dist/artifacts-ui.d.ts +20 -5
- package/dist/{chunk-NQLDCJAP.js → chunk-D4TDKCT5.js} +2 -2
- package/dist/chunk-FNXK3VTC.js +1 -0
- package/dist/{client-history-HTRKHBAF.js → client-history-QGAFMCBE.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/{client-stream-N22JHTLE.js → client-stream-WXD2FL23.js} +1 -1
- package/dist/client-stream.js +3 -3
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +5 -5
- package/dist/context-mentions.d.cts +20 -5
- package/dist/context-mentions.d.ts +20 -5
- package/dist/event-stream-view.d.cts +20 -5
- package/dist/event-stream-view.d.ts +20 -5
- package/dist/forms-ui.d.cts +26 -5
- package/dist/forms-ui.d.ts +26 -5
- package/dist/index.cjs +41 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -30
- package/dist/index.d.ts +59 -30
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +41 -41
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +20 -5
- package/dist/session-reconnect.d.ts +20 -5
- package/dist/smart-dom-reader.d.cts +20 -5
- package/dist/smart-dom-reader.d.ts +20 -5
- package/dist/theme-editor-preview.cjs +63 -63
- package/dist/theme-editor-preview.d.cts +20 -5
- package/dist/theme-editor-preview.d.ts +20 -5
- package/dist/theme-editor-preview.js +6 -6
- package/dist/theme-editor.d.cts +21 -6
- package/dist/theme-editor.d.ts +21 -6
- package/dist/voice-runtime-TWFJ4UUS.js +17 -0
- package/dist/voice-runtime.cjs +8 -8
- package/dist/voice-runtime.d.cts +31 -5
- package/dist/voice-runtime.d.ts +31 -5
- package/dist/voice-runtime.js +8 -8
- package/dist/webmcp-runtime-5Q3IHAHP.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +22 -26
- package/dist/webmcp-runtime.d.ts +22 -26
- package/dist/webmcp-runtime.js +2 -2
- package/package.json +5 -2
- package/src/client.interrupt.test.ts +18 -0
- package/src/client.ts +7 -0
- package/src/generated/runtype-openapi-contract.ts +3 -0
- package/src/session.ts +11 -3
- package/src/session.voice.test.ts +140 -0
- package/src/theme-editor/webmcp/types.ts +1 -1
- package/src/types.ts +26 -5
- package/src/voice/runtype-voice-provider.ts +93 -11
- package/src/voice/voice-clause-buffer.test.ts +143 -0
- package/src/voice/voice-clause-buffer.ts +187 -0
- package/src/voice/voice-delegation.ts +85 -3
- package/src/voice/voice.test.ts +257 -2
- package/src/webmcp-bridge.test.ts +87 -21
- package/src/webmcp-bridge.ts +21 -26
- package/src/webmcp-polyfill.integration.test.ts +339 -0
- package/src/webmcp-polyfill.ts +7 -4
- package/src/webmcp-runtime-bundle.test.ts +1 -1
- package/src/webmcp-runtime-entry.ts +34 -19
- package/dist/chunk-3255TNY7.js +0 -1
- package/dist/voice-runtime-RHRX4F3T.js +0 -17
- package/dist/webmcp-runtime-V6ZWW3PI.js +0 -6
package/dist/webmcp-runtime.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
var
|
|
1
|
+
var C="webmcp:",w=t=>t.startsWith(C)?t.slice(C.length):t,u={warn(t,...e){typeof console<"u"&&typeof console.warn=="function"&&console.warn(`[Persona/WebMCP] ${t}`,...e)}},b=class{constructor(e,n){this.config=e;this.#e=n,this.#o=e.onConfirm??null,this.#r=3e4}#o;#r;#t=!1;#i=null;#l=!1;#e;setConfirmHandler(e){this.#o=e}isOperational(){return this.config.enabled!==!0||!this.#t?!1:this.#n()!==null}async snapshotForDispatch(){if(await this.#s(),this.config.enabled!==!0)return[];let e=this.#n();if(!e)return[];let n;try{n=(await e.getTools()).filter(h)}catch(i){return u.warn("getTools() threw: shipping an empty WebMCP snapshot.",i),[]}this.#e.recordToolDisplayTitles(n);let o=typeof location<"u"?location.origin:"";return n.filter(i=>this.#c(i.name)).map(i=>{let l=P(i.annotations);return{name:i.name,description:i.description,parametersSchema:W(i.inputSchema),origin:"webmcp",...o?{pageOrigin:o}:{},...l?{annotations:l}:{},...l?.untrustedContentHint===!0?{untrustedContentHint:!0}:{}}})}async executeToolCall(e,n,o){if(await this.#s(),this.config.enabled!==!0)return s("WebMCP is not enabled on this widget.");let i=this.#n();if(!i){let r=typeof document<"u"&&!!document.modelContext;return s(r?"WebMCP is not operational: document.modelContext is present but does not expose the strict getTools()/executeTool() surface (likely a different or older WebMCP polyfill).":"WebMCP bridge is not operational on this page (document.modelContext not available).")}let l=w(e),c;try{c=(await i.getTools()).filter(h)}catch(r){let p=r instanceof Error?r.message:String(r);return s(`Failed to read WebMCP registry: ${p}`)}this.#e.recordToolDisplayTitles(c);let f=c.find(r=>r.name===l);if(!f)return s(`WebMCP tool not registered on this page: ${l}`);if(!this.#c(l))return s(`WebMCP tool not allowed by client allowlist: ${l}`);if(o?.aborted)return s("Aborted by cancel()");let m=this.#e.getToolDisplayTitle(l),M={toolName:l,args:n,description:f.description,...m?{title:m}:{},reason:"gate"};if(!await this.#u(M))return s("User declined the tool call.");if(o?.aborted)return s("Aborted by cancel()");let a=new AbortController,y=!1,T=setTimeout(()=>{y=!0,a.abort()},this.#r),g=()=>a.abort();o&&(o.aborted?a.abort():o.addEventListener("abort",g,{once:!0}));try{let r=await i.executeTool(f,O(n),{signal:a.signal});return A(r)}catch(r){if(y)return s(`WebMCP tool '${l}' timed out after ${this.#r}ms`);if(o?.aborted)return s("Aborted by cancel()");let p=r instanceof Error?r.message:String(r);return s(p)}finally{clearTimeout(T),o&&o.removeEventListener("abort",g)}}#s(){return this.config.enabled!==!0?Promise.resolve():(this.#i||(this.#i=this.#a()),this.#i)}async#a(){try{if(this.#n()){this.#t=!0;return}let e=await this.#e.loadPolyfill();"initializeWebMCPPolyfill"in e?e.initializeWebMCPPolyfill():e.installWebMCP(),this.#t=!0}catch(e){u.warn("Failed to load @mcp-b/webmcp-polyfill: WebMCP consumption disabled.",e),this.#t=!1}}#n(){if(typeof document>"u")return null;let e=document.modelContext;if(!e||typeof e!="object")return null;let n=e;return typeof n.getTools!="function"||typeof n.executeTool!="function"?(this.#l||(this.#l=!0,u.warn("document.modelContext is present but does not expose getTools()/executeTool(): WebMCP consumption is disabled. Another (incompatible or older) WebMCP polyfill likely installed document.modelContext before Persona. Remove it, or use a polyfill implementing the strict standard surface (e.g. @mcp-b/webmcp-polyfill).")),null):e}async#u(e){let n=this.#o??k;try{return await n(e)}catch(o){return u.warn(`Confirm handler threw for WebMCP tool '${e.toolName}'; declining.`,o),!1}}#c(e){let n=this.config.allowlist;return!n||n.length===0?!0:n.some(o=>x(e,o))}},x=(t,e)=>{if(e==="*")return!0;let n=e.replace(/[.+?^${}()|[\]\\]/g,"\\$&");return new RegExp("^"+n.replace(/\*/g,".*")+"$").test(t)},d={type:"object",properties:{}},P=t=>{if(!t||typeof t!="object")return;let e={...typeof t.readOnlyHint=="boolean"?{readOnlyHint:t.readOnlyHint}:{},...typeof t.untrustedContentHint=="boolean"?{untrustedContentHint:t.untrustedContentHint}:{}};return Object.keys(e).length>0?e:void 0},W=t=>{if(t===void 0||t==="")return{...d};if(typeof t=="object")return t!==null&&!Array.isArray(t)?t:{...d};try{let e=JSON.parse(t);return e!==null&&typeof e=="object"&&!Array.isArray(e)?e:{...d}}catch{return{...d}}},A=t=>{if(t==null)return{content:[{type:"text",text:""}]};let e;try{e=JSON.parse(t)}catch{return{content:[{type:"text",text:t}]}}return e!==null&&typeof e=="object"&&Array.isArray(e.content)?e:{content:[{type:"text",text:typeof e=="string"?e:D(e)}]}},O=t=>{let e;try{e=JSON.stringify(t)??"{}"}catch{e="{}"}let n=JSON.parse(e);if(n===null||typeof n!="object")throw new TypeError("WebMCP tool arguments must be an object or array.");return Object.defineProperty(n,Symbol.toPrimitive,{value:()=>e}),n},s=t=>({isError:!0,content:[{type:"text",text:t}]}),k=async t=>{if(typeof window>"u"||typeof window.confirm!="function")return!1;let e=S(t.args),n=`Allow the AI to call ${t.toolName}`+(e?`
|
|
2
2
|
|
|
3
3
|
Arguments:
|
|
4
4
|
${e}`:"")+(t.description?`
|
|
5
5
|
|
|
6
|
-
${t.description}`:"");return window.confirm(n)},
|
|
6
|
+
${t.description}`:"");return window.confirm(n)},S=t=>{if(t==null)return"";try{let e=JSON.stringify(t,null,2);return e.length>500?e.slice(0,500)+"\u2026":e}catch{return String(t)}},h=t=>(t.window===void 0||typeof window<"u"&&t.window===window)&&(t.origin===void 0||typeof location<"u"&&t.origin===location.origin),D=t=>{if(t===void 0)return"";try{return JSON.stringify(t)}catch{return String(t)}};export{b as WebMcpBridge};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.28.1",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -138,6 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
"devDependencies": {
|
|
140
140
|
"@size-limit/file": "^12.1.0",
|
|
141
|
+
"@types/jsdom": "^30.0.0",
|
|
141
142
|
"@types/node": "^20.12.7",
|
|
142
143
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
143
144
|
"@typescript-eslint/parser": "^7.0.0",
|
|
@@ -147,12 +148,14 @@
|
|
|
147
148
|
"eslint": "^8.57.0",
|
|
148
149
|
"eslint-config-prettier": "^9.1.0",
|
|
149
150
|
"fake-indexeddb": "^6.2.5",
|
|
151
|
+
"jsdom": "^28.1.0",
|
|
150
152
|
"rimraf": "^5.0.5",
|
|
151
153
|
"size-limit": "^12.1.0",
|
|
152
154
|
"terser": "^5.49.0",
|
|
153
155
|
"tsup": "^8.0.1",
|
|
154
156
|
"typescript": "^5.4.5",
|
|
155
|
-
"vitest": "^4.0.9"
|
|
157
|
+
"vitest": "^4.0.9",
|
|
158
|
+
"webmcp-polyfill-v6": "npm:@mcp-b/webmcp-polyfill@6.0.0"
|
|
156
159
|
},
|
|
157
160
|
"engines": {
|
|
158
161
|
"node": ">=20.0.0"
|
|
@@ -90,6 +90,24 @@ describe("client-token turn identity", () => {
|
|
|
90
90
|
expect(bodies[1].turnId).not.toBe(bodies[0].turnId);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
it("a voice-spoken send carries the spoken-reply hint; others don't", async () => {
|
|
94
|
+
const bodies: Record<string, unknown>[] = [];
|
|
95
|
+
global.fetch = vi.fn(async (url: unknown, init: any) => {
|
|
96
|
+
if (String(url).endsWith("/v1/client/init")) return initResponse();
|
|
97
|
+
bodies.push(JSON.parse(init.body));
|
|
98
|
+
const stream = controlledStream();
|
|
99
|
+
stream.close();
|
|
100
|
+
return stream.response;
|
|
101
|
+
}) as unknown as typeof fetch;
|
|
102
|
+
|
|
103
|
+
const client = clientTokenClient();
|
|
104
|
+
await client.dispatch({ messages, voiceSpoken: true }, () => {});
|
|
105
|
+
await client.dispatch({ messages }, () => {});
|
|
106
|
+
|
|
107
|
+
expect(bodies[0].voice).toEqual({ spoken: true });
|
|
108
|
+
expect(bodies[1]).not.toHaveProperty("voice");
|
|
109
|
+
});
|
|
110
|
+
|
|
93
111
|
it("drops SSE events belonging to a superseded turn", async () => {
|
|
94
112
|
const first = controlledStream();
|
|
95
113
|
const second = controlledStream();
|
package/src/client.ts
CHANGED
|
@@ -74,6 +74,12 @@ type DispatchOptions = {
|
|
|
74
74
|
* `submitMode: "interrupt"` so the server cancels the prior run.
|
|
75
75
|
*/
|
|
76
76
|
interrupt?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Client-token only: a voice model reads the reply aloud (a delegated voice
|
|
79
|
+
* turn). Sends `voice: { spoken: true }` so the server asks for a speakable
|
|
80
|
+
* answer.
|
|
81
|
+
*/
|
|
82
|
+
voiceSpoken?: boolean;
|
|
77
83
|
};
|
|
78
84
|
|
|
79
85
|
export type SSEHandler = (event: AgentWidgetEvent) => void;
|
|
@@ -1510,6 +1516,7 @@ export class AgentWidgetClient {
|
|
|
1510
1516
|
// superseded run and this client can drop its stale events below.
|
|
1511
1517
|
turnId,
|
|
1512
1518
|
...(options.interrupt && { submitMode: 'interrupt' as const }),
|
|
1519
|
+
...(options.voiceSpoken && { voice: { spoken: true as const } }),
|
|
1513
1520
|
};
|
|
1514
1521
|
|
|
1515
1522
|
// Diff-only / send-once WebMCP tool dispatch. `buildPayload()` already
|
|
@@ -683,6 +683,9 @@ export type RuntypeClientChatRequest = {
|
|
|
683
683
|
sessionId: string;
|
|
684
684
|
submitMode?: "normal" | "interrupt" | "steer";
|
|
685
685
|
turnId?: string;
|
|
686
|
+
voice?: {
|
|
687
|
+
spoken?: boolean;
|
|
688
|
+
};
|
|
686
689
|
};
|
|
687
690
|
|
|
688
691
|
export type RuntypeClientChatStreamEvent = RuntypeExecutionStreamEvent;
|
package/src/session.ts
CHANGED
|
@@ -2042,7 +2042,7 @@ export class AgentWidgetSession {
|
|
|
2042
2042
|
controller: AbortController,
|
|
2043
2043
|
assistantMessageId: string,
|
|
2044
2044
|
userMessageId: string,
|
|
2045
|
-
turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean }
|
|
2045
|
+
turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean; voiceSpoken?: boolean }
|
|
2046
2046
|
): Promise<void> {
|
|
2047
2047
|
this.#recordDispatchedProjections(snapshot);
|
|
2048
2048
|
try {
|
|
@@ -2052,7 +2052,8 @@ export class AgentWidgetSession {
|
|
|
2052
2052
|
signal: controller.signal,
|
|
2053
2053
|
assistantMessageId,
|
|
2054
2054
|
composerOptions: turnOptions?.composerOptions,
|
|
2055
|
-
interrupt: turnOptions?.interrupt
|
|
2055
|
+
interrupt: turnOptions?.interrupt,
|
|
2056
|
+
voiceSpoken: turnOptions?.voiceSpoken
|
|
2056
2057
|
},
|
|
2057
2058
|
this.handleEvent
|
|
2058
2059
|
);
|
|
@@ -2070,6 +2071,7 @@ export class AgentWidgetSession {
|
|
|
2070
2071
|
signal: controller.signal,
|
|
2071
2072
|
assistantMessageId,
|
|
2072
2073
|
composerOptions: turnOptions?.composerOptions,
|
|
2074
|
+
voiceSpoken: turnOptions?.voiceSpoken,
|
|
2073
2075
|
},
|
|
2074
2076
|
this.handleEvent
|
|
2075
2077
|
);
|
|
@@ -2705,7 +2707,9 @@ export class AgentWidgetSession {
|
|
|
2705
2707
|
userMessageId,
|
|
2706
2708
|
{
|
|
2707
2709
|
composerOptions: options?.composerOptions,
|
|
2708
|
-
interrupt: options?.interrupt
|
|
2710
|
+
interrupt: options?.interrupt,
|
|
2711
|
+
// The delegated voice turn's reply is read aloud (spoken-reply hint).
|
|
2712
|
+
voiceSpoken: !!options?.voiceTurn
|
|
2709
2713
|
}
|
|
2710
2714
|
);
|
|
2711
2715
|
} catch (error) {
|
|
@@ -3679,6 +3683,7 @@ export class AgentWidgetSession {
|
|
|
3679
3683
|
|
|
3680
3684
|
private handleEvent = (event: AgentWidgetEvent) => {
|
|
3681
3685
|
const delegation = this.#voiceDelegation;
|
|
3686
|
+
let delegated = false;
|
|
3682
3687
|
if (delegation) {
|
|
3683
3688
|
if (event.type === "error" || (event.type === "status" && event.status === "error")) {
|
|
3684
3689
|
delegation.failed = true;
|
|
@@ -3687,10 +3692,13 @@ export class AgentWidgetSession {
|
|
|
3687
3692
|
// unless the server dropped the result (then nobody speaks it).
|
|
3688
3693
|
if (!delegation.dropped) this.ttsSpokenMessageIds.add(event.message.id);
|
|
3689
3694
|
if (!delegation.ids.includes(event.message.id)) delegation.ids.push(event.message.id);
|
|
3695
|
+
delegated = true;
|
|
3690
3696
|
}
|
|
3691
3697
|
}
|
|
3692
3698
|
if (event.type === "message") {
|
|
3693
3699
|
this.upsertMessage(event.message);
|
|
3700
|
+
// The delegated answer grew: a streaming voice bridge reads it now.
|
|
3701
|
+
if (delegated) delegation!.onUpdate?.();
|
|
3694
3702
|
|
|
3695
3703
|
// A resolved approval reaches this handler only from the server's
|
|
3696
3704
|
// `approval_complete` (resolveApproval's optimistic update upserts
|
|
@@ -781,6 +781,146 @@ describe('AgentWidgetSession - voice client delegation bridge', () => {
|
|
|
781
781
|
expect(result.followUp).toBeTypeOf('function');
|
|
782
782
|
});
|
|
783
783
|
|
|
784
|
+
describe('streaming the answer (delegation_stream)', () => {
|
|
785
|
+
/** Streams `chunks` into one assistant message, growing it like a text stream. */
|
|
786
|
+
const streamReply = (onEvent: (event: AgentWidgetEvent) => void, chunks: string[], id = 'assistant-s1') => {
|
|
787
|
+
const base = { id, role: 'assistant' as const, createdAt: new Date().toISOString() };
|
|
788
|
+
let content = '';
|
|
789
|
+
onEvent({ type: 'status', status: 'connecting' });
|
|
790
|
+
for (const chunk of chunks) {
|
|
791
|
+
content += chunk;
|
|
792
|
+
onEvent({ type: 'message', message: { ...base, content, streaming: true } });
|
|
793
|
+
}
|
|
794
|
+
onEvent({ type: 'message', message: { ...base, content, streaming: false } });
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
it('streams the answer in clause pieces while it renders, then reports streamedChars', async () => {
|
|
798
|
+
drive('user', 'what are your hours', true, 'u1');
|
|
799
|
+
const pieces: string[] = [];
|
|
800
|
+
const seenBeforeEnd: string[] = [];
|
|
801
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
802
|
+
streamReply(onEvent, ['We open at ', 'eight, and close ', 'at six. On Sundays ', 'we are closed.']);
|
|
803
|
+
seenBeforeEnd.push(...pieces);
|
|
804
|
+
// A tool bubble between two answer messages never streams.
|
|
805
|
+
onEvent({
|
|
806
|
+
type: 'message',
|
|
807
|
+
message: { id: 'tool-1', role: 'assistant', content: '', createdAt: new Date().toISOString(), variant: 'tool' },
|
|
808
|
+
});
|
|
809
|
+
streamReply(onEvent, ['Anything else?'], 'assistant-s2');
|
|
810
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
811
|
+
});
|
|
812
|
+
const result = await h.state.bridge!.runDelegatedTurn(
|
|
813
|
+
req({ delegationId: 'd1', userText: 'what are your hours', stream: (text) => pieces.push(text) }),
|
|
814
|
+
);
|
|
815
|
+
expect(result.text).toBe('We open at eight, and close at six. On Sundays we are closed.\n\nAnything else?');
|
|
816
|
+
// The first pieces went out while the stream was still running.
|
|
817
|
+
expect(seenBeforeEnd).toEqual(['We open at eight, ', 'and close at six. ']);
|
|
818
|
+
expect(pieces.join('')).toBe(result.text);
|
|
819
|
+
expect(result.streamedChars).toBe(result.text.length);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('streams the answer of an approval-parked turn; the approval script is the unstreamed suffix', async () => {
|
|
823
|
+
drive('user', 'order two croissants', true, 'u1');
|
|
824
|
+
parkOnApproval();
|
|
825
|
+
const pieces: string[] = [];
|
|
826
|
+
const result = await h.state.bridge!.runDelegatedTurn(
|
|
827
|
+
req({ delegationId: 'd1', userText: 'order two croissants', stream: (text) => pieces.push(text) }),
|
|
828
|
+
);
|
|
829
|
+
expect(result.status).toBe('pending_approval');
|
|
830
|
+
expect(pieces).toEqual(['I can do that.']);
|
|
831
|
+
expect(result.streamedChars).toBe('I can do that.'.length);
|
|
832
|
+
expect(result.text.startsWith('I can do that.\n\n')).toBe(true);
|
|
833
|
+
expect(result.text.slice(result.streamedChars)).toContain("This action needs the user's approval");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('stops streaming when the answer is rewritten, and counts only the shared prefix', async () => {
|
|
837
|
+
drive('user', 'hours', true, 'u1');
|
|
838
|
+
const pieces: string[] = [];
|
|
839
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
840
|
+
const base = { id: 'assistant-rw', role: 'assistant' as const, createdAt: new Date().toISOString() };
|
|
841
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at eight. Sun', streaming: true } });
|
|
842
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at 8am. Closed Sundays.', streaming: false } });
|
|
843
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
844
|
+
});
|
|
845
|
+
const result = await h.state.bridge!.runDelegatedTurn(
|
|
846
|
+
req({ delegationId: 'd1', userText: 'hours', stream: (text) => pieces.push(text) }),
|
|
847
|
+
);
|
|
848
|
+
expect(pieces).toEqual(['We open at eight. ']);
|
|
849
|
+
expect(result.text).toBe('We open at 8am. Closed Sundays.');
|
|
850
|
+
expect(result.streamedChars).toBe('We open at '.length);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('streams a held sentence end after 200 ms of quiet, and stops on cancellation', async () => {
|
|
854
|
+
vi.useFakeTimers();
|
|
855
|
+
try {
|
|
856
|
+
drive('user', 'hours', true, 'u1');
|
|
857
|
+
const pieces: string[] = [];
|
|
858
|
+
let resume!: () => void;
|
|
859
|
+
const paused = new Promise<void>((resolve) => (resume = resolve));
|
|
860
|
+
const base = { id: 'assistant-idle', role: 'assistant' as const, createdAt: new Date().toISOString() };
|
|
861
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
862
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at 8 a.m.', streaming: true } });
|
|
863
|
+
await paused;
|
|
864
|
+
onEvent({ type: 'message', message: { ...base, content: 'We open at 8 a.m. Closed Sundays.', streaming: false } });
|
|
865
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
866
|
+
});
|
|
867
|
+
const turn = h.state.bridge!.runDelegatedTurn(
|
|
868
|
+
req({ delegationId: 'd1', userText: 'hours', stream: (text) => pieces.push(text) }),
|
|
869
|
+
);
|
|
870
|
+
await vi.advanceTimersByTimeAsync(199);
|
|
871
|
+
expect(pieces).toEqual([]);
|
|
872
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
873
|
+
expect(pieces).toEqual(['We open at 8 a.m.']);
|
|
874
|
+
resume();
|
|
875
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
876
|
+
const result = await turn;
|
|
877
|
+
expect(pieces).toEqual(['We open at 8 a.m.', ' Closed Sundays.']);
|
|
878
|
+
expect(pieces.join('')).toBe(result.text);
|
|
879
|
+
expect(result.streamedChars).toBe(result.text.length);
|
|
880
|
+
|
|
881
|
+
// Cancelled while a sentence end is held: the idle flush never fires.
|
|
882
|
+
const later: string[] = [];
|
|
883
|
+
let resume2!: () => void;
|
|
884
|
+
const paused2 = new Promise<void>((resolve) => (resume2 = resolve));
|
|
885
|
+
const base2 = { ...base, id: 'assistant-idle-2' };
|
|
886
|
+
dispatch.mockImplementation(async (_options, onEvent) => {
|
|
887
|
+
onEvent({ type: 'message', message: { ...base2, content: 'Sure thing.', streaming: true } });
|
|
888
|
+
await paused2;
|
|
889
|
+
onEvent({ type: 'message', message: { ...base2, content: 'Sure thing. Done.', streaming: false } });
|
|
890
|
+
onEvent({ type: 'status', status: 'idle' });
|
|
891
|
+
});
|
|
892
|
+
const turn2 = h.state.bridge!.runDelegatedTurn(
|
|
893
|
+
req({ delegationId: 'd2', userText: 'more', stream: (text) => later.push(text) }),
|
|
894
|
+
);
|
|
895
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
896
|
+
h.state.bridge!.dropDelegation!('d2');
|
|
897
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
898
|
+
expect(later).toEqual([]);
|
|
899
|
+
resume2();
|
|
900
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
901
|
+
await turn2;
|
|
902
|
+
expect(later).toEqual([]);
|
|
903
|
+
} finally {
|
|
904
|
+
vi.useRealTimers();
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('streams nothing and omits streamedChars without a stream callback', async () => {
|
|
909
|
+
drive('user', 'hours', true, 'u1');
|
|
910
|
+
dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'We open at nine. Sundays closed.'));
|
|
911
|
+
const result = await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'hours' }));
|
|
912
|
+
expect(result).not.toHaveProperty('streamedChars');
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('marks only the delegated voice turn as spoken (voice: { spoken: true })', async () => {
|
|
917
|
+
dispatch.mockImplementation(async (_options, onEvent) => reply(onEvent, 'Nine to five.'));
|
|
918
|
+
await h.state.bridge!.runDelegatedTurn(req({ delegationId: 'd1', userText: 'what are your hours' }));
|
|
919
|
+
await session.sendMessage('typed question');
|
|
920
|
+
const options = dispatch.mock.calls.map(([o]) => (o as { voiceSpoken?: boolean }).voiceSpoken);
|
|
921
|
+
expect(options).toEqual([true, false]);
|
|
922
|
+
});
|
|
923
|
+
|
|
784
924
|
it('follows up with the answer once the visitor approves, kept off browser TTS', async () => {
|
|
785
925
|
drive('user', 'order two croissants', true, 'u1');
|
|
786
926
|
parkOnApproval();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* We deliberately keep our own small WebMCP types rather than depending on
|
|
6
6
|
* `@mcp-b/webmcp-types`, so the tool definitions are transport-agnostic and the
|
|
7
|
-
*
|
|
7
|
+
* tool definitions do not depend on the polyfill's types. These mirror the WebMCP draft
|
|
8
8
|
* (`registerTool(tool, { signal })`) and the MCP tool-result envelope that
|
|
9
9
|
* `@mcp-b/webmcp-polyfill` expects `execute` to return.
|
|
10
10
|
*/
|
package/src/types.ts
CHANGED
|
@@ -881,9 +881,8 @@ export type WebMcpConfirmInfo = {
|
|
|
881
881
|
};
|
|
882
882
|
/**
|
|
883
883
|
* Why the confirm was requested. Currently always `'gate'`: the default
|
|
884
|
-
* confirm-by-default gate that fires before every `webmcp:*` call.
|
|
885
|
-
*
|
|
886
|
-
* internally, so Persona no longer surfaces a nested in-tool confirm.)
|
|
884
|
+
* confirm-by-default gate that fires before every `webmcp:*` call. Tool
|
|
885
|
+
* callbacks on v6 receive an AbortSignal, not a nested confirmation API.
|
|
887
886
|
*/
|
|
888
887
|
reason: 'gate';
|
|
889
888
|
};
|
|
@@ -5265,6 +5264,13 @@ export type VoiceDelegationRequest = {
|
|
|
5265
5264
|
userUtteranceIds: string[];
|
|
5266
5265
|
/** The voice model's view of the conversation it delegated from. */
|
|
5267
5266
|
messages: Array<{ role: string; content: unknown }>;
|
|
5267
|
+
/**
|
|
5268
|
+
* Set when the server streams delegated answers (`delegation_stream`): called
|
|
5269
|
+
* with each speakable piece of the turn's answer, in order, while it renders.
|
|
5270
|
+
* The pieces, concatenated, are a prefix of the result's `text`; the result
|
|
5271
|
+
* says how much of it with `streamedChars`.
|
|
5272
|
+
*/
|
|
5273
|
+
stream?: (text: string) => void;
|
|
5268
5274
|
};
|
|
5269
5275
|
|
|
5270
5276
|
/**
|
|
@@ -5305,6 +5311,12 @@ export type VoiceDelegationResult = {
|
|
|
5305
5311
|
* @default true
|
|
5306
5312
|
*/
|
|
5307
5313
|
inChat?: boolean;
|
|
5314
|
+
/**
|
|
5315
|
+
* How many leading characters of `text` already went out through
|
|
5316
|
+
* `request.stream` (the rest, such as an approval script, is unstreamed).
|
|
5317
|
+
* Omitted or 0: nothing was streamed.
|
|
5318
|
+
*/
|
|
5319
|
+
streamedChars?: number;
|
|
5308
5320
|
/**
|
|
5309
5321
|
* Set when the turn parked on approvals (`text` then asks for the decision).
|
|
5310
5322
|
* Resolves with the terminal result once the visitor decides (or the
|
|
@@ -6266,6 +6278,12 @@ export type ClientChatRequest = {
|
|
|
6266
6278
|
turnId?: RuntypeClientChatRequest['turnId'];
|
|
6267
6279
|
/** Whether this turn should interrupt a prior in-flight response (from Core public OpenAPI). */
|
|
6268
6280
|
submitMode?: RuntypeClientChatRequest['submitMode'];
|
|
6281
|
+
/**
|
|
6282
|
+
* Spoken-reply hint, set on a delegated voice turn whose reply a voice model
|
|
6283
|
+
* reads aloud: the server asks the agent for a speakable answer (the direct
|
|
6284
|
+
* answer first, in plain sentences). Not persisted into history.
|
|
6285
|
+
*/
|
|
6286
|
+
voice?: { spoken: true };
|
|
6269
6287
|
};
|
|
6270
6288
|
|
|
6271
6289
|
/**
|
|
@@ -7686,8 +7704,11 @@ export type AgentWidgetConfig = {
|
|
|
7686
7704
|
/**
|
|
7687
7705
|
* WebMCP: consume page-registered tools (`document.modelContext.registerTool`).
|
|
7688
7706
|
* When `enabled`, the widget installs `@mcp-b/webmcp-polyfill`, snapshots the
|
|
7689
|
-
* registry on every dispatch, ships it as `clientTools[]`,
|
|
7690
|
-
* returned `webmcp:*`
|
|
7707
|
+
* current document's registry on every dispatch, ships it as `clientTools[]`,
|
|
7708
|
+
* and executes returned `webmcp:*` calls with confirm-by-default gating.
|
|
7709
|
+
* Supports host-installed v5 JSON-string and v6 object-input contracts.
|
|
7710
|
+
* The automatic fallback stays on v5 to preserve existing browser support
|
|
7711
|
+
* and callback-result behavior.
|
|
7691
7712
|
*
|
|
7692
7713
|
* Server-side policy on the chat surface is the source of truth: these
|
|
7693
7714
|
* fields layer on top.
|
|
@@ -64,7 +64,25 @@
|
|
|
64
64
|
// agent as conversation; a user bubble becomes conversation only when it is
|
|
65
65
|
// submitted. With `context` negotiated, one `context{text}` frame (chat history
|
|
66
66
|
// as of call start plus the host's `callContext`) is held until the visitor's
|
|
67
|
-
// first final transcript or first delegation
|
|
67
|
+
// first final transcript or first delegation; a delegation's frames (deltas
|
|
68
|
+
// and result) wait for it (up to 2 s, then it is dropped).
|
|
69
|
+
//
|
|
70
|
+
// Streaming (`delegation_stream` and `delegation_read_back` both negotiated,
|
|
71
|
+
// with client delegation; the read-back tags are what keep a streamed answer
|
|
72
|
+
// spoken as several utterances folded): while a
|
|
73
|
+
// delegated turn renders, its assistant answer goes out as ordered
|
|
74
|
+
// `delegation_delta{delegationId, text}` pieces (clause-sized, see
|
|
75
|
+
// voice-clause-buffer.ts; approval cards, tool bubbles and captions never
|
|
76
|
+
// stream), all before the delegation's first `delegation_update` or
|
|
77
|
+
// `delegation_result`. That frame then carries `streamedChars`: how many
|
|
78
|
+
// leading characters of its `text` the deltas already sent (an approval
|
|
79
|
+
// script appended after the answer is the unstreamed rest). Absent or 0: none.
|
|
80
|
+
// The server reports each spoken chunk with `delegation_progress{delegationId,
|
|
81
|
+
// text}`; the first one of a spoken phase folds the read-back as
|
|
82
|
+
// `delegation_completed{speak:true}` does (status stays "processing" until
|
|
83
|
+
// completed), and that phase's completion doesn't fold another utterance.
|
|
84
|
+
// Delegated sends carry `voice: { spoken: true }` on the chat request, so the
|
|
85
|
+
// agent answers in speakable sentences.
|
|
68
86
|
|
|
69
87
|
import type {
|
|
70
88
|
VoiceProvider,
|
|
@@ -299,8 +317,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
299
317
|
// The released context's send; settles once it went out (or never will).
|
|
300
318
|
#contextSend: { done: Promise<void>; drop: () => void } | null = null;
|
|
301
319
|
#delegations: Promise<void> = Promise.resolve();
|
|
302
|
-
// Delegations answered ok
|
|
320
|
+
// Delegations answered ok (or that streamed part of their answer): their
|
|
321
|
+
// spoken read-back is folded.
|
|
303
322
|
#answered = new Set<string>();
|
|
323
|
+
// Delegations whose current spoken phase already folded on `delegation_progress`.
|
|
324
|
+
#progressed = new Set<string>();
|
|
325
|
+
// The delegation whose read-back `#foldReadback` is waiting for.
|
|
326
|
+
#foldArmedBy: string | null = null;
|
|
304
327
|
#foldReadback = false;
|
|
305
328
|
#assistantTurns = new Set<string>();
|
|
306
329
|
#foldedTurns = new Set<string>();
|
|
@@ -473,7 +496,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
473
496
|
#voiceSocketUrl(host: string, agentId: string, attach?: URLSearchParams): string {
|
|
474
497
|
const capabilities = ["partial_transcript", "context"];
|
|
475
498
|
if (this.bridge && this.config?.clientDelegation !== false)
|
|
476
|
-
capabilities.push("client_delegation", "delegation_update", "delegation_read_back");
|
|
499
|
+
capabilities.push("client_delegation", "delegation_update", "delegation_read_back", "delegation_stream");
|
|
477
500
|
if (attach) capabilities.unshift("attach");
|
|
478
501
|
const params = new URLSearchParams({
|
|
479
502
|
voiceProtocol: VOICE_PROTOCOL,
|
|
@@ -849,6 +872,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
849
872
|
const tag = msg.delegationId;
|
|
850
873
|
if (typeof tag === "string" && this.#answered.has(tag) && !this.#assistantTurns.has(turnId)) {
|
|
851
874
|
this.#foldedTurns.add(turnId);
|
|
875
|
+
this.#foldReadback = false;
|
|
876
|
+
} else if (this.#foldReadback && !this.#assistantTurns.has(turnId)) {
|
|
877
|
+
// Armed by `delegation_progress`: the server may leave the
|
|
878
|
+
// read-back untagged (it continued a filler it cut), so the
|
|
879
|
+
// first new utterance after it is the read-back.
|
|
880
|
+
this.#foldedTurns.add(turnId);
|
|
881
|
+
this.#foldReadback = false;
|
|
852
882
|
}
|
|
853
883
|
} else if (this.#foldReadback && !this.#assistantTurns.has(turnId)) {
|
|
854
884
|
this.#foldedTurns.add(turnId);
|
|
@@ -891,8 +921,13 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
891
921
|
} else if (msg.speak !== false && this.#answered.has(delegationId)) {
|
|
892
922
|
// Every spoken phase (the approval ask, the late result) of an answer
|
|
893
923
|
// the chat shows is read back. A refusal of one that never started
|
|
894
|
-
// here has no chat answer: it renders. `final` may never come.
|
|
895
|
-
|
|
924
|
+
// here has no chat answer: it renders. `final` may never come. A
|
|
925
|
+
// phase whose first `delegation_progress` already armed the fold
|
|
926
|
+
// ends here without arming it again. With `delegation_read_back`
|
|
927
|
+
// the tag marks the read-back, so completion arms nothing (only
|
|
928
|
+
// progress does, for a read-back the server left untagged).
|
|
929
|
+
if (!this.#progressed.delete(delegationId) && !this.#capabilities.has("delegation_read_back"))
|
|
930
|
+
this.#armFold(delegationId);
|
|
896
931
|
}
|
|
897
932
|
this.#delegating = msg.type === "delegation_started";
|
|
898
933
|
if (!this.#isSpeaking && !this.#isCancelling()) {
|
|
@@ -901,6 +936,23 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
901
936
|
break;
|
|
902
937
|
}
|
|
903
938
|
|
|
939
|
+
case "delegation_progress": {
|
|
940
|
+
// The server handed a spoken chunk of this delegation to the voice
|
|
941
|
+
// model: its read-back is starting. The first one of a phase folds it,
|
|
942
|
+
// as `delegation_completed{speak:true}` does; later ones (and that
|
|
943
|
+
// phase's completion) don't fold another utterance. Still delegating.
|
|
944
|
+
const delegationId = String(msg.delegationId);
|
|
945
|
+
if (
|
|
946
|
+
this.#answered.has(delegationId) &&
|
|
947
|
+
!this.#progressed.has(delegationId) &&
|
|
948
|
+
!this.#cancelledDelegations.has(delegationId)
|
|
949
|
+
) {
|
|
950
|
+
this.#progressed.add(delegationId);
|
|
951
|
+
this.#armFold(delegationId);
|
|
952
|
+
}
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
|
|
904
956
|
case "delegation_cancelled":
|
|
905
957
|
// The server gave up on it: no more frames for it, and its approval
|
|
906
958
|
// timers stop. The chat turn still renders. Past its deadline (or for
|
|
@@ -1021,9 +1073,19 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
1021
1073
|
if (timedOut) send.drop();
|
|
1022
1074
|
}
|
|
1023
1075
|
|
|
1076
|
+
/** The next new assistant utterance is this delegation's read-back: fold it. */
|
|
1077
|
+
#armFold(delegationId: string): void {
|
|
1078
|
+
this.#foldReadback = true;
|
|
1079
|
+
this.#foldArmedBy = delegationId;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1024
1082
|
/** No more frames for this delegation; its chat answer goes back to browser TTS. */
|
|
1025
1083
|
#dropDelegation(delegationId: string, expired = false): void {
|
|
1026
1084
|
this.#cancelledDelegations.add(delegationId);
|
|
1085
|
+
// A fold it armed whose read-back never started would hide the next
|
|
1086
|
+
// unrelated utterance: disarm it.
|
|
1087
|
+
if (this.#foldArmedBy === delegationId) this.#foldReadback = false;
|
|
1088
|
+
this.#progressed.delete(delegationId);
|
|
1027
1089
|
this.bridge?.dropDelegation?.(delegationId, expired);
|
|
1028
1090
|
this.#followUps.get(delegationId)?.abort();
|
|
1029
1091
|
}
|
|
@@ -1032,28 +1094,46 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
1032
1094
|
#runDelegation(delegationId: string, request: VoiceDelegationRequest, generation: number): void {
|
|
1033
1095
|
const bridge = this.bridge!;
|
|
1034
1096
|
/** Send a frame for this delegation, unless the call or the delegation ended. */
|
|
1035
|
-
const send = (type
|
|
1097
|
+
const send = ({ type, ...fields }: Record<string, unknown> & { type: string }) => {
|
|
1036
1098
|
const ws = this.#ws;
|
|
1037
1099
|
if (generation !== this.#callGeneration || ws?.readyState !== WebSocket.OPEN) return false;
|
|
1038
1100
|
if (this.#cancelledDelegations.has(delegationId)) return false;
|
|
1039
|
-
ws.send(JSON.stringify({ type, delegationId,
|
|
1101
|
+
ws.send(JSON.stringify({ type, delegationId, ...fields }));
|
|
1040
1102
|
return true;
|
|
1041
1103
|
};
|
|
1104
|
+
const answer = (type: "delegation_update" | "delegation_result", status: string, text: string, streamedChars = 0) =>
|
|
1105
|
+
send({ type, status, text, ...(streamedChars > 0 && { streamedChars }) });
|
|
1042
1106
|
this.#delegations = this.#delegations.then(async () => {
|
|
1043
1107
|
// Cancelled while queued behind another turn: never start it.
|
|
1044
1108
|
if (generation !== this.#callGeneration || this.#cancelledDelegations.has(delegationId)) return;
|
|
1109
|
+
// Streaming (`delegation_stream`): the answer goes out as
|
|
1110
|
+
// `delegation_delta` pieces while the chat renders it, after the call
|
|
1111
|
+
// context, and all of them before the result.
|
|
1112
|
+
let deltas: Promise<void> | null = null;
|
|
1113
|
+
// Only with read-back tags: a streamed answer can be spoken as several
|
|
1114
|
+
// utterances, and the tags are what keep all of them folded.
|
|
1115
|
+
const streaming = this.#capabilities.has("delegation_stream") && this.#capabilities.has("delegation_read_back");
|
|
1116
|
+
const stream = (text: string) => {
|
|
1117
|
+
deltas = (deltas ?? this.#awaitContextSend()).then(() => {
|
|
1118
|
+
// The chat shows what was streamed: its read-back is folded.
|
|
1119
|
+
if (send({ type: "delegation_delta", text })) this.#answered.add(delegationId);
|
|
1120
|
+
});
|
|
1121
|
+
};
|
|
1045
1122
|
const result: VoiceDelegationResult = await bridge
|
|
1046
|
-
.runDelegatedTurn(request)
|
|
1123
|
+
.runDelegatedTurn(streaming ? { ...request, stream } : request)
|
|
1047
1124
|
.catch(() => ({ status: "failed", text: "" }));
|
|
1048
1125
|
await this.#awaitContextSend();
|
|
1126
|
+
await deltas;
|
|
1127
|
+
const streamedChars = streaming ? (result.streamedChars ?? 0) : 0;
|
|
1049
1128
|
// Parked on an approval: ask now (non-terminal), answer once the visitor
|
|
1050
1129
|
// decides. A server that can't take an update gets the ask as the result.
|
|
1051
1130
|
const parked = !!result.followUp;
|
|
1052
1131
|
const update = parked && this.#capabilities.has("delegation_update");
|
|
1053
|
-
const sent =
|
|
1132
|
+
const sent = answer(
|
|
1054
1133
|
update ? "delegation_update" : "delegation_result",
|
|
1055
1134
|
parked && !update ? "completed" : result.status,
|
|
1056
1135
|
result.text,
|
|
1136
|
+
streamedChars,
|
|
1057
1137
|
);
|
|
1058
1138
|
if (!sent) return;
|
|
1059
1139
|
if (result.status !== "failed" && result.inChat !== false) this.#answered.add(delegationId);
|
|
@@ -1068,12 +1148,12 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
1068
1148
|
approvalTimeoutMs: this.config?.approvalTimeoutMs && Math.min(this.config.approvalTimeoutMs, APPROVAL_TIMEOUT_MAX_MS),
|
|
1069
1149
|
readBack: update,
|
|
1070
1150
|
// Another gated tool in the same turn: another (non-terminal) update.
|
|
1071
|
-
onUpdate: (text) =>
|
|
1151
|
+
onUpdate: (text) => answer("delegation_update", "pending_approval", text),
|
|
1072
1152
|
})
|
|
1073
1153
|
.then((followUp) => {
|
|
1074
1154
|
this.#followUps.delete(delegationId);
|
|
1075
1155
|
// Hang-up and cancellation send nothing.
|
|
1076
|
-
if (update && followUp && !abort.signal.aborted)
|
|
1156
|
+
if (update && followUp && !abort.signal.aborted) answer("delegation_result", followUp.status, followUp.text);
|
|
1077
1157
|
});
|
|
1078
1158
|
});
|
|
1079
1159
|
}
|
|
@@ -1170,6 +1250,8 @@ export class RuntypeVoiceProvider implements VoiceProvider {
|
|
|
1170
1250
|
this.#contextSend = null;
|
|
1171
1251
|
this.#delegations = Promise.resolve();
|
|
1172
1252
|
this.#answered.clear();
|
|
1253
|
+
this.#progressed.clear();
|
|
1254
|
+
this.#foldArmedBy = null;
|
|
1173
1255
|
this.#foldReadback = false;
|
|
1174
1256
|
this.#assistantTurns.clear();
|
|
1175
1257
|
this.#foldedTurns.clear();
|