@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.
- 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.cjs +3 -3
- package/dist/approval-ui.d.cts +1 -1
- package/dist/approval-ui.d.ts +1 -1
- package/dist/approval-ui.js +3 -3
- package/dist/artifacts-ui.d.cts +2 -2
- package/dist/artifacts-ui.d.ts +2 -2
- package/dist/chunk-B1-_pp-Y.js +4 -0
- package/dist/chunk-B2HCRERn.js +9 -0
- package/dist/chunk-Bm2-bemv.js +1 -0
- package/dist/chunk-BvhTpG6k.js +1 -0
- package/dist/{chunk-DjOvgFsn.js → chunk-Car1mORT.js} +1 -1
- package/dist/chunk-D5B629Mj.js +1 -0
- package/dist/chunk-DO9_WSpy.js +1 -0
- package/dist/chunk-DTDHeUsG.js +1 -0
- package/dist/chunk-DjFpGFGA.js +1 -0
- package/dist/chunk-DrpuvY5W.js +1 -0
- package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
- package/dist/client-history.js +1 -1
- package/dist/client-stream.js +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/{composer-input-D3sB8_P_.d.cts → composer-input-C3Xh1Qzj.d.cts} +1 -1
- package/dist/{composer-input-D3sB8_P_.d.ts → composer-input-C3Xh1Qzj.d.ts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.d.cts → context-mention-manager-BsEiGObk.d.cts} +1 -1
- package/dist/{context-mention-manager-D2qxE5op.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-Dsw66XqE.d.cts → defaults-DN-L67ak.d.cts} +1 -1
- package/dist/{defaults-Dsw66XqE.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-DJpJcWAk.d.cts → fallback-speech-engine-Cph8FTux.d.cts} +1 -1
- package/dist/{fallback-speech-engine-DJpJcWAk.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 +56 -56
- 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 +2 -3
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.cts → markdown-parsers-loader-C4Xze-Jw.d.cts} +1 -1
- package/dist/{markdown-parsers-loader-CCY7T4Up.d.ts → markdown-parsers-loader-C4Xze-Jw.d.ts} +1 -1
- package/dist/{preview-DKKpfTo7.d.cts → preview-m9QlqsZi.d.cts} +2 -2
- package/dist/{preview-DKKpfTo7.d.ts → preview-m9QlqsZi.d.ts} +2 -2
- package/dist/{session-DleMr9dZ.d.ts → session-BtvvmWnw.d.cts} +21 -9
- package/dist/{session-DleMr9dZ.d.cts → session-BtvvmWnw.d.ts} +21 -9
- package/dist/session-actions.js +1 -1
- package/dist/session-reconnect.cjs +1 -1
- package/dist/session-reconnect.d.cts +8 -2
- package/dist/session-reconnect.d.ts +8 -2
- package/dist/session-reconnect.js +1 -1
- package/dist/smart-dom-reader.d.cts +1 -1
- package/dist/smart-dom-reader.d.ts +1 -1
- package/dist/theme-editor-preview.cjs +62 -62
- package/dist/theme-editor-preview.d.cts +1 -1
- package/dist/theme-editor-preview.d.ts +1 -1
- package/dist/theme-editor-preview.js +41 -41
- 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-oAZ1VReS.d.cts → types-DL-oBHE1.d.cts} +41 -3
- package/dist/{types-oAZ1VReS.d.ts → types-DL-oBHE1.d.ts} +41 -3
- package/dist/voice-client-tools.d.cts +1 -1
- package/dist/voice-client-tools.d.ts +1 -1
- package/dist/voice-runtime.cjs +3 -3
- package/dist/voice-runtime.d.cts +10 -4
- package/dist/voice-runtime.d.ts +10 -4
- package/dist/voice-runtime.js +5 -5
- package/dist/voice-worklet-player.d.cts +2 -2
- package/dist/voice-worklet-player.d.ts +2 -2
- package/dist/{webmcp-runtime-entry-Deg0NfmB.d.cts → webmcp-runtime-entry-T6SU_TlL.d.cts} +1 -1
- package/dist/{webmcp-runtime-entry-Deg0NfmB.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/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/client-approve.test.ts +17 -1
- package/src/client-resume.ts +15 -3
- package/src/client.interrupt.test.ts +21 -0
- package/src/client.ts +24 -4
- package/src/components/activity-row.ts +3 -2
- package/src/components/approval-actions.test.ts +8 -0
- package/src/components/approval-actions.ts +6 -2
- package/src/components/approval-bubble.test.ts +14 -0
- package/src/components/approval-bubble.ts +8 -1
- package/src/components/tool-bubble.ts +8 -5
- package/src/generated/runtype-openapi-contract.ts +7 -1
- package/src/resume-dedupe-local-only.test.ts +273 -0
- package/src/runtime/persist-state.test.ts +86 -0
- package/src/session-actions.ts +20 -6
- package/src/session-reconnect.ts +18 -1
- package/src/session.restored-browser-tools.test.ts +272 -0
- package/src/session.test.ts +23 -0
- package/src/session.ts +211 -46
- package/src/session.voice.test.ts +169 -0
- package/src/styles/widget.css +4 -2
- package/src/types.ts +37 -2
- package/src/ui.restored-browser-tools.test.ts +128 -0
- 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 +152 -124
- package/src/ui.typing-indicator.test.ts +21 -0
- package/src/utils/formatting.test.ts +9 -1
- package/src/utils/formatting.ts +4 -0
- package/src/utils/local-only-message.ts +15 -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/src/utils/restored-browser-state.ts +50 -0
- package/src/utils/storage.ts +12 -1
- package/src/utils/voice-prior-tool-results.ts +37 -0
- package/src/voice/runtype-voice-provider.ts +13 -8
- package/src/voice/session-voice-wiring.ts +1 -2
- package/src/voice/voice-delegation.ts +10 -3
- package/src/voice/voice.test.ts +38 -2
- package/dist/chunk-B6zZsT6F.js +0 -1
- package/dist/chunk-CIVsNcrk.js +0 -1
- package/dist/chunk-CKdlNyzM.js +0 -9
- package/dist/chunk-DbhxjyOB.js +0 -4
- package/dist/chunk-DsdqLStH.js +0 -1
- 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?: {
|
|
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
|
+
});
|
package/src/ui.scroll.test.ts
CHANGED
|
@@ -441,8 +441,9 @@ describe("createAgentExperience streaming scroll", () => {
|
|
|
441
441
|
metrics.setScrollHeight(1080);
|
|
442
442
|
emitStreamingMessage(controller, "Chunk two");
|
|
443
443
|
|
|
444
|
-
//
|
|
445
|
-
|
|
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
|
+
});
|