@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.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-C6tFDxKy.d.cts → types-CSmiKRVa.d.cts} +11 -0
- package/dist/animations/{types-C6tFDxKy.d.ts → types-CSmiKRVa.d.ts} +11 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-DFBSCFYN.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +51 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +238 -3
- package/dist/index.d.ts +238 -3
- package/dist/index.global.js +37 -37
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +51 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers-entry-NVFT3TE6.js +1 -0
- package/dist/runtype-tts-entry-HFUV2UF7.js +1 -0
- package/dist/session-reconnect-U77QFUR7.js +1 -0
- package/dist/smart-dom-reader.d.cts +95 -0
- package/dist/smart-dom-reader.d.ts +95 -0
- package/dist/theme-editor-preview.cjs +48 -48
- package/dist/theme-editor-preview.d.cts +135 -1
- package/dist/theme-editor-preview.d.ts +135 -1
- package/dist/theme-editor-preview.js +48 -48
- package/dist/theme-editor.d.cts +95 -0
- package/dist/theme-editor.d.ts +95 -0
- package/package.json +5 -3
- package/src/client.ts +55 -9
- package/src/generated/runtype-openapi-contract.ts +16 -1
- package/src/reconnect-wake.test.ts +162 -0
- package/src/reconnect.test.ts +430 -0
- package/src/session-reconnect.ts +282 -0
- package/src/session.ts +408 -5
- package/src/types.ts +107 -1
- package/src/ui.stream-animation-update.test.ts +99 -0
- package/src/ui.ts +71 -1
- package/src/utils/constants.ts +3 -1
- package/src/utils/morph.test.ts +47 -0
- package/src/utils/morph.ts +18 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Durable-session reconnect orchestration (the cold path).
|
|
2
|
+
//
|
|
3
|
+
// This module holds the heavy reconnect machinery — the bounded backoff loop,
|
|
4
|
+
// the focus/online wake listeners, and the give-up finalizer. It is only ever
|
|
5
|
+
// reached through a dynamic `import("./session-reconnect")` in `session.ts`,
|
|
6
|
+
// gated on `config.reconnectStream`, so a widget that never opts into durable
|
|
7
|
+
// reconnect (the overwhelming majority, including the theme-editor preview)
|
|
8
|
+
// never loads it. With code-splitting enabled on the ESM subpath builds, that
|
|
9
|
+
// keeps this code out of those bundles entirely.
|
|
10
|
+
//
|
|
11
|
+
// IMPORTANT: this module must NOT statically import from `session.ts` (that
|
|
12
|
+
// would form a runtime cycle and pull the whole session back into the split
|
|
13
|
+
// chunk, defeating the point). It reaches everything it needs through the
|
|
14
|
+
// `ReconnectHost` interface the session hands it. Type-only imports are erased
|
|
15
|
+
// at build time, so importing `AgentWidgetSessionStatus` as a type is fine.
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
AgentWidgetConfig,
|
|
19
|
+
AgentWidgetMessage,
|
|
20
|
+
ResumableHandle,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import type { AgentWidgetSessionStatus } from "./session";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The narrow surface the reconnect loop needs from the owning session. The
|
|
26
|
+
* session builds this once (lazily, alongside the controller) so the loop can
|
|
27
|
+
* read/advance the resume handle, drive status/streaming, and pipe the resumed
|
|
28
|
+
* stream back through the session's own `connectStream`.
|
|
29
|
+
*/
|
|
30
|
+
export interface ReconnectHost {
|
|
31
|
+
readonly config: AgentWidgetConfig;
|
|
32
|
+
getResumable(): ResumableHandle | null;
|
|
33
|
+
clearResumable(): void;
|
|
34
|
+
getStatus(): AgentWidgetSessionStatus;
|
|
35
|
+
setStatus(status: AgentWidgetSessionStatus): void;
|
|
36
|
+
setStreaming(streaming: boolean): void;
|
|
37
|
+
setReconnecting(value: boolean): void;
|
|
38
|
+
setAbortController(controller: AbortController | null): void;
|
|
39
|
+
getMessages(): AgentWidgetMessage[];
|
|
40
|
+
notifyMessagesChanged(): void;
|
|
41
|
+
/** Pipe a resumed SSE body through the session, filling the same bubble. */
|
|
42
|
+
resumeConnect(
|
|
43
|
+
body: ReadableStream<Uint8Array>,
|
|
44
|
+
assistantMessageId: string,
|
|
45
|
+
seedContent: string
|
|
46
|
+
): Promise<void>;
|
|
47
|
+
appendMessage(message: AgentWidgetMessage): void;
|
|
48
|
+
nextSequence(): number;
|
|
49
|
+
emitReconnect(event: {
|
|
50
|
+
phase: "paused" | "resuming" | "resumed";
|
|
51
|
+
handle: ResumableHandle;
|
|
52
|
+
attempt?: number;
|
|
53
|
+
}): void;
|
|
54
|
+
/** Build the give-up bubble content (honors `config.errorMessage`). */
|
|
55
|
+
buildErrorContent(message: string): string;
|
|
56
|
+
onError(error: Error): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ReconnectController {
|
|
60
|
+
/** Start the backoff loop. The session has already flipped the visible state
|
|
61
|
+
* (streaming + `resuming` status + `paused` event) synchronously. */
|
|
62
|
+
begin(): void;
|
|
63
|
+
/** Cancel any pending backoff and drop the wake listeners. */
|
|
64
|
+
teardown(): void;
|
|
65
|
+
/** Short-circuit the current backoff sleep (focus/online or manual retry). */
|
|
66
|
+
wake(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_BACKOFF_MS = [1000, 2000, 4000, 8000, 8000];
|
|
70
|
+
|
|
71
|
+
export function createReconnectController(
|
|
72
|
+
host: ReconnectHost
|
|
73
|
+
): ReconnectController {
|
|
74
|
+
// 1-based attempt counter for the active run (0 = not running).
|
|
75
|
+
let attempt = 0;
|
|
76
|
+
let waitResolve: (() => void) | null = null;
|
|
77
|
+
let waitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
78
|
+
let listenersAttached = false;
|
|
79
|
+
|
|
80
|
+
const wake = (): void => {
|
|
81
|
+
if (waitTimer) {
|
|
82
|
+
clearTimeout(waitTimer);
|
|
83
|
+
waitTimer = null;
|
|
84
|
+
}
|
|
85
|
+
if (waitResolve) {
|
|
86
|
+
const resolve = waitResolve;
|
|
87
|
+
waitResolve = null;
|
|
88
|
+
resolve();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Short-circuit the active backoff sleep, but only while a reconnect is
|
|
93
|
+
// genuinely in flight (`resuming`/`paused`).
|
|
94
|
+
const wakeIfActive = (): void => {
|
|
95
|
+
const status = host.getStatus();
|
|
96
|
+
if (status !== "resuming" && status !== "paused") return;
|
|
97
|
+
wake();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// `visibilitychange` fires on both show and hide; only wake on the show
|
|
101
|
+
// transition (ignore the tab being backgrounded).
|
|
102
|
+
const handleVisibility = (): void => {
|
|
103
|
+
if (
|
|
104
|
+
typeof document !== "undefined" &&
|
|
105
|
+
document.visibilityState === "hidden"
|
|
106
|
+
) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
wakeIfActive();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Connectivity restored: retry immediately, even in a background tab — the
|
|
113
|
+
// whole point of the `online` listener is to react before the backoff timer,
|
|
114
|
+
// so it must NOT inherit the visibility guard above.
|
|
115
|
+
const handleOnline = (): void => {
|
|
116
|
+
wakeIfActive();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const attachListeners = (): void => {
|
|
120
|
+
if (listenersAttached) return;
|
|
121
|
+
if (typeof document !== "undefined") {
|
|
122
|
+
document.addEventListener("visibilitychange", handleVisibility);
|
|
123
|
+
}
|
|
124
|
+
if (typeof window !== "undefined") {
|
|
125
|
+
window.addEventListener("online", handleOnline);
|
|
126
|
+
}
|
|
127
|
+
listenersAttached = true;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const detachListeners = (): void => {
|
|
131
|
+
if (!listenersAttached) return;
|
|
132
|
+
if (typeof document !== "undefined") {
|
|
133
|
+
document.removeEventListener("visibilitychange", handleVisibility);
|
|
134
|
+
}
|
|
135
|
+
if (typeof window !== "undefined") {
|
|
136
|
+
window.removeEventListener("online", handleOnline);
|
|
137
|
+
}
|
|
138
|
+
listenersAttached = false;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const waitBackoff = (ms: number): Promise<void> =>
|
|
142
|
+
new Promise<void>((resolve) => {
|
|
143
|
+
waitResolve = resolve;
|
|
144
|
+
waitTimer = setTimeout(() => {
|
|
145
|
+
waitTimer = null;
|
|
146
|
+
waitResolve = null;
|
|
147
|
+
resolve();
|
|
148
|
+
}, ms);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** Give up after exhausting attempts: finalize the bubble and surface error. */
|
|
152
|
+
const finalizeFailure = (): void => {
|
|
153
|
+
const handle = host.getResumable();
|
|
154
|
+
host.clearResumable();
|
|
155
|
+
host.setReconnecting(false);
|
|
156
|
+
attempt = 0;
|
|
157
|
+
detachListeners();
|
|
158
|
+
host.setAbortController(null);
|
|
159
|
+
|
|
160
|
+
let changed = false;
|
|
161
|
+
for (const msg of host.getMessages()) {
|
|
162
|
+
if (msg.streaming) {
|
|
163
|
+
msg.streaming = false;
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const content = host.buildErrorContent(
|
|
169
|
+
"Connection lost and the response could not be resumed."
|
|
170
|
+
);
|
|
171
|
+
if (content) {
|
|
172
|
+
host.appendMessage({
|
|
173
|
+
id: `reconnect-failed-${handle?.executionId ?? host.nextSequence()}`,
|
|
174
|
+
role: "assistant",
|
|
175
|
+
content,
|
|
176
|
+
createdAt: new Date().toISOString(),
|
|
177
|
+
streaming: false,
|
|
178
|
+
sequence: host.nextSequence(),
|
|
179
|
+
});
|
|
180
|
+
} else if (changed) {
|
|
181
|
+
host.notifyMessagesChanged();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
host.setStreaming(false);
|
|
185
|
+
host.setStatus("idle");
|
|
186
|
+
host.onError(new Error("Durable session reconnect failed."));
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const runLoop = async (): Promise<void> => {
|
|
190
|
+
const backoff = host.config.reconnect?.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
191
|
+
const maxAttempts = host.config.reconnect?.maxAttempts ?? backoff.length;
|
|
192
|
+
const reconnectStream = host.config.reconnectStream;
|
|
193
|
+
if (!reconnectStream) {
|
|
194
|
+
host.setReconnecting(false);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
while (host.getResumable() && attempt < maxAttempts) {
|
|
199
|
+
attempt += 1;
|
|
200
|
+
// An attempt is in flight. No-op for attempt 1 (the session's synchronous
|
|
201
|
+
// `beginReconnect` already set `resuming`); flips back from `paused` after
|
|
202
|
+
// each backoff wait.
|
|
203
|
+
host.setStatus("resuming");
|
|
204
|
+
const handle = host.getResumable()!;
|
|
205
|
+
const before = handle.lastEventId;
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
host.setAbortController(controller);
|
|
208
|
+
host.emitReconnect({ phase: "resuming", handle, attempt });
|
|
209
|
+
|
|
210
|
+
let body: ReadableStream<Uint8Array> | null = null;
|
|
211
|
+
try {
|
|
212
|
+
const res = await reconnectStream({
|
|
213
|
+
executionId: handle.executionId,
|
|
214
|
+
after: handle.lastEventId,
|
|
215
|
+
signal: controller.signal,
|
|
216
|
+
});
|
|
217
|
+
if (res && res.ok && res.body) body = res.body;
|
|
218
|
+
} catch {
|
|
219
|
+
body = null;
|
|
220
|
+
}
|
|
221
|
+
if (controller.signal.aborted) return; // torn down (cancel/new turn)
|
|
222
|
+
|
|
223
|
+
if (body) {
|
|
224
|
+
// Seed the resume with the text already shown so post-cursor deltas
|
|
225
|
+
// append rather than clobber the bubble.
|
|
226
|
+
const found = host
|
|
227
|
+
.getMessages()
|
|
228
|
+
.find((m) => m.id === handle.assistantMessageId)?.content;
|
|
229
|
+
const seedContent = typeof found === "string" ? found : "";
|
|
230
|
+
try {
|
|
231
|
+
await host.resumeConnect(body, handle.assistantMessageId, seedContent);
|
|
232
|
+
} catch {
|
|
233
|
+
// resumeConnect swallows resume-stream errors while `resuming`;
|
|
234
|
+
// anything escaping here just falls through to backoff/retry.
|
|
235
|
+
}
|
|
236
|
+
if (controller.signal.aborted) return;
|
|
237
|
+
// Reconnect reached a terminal: the idle(terminal)/error handler cleared
|
|
238
|
+
// the handle. We're done.
|
|
239
|
+
if (!host.getResumable()) {
|
|
240
|
+
host.setReconnecting(false);
|
|
241
|
+
attempt = 0;
|
|
242
|
+
detachListeners();
|
|
243
|
+
host.emitReconnect({ phase: "resumed", handle });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Made forward progress before dropping again → reset the attempt
|
|
247
|
+
// budget so a long, flaky run isn't capped by total drops.
|
|
248
|
+
if (host.getResumable()!.lastEventId !== before) attempt = 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (host.getResumable() && attempt < maxAttempts) {
|
|
252
|
+
// Dropped and waiting to retry: surface `paused` so
|
|
253
|
+
// `statusIndicator.pausedText` renders during the backoff sleep.
|
|
254
|
+
host.setStatus("paused");
|
|
255
|
+
await waitBackoff(
|
|
256
|
+
backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 1000
|
|
257
|
+
);
|
|
258
|
+
if (controller.signal.aborted) return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (host.getResumable()) finalizeFailure();
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
begin(): void {
|
|
267
|
+
attempt = 0;
|
|
268
|
+
attachListeners();
|
|
269
|
+
void runLoop();
|
|
270
|
+
},
|
|
271
|
+
teardown(): void {
|
|
272
|
+
if (waitTimer) {
|
|
273
|
+
clearTimeout(waitTimer);
|
|
274
|
+
waitTimer = null;
|
|
275
|
+
}
|
|
276
|
+
waitResolve = null;
|
|
277
|
+
attempt = 0;
|
|
278
|
+
detachListeners();
|
|
279
|
+
},
|
|
280
|
+
wake,
|
|
281
|
+
};
|
|
282
|
+
}
|