@runtypelabs/persona 4.6.0 → 4.7.0

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.
@@ -0,0 +1,312 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import type { AgentWidgetStoredState } from "./types";
7
+ import { createUnifiedEventWrite } from "./utils/__fixtures__/unified-translator.oracle";
8
+
9
+ type RafCallback = (time: number) => void;
10
+
11
+ const installRafMock = () => {
12
+ let nextId = 1;
13
+ let now = 0;
14
+ const callbacks = new Map<number, RafCallback>();
15
+ vi.stubGlobal("requestAnimationFrame", (callback: RafCallback) => {
16
+ const id = nextId++;
17
+ callbacks.set(id, callback);
18
+ return id;
19
+ });
20
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => callbacks.delete(id));
21
+ return {
22
+ step() {
23
+ const pending = [...callbacks.values()];
24
+ callbacks.clear();
25
+ now += 16;
26
+ pending.forEach((callback) => callback(now));
27
+ },
28
+ flush() {
29
+ for (let count = 0; callbacks.size > 0 && count < 20; count += 1) this.step();
30
+ if (callbacks.size > 0) throw new Error("RAF queue did not settle");
31
+ },
32
+ };
33
+ };
34
+
35
+ const flushMicrotasks = async (times = 20) => {
36
+ for (let index = 0; index < times; index += 1) {
37
+ // eslint-disable-next-line no-await-in-loop
38
+ await Promise.resolve();
39
+ }
40
+ };
41
+
42
+ const legacyEvent = (type: string, data: Record<string, unknown>) =>
43
+ `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
44
+
45
+ const createStreamHarness = () => {
46
+ const encoder = new TextEncoder();
47
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
48
+ let write: ((frame: string) => void) | null = null;
49
+ const fetchMock = vi.fn(async () =>
50
+ new Response(new ReadableStream<Uint8Array>({
51
+ start(streamController) {
52
+ controller = streamController;
53
+ write = createUnifiedEventWrite((chunk) => {
54
+ streamController.enqueue(encoder.encode(chunk));
55
+ });
56
+ },
57
+ }))
58
+ );
59
+ return {
60
+ fetchMock,
61
+ send(type: string, data: Record<string, unknown>) {
62
+ if (!write) throw new Error("stream not started");
63
+ write(legacyEvent(type, data));
64
+ },
65
+ close() {
66
+ controller?.close();
67
+ },
68
+ };
69
+ };
70
+
71
+ const startTurn = async (mount: HTMLElement) => {
72
+ const input = mount.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]")!;
73
+ input.value = "start";
74
+ mount.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!.click();
75
+ await flushMicrotasks();
76
+ };
77
+
78
+ const startText = (stream: ReturnType<typeof createStreamHarness>, deltas: string[]) => {
79
+ stream.send("flow_start", { flowId: "flow_1", flowName: "Test", totalSteps: 1 });
80
+ stream.send("step_start", {
81
+ id: "step_1",
82
+ name: "Prompt",
83
+ stepType: "prompt",
84
+ index: 0,
85
+ totalSteps: 1,
86
+ });
87
+ stream.send("text_start", { messageId: "message_1" });
88
+ deltas.forEach((text) => stream.send("step_delta", { id: "step_1", text }));
89
+ };
90
+
91
+ const finishText = (stream: ReturnType<typeof createStreamHarness>) => {
92
+ stream.send("text_end", { messageId: "message_1" });
93
+ stream.send("step_complete", {
94
+ id: "step_1",
95
+ name: "Prompt",
96
+ stepType: "prompt",
97
+ success: true,
98
+ result: { response: "Hello world" },
99
+ });
100
+ stream.send("flow_complete", { flowId: "flow_1", success: true });
101
+ stream.close();
102
+ };
103
+
104
+ const createWidget = (save: (state: AgentWidgetStoredState) => void) => {
105
+ const mount = document.createElement("div");
106
+ document.body.appendChild(mount);
107
+ const controller = createAgentExperience(mount, {
108
+ apiUrl: "https://api.example.com/chat",
109
+ launcher: { enabled: false },
110
+ storageAdapter: { save },
111
+ });
112
+ return { mount, controller };
113
+ };
114
+
115
+ describe("pure streaming text update coalescing", () => {
116
+ const originalFetch = global.fetch;
117
+
118
+ beforeEach(() => {
119
+ window.scrollTo = vi.fn();
120
+ });
121
+
122
+ afterEach(() => {
123
+ document.body.innerHTML = "";
124
+ global.fetch = originalFetch;
125
+ vi.restoreAllMocks();
126
+ });
127
+
128
+ it("coalesces same-message text deltas and flushes the terminal state", async () => {
129
+ const raf = installRafMock();
130
+ const stream = createStreamHarness();
131
+ global.fetch = stream.fetchMock;
132
+ const saves: AgentWidgetStoredState[] = [];
133
+ const { mount, controller } = createWidget((state) => saves.push(state));
134
+ await startTurn(mount);
135
+
136
+ startText(stream, ["Hel"]);
137
+ await flushMicrotasks();
138
+ const afterFirstDelta = saves.length;
139
+ stream.send("step_delta", { id: "step_1", text: "lo" });
140
+ stream.send("step_delta", { id: "step_1", text: " world" });
141
+ await flushMicrotasks();
142
+
143
+ expect(saves).toHaveLength(afterFirstDelta);
144
+ expect(mount.textContent).not.toContain("Hello world");
145
+ raf.step();
146
+ expect(saves).toHaveLength(afterFirstDelta + 1);
147
+ expect(mount.textContent).toContain("Hello world");
148
+
149
+ finishText(stream);
150
+ await flushMicrotasks(40);
151
+ expect(controller.getState().streaming).toBe(false);
152
+ expect(saves.at(-1)?.messages?.at(-1)?.content).toBe("Hello world");
153
+ controller.destroy();
154
+ });
155
+
156
+ it("flushes pending text and applies ask, tool, and approval insertions immediately", async () => {
157
+ installRafMock();
158
+ const stream = createStreamHarness();
159
+ global.fetch = stream.fetchMock;
160
+ const save = vi.fn();
161
+ const { mount, controller } = createWidget(save);
162
+ await startTurn(mount);
163
+
164
+ startText(stream, ["Hel", "lo"]);
165
+ await flushMicrotasks();
166
+ const beforeAsk = save.mock.calls.length;
167
+ controller.injectTestMessage({
168
+ type: "message",
169
+ message: {
170
+ id: "ask-1",
171
+ role: "assistant",
172
+ content: "",
173
+ createdAt: "2026-07-11T00:00:00.000Z",
174
+ streaming: false,
175
+ variant: "tool",
176
+ toolCall: {
177
+ id: "ask-1",
178
+ name: "ask_user_question",
179
+ status: "complete",
180
+ args: { questions: [{ question: "Choose now", options: [{ label: "Yes" }] }] },
181
+ chunks: [],
182
+ },
183
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: true },
184
+ },
185
+ });
186
+ expect(save.mock.calls.length).toBeGreaterThan(beforeAsk);
187
+ expect(mount.textContent).toContain("Hello");
188
+ expect(mount.querySelector("[data-persona-ask-sheet-for]")).not.toBeNull();
189
+
190
+ const beforeTool = save.mock.calls.length;
191
+ controller.injectTestMessage({
192
+ type: "message",
193
+ message: {
194
+ id: "tool-1",
195
+ role: "assistant",
196
+ content: "",
197
+ createdAt: "2026-07-11T00:00:01.000Z",
198
+ streaming: true,
199
+ variant: "tool",
200
+ toolCall: { id: "tool-1", name: "search", status: "running", chunks: [] },
201
+ },
202
+ });
203
+ expect(save.mock.calls.length).toBeGreaterThan(beforeTool);
204
+ expect(mount.textContent).toContain("search");
205
+
206
+ const beforeApproval = save.mock.calls.length;
207
+ controller.injectTestMessage({
208
+ type: "message",
209
+ message: {
210
+ id: "approval-1",
211
+ role: "assistant",
212
+ content: "",
213
+ createdAt: "2026-07-11T00:00:02.000Z",
214
+ streaming: false,
215
+ variant: "approval",
216
+ approval: {
217
+ id: "approval-1",
218
+ status: "pending",
219
+ agentId: "agent-1",
220
+ executionId: "exec-1",
221
+ toolName: "Approve search",
222
+ description: "Approval test",
223
+ },
224
+ },
225
+ });
226
+ expect(save.mock.calls.length).toBeGreaterThan(beforeApproval);
227
+ expect(mount.textContent).toContain("Approve search");
228
+ controller.destroy();
229
+ stream.close();
230
+ });
231
+
232
+ it("flushes pending text when a different message changes", async () => {
233
+ installRafMock();
234
+ const stream = createStreamHarness();
235
+ global.fetch = stream.fetchMock;
236
+ const save = vi.fn();
237
+ const { mount, controller } = createWidget(save);
238
+ await startTurn(mount);
239
+ startText(stream, ["one", " two"]);
240
+ await flushMicrotasks();
241
+ const userMessage = controller.getMessages().find((message) => message.role === "user")!;
242
+ const beforeUpdate = save.mock.calls.length;
243
+
244
+ controller.injectTestMessage({
245
+ type: "message",
246
+ message: {
247
+ ...userMessage,
248
+ content: "changed user message",
249
+ },
250
+ });
251
+
252
+ expect(save.mock.calls.length).toBeGreaterThan(beforeUpdate);
253
+ expect(mount.textContent).toContain("changed user message");
254
+ expect(mount.textContent).toContain("one two");
255
+ controller.destroy();
256
+ stream.close();
257
+ });
258
+
259
+ it("discards pending pre-clear text instead of replaying it", async () => {
260
+ const raf = installRafMock();
261
+ const stream = createStreamHarness();
262
+ global.fetch = stream.fetchMock;
263
+ const saves: AgentWidgetStoredState[] = [];
264
+ const clear = vi.fn();
265
+ const mount = document.createElement("div");
266
+ document.body.appendChild(mount);
267
+ const controller = createAgentExperience(mount, {
268
+ apiUrl: "https://api.example.com/chat",
269
+ launcher: { enabled: false },
270
+ storageAdapter: {
271
+ save: (state) => {
272
+ saves.push(state);
273
+ },
274
+ clear,
275
+ },
276
+ });
277
+ await startTurn(mount);
278
+ startText(stream, ["one", " two"]);
279
+ await flushMicrotasks();
280
+
281
+ controller.clearChat();
282
+ raf.flush();
283
+
284
+ expect(controller.getMessages()).toEqual([]);
285
+ expect(clear).toHaveBeenCalledTimes(1);
286
+ expect(
287
+ saves.some((state) =>
288
+ state.messages?.some((message) => message.content === "one two")
289
+ )
290
+ ).toBe(false);
291
+ stream.close();
292
+ });
293
+
294
+ it("flushes final pending text before destroy teardown", async () => {
295
+ const raf = installRafMock();
296
+ const stream = createStreamHarness();
297
+ global.fetch = stream.fetchMock;
298
+ const saves: AgentWidgetStoredState[] = [];
299
+ const { mount, controller } = createWidget((state) => saves.push(state));
300
+ await startTurn(mount);
301
+ startText(stream, ["one", " two"]);
302
+ await flushMicrotasks();
303
+ const beforeDestroy = saves.length;
304
+
305
+ controller.destroy();
306
+ raf.flush();
307
+
308
+ expect(saves).toHaveLength(beforeDestroy + 1);
309
+ expect(saves.at(-1)?.messages?.at(-1)?.content).toBe("one two");
310
+ stream.close();
311
+ });
312
+ });