@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.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/approval-ui.d.cts +1 -1
- package/dist/approval-ui.d.ts +1 -1
- package/dist/artifacts-ui.d.cts +2 -2
- package/dist/artifacts-ui.d.ts +2 -2
- package/dist/{chunk-DGlFKA0r.js → chunk-B2HCRERn.js} +1 -1
- package/dist/{chunk-BcLIgi1h.js → chunk-Car1mORT.js} +1 -1
- package/dist/chunk-DrpuvY5W.js +1 -0
- package/dist/client-history.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/{composer-input-B0P4OtNi.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
- package/dist/{composer-input-B0P4OtNi.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
- package/dist/{context-mention-manager-DmLI_RM6.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
- package/dist/{context-mention-manager-DmLI_RM6.d.ts → context-mention-manager-BsEiGObk.d.ts} +1 -1
- package/dist/context-mentions-inline.d.cts +2 -2
- package/dist/context-mentions-inline.d.ts +2 -2
- package/dist/context-mentions.d.cts +3 -3
- package/dist/context-mentions.d.ts +3 -3
- package/dist/{defaults-DVo1wbZs.d.cts → defaults-DN-L67ak.d.cts} +1 -1
- package/dist/{defaults-DVo1wbZs.d.ts → defaults-DN-L67ak.d.ts} +1 -1
- package/dist/event-stream-view.d.cts +1 -1
- package/dist/event-stream-view.d.ts +1 -1
- package/dist/{fallback-speech-engine-BMnlmKnY.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
- package/dist/{fallback-speech-engine-BMnlmKnY.d.ts → fallback-speech-engine-Cph8FTux.d.ts} +1 -1
- package/dist/forms-ui.d.cts +2 -2
- package/dist/forms-ui.d.ts +2 -2
- package/dist/history-view.d.cts +1 -1
- package/dist/history-view.d.ts +1 -1
- package/dist/index.cjs +51 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +37 -37
- package/dist/index.js.map +1 -1
- package/dist/{markdown-parsers-loader-CeovcyYf.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
- package/dist/{markdown-parsers-loader-CeovcyYf.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
- package/dist/{preview-Bkb4UrQC.d.cts → preview-m9QlqsZi.d.cts} +2 -2
- package/dist/{preview-Bkb4UrQC.d.ts → preview-m9QlqsZi.d.ts} +2 -2
- package/dist/{session-iLyKipoM.d.cts → session-BtvvmWnw.d.cts} +6 -6
- package/dist/{session-iLyKipoM.d.ts → session-BtvvmWnw.d.ts} +6 -6
- package/dist/session-reconnect.d.cts +2 -2
- package/dist/session-reconnect.d.ts +2 -2
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +54 -54
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +35 -35
- package/dist/theme-editor.d.cts +2 -2
- package/dist/theme-editor.d.ts +2 -2
- package/dist/theme-reference.d.cts +2 -2
- package/dist/theme-reference.d.ts +2 -2
- package/dist/{types-BuDirJkQ.d.cts → types-DL-oBHE1.d.cts} +6 -0
- package/dist/{types-BuDirJkQ.d.ts → types-DL-oBHE1.d.ts} +6 -0
- package/dist/voice-client-tools.d.cts +1 -1
- package/dist/voice-client-tools.d.ts +1 -1
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +2 -2
- package/dist/voice-runtime.d.ts +2 -2
- package/dist/voice-runtime.js +1 -1
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-CXLjRt1k.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
- package/dist/{webmcp-runtime-entry-CXLjRt1k.d.ts → webmcp-runtime-entry-T6SU_TlL.d.ts} +1 -1
- package/dist/webmcp-runtime.d.cts +1 -1
- package/dist/webmcp-runtime.d.ts +1 -1
- package/package.json +1 -1
- package/src/generated/runtype-openapi-contract.ts +6 -0
- package/src/session.test.ts +23 -0
- package/src/session.ts +35 -17
- package/src/ui.scroll.test.ts +7 -2
- package/src/ui.stream-replay.test.ts +86 -0
- package/src/ui.streaming-coalescing.test.ts +183 -0
- package/src/ui.ts +120 -106
- package/src/ui.typing-indicator.test.ts +21 -0
- package/src/utils/message-fingerprint.test.ts +9 -0
- package/src/utils/message-fingerprint.ts +18 -5
- package/src/utils/morph.ts +10 -0
- 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
|
-
|
|
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
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
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
|
-
|
|
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 (
|
|
8646
|
-
|
|
8647
|
-
|
|
8648
|
-
|
|
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
|
-
|
|
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
|
|
8665
|
-
let
|
|
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
|
-
|
|
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 (
|
|
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
|
|
8744
|
-
if (
|
|
8745
|
-
cancelAnimationFrame(
|
|
8746
|
-
|
|
8761
|
+
const flushPendingMessages = () => {
|
|
8762
|
+
if (pendingMessagesRAF !== null) {
|
|
8763
|
+
cancelAnimationFrame(pendingMessagesRAF);
|
|
8764
|
+
pendingMessagesRAF = null;
|
|
8747
8765
|
}
|
|
8748
|
-
const pending =
|
|
8749
|
-
|
|
8766
|
+
const pending = pendingMessages;
|
|
8767
|
+
pendingMessages = null;
|
|
8750
8768
|
if (pending) applyMessagesChanged(pending);
|
|
8751
8769
|
};
|
|
8752
8770
|
|
|
8753
|
-
const
|
|
8754
|
-
if (
|
|
8755
|
-
|
|
8756
|
-
|
|
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
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
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
|
-
|
|
8828
|
+
discardPendingMessages();
|
|
8815
8829
|
} else {
|
|
8816
|
-
|
|
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
|
-
|
|
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);
|