@runtypelabs/persona 4.4.2 → 4.6.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.
Files changed (46) 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-8RICZWQe.d.cts} +6 -0
  4. package/dist/animations/{types-C6tFDxKy.d.ts → types-8RICZWQe.d.ts} +6 -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 +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +50 -50
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +286 -11
  13. package/dist/index.d.ts +286 -11
  14. package/dist/index.global.js +39 -39
  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 +147 -8
  23. package/dist/smart-dom-reader.d.ts +147 -8
  24. package/dist/theme-editor-preview.cjs +48 -48
  25. package/dist/theme-editor-preview.d.cts +187 -9
  26. package/dist/theme-editor-preview.d.ts +187 -9
  27. package/dist/theme-editor-preview.js +48 -48
  28. package/dist/theme-editor.cjs +1 -1
  29. package/dist/theme-editor.d.cts +147 -8
  30. package/dist/theme-editor.d.ts +147 -8
  31. package/dist/theme-editor.js +1 -1
  32. package/package.json +2 -2
  33. package/src/client.ts +48 -7
  34. package/src/defaults.ts +9 -1
  35. package/src/generated/runtype-openapi-contract.ts +6 -0
  36. package/src/index-core.ts +5 -0
  37. package/src/reconnect-wake.test.ts +162 -0
  38. package/src/reconnect.test.ts +430 -0
  39. package/src/session-reconnect.ts +282 -0
  40. package/src/session.ts +408 -5
  41. package/src/types.ts +165 -9
  42. package/src/ui.scroll-additive.test.ts +451 -0
  43. package/src/ui.scroll.test.ts +8 -2
  44. package/src/ui.stream-animation-update.test.ts +99 -0
  45. package/src/ui.ts +371 -41
  46. package/src/utils/constants.ts +3 -1
package/src/client.ts CHANGED
@@ -954,11 +954,12 @@ export class AgentWidgetClient {
954
954
  public async processStream(
955
955
  body: ReadableStream<Uint8Array>,
956
956
  onEvent: SSEHandler,
957
- assistantMessageId?: string
957
+ assistantMessageId?: string,
958
+ seedContent?: string
958
959
  ): Promise<void> {
959
960
  onEvent({ type: "status", status: "connected" });
960
961
  try {
961
- await this.streamResponse(body, onEvent, assistantMessageId);
962
+ await this.streamResponse(body, onEvent, assistantMessageId, seedContent);
962
963
  } finally {
963
964
  onEvent({ type: "status", status: "idle" });
964
965
  }
@@ -1353,7 +1354,12 @@ export class AgentWidgetClient {
1353
1354
  private async streamResponse(
1354
1355
  body: ReadableStream<Uint8Array>,
1355
1356
  onEvent: SSEHandler,
1356
- assistantMessageId?: string
1357
+ assistantMessageId?: string,
1358
+ // Durable reconnect: seed the assistant accumulator with the text already
1359
+ // shown before the drop, so replayed post-cursor deltas APPEND to it
1360
+ // instead of a fresh stream clobbering it (the replay carries only
1361
+ // `seq > after`, i.e. the new deltas, not the full text).
1362
+ seedContent?: string
1357
1363
  ) {
1358
1364
  const reader = body.getReader();
1359
1365
  const decoder = new TextDecoder();
@@ -1490,10 +1496,14 @@ export class AgentWidgetClient {
1490
1496
  const ensureAssistantMessage = () => {
1491
1497
  if (assistantMessage) return assistantMessage;
1492
1498
  let id: string;
1499
+ let initialContent = "";
1493
1500
  const segment = currentTextBlockId;
1494
1501
  if (!assistantIdConsumed && baseAssistantId) {
1495
1502
  id = baseAssistantId;
1496
1503
  assistantIdConsumed = true;
1504
+ // First (and only) time we reuse the caller-supplied id: this is the
1505
+ // bubble a durable reconnect resumes into, so continue its text.
1506
+ initialContent = seedContent ?? "";
1497
1507
  } else if (baseAssistantId && segment) {
1498
1508
  id = `${baseAssistantId}_${segment}`;
1499
1509
  } else {
@@ -1502,7 +1512,7 @@ export class AgentWidgetClient {
1502
1512
  assistantMessage = {
1503
1513
  id,
1504
1514
  role: "assistant",
1505
- content: "",
1515
+ content: initialContent,
1506
1516
  createdAt: new Date().toISOString(),
1507
1517
  streaming: true,
1508
1518
  sequence: nextSequence()
@@ -2877,7 +2887,11 @@ export class AgentWidgetClient {
2877
2887
  pendingFlowRaw = "";
2878
2888
  lastSealedFlowBubble = null;
2879
2889
 
2880
- onEvent({ type: "status", status: "idle" });
2890
+ // `terminal: true` marks this as a graceful finish (not a drop). The
2891
+ // session uses it to distinguish the real end-of-turn from the plain
2892
+ // `idle` the dispatch wrappers emit in their `finally` when a durable
2893
+ // connection drops mid-stream (durable-reconnect drop detection).
2894
+ onEvent({ type: "status", status: "idle", terminal: true });
2881
2895
  } else if (payloadType === "execution_error") {
2882
2896
  // Terminal failure. The non-terminal `error` is handled
2883
2897
  // separately (recoverable → warn).
@@ -3163,20 +3177,43 @@ export class AgentWidgetClient {
3163
3177
  const lines = event.split("\n");
3164
3178
  let eventType = "message";
3165
3179
  let data = "";
3180
+ // Durable-reconnect cursor: the SSE `id:` line (the durable row seq).
3181
+ // Only durable, resumable agent executions stamp these (e.g. Claude
3182
+ // Managed agents, or any async/background run the backend persists and
3183
+ // can replay); other streams carry no cursor. We emit a `cursor`
3184
+ // event AFTER the frame is fully parsed and dispatched, so the session's
3185
+ // `lastEventId` only advances past frames it has actually applied, so the
3186
+ // happy path has no dupes against the server's `seq > after` replay.
3187
+ let frameId: string | null = null;
3166
3188
 
3167
3189
  for (const line of lines) {
3168
3190
  if (line.startsWith("event:")) {
3169
3191
  eventType = line.replace("event:", "").trim();
3170
3192
  } else if (line.startsWith("data:")) {
3171
3193
  data += line.replace("data:", "").trim();
3194
+ } else if (line.startsWith("id:")) {
3195
+ frameId = line.slice(3).trim();
3172
3196
  }
3173
3197
  }
3174
3198
 
3175
- if (!data) continue;
3199
+ const advanceCursor = () => {
3200
+ if (frameId !== null && frameId !== "") {
3201
+ onEvent({ type: "cursor", id: frameId });
3202
+ }
3203
+ };
3204
+
3205
+ // A frame with an `id:` but no `data:` (e.g. a bare keepalive line) is
3206
+ // still a received durable row, so advance the cursor past it.
3207
+ if (!data) {
3208
+ advanceCursor();
3209
+ continue;
3210
+ }
3176
3211
  let payload: any;
3177
3212
  try {
3178
3213
  payload = JSON.parse(data);
3179
3214
  } catch (error) {
3215
+ // Parse failure: the frame was NOT applied. Do NOT advance the cursor
3216
+ // so a reconnect re-fetches this row.
3180
3217
  onEvent({
3181
3218
  type: "error",
3182
3219
  error:
@@ -3209,7 +3246,10 @@ export class AgentWidgetClient {
3209
3246
  if (assistantMessageRef.current && assistantMessageRef.current !== assistantMessage) {
3210
3247
  assistantMessage = assistantMessageRef.current;
3211
3248
  }
3212
- if (handled) continue; // Skip default handling if custom handler processed it
3249
+ if (handled) {
3250
+ advanceCursor();
3251
+ continue; // Skip default handling if custom handler processed it
3252
+ }
3213
3253
  }
3214
3254
 
3215
3255
  // The wire is the wire vocabulary; the handler consumes it
@@ -3217,6 +3257,7 @@ export class AgentWidgetClient {
3217
3257
  // drains straight through.
3218
3258
  seqReadyQueue.push({ payloadType, payload });
3219
3259
  drainReadyQueue();
3260
+ advanceCursor();
3220
3261
  }
3221
3262
  }
3222
3263
 
package/src/defaults.ts CHANGED
@@ -129,8 +129,16 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
129
129
  label: "",
130
130
  },
131
131
  scrollBehavior: {
132
- mode: "follow",
132
+ // ChatGPT-style default: pin the just-sent message near the top and let
133
+ // the reply stream into the space below. Restore the old "stick to the
134
+ // bottom" behavior with `mode: "follow"`.
135
+ mode: "anchor-top",
133
136
  anchorTopOffset: 16,
137
+ // Surface the unread count + "streaming below" hint while pinned, so the
138
+ // reader still sees activity arriving off-screen under the pinned turn.
139
+ // (Suppressed by default historically; opted on alongside the anchor-top
140
+ // default so the default UX keeps the affordance.)
141
+ showActivityWhilePinned: true,
134
142
  },
135
143
  toolCallDisplay: {
136
144
  collapsedMode: "tool-call",
@@ -311,12 +311,15 @@ export type RuntypeExecutionStreamEvent = ({
311
311
  seq: number;
312
312
  type: "approval_complete";
313
313
  }) | ({
314
+ awaitReason?: string;
314
315
  awaitedAt?: string;
316
+ crawlId?: string;
315
317
  executionId: string;
316
318
  origin?: "webmcp" | "sdk";
317
319
  pageOrigin?: string;
318
320
  parameters?: Record<string, unknown>;
319
321
  seq: number;
322
+ stepId?: string;
320
323
  toolCallId?: string;
321
324
  toolId?: string;
322
325
  toolName?: string;
@@ -406,13 +409,16 @@ export type RuntypeFlowSSEEvent = {
406
409
  type: "flow_error";
407
410
  upgradeUrl?: string;
408
411
  }) | ({
412
+ awaitReason?: string;
409
413
  awaitedAt: string;
414
+ crawlId?: string;
410
415
  executionId?: string;
411
416
  flowId: string;
412
417
  origin?: "webmcp" | "sdk";
413
418
  pageOrigin?: string;
414
419
  parameters?: Record<string, unknown>;
415
420
  seq?: number;
421
+ stepId?: string;
416
422
  toolCallId?: string;
417
423
  toolId?: string;
418
424
  toolName?: string;
package/src/index-core.ts CHANGED
@@ -263,6 +263,11 @@ export type {
263
263
  AgentWidgetStreamAnimationType,
264
264
  AgentWidgetStreamAnimationFeature,
265
265
  AgentWidgetStreamAnimationPlaceholder,
266
+ AgentWidgetScrollMode,
267
+ AgentWidgetScrollRestorePosition,
268
+ AgentWidgetScrollBehaviorFeature,
269
+ AgentWidgetScrollToBottomFeature,
270
+ AgentWidgetComponentRenderer,
266
271
  } from "./types";
267
272
 
268
273
  // Action system types: needed to type the `actionHandlers` / `actionParsers`
@@ -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
+ });