@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
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createAgentExperience } from "./ui";
4
+ import { provideApprovalUi } from "./approval-ui-loader";
5
+ import * as approvalUiEntry from "./approval-ui-entry";
6
+ import type { AgentWidgetMessage, AgentWidgetStoredState } from "./types";
7
+ import { createDemoHistoryProvider } from "./internal/demo-history-provider";
8
+
9
+ provideApprovalUi(approvalUiEntry);
10
+ let controller: ReturnType<typeof createAgentExperience> | undefined;
11
+ afterEach(() => { controller?.destroy(); document.body.replaceChildren(); localStorage.clear(); vi.restoreAllMocks(); });
12
+
13
+ const snapshot = (): AgentWidgetStoredState => ({
14
+ metadata: { custom: "retained" }, draft: { text: "My unsent proposal" },
15
+ messages: [
16
+ { id: "tool-1", role: "assistant", content: "", createdAt: "2026-01-01", sequence: 1, streaming: true, variant: "tool", agentMetadata: { executionId: "exec", awaitingLocalTool: true }, toolCall: { id: "call-1", name: "webmcp:purchase", status: "running", startedAt: 1, args: { quantity: 2 }, approvalStatus: "pending" } },
17
+ { id: "tool-2", role: "assistant", content: "", createdAt: "2026-01-01", sequence: 2, variant: "tool", toolCall: { id: "call-2", name: "webmcp:search", status: "complete", result: "Found two items", durationMs: 2000 } },
18
+ { id: "approval-webmcp-1", role: "assistant", content: "", createdAt: "2026-01-01", sequence: 3, variant: "approval", approval: { id: "webmcp-1", status: "pending", agentId: "", executionId: "", toolType: "webmcp", toolName: "webmcp:purchase", description: "Buy items", parameters: { quantity: 2 } } },
19
+ ] satisfies AgentWidgetMessage[],
20
+ });
21
+
22
+ for (const source of ["sync", "async", "initial", "hook"] as const) {
23
+ describe.each(["card", "row"] as const)(`${source} restoration (%s)`, variant => {
24
+ it("renders historical controls and activity as interrupted while preserving drafts", async () => {
25
+ const stored = snapshot();
26
+ const before = structuredClone(stored);
27
+ const mount = document.createElement("div"); document.body.appendChild(mount);
28
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
29
+ controller = createAgentExperience(mount, {
30
+ apiUrl: "/test", launcher: { enabled: false }, webmcp: { enabled: true },
31
+ ...(source === "initial" ? { initialMessages: stored.messages, persistState: false } : source === "hook" ? { onStateLoaded: () => stored, persistState: false } : {
32
+ storageAdapter: { load: () => source === "async" ? Promise.resolve(stored) : stored, save: vi.fn(), clear: vi.fn() },
33
+ }),
34
+ features: { toolCallDisplay: { variant, grouped: true, groupedMode: "collapsible" } },
35
+ toolCall: { completeTextTemplate: "Finished {toolName} ~{duration}~" },
36
+ });
37
+ await vi.waitFor(() => expect(mount.textContent).toContain("chat restarted"));
38
+ expect(mount.querySelector('[data-action="allow"], [data-action="deny"], [data-approval-buttons]')).toBeNull();
39
+ expect(mount.querySelector('[data-tool-elapsed], [data-activity-state="running"], [data-activity-state="awaiting-approval"]')).toBeNull();
40
+ expect(mount.querySelector('[data-message-id="tool-1"]')?.textContent).toContain("Interrupted");
41
+ expect(mount.querySelector('[data-message-id="tool-1"]')?.textContent).not.toContain("Finished");
42
+ if (variant === "row") {
43
+ expect(mount.querySelector('[data-persona-tool-group]')?.getAttribute("data-activity-state")).toBe("interrupted");
44
+ expect(mount.textContent).toContain("2s");
45
+ }
46
+ if (source === "sync" || source === "async") expect(controller.getComposerState().text).toBe("My unsent proposal");
47
+ expect(stored).toEqual(before);
48
+ expect(fetchSpy).not.toHaveBeenCalled();
49
+ });
50
+ });
51
+ }
52
+
53
+ it("keeps a cardless interrupted tool visible when completed rows are removed", async () => {
54
+ const stored = snapshot();
55
+ const mount = document.createElement("div"); document.body.appendChild(mount);
56
+ controller = createAgentExperience(mount, {
57
+ launcher: { enabled: false }, persistState: false,
58
+ initialMessages: [stored.messages![0]],
59
+ features: { toolCallDisplay: { variant: "row", completedVisibility: "removed" } },
60
+ toolCall: { renderCollapsedSummary: ({ createElapsedElement }) => createElapsedElement() },
61
+ });
62
+ expect(mount.querySelector('[data-message-id="tool-1"]')).not.toBeNull();
63
+ expect(mount.querySelector('[data-tool-elapsed]')).toBeNull();
64
+ expect(mount.textContent).toContain("chat restarted");
65
+ });
66
+
67
+ it("supports a custom approval renderer that only handles the original statuses", () => {
68
+ const labels = { pending: "Waiting", approved: "Allowed", denied: "Denied", timeout: "Expired" };
69
+ const mount = document.createElement("div"); document.body.appendChild(mount);
70
+ const render = vi.fn(({ message }: { message: AgentWidgetMessage }) => {
71
+ const node = document.createElement("span");
72
+ node.textContent = message.approval ? labels[message.approval.status] : "";
73
+ return node;
74
+ });
75
+ controller = createAgentExperience(mount, {
76
+ launcher: { enabled: false }, persistState: false,
77
+ initialMessages: [snapshot().messages![2]],
78
+ plugins: [{ id: "existing-approval-renderer", renderApproval: render }],
79
+ });
80
+ expect(mount.textContent).toContain("Expired");
81
+ expect(render).toHaveBeenCalled();
82
+ expect(render.mock.calls[0][0].message.approval).toMatchObject({ status: "timeout", interrupted: true });
83
+ });
84
+
85
+ it.each(["card", "row"] as const)("loading earlier messages retires browser activity in %s display", async variant => {
86
+ const stored = snapshot();
87
+ const before = structuredClone(stored);
88
+ const provider = createDemoHistoryProvider();
89
+ const summary = { id: "restored", title: "Restored", targetId: null, preview: "", createdAt: "2026-01-01", updatedAt: "2026-01-02", messageCount: 4 };
90
+ vi.spyOn(provider, "prepareOpen").mockResolvedValue({ conversationId: summary.id, conversationRevision: "r1", commit: vi.fn(), discard: vi.fn() });
91
+ vi.spyOn(provider, "getPage").mockImplementation(async (_id, options) => ({
92
+ summary, conversationRevision: "r1", nextCursor: options?.cursor ? null : "older",
93
+ messages: options?.cursor ? stored.messages! : [{ id: "newer", role: "assistant", content: "Newer reply", createdAt: "2026-01-02" }],
94
+ }));
95
+ const mount = document.createElement("div"); document.body.appendChild(mount);
96
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
97
+ controller = createAgentExperience(mount, {
98
+ apiUrl: "/test", launcher: { enabled: false }, persistState: false,
99
+ features: { history: { enabled: true, provider }, toolCallDisplay: { variant, grouped: true } },
100
+ });
101
+ await controller.openConversation(summary.id);
102
+ const earlier = mount.querySelector<HTMLButtonElement>("[data-persona-history-earlier]");
103
+ expect(earlier).not.toBeNull();
104
+ earlier!.click();
105
+ await vi.waitFor(() => expect(mount.textContent).toContain("chat restarted"));
106
+ expect(mount.querySelector('[data-action="allow"], [data-action="deny"], [data-tool-elapsed], [data-activity-state="running"], [data-activity-state="awaiting-approval"]')).toBeNull();
107
+ expect(mount.textContent).toContain("Interrupted");
108
+ expect(mount.textContent).toContain("Newer reply");
109
+ expect(stored).toEqual(before);
110
+ expect(fetchSpy).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it.each(["button", "controller"] as const)("restored server WebMCP approval uses its transport via %s", async route => {
114
+ const message = snapshot().messages![2];
115
+ message.approval!.executionId = "durable-exec";
116
+ const onDecision = vi.fn(async () => undefined);
117
+ const mount = document.createElement("div"); document.body.appendChild(mount);
118
+ controller = createAgentExperience(mount, {
119
+ launcher: { enabled: false }, persistState: false, initialMessages: [message], approval: { onDecision },
120
+ });
121
+ expect(onDecision).not.toHaveBeenCalled();
122
+ if (route === "button") mount.querySelector<HTMLButtonElement>('[data-action="allow"]')!.click();
123
+ else await controller.resolveApproval(message.approval!.id, "approved");
124
+ await vi.waitFor(() => expect(onDecision).toHaveBeenCalledTimes(1));
125
+ expect(onDecision.mock.calls[0]).toEqual([{
126
+ approvalId: "webmcp-1", executionId: "durable-exec", agentId: "", toolName: "webmcp:purchase",
127
+ }, "approved", undefined]);
128
+ });
package/src/ui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isBrowserLocalApproval } from "./utils/restored-browser-state";
1
2
  import { activityVariant, activityDuration, createActivityLifecycle, createActivityGroup } from "./components/activity-row";
2
3
  import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
3
4
  import { usesSessionVoice } from "./utils/voice-support";
@@ -331,7 +332,8 @@ import {
331
332
  defaultActionHandlers,
332
333
  defaultJsonActionParser
333
334
  } from "./utils/actions";
334
- import { createLocalStorageAdapter } from "./utils/storage";
335
+ import { createLocalStorageAdapter, transcriptStorageKey } from "./utils/storage";
336
+ import { isLocalOnlyMessage } from "./utils/local-only-message";
335
337
  import { createVisitorStore, type VisitorStore } from "./utils/visitor-store";
336
338
  import { loadHistoryView } from "./history-view-loader";
337
339
  import { getHistoryShellSync, loadHistoryShell, type HistoryShellModule } from "./history-shell-loader";
@@ -501,11 +503,13 @@ const ensureRecord = (value: unknown): Record<string, unknown> => {
501
503
  return { ...(value as Record<string, unknown>) };
502
504
  };
503
505
 
504
- const stripStreamingFromMessages = (messages: AgentWidgetMessage[]) =>
505
- messages.map((message) => ({
506
- ...message,
507
- streaming: false
508
- }));
506
+ const messagesForStorage = (messages: AgentWidgetMessage[]) =>
507
+ messages
508
+ .filter((message) => !isLocalOnlyMessage(message))
509
+ .map((message) => ({
510
+ ...message,
511
+ streaming: false
512
+ }));
509
513
 
510
514
  type Controller = {
511
515
  update: (config: AgentWidgetConfigPatch) => void;
@@ -1064,7 +1068,12 @@ export const createAgentExperience = (
1064
1068
  const storageAdapter: AgentWidgetStorageAdapter | null =
1065
1069
  messagePersistenceDisabled
1066
1070
  ? null
1067
- : (config.storageAdapter ?? createLocalStorageAdapter());
1071
+ : (config.storageAdapter ??
1072
+ createLocalStorageAdapter(
1073
+ transcriptStorageKey(
1074
+ typeof config.persistState === "object" ? config.persistState.keyPrefix : undefined
1075
+ )
1076
+ ));
1068
1077
  const personaOwnsClientTokenRecovery =
1069
1078
  Boolean(config.clientToken) && typeof config.reconnectStream !== "function";
1070
1079
  let persistentMetadata: Record<string, unknown> = {};
@@ -3055,8 +3064,8 @@ export const createAgentExperience = (
3055
3064
 
3056
3065
  // WebMCP gate approvals resolve a local Promise the bridge is parked on
3057
3066
  // (no server round-trip); server-driven approvals call the API. The
3058
- // `toolType` marker set in `requestWebMcpApproval` discriminates the two.
3059
- if (approvalMessage.approval.toolType === "webmcp") {
3067
+ // `toolType` marker and absent execution id identify browser-local gates.
3068
+ if (isBrowserLocalApproval(approvalMessage.approval)) {
3060
3069
  session.resolveWebMcpApproval(messageId, decision);
3061
3070
  } else {
3062
3071
  session.resolveApproval(approvalMessage.approval, decision);
@@ -5159,7 +5168,7 @@ export const createAgentExperience = (
5159
5168
 
5160
5169
  const getMessagesForPersistence = () =>
5161
5170
  session
5162
- ? stripStreamingFromMessages(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
5171
+ ? messagesForStorage(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
5163
5172
  : [];
5164
5173
 
5165
5174
  let storageMutationTail: Promise<void> | null = null;
@@ -5209,7 +5218,7 @@ export const createAgentExperience = (
5209
5218
 
5210
5219
  // Allow saving even if session doesn't exist yet (for metadata during init)
5211
5220
  const messages = messagesOverride
5212
- ? stripStreamingFromMessages(messagesOverride)
5221
+ ? messagesForStorage(messagesOverride)
5213
5222
  : session
5214
5223
  ? getMessagesForPersistence()
5215
5224
  : [];
@@ -6239,7 +6248,8 @@ export const createAgentExperience = (
6239
6248
  return (
6240
6249
  dropCompletedToolCalls &&
6241
6250
  message.variant === "tool" &&
6242
- message.toolCall?.status === "complete"
6251
+ message.toolCall?.status === "complete" &&
6252
+ !message.toolCall.interrupted
6243
6253
  );
6244
6254
  };
6245
6255
 
@@ -6507,7 +6517,7 @@ export const createAgentExperience = (
6507
6517
  ?.getMessages()
6508
6518
  .find((m) => m.id === approvalMessageId);
6509
6519
  if (!live?.approval) return;
6510
- if (live.approval.toolType === "webmcp") {
6520
+ if (isBrowserLocalApproval(live.approval)) {
6511
6521
  sessionRef.current?.resolveWebMcpApproval(live.id, decision);
6512
6522
  } else {
6513
6523
  sessionRef.current?.resolveApproval(live.approval, decision, options);
@@ -6801,7 +6811,7 @@ export const createAgentExperience = (
6801
6811
  // A tool call hidden by `completedVisibility: "removed"` does not
6802
6812
  // create a visible break in the transcript, so it should not split
6803
6813
  // an otherwise contiguous group.
6804
- if (dropCompletedToolCalls && message.toolCall.status === "complete") {
6814
+ if (dropCompletedToolCalls && message.toolCall.status === "complete" && !message.toolCall.interrupted) {
6805
6815
  return;
6806
6816
  }
6807
6817
  currentGroup.push(message);
@@ -6859,10 +6869,12 @@ export const createAgentExperience = (
6859
6869
  id, name: `${group.length} tools`, status: active ? "running" : "complete",
6860
6870
  chunks: group.flatMap(item => item.toolCall?.chunks ?? []),
6861
6871
  success: !group.some(item => item.toolCall?.success === false),
6872
+ interrupted: !active && group.some(item => item.toolCall?.interrupted),
6862
6873
  } };
6863
6874
  activityIds.add(id);
6864
6875
  activityLifecycle.observe(synthetic, "tool", toolExpansionState, config.features.toolCallDisplay);
6865
- const label = `${active ? "Using" : "Used"} ${group.length} tools`;
6876
+ const label = synthetic.toolCall?.interrupted ? `${group.length} tools · Interrupted`
6877
+ : `${active ? "Using" : "Used"} ${group.length} tools`;
6866
6878
  const custom = config.toolCall?.renderGroupedSummary?.({ messages: group, toolCalls: group.map(item => item.toolCall!), defaultSummary: label, config });
6867
6879
  const row = createActivityGroup(synthetic, config, toolExpansionState.has(id), custom ?? label);
6868
6880
  row.bubble.classList.add("persona-tool-group");
@@ -6888,7 +6900,9 @@ export const createAgentExperience = (
6888
6900
  "persona-tool-group-summary persona-text-xs persona-text-persona-muted";
6889
6901
  summary.id = `tool-group-summary-${group[0].id}`;
6890
6902
 
6891
- const defaultSummary = `Called ${group.length} ${group.length === 1 ? "tool" : "tools"}`;
6903
+ const toolCount = `${group.length} ${group.length === 1 ? "tool" : "tools"}`;
6904
+ const defaultSummary = group.some(item => item.toolCall?.interrupted)
6905
+ ? `${toolCount} · Interrupted` : `Called ${toolCount}`;
6892
6906
  const renderedSummary = config.toolCall?.renderGroupedSummary?.({
6893
6907
  messages: group,
6894
6908
  toolCalls: group
@@ -13277,8 +13291,8 @@ export const createAgentExperience = (
13277
13291
  // Mirror the in-panel click handler: WebMCP gate bubbles resolve a local
13278
13292
  // Promise the bridge is parked on (no server round-trip and they carry an
13279
13293
  // empty executionId/agentId), so they must NOT hit the server approval
13280
- // API. Route by the `toolType` marker set in `requestWebMcpApproval`.
13281
- if (approvalMessage.approval.toolType === "webmcp") {
13294
+ // API. Server-backed approvals retain their execution id and use the API.
13295
+ if (isBrowserLocalApproval(approvalMessage.approval)) {
13282
13296
  session.resolveWebMcpApproval(approvalMessage.id, decision);
13283
13297
  return;
13284
13298
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed } from "./formatting";
2
+ import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed, computeToolElapsed } from "./formatting";
3
3
 
4
4
  describe("JSON Stream Parser", () => {
5
5
  it("should extract text field incrementally as JSON streams in", () => {
@@ -268,3 +268,11 @@ describe("computeReasoningElapsed", () => {
268
268
  expect(result).toBe("<0.1s");
269
269
  });
270
270
  });
271
+
272
+ it("never invents an interrupted tool's duration, including elapsed render hooks", () => {
273
+ const tool = { id: "browser", status: "complete" as const, interrupted: true };
274
+ expect(computeToolElapsed(tool)).toBe("");
275
+ expect(computeToolElapsed({ ...tool, startedAt: 1 })).toBe("");
276
+ expect(computeToolElapsed({ ...tool, durationMs: 2000 })).toBe("2s");
277
+ expect(computeToolElapsed({ ...tool, startedAt: 1000, completedAt: 4000 })).toBe("3s");
278
+ });
@@ -124,6 +124,10 @@ export const formatElapsedMs = (ms: number): string => {
124
124
  * Computes the current elapsed time string for a tool call.
125
125
  */
126
126
  export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
127
+ if (
128
+ tool.interrupted && tool.duration === undefined && tool.durationMs === undefined &&
129
+ (tool.startedAt === undefined || tool.completedAt === undefined)
130
+ ) return "";
127
131
  const durationMs =
128
132
  typeof tool.duration === "number"
129
133
  ? tool.duration
@@ -0,0 +1,15 @@
1
+ import type { AgentWidgetMessage } from "../types";
2
+
3
+ /** Id prefix of the bubble the durable reconnect loop posts when it gives up. */
4
+ export const RECONNECT_FAILED_ID_PREFIX = "reconnect-failed-";
5
+
6
+ /**
7
+ * A widget-generated notice that is shown in this browser only: never sent as
8
+ * conversation history and never written to the storage adapter. The id check
9
+ * covers reconnect-failure bubbles persisted before `localOnly` existed.
10
+ */
11
+ export const isLocalOnlyMessage = (message: AgentWidgetMessage): boolean =>
12
+ message.localOnly === true ||
13
+ (message.role === "assistant" &&
14
+ typeof message.id === "string" &&
15
+ message.id.startsWith(RECONNECT_FAILED_ID_PREFIX));
@@ -0,0 +1,50 @@
1
+ import type { AgentWidgetApproval, AgentWidgetMessage, AgentWidgetToolCall } from "../types";
2
+ import { isWebMcpToolName } from "../webmcp-bridge";
3
+
4
+ export const BROWSER_TOOL_INTERRUPTION =
5
+ "The chat restarted and this browser tool was interrupted. Request a fresh proposal to try again.";
6
+
7
+ export function isBrowserLocalApproval(approval: AgentWidgetApproval): boolean {
8
+ return approval.toolType === "webmcp" && !approval.executionId;
9
+ }
10
+
11
+ /** History cannot restore a browser callback. Server continuations stay live. */
12
+ export function restoreBrowserToolState(message: AgentWidgetMessage): AgentWidgetMessage {
13
+ const approval = message.approval;
14
+ // Server approvals can also name webmcp tools. An execution id identifies
15
+ // their durable continuation; native browser gates have no execution id.
16
+ const interruptedApproval = approval?.status === "pending" && isBrowserLocalApproval(approval);
17
+ let hasBrowserTool = false;
18
+ const settle = (tool: AgentWidgetToolCall): AgentWidgetToolCall => {
19
+ if (!tool.name || !isWebMcpToolName(tool.name)) return tool;
20
+ hasBrowserTool = true;
21
+ // Older await frames used complete for the wire step before the browser
22
+ // had executed anything. A recorded outcome, however, remains historical.
23
+ const unfinished = tool.status !== "complete" ||
24
+ (message.agentMetadata?.awaitingLocalTool === true &&
25
+ tool.result === undefined && tool.success === undefined);
26
+ if (!unfinished) return tool;
27
+ return {
28
+ ...tool,
29
+ status: "complete",
30
+ interrupted: true,
31
+ success: false,
32
+ error: BROWSER_TOOL_INTERRUPTION,
33
+ approvalStatus: tool.approvalStatus === "pending" ? "timeout" : tool.approvalStatus,
34
+ // Retain recorded durations and paired timestamps. Never fabricate an
35
+ // end timestamp or derive a duration from the time spent off the page.
36
+ startedAt: tool.completedAt === undefined ? undefined : tool.startedAt,
37
+ };
38
+ };
39
+ const toolCall = message.toolCall ? settle(message.toolCall) : undefined;
40
+ const tools = message.tools?.map(settle);
41
+ return {
42
+ ...message,
43
+ streaming: hasBrowserTool || interruptedApproval ? false : message.streaming,
44
+ ...(interruptedApproval ? { approval: { ...approval, status: "timeout" as const, interrupted: true } } : {}),
45
+ ...(toolCall ? { toolCall } : {}),
46
+ ...(tools ? { tools } : {}),
47
+ ...(hasBrowserTool && message.agentMetadata?.awaitingLocalTool
48
+ ? { agentMetadata: { ...message.agentMetadata, awaitingLocalTool: false } } : {}),
49
+ };
50
+ }
@@ -30,8 +30,19 @@ const sanitizeArtifacts = (artifacts: PersonaArtifactRecord[]) =>
30
30
  status: "complete" as const
31
31
  }));
32
32
 
33
+ const DEFAULT_PERSIST_KEY_PREFIX = "persona-";
34
+
35
+ /**
36
+ * The widget's default transcript key, scoped by `persistState.keyPrefix` so
37
+ * two widgets on one origin with distinct prefixes keep separate transcripts.
38
+ * The default prefix yields the legacy `"persona-state"` key, so unprefixed
39
+ * widgets keep their stored conversation; prefixed widgets never adopt it.
40
+ */
41
+ export const transcriptStorageKey = (keyPrefix?: string): string =>
42
+ `${keyPrefix ?? DEFAULT_PERSIST_KEY_PREFIX}state`;
43
+
33
44
  export const createLocalStorageAdapter = (
34
- key = "persona-state"
45
+ key = transcriptStorageKey()
35
46
  ): AgentWidgetStorageAdapter => {
36
47
  const getStorage = () => {
37
48
  if (typeof window === "undefined" || !window.localStorage) {
@@ -0,0 +1,37 @@
1
+ import type { AgentWidgetMessage } from "../types";
2
+
3
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
4
+ typeof value === "object" && value !== null && !Array.isArray(value);
5
+
6
+ /**
7
+ * Tool rows for the page tools a voice server already ran before handing the
8
+ * turn to the chat (`delegation_started.input.priorToolResults`). Each row is
9
+ * a completed call carrying `clientToolAnswer`, so proxy/agent payloads replay
10
+ * it to the model as an assistant `toolCalls` + `tool` `toolResults` pair under
11
+ * the same `toolCallId`. Nothing marks it awaiting, so it never runs again.
12
+ * Malformed entries and repeats of a `toolCallId` are skipped.
13
+ */
14
+ export const voicePriorToolRows = (value: unknown, createdAt: string): AgentWidgetMessage[] => {
15
+ const seen = new Set<string>();
16
+ return (Array.isArray(value) ? value : []).flatMap((entry): AgentWidgetMessage[] => {
17
+ if (!isRecord(entry)) return [];
18
+ const { toolCallId, name } = entry;
19
+ if (typeof toolCallId !== "string" || !toolCallId || typeof name !== "string" || !name) return [];
20
+ if (seen.has(toolCallId)) return [];
21
+ seen.add(toolCallId);
22
+ const args = isRecord(entry.arguments) ? entry.arguments : {};
23
+ const result = entry.result ?? null;
24
+ return [
25
+ {
26
+ id: `voice-tool-${toolCallId}`,
27
+ role: "assistant",
28
+ variant: "tool",
29
+ content: "",
30
+ createdAt,
31
+ streaming: false,
32
+ toolCall: { id: toolCallId, name, status: "complete", args, result },
33
+ agentMetadata: { clientToolAnswer: { toolCallId, toolName: name, args, result } },
34
+ },
35
+ ];
36
+ });
37
+ };
@@ -99,7 +99,10 @@
99
99
  // sent), `failed`, or `timeout` (past `timeoutMs`, which bounds the gate too).
100
100
  // Then `delegation_completed{route:'direct', text}` for a delegation that never
101
101
  // started here writes the heard user turn and `text` into the chat. When the
102
- // server falls through, the delegation arrives as today's `delegation_started`.
102
+ // server falls through, the delegation arrives as today's `delegation_started`;
103
+ // its `input.priorToolResults` (page tools the server already ran; sent only
104
+ // with `prior_tool_results`, declared beside `client_tools`) ride along to the
105
+ // chat turn as completed calls, never run again here.
103
106
 
104
107
  import type {
105
108
  VoiceClientToolsSession,
@@ -519,17 +522,17 @@ export class RuntypeVoiceProvider implements VoiceProvider {
519
522
  * the URL, and these params are not secrets.
520
523
  */
521
524
  #voiceSocketUrl(host: string, agentId: string, attach?: URLSearchParams): string {
522
- const capabilities = ["partial_transcript", "context"];
525
+ let capabilities = "partial_transcript,context";
523
526
  if (this.bridge && this.config?.clientDelegation !== false) {
524
- capabilities.push("client_delegation", "delegation_update", "delegation_read_back", "delegation_stream");
525
- if (this.bridge.clientTools?.()) capabilities.push("client_tools");
527
+ capabilities += ",client_delegation,delegation_update,delegation_read_back,delegation_stream";
528
+ if (this.bridge.clientTools?.()) capabilities += ",client_tools,prior_tool_results";
526
529
  }
527
- if (attach) capabilities.unshift("attach");
530
+ if (attach) capabilities = `attach,${capabilities}`;
528
531
  const params = new URLSearchParams({
529
532
  voiceProtocol: VOICE_PROTOCOL,
530
533
  clientVersion: `persona/${VERSION}`,
531
534
  voiceCapabilities: FULL_DUPLEX_CAPABILITY,
532
- clientCapabilities: capabilities.join(","),
535
+ clientCapabilities: capabilities,
533
536
  });
534
537
  attach?.forEach((value, key) => params.set(key, value));
535
538
  return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice?${params}`;
@@ -877,9 +880,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
877
880
  this.#pendingContext = this.#buildContextText();
878
881
  }
879
882
  if (this.#clientDelegation && this.#capabilities.has("client_tools") && !this.#fastTier) {
883
+ // INVARIANT: #clientDelegation implies a bridge.
880
884
  this.#fastTier =
881
- this.bridge
882
- ?.clientTools?.()
885
+ this.bridge!
886
+ .clientTools?.()
883
887
  ?.open({
884
888
  send: (frame) => {
885
889
  const ws = this.#ws;
@@ -974,6 +978,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
974
978
  ? input.userUtteranceIds.filter((id: unknown): id is string => typeof id === "string")
975
979
  : [],
976
980
  messages: Array.isArray(input.messages) ? input.messages : [],
981
+ priorToolResults: input.priorToolResults,
977
982
  },
978
983
  generation,
979
984
  );
@@ -81,8 +81,7 @@ export const wireSessionVoice = (
81
81
  busy: () => p.busy(),
82
82
  parked: () => s.webMcpApprovalResolvers.size > 0,
83
83
  claim: (text, userUtteranceIds) => p.keyed?.claimUserTurn(text, userUtteranceIds) ?? null,
84
- send: (text, userMessageId) =>
85
- s.sendMessage(text, { viaVoice: true, voiceTurn: { userMessageId } }),
84
+ send: (text, voiceTurn) => s.sendMessage(text, { viaVoice: true, voiceTurn }),
86
85
  track: (capture) => {
87
86
  p.setDelegation(capture);
88
87
  },
@@ -45,8 +45,11 @@ export interface VoiceDelegationHost {
45
45
  parked(): boolean;
46
46
  /** The transcript bubble the request came from (see KeyedVoiceTranscript.claimUserTurn). */
47
47
  claim(userText: string, userUtteranceIds: string[]): string | null;
48
- /** sendMessage as a voice turn, submitting `userMessageId`'s bubble when given. */
49
- send(userText: string, userMessageId: string | undefined): Promise<void>;
48
+ /**
49
+ * sendMessage as a voice turn, submitting `userMessageId`'s bubble when
50
+ * given and recording `priorToolResults` as already-run tool calls.
51
+ */
52
+ send(userText: string, voiceTurn: { userMessageId?: string | null; priorToolResults?: unknown }): Promise<void>;
50
53
  /** Route the chat stream's assistant messages and failures into `capture` (or stop). */
51
54
  track(capture: VoiceDelegationCapture | null): void;
52
55
  /** Deny a pending approval (server gate or WebMCP) by its message id. */
@@ -240,6 +243,7 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
240
243
  userText,
241
244
  userUtteranceIds,
242
245
  stream,
246
+ priorToolResults,
243
247
  }: VoiceDelegationRequest): Promise<VoiceDelegationResult> {
244
248
  const live = parks.filter((park) => pendingOf(park).length);
245
249
  const pendingIds = live.flatMap(pendingOf);
@@ -306,7 +310,10 @@ export function createVoiceSessionBridge(host: VoiceDelegationHost): VoiceSessio
306
310
  active = capture;
307
311
  retrack();
308
312
  try {
309
- await host.send(userText, host.claim(userText, userUtteranceIds) ?? undefined);
313
+ await host.send(userText, {
314
+ userMessageId: host.claim(userText, userUtteranceIds),
315
+ priorToolResults,
316
+ });
310
317
  // Local tools and approvals may continue the turn past the first stream.
311
318
  await settled(capture);
312
319
  } catch {
@@ -1762,7 +1762,7 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
1762
1762
  );
1763
1763
  const results = (ws: MockWebSocket) => sentJson(ws).filter((f) => f.type === 'client_tool_result');
1764
1764
 
1765
- it('declares client_tools only when the bridge has WebMCP tools', async () => {
1765
+ it('declares client_tools and prior_tool_results only when the bridge has WebMCP tools', async () => {
1766
1766
  const { ws } = await startFastCall();
1767
1767
  expect(ws.url).toBe(
1768
1768
  voiceUrl('wss://api.example.com', 'a1', [
@@ -1772,12 +1772,15 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
1772
1772
  'delegation_read_back',
1773
1773
  'delegation_stream',
1774
1774
  'client_tools',
1775
+ 'prior_tool_results',
1775
1776
  ]),
1776
1777
  );
1777
1778
  const provider = new RuntypeVoiceProvider(baseConfig());
1778
1779
  provider.setSessionBridge({ ...makeBridge().bridge, clientTools: () => null });
1779
1780
  await provider.startListening();
1780
- expect(new URL(lastWs().url).searchParams.get('clientCapabilities')).not.toContain('client_tools');
1781
+ const declared = new URL(lastWs().url).searchParams.get('clientCapabilities');
1782
+ expect(declared).not.toContain('client_tools');
1783
+ expect(declared).not.toContain('prior_tool_results');
1781
1784
  });
1782
1785
 
1783
1786
  it('sends client_tools after session_config with full webmcp: names and the dispatch fields', async () => {
@@ -2002,6 +2005,39 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
2002
2005
  ]);
2003
2006
  });
2004
2007
 
2008
+ it('hands a fall-through its priorToolResults without running them again on the page', async () => {
2009
+ const { ws, calls, executed, recorded, pending } = await startFastCall([...FAST_CAPS, 'prior_tool_results']);
2010
+ const prior = [
2011
+ { toolCallId: 'vtc_01', name: 'webmcp:jump_to_slide', arguments: { position: 5 }, result: { slideTitle: 'Pricing' } },
2012
+ ];
2013
+ heard(ws);
2014
+ toolCall(ws);
2015
+ await flush();
2016
+ expect(executed).toHaveLength(1);
2017
+ ws.triggerMessage(
2018
+ JSON.stringify({
2019
+ type: 'delegation_started',
2020
+ delegationId: 'd1',
2021
+ input: { text: 'Jump to the pricing slide', userUtteranceIds: ['u1'], messages: [], priorToolResults: prior },
2022
+ }),
2023
+ );
2024
+ await flush();
2025
+ expect(calls).toHaveLength(1);
2026
+ expect(calls[0].priorToolResults).toEqual(prior);
2027
+ pending[0]({ status: 'completed', text: 'You are on Pricing.' });
2028
+ await flush();
2029
+ directCompleted(ws);
2030
+ expect(executed).toHaveLength(1);
2031
+ expect(recorded).toEqual([]);
2032
+ });
2033
+
2034
+ it('leaves priorToolResults unset on a fall-through without them', async () => {
2035
+ const { ws, calls } = await startFastCall();
2036
+ started(ws, 'd1');
2037
+ await flush();
2038
+ expect(calls[0].priorToolResults).toBeUndefined();
2039
+ });
2040
+
2005
2041
  it('ignores a direct completion when client_tools was not negotiated', async () => {
2006
2042
  const { ws, recorded } = await startFastCall(ALL_CAPS);
2007
2043
  heard(ws);
@@ -1 +0,0 @@
1
- const e=[1e3,2e3,4e3,8e3,8e3];function t(t){let n=0,r=null,i=null,a=!1,o=()=>{if(i&&=(clearTimeout(i),null),r){let e=r;r=null,e()}},s=()=>{let e=t.getStatus();(e===`resuming`||e===`paused`)&&o()},c=()=>{(typeof document>`u`||document.visibilityState!==`hidden`)&&s()},l=()=>{s()},u=()=>{a||=(typeof document<`u`&&document.addEventListener(`visibilitychange`,c),typeof window<`u`&&window.addEventListener(`online`,l),!0)},d=()=>{a&&=(typeof document<`u`&&document.removeEventListener(`visibilitychange`,c),typeof window<`u`&&window.removeEventListener(`online`,l),!1)},f=e=>new Promise(t=>{r=t,i=setTimeout(()=>{i=null,r=null,t()},e)}),p=()=>{let e=t.getResumable();t.clearResumable(),t.setReconnecting(!1),n=0,d(),t.setAbortController(null);let r=!1;for(let e of t.getMessages())e.streaming&&(e.streaming=!1,r=!0);let i=t.buildErrorContent(`Connection lost and the response could not be resumed.`);i?t.appendMessage({id:`reconnect-failed-${e?.executionId??t.nextSequence()}`,role:`assistant`,content:i,createdAt:(/* @__PURE__ */ new Date()).toISOString(),streaming:!1,sequence:t.nextSequence()}):r&&t.notifyMessagesChanged(),t.setStreaming(!1),t.setStatus(`idle`),t.onError(/* @__PURE__ */ Error(`Durable session reconnect failed.`))},m=async()=>{let r=t.config.reconnect?.backoffMs??e,i=t.config.reconnect?.maxAttempts??r.length,a=t.config.reconnectStream;if(!a)t.setReconnecting(!1);else{for(;t.getResumable()&&n<i;){n+=1,t.setStatus(`resuming`);let e=t.getResumable(),o=e.lastEventId,s=new AbortController;t.setAbortController(s),t.emitReconnect({phase:`resuming`,handle:e,attempt:n});let c=null;try{let t=await a({executionId:e.executionId,after:e.lastEventId,signal:s.signal});t&&t.ok&&t.body&&(c=t.body)}catch{c=null}if(s.signal.aborted)return;if(c){let r=t.getMessages().find(t=>t.id===e.assistantMessageId)?.content,i=typeof r==`string`?r:``;try{await t.resumeConnect(c,e.assistantMessageId,i)}catch{}if(s.signal.aborted)return;if(!t.getResumable()){t.setReconnecting(!1),n=0,d(),t.emitReconnect({phase:`resumed`,handle:e});return}t.getResumable().lastEventId!==o&&(n=0)}if(t.getResumable()&&n<i&&(t.setStatus(`paused`),await f(r[Math.min(n-1,r.length-1)]??1e3),s.signal.aborted))return}t.getResumable()&&p()}};return{begin(){n=0,u(),m()},teardown(){i&&=(clearTimeout(i),null),r=null,n=0,d()},wake:o}}export{t as createReconnectController};
@@ -1 +0,0 @@
1
- const e=`4.29.0`;export{e as t};