@runtypelabs/persona 4.5.0 → 4.6.1

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 (42) 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/{types-C6tFDxKy.d.cts → types-CSmiKRVa.d.cts} +11 -0
  4. package/dist/animations/{types-C6tFDxKy.d.ts → types-CSmiKRVa.d.ts} +11 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-DFBSCFYN.js +1 -0
  8. package/dist/codegen.cjs +4 -4
  9. package/dist/codegen.js +4 -4
  10. package/dist/index.cjs +51 -51
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +238 -3
  13. package/dist/index.d.ts +238 -3
  14. package/dist/index.global.js +37 -37
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +51 -51
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
  20. package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
  21. package/dist/session-reconnect-U77QFUR7.js +1 -0
  22. package/dist/smart-dom-reader.d.cts +95 -0
  23. package/dist/smart-dom-reader.d.ts +95 -0
  24. package/dist/theme-editor-preview.cjs +48 -48
  25. package/dist/theme-editor-preview.d.cts +135 -1
  26. package/dist/theme-editor-preview.d.ts +135 -1
  27. package/dist/theme-editor-preview.js +48 -48
  28. package/dist/theme-editor.d.cts +95 -0
  29. package/dist/theme-editor.d.ts +95 -0
  30. package/package.json +5 -3
  31. package/src/client.ts +55 -9
  32. package/src/generated/runtype-openapi-contract.ts +16 -1
  33. package/src/reconnect-wake.test.ts +162 -0
  34. package/src/reconnect.test.ts +430 -0
  35. package/src/session-reconnect.ts +282 -0
  36. package/src/session.ts +408 -5
  37. package/src/types.ts +107 -1
  38. package/src/ui.stream-animation-update.test.ts +99 -0
  39. package/src/ui.ts +71 -1
  40. package/src/utils/constants.ts +3 -1
  41. package/src/utils/morph.test.ts +47 -0
  42. package/src/utils/morph.ts +18 -0
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Wake-listener behavior for durable reconnect (`session-reconnect.ts`). These
4
+ // need a DOM so the `visibilitychange` / `online` listeners actually attach, so
5
+ // this file runs in jsdom (the sibling `reconnect.test.ts` is node-env, where
6
+ // the `typeof document/window` guards skip the listeners entirely).
7
+ //
8
+ // The crux: `online` must short-circuit the backoff sleep even when the tab is
9
+ // backgrounded, while `visibilitychange` must only wake on the show transition.
10
+
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
12
+ import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
13
+ import { AgentWidgetMessage } from "./types";
14
+
15
+ const enc = new TextEncoder();
16
+
17
+ function sseStream(frames: string[]): ReadableStream<Uint8Array> {
18
+ return new ReadableStream({
19
+ start(controller) {
20
+ for (const f of frames) controller.enqueue(enc.encode(f));
21
+ controller.close();
22
+ },
23
+ });
24
+ }
25
+
26
+ function frame(
27
+ id: number | null,
28
+ type: string,
29
+ data: Record<string, unknown> = {}
30
+ ): string {
31
+ const head = id !== null ? `id: ${id}\n` : "";
32
+ return `${head}event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
33
+ }
34
+
35
+ async function waitFor(pred: () => boolean, timeoutMs = 1500): Promise<void> {
36
+ const start = Date.now();
37
+ // eslint-disable-next-line no-constant-condition
38
+ while (true) {
39
+ if (pred()) return;
40
+ if (Date.now() - start > timeoutMs) throw new Error("waitFor: timed out");
41
+ await new Promise((r) => setTimeout(r, 5));
42
+ }
43
+ }
44
+
45
+ function setVisibility(state: "visible" | "hidden"): void {
46
+ Object.defineProperty(document, "visibilityState", {
47
+ configurable: true,
48
+ get: () => state,
49
+ });
50
+ }
51
+
52
+ describe("AgentWidgetSession - reconnect wake listeners", () => {
53
+ let messages: AgentWidgetMessage[];
54
+ let status: AgentWidgetSessionStatus;
55
+ let reconnectPhases: string[];
56
+
57
+ const baseCallbacks = () => ({
58
+ onMessagesChanged: (m: AgentWidgetMessage[]) => {
59
+ messages = m;
60
+ },
61
+ onStatusChanged: (s: AgentWidgetSessionStatus) => {
62
+ status = s;
63
+ },
64
+ onStreamingChanged: () => {},
65
+ onError: () => {},
66
+ onReconnect: (ev: { phase: string }) => {
67
+ reconnectPhases.push(ev.phase);
68
+ },
69
+ });
70
+
71
+ const assistantText = () =>
72
+ messages.find((m) => m.role === "assistant" && !m.variant)?.content ?? "";
73
+
74
+ // A session that drops mid-stream, fails its first reconnect attempt (forcing
75
+ // a deliberately long backoff sleep), then succeeds on the second. A prompt
76
+ // 2nd attempt can therefore only mean a wake fired — never the 10s timer.
77
+ function dropThenResumeSession() {
78
+ const initial = sseStream([
79
+ frame(1, "text_delta", {
80
+ id: "text_0",
81
+ delta: "Hello",
82
+ executionId: "exec_1",
83
+ }),
84
+ ]);
85
+ const resume = sseStream([
86
+ frame(2, "text_delta", {
87
+ id: "text_0",
88
+ delta: " world",
89
+ executionId: "exec_1",
90
+ }),
91
+ frame(3, "execution_complete", { success: true, kind: "agent" }),
92
+ ]);
93
+ let calls = 0;
94
+ const session = new AgentWidgetSession(
95
+ {
96
+ apiUrl: "http://x",
97
+ customFetch: async () => ({ ok: true, body: initial }) as any,
98
+ reconnectStream: async () => {
99
+ calls += 1;
100
+ if (calls === 1) return { ok: false } as any;
101
+ return { ok: true, body: resume } as any;
102
+ },
103
+ reconnect: { backoffMs: [10000], maxAttempts: 5 },
104
+ },
105
+ baseCallbacks()
106
+ );
107
+ return {
108
+ session,
109
+ getCalls: () => calls,
110
+ };
111
+ }
112
+
113
+ beforeEach(() => {
114
+ messages = [];
115
+ status = "idle";
116
+ reconnectPhases = [];
117
+ setVisibility("visible");
118
+ });
119
+
120
+ afterEach(() => {
121
+ setVisibility("visible");
122
+ vi.restoreAllMocks();
123
+ });
124
+
125
+ it("online wakes the backoff immediately even when the tab is backgrounded", async () => {
126
+ const { session, getCalls } = dropThenResumeSession();
127
+
128
+ await session.sendMessage("hi");
129
+ // Wait until the first reconnect attempt has failed and we're sleeping in
130
+ // the (10s) backoff before the second attempt.
131
+ await waitFor(() => status === "paused");
132
+ expect(getCalls()).toBe(1);
133
+
134
+ // Background the tab, then regain connectivity. The visibility guard must
135
+ // NOT suppress the `online` wake.
136
+ setVisibility("hidden");
137
+ window.dispatchEvent(new Event("online"));
138
+
139
+ // Without the fix this times out (the loop would wait the full 10s).
140
+ await waitFor(() => reconnectPhases.includes("resumed"));
141
+ expect(getCalls()).toBe(2);
142
+ expect(assistantText()).toBe("Hello world");
143
+ expect(status).toBe("idle");
144
+ });
145
+
146
+ it("visibilitychange wakes the backoff when the tab becomes visible", async () => {
147
+ const { session, getCalls } = dropThenResumeSession();
148
+
149
+ await session.sendMessage("hi");
150
+ await waitFor(() => status === "paused");
151
+ expect(getCalls()).toBe(1);
152
+
153
+ // Tab brought back to the foreground: the show transition should wake.
154
+ setVisibility("visible");
155
+ document.dispatchEvent(new Event("visibilitychange"));
156
+
157
+ await waitFor(() => reconnectPhases.includes("resumed"));
158
+ expect(getCalls()).toBe(2);
159
+ expect(assistantText()).toBe("Hello world");
160
+ expect(status).toBe("idle");
161
+ });
162
+ });
@@ -0,0 +1,430 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+ import { AgentWidgetClient } from "./client";
3
+ import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
4
+ import { AgentWidgetEvent, AgentWidgetMessage, ResumableHandle } from "./types";
5
+
6
+ // ── Helpers ────────────────────────────────────────────────────────────────
7
+
8
+ const enc = new TextEncoder();
9
+
10
+ /** Build a closed SSE ReadableStream from raw frame strings. */
11
+ function sseStream(frames: string[]): ReadableStream<Uint8Array> {
12
+ return new ReadableStream({
13
+ start(controller) {
14
+ for (const f of frames) controller.enqueue(enc.encode(f));
15
+ controller.close();
16
+ },
17
+ });
18
+ }
19
+
20
+ /** Build one SSE frame; `id` becomes the `id:` cursor line when non-null. */
21
+ function frame(
22
+ id: number | null,
23
+ type: string,
24
+ data: Record<string, unknown> = {}
25
+ ): string {
26
+ const head = id !== null ? `id: ${id}\n` : "";
27
+ return `${head}event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
28
+ }
29
+
30
+ async function waitFor(pred: () => boolean, timeoutMs = 1500): Promise<void> {
31
+ const start = Date.now();
32
+ // eslint-disable-next-line no-constant-condition
33
+ while (true) {
34
+ if (pred()) return;
35
+ if (Date.now() - start > timeoutMs) throw new Error("waitFor: timed out");
36
+ await new Promise((r) => setTimeout(r, 5));
37
+ }
38
+ }
39
+
40
+ // ── Client: cursor (`id:`) parsing + terminal tagging ────────────────────────
41
+
42
+ describe("AgentWidgetClient - durable cursor + terminal", () => {
43
+ let client: AgentWidgetClient;
44
+ let events: AgentWidgetEvent[];
45
+
46
+ beforeEach(() => {
47
+ events = [];
48
+ client = new AgentWidgetClient({ apiUrl: "http://localhost:8000" });
49
+ });
50
+
51
+ it("emits a `cursor` event per frame carrying an `id:` line", async () => {
52
+ global.fetch = vi.fn().mockResolvedValue({
53
+ ok: true,
54
+ body: sseStream([
55
+ frame(1, "text_delta", { id: "text_0", delta: "Hello" }),
56
+ frame(2, "text_delta", { id: "text_0", delta: " world" }),
57
+ frame(3, "execution_complete", { success: true, kind: "agent" }),
58
+ ]),
59
+ });
60
+
61
+ await client.dispatch({ messages: [] }, (e) => events.push(e));
62
+
63
+ const cursors = events
64
+ .filter((e): e is { type: "cursor"; id: string } => e.type === "cursor")
65
+ .map((e) => e.id);
66
+ expect(cursors).toEqual(["1", "2", "3"]);
67
+ });
68
+
69
+ it("tags the graceful terminal `idle` with `terminal: true`", async () => {
70
+ global.fetch = vi.fn().mockResolvedValue({
71
+ ok: true,
72
+ body: sseStream([
73
+ frame(1, "text_delta", { id: "text_0", delta: "Hi" }),
74
+ frame(2, "execution_complete", { success: true, kind: "agent" }),
75
+ ]),
76
+ });
77
+
78
+ await client.dispatch({ messages: [] }, (e) => events.push(e));
79
+
80
+ const terminalIdle = events.find(
81
+ (e) => e.type === "status" && e.status === "idle" && e.terminal === true
82
+ );
83
+ expect(terminalIdle).toBeDefined();
84
+ });
85
+
86
+ it("emits no `cursor` events for a stream without `id:` lines", async () => {
87
+ global.fetch = vi.fn().mockResolvedValue({
88
+ ok: true,
89
+ body: sseStream([
90
+ frame(null, "text_delta", { id: "text_0", delta: "Hi" }),
91
+ frame(null, "execution_complete", { success: true, kind: "agent" }),
92
+ ]),
93
+ });
94
+
95
+ await client.dispatch({ messages: [] }, (e) => events.push(e));
96
+
97
+ expect(events.some((e) => e.type === "cursor")).toBe(false);
98
+ });
99
+ });
100
+
101
+ // ── Session: drop detection, reconnect, resume ───────────────────────────────
102
+
103
+ describe("AgentWidgetSession - durable reconnect", () => {
104
+ let messages: AgentWidgetMessage[];
105
+ let status: AgentWidgetSessionStatus;
106
+ let statusHistory: AgentWidgetSessionStatus[];
107
+ let lastError: Error | undefined;
108
+ let reconnectPhases: string[];
109
+ let executionStates: (ResumableHandle | null)[];
110
+
111
+ const baseCallbacks = () => ({
112
+ onMessagesChanged: (m: AgentWidgetMessage[]) => {
113
+ messages = m;
114
+ },
115
+ onStatusChanged: (s: AgentWidgetSessionStatus) => {
116
+ status = s;
117
+ statusHistory.push(s);
118
+ },
119
+ onStreamingChanged: () => {},
120
+ onError: (e: Error) => {
121
+ lastError = e;
122
+ },
123
+ onReconnect: (ev: { phase: string }) => {
124
+ reconnectPhases.push(ev.phase);
125
+ },
126
+ });
127
+
128
+ beforeEach(() => {
129
+ messages = [];
130
+ status = "idle";
131
+ statusHistory = [];
132
+ lastError = undefined;
133
+ reconnectPhases = [];
134
+ executionStates = [];
135
+ });
136
+
137
+ afterEach(() => {
138
+ vi.restoreAllMocks();
139
+ });
140
+
141
+ const assistantText = () =>
142
+ messages.find((m) => m.role === "assistant" && !m.variant)?.content ?? "";
143
+
144
+ it("reconnects after a drop and appends replayed deltas to the same bubble", async () => {
145
+ // Initial stream: one delta, then closes WITHOUT a terminal (a drop).
146
+ const initial = sseStream([
147
+ frame(1, "text_delta", {
148
+ id: "text_0",
149
+ delta: "Hello",
150
+ executionId: "exec_1",
151
+ }),
152
+ ]);
153
+ // Reconnect stream: replays post-cursor delta, then a graceful terminal.
154
+ const resume = sseStream([
155
+ frame(2, "text_delta", {
156
+ id: "text_0",
157
+ delta: " world",
158
+ executionId: "exec_1",
159
+ }),
160
+ frame(3, "execution_complete", { success: true, kind: "agent" }),
161
+ ]);
162
+
163
+ let reconnectCtx: { executionId: string; after: string } | null = null;
164
+ const session = new AgentWidgetSession(
165
+ {
166
+ apiUrl: "http://x",
167
+ customFetch: async () => ({ ok: true, body: initial }) as any,
168
+ reconnectStream: async (ctx) => {
169
+ reconnectCtx = { executionId: ctx.executionId, after: ctx.after };
170
+ return { ok: true, body: resume } as any;
171
+ },
172
+ },
173
+ baseCallbacks()
174
+ );
175
+
176
+ await session.sendMessage("hi");
177
+ await waitFor(() => reconnectPhases.includes("resumed"));
178
+
179
+ expect(status).toBe("idle");
180
+ // The turn passed through `resuming` before settling.
181
+ expect(statusHistory).toContain("resuming");
182
+ expect(assistantText()).toBe("Hello world");
183
+ expect(reconnectCtx).toEqual({ executionId: "exec_1", after: "1" });
184
+ expect(reconnectPhases).toContain("paused");
185
+ expect(reconnectPhases).toContain("resuming");
186
+ expect(reconnectPhases).toContain("resumed");
187
+ expect(lastError).toBeUndefined();
188
+ // No dispatch-error fallback bubble.
189
+ expect(messages.every((m) => !/couldn't reach/i.test(m.content ?? ""))).toBe(
190
+ true
191
+ );
192
+ });
193
+
194
+ it("does NOT reconnect when no reconnectStream is configured (finalizes as today)", async () => {
195
+ const initial = sseStream([
196
+ frame(1, "text_delta", {
197
+ id: "text_0",
198
+ delta: "Hello",
199
+ executionId: "exec_1",
200
+ }),
201
+ ]);
202
+ const session = new AgentWidgetSession(
203
+ {
204
+ apiUrl: "http://x",
205
+ customFetch: async () => ({ ok: true, body: initial }) as any,
206
+ },
207
+ baseCallbacks()
208
+ );
209
+
210
+ await session.sendMessage("hi");
211
+ await waitFor(() => status === "idle");
212
+
213
+ expect(status).toBe("idle");
214
+ expect(reconnectPhases).toEqual([]);
215
+ });
216
+
217
+ it("does NOT reconnect on a graceful terminal", async () => {
218
+ const stream = sseStream([
219
+ frame(1, "text_delta", {
220
+ id: "text_0",
221
+ delta: "Done",
222
+ executionId: "exec_1",
223
+ }),
224
+ frame(2, "execution_complete", { success: true, kind: "agent" }),
225
+ ]);
226
+ const reconnectStream = vi.fn();
227
+ const session = new AgentWidgetSession(
228
+ {
229
+ apiUrl: "http://x",
230
+ customFetch: async () => ({ ok: true, body: stream }) as any,
231
+ reconnectStream,
232
+ },
233
+ baseCallbacks()
234
+ );
235
+
236
+ await session.sendMessage("hi");
237
+ await waitFor(() => status === "idle");
238
+
239
+ expect(reconnectStream).not.toHaveBeenCalled();
240
+ expect(reconnectPhases).toEqual([]);
241
+ expect(assistantText()).toBe("Done");
242
+ });
243
+
244
+ it("does NOT reconnect when the user cancels (abort)", async () => {
245
+ // A stream that stays open after the first delta, so cancel() (not a drop)
246
+ // drives the end. The drop gate must not arm after a user stop.
247
+ const initial = new ReadableStream<Uint8Array>({
248
+ start(controller) {
249
+ controller.enqueue(
250
+ enc.encode(
251
+ frame(1, "text_delta", {
252
+ id: "text_0",
253
+ delta: "Hello",
254
+ executionId: "exec_1",
255
+ })
256
+ )
257
+ );
258
+ // Intentionally never closed: cancel() ends the turn.
259
+ },
260
+ });
261
+ const reconnectStream = vi.fn();
262
+ const session = new AgentWidgetSession(
263
+ {
264
+ apiUrl: "http://x",
265
+ customFetch: async () => ({ ok: true, body: initial }) as any,
266
+ reconnectStream,
267
+ },
268
+ baseCallbacks()
269
+ );
270
+
271
+ void session.sendMessage("hi");
272
+ await waitFor(() => assistantText() === "Hello");
273
+ session.cancel();
274
+ await new Promise((r) => setTimeout(r, 20));
275
+
276
+ expect(reconnectStream).not.toHaveBeenCalled();
277
+ expect(status).toBe("idle");
278
+ });
279
+
280
+ it("finalizes with an error after exhausting reconnect attempts", async () => {
281
+ const initial = sseStream([
282
+ frame(1, "text_delta", {
283
+ id: "text_0",
284
+ delta: "Hello",
285
+ executionId: "exec_1",
286
+ }),
287
+ ]);
288
+ const reconnectStream = vi.fn(async () => {
289
+ throw new Error("network down");
290
+ });
291
+ const session = new AgentWidgetSession(
292
+ {
293
+ apiUrl: "http://x",
294
+ customFetch: async () => ({ ok: true, body: initial }) as any,
295
+ reconnectStream,
296
+ reconnect: { backoffMs: [1, 1], maxAttempts: 2 },
297
+ },
298
+ baseCallbacks()
299
+ );
300
+
301
+ await session.sendMessage("hi");
302
+ await waitFor(() => status === "idle");
303
+
304
+ expect(reconnectStream).toHaveBeenCalledTimes(2);
305
+ expect(lastError).toBeDefined();
306
+ // The partial text is preserved; a failure bubble is appended.
307
+ expect(assistantText()).toContain("Hello");
308
+ });
309
+
310
+ it("surfaces `paused` status during the backoff wait between attempts", async () => {
311
+ const initial = sseStream([
312
+ frame(1, "text_delta", {
313
+ id: "text_0",
314
+ delta: "Hello",
315
+ executionId: "exec_1",
316
+ }),
317
+ ]);
318
+ const resume = sseStream([
319
+ frame(2, "text_delta", {
320
+ id: "text_0",
321
+ delta: " world",
322
+ executionId: "exec_1",
323
+ }),
324
+ frame(3, "execution_complete", { success: true, kind: "agent" }),
325
+ ]);
326
+ let calls = 0;
327
+ const session = new AgentWidgetSession(
328
+ {
329
+ apiUrl: "http://x",
330
+ customFetch: async () => ({ ok: true, body: initial }) as any,
331
+ reconnectStream: async () => {
332
+ calls += 1;
333
+ // First attempt fails (forcing a backoff wait), second succeeds.
334
+ if (calls === 1) return { ok: false } as any;
335
+ return { ok: true, body: resume } as any;
336
+ },
337
+ reconnect: { backoffMs: [5, 5], maxAttempts: 3 },
338
+ },
339
+ baseCallbacks()
340
+ );
341
+
342
+ await session.sendMessage("hi");
343
+ await waitFor(() => reconnectPhases.includes("resumed"));
344
+
345
+ // An in-flight attempt is `resuming`; the wait before the 2nd attempt is
346
+ // `paused`, so both appear before the turn settles.
347
+ expect(statusHistory).toContain("resuming");
348
+ expect(statusHistory).toContain("paused");
349
+ // `paused` is followed by another `resuming` (the retry), proving it's the
350
+ // inter-attempt wait and not a terminal state.
351
+ const pausedAt = statusHistory.indexOf("paused");
352
+ expect(statusHistory.slice(pausedAt + 1)).toContain("resuming");
353
+ expect(assistantText()).toBe("Hello world");
354
+ expect(status).toBe("idle");
355
+ });
356
+
357
+ it("notifies onExecutionState on create and clears it on terminal", async () => {
358
+ const stream = sseStream([
359
+ frame(1, "text_delta", {
360
+ id: "text_0",
361
+ delta: "Hi",
362
+ executionId: "exec_9",
363
+ }),
364
+ frame(2, "execution_complete", { success: true, kind: "agent" }),
365
+ ]);
366
+ const session = new AgentWidgetSession(
367
+ {
368
+ apiUrl: "http://x",
369
+ customFetch: async () => ({ ok: true, body: stream }) as any,
370
+ reconnectStream: async () => ({ ok: true, body: sseStream([]) }) as any,
371
+ onExecutionState: (h) => executionStates.push(h),
372
+ },
373
+ baseCallbacks()
374
+ );
375
+
376
+ await session.sendMessage("hi");
377
+ await waitFor(() => status === "idle");
378
+
379
+ // At least one non-null handle was surfaced, and the final state is null.
380
+ expect(executionStates.some((h) => h?.executionId === "exec_9")).toBe(true);
381
+ expect(executionStates[executionStates.length - 1]).toBeNull();
382
+ });
383
+
384
+ it("resumeFromHandle boots straight into resuming and reconnects", async () => {
385
+ const resume = sseStream([
386
+ frame(6, "text_delta", {
387
+ id: "text_0",
388
+ delta: " more",
389
+ executionId: "exec_5",
390
+ }),
391
+ frame(7, "execution_complete", { success: true, kind: "agent" }),
392
+ ]);
393
+ let reconnectCtx: { executionId: string; after: string } | null = null;
394
+ const session = new AgentWidgetSession(
395
+ {
396
+ apiUrl: "http://x",
397
+ reconnectStream: async (ctx) => {
398
+ reconnectCtx = { executionId: ctx.executionId, after: ctx.after };
399
+ return { ok: true, body: resume } as any;
400
+ },
401
+ },
402
+ baseCallbacks()
403
+ );
404
+
405
+ // Simulate restored history: a prior user + partial assistant bubble.
406
+ session.hydrateMessages([
407
+ {
408
+ id: "u1",
409
+ role: "user",
410
+ content: "tell me a story",
411
+ createdAt: new Date().toISOString(),
412
+ },
413
+ {
414
+ id: "a1",
415
+ role: "assistant",
416
+ content: "Once upon a time",
417
+ createdAt: new Date().toISOString(),
418
+ },
419
+ ]);
420
+
421
+ session.resumeFromHandle({ executionId: "exec_5", after: "5" });
422
+ expect(status).toBe("resuming");
423
+
424
+ await waitFor(() => status === "idle");
425
+
426
+ expect(reconnectCtx).toEqual({ executionId: "exec_5", after: "5" });
427
+ // Replayed delta appended to the reopened trailing assistant bubble.
428
+ expect(assistantText()).toBe("Once upon a time more");
429
+ });
430
+ });