@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
@@ -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
+ });
@@ -268,3 +268,89 @@ describe("persistState gates storage adapter", () => {
268
268
  controller.destroy();
269
269
  });
270
270
  });
271
+
272
+ describe("default transcript key and local-only notices (runtypelabs/core#9961)", () => {
273
+ afterEach(() => {
274
+ document.body.innerHTML = "";
275
+ try {
276
+ window.localStorage.clear();
277
+ } catch {
278
+ /* jsdom edge cases */
279
+ }
280
+ });
281
+
282
+ const mount = () => {
283
+ const el = document.createElement("div");
284
+ document.body.appendChild(el);
285
+ return el;
286
+ };
287
+ const storedContents = (key: string): string[] =>
288
+ (JSON.parse(window.localStorage.getItem(key) ?? "{}").messages ?? []).map(
289
+ (m: { content?: string }) => m.content
290
+ );
291
+
292
+ it("scopes each widget's transcript by persistState.keyPrefix", () => {
293
+ const a = createAgentExperience(mount(), {
294
+ ...baseConfig(),
295
+ persistState: { keyPrefix: "ops-" },
296
+ });
297
+ const b = createAgentExperience(mount(), {
298
+ ...baseConfig(),
299
+ persistState: { keyPrefix: "ehr-" },
300
+ });
301
+
302
+ a.injectAssistantMessage({ content: "ops reply" });
303
+ b.injectAssistantMessage({ content: "ehr reply" });
304
+
305
+ expect(storedContents("ops-state")).toEqual(["ops reply"]);
306
+ expect(storedContents("ehr-state")).toEqual(["ehr reply"]);
307
+ expect(window.localStorage.getItem(DEFAULT_KEY)).toBeNull();
308
+ a.destroy();
309
+ b.destroy();
310
+ });
311
+
312
+ it("keeps reading the legacy key only for the default prefix", () => {
313
+ window.localStorage.setItem(
314
+ DEFAULT_KEY,
315
+ JSON.stringify({
316
+ messages: [
317
+ { id: "legacy-1", role: "assistant", content: "legacy reply", createdAt: "2026-01-01T00:00:00.000Z" },
318
+ ],
319
+ })
320
+ );
321
+
322
+ const unprefixed = createAgentExperience(mount(), baseConfig());
323
+ const prefixed = createAgentExperience(mount(), {
324
+ ...baseConfig(),
325
+ persistState: { keyPrefix: "ops-" },
326
+ });
327
+
328
+ expect(unprefixed.getMessages().map((m) => m.content)).toEqual(["legacy reply"]);
329
+ expect(prefixed.getMessages().map((m) => m.content)).toEqual([]);
330
+ unprefixed.destroy();
331
+ prefixed.destroy();
332
+ });
333
+
334
+ it("never stores a local-only notice, including legacy reconnect-failure bubbles", () => {
335
+ window.localStorage.setItem(
336
+ DEFAULT_KEY,
337
+ JSON.stringify({
338
+ messages: [
339
+ { id: "u1", role: "user", content: "hi", createdAt: "2026-01-01T00:00:00.000Z" },
340
+ {
341
+ id: "reconnect-failed-exec_1",
342
+ role: "assistant",
343
+ content: "Connection lost and the response could not be resumed.",
344
+ createdAt: "2026-01-01T00:00:01.000Z",
345
+ },
346
+ ],
347
+ })
348
+ );
349
+ const controller = createAgentExperience(mount(), baseConfig());
350
+
351
+ controller.injectAssistantMessage({ content: "kept" });
352
+
353
+ expect(storedContents(DEFAULT_KEY)).toEqual(["hi", "kept"]);
354
+ controller.destroy();
355
+ });
356
+ });
@@ -10,6 +10,7 @@
10
10
  * a host object (`SessionActionsHost`) that exposes the internals they need.
11
11
  */
12
12
  import type { AgentWidgetClient } from "./client";
13
+ import { generateAssistantMessageId } from "./utils/message-id";
13
14
  import {
14
15
  SUGGEST_REPLIES_TOOL_NAME,
15
16
  suggestRepliesToolResult,
@@ -40,7 +41,7 @@ export type SessionActionsInternals = {
40
41
  ): void;
41
42
  connectStream(
42
43
  stream: ReadableStream<Uint8Array>,
43
- options?: { allowReentry?: boolean }
44
+ options?: { allowReentry?: boolean; assistantMessageId?: string }
44
45
  ): Promise<void>;
45
46
  };
46
47
 
@@ -83,6 +84,8 @@ export async function resolveApproval(
83
84
  // 1. Update approval message status immediately for responsive UI
84
85
  const approvalMessageId = `approval-${approval.id}`;
85
86
  const errorMessageId = `approval-error-${approval.id}`;
87
+ // The client-token server stores the continuation under this id (core#9961).
88
+ const resumedAssistantId = generateAssistantMessageId();
86
89
  const requestToken = {};
87
90
  h.s.approvalTokens.set(approvalMessageId, requestToken);
88
91
  const updatedApproval: AgentWidgetApproval = {
@@ -141,7 +144,8 @@ export async function resolveApproval(
141
144
  executionId: approval.executionId,
142
145
  approvalId: approval.id,
143
146
  },
144
- decision
147
+ decision,
148
+ { assistantMessageId: resumedAssistantId }
145
149
  );
146
150
  }
147
151
 
@@ -181,6 +185,7 @@ export async function resolveApproval(
181
185
  id: errorMessageId,
182
186
  role: "assistant",
183
187
  content: errorText,
188
+ localOnly: true,
184
189
  createdAt: new Date().toISOString(),
185
190
  streaming: false,
186
191
  sequence: h.nextSequence(),
@@ -205,7 +210,10 @@ export async function resolveApproval(
205
210
  }
206
211
 
207
212
  if (stream) {
208
- await h.s.connectStream(stream, { allowReentry: true });
213
+ await h.s.connectStream(stream, {
214
+ allowReentry: true,
215
+ assistantMessageId: resumedAssistantId,
216
+ });
209
217
  } else {
210
218
  if (decision === 'denied') {
211
219
  // No stream body for denied: inject a denial message, and settle
@@ -369,12 +377,14 @@ export async function resolveAskUserQuestion(
369
377
  }
370
378
 
371
379
  try {
380
+ const assistantMessageId = generateAssistantMessageId();
372
381
  const response = await h.s.client.resumeFlow(
373
382
  executionId,
374
383
  { [toolName]: answer },
375
384
  {
376
385
  after:
377
386
  h.resumeAfter(executionId),
387
+ assistantMessageId,
378
388
  },
379
389
  );
380
390
 
@@ -386,7 +396,7 @@ export async function resolveAskUserQuestion(
386
396
  }
387
397
 
388
398
  if (response.body) {
389
- await h.s.connectStream(response.body, { allowReentry: true });
399
+ await h.s.connectStream(response.body, { allowReentry: true, assistantMessageId });
390
400
  } else {
391
401
  // No body to pipe: drop the pre-set streaming flag so the indicator
392
402
  // doesn't linger forever.
@@ -685,10 +695,12 @@ export async function resolveWebMcpToolCallBatch(
685
695
  toolOutputs[r.resumeKey] = r.output;
686
696
  }
687
697
 
698
+ const assistantMessageId = generateAssistantMessageId();
688
699
  const response = await h.s.client.resumeFlow(executionId, toolOutputs, {
689
700
  signal: batchController.signal,
690
701
  after:
691
702
  h.resumeAfter(executionId),
703
+ assistantMessageId,
692
704
  });
693
705
  if (!response.ok) {
694
706
  const errorData = await response.json().catch(() => null);
@@ -729,7 +741,7 @@ export async function resolveWebMcpToolCallBatch(
729
741
  );
730
742
  }
731
743
  if (response.body) {
732
- await h.s.connectStream(response.body, { allowReentry: true });
744
+ await h.s.connectStream(response.body, { allowReentry: true, assistantMessageId });
733
745
  }
734
746
  } catch (error) {
735
747
  const isAbortError =
@@ -881,6 +893,7 @@ async function resumeWithToolOutput(
881
893
  output: unknown,
882
894
  options?: { onHttpOk?: () => void; signal?: AbortSignal },
883
895
  ): Promise<void> {
896
+ const assistantMessageId = generateAssistantMessageId();
884
897
  const response = await h.s.client.resumeFlow(
885
898
  executionId,
886
899
  { [resumeKey]: output },
@@ -888,6 +901,7 @@ async function resumeWithToolOutput(
888
901
  signal: options?.signal,
889
902
  after:
890
903
  h.resumeAfter(executionId),
904
+ assistantMessageId,
891
905
  },
892
906
  );
893
907
  if (!response.ok) {
@@ -896,7 +910,7 @@ async function resumeWithToolOutput(
896
910
  }
897
911
  options?.onHttpOk?.();
898
912
  if (response.body) {
899
- await h.s.connectStream(response.body, { allowReentry: true });
913
+ await h.s.connectStream(response.body, { allowReentry: true, assistantMessageId });
900
914
  } else if (h.s.webMcpResolveControllers.size === 0) {
901
915
  // No stream to pipe. Clear streaming only when no WebMCP resolve is in
902
916
  // flight: for a WebMCP caller the current resolve's controller is still
@@ -20,6 +20,7 @@ import type {
20
20
  ResumableHandle,
21
21
  } from "./types";
22
22
  import type { AgentWidgetSessionStatus } from "./session";
23
+ import { RECONNECT_FAILED_ID_PREFIX } from "./utils/local-only-message";
23
24
 
24
25
  /**
25
26
  * The narrow surface the reconnect loop needs from the owning session. The
@@ -45,6 +46,12 @@ export interface ReconnectHost {
45
46
  seedContent: string
46
47
  ): Promise<void>;
47
48
  appendMessage(message: AgentWidgetMessage): void;
49
+ /**
50
+ * After an attempt that did not finish the run: if the run is parked on an
51
+ * await (or a reload-interrupted page tool), settle the session quietly and
52
+ * return true so the loop stops; return false for an ordinary drop.
53
+ */
54
+ settleParked(executionId: string): boolean;
48
55
  nextSequence(): number;
49
56
  emitReconnect(event: {
50
57
  phase: "paused" | "resuming" | "resumed";
@@ -170,9 +177,10 @@ export function createReconnectController(
170
177
  );
171
178
  if (content) {
172
179
  host.appendMessage({
173
- id: `reconnect-failed-${handle?.executionId ?? host.nextSequence()}`,
180
+ id: `${RECONNECT_FAILED_ID_PREFIX}${handle?.executionId ?? host.nextSequence()}`,
174
181
  role: "assistant",
175
182
  content,
183
+ localOnly: true,
176
184
  createdAt: new Date().toISOString(),
177
185
  streaming: false,
178
186
  sequence: host.nextSequence(),
@@ -248,6 +256,15 @@ export function createReconnectController(
248
256
  if (host.getResumable()!.lastEventId !== before) attempt = 0;
249
257
  }
250
258
 
259
+ // A parked run has nothing more to replay; retrying would only end in
260
+ // the "Connection lost" bubble. Only a reconnect that actually reached
261
+ // the server can prove that — a failed request keeps retrying.
262
+ if (body && host.getResumable() && host.settleParked(handle.executionId)) {
263
+ attempt = 0;
264
+ detachListeners();
265
+ return;
266
+ }
267
+
251
268
  if (host.getResumable() && attempt < maxAttempts) {
252
269
  // Dropped and waiting to retry: surface `paused` so
253
270
  // `statusIndicator.pausedText` renders during the backoff sleep.