@runtypelabs/persona 4.29.0 → 4.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) 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-B2HCRERn.js +9 -0
  13. package/dist/chunk-Bm2-bemv.js +1 -0
  14. package/dist/chunk-BvhTpG6k.js +1 -0
  15. package/dist/{chunk-DjOvgFsn.js → chunk-Car1mORT.js} +1 -1
  16. package/dist/chunk-D5B629Mj.js +1 -0
  17. package/dist/chunk-DO9_WSpy.js +1 -0
  18. package/dist/chunk-DTDHeUsG.js +1 -0
  19. package/dist/chunk-DjFpGFGA.js +1 -0
  20. package/dist/chunk-DrpuvY5W.js +1 -0
  21. package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
  22. package/dist/client-history.js +1 -1
  23. package/dist/client-stream.js +1 -1
  24. package/dist/codegen.cjs +1 -1
  25. package/dist/codegen.js +1 -1
  26. package/dist/{composer-input-D3sB8_P_.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
  27. package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
  28. package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
  29. package/dist/{context-mention-manager-D2qxE5op.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
  30. package/dist/context-mentions-inline.d.cts +2 -2
  31. package/dist/context-mentions-inline.d.ts +2 -2
  32. package/dist/context-mentions.d.cts +3 -3
  33. package/dist/context-mentions.d.ts +3 -3
  34. package/dist/{defaults-Dsw66XqE.d.cts → defaults-DN-L67ak.d.cts} +1 -1
  35. package/dist/{defaults-Dsw66XqE.d.ts → defaults-DN-L67ak.d.ts} +1 -1
  36. package/dist/event-stream-view.d.cts +1 -1
  37. package/dist/event-stream-view.d.ts +1 -1
  38. package/dist/{fallback-speech-engine-DJpJcWAk.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
  39. package/dist/{fallback-speech-engine-DJpJcWAk.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
  40. package/dist/forms-ui.d.cts +2 -2
  41. package/dist/forms-ui.d.ts +2 -2
  42. package/dist/history-view.d.cts +1 -1
  43. package/dist/history-view.d.ts +1 -1
  44. package/dist/index.cjs +56 -56
  45. package/dist/index.cjs.map +1 -1
  46. package/dist/index.d.cts +6 -6
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.global.js +2 -3
  49. package/dist/index.global.js.map +1 -1
  50. package/dist/index.js +42 -42
  51. package/dist/index.js.map +1 -1
  52. package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
  53. package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
  54. package/dist/{preview-DKKpfTo7.d.cts → preview-m9QlqsZi.d.cts} +2 -2
  55. package/dist/{preview-DKKpfTo7.d.ts → preview-m9QlqsZi.d.ts} +2 -2
  56. package/dist/{session-DleMr9dZ.d.ts → session-BtvvmWnw.d.cts} +21 -9
  57. package/dist/{session-DleMr9dZ.d.cts → session-BtvvmWnw.d.ts} +21 -9
  58. package/dist/session-actions.js +1 -1
  59. package/dist/session-reconnect.cjs +1 -1
  60. package/dist/session-reconnect.d.cts +8 -2
  61. package/dist/session-reconnect.d.ts +8 -2
  62. package/dist/session-reconnect.js +1 -1
  63. package/dist/smart-dom-reader.d.cts +1 -1
  64. package/dist/smart-dom-reader.d.ts +1 -1
  65. package/dist/theme-editor-preview.cjs +62 -62
  66. package/dist/theme-editor-preview.d.cts +1 -1
  67. package/dist/theme-editor-preview.d.ts +1 -1
  68. package/dist/theme-editor-preview.js +41 -41
  69. package/dist/theme-editor.d.cts +2 -2
  70. package/dist/theme-editor.d.ts +2 -2
  71. package/dist/theme-reference.d.cts +2 -2
  72. package/dist/theme-reference.d.ts +2 -2
  73. package/dist/{types-oAZ1VReS.d.cts → types-DL-oBHE1.d.cts} +41 -3
  74. package/dist/{types-oAZ1VReS.d.ts → types-DL-oBHE1.d.ts} +41 -3
  75. package/dist/voice-client-tools.d.cts +1 -1
  76. package/dist/voice-client-tools.d.ts +1 -1
  77. package/dist/voice-runtime.cjs +3 -3
  78. package/dist/voice-runtime.d.cts +10 -4
  79. package/dist/voice-runtime.d.ts +10 -4
  80. package/dist/voice-runtime.js +5 -5
  81. package/dist/voice-worklet-player.d.cts +2 -2
  82. package/dist/voice-worklet-player.d.ts +2 -2
  83. package/dist/{webmcp-runtime-entry-Deg0NfmB.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
  84. package/dist/{webmcp-runtime-entry-Deg0NfmB.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
  85. package/dist/webmcp-runtime.d.cts +1 -1
  86. package/dist/webmcp-runtime.d.ts +1 -1
  87. package/dist/widget.css +1 -1
  88. package/package.json +1 -1
  89. package/src/client-approve.test.ts +17 -1
  90. package/src/client-resume.ts +15 -3
  91. package/src/client.interrupt.test.ts +21 -0
  92. package/src/client.ts +24 -4
  93. package/src/components/activity-row.ts +3 -2
  94. package/src/components/approval-actions.test.ts +8 -0
  95. package/src/components/approval-actions.ts +6 -2
  96. package/src/components/approval-bubble.test.ts +14 -0
  97. package/src/components/approval-bubble.ts +8 -1
  98. package/src/components/tool-bubble.ts +8 -5
  99. package/src/generated/runtype-openapi-contract.ts +7 -1
  100. package/src/resume-dedupe-local-only.test.ts +273 -0
  101. package/src/runtime/persist-state.test.ts +86 -0
  102. package/src/session-actions.ts +20 -6
  103. package/src/session-reconnect.ts +18 -1
  104. package/src/session.restored-browser-tools.test.ts +272 -0
  105. package/src/session.test.ts +23 -0
  106. package/src/session.ts +211 -46
  107. package/src/session.voice.test.ts +169 -0
  108. package/src/styles/widget.css +4 -2
  109. package/src/types.ts +37 -2
  110. package/src/ui.restored-browser-tools.test.ts +128 -0
  111. package/src/ui.scroll.test.ts +7 -2
  112. package/src/ui.stream-replay.test.ts +86 -0
  113. package/src/ui.streaming-coalescing.test.ts +183 -0
  114. package/src/ui.ts +152 -124
  115. package/src/ui.typing-indicator.test.ts +21 -0
  116. package/src/utils/formatting.test.ts +9 -1
  117. package/src/utils/formatting.ts +4 -0
  118. package/src/utils/local-only-message.ts +15 -0
  119. package/src/utils/message-fingerprint.test.ts +9 -0
  120. package/src/utils/message-fingerprint.ts +18 -5
  121. package/src/utils/morph.ts +10 -0
  122. package/src/utils/restored-browser-state.ts +50 -0
  123. package/src/utils/storage.ts +12 -1
  124. package/src/utils/voice-prior-tool-results.ts +37 -0
  125. package/src/voice/runtype-voice-provider.ts +13 -8
  126. package/src/voice/session-voice-wiring.ts +1 -2
  127. package/src/voice/voice-delegation.ts +10 -3
  128. package/src/voice/voice.test.ts +38 -2
  129. package/dist/chunk-B6zZsT6F.js +0 -1
  130. package/dist/chunk-CIVsNcrk.js +0 -1
  131. package/dist/chunk-CKdlNyzM.js +0 -9
  132. package/dist/chunk-DbhxjyOB.js +0 -4
  133. package/dist/chunk-DsdqLStH.js +0 -1
  134. package/dist/chunk-GIqbL2Eg.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.29.0",
3
+ "version": "4.29.2",
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",
@@ -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,
@@ -55,6 +56,7 @@ import type { ClientResumeHost } from "./client-resume";
55
56
  /** History transcripts stay on the wire shape; `utils/history-messages.ts` maps them. */
56
57
  export type { HistoryWireMessage } from "./utils/history-messages";
57
58
  import { divergentDisplayProjection } from "./utils/history-messages";
59
+ import { isLocalOnlyMessage } from "./utils/local-only-message";
58
60
  // artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
59
61
  // artifact events are always processed here regardless of config.
60
62
 
@@ -81,6 +83,12 @@ type DispatchOptions = {
81
83
  * answer.
82
84
  */
83
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[];
84
92
  };
85
93
 
86
94
  export type SSEHandler = (event: AgentWidgetEvent) => void;
@@ -153,6 +161,7 @@ const isHistoryClientError = (
153
161
  const hasValidContent = (message: AgentWidgetMessage): boolean => {
154
162
  // Display-only voice captions are never conversation.
155
163
  if (message.voiceCaption) return false;
164
+ if (isLocalOnlyMessage(message)) return false;
156
165
  // Check contentParts (multi-modal content)
157
166
  if (message.contentParts && message.contentParts.length > 0) {
158
167
  return true;
@@ -1553,7 +1562,12 @@ export class AgentWidgetClient {
1553
1562
  // superseded run and this client can drop its stale events below.
1554
1563
  turnId,
1555
1564
  ...(options.interrupt && { submitMode: 'interrupt' as const }),
1556
- ...(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
+ }),
1557
1571
  };
1558
1572
 
1559
1573
  // Diff-only / send-once WebMCP tool dispatch. `buildPayload()` already
@@ -1867,9 +1881,10 @@ export class AgentWidgetClient {
1867
1881
  */
1868
1882
  public async resolveApproval(
1869
1883
  approval: { agentId: string; executionId: string; approvalId: string },
1870
- decision: 'approved' | 'denied'
1884
+ decision: 'approved' | 'denied',
1885
+ options?: { assistantMessageId?: string }
1871
1886
  ): Promise<Response> {
1872
- return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision);
1887
+ return (await loadClientStream()).resolveApproval(this.#resumeHost(), approval, decision, options);
1873
1888
  }
1874
1889
 
1875
1890
  /**
@@ -2009,7 +2024,12 @@ export class AgentWidgetClient {
2009
2024
  public async resumeFlow(
2010
2025
  executionId: string,
2011
2026
  toolOutputs: Record<string, unknown>,
2012
- options?: { streamResponse?: boolean; signal?: AbortSignal; after?: string }
2027
+ options?: {
2028
+ streamResponse?: boolean;
2029
+ signal?: AbortSignal;
2030
+ after?: string;
2031
+ assistantMessageId?: string;
2032
+ }
2013
2033
  ): Promise<Response> {
2014
2034
  return (await loadClientStream()).resumeFlow(this.#resumeHost(), executionId, toolOutputs, options);
2015
2035
  }
@@ -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
 
@@ -710,9 +710,15 @@ export type RuntypeClientChatRequest = {
710
710
  }>;
711
711
  metadata?: Record<string, unknown>;
712
712
  sessionId: string;
713
- submitMode?: "normal" | "interrupt" | "steer";
713
+ submitMode?: "normal" | "interrupt" | "steer" | "auto";
714
714
  turnId?: string;
715
715
  voice?: {
716
+ priorToolResults?: Array<{
717
+ arguments: Record<string, unknown>;
718
+ name: string;
719
+ result?: unknown;
720
+ toolCallId: string;
721
+ }>;
716
722
  spoken?: boolean;
717
723
  };
718
724
  };
@@ -0,0 +1,273 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { AgentWidgetClient } from "./client";
3
+ import { AgentWidgetSession, type AgentWidgetSessionStatus } from "./session";
4
+ import type { AgentWidgetConfig, AgentWidgetMessage, ResumableHandle } from "./types";
5
+
6
+ // runtypelabs/core#9961: resumed replies must keep the id the server stores
7
+ // them under, and widget failure notices must never become history.
8
+
9
+ const originalFetch = global.fetch;
10
+ afterEach(() => {
11
+ global.fetch = originalFetch;
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ const enc = new TextEncoder();
16
+ const frame = (data: Record<string, unknown>) => `data: ${JSON.stringify(data)}\n\n`;
17
+ const sseStream = (text: string) =>
18
+ new ReadableStream<Uint8Array>({
19
+ start(c) {
20
+ if (text) c.enqueue(enc.encode(text));
21
+ c.close();
22
+ },
23
+ });
24
+ const replyFrames = (text: string) =>
25
+ frame({ type: "text_start", id: "t1" }) +
26
+ frame({ type: "text_delta", id: "t1", delta: text }) +
27
+ frame({ type: "text_complete", id: "t1" }) +
28
+ frame({ type: "execution_complete", kind: "agent", success: true });
29
+
30
+ type ClientInternals = {
31
+ clientSession: { sessionId: string; expiresAt: Date };
32
+ isWebMcpOperational: () => boolean;
33
+ executeWebMcpToolCall: () => Promise<unknown>;
34
+ resumeFlow: (...args: unknown[]) => Promise<Response>;
35
+ };
36
+ const internals = (session: AgentWidgetSession) =>
37
+ (session as unknown as { client: ClientInternals }).client;
38
+
39
+ const track = (config: AgentWidgetConfig) => {
40
+ const state = {
41
+ messages: [] as AgentWidgetMessage[],
42
+ status: "idle" as AgentWidgetSessionStatus,
43
+ errors: [] as Error[],
44
+ };
45
+ const session = new AgentWidgetSession(config, {
46
+ onMessagesChanged: (m) => {
47
+ state.messages = m;
48
+ },
49
+ onStatusChanged: (s) => {
50
+ state.status = s;
51
+ },
52
+ onStreamingChanged: () => {},
53
+ onError: (e) => state.errors.push(e),
54
+ });
55
+ return { session, state };
56
+ };
57
+
58
+ const awaitingWebMcpTool = (): AgentWidgetMessage => ({
59
+ id: "tool-call-1",
60
+ role: "assistant",
61
+ content: "",
62
+ createdAt: "2026-01-01T00:00:01.000Z",
63
+ streaming: true,
64
+ variant: "tool",
65
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "call-1" },
66
+ toolCall: { id: "call-1", name: "webmcp:purchase", status: "running", args: { quantity: 1 } },
67
+ });
68
+
69
+ describe("resumed reply ids", () => {
70
+ const tokenClient = () => {
71
+ const client = new AgentWidgetClient({ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com" });
72
+ (client as unknown as ClientInternals).clientSession = {
73
+ sessionId: "cs_123",
74
+ expiresAt: new Date(Date.now() + 60_000),
75
+ };
76
+ return client;
77
+ };
78
+ const captureBodies = () => {
79
+ const bodies: Record<string, unknown>[] = [];
80
+ global.fetch = vi.fn(async (_url: string, init: RequestInit) => {
81
+ bodies.push(JSON.parse(init.body as string));
82
+ return new Response(sseStream(""));
83
+ }) as unknown as typeof fetch;
84
+ return bodies;
85
+ };
86
+
87
+ it("sends the local bubble id as assistantMessageId on a client-token /resume", async () => {
88
+ const bodies = captureBodies();
89
+ await tokenClient().resumeFlow("exec_1", { call_1: "ok" }, { assistantMessageId: "ast_local" });
90
+ expect(bodies[0]).toMatchObject({ executionId: "exec_1", sessionId: "cs_123", assistantMessageId: "ast_local" });
91
+ });
92
+
93
+ it("keeps the proxy /resume body unchanged", async () => {
94
+ const bodies = captureBodies();
95
+ const client = new AgentWidgetClient({ apiUrl: "https://proxy.example.com/chat" });
96
+ await client.resumeFlow("exec_1", { call_1: "ok" }, { assistantMessageId: "ast_local" });
97
+ expect(bodies[0].assistantMessageId).toBeUndefined();
98
+ });
99
+
100
+ it("renders a resumed WebMCP leg into the bubble whose id it sent", async () => {
101
+ const { session, state } = track({ apiUrl: "http://test", webmcp: { enabled: true } });
102
+ const client = internals(session);
103
+ client.isWebMcpOperational = () => true;
104
+ client.executeWebMcpToolCall = vi.fn(async () => ({ content: [{ type: "text", text: "Purchased" }] }));
105
+ const resume = vi.fn(async () => new Response(sseStream(replyFrames("Your order is in."))));
106
+ client.resumeFlow = resume;
107
+
108
+ await session.resolveWebMcpToolCall(awaitingWebMcpTool());
109
+
110
+ expect(resume).toHaveBeenCalledTimes(1);
111
+ const sentId = (resume.mock.calls[0] as unknown[])[2] as { assistantMessageId?: string };
112
+ expect(sentId.assistantMessageId).toMatch(/^ast_/);
113
+ const reply = state.messages.find((m) => m.content === "Your order is in.");
114
+ expect(reply?.id).toBe(sentId.assistantMessageId);
115
+ session.destroy();
116
+ });
117
+ });
118
+
119
+ describe("local-only failure notices", () => {
120
+ it("shows a dispatch failure but never sends it as history", async () => {
121
+ const payloads: Array<{ messages: Array<{ role: string; content: unknown }> }> = [];
122
+ let calls = 0;
123
+ const { session, state } = track({
124
+ apiUrl: "http://test",
125
+ customFetch: async (_url, _init, payload) => {
126
+ payloads.push(payload as (typeof payloads)[number]);
127
+ calls += 1;
128
+ if (calls === 1) throw new Error("upstream unavailable");
129
+ return new Response(sseStream(replyFrames("Back online.")));
130
+ },
131
+ });
132
+
133
+ await session.sendMessage("first");
134
+ const notice = state.messages.at(-1)!;
135
+ expect(notice.role).toBe("assistant");
136
+ expect(notice.localOnly).toBe(true);
137
+
138
+ await session.sendMessage("second");
139
+ expect(payloads[1].messages.map((m) => m.content)).toEqual(["first", "second"]);
140
+ session.destroy();
141
+ });
142
+
143
+ it("drops reconnect-failure bubbles stored before the flag existed", async () => {
144
+ const payloads: Array<{ messages: Array<{ content: unknown }> }> = [];
145
+ const { session } = track({
146
+ apiUrl: "http://test",
147
+ initialMessages: [
148
+ { id: "u1", role: "user", content: "hi", createdAt: "2026-01-01T00:00:00.000Z" },
149
+ {
150
+ id: "reconnect-failed-exec_1",
151
+ role: "assistant",
152
+ content: "Connection lost and the response could not be resumed.",
153
+ createdAt: "2026-01-01T00:00:01.000Z",
154
+ },
155
+ ],
156
+ customFetch: async (_url, _init, payload) => {
157
+ payloads.push(payload as (typeof payloads)[number]);
158
+ return new Response(sseStream(replyFrames("ok")));
159
+ },
160
+ });
161
+
162
+ await session.sendMessage("again");
163
+ expect(payloads[0].messages.map((m) => m.content)).toEqual(["hi", "again"]);
164
+ session.destroy();
165
+ });
166
+
167
+ it("marks the reconnect give-up bubble local-only", async () => {
168
+ const { session, state } = track({
169
+ apiUrl: "http://test",
170
+ reconnectStream: async () => {
171
+ throw new Error("network down");
172
+ },
173
+ reconnect: { backoffMs: [1], maxAttempts: 1 },
174
+ });
175
+ session.hydrateMessages([
176
+ { id: "u1", role: "user", content: "tell me", createdAt: "2026-01-01T00:00:00.000Z" },
177
+ { id: "a1", role: "assistant", content: "Once", createdAt: "2026-01-01T00:00:01.000Z" },
178
+ ]);
179
+
180
+ session.resumeFromHandle({ executionId: "exec_5", after: "5" });
181
+ await vi.waitFor(() => expect(state.errors).toHaveLength(1));
182
+
183
+ const notice = state.messages.find((m) => m.id === "reconnect-failed-exec_5");
184
+ expect(notice?.localOnly).toBe(true);
185
+ session.destroy();
186
+ });
187
+ });
188
+
189
+ describe("boot resume of a run parked on an await", () => {
190
+ it("keeps retrying when the reconnect request itself fails", async () => {
191
+ let calls = 0;
192
+ const reconnectStream = vi.fn(async () => {
193
+ calls += 1;
194
+ if (calls === 1) throw new Error("network blip");
195
+ return new Response(sseStream(""));
196
+ });
197
+ const { session, state } = track({
198
+ apiUrl: "http://test",
199
+ webmcp: { enabled: true },
200
+ reconnectStream,
201
+ reconnect: { backoffMs: [1, 1, 1, 1, 1], maxAttempts: 5 },
202
+ });
203
+ session.hydrateMessages([
204
+ { id: "u1", role: "user", content: "buy one", createdAt: "2026-01-01T00:00:00.000Z" },
205
+ { id: "a1", role: "assistant", content: "Buying it now.", createdAt: "2026-01-01T00:00:00.500Z" },
206
+ awaitingWebMcpTool(),
207
+ ]);
208
+
209
+ session.resumeFromHandle({ executionId: "exec-1", after: "7" });
210
+ await vi.waitFor(() => expect(state.status).toBe("idle"));
211
+ await new Promise((r) => setTimeout(r, 20));
212
+
213
+ expect(reconnectStream).toHaveBeenCalledTimes(2);
214
+ expect(state.errors).toEqual([]);
215
+ expect(session.getResumableHandle()).toBeNull();
216
+ session.destroy();
217
+ });
218
+
219
+ it("ends quietly when the reload interrupted the WebMCP call and nothing replays", async () => {
220
+ const executionStates: Array<ResumableHandle | null> = [];
221
+ const reconnectStream = vi.fn(async () => new Response(sseStream("")));
222
+ const { session, state } = track({
223
+ apiUrl: "http://test",
224
+ webmcp: { enabled: true },
225
+ reconnectStream,
226
+ reconnect: { backoffMs: [1, 1, 1, 1, 1], maxAttempts: 5 },
227
+ onExecutionState: (h) => executionStates.push(h),
228
+ });
229
+ session.hydrateMessages([
230
+ { id: "u1", role: "user", content: "buy one", createdAt: "2026-01-01T00:00:00.000Z" },
231
+ { id: "a1", role: "assistant", content: "Buying it now.", createdAt: "2026-01-01T00:00:00.500Z" },
232
+ awaitingWebMcpTool(),
233
+ ]);
234
+
235
+ session.resumeFromHandle({ executionId: "exec-1", after: "7" });
236
+ await vi.waitFor(() => expect(state.status).toBe("idle"));
237
+ await new Promise((r) => setTimeout(r, 20));
238
+
239
+ expect(reconnectStream).toHaveBeenCalledTimes(1);
240
+ expect(state.errors).toEqual([]);
241
+ expect(state.messages.some((m) => m.id.startsWith("reconnect-failed-"))).toBe(false);
242
+ expect(state.messages.every((m) => !m.streaming)).toBe(true);
243
+ expect(state.messages.map((m) => m.id)).toEqual(["u1", "a1", "tool-call-1"]);
244
+ expect(session.getResumableHandle()).toBeNull();
245
+ expect(executionStates.at(-1)).toBeNull();
246
+ session.destroy();
247
+ });
248
+
249
+ it("keeps the cursor for a pending ask_user_question card instead of reconnecting", async () => {
250
+ const reconnectStream = vi.fn(async () => new Response(sseStream("")));
251
+ const { session, state } = track({ apiUrl: "http://test", reconnectStream });
252
+ session.hydrateMessages([
253
+ { id: "u1", role: "user", content: "plan a trip", createdAt: "2026-01-01T00:00:00.000Z" },
254
+ {
255
+ id: "tool-ask-1",
256
+ role: "assistant",
257
+ content: "",
258
+ createdAt: "2026-01-01T00:00:01.000Z",
259
+ variant: "tool",
260
+ agentMetadata: { executionId: "exec-2", awaitingLocalTool: true },
261
+ toolCall: { id: "ask-1", name: "ask_user_question", status: "running" },
262
+ },
263
+ ]);
264
+
265
+ session.resumeFromHandle({ executionId: "exec-2", after: "4" });
266
+ await new Promise((r) => setTimeout(r, 20));
267
+
268
+ expect(reconnectStream).not.toHaveBeenCalled();
269
+ expect(state.status).toBe("idle");
270
+ expect(session.getResumableHandle()).toMatchObject({ executionId: "exec-2", lastEventId: "4" });
271
+ session.destroy();
272
+ });
273
+ });