@runtypelabs/persona 4.29.0 → 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 (126) 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-DjOvgFsn.js → chunk-BcLIgi1h.js} +1 -1
  13. package/dist/chunk-Bm2-bemv.js +1 -0
  14. package/dist/chunk-BvhTpG6k.js +1 -0
  15. package/dist/chunk-Csks3Qw1.js +1 -0
  16. package/dist/chunk-D5B629Mj.js +1 -0
  17. package/dist/chunk-DGlFKA0r.js +9 -0
  18. package/dist/chunk-DO9_WSpy.js +1 -0
  19. package/dist/chunk-DTDHeUsG.js +1 -0
  20. package/dist/chunk-DjFpGFGA.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-B0P4OtNi.d.cts} +1 -1
  27. package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-B0P4OtNi.d.ts} +1 -1
  28. package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-DmLI_RM6.d.cts} +1 -1
  29. package/dist/{context-mention-manager-D2qxE5op.d.ts → context-mention-manager-DmLI_RM6.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-DVo1wbZs.d.cts} +1 -1
  35. package/dist/{defaults-Dsw66XqE.d.ts → defaults-DVo1wbZs.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-BMnlmKnY.d.cts} +1 -1
  39. package/dist/{fallback-speech-engine-DJpJcWAk.d.ts → fallback-speech-engine-BMnlmKnY.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 +38 -38
  51. package/dist/index.js.map +1 -1
  52. package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-CeovcyYf.d.cts} +1 -1
  53. package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-CeovcyYf.d.ts} +1 -1
  54. package/dist/{preview-DKKpfTo7.d.cts → preview-Bkb4UrQC.d.cts} +2 -2
  55. package/dist/{preview-DKKpfTo7.d.ts → preview-Bkb4UrQC.d.ts} +2 -2
  56. package/dist/session-actions.js +1 -1
  57. package/dist/{session-DleMr9dZ.d.ts → session-iLyKipoM.d.cts} +21 -9
  58. package/dist/{session-DleMr9dZ.d.cts → session-iLyKipoM.d.ts} +21 -9
  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 +61 -61
  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 +40 -40
  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-BuDirJkQ.d.cts} +35 -3
  74. package/dist/{types-oAZ1VReS.d.ts → types-BuDirJkQ.d.ts} +35 -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-CXLjRt1k.d.cts} +1 -1
  84. package/dist/{webmcp-runtime-entry-Deg0NfmB.d.ts → webmcp-runtime-entry-CXLjRt1k.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 +1 -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.ts +176 -29
  106. package/src/session.voice.test.ts +169 -0
  107. package/src/styles/widget.css +4 -2
  108. package/src/types.ts +37 -2
  109. package/src/ui.restored-browser-tools.test.ts +128 -0
  110. package/src/ui.ts +32 -18
  111. package/src/utils/formatting.test.ts +9 -1
  112. package/src/utils/formatting.ts +4 -0
  113. package/src/utils/local-only-message.ts +15 -0
  114. package/src/utils/restored-browser-state.ts +50 -0
  115. package/src/utils/storage.ts +12 -1
  116. package/src/utils/voice-prior-tool-results.ts +37 -0
  117. package/src/voice/runtype-voice-provider.ts +13 -8
  118. package/src/voice/session-voice-wiring.ts +1 -2
  119. package/src/voice/voice-delegation.ts +10 -3
  120. package/src/voice/voice.test.ts +38 -2
  121. package/dist/chunk-B6zZsT6F.js +0 -1
  122. package/dist/chunk-CIVsNcrk.js +0 -1
  123. package/dist/chunk-CKdlNyzM.js +0 -9
  124. package/dist/chunk-DbhxjyOB.js +0 -4
  125. package/dist/chunk-DsdqLStH.js +0 -1
  126. package/dist/chunk-GIqbL2Eg.js +0 -1
@@ -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.
@@ -0,0 +1,272 @@
1
+ import { describe, expect, expectTypeOf, it, vi } from "vitest";
2
+ import { AgentWidgetSession } from "./session";
3
+ import type { AgentWidgetApproval, AgentWidgetConfig, AgentWidgetMessage, AgentWidgetToolCall, WebMcpConfirmInfo } from "./types";
4
+ import { BROWSER_TOOL_INTERRUPTION } from "./utils/restored-browser-state";
5
+ import { createDemoHistoryProvider } from "./internal/demo-history-provider";
6
+ import type { HistoryPageResult } from "./internal/history-provider";
7
+
8
+ const info: WebMcpConfirmInfo = { toolName: "purchase", args: { quantity: 2 }, reason: "gate" };
9
+ const approval = (status: NonNullable<AgentWidgetMessage["approval"]>["status"] = "pending"): AgentWidgetMessage => ({
10
+ id: "approval-webmcp-1", role: "assistant", content: "", createdAt: "2026-01-01",
11
+ variant: "approval", approval: { id: "webmcp-1", status, agentId: "", executionId: "", toolName: info.toolName, toolType: "webmcp", description: "Buy items", parameters: info.args },
12
+ });
13
+ const tool = (): AgentWidgetMessage => ({
14
+ id: "tool-call-1", role: "assistant", content: "", createdAt: "2026-01-01", streaming: true, variant: "tool",
15
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "call-1" },
16
+ toolCall: { id: "call-1", name: `webmcp:${info.toolName}`, status: "running", args: info.args, startedAt: 1000, approvalStatus: "pending" },
17
+ });
18
+ const makeSession = (config: AgentWidgetConfig = {}) => new AgentWidgetSession(config, {
19
+ onMessagesChanged: vi.fn(), onStatusChanged: vi.fn(), onStreamingChanged: vi.fn(),
20
+ });
21
+
22
+ it("keeps the public approval status unions compatible with existing exhaustive handlers", () => {
23
+ type ExistingStatus = "pending" | "approved" | "denied" | "timeout";
24
+ expectTypeOf<AgentWidgetApproval["status"]>().toEqualTypeOf<ExistingStatus>();
25
+ expectTypeOf<AgentWidgetToolCall["approvalStatus"]>().toEqualTypeOf<ExistingStatus | undefined>();
26
+ });
27
+
28
+ describe.each(["initial", "hydrate"] as const)("browser history via %s", path => {
29
+ const restore = (messages: AgentWidgetMessage[]) => {
30
+ const session = makeSession(path === "initial" ? { initialMessages: messages } : {});
31
+ if (path === "hydrate") session.hydrateMessages(messages);
32
+ return session;
33
+ };
34
+ it("expires the lost callback, settles the tool and preserves the snapshot", () => {
35
+ const snapshot = [approval(), tool()];
36
+ const before = structuredClone(snapshot);
37
+ const session = restore(snapshot);
38
+ expect(session.getMessages()[0].approval).toMatchObject({ status: "timeout", interrupted: true });
39
+ const restored = session.getMessages()[1];
40
+ expect(restored.streaming).toBe(false);
41
+ expect(restored.agentMetadata).toEqual({ ...before[1].agentMetadata, awaitingLocalTool: false });
42
+ expect(restored.toolCall).toEqual({ ...before[1].toolCall, status: "complete", success: false, interrupted: true, error: BROWSER_TOOL_INTERRUPTION, approvalStatus: "timeout", startedAt: undefined });
43
+ expect(restored.toolCall?.durationMs).toBeUndefined();
44
+ expect(restored.toolCall?.completedAt).toBeUndefined();
45
+ session.resolveWebMcpApproval(snapshot[0].id, "approved");
46
+ expect(session.getMessages()[0].approval).toMatchObject({ status: "timeout", interrupted: true });
47
+ expect(snapshot).toEqual(before);
48
+ session.destroy();
49
+ });
50
+ it.each(["timeout", "approved", "denied"] as const)("settles unfinished tools even with a %s approval", status => {
51
+ const session = restore([approval(status), tool()]);
52
+ expect(session.getMessages()[0].approval?.status).toBe(status);
53
+ expect(session.getMessages()[0].approval?.interrupted).toBeUndefined();
54
+ expect(session.getMessages()[1].toolCall?.interrupted).toBe(true);
55
+ session.destroy();
56
+ });
57
+ it("settles cardless and legacy awaits and mixed collections without losing recorded results or timing", () => {
58
+ const message = tool();
59
+ message.toolCall!.status = "complete"; // legacy wire await, not an outcome
60
+ message.tools = [
61
+ { id: "pending", name: `webmcp:${info.toolName}`, status: "pending", args: { sku: "A" }, durationMs: 200 },
62
+ { id: "paired", name: `webmcp:${info.toolName}`, status: "running", startedAt: 10, completedAt: 40 },
63
+ { id: "done", name: `webmcp:${info.toolName}`, status: "complete", success: true, result: { bought: 2 }, startedAt: 10, completedAt: 30, duration: 20 },
64
+ { id: "server", name: "server_search", status: "running", startedAt: 10 },
65
+ ];
66
+ const before = structuredClone(message);
67
+ const session = restore([message]);
68
+ const restored = session.getMessages()[0];
69
+ expect(restored.toolCall?.interrupted).toBe(true);
70
+ expect(restored.tools?.[0]).toMatchObject({ status: "complete", interrupted: true, durationMs: 200 });
71
+ expect(restored.tools?.[1]).toMatchObject({ status: "complete", startedAt: 10, completedAt: 40 });
72
+ expect(restored.tools?.slice(2)).toEqual(before.tools?.slice(2));
73
+ expect(message).toEqual(before);
74
+ session.destroy();
75
+ });
76
+ it("keeps server approval continuations including server gates for WebMCP tools", () => {
77
+ const server = approval();
78
+ server.approval!.executionId = "durable-execution";
79
+ const session = restore([server]);
80
+ expect(session.getMessages()[0].approval).toEqual(server.approval);
81
+ session.destroy();
82
+ });
83
+ it.each(["approved", "denied"] as const)("generates distinct fresh approvals and resolves %s once", async decision => {
84
+ const session = restore([approval()]);
85
+ const pending = session.requestWebMcpApproval(info);
86
+ const fresh = session.getMessages().find(m => m.approval?.status === "pending")!;
87
+ expect(fresh.id).not.toBe("approval-webmcp-1");
88
+ session.resolveWebMcpApproval(fresh.id, decision);
89
+ session.resolveWebMcpApproval(fresh.id, decision === "approved" ? "denied" : "approved");
90
+ expect(await pending).toBe(decision === "approved");
91
+ expect(session.getMessages().find(m => m.id === fresh.id)?.approval?.status).toBe(decision);
92
+ expect(session.getMessages().find(m => m.id === "approval-webmcp-1")?.approval).toMatchObject({ status: "timeout", interrupted: true });
93
+ session.destroy();
94
+ });
95
+ });
96
+
97
+ it("conversation switching also retires browser callbacks", async () => {
98
+ const { createDemoHistoryProvider } = await import("./internal/demo-history-provider");
99
+ const provider = createDemoHistoryProvider();
100
+ const summary = { id: "restored", title: "Restored", targetId: "flow", preview: "", createdAt: "2026-01-01", updatedAt: "2026-01-01", messageCount: 2 };
101
+ const snapshot = [approval(), tool()];
102
+ vi.spyOn(provider, "getPage").mockResolvedValue({ summary, messages: snapshot, conversationRevision: "r1", nextCursor: null });
103
+ vi.spyOn(provider, "prepareOpen").mockResolvedValue({ conversationId: summary.id, conversationRevision: "r1", commit: vi.fn(), discard: vi.fn() });
104
+ const session = new AgentWidgetSession({}, {
105
+ onMessagesChanged: vi.fn(), onStatusChanged: vi.fn(), onStreamingChanged: vi.fn(),
106
+ }, { historyProvider: provider });
107
+ await session.openConversation(summary.id);
108
+ expect(session.getMessages()[0].approval).toMatchObject({ status: "timeout", interrupted: true });
109
+ expect(session.getMessages()[1].toolCall?.interrupted).toBe(true);
110
+ expect(snapshot[0].approval?.status).toBe("pending");
111
+ session.destroy();
112
+ });
113
+
114
+ describe("browser state from provider pages", () => {
115
+ const summary = { id: "restored", title: "Restored", targetId: "flow", preview: "", createdAt: "2026-01-01", updatedAt: "2026-01-02", messageCount: 5 };
116
+ const newer: AgentWidgetMessage = { id: "newer", role: "assistant", content: "Newer reply", createdAt: "2026-01-02" };
117
+ const setup = (messages: AgentWidgetMessage[] = [newer]) => {
118
+ const provider = createDemoHistoryProvider();
119
+ const getPage = vi.spyOn(provider, "getPage");
120
+ const session = new AgentWidgetSession({ initialMessages: messages, getStoredConversationId: () => summary.id }, {
121
+ onMessagesChanged: vi.fn(), onStatusChanged: vi.fn(), onStreamingChanged: vi.fn(),
122
+ }, { historyProvider: provider });
123
+ return { session, getPage };
124
+ };
125
+
126
+ it.each(["older", "boot replacement", "boot overlap"] as const)("normalizes the %s page without mutating provider snapshots", async path => {
127
+ const server = approval();
128
+ server.id = "server-gate";
129
+ server.approval = { ...server.approval!, id: "server-gate", executionId: "durable-exec" };
130
+ const done = tool();
131
+ done.id = "completed-tool";
132
+ done.streaming = false;
133
+ done.agentMetadata = { executionId: "done-exec" };
134
+ done.toolCall = { ...done.toolCall!, status: "complete", success: true, result: { bought: 2 }, startedAt: 10, completedAt: 30, durationMs: 20, approvalStatus: "approved" };
135
+ const snapshot = [approval(), tool(), server, done, ...(path === "boot overlap" ? [newer] : [])];
136
+ const before = structuredClone(snapshot);
137
+ const { session, getPage } = setup();
138
+ getPage.mockResolvedValue({ summary, messages: snapshot, conversationRevision: "r1", nextCursor: null });
139
+ if (path === "older") {
140
+ const page = await session.loadOlderMessages(summary.id, "older");
141
+ expect(page.messages.find(m => m.id === approval().id)?.approval).toMatchObject({ status: "timeout", interrupted: true });
142
+ expect(page.messages.find(m => m.id === tool().id)?.toolCall?.interrupted).toBe(true);
143
+ } else {
144
+ await session.reconcileBootConversation();
145
+ }
146
+ const restored = session.getMessages();
147
+ expect(restored.find(m => m.id === approval().id)?.approval).toMatchObject({ status: "timeout", interrupted: true });
148
+ expect(restored.find(m => m.id === tool().id)).toMatchObject({ streaming: false, agentMetadata: { awaitingLocalTool: false }, toolCall: { interrupted: true, status: "complete", success: false } });
149
+ expect(restored.find(m => m.id === server.id)?.approval).toEqual(server.approval);
150
+ expect(restored.find(m => m.id === done.id)?.toolCall).toEqual(done.toolCall);
151
+ expect(restored.some(m => m.id === newer.id)).toBe(path !== "boot replacement");
152
+ expect(snapshot).toEqual(before);
153
+ session.destroy();
154
+ });
155
+
156
+ it.each(["older", "boot"] as const)("keeps a live gate usable when the %s page overlaps it", async path => {
157
+ const { session, getPage } = setup();
158
+ const decision = session.requestWebMcpApproval(info);
159
+ const live = session.getMessages().find(m => m.approval?.status === "pending")!;
160
+ getPage.mockResolvedValue({ summary, messages: [structuredClone(live)], conversationRevision: "r1", nextCursor: null });
161
+ if (path === "older") await session.loadOlderMessages(summary.id, "older");
162
+ else await session.reconcileBootConversation();
163
+ expect(session.getMessages().find(m => m.id === live.id)?.approval?.status).toBe("pending");
164
+ session.resolveWebMcpApproval(live.id, "approved");
165
+ expect(await decision).toBe(true);
166
+ session.destroy();
167
+ });
168
+
169
+ it("keeps a decision made while an older page was loading", async () => {
170
+ const { session, getPage } = setup();
171
+ const decision = session.requestWebMcpApproval(info);
172
+ const live = session.getMessages().find(m => m.approval?.status === "pending")!;
173
+ const snapshot = structuredClone(live);
174
+ let finish!: (page: HistoryPageResult) => void;
175
+ getPage.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
176
+ const page = session.loadOlderMessages(summary.id, "older");
177
+ session.resolveWebMcpApproval(live.id, "approved");
178
+ expect(await decision).toBe(true);
179
+ finish({ summary, messages: [snapshot], conversationRevision: "r1", nextCursor: null });
180
+ await page;
181
+ expect(session.getMessages().find(m => m.id === live.id)?.approval?.status).toBe("approved");
182
+ expect(snapshot.approval?.status).toBe("pending");
183
+ session.destroy();
184
+ });
185
+
186
+ it("keeps a running browser tool and its completed result when older pages overlap", async () => {
187
+ const { session, getPage } = setup();
188
+ const client = (session as unknown as { client: {
189
+ executeWebMcpToolCall: () => Promise<unknown>;
190
+ resumeFlow: () => Promise<Response>;
191
+ } }).client;
192
+ let finish!: (result: unknown) => void;
193
+ client.executeWebMcpToolCall = vi.fn(() => new Promise(resolve => { finish = resolve; }));
194
+ client.resumeFlow = vi.fn(async () => new Response('data: {"type":"execution_complete","success":true,"kind":"agent"}\n\n'));
195
+ const snapshot = tool();
196
+ const execution = session.resolveWebMcpToolCall(snapshot);
197
+ await vi.waitFor(() => expect(client.executeWebMcpToolCall).toHaveBeenCalledTimes(1));
198
+ getPage.mockResolvedValue({ summary, messages: [snapshot], conversationRevision: "r1", nextCursor: null });
199
+ await session.loadOlderMessages(summary.id, "older");
200
+ expect(session.getMessages().find(m => m.id === snapshot.id)?.toolCall).toMatchObject({ status: "running" });
201
+ expect(session.getMessages().find(m => m.id === snapshot.id)?.toolCall?.interrupted).not.toBe(true);
202
+ finish({ content: [{ type: "text", text: "Purchased" }] });
203
+ await execution;
204
+ const completed = session.getMessages().find(m => m.id === snapshot.id)!.toolCall;
205
+ await session.loadOlderMessages(summary.id, "another-page");
206
+ expect(session.getMessages().find(m => m.id === snapshot.id)?.toolCall).toEqual(completed);
207
+ expect(client.executeWebMcpToolCall).toHaveBeenCalledTimes(1);
208
+ expect(client.resumeFlow).toHaveBeenCalledTimes(1);
209
+ session.destroy();
210
+ });
211
+ });
212
+
213
+ it("a reconnect replay uses a fresh gate, executes once, and never re-executes completed history", async () => {
214
+ const frame = (data: object) => `data: ${JSON.stringify(data)}\n\n`;
215
+ const awaitFrame = frame({ type: "await", executionId: "exec-1", awaitReason: "local_tool_required", toolCallId: "call-1", toolName: "webmcp:purchase", parameters: info.args });
216
+ const stream = (text: string) => new ReadableStream<Uint8Array>({ start(c) { c.enqueue(new TextEncoder().encode(text)); c.close(); } });
217
+ const session = makeSession({ apiUrl: "http://test", webmcp: { enabled: true }, reconnectStream: async () => new Response(stream(awaitFrame + awaitFrame)) });
218
+ // Stub only the HTTP and browser boundaries, leaving the real event pipeline.
219
+ const client = (session as unknown as { client: {
220
+ isWebMcpOperational: () => boolean;
221
+ executeWebMcpToolCall: () => Promise<unknown>;
222
+ resumeFlow: () => Promise<Response>;
223
+ } }).client;
224
+ client.isWebMcpOperational = () => true;
225
+ const execute = vi.fn(async () => {
226
+ const allowed = await session.requestWebMcpApproval(info);
227
+ return { content: [{ type: "text", text: allowed ? "Purchased" : "Denied" }], isError: !allowed };
228
+ });
229
+ client.executeWebMcpToolCall = execute;
230
+ const resume = vi.fn(async () => new Response(stream(frame({ type: "execution_complete", success: true, kind: "agent" }))));
231
+ client.resumeFlow = resume;
232
+ session.hydrateMessages([approval(), tool()]);
233
+ expect(execute).not.toHaveBeenCalled();
234
+ session.resumeFromHandle({ executionId: "exec-1", after: "5" });
235
+ await vi.waitFor(() => expect(session.getMessages().some(m => m.approval?.status === "pending")).toBe(true));
236
+ const fresh = session.getMessages().find(m => m.approval?.status === "pending")!;
237
+ expect(fresh.id).not.toBe("approval-webmcp-1");
238
+ expect(resume).not.toHaveBeenCalled();
239
+ session.resolveWebMcpApproval(fresh.id, "approved");
240
+ session.resolveWebMcpApproval(fresh.id, "approved");
241
+ await vi.waitFor(() => expect(resume).toHaveBeenCalledTimes(1));
242
+ expect(execute).toHaveBeenCalledTimes(1);
243
+ await vi.waitFor(() => expect(session.getMessages().some(m => m.toolCall?.result !== undefined)).toBe(true));
244
+ const completedSnapshot = session.getMessages();
245
+ session.hydrateMessages(completedSnapshot);
246
+ await session.connectStream(stream(
247
+ frame({ type: "tool_start", executionId: "exec-1", toolCallId: "call-1", toolName: "webmcp:purchase", parameters: info.args }) +
248
+ frame({ type: "tool_output_delta", executionId: "exec-1", toolCallId: "call-1", delta: "stale output" }) +
249
+ awaitFrame + awaitFrame
250
+ ));
251
+ // Microtask batch / lazy action adoption must have had time to run.
252
+ await new Promise(resolve => setTimeout(resolve, 20));
253
+ expect(execute).toHaveBeenCalledTimes(1);
254
+ expect(resume).toHaveBeenCalledTimes(1);
255
+ session.destroy();
256
+ });
257
+
258
+ it("a restored server approval still continues through the configured transport", async () => {
259
+ const server = approval();
260
+ server.approval = { ...server.approval!, id: "server-gate", toolType: "server", agentId: "agent", executionId: "durable-exec" };
261
+ server.id = "approval-server-gate";
262
+ const onDecision = vi.fn(async () => new ReadableStream<Uint8Array>({ start(c) {
263
+ c.enqueue(new TextEncoder().encode('data: {"type":"text_delta","delta":"Server continued"}\n\ndata: {"type":"execution_complete","success":true,"kind":"agent"}\n\n'));
264
+ c.close();
265
+ } }));
266
+ const session = makeSession({ initialMessages: [server], approval: { onDecision } });
267
+ expect(onDecision).not.toHaveBeenCalled();
268
+ await session.resolveApproval(session.getMessages()[0].approval!, "approved");
269
+ expect(onDecision).toHaveBeenCalledWith({ approvalId: "server-gate", executionId: "durable-exec", agentId: "agent", toolName: info.toolName }, "approved", undefined);
270
+ expect(session.getMessages().some(m => m.content === "Server continued")).toBe(true);
271
+ session.destroy();
272
+ });