@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
package/src/session.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
AgentWidgetApprovalDecisionOptions,
|
|
13
13
|
WebMcpConfirmInfo,
|
|
14
14
|
AgentExecutionState,
|
|
15
|
+
ResumableHandle,
|
|
15
16
|
ClientSession,
|
|
16
17
|
ContentPart,
|
|
17
18
|
InjectMessageOptions,
|
|
@@ -44,12 +45,25 @@ import {
|
|
|
44
45
|
} from "./voice";
|
|
45
46
|
import { resolveSpeakableText } from "./utils/speech-text";
|
|
46
47
|
import { loadRuntypeTts } from "./voice/runtype-tts-loader";
|
|
48
|
+
// Type-only (erased at build): the runtime reconnect machinery is reached via a
|
|
49
|
+
// dynamic import in `beginReconnect`, so it never lands in bundles that don't
|
|
50
|
+
// opt into durable reconnect (see ./session-reconnect.ts).
|
|
51
|
+
import type {
|
|
52
|
+
ReconnectController,
|
|
53
|
+
ReconnectHost,
|
|
54
|
+
} from "./session-reconnect";
|
|
47
55
|
|
|
48
56
|
export type AgentWidgetSessionStatus =
|
|
49
57
|
| "idle"
|
|
50
58
|
| "connecting"
|
|
51
59
|
| "connected"
|
|
52
|
-
| "error"
|
|
60
|
+
| "error"
|
|
61
|
+
// Durable-session reconnect states. `paused`: a durable stream dropped and a
|
|
62
|
+
// reconnect is pending. `resuming`: a reconnect attempt is in flight. While
|
|
63
|
+
// in either, the in-progress assistant bubble stays open (not finalized) and
|
|
64
|
+
// `streaming` stays true so the typing indicator persists.
|
|
65
|
+
| "paused"
|
|
66
|
+
| "resuming";
|
|
53
67
|
|
|
54
68
|
/**
|
|
55
69
|
* Config fields the `AgentWidgetClient` reads to shape the connection and each
|
|
@@ -107,6 +121,17 @@ type SessionCallbacks = {
|
|
|
107
121
|
artifacts: PersonaArtifactRecord[];
|
|
108
122
|
selectedId: string | null;
|
|
109
123
|
}) => void;
|
|
124
|
+
/**
|
|
125
|
+
* Durable-session reconnect lifecycle, surfaced so the UI can map it to the
|
|
126
|
+
* public `stream:paused` / `stream:resuming` / `stream:resumed` controller
|
|
127
|
+
* events. `paused` fires once on the drop, `resuming` once per attempt,
|
|
128
|
+
* `resumed` when a reconnect reaches its terminal.
|
|
129
|
+
*/
|
|
130
|
+
onReconnect?: (event: {
|
|
131
|
+
phase: "paused" | "resuming" | "resumed";
|
|
132
|
+
handle: ResumableHandle;
|
|
133
|
+
attempt?: number;
|
|
134
|
+
}) => void;
|
|
110
135
|
};
|
|
111
136
|
|
|
112
137
|
/**
|
|
@@ -171,6 +196,29 @@ export class AgentWidgetSession {
|
|
|
171
196
|
// Agent execution state
|
|
172
197
|
private agentExecution: AgentExecutionState | null = null;
|
|
173
198
|
|
|
199
|
+
// ── Durable-session reconnect ──────────────────────────────────
|
|
200
|
+
// The in-flight resume handle for the durable lane (any resumable agent
|
|
201
|
+
// execution the backend persists and can replay, e.g. Claude Managed agents
|
|
202
|
+
// or async/background runs). Created when a stream that carries SSE `id:`
|
|
203
|
+
// lines first yields a cursor (and an executionId is known); advanced as the
|
|
204
|
+
// cursor climbs; cleared on a graceful terminal, a terminal error, or any
|
|
205
|
+
// teardown. Drives both in-session reconnect and host persistence
|
|
206
|
+
// (`onExecutionState`).
|
|
207
|
+
private resumable: ResumableHandle | null = null;
|
|
208
|
+
// The assistant message id of the current turn, so a reconnect keeps filling
|
|
209
|
+
// the SAME bubble instead of opening a new one.
|
|
210
|
+
private activeAssistantMessageId: string | null = null;
|
|
211
|
+
// True while a reconnect run is active (guards against re-entry from a second
|
|
212
|
+
// drop and tells the dispatch/connectStream catches to stay quiet).
|
|
213
|
+
private reconnecting = false;
|
|
214
|
+
// The reconnect orchestration (backoff loop, wake listeners, give-up
|
|
215
|
+
// finalizer) lives in a lazily-imported module so it stays out of bundles
|
|
216
|
+
// that never opt into durable reconnect. Created on the first reconnect.
|
|
217
|
+
private reconnectController: ReconnectController | null = null;
|
|
218
|
+
private reconnectControllerPromise: Promise<ReconnectController> | null = null;
|
|
219
|
+
// Trailing-edge throttle for `onExecutionState` so it isn't fired per delta.
|
|
220
|
+
private executionStateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
|
+
|
|
174
222
|
private artifacts = new Map<string, PersonaArtifactRecord>();
|
|
175
223
|
private selectedArtifactId: string | null = null;
|
|
176
224
|
|
|
@@ -1096,10 +1144,17 @@ export class AgentWidgetSession {
|
|
|
1096
1144
|
// one) so a lingering resolve can't race the new dispatch or post a stale
|
|
1097
1145
|
// /resume against a superseded execution.
|
|
1098
1146
|
this.abortWebMcpResolves();
|
|
1147
|
+
// A new turn also supersedes any pending durable reconnect from the prior
|
|
1148
|
+
// turn (cancels backoff/listeners, clears the old resume handle).
|
|
1149
|
+
this.teardownReconnect();
|
|
1099
1150
|
|
|
1100
1151
|
// Generate IDs for both user message and expected assistant response
|
|
1101
1152
|
const userMessageId = generateUserMessageId();
|
|
1102
1153
|
const assistantMessageId = generateAssistantMessageId();
|
|
1154
|
+
// The active assistant bubble for a durable reconnect is captured from the
|
|
1155
|
+
// real streamed message events (see handleEvent), not pre-assigned here:
|
|
1156
|
+
// the proxy path auto-generates a different id than `assistantMessageId`.
|
|
1157
|
+
this.activeAssistantMessageId = null;
|
|
1103
1158
|
|
|
1104
1159
|
const userMessage: AgentWidgetMessage = {
|
|
1105
1160
|
id: userMessageId,
|
|
@@ -1132,6 +1187,11 @@ export class AgentWidgetSession {
|
|
|
1132
1187
|
this.handleEvent
|
|
1133
1188
|
);
|
|
1134
1189
|
} catch (error) {
|
|
1190
|
+
// A durable drop fired the dispatch wrapper's `finally` (plain `idle`)
|
|
1191
|
+
// first, which already flipped us into `resuming` and armed reconnect.
|
|
1192
|
+
// The subsequent dispatch rejection must NOT paint a dispatch-error
|
|
1193
|
+
// bubble: the turn is being resumed, not failed.
|
|
1194
|
+
if (this.status === "resuming" || this.reconnecting) return;
|
|
1135
1195
|
// Check if this is an abort error (user canceled, navigated away, etc.)
|
|
1136
1196
|
// In these cases, don't show fallback - the request was intentionally interrupted
|
|
1137
1197
|
const isAbortError =
|
|
@@ -1191,8 +1251,10 @@ export class AgentWidgetSession {
|
|
|
1191
1251
|
if (this.streaming) return;
|
|
1192
1252
|
|
|
1193
1253
|
this.abortController?.abort();
|
|
1254
|
+
this.teardownReconnect();
|
|
1194
1255
|
|
|
1195
1256
|
const assistantMessageId = generateAssistantMessageId();
|
|
1257
|
+
this.activeAssistantMessageId = null;
|
|
1196
1258
|
|
|
1197
1259
|
this.setStreaming(true);
|
|
1198
1260
|
|
|
@@ -1211,6 +1273,7 @@ export class AgentWidgetSession {
|
|
|
1211
1273
|
this.handleEvent
|
|
1212
1274
|
);
|
|
1213
1275
|
} catch (error) {
|
|
1276
|
+
if (this.status === "resuming" || this.reconnecting) return;
|
|
1214
1277
|
// Check if this is an abort error (a prior in-flight stream was canceled,
|
|
1215
1278
|
// the user navigated away, etc.). In these cases, don't show fallback or
|
|
1216
1279
|
// fire onError - the request was intentionally interrupted.
|
|
@@ -1258,18 +1321,44 @@ export class AgentWidgetSession {
|
|
|
1258
1321
|
*/
|
|
1259
1322
|
public async connectStream(
|
|
1260
1323
|
stream: ReadableStream<Uint8Array>,
|
|
1261
|
-
options?: {
|
|
1324
|
+
options?: {
|
|
1325
|
+
assistantMessageId?: string;
|
|
1326
|
+
allowReentry?: boolean;
|
|
1327
|
+
/**
|
|
1328
|
+
* Durable reconnect: keep the assistant message identified by
|
|
1329
|
+
* `assistantMessageId` OPEN so the replayed deltas keep filling it. The
|
|
1330
|
+
* default stale-finalize pass would otherwise seal the very bubble we're
|
|
1331
|
+
* resuming into.
|
|
1332
|
+
*/
|
|
1333
|
+
preserveAssistantId?: boolean;
|
|
1334
|
+
/**
|
|
1335
|
+
* Durable reconnect: text already shown in the resumed bubble, so the
|
|
1336
|
+
* fresh stream's accumulator continues from it (the replay carries only
|
|
1337
|
+
* post-cursor deltas, not the full text).
|
|
1338
|
+
*/
|
|
1339
|
+
seedContent?: string;
|
|
1340
|
+
}
|
|
1262
1341
|
): Promise<void> {
|
|
1263
1342
|
if (this.streaming && !options?.allowReentry) return;
|
|
1264
1343
|
if (!options?.allowReentry) {
|
|
1265
1344
|
this.abortController?.abort();
|
|
1266
1345
|
}
|
|
1267
1346
|
|
|
1347
|
+
// Durable reconnect keeps filling the same bubble: track its id so the
|
|
1348
|
+
// cursor handler attaches to it.
|
|
1349
|
+
if (options?.preserveAssistantId && options.assistantMessageId) {
|
|
1350
|
+
this.activeAssistantMessageId = options.assistantMessageId;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1268
1353
|
// Finalize any stale streaming messages from the previous stream
|
|
1269
|
-
// (e.g., tool messages interrupted by approval pause)
|
|
1354
|
+
// (e.g., tool messages interrupted by approval pause), except the bubble
|
|
1355
|
+
// an active reconnect is resuming into.
|
|
1356
|
+
const preserveId = options?.preserveAssistantId
|
|
1357
|
+
? options.assistantMessageId
|
|
1358
|
+
: undefined;
|
|
1270
1359
|
let hasStale = false;
|
|
1271
1360
|
for (const msg of this.messages) {
|
|
1272
|
-
if (msg.streaming) {
|
|
1361
|
+
if (msg.streaming && msg.id !== preserveId) {
|
|
1273
1362
|
msg.streaming = false;
|
|
1274
1363
|
hasStale = true;
|
|
1275
1364
|
}
|
|
@@ -1284,9 +1373,14 @@ export class AgentWidgetSession {
|
|
|
1284
1373
|
await this.client.processStream(
|
|
1285
1374
|
stream,
|
|
1286
1375
|
this.handleEvent,
|
|
1287
|
-
options?.assistantMessageId
|
|
1376
|
+
options?.assistantMessageId,
|
|
1377
|
+
options?.seedContent
|
|
1288
1378
|
);
|
|
1289
1379
|
} catch (error) {
|
|
1380
|
+
// During a durable reconnect a thrown resume stream is just another drop:
|
|
1381
|
+
// the reconnect loop owns the retry/backoff. Don't paint an error or tear
|
|
1382
|
+
// down, stay in `resuming`.
|
|
1383
|
+
if (this.status === "resuming" || this.reconnecting) return;
|
|
1290
1384
|
this.setStatus("error");
|
|
1291
1385
|
// Mirror the idle/error handlers: a failed resume stream must not tear
|
|
1292
1386
|
// down streaming/abortController while another WebMCP resolve is still
|
|
@@ -2446,6 +2540,9 @@ export class AgentWidgetSession {
|
|
|
2446
2540
|
public cancel() {
|
|
2447
2541
|
this.abortController?.abort();
|
|
2448
2542
|
this.abortController = null;
|
|
2543
|
+
// A user stop also cancels any pending/in-flight durable reconnect and
|
|
2544
|
+
// clears the resume handle (the abort above already killed its fetch).
|
|
2545
|
+
this.teardownReconnect();
|
|
2449
2546
|
// Tear down every in-flight WebMCP resolve (each owns its own controller,
|
|
2450
2547
|
// independent of the shared one above). Clear the inflight set so retries
|
|
2451
2548
|
// are possible if the user re-issues the same step_await context.
|
|
@@ -2464,6 +2561,7 @@ export class AgentWidgetSession {
|
|
|
2464
2561
|
this.stopSpeaking();
|
|
2465
2562
|
this.abortController?.abort();
|
|
2466
2563
|
this.abortController = null;
|
|
2564
|
+
this.teardownReconnect();
|
|
2467
2565
|
// Tear down every in-flight WebMCP resolve too: their messages are about
|
|
2468
2566
|
// to be wiped, and a microtask-deferred resolve must not survive the clear.
|
|
2469
2567
|
this.abortWebMcpResolves();
|
|
@@ -2603,6 +2701,9 @@ export class AgentWidgetSession {
|
|
|
2603
2701
|
public hydrateMessages(messages: AgentWidgetMessage[]) {
|
|
2604
2702
|
this.abortController?.abort();
|
|
2605
2703
|
this.abortController = null;
|
|
2704
|
+
// Hydration replaces the conversation: also cancel any pending reconnect and
|
|
2705
|
+
// clear the resume handle (a boot resume re-arms via resumeFromHandle after).
|
|
2706
|
+
this.teardownReconnect();
|
|
2606
2707
|
// Hydration replaces the conversation: abort and forget every in-flight
|
|
2607
2708
|
// WebMCP resolve; their messages are about to be replaced.
|
|
2608
2709
|
this.abortWebMcpResolves();
|
|
@@ -2638,6 +2739,18 @@ export class AgentWidgetSession {
|
|
|
2638
2739
|
if (event.type === "message") {
|
|
2639
2740
|
this.upsertMessage(event.message);
|
|
2640
2741
|
|
|
2742
|
+
// Track the open assistant text bubble's REAL streamed id so a durable
|
|
2743
|
+
// reconnect keeps filling the same message. The proxy path auto-generates
|
|
2744
|
+
// this id (it isn't the session's pre-generated one), so we must read it
|
|
2745
|
+
// off the actual message events, not assume it.
|
|
2746
|
+
if (
|
|
2747
|
+
event.message.role === "assistant" &&
|
|
2748
|
+
!event.message.variant &&
|
|
2749
|
+
event.message.streaming
|
|
2750
|
+
) {
|
|
2751
|
+
this.activeAssistantMessageId = event.message.id;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2641
2754
|
// Local-tool auto-resolve: when a step_await emits a tool-variant
|
|
2642
2755
|
// message for a `webmcp:*` tool, or the built-in fire-and-forget
|
|
2643
2756
|
// `suggest_replies`: resolve it and post the result to /resume.
|
|
@@ -2694,11 +2807,27 @@ export class AgentWidgetSession {
|
|
|
2694
2807
|
this.agentExecution.currentIteration = event.message.agentMetadata.iteration;
|
|
2695
2808
|
}
|
|
2696
2809
|
}
|
|
2810
|
+
} else if (event.type === "cursor") {
|
|
2811
|
+
// Durable-reconnect cursor. Track the highest SSE `id:` seq as the resume
|
|
2812
|
+
// cursor and (lazily) form the resume handle once the executionId is
|
|
2813
|
+
// known. Only durable, resumable executions emit these, so this is the
|
|
2814
|
+
// natural gate: no `id:` lines → no handle → reconnect never arms.
|
|
2815
|
+
this.trackCursor(event.id);
|
|
2697
2816
|
} else if (event.type === "status") {
|
|
2817
|
+
// A plain `idle` (no `terminal`) on the durable lane, while the run is
|
|
2818
|
+
// genuinely mid-turn, is a dropped connection, not a finish and not an
|
|
2819
|
+
// intentional `step_await` pause. Reconnect instead of finalizing.
|
|
2820
|
+
if (event.status === "idle" && !event.terminal && this.isDurableDrop()) {
|
|
2821
|
+
this.beginReconnect();
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2698
2824
|
this.setStatus(event.status);
|
|
2699
2825
|
if (event.status === "connecting") {
|
|
2700
2826
|
this.setStreaming(true);
|
|
2701
2827
|
} else if (event.status === "idle" || event.status === "error") {
|
|
2828
|
+
// Graceful terminal or terminal error: the durable turn is over, so
|
|
2829
|
+
// drop the resume handle (no reconnect should arm after this).
|
|
2830
|
+
this.clearResumable();
|
|
2702
2831
|
// Keep the typing indicator up while a WebMCP resolve is still in
|
|
2703
2832
|
// flight: in a chained turn the intermediate resume stream ends with an
|
|
2704
2833
|
// idle status, but the successor tool is still executing. The resolve's
|
|
@@ -2733,6 +2862,8 @@ export class AgentWidgetSession {
|
|
|
2733
2862
|
}
|
|
2734
2863
|
} else if (event.type === "error") {
|
|
2735
2864
|
this.setStatus("error");
|
|
2865
|
+
// Terminal error: drop the resume handle so no reconnect arms.
|
|
2866
|
+
this.clearResumable();
|
|
2736
2867
|
// Mirror the idle/status handler: don't tear down streaming while a
|
|
2737
2868
|
// WebMCP resolve is still confirming/executing on another stream: an
|
|
2738
2869
|
// error on one chained resume stream must not hide the typing indicator
|
|
@@ -2756,6 +2887,278 @@ export class AgentWidgetSession {
|
|
|
2756
2887
|
}
|
|
2757
2888
|
};
|
|
2758
2889
|
|
|
2890
|
+
// ── Durable-session reconnect ──────────────────────────────────
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* Record the latest SSE `id:` cursor and (lazily) form/advance the resume
|
|
2894
|
+
* handle. Needs an executionId (lifted into `agentExecution` from the same
|
|
2895
|
+
* frame's metadata, handled before this trailing cursor event) and the active
|
|
2896
|
+
* assistant message id.
|
|
2897
|
+
*/
|
|
2898
|
+
private trackCursor(id: string): void {
|
|
2899
|
+
const executionId = this.agentExecution?.executionId;
|
|
2900
|
+
if (!executionId || !this.activeAssistantMessageId) return;
|
|
2901
|
+
// Only track while the run is live. A graceful terminal sets the execution
|
|
2902
|
+
// to 'complete'/'error' and clears the handle BEFORE its own frame's
|
|
2903
|
+
// trailing cursor fires; without this guard that cursor would re-arm the
|
|
2904
|
+
// handle and the next plain `idle` would look like a spurious drop.
|
|
2905
|
+
if (this.agentExecution?.status !== "running") return;
|
|
2906
|
+
const isNew = this.resumable === null;
|
|
2907
|
+
this.resumable = {
|
|
2908
|
+
executionId,
|
|
2909
|
+
lastEventId: id,
|
|
2910
|
+
assistantMessageId: this.activeAssistantMessageId,
|
|
2911
|
+
status: "running",
|
|
2912
|
+
};
|
|
2913
|
+
// Fire immediately when the handle first appears (so the host can persist
|
|
2914
|
+
// the executionId promptly); throttle subsequent cursor advances.
|
|
2915
|
+
this.notifyExecutionState(isNew);
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
/**
|
|
2919
|
+
* The drop gate: is this stream-end a dropped connection we should reconnect,
|
|
2920
|
+
* rather than a graceful finish or an intentional `step_await` pause? ALL must
|
|
2921
|
+
* hold (see plan §Design overview keystone 3).
|
|
2922
|
+
*/
|
|
2923
|
+
private isDurableDrop(): boolean {
|
|
2924
|
+
return (
|
|
2925
|
+
this.resumable !== null &&
|
|
2926
|
+
typeof this.config.reconnectStream === "function" &&
|
|
2927
|
+
this.abortController?.signal.aborted !== true &&
|
|
2928
|
+
this.webMcpResolveControllers.size === 0 &&
|
|
2929
|
+
this.webMcpAwaitBatches.size === 0 &&
|
|
2930
|
+
!this.isAwaitPending()
|
|
2931
|
+
);
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
/**
|
|
2935
|
+
* True when the run is parked awaiting user input (an unanswered
|
|
2936
|
+
* `ask_user_question` / local-tool await, or a pending approval). Those end
|
|
2937
|
+
* the stream with `idle` and no terminal too, but are NOT drops.
|
|
2938
|
+
*/
|
|
2939
|
+
private isAwaitPending(): boolean {
|
|
2940
|
+
return this.messages.some((m) => {
|
|
2941
|
+
if (
|
|
2942
|
+
m.agentMetadata?.awaitingLocalTool === true &&
|
|
2943
|
+
m.agentMetadata?.askUserQuestionAnswered !== true
|
|
2944
|
+
) {
|
|
2945
|
+
return true;
|
|
2946
|
+
}
|
|
2947
|
+
if (m.variant === "approval" && m.approval?.status === "pending") {
|
|
2948
|
+
return true;
|
|
2949
|
+
}
|
|
2950
|
+
return false;
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
/**
|
|
2955
|
+
* Enter the reconnect flow. Keeps the assistant bubble open and `streaming`
|
|
2956
|
+
* true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
|
|
2957
|
+
* calls (a second drop while reconnecting) are no-ops.
|
|
2958
|
+
*/
|
|
2959
|
+
private beginReconnect(): void {
|
|
2960
|
+
if (this.reconnecting) return;
|
|
2961
|
+
if (!this.resumable || typeof this.config.reconnectStream !== "function") {
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
// Flip the visible state synchronously — the bubble stays open and a
|
|
2965
|
+
// `resuming` status is observable immediately (e.g. right after
|
|
2966
|
+
// `resumeFromHandle`). The heavy backoff loop is loaded lazily below.
|
|
2967
|
+
this.reconnecting = true;
|
|
2968
|
+
this.callbacks.onReconnect?.({ phase: "paused", handle: this.resumable });
|
|
2969
|
+
this.setStreaming(true);
|
|
2970
|
+
this.setStatus("resuming");
|
|
2971
|
+
void this.loadReconnectController().then((controller) => {
|
|
2972
|
+
// The run may have been torn down (new turn / cancel / hydrate) while the
|
|
2973
|
+
// module was loading; only start the loop if we're still reconnecting.
|
|
2974
|
+
if (this.reconnecting && this.resumable) controller.begin();
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* Lazily import and instantiate the reconnect controller. Cached so repeated
|
|
2980
|
+
* reconnects reuse one controller (and one module load). Deliberately kept off
|
|
2981
|
+
* every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
|
|
2982
|
+
* that never reconnects never pulls the chunk.
|
|
2983
|
+
*/
|
|
2984
|
+
private loadReconnectController(): Promise<ReconnectController> {
|
|
2985
|
+
if (this.reconnectController) {
|
|
2986
|
+
return Promise.resolve(this.reconnectController);
|
|
2987
|
+
}
|
|
2988
|
+
if (!this.reconnectControllerPromise) {
|
|
2989
|
+
this.reconnectControllerPromise = import("./session-reconnect").then(
|
|
2990
|
+
({ createReconnectController }) => {
|
|
2991
|
+
const controller = createReconnectController(
|
|
2992
|
+
this.buildReconnectHost()
|
|
2993
|
+
);
|
|
2994
|
+
this.reconnectController = controller;
|
|
2995
|
+
return controller;
|
|
2996
|
+
}
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
return this.reconnectControllerPromise;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
/** The narrow surface the lazily-loaded reconnect loop drives. */
|
|
3003
|
+
private buildReconnectHost(): ReconnectHost {
|
|
3004
|
+
const session = this;
|
|
3005
|
+
return {
|
|
3006
|
+
get config() {
|
|
3007
|
+
return session.config;
|
|
3008
|
+
},
|
|
3009
|
+
getResumable: () => session.resumable,
|
|
3010
|
+
clearResumable: () => session.clearResumable(),
|
|
3011
|
+
getStatus: () => session.status,
|
|
3012
|
+
setStatus: (status) => session.setStatus(status),
|
|
3013
|
+
setStreaming: (streaming) => session.setStreaming(streaming),
|
|
3014
|
+
setReconnecting: (value) => {
|
|
3015
|
+
session.reconnecting = value;
|
|
3016
|
+
},
|
|
3017
|
+
setAbortController: (controller) => {
|
|
3018
|
+
session.abortController = controller;
|
|
3019
|
+
},
|
|
3020
|
+
getMessages: () => session.messages,
|
|
3021
|
+
notifyMessagesChanged: () =>
|
|
3022
|
+
session.callbacks.onMessagesChanged([...session.messages]),
|
|
3023
|
+
resumeConnect: (body, assistantMessageId, seedContent) =>
|
|
3024
|
+
session.connectStream(body, {
|
|
3025
|
+
assistantMessageId,
|
|
3026
|
+
allowReentry: true,
|
|
3027
|
+
preserveAssistantId: true,
|
|
3028
|
+
seedContent,
|
|
3029
|
+
}),
|
|
3030
|
+
appendMessage: (message) => session.appendMessage(message),
|
|
3031
|
+
nextSequence: () => session.nextSequence(),
|
|
3032
|
+
emitReconnect: (event) => session.callbacks.onReconnect?.(event),
|
|
3033
|
+
buildErrorContent: (message) =>
|
|
3034
|
+
buildDispatchErrorContent(
|
|
3035
|
+
new Error(message),
|
|
3036
|
+
session.config.errorMessage
|
|
3037
|
+
),
|
|
3038
|
+
onError: (error) => session.callbacks.onError?.(error),
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
/** Public manual retry (e.g. a "Reconnect" button). */
|
|
3043
|
+
public reconnectNow(): void {
|
|
3044
|
+
if (this.reconnecting) {
|
|
3045
|
+
// Already trying, so just short-circuit the current backoff. (No-op if the
|
|
3046
|
+
// controller is still loading; the first attempt hasn't slept yet.)
|
|
3047
|
+
this.reconnectController?.wake();
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
this.beginReconnect();
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
/**
|
|
3054
|
+
* Resume a durable turn on boot (tab-reload path). Re-opens the trailing
|
|
3055
|
+
* assistant bubble (or mints one), restores the execution/handle, and
|
|
3056
|
+
* reconnects from the persisted cursor. No-op without `reconnectStream`.
|
|
3057
|
+
*/
|
|
3058
|
+
public resumeFromHandle(resume: { executionId: string; after: string }): void {
|
|
3059
|
+
if (typeof this.config.reconnectStream !== "function") return;
|
|
3060
|
+
if (this.reconnecting) return;
|
|
3061
|
+
|
|
3062
|
+
let assistantId = this.reopenTrailingAssistant();
|
|
3063
|
+
if (!assistantId) {
|
|
3064
|
+
assistantId = generateAssistantMessageId();
|
|
3065
|
+
this.appendMessage({
|
|
3066
|
+
id: assistantId,
|
|
3067
|
+
role: "assistant",
|
|
3068
|
+
content: "",
|
|
3069
|
+
createdAt: new Date().toISOString(),
|
|
3070
|
+
streaming: true,
|
|
3071
|
+
sequence: this.nextSequence(),
|
|
3072
|
+
});
|
|
3073
|
+
}
|
|
3074
|
+
this.activeAssistantMessageId = assistantId;
|
|
3075
|
+
if (!this.agentExecution) {
|
|
3076
|
+
this.agentExecution = {
|
|
3077
|
+
executionId: resume.executionId,
|
|
3078
|
+
agentId: "",
|
|
3079
|
+
agentName: "",
|
|
3080
|
+
status: "running",
|
|
3081
|
+
currentIteration: 0,
|
|
3082
|
+
maxTurns: 0,
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
this.resumable = {
|
|
3086
|
+
executionId: resume.executionId,
|
|
3087
|
+
lastEventId: resume.after,
|
|
3088
|
+
assistantMessageId: assistantId,
|
|
3089
|
+
status: "running",
|
|
3090
|
+
};
|
|
3091
|
+
this.beginReconnect();
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
/**
|
|
3095
|
+
* Reopen the trailing assistant bubble (the persisted partial) so replayed
|
|
3096
|
+
* deltas append to it. Returns its id, or null if the last turn doesn't end
|
|
3097
|
+
* in a plain assistant message.
|
|
3098
|
+
*/
|
|
3099
|
+
private reopenTrailingAssistant(): string | null {
|
|
3100
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
3101
|
+
const m = this.messages[i];
|
|
3102
|
+
if (m.role === "assistant" && !m.variant) {
|
|
3103
|
+
m.streaming = true;
|
|
3104
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
3105
|
+
return m.id;
|
|
3106
|
+
}
|
|
3107
|
+
if (m.role === "user") break;
|
|
3108
|
+
}
|
|
3109
|
+
return null;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
/**
|
|
3113
|
+
* Tear down any in-flight/pending reconnect and clear the resume handle. Runs
|
|
3114
|
+
* on every new turn / cancel / hydrate, so it must NOT pull the reconnect
|
|
3115
|
+
* chunk: it only delegates to the controller if one was already created.
|
|
3116
|
+
*/
|
|
3117
|
+
private teardownReconnect(): void {
|
|
3118
|
+
this.reconnecting = false;
|
|
3119
|
+
this.reconnectController?.teardown();
|
|
3120
|
+
this.clearResumable();
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
/** Drop the resume handle and notify the host (`onExecutionState(null)`). */
|
|
3124
|
+
private clearResumable(): void {
|
|
3125
|
+
if (this.executionStateTimer) {
|
|
3126
|
+
clearTimeout(this.executionStateTimer);
|
|
3127
|
+
this.executionStateTimer = null;
|
|
3128
|
+
}
|
|
3129
|
+
const had = this.resumable !== null;
|
|
3130
|
+
this.resumable = null;
|
|
3131
|
+
if (had) this.config.onExecutionState?.(null);
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
/**
|
|
3135
|
+
* Surface the resume handle to the host for persistence. Immediate on
|
|
3136
|
+
* create/clear; trailing-edge throttled on cursor advances so it isn't called
|
|
3137
|
+
* per delta.
|
|
3138
|
+
*/
|
|
3139
|
+
private notifyExecutionState(immediate: boolean): void {
|
|
3140
|
+
const cb = this.config.onExecutionState;
|
|
3141
|
+
if (!cb) return;
|
|
3142
|
+
if (immediate) {
|
|
3143
|
+
if (this.executionStateTimer) {
|
|
3144
|
+
clearTimeout(this.executionStateTimer);
|
|
3145
|
+
this.executionStateTimer = null;
|
|
3146
|
+
}
|
|
3147
|
+
cb(this.resumable);
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
if (this.executionStateTimer) return; // a trailing call is already queued
|
|
3151
|
+
this.executionStateTimer = setTimeout(() => {
|
|
3152
|
+
this.executionStateTimer = null;
|
|
3153
|
+
this.config.onExecutionState?.(this.resumable);
|
|
3154
|
+
}, 500);
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
/** The current durable resume handle, if any (read-only). */
|
|
3158
|
+
public getResumableHandle(): ResumableHandle | null {
|
|
3159
|
+
return this.resumable;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
2759
3162
|
private setStatus(status: AgentWidgetSessionStatus) {
|
|
2760
3163
|
if (this.status === status) return;
|
|
2761
3164
|
this.status = status;
|
package/src/types.ts
CHANGED
|
@@ -386,6 +386,28 @@ export type AgentExecutionState = {
|
|
|
386
386
|
stopReason?: 'complete' | 'end_turn' | 'max_turns' | 'max_cost' | 'timeout' | 'error';
|
|
387
387
|
};
|
|
388
388
|
|
|
389
|
+
/**
|
|
390
|
+
* The coordinates needed to resume a durable agent turn after the SSE
|
|
391
|
+
* connection drops (any resumable, server-persisted execution, e.g. Claude
|
|
392
|
+
* Managed agents or async/background runs). Held on the session while a
|
|
393
|
+
* resumable stream is in flight
|
|
394
|
+
* and surfaced to the host via {@link AgentWidgetConfig.onExecutionState} so it
|
|
395
|
+
* can persist `{ executionId, lastEventId }` next to its own `conversationId`
|
|
396
|
+
* for the tab-reload path.
|
|
397
|
+
*
|
|
398
|
+
* `conversationId` is intentionally absent: it is host-owned (it lives in the
|
|
399
|
+
* host's `reconnectStream`/`customFetch` closure; the widget never sees it).
|
|
400
|
+
*/
|
|
401
|
+
export type ResumableHandle = {
|
|
402
|
+
/** The durable turn to reconnect to (from `agentMetadata.executionId`). */
|
|
403
|
+
executionId: string;
|
|
404
|
+
/** Highest SSE `id:` seq applied: the `?after=` reconnect cursor. */
|
|
405
|
+
lastEventId: string;
|
|
406
|
+
/** The open assistant message being streamed into, kept open on resume. */
|
|
407
|
+
assistantMessageId: string;
|
|
408
|
+
status: 'running';
|
|
409
|
+
};
|
|
410
|
+
|
|
389
411
|
/**
|
|
390
412
|
* Metadata attached to messages created during agent execution.
|
|
391
413
|
*/
|
|
@@ -689,6 +711,12 @@ export type AgentWidgetControllerEventMap = {
|
|
|
689
711
|
"eventStream:closed": { timestamp: number };
|
|
690
712
|
"approval:requested": { approval: AgentWidgetApproval; message: AgentWidgetMessage };
|
|
691
713
|
"approval:resolved": { approval: AgentWidgetApproval; decision: string };
|
|
714
|
+
/** A durable stream dropped; the widget is about to reconnect. */
|
|
715
|
+
"stream:paused": { executionId: string; after: string };
|
|
716
|
+
/** A reconnect attempt is in flight (`attempt` is 1-based). */
|
|
717
|
+
"stream:resuming": { executionId: string; after: string; attempt: number };
|
|
718
|
+
/** The durable turn resumed and reached its terminal after a reconnect. */
|
|
719
|
+
"stream:resumed": { executionId: string; after: string };
|
|
692
720
|
};
|
|
693
721
|
|
|
694
722
|
/**
|
|
@@ -1851,6 +1879,10 @@ export type AgentWidgetStatusIndicatorConfig = {
|
|
|
1851
1879
|
connectingText?: string;
|
|
1852
1880
|
connectedText?: string;
|
|
1853
1881
|
errorText?: string;
|
|
1882
|
+
/** Status text while a dropped durable stream is awaiting reconnect. */
|
|
1883
|
+
pausedText?: string;
|
|
1884
|
+
/** Status text while a reconnect attempt is in flight. */
|
|
1885
|
+
resumingText?: string;
|
|
1854
1886
|
};
|
|
1855
1887
|
|
|
1856
1888
|
export type AgentWidgetVoiceRecognitionConfig = {
|
|
@@ -4160,6 +4192,62 @@ export type AgentWidgetConfig = {
|
|
|
4160
4192
|
* ```
|
|
4161
4193
|
*/
|
|
4162
4194
|
customFetch?: AgentWidgetCustomFetch;
|
|
4195
|
+
/**
|
|
4196
|
+
* Durable-session reconnect transport (host-owned, symmetric to
|
|
4197
|
+
* {@link customFetch}). When a durable agent stream drops mid-turn (any
|
|
4198
|
+
* resumable, server-persisted execution, e.g. Claude Managed agents or
|
|
4199
|
+
* async/background runs), the widget calls this to fetch the read-only
|
|
4200
|
+
* reconnect stream and pipes its body through the normal event pipeline to
|
|
4201
|
+
* resume from where it left off.
|
|
4202
|
+
*
|
|
4203
|
+
* The host closure owns `agentId` / `conversationId` / base URL / auth and
|
|
4204
|
+
* builds the events request, e.g.:
|
|
4205
|
+
*
|
|
4206
|
+
* ```typescript
|
|
4207
|
+
* reconnectStream: ({ executionId, after, signal }) =>
|
|
4208
|
+
* fetch(
|
|
4209
|
+
* `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` +
|
|
4210
|
+
* `/events?conversationId=${conversationId}&after=${after}`,
|
|
4211
|
+
* { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": ver }, signal }
|
|
4212
|
+
* )
|
|
4213
|
+
* ```
|
|
4214
|
+
*
|
|
4215
|
+
* Resolve with a `text/event-stream` `Response`. Throw or resolve non-ok to
|
|
4216
|
+
* signal "this attempt failed" (the widget backs off and retries, then gives
|
|
4217
|
+
* up after the bounded attempts). Reconnect only ever arms on the durable
|
|
4218
|
+
* lane (streams carrying SSE `id:` lines); without this hook a drop finalizes
|
|
4219
|
+
* exactly as before.
|
|
4220
|
+
*/
|
|
4221
|
+
reconnectStream?: (ctx: {
|
|
4222
|
+
executionId: string;
|
|
4223
|
+
after: string;
|
|
4224
|
+
signal: AbortSignal;
|
|
4225
|
+
}) => Promise<Response>;
|
|
4226
|
+
/**
|
|
4227
|
+
* Tuning for the auto-reconnect backoff. Defaults to ~5 attempts over ~30s
|
|
4228
|
+
* (`backoffMs: [1000, 2000, 4000, 8000, 8000]`).
|
|
4229
|
+
*/
|
|
4230
|
+
reconnect?: {
|
|
4231
|
+
/** Max reconnect attempts before giving up and finalizing. @default backoffMs.length */
|
|
4232
|
+
maxAttempts?: number;
|
|
4233
|
+
/** Per-attempt delay (ms) before each retry. @default [1000, 2000, 4000, 8000, 8000] */
|
|
4234
|
+
backoffMs?: number[];
|
|
4235
|
+
};
|
|
4236
|
+
/**
|
|
4237
|
+
* Called whenever the durable resume handle changes: created when a durable
|
|
4238
|
+
* turn starts streaming, updated as the cursor advances (throttled), and
|
|
4239
|
+
* `null` when the turn finishes, errors, or is torn down. Persist
|
|
4240
|
+
* `{ executionId, lastEventId }` next to your `conversationId` and pass it
|
|
4241
|
+
* back via {@link resume} on the next mount to survive a tab reload.
|
|
4242
|
+
*/
|
|
4243
|
+
onExecutionState?: (handle: ResumableHandle | null) => void;
|
|
4244
|
+
/**
|
|
4245
|
+
* Resume a durable turn on boot (tab-reload path). When present alongside
|
|
4246
|
+
* {@link reconnectStream}, the widget enters `resuming` immediately on mount
|
|
4247
|
+
* and reconnects from `after`, replaying everything past the cursor into the
|
|
4248
|
+
* restored conversation.
|
|
4249
|
+
*/
|
|
4250
|
+
resume?: { executionId: string; after: string };
|
|
4163
4251
|
/**
|
|
4164
4252
|
* Custom SSE event parser for non-standard streaming response formats.
|
|
4165
4253
|
*
|
|
@@ -4736,8 +4824,26 @@ export type PersonaArtifactManualUpsert =
|
|
|
4736
4824
|
|
|
4737
4825
|
export type AgentWidgetEvent =
|
|
4738
4826
|
| { type: "message"; message: AgentWidgetMessage }
|
|
4739
|
-
| {
|
|
4827
|
+
| {
|
|
4828
|
+
type: "status";
|
|
4829
|
+
status: "connecting" | "connected" | "error" | "idle";
|
|
4830
|
+
/**
|
|
4831
|
+
* Set on the `idle` emitted from a graceful execution terminal
|
|
4832
|
+
* (`execution_complete`). Distinguishes a real finish from the plain
|
|
4833
|
+
* `idle` the dispatch wrappers emit in their `finally` on a dropped
|
|
4834
|
+
* connection. The durable-reconnect drop detection keys off this.
|
|
4835
|
+
*/
|
|
4836
|
+
terminal?: boolean;
|
|
4837
|
+
}
|
|
4740
4838
|
| { type: "error"; error: Error }
|
|
4839
|
+
/**
|
|
4840
|
+
* Durable-session reconnect cursor. Carries the SSE `id:` line (the durable
|
|
4841
|
+
* row seq) of a fully-parsed frame so the session can track `lastEventId`
|
|
4842
|
+
* and resume from `?after=<id>` after a drop. Only emitted on the durable
|
|
4843
|
+
* lane (any resumable execution that stamps `id:` lines, e.g. Claude Managed
|
|
4844
|
+
* agents or async/background runs).
|
|
4845
|
+
*/
|
|
4846
|
+
| { type: "cursor"; id: string }
|
|
4741
4847
|
| {
|
|
4742
4848
|
type: "artifact_start";
|
|
4743
4849
|
id: string;
|