@runtypelabs/persona 4.29.0 → 4.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/wipe.d.cts +1 -1
  4. package/dist/animations/wipe.d.ts +1 -1
  5. package/dist/approval-ui.cjs +3 -3
  6. package/dist/approval-ui.d.cts +1 -1
  7. package/dist/approval-ui.d.ts +1 -1
  8. package/dist/approval-ui.js +3 -3
  9. package/dist/artifacts-ui.d.cts +2 -2
  10. package/dist/artifacts-ui.d.ts +2 -2
  11. package/dist/chunk-B1-_pp-Y.js +4 -0
  12. package/dist/chunk-B2HCRERn.js +9 -0
  13. package/dist/chunk-Bm2-bemv.js +1 -0
  14. package/dist/chunk-BvhTpG6k.js +1 -0
  15. package/dist/{chunk-DjOvgFsn.js → chunk-Car1mORT.js} +1 -1
  16. package/dist/chunk-D5B629Mj.js +1 -0
  17. package/dist/chunk-DO9_WSpy.js +1 -0
  18. package/dist/chunk-DTDHeUsG.js +1 -0
  19. package/dist/chunk-DjFpGFGA.js +1 -0
  20. package/dist/chunk-DrpuvY5W.js +1 -0
  21. package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
  22. package/dist/client-history.js +1 -1
  23. package/dist/client-stream.js +1 -1
  24. package/dist/codegen.cjs +1 -1
  25. package/dist/codegen.js +1 -1
  26. package/dist/{composer-input-D3sB8_P_.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
  27. package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
  28. package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
  29. package/dist/{context-mention-manager-D2qxE5op.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
  30. package/dist/context-mentions-inline.d.cts +2 -2
  31. package/dist/context-mentions-inline.d.ts +2 -2
  32. package/dist/context-mentions.d.cts +3 -3
  33. package/dist/context-mentions.d.ts +3 -3
  34. package/dist/{defaults-Dsw66XqE.d.cts → defaults-DN-L67ak.d.cts} +1 -1
  35. package/dist/{defaults-Dsw66XqE.d.ts → defaults-DN-L67ak.d.ts} +1 -1
  36. package/dist/event-stream-view.d.cts +1 -1
  37. package/dist/event-stream-view.d.ts +1 -1
  38. package/dist/{fallback-speech-engine-DJpJcWAk.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
  39. package/dist/{fallback-speech-engine-DJpJcWAk.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
  40. package/dist/forms-ui.d.cts +2 -2
  41. package/dist/forms-ui.d.ts +2 -2
  42. package/dist/history-view.d.cts +1 -1
  43. package/dist/history-view.d.ts +1 -1
  44. package/dist/index.cjs +56 -56
  45. package/dist/index.cjs.map +1 -1
  46. package/dist/index.d.cts +6 -6
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.global.js +2 -3
  49. package/dist/index.global.js.map +1 -1
  50. package/dist/index.js +42 -42
  51. package/dist/index.js.map +1 -1
  52. package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
  53. package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
  54. package/dist/{preview-DKKpfTo7.d.cts → preview-m9QlqsZi.d.cts} +2 -2
  55. package/dist/{preview-DKKpfTo7.d.ts → preview-m9QlqsZi.d.ts} +2 -2
  56. package/dist/{session-DleMr9dZ.d.ts → session-BtvvmWnw.d.cts} +21 -9
  57. package/dist/{session-DleMr9dZ.d.cts → session-BtvvmWnw.d.ts} +21 -9
  58. package/dist/session-actions.js +1 -1
  59. package/dist/session-reconnect.cjs +1 -1
  60. package/dist/session-reconnect.d.cts +8 -2
  61. package/dist/session-reconnect.d.ts +8 -2
  62. package/dist/session-reconnect.js +1 -1
  63. package/dist/smart-dom-reader.d.cts +1 -1
  64. package/dist/smart-dom-reader.d.ts +1 -1
  65. package/dist/theme-editor-preview.cjs +62 -62
  66. package/dist/theme-editor-preview.d.cts +1 -1
  67. package/dist/theme-editor-preview.d.ts +1 -1
  68. package/dist/theme-editor-preview.js +41 -41
  69. package/dist/theme-editor.d.cts +2 -2
  70. package/dist/theme-editor.d.ts +2 -2
  71. package/dist/theme-reference.d.cts +2 -2
  72. package/dist/theme-reference.d.ts +2 -2
  73. package/dist/{types-oAZ1VReS.d.cts → types-DL-oBHE1.d.cts} +41 -3
  74. package/dist/{types-oAZ1VReS.d.ts → types-DL-oBHE1.d.ts} +41 -3
  75. package/dist/voice-client-tools.d.cts +1 -1
  76. package/dist/voice-client-tools.d.ts +1 -1
  77. package/dist/voice-runtime.cjs +3 -3
  78. package/dist/voice-runtime.d.cts +10 -4
  79. package/dist/voice-runtime.d.ts +10 -4
  80. package/dist/voice-runtime.js +5 -5
  81. package/dist/voice-worklet-player.d.cts +2 -2
  82. package/dist/voice-worklet-player.d.ts +2 -2
  83. package/dist/{webmcp-runtime-entry-Deg0NfmB.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
  84. package/dist/{webmcp-runtime-entry-Deg0NfmB.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
  85. package/dist/webmcp-runtime.d.cts +1 -1
  86. package/dist/webmcp-runtime.d.ts +1 -1
  87. package/dist/widget.css +1 -1
  88. package/package.json +1 -1
  89. package/src/client-approve.test.ts +17 -1
  90. package/src/client-resume.ts +15 -3
  91. package/src/client.interrupt.test.ts +21 -0
  92. package/src/client.ts +24 -4
  93. package/src/components/activity-row.ts +3 -2
  94. package/src/components/approval-actions.test.ts +8 -0
  95. package/src/components/approval-actions.ts +6 -2
  96. package/src/components/approval-bubble.test.ts +14 -0
  97. package/src/components/approval-bubble.ts +8 -1
  98. package/src/components/tool-bubble.ts +8 -5
  99. package/src/generated/runtype-openapi-contract.ts +7 -1
  100. package/src/resume-dedupe-local-only.test.ts +273 -0
  101. package/src/runtime/persist-state.test.ts +86 -0
  102. package/src/session-actions.ts +20 -6
  103. package/src/session-reconnect.ts +18 -1
  104. package/src/session.restored-browser-tools.test.ts +272 -0
  105. package/src/session.test.ts +23 -0
  106. package/src/session.ts +211 -46
  107. package/src/session.voice.test.ts +169 -0
  108. package/src/styles/widget.css +4 -2
  109. package/src/types.ts +37 -2
  110. package/src/ui.restored-browser-tools.test.ts +128 -0
  111. package/src/ui.scroll.test.ts +7 -2
  112. package/src/ui.stream-replay.test.ts +86 -0
  113. package/src/ui.streaming-coalescing.test.ts +183 -0
  114. package/src/ui.ts +152 -124
  115. package/src/ui.typing-indicator.test.ts +21 -0
  116. package/src/utils/formatting.test.ts +9 -1
  117. package/src/utils/formatting.ts +4 -0
  118. package/src/utils/local-only-message.ts +15 -0
  119. package/src/utils/message-fingerprint.test.ts +9 -0
  120. package/src/utils/message-fingerprint.ts +18 -5
  121. package/src/utils/morph.ts +10 -0
  122. package/src/utils/restored-browser-state.ts +50 -0
  123. package/src/utils/storage.ts +12 -1
  124. package/src/utils/voice-prior-tool-results.ts +37 -0
  125. package/src/voice/runtype-voice-provider.ts +13 -8
  126. package/src/voice/session-voice-wiring.ts +1 -2
  127. package/src/voice/voice-delegation.ts +10 -3
  128. package/src/voice/voice.test.ts +38 -2
  129. package/dist/chunk-B6zZsT6F.js +0 -1
  130. package/dist/chunk-CIVsNcrk.js +0 -1
  131. package/dist/chunk-CKdlNyzM.js +0 -9
  132. package/dist/chunk-DbhxjyOB.js +0 -4
  133. package/dist/chunk-DsdqLStH.js +0 -1
  134. package/dist/chunk-GIqbL2Eg.js +0 -1
package/src/types.ts CHANGED
@@ -5267,6 +5267,13 @@ export type VoiceDelegationRequest = {
5267
5267
  userUtteranceIds: string[];
5268
5268
  /** The voice model's view of the conversation it delegated from. */
5269
5269
  messages: Array<{ role: string; content: unknown }>;
5270
+ /**
5271
+ * `delegation_started.input.priorToolResults` as received: page tools the
5272
+ * server already ran for this turn, each `{ toolCallId, name, arguments,
5273
+ * result }`. Unvalidated. The chat turn records them as completed calls and
5274
+ * never runs them again.
5275
+ */
5276
+ priorToolResults?: unknown;
5270
5277
  /**
5271
5278
  * Set when the server streams delegated answers (`delegation_stream`): called
5272
5279
  * with each speakable piece of the turn's answer, in order, while it renders.
@@ -6404,7 +6411,23 @@ export type ClientChatRequest = {
6404
6411
  * reads aloud: the server asks the agent for a speakable answer (the direct
6405
6412
  * answer first, in plain sentences). Not persisted into history.
6406
6413
  */
6407
- voice?: { spoken: true };
6414
+ voice?: {
6415
+ spoken: true;
6416
+ /**
6417
+ * Page tools the voice server already ran for this hand-back
6418
+ * (`delegation_started.input.priorToolResults`), sent only when it carried
6419
+ * some. The agent narrates their results instead of calling them again.
6420
+ */
6421
+ priorToolResults?: VoicePriorToolResult[];
6422
+ };
6423
+ };
6424
+
6425
+ /** A page tool call the voice server already ran, as `/v1/client/chat` takes it. */
6426
+ export type VoicePriorToolResult = {
6427
+ toolCallId: string;
6428
+ name: string;
6429
+ arguments: Record<string, unknown>;
6430
+ result: unknown;
6408
6431
  };
6409
6432
 
6410
6433
  /**
@@ -7206,7 +7229,8 @@ export type AgentWidgetPersistStateConfig = {
7206
7229
  */
7207
7230
  storage?: 'local' | 'session';
7208
7231
  /**
7209
- * Prefix for storage keys.
7232
+ * Prefix for storage keys. Also scopes the default transcript key
7233
+ * (`${keyPrefix}state` in localStorage) when no `storageAdapter` is given.
7210
7234
  * @default 'persona-'
7211
7235
  */
7212
7236
  keyPrefix?: string;
@@ -8354,6 +8378,8 @@ export type AgentWidgetReasoning = {
8354
8378
  export type AgentWidgetToolCall = {
8355
8379
  /** Optional approval state for activity-row status chrome. */
8356
8380
  approvalStatus?: "pending" | "approved" | "denied" | "timeout";
8381
+ /** Terminal browser work whose callback was lost when the chat restarted. */
8382
+ interrupted?: boolean;
8357
8383
  id: string;
8358
8384
  name?: string;
8359
8385
  status: "pending" | "running" | "complete";
@@ -8381,6 +8407,8 @@ export type AgentWidgetToolCall = {
8381
8407
  export type AgentWidgetApproval = {
8382
8408
  id: string;
8383
8409
  status: "pending" | "approved" | "denied" | "timeout";
8410
+ /** A browser callback lost on restart. The approval expires with status `timeout`. */
8411
+ interrupted?: boolean;
8384
8412
  agentId: string;
8385
8413
  executionId: string;
8386
8414
  toolName: string;
@@ -8505,6 +8533,13 @@ export type AgentWidgetMessage = {
8505
8533
  * model as conversation.
8506
8534
  */
8507
8535
  voiceCaption?: boolean;
8536
+ /**
8537
+ * Set on widget-generated failure notices (a failed dispatch, a rejected
8538
+ * approval decision, a reconnect that gave up). The bubble renders in this
8539
+ * browser only: it is never sent to the model as conversation history and
8540
+ * never written to the storage adapter.
8541
+ */
8542
+ localOnly?: boolean;
8508
8543
  /**
8509
8544
  * Raw structured payload for this message (e.g., JSON action response).
8510
8545
  * Populated automatically when structured parsers run.
@@ -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
+ });
@@ -441,8 +441,9 @@ describe("createAgentExperience streaming scroll", () => {
441
441
  metrics.setScrollHeight(1080);
442
442
  emitStreamingMessage(controller, "Chunk two");
443
443
 
444
- // Only run the scheduled auto-scroll frame, not the whole animation.
445
- raf.step(1);
444
+ // Run the coalesced render frame and the auto-scroll frame it schedules,
445
+ // not the whole animation.
446
+ raf.step(2);
446
447
 
447
448
  expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
448
449
 
@@ -1077,6 +1078,8 @@ describe("createAgentExperience streaming scroll", () => {
1077
1078
  // header of the streaming reply). No handoff.
1078
1079
  metrics.setScrollHeight(1284);
1079
1080
  emitStreamingMessage(controller, "Streaming response");
1081
+ // Streaming updates render on the next animation frame.
1082
+ raf.step(1);
1080
1083
  const streamBubble = scrollContainer!.querySelector<HTMLElement>(
1081
1084
  `[data-message-id="${STREAM_MESSAGE_ID}"]`
1082
1085
  );
@@ -1269,6 +1272,8 @@ describe("createAgentExperience streaming scroll", () => {
1269
1272
  // so the anchor hands off to the block: target = 600 - 16 = 584. This is
1270
1273
  // the stranded case the handoff exists for (tall send / tool prelude).
1271
1274
  emitStreamingMessage(controller, "Streaming response");
1275
+ // Streaming updates render on the next animation frame.
1276
+ raf.step(1);
1272
1277
  const streamBubble = scrollContainer!.querySelector<HTMLElement>(
1273
1278
  `[data-message-id="${STREAM_MESSAGE_ID}"]`
1274
1279
  );
@@ -0,0 +1,86 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import * as morph from "./utils/morph";
7
+
8
+ vi.mock("./utils/morph", async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import("./utils/morph")>();
10
+ return { ...actual, morphMessages: vi.fn(actual.morphMessages) };
11
+ });
12
+
13
+ // A finished, tool-heavy agent run replayed through `connectStream` arrives as
14
+ // one burst of already-queued frames. Each frame used to render the whole
15
+ // transcript synchronously, so replay cost O(frames²) and crashed the tab
16
+ // (#506). The burst now coalesces into a bounded number of render passes.
17
+ const replayFrames = (pairs: number): string[] => {
18
+ const frames: string[] = [];
19
+ let seq = 0;
20
+ const push = (event: Record<string, unknown>) => {
21
+ seq += 1;
22
+ frames.push(
23
+ `id: ${seq}\nevent: ${event.type}\ndata: ${JSON.stringify({ ...event, seq, executionId: "exec_1" })}\n\n`
24
+ );
25
+ };
26
+ push({ type: "execution_start", kind: "agent", startedAt: new Date().toISOString() });
27
+ for (let index = 0; index < pairs; index += 1) {
28
+ const toolCallId = `call_${index}`;
29
+ push({
30
+ type: "tool_start",
31
+ toolCallId,
32
+ toolName: "get_item",
33
+ toolType: "mcp",
34
+ parameters: { id: `item_${index}` },
35
+ startedAt: new Date().toISOString(),
36
+ });
37
+ push({
38
+ type: "tool_complete",
39
+ toolCallId,
40
+ toolName: "get_item",
41
+ success: true,
42
+ result: `{"id":"item_${index}","description":"${"x".repeat(150)}"}`,
43
+ executionTime: 12,
44
+ });
45
+ }
46
+ push({
47
+ type: "execution_complete",
48
+ kind: "agent",
49
+ success: true,
50
+ outcome: "completed",
51
+ completedAt: new Date().toISOString(),
52
+ });
53
+ return frames;
54
+ };
55
+
56
+ afterEach(() => {
57
+ document.body.innerHTML = "";
58
+ vi.mocked(morph.morphMessages).mockClear();
59
+ });
60
+
61
+ it("replays a long tool-heavy transcript in a bounded number of render passes", async () => {
62
+ const pairs = 200;
63
+ const mount = document.createElement("div");
64
+ document.body.appendChild(mount);
65
+ const controller = createAgentExperience(mount, {
66
+ apiUrl: "https://api.example.com/chat",
67
+ launcher: { enabled: false },
68
+ features: { showToolCalls: true, toolCallDisplay: { collapsedMode: "tool-name" } },
69
+ });
70
+ const encoder = new TextEncoder();
71
+ const stream = new ReadableStream<Uint8Array>({
72
+ start(streamController) {
73
+ for (const frame of replayFrames(pairs)) streamController.enqueue(encoder.encode(frame));
74
+ streamController.close();
75
+ },
76
+ });
77
+ vi.mocked(morph.morphMessages).mockClear();
78
+
79
+ await controller.connectStream(stream);
80
+
81
+ expect(mount.querySelectorAll(".persona-tool-bubble")).toHaveLength(pairs);
82
+ expect(controller.getState().streaming).toBe(false);
83
+ // 2 × pairs frames; a handful of passes for stream start/end bookkeeping.
84
+ expect(vi.mocked(morph.morphMessages).mock.calls.length).toBeLessThan(10);
85
+ controller.destroy();
86
+ });
@@ -316,3 +316,186 @@ describe("pure streaming text update coalescing", () => {
316
316
  stream.close();
317
317
  });
318
318
  });
319
+
320
+ describe("streaming tool update coalescing", () => {
321
+ const originalFetch = global.fetch;
322
+
323
+ beforeEach(() => {
324
+ window.scrollTo = vi.fn();
325
+ });
326
+
327
+ afterEach(() => {
328
+ document.body.innerHTML = "";
329
+ global.fetch = originalFetch;
330
+ vi.restoreAllMocks();
331
+ vi.unstubAllGlobals();
332
+ });
333
+
334
+ const sendToolPair = (stream: ReturnType<typeof createStreamHarness>, index: number) => {
335
+ const toolCallId = `call_${index}`;
336
+ stream.send("tool_start", {
337
+ executionId: "exec-1",
338
+ toolCallId,
339
+ toolName: "get_item",
340
+ toolType: "mcp",
341
+ parameters: { id: `item_${index}` },
342
+ startedAt: new Date().toISOString(),
343
+ });
344
+ stream.send("tool_complete", {
345
+ executionId: "exec-1",
346
+ toolCallId,
347
+ toolName: "get_item",
348
+ success: true,
349
+ result: `{"id":"item_${index}"}`,
350
+ executionTime: 12,
351
+ });
352
+ };
353
+
354
+ const toolRows = (mount: HTMLElement) => mount.querySelectorAll(".persona-tool-bubble").length;
355
+
356
+ it("renders a burst of tool frames once per animation frame", async () => {
357
+ const raf = installRafMock();
358
+ const stream = createStreamHarness();
359
+ global.fetch = stream.fetchMock;
360
+ const saves: AgentWidgetStoredState[] = [];
361
+ const { mount, controller } = createWidget((state) => saves.push(state));
362
+ await startTurn(mount);
363
+ stream.send("execution_start", { kind: "agent", executionId: "exec-1" });
364
+ await flushMicrotasks();
365
+ raf.flush();
366
+ const beforeBurst = saves.length;
367
+
368
+ for (let index = 0; index < 5; index += 1) sendToolPair(stream, index);
369
+ await flushMicrotasks(60);
370
+
371
+ expect(toolRows(mount)).toBe(0);
372
+ expect(saves).toHaveLength(beforeBurst);
373
+ raf.step();
374
+ expect(toolRows(mount)).toBe(5);
375
+ expect(saves).toHaveLength(beforeBurst + 1);
376
+
377
+ controller.destroy();
378
+ stream.close();
379
+ });
380
+
381
+ it("emits assistant:complete for a message that starts and finishes within one frame", async () => {
382
+ const raf = installRafMock();
383
+ const stream = createStreamHarness();
384
+ global.fetch = stream.fetchMock;
385
+ const { mount, controller } = createWidget(() => {});
386
+ const completed = vi.fn();
387
+ controller.on("assistant:complete", completed);
388
+ await startTurn(mount);
389
+
390
+ startText(stream, ["Hello", " world"]);
391
+ finishText(stream);
392
+ await flushMicrotasks(60);
393
+ raf.flush();
394
+
395
+ expect(controller.getState().streaming).toBe(false);
396
+ expect(completed).toHaveBeenCalledTimes(1);
397
+ expect(completed.mock.calls[0][0].content).toBe("Hello world");
398
+ controller.destroy();
399
+ });
400
+
401
+ it("leaves unchanged transcript rows in place across render passes", async () => {
402
+ const raf = installRafMock();
403
+ const stream = createStreamHarness();
404
+ global.fetch = stream.fetchMock;
405
+ const { mount, controller } = createWidget(() => {});
406
+ await startTurn(mount);
407
+ stream.send("execution_start", { kind: "agent", executionId: "exec-1" });
408
+ sendToolPair(stream, 0);
409
+ await flushMicrotasks(40);
410
+ raf.flush();
411
+
412
+ const firstRow = mount
413
+ .querySelector<HTMLElement>(".persona-tool-bubble")!
414
+ .closest<HTMLElement>("[data-wrapper-id]")!;
415
+ // A marker no render produces: re-morphing the row from the cache strips it.
416
+ firstRow.setAttribute("data-test-live", "true");
417
+
418
+ sendToolPair(stream, 1);
419
+ await flushMicrotasks(40);
420
+ raf.flush();
421
+
422
+ expect(toolRows(mount)).toBe(2);
423
+ const rowAfter = mount.querySelector<HTMLElement>(
424
+ `[data-wrapper-id="${firstRow.getAttribute("data-wrapper-id")}"]`
425
+ );
426
+ expect(rowAfter).toBe(firstRow);
427
+ expect(rowAfter?.getAttribute("data-test-live")).toBe("true");
428
+ expect(rowAfter?.querySelector(".persona-tool-bubble")).not.toBeNull();
429
+ controller.destroy();
430
+ stream.close();
431
+ });
432
+ it("saves the newest coalesced snapshot alongside the durable cursor", async () => {
433
+ const raf = installRafMock();
434
+ const saves: AgentWidgetStoredState[] = [];
435
+ const mount = document.createElement("div");
436
+ document.body.appendChild(mount);
437
+ const controller = createAgentExperience(mount, {
438
+ apiUrl: "https://api.example.com/chat",
439
+ clientToken: "ct_test",
440
+ launcher: { enabled: false },
441
+ storageAdapter: { save: (state) => { saves.push(state); } },
442
+ });
443
+ const at = (second: number) => `2026-07-11T00:00:0${second}.000Z`;
444
+ controller.injectTestMessage({ type: "status", status: "connecting" });
445
+ controller.injectTestMessage({
446
+ type: "message",
447
+ message: {
448
+ id: "reply-1",
449
+ role: "assistant",
450
+ content: "Working",
451
+ createdAt: at(0),
452
+ streaming: true,
453
+ agentMetadata: { executionId: "exec-1" },
454
+ },
455
+ });
456
+ controller.injectTestMessage({ type: "cursor", id: "1" });
457
+ raf.flush();
458
+ await flushMicrotasks();
459
+
460
+ // One SSE chunk: an approval (applied now, its save queued) then a tool
461
+ // update (coalesced to the next frame) that advances the cursor.
462
+ controller.injectTestMessage({
463
+ type: "message",
464
+ message: {
465
+ id: "approval-1",
466
+ role: "assistant",
467
+ content: "",
468
+ createdAt: at(1),
469
+ streaming: false,
470
+ variant: "approval",
471
+ approval: {
472
+ id: "approval-1",
473
+ status: "pending",
474
+ agentId: "agent-1",
475
+ executionId: "exec-1",
476
+ toolName: "Approve search",
477
+ description: "Approval test",
478
+ },
479
+ },
480
+ });
481
+ controller.injectTestMessage({
482
+ type: "message",
483
+ message: {
484
+ id: "tool-1",
485
+ role: "assistant",
486
+ content: "",
487
+ createdAt: at(2),
488
+ streaming: true,
489
+ variant: "tool",
490
+ toolCall: { id: "tool-1", name: "search", status: "running", chunks: [] },
491
+ },
492
+ });
493
+ controller.injectTestMessage({ type: "cursor", id: "2" });
494
+ await flushMicrotasks();
495
+
496
+ const saved = saves.at(-1)!;
497
+ expect(saved.metadata?.durableResume).toMatchObject({ after: "2" });
498
+ expect(saved.messages?.map((message) => message.id)).toContain("tool-1");
499
+ controller.destroy();
500
+ });
501
+ });