@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.
Files changed (145) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/wipe.d.cts +1 -1
  4. package/dist/animations/wipe.d.ts +1 -1
  5. package/dist/approval-ui.cjs +3 -3
  6. package/dist/approval-ui.d.cts +1 -1
  7. package/dist/approval-ui.d.ts +1 -1
  8. package/dist/approval-ui.js +3 -3
  9. package/dist/artifacts-ui.d.cts +2 -2
  10. package/dist/artifacts-ui.d.ts +2 -2
  11. package/dist/chunk-B1-_pp-Y.js +4 -0
  12. package/dist/{chunk-DrYAfpvk.js → chunk-BcLIgi1h.js} +1 -1
  13. package/dist/chunk-BkW8s3fj.js +2 -0
  14. package/dist/chunk-Bm2-bemv.js +1 -0
  15. package/dist/chunk-BvhTpG6k.js +1 -0
  16. package/dist/chunk-Csks3Qw1.js +1 -0
  17. package/dist/chunk-D5B629Mj.js +1 -0
  18. package/dist/chunk-DGlFKA0r.js +9 -0
  19. package/dist/chunk-DO9_WSpy.js +1 -0
  20. package/dist/chunk-DTDHeUsG.js +1 -0
  21. package/dist/chunk-DjFpGFGA.js +1 -0
  22. package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
  23. package/dist/chunk-lA5CJCgK.js +1 -0
  24. package/dist/client-history.js +1 -1
  25. package/dist/client-stream.js +1 -1
  26. package/dist/codegen.cjs +1 -1
  27. package/dist/codegen.js +1 -1
  28. package/dist/{composer-input-C39npJaz.d.cts → composer-input-B0P4OtNi.d.cts} +1 -1
  29. package/dist/{composer-input-C39npJaz.d.ts → composer-input-B0P4OtNi.d.ts} +1 -1
  30. package/dist/{context-mention-manager-DLCB1l9E.d.cts → context-mention-manager-DmLI_RM6.d.cts} +1 -1
  31. package/dist/{context-mention-manager-DLCB1l9E.d.ts → context-mention-manager-DmLI_RM6.d.ts} +1 -1
  32. package/dist/context-mentions-inline.d.cts +2 -2
  33. package/dist/context-mentions-inline.d.ts +2 -2
  34. package/dist/context-mentions.d.cts +3 -3
  35. package/dist/context-mentions.d.ts +3 -3
  36. package/dist/{defaults-DiAgWHXy.d.cts → defaults-DVo1wbZs.d.cts} +1 -1
  37. package/dist/{defaults-DiAgWHXy.d.ts → defaults-DVo1wbZs.d.ts} +1 -1
  38. package/dist/event-stream-view.d.cts +1 -1
  39. package/dist/event-stream-view.d.ts +1 -1
  40. package/dist/{fallback-speech-engine-C5_PUZjH.d.cts → fallback-speech-engine-BMnlmKnY.d.cts} +1 -1
  41. package/dist/{fallback-speech-engine-C5_PUZjH.d.ts → fallback-speech-engine-BMnlmKnY.d.ts} +1 -1
  42. package/dist/forms-ui.d.cts +2 -2
  43. package/dist/forms-ui.d.ts +2 -2
  44. package/dist/history-view.d.cts +1 -1
  45. package/dist/history-view.d.ts +1 -1
  46. package/dist/index.cjs +50 -49
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.cts +7 -7
  49. package/dist/index.d.ts +7 -7
  50. package/dist/index.global.js +2 -3
  51. package/dist/index.global.js.map +1 -1
  52. package/dist/index.js +38 -37
  53. package/dist/index.js.map +1 -1
  54. package/dist/{markdown-parsers-loader-BBdyIEU7.d.cts → markdown-parsers-loader-CeovcyYf.d.cts} +1 -1
  55. package/dist/{markdown-parsers-loader-BBdyIEU7.d.ts → markdown-parsers-loader-CeovcyYf.d.ts} +1 -1
  56. package/dist/{preview-Chjb0bmI.d.cts → preview-Bkb4UrQC.d.cts} +2 -2
  57. package/dist/{preview-Chjb0bmI.d.ts → preview-Bkb4UrQC.d.ts} +2 -2
  58. package/dist/session-actions.js +1 -1
  59. package/dist/{session-a_qIp4xp.d.ts → session-iLyKipoM.d.cts} +28 -10
  60. package/dist/{session-a_qIp4xp.d.cts → session-iLyKipoM.d.ts} +28 -10
  61. package/dist/session-reconnect.cjs +1 -1
  62. package/dist/session-reconnect.d.cts +8 -2
  63. package/dist/session-reconnect.d.ts +8 -2
  64. package/dist/session-reconnect.js +1 -1
  65. package/dist/smart-dom-reader.d.cts +1 -1
  66. package/dist/smart-dom-reader.d.ts +1 -1
  67. package/dist/theme-editor-preview.cjs +61 -60
  68. package/dist/theme-editor-preview.d.cts +1 -1
  69. package/dist/theme-editor-preview.d.ts +1 -1
  70. package/dist/theme-editor-preview.js +40 -40
  71. package/dist/theme-editor.d.cts +2 -2
  72. package/dist/theme-editor.d.ts +2 -2
  73. package/dist/theme-reference.d.cts +2 -2
  74. package/dist/theme-reference.d.ts +2 -2
  75. package/dist/{types-BdO1jNRm.d.cts → types-BuDirJkQ.d.cts} +200 -23
  76. package/dist/{types-BdO1jNRm.d.ts → types-BuDirJkQ.d.ts} +200 -23
  77. package/dist/voice-client-tools.cjs +1 -0
  78. package/dist/voice-client-tools.d.cts +13 -0
  79. package/dist/voice-client-tools.d.ts +13 -0
  80. package/dist/voice-client-tools.js +1 -0
  81. package/dist/voice-runtime.cjs +3 -3
  82. package/dist/voice-runtime.d.cts +15 -5
  83. package/dist/voice-runtime.d.ts +15 -5
  84. package/dist/voice-runtime.js +5 -5
  85. package/dist/voice-worklet-player.d.cts +2 -2
  86. package/dist/voice-worklet-player.d.ts +2 -2
  87. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.ts → webmcp-runtime-entry-CXLjRt1k.d.cts} +26 -5
  88. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.cts → webmcp-runtime-entry-CXLjRt1k.d.ts} +26 -5
  89. package/dist/webmcp-runtime.cjs +2 -1
  90. package/dist/webmcp-runtime.d.cts +1 -1
  91. package/dist/webmcp-runtime.d.ts +1 -1
  92. package/dist/webmcp-runtime.js +2 -1
  93. package/dist/widget.css +1 -1
  94. package/package.json +6 -1
  95. package/src/client-approve.test.ts +17 -1
  96. package/src/client-resume.ts +15 -3
  97. package/src/client.interrupt.test.ts +21 -0
  98. package/src/client.ts +61 -4
  99. package/src/client.voice-tools.test.ts +65 -0
  100. package/src/components/activity-row.ts +3 -2
  101. package/src/components/approval-actions.test.ts +8 -0
  102. package/src/components/approval-actions.ts +6 -2
  103. package/src/components/approval-bubble.test.ts +14 -0
  104. package/src/components/approval-bubble.ts +8 -1
  105. package/src/components/tool-bubble.ts +8 -5
  106. package/src/generated/runtype-openapi-contract.ts +39 -1
  107. package/src/index-core.ts +11 -0
  108. package/src/index-global.ts +6 -0
  109. package/src/resume-dedupe-local-only.test.ts +273 -0
  110. package/src/runtime/persist-state.test.ts +86 -0
  111. package/src/session-actions.ts +20 -6
  112. package/src/session-reconnect.ts +18 -1
  113. package/src/session.restored-browser-tools.test.ts +272 -0
  114. package/src/session.test.ts +26 -0
  115. package/src/session.ts +185 -31
  116. package/src/session.voice.test.ts +212 -0
  117. package/src/styles/widget.css +4 -2
  118. package/src/types.ts +178 -22
  119. package/src/ui.restored-browser-tools.test.ts +128 -0
  120. package/src/ui.ts +32 -18
  121. package/src/utils/formatting.test.ts +9 -1
  122. package/src/utils/formatting.ts +4 -0
  123. package/src/utils/local-only-message.ts +15 -0
  124. package/src/utils/restored-browser-state.ts +50 -0
  125. package/src/utils/storage.ts +12 -1
  126. package/src/utils/voice-prior-tool-results.ts +37 -0
  127. package/src/voice/runtype-voice-provider.ts +94 -7
  128. package/src/voice/session-voice-wiring.ts +27 -3
  129. package/src/voice/voice-client-tools-session.ts +116 -0
  130. package/src/voice/voice-delegation.ts +19 -3
  131. package/src/voice/voice.test.ts +396 -0
  132. package/src/voice-client-tools-bundle.test.ts +37 -0
  133. package/src/voice-client-tools-loader.ts +24 -0
  134. package/src/voice-client-tools.ts +9 -0
  135. package/src/webmcp-bridge.test.ts +152 -1
  136. package/src/webmcp-bridge.ts +5 -6
  137. package/src/webmcp-polyfill.integration.test.ts +5 -1
  138. package/src/webmcp-runtime-entry.ts +151 -26
  139. package/dist/chunk-B6zZsT6F.js +0 -1
  140. package/dist/chunk-CztSjKHK.js +0 -9
  141. package/dist/chunk-DbhxjyOB.js +0 -4
  142. package/dist/chunk-DsdqLStH.js +0 -1
  143. package/dist/chunk-GIqbL2Eg.js +0 -1
  144. package/dist/chunk-HUOUif-P.js +0 -1
  145. package/dist/chunk-UlcxdHef.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.28.2",
3
+ "version": "4.29.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",
@@ -117,6 +117,11 @@
117
117
  "import": "./dist/voice-runtime.js",
118
118
  "require": "./dist/voice-runtime.cjs"
119
119
  },
120
+ "./voice-client-tools": {
121
+ "types": "./dist/voice-client-tools.d.ts",
122
+ "import": "./dist/voice-client-tools.js",
123
+ "require": "./dist/voice-client-tools.cjs"
124
+ },
120
125
  "./forms-ui": {
121
126
  "types": "./dist/forms-ui.d.ts",
122
127
  "import": "./dist/forms-ui.js",
@@ -176,6 +176,22 @@ describe("AgentWidgetSession.resolveApproval in client-token mode", () => {
176
176
  expect(errors).toEqual([]);
177
177
  });
178
178
 
179
+ it("sends the continuation bubble's id so the stored reply dedupes by id (core#9961)", async () => {
180
+ const calls = mockFetch(
181
+ sse([
182
+ { type: "text_start", id: "t1" },
183
+ { type: "text_delta", id: "t1", delta: "Order placed." },
184
+ { type: "text_complete", id: "t1" },
185
+ { type: "execution_complete", kind: "agent", success: true },
186
+ ])
187
+ );
188
+ const { approve, last } = setup();
189
+ await approve();
190
+
191
+ expect(calls[0].body.assistantMessageId).toMatch(/^ast_/);
192
+ expect(last()?.id).toBe(calls[0].body.assistantMessageId);
193
+ });
194
+
179
195
  it.each([
180
196
  [
181
197
  403,
@@ -210,7 +226,7 @@ describe("AgentWidgetSession.resolveApproval in client-token mode", () => {
210
226
  await approve();
211
227
 
212
228
  expect(bubble()?.approval?.status).toBe(badge);
213
- expect(last()).toMatchObject({ id: "approval-error-appr_1", content: text });
229
+ expect(last()).toMatchObject({ id: "approval-error-appr_1", content: text, localOnly: true });
214
230
  expect(session.isStreaming()).toBe(false);
215
231
  expect(errors.map((e) => e.message)).toEqual([text]);
216
232
  // A refused request settles too: its token must not outlive it.
@@ -41,7 +41,8 @@ export type ClientResumeHost = {
41
41
  export async function resolveApproval(
42
42
  h: ClientResumeHost,
43
43
  approval: { agentId: string; executionId: string; approvalId: string },
44
- decision: "approved" | "denied"
44
+ decision: "approved" | "denied",
45
+ options?: { assistantMessageId?: string }
45
46
  ): Promise<Response> {
46
47
  let headers: Record<string, string> = {
47
48
  'Content-Type': 'application/json',
@@ -68,7 +69,10 @@ export async function resolveApproval(
68
69
 
69
70
  // Same re-validation as `resumeFlow`: an approval can sit for a long time.
70
71
  const { sessionId } = await h.initSession();
71
- const response = await post('/v1/client/approve', { sessionId });
72
+ const response = await post('/v1/client/approve', {
73
+ sessionId,
74
+ ...(options?.assistantMessageId ? { assistantMessageId: options.assistantMessageId } : {}),
75
+ });
72
76
  // A core without the route answers with its generic `Not Found`; an unknown
73
77
  // or expired pause 404s with its own error, which the caller surfaces.
74
78
  if (response.status === 404) {
@@ -83,7 +87,13 @@ export async function resumeFlow(
83
87
  h: ClientResumeHost,
84
88
  executionId: string,
85
89
  toolOutputs: Record<string, unknown>,
86
- options?: { streamResponse?: boolean; signal?: AbortSignal; after?: string }
90
+ options?: {
91
+ streamResponse?: boolean;
92
+ signal?: AbortSignal;
93
+ after?: string;
94
+ /** Id of the local bubble the resumed stream renders into (client-token only). */
95
+ assistantMessageId?: string;
96
+ }
87
97
  ): Promise<Response> {
88
98
  const isClientToken = h.isClientTokenMode();
89
99
  const url = isClientToken
@@ -125,6 +135,8 @@ export async function resumeFlow(
125
135
  // Thread the (refreshed) sessionId through like `/v1/client/chat` does.
126
136
  if (resumeSessionId) {
127
137
  body.sessionId = resumeSessionId;
138
+ // WHY(runtypelabs/core#9961): the server stores the reply under this id, so a later re-send dedupes by id.
139
+ if (options?.assistantMessageId) body.assistantMessageId = options.assistantMessageId;
128
140
  }
129
141
 
130
142
  if (isClientToken && resumeSessionId) {
@@ -108,6 +108,27 @@ describe("client-token turn identity", () => {
108
108
  expect(bodies[1]).not.toHaveProperty("voice");
109
109
  });
110
110
 
111
+ it("a voice hand-back with prior tool results sends them under voice; an empty list sends none", async () => {
112
+ const bodies: Record<string, unknown>[] = [];
113
+ global.fetch = vi.fn(async (url: unknown, init: any) => {
114
+ if (String(url).endsWith("/v1/client/init")) return initResponse();
115
+ bodies.push(JSON.parse(init.body));
116
+ const stream = controlledStream();
117
+ stream.close();
118
+ return stream.response;
119
+ }) as unknown as typeof fetch;
120
+ const prior = [
121
+ { toolCallId: "vtc_1", name: "webmcp:jump_to_slide", arguments: { position: 5 }, result: { slideTitle: "Pricing" } },
122
+ ];
123
+
124
+ const client = clientTokenClient();
125
+ await client.dispatch({ messages, voiceSpoken: true, voicePriorToolResults: prior }, () => {});
126
+ await client.dispatch({ messages, voiceSpoken: true, voicePriorToolResults: [] }, () => {});
127
+
128
+ expect(bodies[0].voice).toEqual({ spoken: true, priorToolResults: prior });
129
+ expect(bodies[1].voice).toEqual({ spoken: true });
130
+ });
131
+
111
132
  it("drops SSE events belonging to a superseded turn", async () => {
112
133
  const first = controlledStream();
113
134
  const second = controlledStream();
package/src/client.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  AgentWidgetSSEEventResult as _AgentWidgetSSEEventResult,
15
15
  ClientSession,
16
16
  ClientChatRequest,
17
+ VoicePriorToolResult,
17
18
  ClientToolDefinition,
18
19
  ClientFeedbackRequest,
19
20
  ClientFeedbackType,
@@ -34,6 +35,7 @@ import {
34
35
  recordWebMcpToolDisplayTitles,
35
36
  } from "./webmcp-bridge";
36
37
  import { loadWebMcpRuntime } from "./webmcp-runtime-loader";
38
+ import { loadVoiceClientTools } from "./voice-client-tools-loader";
37
39
  import type { WebMcpBridge } from "./webmcp-runtime-entry";
38
40
  import { resolveTarget } from "./utils/target";
39
41
  import { generateTurnId } from "./utils/message-id";
@@ -54,6 +56,7 @@ import type { ClientResumeHost } from "./client-resume";
54
56
  /** History transcripts stay on the wire shape; `utils/history-messages.ts` maps them. */
55
57
  export type { HistoryWireMessage } from "./utils/history-messages";
56
58
  import { divergentDisplayProjection } from "./utils/history-messages";
59
+ import { isLocalOnlyMessage } from "./utils/local-only-message";
57
60
  // artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
58
61
  // artifact events are always processed here regardless of config.
59
62
 
@@ -80,6 +83,12 @@ type DispatchOptions = {
80
83
  * answer.
81
84
  */
82
85
  voiceSpoken?: boolean;
86
+ /**
87
+ * Client-token only: page tools the voice server already ran for this
88
+ * delegated turn, sent as `voice.priorToolResults` with `voiceSpoken`.
89
+ * Proxy/agent payloads carry them as replayed tool rows instead.
90
+ */
91
+ voicePriorToolResults?: VoicePriorToolResult[];
83
92
  };
84
93
 
85
94
  export type SSEHandler = (event: AgentWidgetEvent) => void;
@@ -152,6 +161,7 @@ const isHistoryClientError = (
152
161
  const hasValidContent = (message: AgentWidgetMessage): boolean => {
153
162
  // Display-only voice captions are never conversation.
154
163
  if (message.voiceCaption) return false;
164
+ if (isLocalOnlyMessage(message)) return false;
155
165
  // Check contentParts (multi-modal content)
156
166
  if (message.contentParts && message.contentParts.length > 0) {
157
167
  return true;
@@ -406,6 +416,42 @@ export class AgentWidgetClient {
406
416
  * session that it should fall back to the legacy local-tool resume path,
407
417
  * if any).
408
418
  */
419
+ /**
420
+ * WebMCP access for a Runtype voice call's fast tier: the same per-turn
421
+ * snapshot `clientTools[]` carries, registry change notifications, and the
422
+ * voice execute path. `null` when WebMCP is not enabled.
423
+ */
424
+ public getVoiceClientTools():import("./types").VoiceClientTools | null {
425
+ if (this.config.webmcp?.enabled !== true) return null;
426
+ const bridge = () => this.#getWebMcpBridge();
427
+ const surface: import("./types").VoiceClientTools = {
428
+ open: async (channel) => (await loadVoiceClientTools()).createVoiceClientToolsSession(surface, channel),
429
+ snapshot: async () => {
430
+ const tools = (await (await bridge())?.snapshotForDispatch()) ?? [];
431
+ return { tools, fingerprint: computeClientToolsFingerprint(tools) };
432
+ },
433
+ onChange: (listener) => {
434
+ let stop: (() => void) | null = null;
435
+ let stopped = false;
436
+ void bridge().then((b) => {
437
+ if (b && !stopped) stop = b.watchToolChanges(listener);
438
+ });
439
+ return () => {
440
+ stopped = true;
441
+ stop?.();
442
+ };
443
+ },
444
+ execute: async (name, args, options) => {
445
+ const b = await bridge();
446
+ if (!b) {
447
+ return { status: "failed", error: { message: "WebMCP runtime failed to load." } };
448
+ }
449
+ return b.executeVoiceToolCall(name, args, options);
450
+ },
451
+ };
452
+ return surface;
453
+ }
454
+
409
455
  public executeWebMcpToolCall(
410
456
  wireToolName: string,
411
457
  args: unknown,
@@ -1516,7 +1562,12 @@ export class AgentWidgetClient {
1516
1562
  // superseded run and this client can drop its stale events below.
1517
1563
  turnId,
1518
1564
  ...(options.interrupt && { submitMode: 'interrupt' as const }),
1519
- ...(options.voiceSpoken && { voice: { spoken: true as const } }),
1565
+ ...(options.voiceSpoken && {
1566
+ voice: {
1567
+ spoken: true as const,
1568
+ ...(options.voicePriorToolResults?.length && { priorToolResults: options.voicePriorToolResults }),
1569
+ },
1570
+ }),
1520
1571
  };
1521
1572
 
1522
1573
  // Diff-only / send-once WebMCP tool dispatch. `buildPayload()` already
@@ -1830,9 +1881,10 @@ export class AgentWidgetClient {
1830
1881
  */
1831
1882
  public async resolveApproval(
1832
1883
  approval: { agentId: string; executionId: string; approvalId: string },
1833
- decision: 'approved' | 'denied'
1884
+ decision: 'approved' | 'denied',
1885
+ options?: { assistantMessageId?: string }
1834
1886
  ): Promise<Response> {
1835
- return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision);
1887
+ return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision, options);
1836
1888
  }
1837
1889
 
1838
1890
  /**
@@ -1972,7 +2024,12 @@ export class AgentWidgetClient {
1972
2024
  public async resumeFlow(
1973
2025
  executionId: string,
1974
2026
  toolOutputs: Record<string, unknown>,
1975
- options?: { streamResponse?: boolean; signal?: AbortSignal; after?: string }
2027
+ options?: {
2028
+ streamResponse?: boolean;
2029
+ signal?: AbortSignal;
2030
+ after?: string;
2031
+ assistantMessageId?: string;
2032
+ }
1976
2033
  ): Promise<Response> {
1977
2034
  return (await loadClientStream()).resumeFlow(this.#resumeHost(), executionId, toolOutputs, options);
1978
2035
  }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { AgentWidgetClient } from "./client";
4
+ import type { ClientToolDefinition } from "./types";
5
+ import { computeClientToolsFingerprint } from "./webmcp-bridge";
6
+
7
+ const tool: ClientToolDefinition = {
8
+ name: "next_slide",
9
+ description: "Advance the presentation to the next slide.",
10
+ parametersSchema: { type: "object", properties: {} },
11
+ origin: "webmcp",
12
+ annotations: { readOnlyHint: true },
13
+ };
14
+
15
+ const withBridge = (bridge: Record<string, unknown>) => {
16
+ const client = new AgentWidgetClient({ apiUrl: "http://test", webmcp: { enabled: true } });
17
+ (client as unknown as { webMcpBridge: unknown }).webMcpBridge = bridge;
18
+ return client;
19
+ };
20
+
21
+ describe("AgentWidgetClient.getVoiceClientTools", () => {
22
+ it("is null when WebMCP is off", () => {
23
+ expect(new AgentWidgetClient({ apiUrl: "http://test" }).getVoiceClientTools()).toBeNull();
24
+ });
25
+
26
+ it("snapshots exactly the dispatch clientTools[] entries, with their fingerprint", async () => {
27
+ const client = withBridge({ snapshotForDispatch: async () => [tool] });
28
+ const snapshot = await client.getVoiceClientTools()!.snapshot();
29
+ expect(snapshot).toEqual({ tools: [tool], fingerprint: computeClientToolsFingerprint([tool]) });
30
+ });
31
+
32
+ it("routes execute and change subscriptions to the bridge's voice methods", async () => {
33
+ const stop = vi.fn();
34
+ const watchToolChanges = vi.fn(() => stop);
35
+ const executeVoiceToolCall = vi.fn(async () => ({ status: "completed", result: { slidePosition: 2 } }));
36
+ const surface = withBridge({ watchToolChanges, executeVoiceToolCall }).getVoiceClientTools()!;
37
+
38
+ const signal = new AbortController().signal;
39
+ const outcome = await surface.execute("webmcp:next_slide", {}, { signal, autoConfirmReadOnly: true });
40
+ expect(executeVoiceToolCall).toHaveBeenCalledWith("webmcp:next_slide", {}, { signal, autoConfirmReadOnly: true });
41
+ expect(outcome).toEqual({ status: "completed", result: { slidePosition: 2 } });
42
+
43
+ const listener = vi.fn();
44
+ const unsubscribe = surface.onChange(listener);
45
+ await Promise.resolve();
46
+ await Promise.resolve();
47
+ expect(watchToolChanges).toHaveBeenCalledWith(listener);
48
+ unsubscribe();
49
+ expect(stop).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it("opens a fast-tier session from the lazy chunk that sends the snapshot through the channel", async () => {
53
+ const client = new AgentWidgetClient({ apiUrl: "http://test", webmcp: { enabled: true } });
54
+ const slot = client as unknown as { webMcpBridge: unknown };
55
+ // Let the real lazy bridge adopt first, so it cannot replace the fake mid-test.
56
+ await vi.waitFor(() => expect(slot.webMcpBridge).not.toBeNull());
57
+ slot.webMcpBridge = { snapshotForDispatch: async () => [tool], watchToolChanges: () => () => undefined };
58
+ const surface = client.getVoiceClientTools()!;
59
+ const sent: Array<Record<string, unknown>> = [];
60
+ const session = await surface.open({ send: (frame) => (sent.push(frame), true), cancelled: () => false });
61
+ await vi.waitFor(() => expect(sent).toHaveLength(1));
62
+ expect(sent[0]).toMatchObject({ type: "client_tools", tools: [{ ...tool, name: "webmcp:next_slide" }] });
63
+ session.close();
64
+ });
65
+ });
@@ -41,13 +41,14 @@ export function applyActivityRow(bubble: HTMLElement, message: AgentWidgetMessag
41
41
  const data = kind === "tool" ? message.toolCall : message.reasoning;
42
42
  const active = data?.status !== "complete";
43
43
  const approval = message.approval?.status ?? message.toolCall?.approvalStatus;
44
- const state = approval === "denied" ? "denied" : approval === "pending" ? "awaiting-approval"
44
+ const state = message.toolCall?.interrupted ? "interrupted"
45
+ : approval === "denied" ? "denied" : approval === "pending" ? "awaiting-approval"
45
46
  : message.toolCall?.success === false ? "error" : !active ? "done"
46
47
  : data?.status === "pending" ? "pending" : "running";
47
48
  bubble.dataset.activityState = state;
48
49
  const icon = createElement("span", "persona-activity-icon");
49
50
  icon.dataset.activityIcon = state;
50
- const names = { pending: "circle", running: "loader-circle", done: "check", error: "x", denied: "x", "awaiting-approval": "clock" };
51
+ const names = { pending: "circle", running: "loader-circle", done: "check", error: "x", denied: "x", interrupted: "x", "awaiting-approval": "clock" };
51
52
  const customIcon = kind === "reasoning" ? config.features?.reasoningDisplay?.iconName : undefined;
52
53
  const glyph = customIcon ? renderLucideIcon(customIcon, 16, "currentColor", 2)
53
54
  : state === "pending" ? renderIconNode(Circle, 16, "currentColor", 2)
@@ -346,3 +346,11 @@ describe("built-in approval — resolved states", () => {
346
346
  expect(el?.textContent).toContain("timed out");
347
347
  });
348
348
  });
349
+
350
+ it("interrupted approvals explain expiry without action controls", () => {
351
+ const { el } = render({}, makeMessage({ status: "timeout", interrupted: true }));
352
+ expect(el?.textContent).toContain("approval expired");
353
+ expect(el?.textContent).toContain("chat restarted");
354
+ expect(el?.textContent).toContain("fresh proposal");
355
+ expect(el?.querySelector("button, [data-action]")).toBeNull();
356
+ });
@@ -18,7 +18,8 @@
18
18
  * tool call takes over the transcript); denied/timeout → a subtle one-line
19
19
  * trace.
20
20
  */
21
- import { createElement } from "../utils/dom";
21
+ import { createElement, createNode } from "../utils/dom";
22
+ import { BROWSER_TOOL_INTERRUPTION } from "../utils/restored-browser-state";
22
23
  // The webmcp-title lookup is injected by ui.ts at chunk adoption (see
23
24
  // approval-deps): this module ships in the lazy approval-ui chunk. Icons are
24
25
  // direct lucide data imports.
@@ -168,7 +169,10 @@ const buildResolvedTrace = (approval: Approval): HTMLElement => {
168
169
  if (icon) row.appendChild(icon);
169
170
  const name = createElement("span", "persona-approval-resolved-name");
170
171
  name.textContent = approval.toolName ? humanizeToolName(approval.toolName) : "Tool";
171
- row.append(name, document.createTextNode(approval.status === "timeout" ? " timed out" : " denied"));
172
+ const explanation = approval.status === "timeout" && approval.interrupted
173
+ ? ` approval expired. ${BROWSER_TOOL_INTERRUPTION}`
174
+ : approval.status === "timeout" ? " timed out" : " denied";
175
+ row.appendChild(createNode("span", { className: "persona-approval-resolved-text" }, name, explanation));
172
176
  return row;
173
177
  };
174
178
 
@@ -5,6 +5,7 @@ import {
5
5
  createApprovalBubble,
6
6
  humanizeToolName,
7
7
  updateApprovalDetailsUI,
8
+ updateApprovalBubbleUI,
8
9
  } from "./approval-bubble";
9
10
  import {
10
11
  getWebMcpToolDisplayTitle,
@@ -45,6 +46,19 @@ const makeMessage = (
45
46
  },
46
47
  });
47
48
 
49
+ it("shows interruption metadata while keeping the existing timeout status", () => {
50
+ const bubble = createApprovalBubble(makeMessage({ status: "timeout", interrupted: true }));
51
+ const badge = bubble.querySelector("[data-approval-status]");
52
+ expect(badge?.getAttribute("data-approval-status")).toBe("timeout");
53
+ expect(badge?.textContent).toBe("Interrupted");
54
+ expect(bubble.textContent).toContain("chat restarted");
55
+ expect(bubble.textContent).toContain("fresh proposal");
56
+ expect(bubble.querySelector('[data-action="allow"], [data-action="deny"]')).toBeNull();
57
+ updateApprovalBubbleUI("msg-1", bubble, {}, makeMessage({ status: "timeout", interrupted: true }).approval);
58
+ expect(badge?.textContent).toBe("Interrupted");
59
+ expect(badge?.getAttribute("data-approval-status")).toBe("timeout");
60
+ });
61
+
48
62
  describe("createApprovalBubble shadow", () => {
49
63
  it("falls back to the themeable --persona-approval-shadow variable when no config.shadow is set", () => {
50
64
  const bubble = createApprovalBubble(makeMessage());
@@ -1,3 +1,4 @@
1
+ import { BROWSER_TOOL_INTERRUPTION } from "../utils/restored-browser-state";
1
2
  import { createElement } from "../utils/dom";
2
3
  import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
3
4
  import { formatUnknownValue } from "../utils/formatting";
@@ -100,6 +101,7 @@ export const updateApprovalBubbleUI = (
100
101
  if (statusBadge) {
101
102
  statusBadge.textContent = approval.status === "approved" ? "Approved"
102
103
  : approval.status === "denied" ? "Denied"
104
+ : (approval.status === "timeout" && approval.interrupted) ? "Interrupted"
103
105
  : approval.status === "timeout" ? "Timeout"
104
106
  : "Pending";
105
107
 
@@ -232,12 +234,17 @@ export const createApprovalBubble = (
232
234
  } else if (approval.status === "timeout") {
233
235
  badge.style.backgroundColor = "var(--persona-palette-colors-warning-100)";
234
236
  badge.style.color = "var(--persona-palette-colors-warning-700)";
235
- badge.textContent = "Timeout";
237
+ badge.textContent = (approval.status === "timeout" && approval.interrupted) ? "Interrupted" : "Timeout";
236
238
  }
237
239
  titleRow.appendChild(badge);
238
240
  }
239
241
 
240
242
  content.appendChild(titleRow);
243
+ if (approval.status === "timeout" && approval.interrupted) {
244
+ const notice = createElement("p", "persona-text-sm");
245
+ notice.textContent = BROWSER_TOOL_INTERRUPTION;
246
+ content.appendChild(notice);
247
+ }
241
248
 
242
249
  // User-facing summary line. The wire `description` is the tool's
243
250
  // agent-facing description (prompt prose, usage rules), so it is not shown
@@ -78,7 +78,8 @@ const getToolSummaryText = (
78
78
  message,
79
79
  toolDisplayConfig?.previewMaxLines ?? DEFAULT_TOOL_CALL_DISPLAY.previewMaxLines
80
80
  );
81
- const defaultSummary = tool ? (activityVariant(config, "tool") === "row" ? `${tool.status === "complete" ? "Used" : "Using"} ${tool.name?.trim() || "tool"}` : describeToolTitle(tool)) : "";
81
+ const defaultSummary = tool?.interrupted ? `${tool.name?.trim() || "Tool"} · Interrupted`
82
+ : tool ? (activityVariant(config, "tool") === "row" ? `${tool.status === "complete" ? "Used" : "Using"} ${tool.name?.trim() || "tool"}` : describeToolTitle(tool)) : "";
82
83
 
83
84
  if (!tool) {
84
85
  return { summary: defaultSummary, previewText, isActive: false };
@@ -96,11 +97,13 @@ const getToolSummaryText = (
96
97
  // Apply text templates if configured
97
98
  if (isActive && toolCallConfig.activeTextTemplate) {
98
99
  summary = resolveToolHeaderText(tool, toolCallConfig.activeTextTemplate, summary);
99
- } else if (!isActive && toolCallConfig.completeTextTemplate) {
100
+ } else if (!isActive && !tool.interrupted && toolCallConfig.completeTextTemplate) {
100
101
  summary = resolveToolHeaderText(tool, toolCallConfig.completeTextTemplate, summary);
101
102
  }
102
103
 
103
- if (tool.success === false) summary += " · Failed";
104
+ if (tool.interrupted) {
105
+ if (!summary.includes(" · Interrupted")) summary += " · Interrupted";
106
+ } else if (tool.success === false) summary += " · Failed";
104
107
  else if (tool.superseded) summary += " · Approved, ran again below";
105
108
  return { summary, previewText, isActive };
106
109
  };
@@ -184,7 +187,7 @@ export const createToolBubble = (
184
187
  // Helper: build a <span data-tool-elapsed> that the global timer in ui.ts updates
185
188
  const createElapsedSpan = (): HTMLElement => {
186
189
  const span = createElement("span", "");
187
- span.setAttribute("data-tool-elapsed", startedAt);
190
+ if (isActive) span.setAttribute("data-tool-elapsed", startedAt);
188
191
  span.textContent = computeToolElapsed(tool);
189
192
  return span;
190
193
  };
@@ -213,7 +216,7 @@ export const createToolBubble = (
213
216
  // Apply loading animation when tool is active and no custom HTMLElement was provided
214
217
  const loadingAnimation = toolDisplayConfig.loadingAnimation ?? DEFAULT_TOOL_CALL_DISPLAY.loadingAnimation;
215
218
  const activeTemplate = toolCallConfig.activeTextTemplate;
216
- const completeTemplate = toolCallConfig.completeTextTemplate;
219
+ const completeTemplate = tool.interrupted ? undefined : toolCallConfig.completeTextTemplate;
217
220
  const currentTemplate = isActive ? activeTemplate : completeTemplate;
218
221
  const skipCustomElement = customSummary instanceof HTMLElement;
219
222
 
@@ -35,6 +35,7 @@ export type RuntypeExecutionStreamEvent = ({
35
35
  finalOutput?: string;
36
36
  iterations?: number;
37
37
  kind: "agent" | "flow";
38
+ outcome?: "completed" | "error" | "aborted";
38
39
  seq: number;
39
40
  stepErrorCount?: number;
40
41
  stopReason?: string;
@@ -60,6 +61,7 @@ export type RuntypeExecutionStreamEvent = ({
60
61
  executionId: string;
61
62
  finalOutput?: string;
62
63
  kind: "agent" | "flow";
64
+ outcome?: "completed" | "error" | "aborted";
63
65
  seq: number;
64
66
  stopReason?: string;
65
67
  type: "execution_error";
@@ -379,6 +381,15 @@ export type RuntypeExecutionStreamEvent = ({
379
381
  seq: number;
380
382
  type: "state_delta";
381
383
  }) | ({
384
+ annotations?: {
385
+ consequentialHint?: boolean;
386
+ debugging?: boolean;
387
+ destructiveHint?: boolean;
388
+ idempotentHint?: boolean;
389
+ openWorldHint?: boolean;
390
+ readOnlyHint?: boolean;
391
+ untrustedContentHint?: boolean;
392
+ };
382
393
  executionId: string;
383
394
  hiddenParameterNames?: Array<string>;
384
395
  iteration?: number;
@@ -477,6 +488,15 @@ export type RuntypeExecutionStreamEvent = ({
477
488
  seq: number;
478
489
  type: "approval_complete";
479
490
  }) | ({
491
+ annotations?: {
492
+ consequentialHint?: boolean;
493
+ debugging?: boolean;
494
+ destructiveHint?: boolean;
495
+ idempotentHint?: boolean;
496
+ openWorldHint?: boolean;
497
+ readOnlyHint?: boolean;
498
+ untrustedContentHint?: boolean;
499
+ };
480
500
  awaitReason?: string;
481
501
  awaitedAt?: string;
482
502
  crawlId?: string;
@@ -641,6 +661,15 @@ export type RuntypeClientInitResponse = {
641
661
  export type RuntypeClientChatRequest = {
642
662
  assistantMessageId?: string;
643
663
  clientTools?: Array<{
664
+ annotations?: {
665
+ consequentialHint?: boolean;
666
+ debugging?: boolean;
667
+ destructiveHint?: boolean;
668
+ idempotentHint?: boolean;
669
+ openWorldHint?: boolean;
670
+ readOnlyHint?: boolean;
671
+ untrustedContentHint?: boolean;
672
+ };
644
673
  description: string;
645
674
  name: string;
646
675
  origin?: "webmcp" | "sdk";
@@ -681,7 +710,7 @@ export type RuntypeClientChatRequest = {
681
710
  }>;
682
711
  metadata?: Record<string, unknown>;
683
712
  sessionId: string;
684
- submitMode?: "normal" | "interrupt" | "steer";
713
+ submitMode?: "normal" | "interrupt" | "steer" | "auto";
685
714
  turnId?: string;
686
715
  voice?: {
687
716
  spoken?: boolean;
@@ -694,6 +723,15 @@ export type RuntypeClientResumeRequest = {
694
723
  after?: string;
695
724
  assistantMessageId?: string;
696
725
  clientTools?: Array<{
726
+ annotations?: {
727
+ consequentialHint?: boolean;
728
+ debugging?: boolean;
729
+ destructiveHint?: boolean;
730
+ idempotentHint?: boolean;
731
+ openWorldHint?: boolean;
732
+ readOnlyHint?: boolean;
733
+ untrustedContentHint?: boolean;
734
+ };
697
735
  description: string;
698
736
  name: string;
699
737
  origin?: "webmcp" | "sdk";
package/src/index-core.ts CHANGED
@@ -174,6 +174,7 @@ export type {
174
174
  // WebMCP: page-discovered tool consumption
175
175
  AgentWidgetWebMcpConfig,
176
176
  ClientToolDefinition,
177
+ ToolAnnotations,
177
178
  WebMcpConfirmHandler,
178
179
  WebMcpConfirmInfo,
179
180
  WebMcpToolResult,
@@ -659,6 +660,16 @@ export type {
659
660
  VoiceDelegationStatus,
660
661
  VoiceDelegationFollowUp,
661
662
  VoiceSessionBridge,
663
+ VoiceClientTools,
664
+ VoiceClientToolsSnapshot,
665
+ VoiceClientToolsChannel,
666
+ VoiceClientToolsSession,
667
+ VoiceClientToolOutcome,
668
+ VoiceClientToolStatus,
669
+ VoiceDirectTurn,
670
+ ClientToolsFrame,
671
+ ClientToolCallFrame,
672
+ ClientToolResultFrame,
662
673
  PcmStreamPlayer,
663
674
  AgentWidgetVoiceStatusEvent,
664
675
  ReadAloudState,
@@ -221,6 +221,12 @@ import { setVoiceRuntimeLoader } from "./voice-runtime-loader";
221
221
 
222
222
  setVoiceRuntimeLoader(siblingChunk("voice-runtime.js"));
223
223
 
224
+ // The voice fast tier (`client_tools`), fetched by client.ts only when a voice
225
+ // call negotiates it. Same sibling-URL scheme.
226
+ import { setVoiceClientToolsLoader } from "./voice-client-tools-loader";
227
+
228
+ setVoiceClientToolsLoader(siblingChunk("voice-client-tools.js"));
229
+
224
230
  // ---------------------------------------------------------------------------
225
231
  // Deferred artifacts-ui loading.
226
232
  //