@runtypelabs/persona 4.29.1 → 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 (85) 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.d.cts +1 -1
  6. package/dist/approval-ui.d.ts +1 -1
  7. package/dist/artifacts-ui.d.cts +2 -2
  8. package/dist/artifacts-ui.d.ts +2 -2
  9. package/dist/{chunk-DGlFKA0r.js → chunk-B2HCRERn.js} +1 -1
  10. package/dist/{chunk-BcLIgi1h.js → chunk-Car1mORT.js} +1 -1
  11. package/dist/chunk-DrpuvY5W.js +1 -0
  12. package/dist/client-history.js +1 -1
  13. package/dist/codegen.cjs +1 -1
  14. package/dist/codegen.js +1 -1
  15. package/dist/{composer-input-B0P4OtNi.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
  16. package/dist/{composer-input-B0P4OtNi.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
  17. package/dist/{context-mention-manager-DmLI_RM6.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
  18. package/dist/{context-mention-manager-DmLI_RM6.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
  19. package/dist/context-mentions-inline.d.cts +2 -2
  20. package/dist/context-mentions-inline.d.ts +2 -2
  21. package/dist/context-mentions.d.cts +3 -3
  22. package/dist/context-mentions.d.ts +3 -3
  23. package/dist/{defaults-DVo1wbZs.d.cts → defaults-DN-L67ak.d.cts} +1 -1
  24. package/dist/{defaults-DVo1wbZs.d.ts → defaults-DN-L67ak.d.ts} +1 -1
  25. package/dist/event-stream-view.d.cts +1 -1
  26. package/dist/event-stream-view.d.ts +1 -1
  27. package/dist/{fallback-speech-engine-BMnlmKnY.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
  28. package/dist/{fallback-speech-engine-BMnlmKnY.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
  29. package/dist/forms-ui.d.cts +2 -2
  30. package/dist/forms-ui.d.ts +2 -2
  31. package/dist/history-view.d.cts +1 -1
  32. package/dist/history-view.d.ts +1 -1
  33. package/dist/index.cjs +51 -51
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +6 -6
  36. package/dist/index.d.ts +6 -6
  37. package/dist/index.global.js +1 -1
  38. package/dist/index.global.js.map +1 -1
  39. package/dist/index.js +37 -37
  40. package/dist/index.js.map +1 -1
  41. package/dist/{markdown-parsers-loader-CeovcyYf.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
  42. package/dist/{markdown-parsers-loader-CeovcyYf.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
  43. package/dist/{preview-Bkb4UrQC.d.cts → preview-m9QlqsZi.d.cts} +2 -2
  44. package/dist/{preview-Bkb4UrQC.d.ts → preview-m9QlqsZi.d.ts} +2 -2
  45. package/dist/{session-iLyKipoM.d.cts → session-BtvvmWnw.d.cts} +6 -6
  46. package/dist/{session-iLyKipoM.d.ts → session-BtvvmWnw.d.ts} +6 -6
  47. package/dist/session-reconnect.d.cts +2 -2
  48. package/dist/session-reconnect.d.ts +2 -2
  49. package/dist/smart-dom-reader.d.cts +1 -1
  50. package/dist/smart-dom-reader.d.ts +1 -1
  51. package/dist/theme-editor-preview.cjs +54 -54
  52. package/dist/theme-editor-preview.d.cts +1 -1
  53. package/dist/theme-editor-preview.d.ts +1 -1
  54. package/dist/theme-editor-preview.js +35 -35
  55. package/dist/theme-editor.d.cts +2 -2
  56. package/dist/theme-editor.d.ts +2 -2
  57. package/dist/theme-reference.d.cts +2 -2
  58. package/dist/theme-reference.d.ts +2 -2
  59. package/dist/{types-BuDirJkQ.d.cts → types-DL-oBHE1.d.cts} +6 -0
  60. package/dist/{types-BuDirJkQ.d.ts → types-DL-oBHE1.d.ts} +6 -0
  61. package/dist/voice-client-tools.d.cts +1 -1
  62. package/dist/voice-client-tools.d.ts +1 -1
  63. package/dist/voice-runtime.cjs +1 -1
  64. package/dist/voice-runtime.d.cts +2 -2
  65. package/dist/voice-runtime.d.ts +2 -2
  66. package/dist/voice-runtime.js +1 -1
  67. package/dist/voice-worklet-player.d.cts +2 -2
  68. package/dist/voice-worklet-player.d.ts +2 -2
  69. package/dist/{webmcp-runtime-entry-CXLjRt1k.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
  70. package/dist/{webmcp-runtime-entry-CXLjRt1k.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
  71. package/dist/webmcp-runtime.d.cts +1 -1
  72. package/dist/webmcp-runtime.d.ts +1 -1
  73. package/package.json +1 -1
  74. package/src/generated/runtype-openapi-contract.ts +6 -0
  75. package/src/session.test.ts +23 -0
  76. package/src/session.ts +35 -17
  77. package/src/ui.scroll.test.ts +7 -2
  78. package/src/ui.stream-replay.test.ts +86 -0
  79. package/src/ui.streaming-coalescing.test.ts +183 -0
  80. package/src/ui.ts +120 -106
  81. package/src/ui.typing-indicator.test.ts +21 -0
  82. package/src/utils/message-fingerprint.test.ts +9 -0
  83. package/src/utils/message-fingerprint.ts +18 -5
  84. package/src/utils/morph.ts +10 -0
  85. package/dist/chunk-Csks3Qw1.js +0 -1
@@ -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
+ });
package/src/ui.ts CHANGED
@@ -4935,6 +4935,13 @@ export const createAgentExperience = (
4935
4935
 
4936
4936
  let isStreaming = false;
4937
4937
  const messageCache = createMessageCache();
4938
+ // Assistant message ids seen streaming in a coalesced (never-applied)
4939
+ // transcript snapshot; see `coalesceStreamingChange`.
4940
+ const coalescedStreamingIds = new Set<string>();
4941
+ // Fingerprint each live transcript row was last morphed to, by message id.
4942
+ // A row whose message still has that fingerprint is left in place by the
4943
+ // next render pass instead of being cloned from the cache and re-morphed.
4944
+ let liveRowFingerprints = new Map<string, string>();
4938
4945
  // Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
4939
4946
  // bubble for, per message id. Lets us skip unnecessary rebuilds across
4940
4947
  // re-renders so user state inside the plugin (typed text, focus) survives.
@@ -6029,7 +6036,7 @@ export const createAgentExperience = (
6029
6036
 
6030
6037
  if (
6031
6038
  message.role === "assistant" &&
6032
- previous?.streaming &&
6039
+ (previous?.streaming || coalescedStreamingIds.has(message.id)) &&
6033
6040
  message.streaming === false
6034
6041
  ) {
6035
6042
  eventBus.emit("assistant:complete", message);
@@ -6049,6 +6056,7 @@ export const createAgentExperience = (
6049
6056
  nextState.forEach((value, key) => {
6050
6057
  messageState.set(key, value);
6051
6058
  });
6059
+ coalescedStreamingIds.clear();
6052
6060
  };
6053
6061
 
6054
6062
 
@@ -6115,6 +6123,21 @@ export const createAgentExperience = (
6115
6123
  // Build new content in a temporary container for morphing
6116
6124
  const tempContainer = document.createElement("div");
6117
6125
 
6126
+ // Unchanged messages keep their live row: the pass appends an empty
6127
+ // `data-persona-reuse` stub that `morphMessages` matches by id and skips,
6128
+ // so a render costs O(changed rows) rather than a deep clone, serialize,
6129
+ // and morph of the whole transcript. Tool grouping restyles and re-parents
6130
+ // rows after this loop, so grouped transcripts always rebuild.
6131
+ const reuseLiveRows = !config.features?.toolCallDisplay?.grouped;
6132
+ const liveRows = new Set<string>();
6133
+ if (reuseLiveRows) {
6134
+ for (const child of Array.from(container.children)) {
6135
+ const id = child.getAttribute("data-wrapper-id");
6136
+ if (id) liveRows.add(id);
6137
+ }
6138
+ }
6139
+ const nextLiveRowFingerprints = new Map<string, string>();
6140
+
6118
6141
  // Create inline loading indicator renderer using priority chain: plugin -> config -> default
6119
6142
  const getInlineLoadingIndicatorRenderer = (): LoadingIndicatorRenderer | undefined => {
6120
6143
  // Check if any plugin has renderLoadingIndicator
@@ -6328,7 +6351,19 @@ export const createAgentExperience = (
6328
6351
  ? null
6329
6352
  : getCachedWrapper(messageCache, message.id, fingerprint);
6330
6353
  if (cachedWrapper) {
6331
- tempContainer.appendChild(cachedWrapper.cloneNode(true));
6354
+ if (
6355
+ liveRows.has(message.id) &&
6356
+ liveRowFingerprints.get(message.id) === fingerprint
6357
+ ) {
6358
+ const stub = document.createElement("div");
6359
+ stub.id = `wrapper-${message.id}`;
6360
+ stub.setAttribute("data-wrapper-id", message.id);
6361
+ stub.setAttribute("data-persona-reuse", "true");
6362
+ tempContainer.appendChild(stub);
6363
+ } else {
6364
+ tempContainer.appendChild(cachedWrapper.cloneNode(true));
6365
+ }
6366
+ if (reuseLiveRows) nextLiveRowFingerprints.set(message.id, fingerprint);
6332
6367
  // Keep the overlay sheet alive only while the server is actively
6333
6368
  // waiting on the user (awaitingLocalTool === true). Before await
6334
6369
  // fires, or after the answer resumes the flow, omit from
@@ -6784,6 +6819,7 @@ export const createAgentExperience = (
6784
6819
  wrapper.appendChild(bubble);
6785
6820
  setCachedWrapper(messageCache, message.id, fingerprint, wrapper);
6786
6821
  tempContainer.appendChild(wrapper);
6822
+ if (reuseLiveRows) nextLiveRowFingerprints.set(message.id, fingerprint);
6787
6823
  });
6788
6824
 
6789
6825
  // Prune any ask_user_question sheets whose source message is no longer in
@@ -7120,6 +7156,16 @@ export const createAgentExperience = (
7120
7156
 
7121
7157
  // Use idiomorph to morph the container contents
7122
7158
  morphMessages(container, tempContainer);
7159
+ liveRowFingerprints = nextLiveRowFingerprints;
7160
+ // A stub idiomorph inserted instead of matching to its live row would
7161
+ // leave an empty row; restore it from the cache.
7162
+ for (const stub of Array.from(
7163
+ container.querySelectorAll<HTMLElement>(":scope > [data-persona-reuse]")
7164
+ )) {
7165
+ const cached = messageCache.get(stub.getAttribute("data-wrapper-id") ?? "");
7166
+ if (cached) stub.replaceWith(cached.wrapper.cloneNode(true));
7167
+ else stub.remove();
7168
+ }
7123
7169
 
7124
7170
  // Set initial edge-fade state on the live table wrappers and attach the
7125
7171
  // delegated scroll listener (once) that keeps the fades in sync.
@@ -8596,74 +8642,48 @@ export const createAgentExperience = (
8596
8642
  }, 100);
8597
8643
  };
8598
8644
 
8599
- const isDeepEqual = (left: unknown, right: unknown): boolean => {
8600
- if (Object.is(left, right)) return true;
8601
- if (left === null || right === null) return false;
8602
- if (typeof left !== "object" || typeof right !== "object") return false;
8603
- if (Array.isArray(left) || Array.isArray(right)) {
8604
- if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
8605
- return false;
8606
- }
8607
- return left.every((value, index) => isDeepEqual(value, right[index]));
8608
- }
8609
- const leftRecord = left as Record<string, unknown>;
8610
- const rightRecord = right as Record<string, unknown>;
8611
- const leftKeys = Object.keys(leftRecord);
8612
- const rightKeys = Object.keys(rightRecord);
8613
- return (
8614
- leftKeys.length === rightKeys.length &&
8615
- leftKeys.every(
8616
- (key) =>
8617
- Object.prototype.hasOwnProperty.call(rightRecord, key) &&
8618
- isDeepEqual(leftRecord[key], rightRecord[key])
8619
- )
8620
- );
8621
- };
8622
-
8623
- const withoutStreamingText = (message: AgentWidgetMessage) => {
8624
- const { content: _content, rawContent: _rawContent, llmContent: _llmContent, ...rest } = message;
8625
- return rest;
8626
- };
8627
-
8628
- const isPlainStreamingAssistant = (message: AgentWidgetMessage) =>
8629
- message.role === "assistant" &&
8630
- message.streaming === true &&
8631
- !message.variant &&
8632
- !message.toolCall &&
8633
- !message.tools &&
8634
- !message.approval &&
8635
- !message.reasoning &&
8636
- !message.contentParts &&
8637
- !message.stopReason &&
8638
- !hasComponentDirective(message);
8645
+ /**
8646
+ * Changes that must reach the DOM (and storage) in the same task rather than
8647
+ * on the next animation frame: a new or edited user message drives send
8648
+ * anchoring and `user:message`, and an approval or a pending local tool
8649
+ * (ask_user_question, WebMCP) is waiting on the user and must be durable
8650
+ * before the page can unload. Everything else in a streaming burst is
8651
+ * coalesced.
8652
+ */
8653
+ const needsImmediateApply = (message: AgentWidgetMessage) =>
8654
+ message.role === "user" ||
8655
+ message.variant === "approval" ||
8656
+ message.agentMetadata?.awaitingLocalTool === true ||
8657
+ isAskUserQuestionMessage(message);
8639
8658
 
8640
- const isPureStreamingTextProgression = (
8659
+ /**
8660
+ * One pass over a streaming update: returns false when a changed entry needs
8661
+ * an immediate apply, and otherwise remembers assistant messages that
8662
+ * streamed inside the coalesced burst, so `trackMessages` still emits
8663
+ * `assistant:complete` when a message starts and finishes between two
8664
+ * applied snapshots. The session copies only the messages it changes, so
8665
+ * identity marks the changed entries; this pointer scan costs no more than
8666
+ * the array copy the session makes for every update.
8667
+ */
8668
+ const coalesceStreamingChange = (
8641
8669
  previous: AgentWidgetMessage[],
8642
- next: AgentWidgetMessage[],
8643
- candidate: { index: number; id: string }
8670
+ next: AgentWidgetMessage[]
8644
8671
  ) => {
8645
- if (previous.length !== next.length) return false;
8646
- const before = previous[candidate.index];
8647
- const after = next[candidate.index];
8648
- if (!before || !after || before.id !== candidate.id || after.id !== candidate.id) {
8649
- return false;
8672
+ if (next.length < previous.length) return false;
8673
+ for (let index = 0; index < next.length; index += 1) {
8674
+ const message = next[index];
8675
+ if (message === previous[index]) continue;
8676
+ if (needsImmediateApply(message)) return false;
8677
+ if (message.role === "assistant" && message.streaming) {
8678
+ coalescedStreamingIds.add(message.id);
8679
+ }
8650
8680
  }
8651
- const textChanged =
8652
- !Object.is(before.content, after.content) ||
8653
- !Object.is(before.rawContent, after.rawContent) ||
8654
- !Object.is(before.llmContent, after.llmContent);
8655
- return (
8656
- textChanged &&
8657
- isPlainStreamingAssistant(before) &&
8658
- isPlainStreamingAssistant(after) &&
8659
- isDeepEqual(withoutStreamingText(before), withoutStreamingText(after))
8660
- );
8681
+ return true;
8661
8682
  };
8662
8683
 
8663
8684
  let lastAppliedMessages: AgentWidgetMessage[] | null = null;
8664
- let activeStreamingTextCandidate: { index: number; id: string } | null = null;
8665
- let pendingStreamingTextMessages: AgentWidgetMessage[] | null = null;
8666
- let streamingTextRAF: number | null = null;
8685
+ let pendingMessages: AgentWidgetMessage[] | null = null;
8686
+ let pendingMessagesRAF: number | null = null;
8667
8687
  let pendingDurablePersistenceMessages: AgentWidgetMessage[] | null = null;
8668
8688
 
8669
8689
  const persistMessages = (messages: AgentWidgetMessage[]) => {
@@ -8672,7 +8692,9 @@ export const createAgentExperience = (
8672
8692
  queueMicrotask(() => {
8673
8693
  if (pendingDurablePersistenceMessages === messages) {
8674
8694
  pendingDurablePersistenceMessages = null;
8675
- persistState(messages);
8695
+ // The resume cursor is read now, so save the newest snapshot: updates
8696
+ // coalesced after this one (still waiting on a frame) already advanced it.
8697
+ persistState(pendingMessages ?? messages);
8676
8698
  }
8677
8699
  });
8678
8700
  };
@@ -8681,17 +8703,13 @@ export const createAgentExperience = (
8681
8703
  lastAppliedMessages = messages;
8682
8704
  let lastUserMessage: AgentWidgetMessage | undefined;
8683
8705
  let lastAssistantMessage: AgentWidgetMessage | undefined;
8684
- activeStreamingTextCandidate = null;
8685
8706
  for (let index = messages.length - 1; index >= 0; index -= 1) {
8686
8707
  const message = messages[index];
8687
8708
  if (!lastUserMessage && message.role === "user") lastUserMessage = message;
8688
8709
  if (!lastAssistantMessage && message.role === "assistant") {
8689
8710
  lastAssistantMessage = message;
8690
8711
  }
8691
- if (!activeStreamingTextCandidate && isPlainStreamingAssistant(message)) {
8692
- activeStreamingTextCandidate = { index, id: message.id };
8693
- }
8694
- if (lastUserMessage && lastAssistantMessage && activeStreamingTextCandidate) break;
8712
+ if (lastUserMessage && lastAssistantMessage) break;
8695
8713
  }
8696
8714
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
8697
8715
  // Freshly (re)built inline artifact blocks render from their persisted
@@ -8740,52 +8758,48 @@ export const createAgentExperience = (
8740
8758
  syncComposerBarPeek();
8741
8759
  };
8742
8760
 
8743
- const flushPendingStreamingText = () => {
8744
- if (streamingTextRAF !== null) {
8745
- cancelAnimationFrame(streamingTextRAF);
8746
- streamingTextRAF = null;
8761
+ const flushPendingMessages = () => {
8762
+ if (pendingMessagesRAF !== null) {
8763
+ cancelAnimationFrame(pendingMessagesRAF);
8764
+ pendingMessagesRAF = null;
8747
8765
  }
8748
- const pending = pendingStreamingTextMessages;
8749
- pendingStreamingTextMessages = null;
8766
+ const pending = pendingMessages;
8767
+ pendingMessages = null;
8750
8768
  if (pending) applyMessagesChanged(pending);
8751
8769
  };
8752
8770
 
8753
- const discardPendingStreamingText = () => {
8754
- if (streamingTextRAF !== null) cancelAnimationFrame(streamingTextRAF);
8755
- streamingTextRAF = null;
8756
- pendingStreamingTextMessages = null;
8771
+ const discardPendingMessages = () => {
8772
+ if (pendingMessagesRAF !== null) cancelAnimationFrame(pendingMessagesRAF);
8773
+ pendingMessagesRAF = null;
8774
+ pendingMessages = null;
8757
8775
  };
8758
8776
 
8777
+ // While a turn streams, every SSE frame changes the transcript, and a render
8778
+ // pass costs O(messages). Applying each frame synchronously makes a burst of
8779
+ // N frames (a long tool run, or a finished transcript replayed through
8780
+ // `connectStream`) cost O(N²) and block the main thread. Coalesce streaming
8781
+ // updates to one render per animation frame; the stream ending, a clear, or
8782
+ // a change in `needsImmediateApply` flushes synchronously.
8759
8783
  const handleMessagesChanged = (messages: AgentWidgetMessage[]) => {
8760
- const comparison = pendingStreamingTextMessages ?? lastAppliedMessages;
8761
- if (
8762
- isStreaming &&
8763
- comparison &&
8764
- activeStreamingTextCandidate &&
8765
- isPureStreamingTextProgression(
8766
- comparison,
8767
- messages,
8768
- activeStreamingTextCandidate
8769
- )
8770
- ) {
8771
- pendingStreamingTextMessages = messages;
8772
- if (streamingTextRAF === null) {
8773
- streamingTextRAF = requestAnimationFrame(() => {
8774
- streamingTextRAF = null;
8775
- const pending = pendingStreamingTextMessages;
8776
- pendingStreamingTextMessages = null;
8784
+ if (messages.length === 0) {
8785
+ discardPendingMessages();
8786
+ applyMessagesChanged(messages);
8787
+ return;
8788
+ }
8789
+ const comparison = pendingMessages ?? lastAppliedMessages;
8790
+ if (isStreaming && comparison && coalesceStreamingChange(comparison, messages)) {
8791
+ pendingMessages = messages;
8792
+ if (pendingMessagesRAF === null) {
8793
+ pendingMessagesRAF = requestAnimationFrame(() => {
8794
+ pendingMessagesRAF = null;
8795
+ const pending = pendingMessages;
8796
+ pendingMessages = null;
8777
8797
  if (pending) applyMessagesChanged(pending);
8778
8798
  });
8779
8799
  }
8780
8800
  return;
8781
8801
  }
8782
-
8783
- if (messages.length === 0) {
8784
- discardPendingStreamingText();
8785
- applyMessagesChanged(messages);
8786
- return;
8787
- }
8788
- flushPendingStreamingText();
8802
+ flushPendingMessages();
8789
8803
  applyMessagesChanged(messages);
8790
8804
  };
8791
8805
 
@@ -8811,9 +8825,9 @@ export const createAgentExperience = (
8811
8825
  onStreamingChanged(streaming) {
8812
8826
  if (!streaming) {
8813
8827
  if (session?.getMessages().length === 0) {
8814
- discardPendingStreamingText();
8828
+ discardPendingMessages();
8815
8829
  } else {
8816
- flushPendingStreamingText();
8830
+ flushPendingMessages();
8817
8831
  }
8818
8832
  }
8819
8833
  isStreaming = streaming;
@@ -13435,7 +13449,7 @@ export const createAgentExperience = (
13435
13449
  // Commit the latest coalesced transcript while the live DOM and storage
13436
13450
  // pipeline still exist, then let the normal teardown cancel any work
13437
13451
  // scheduled by that final apply.
13438
- flushPendingStreamingText();
13452
+ flushPendingMessages();
13439
13453
  if (toolElapsedTimerId != null) {
13440
13454
  clearInterval(toolElapsedTimerId);
13441
13455
  toolElapsedTimerId = null;
@@ -145,9 +145,27 @@ describe("standalone typing indicator with hidden activity rows", () => {
145
145
  document.body.innerHTML = "";
146
146
  if (typeof localStorage !== "undefined") localStorage.clear();
147
147
  vi.restoreAllMocks();
148
+ vi.unstubAllGlobals();
148
149
  });
149
150
 
151
+ // Streaming transcript updates render once per animation frame; queue the
152
+ // frames so each injection can be rendered synchronously.
153
+ let frames = new Map<number, (time: number) => void>();
154
+ let nextFrameId = 1;
155
+ const renderFrame = () => {
156
+ const pending = [...frames.values()];
157
+ frames.clear();
158
+ pending.forEach((callback) => callback(0));
159
+ };
160
+
150
161
  const mountStreaming = async (config: Record<string, unknown>) => {
162
+ frames = new Map();
163
+ vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
164
+ const id = nextFrameId++;
165
+ frames.set(id, callback);
166
+ return id;
167
+ });
168
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
151
169
  global.fetch = vi.fn().mockImplementation(
152
170
  () => new Promise(() => {})
153
171
  ) as unknown as typeof fetch;
@@ -190,6 +208,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
190
208
  toolCall: { id, name: "lookup", status },
191
209
  },
192
210
  });
211
+ renderFrame();
193
212
  };
194
213
 
195
214
  const injectReasoning = (
@@ -211,6 +230,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
211
230
  reasoning: { id, status, chunks: ["thinking"] },
212
231
  },
213
232
  });
233
+ renderFrame();
214
234
  };
215
235
 
216
236
  const typingIndicator = (mount: HTMLElement) =>
@@ -344,6 +364,7 @@ describe("standalone typing indicator with hidden activity rows", () => {
344
364
  streaming: true,
345
365
  },
346
366
  });
367
+ renderFrame();
347
368
  expect(typingIndicator(mount)).toBeNull();
348
369
 
349
370
  controller.destroy();
@@ -35,6 +35,15 @@ describe("computeMessageFingerprint", () => {
35
35
  expect(fp1).toBe(fp2);
36
36
  });
37
37
 
38
+ it("changes when tool args are replaced with a longer object", () => {
39
+ const toolMessage = (args: unknown) =>
40
+ makeMessage({ variant: "tool", toolCall: { status: "running", args } });
41
+ const fp1 = computeMessageFingerprint(toolMessage({ q: "a" }), 0);
42
+ const fp2 = computeMessageFingerprint(toolMessage({ q: "a", limit: 10 }), 0);
43
+ expect(fp1).not.toBe(fp2);
44
+ expect(computeMessageFingerprint(toolMessage({ q: "a" }), 0)).toBe(fp1);
45
+ });
46
+
38
47
  it("changes when content changes", () => {
39
48
  const fp1 = computeMessageFingerprint(makeMessage({ content: "Hello" }), 0);
40
49
  const fp2 = computeMessageFingerprint(makeMessage({ content: "Hello world" }), 0);