@runtypelabs/persona 4.5.0 → 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.
- 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-8RICZWQe.d.cts} +6 -0
- package/dist/animations/{types-C6tFDxKy.d.ts → types-8RICZWQe.d.ts} +6 -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 +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +51 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +228 -2
- package/dist/index.d.ts +228 -2
- 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 +90 -0
- package/dist/smart-dom-reader.d.ts +90 -0
- package/dist/theme-editor-preview.cjs +48 -48
- package/dist/theme-editor-preview.d.cts +130 -1
- package/dist/theme-editor-preview.d.ts +130 -1
- package/dist/theme-editor-preview.js +48 -48
- package/dist/theme-editor.d.cts +90 -0
- package/dist/theme-editor.d.ts +90 -0
- package/package.json +2 -2
- package/src/client.ts +48 -7
- package/src/generated/runtype-openapi-contract.ts +6 -0
- 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/dist/theme-editor.d.cts
CHANGED
|
@@ -1053,12 +1053,15 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1053
1053
|
seq: number;
|
|
1054
1054
|
type: "approval_complete";
|
|
1055
1055
|
}) | ({
|
|
1056
|
+
awaitReason?: string;
|
|
1056
1057
|
awaitedAt?: string;
|
|
1058
|
+
crawlId?: string;
|
|
1057
1059
|
executionId: string;
|
|
1058
1060
|
origin?: "webmcp" | "sdk";
|
|
1059
1061
|
pageOrigin?: string;
|
|
1060
1062
|
parameters?: Record<string, unknown>;
|
|
1061
1063
|
seq: number;
|
|
1064
|
+
stepId?: string;
|
|
1062
1065
|
toolCallId?: string;
|
|
1063
1066
|
toolId?: string;
|
|
1064
1067
|
toolName?: string;
|
|
@@ -1147,13 +1150,16 @@ type RuntypeFlowSSEEvent = {
|
|
|
1147
1150
|
type: "flow_error";
|
|
1148
1151
|
upgradeUrl?: string;
|
|
1149
1152
|
}) | ({
|
|
1153
|
+
awaitReason?: string;
|
|
1150
1154
|
awaitedAt: string;
|
|
1155
|
+
crawlId?: string;
|
|
1151
1156
|
executionId?: string;
|
|
1152
1157
|
flowId: string;
|
|
1153
1158
|
origin?: "webmcp" | "sdk";
|
|
1154
1159
|
pageOrigin?: string;
|
|
1155
1160
|
parameters?: Record<string, unknown>;
|
|
1156
1161
|
seq?: number;
|
|
1162
|
+
stepId?: string;
|
|
1157
1163
|
toolCallId?: string;
|
|
1158
1164
|
toolId?: string;
|
|
1159
1165
|
toolName?: string;
|
|
@@ -1693,6 +1699,27 @@ type AgentWidgetWebMcpConfig = {
|
|
|
1693
1699
|
*/
|
|
1694
1700
|
onConfirm?: WebMcpConfirmHandler;
|
|
1695
1701
|
};
|
|
1702
|
+
/**
|
|
1703
|
+
* The coordinates needed to resume a durable agent turn after the SSE
|
|
1704
|
+
* connection drops (any resumable, server-persisted execution, e.g. Claude
|
|
1705
|
+
* Managed agents or async/background runs). Held on the session while a
|
|
1706
|
+
* resumable stream is in flight
|
|
1707
|
+
* and surfaced to the host via {@link AgentWidgetConfig.onExecutionState} so it
|
|
1708
|
+
* can persist `{ executionId, lastEventId }` next to its own `conversationId`
|
|
1709
|
+
* for the tab-reload path.
|
|
1710
|
+
*
|
|
1711
|
+
* `conversationId` is intentionally absent: it is host-owned (it lives in the
|
|
1712
|
+
* host's `reconnectStream`/`customFetch` closure; the widget never sees it).
|
|
1713
|
+
*/
|
|
1714
|
+
type ResumableHandle = {
|
|
1715
|
+
/** The durable turn to reconnect to (from `agentMetadata.executionId`). */
|
|
1716
|
+
executionId: string;
|
|
1717
|
+
/** Highest SSE `id:` seq applied: the `?after=` reconnect cursor. */
|
|
1718
|
+
lastEventId: string;
|
|
1719
|
+
/** The open assistant message being streamed into, kept open on resume. */
|
|
1720
|
+
assistantMessageId: string;
|
|
1721
|
+
status: 'running';
|
|
1722
|
+
};
|
|
1696
1723
|
/**
|
|
1697
1724
|
* Metadata attached to messages created during agent execution.
|
|
1698
1725
|
*/
|
|
@@ -3009,6 +3036,10 @@ type AgentWidgetStatusIndicatorConfig = {
|
|
|
3009
3036
|
connectingText?: string;
|
|
3010
3037
|
connectedText?: string;
|
|
3011
3038
|
errorText?: string;
|
|
3039
|
+
/** Status text while a dropped durable stream is awaiting reconnect. */
|
|
3040
|
+
pausedText?: string;
|
|
3041
|
+
/** Status text while a reconnect attempt is in flight. */
|
|
3042
|
+
resumingText?: string;
|
|
3012
3043
|
};
|
|
3013
3044
|
type AgentWidgetVoiceRecognitionConfig = {
|
|
3014
3045
|
enabled?: boolean;
|
|
@@ -5090,6 +5121,65 @@ type AgentWidgetConfig = {
|
|
|
5090
5121
|
* ```
|
|
5091
5122
|
*/
|
|
5092
5123
|
customFetch?: AgentWidgetCustomFetch;
|
|
5124
|
+
/**
|
|
5125
|
+
* Durable-session reconnect transport (host-owned, symmetric to
|
|
5126
|
+
* {@link customFetch}). When a durable agent stream drops mid-turn (any
|
|
5127
|
+
* resumable, server-persisted execution, e.g. Claude Managed agents or
|
|
5128
|
+
* async/background runs), the widget calls this to fetch the read-only
|
|
5129
|
+
* reconnect stream and pipes its body through the normal event pipeline to
|
|
5130
|
+
* resume from where it left off.
|
|
5131
|
+
*
|
|
5132
|
+
* The host closure owns `agentId` / `conversationId` / base URL / auth and
|
|
5133
|
+
* builds the events request, e.g.:
|
|
5134
|
+
*
|
|
5135
|
+
* ```typescript
|
|
5136
|
+
* reconnectStream: ({ executionId, after, signal }) =>
|
|
5137
|
+
* fetch(
|
|
5138
|
+
* `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` +
|
|
5139
|
+
* `/events?conversationId=${conversationId}&after=${after}`,
|
|
5140
|
+
* { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": ver }, signal }
|
|
5141
|
+
* )
|
|
5142
|
+
* ```
|
|
5143
|
+
*
|
|
5144
|
+
* Resolve with a `text/event-stream` `Response`. Throw or resolve non-ok to
|
|
5145
|
+
* signal "this attempt failed" (the widget backs off and retries, then gives
|
|
5146
|
+
* up after the bounded attempts). Reconnect only ever arms on the durable
|
|
5147
|
+
* lane (streams carrying SSE `id:` lines); without this hook a drop finalizes
|
|
5148
|
+
* exactly as before.
|
|
5149
|
+
*/
|
|
5150
|
+
reconnectStream?: (ctx: {
|
|
5151
|
+
executionId: string;
|
|
5152
|
+
after: string;
|
|
5153
|
+
signal: AbortSignal;
|
|
5154
|
+
}) => Promise<Response>;
|
|
5155
|
+
/**
|
|
5156
|
+
* Tuning for the auto-reconnect backoff. Defaults to ~5 attempts over ~30s
|
|
5157
|
+
* (`backoffMs: [1000, 2000, 4000, 8000, 8000]`).
|
|
5158
|
+
*/
|
|
5159
|
+
reconnect?: {
|
|
5160
|
+
/** Max reconnect attempts before giving up and finalizing. @default backoffMs.length */
|
|
5161
|
+
maxAttempts?: number;
|
|
5162
|
+
/** Per-attempt delay (ms) before each retry. @default [1000, 2000, 4000, 8000, 8000] */
|
|
5163
|
+
backoffMs?: number[];
|
|
5164
|
+
};
|
|
5165
|
+
/**
|
|
5166
|
+
* Called whenever the durable resume handle changes: created when a durable
|
|
5167
|
+
* turn starts streaming, updated as the cursor advances (throttled), and
|
|
5168
|
+
* `null` when the turn finishes, errors, or is torn down. Persist
|
|
5169
|
+
* `{ executionId, lastEventId }` next to your `conversationId` and pass it
|
|
5170
|
+
* back via {@link resume} on the next mount to survive a tab reload.
|
|
5171
|
+
*/
|
|
5172
|
+
onExecutionState?: (handle: ResumableHandle | null) => void;
|
|
5173
|
+
/**
|
|
5174
|
+
* Resume a durable turn on boot (tab-reload path). When present alongside
|
|
5175
|
+
* {@link reconnectStream}, the widget enters `resuming` immediately on mount
|
|
5176
|
+
* and reconnects from `after`, replaying everything past the cursor into the
|
|
5177
|
+
* restored conversation.
|
|
5178
|
+
*/
|
|
5179
|
+
resume?: {
|
|
5180
|
+
executionId: string;
|
|
5181
|
+
after: string;
|
|
5182
|
+
};
|
|
5093
5183
|
/**
|
|
5094
5184
|
* Custom SSE event parser for non-standard streaming response formats.
|
|
5095
5185
|
*
|
package/dist/theme-editor.d.ts
CHANGED
|
@@ -1053,12 +1053,15 @@ type RuntypeExecutionStreamEvent = ({
|
|
|
1053
1053
|
seq: number;
|
|
1054
1054
|
type: "approval_complete";
|
|
1055
1055
|
}) | ({
|
|
1056
|
+
awaitReason?: string;
|
|
1056
1057
|
awaitedAt?: string;
|
|
1058
|
+
crawlId?: string;
|
|
1057
1059
|
executionId: string;
|
|
1058
1060
|
origin?: "webmcp" | "sdk";
|
|
1059
1061
|
pageOrigin?: string;
|
|
1060
1062
|
parameters?: Record<string, unknown>;
|
|
1061
1063
|
seq: number;
|
|
1064
|
+
stepId?: string;
|
|
1062
1065
|
toolCallId?: string;
|
|
1063
1066
|
toolId?: string;
|
|
1064
1067
|
toolName?: string;
|
|
@@ -1147,13 +1150,16 @@ type RuntypeFlowSSEEvent = {
|
|
|
1147
1150
|
type: "flow_error";
|
|
1148
1151
|
upgradeUrl?: string;
|
|
1149
1152
|
}) | ({
|
|
1153
|
+
awaitReason?: string;
|
|
1150
1154
|
awaitedAt: string;
|
|
1155
|
+
crawlId?: string;
|
|
1151
1156
|
executionId?: string;
|
|
1152
1157
|
flowId: string;
|
|
1153
1158
|
origin?: "webmcp" | "sdk";
|
|
1154
1159
|
pageOrigin?: string;
|
|
1155
1160
|
parameters?: Record<string, unknown>;
|
|
1156
1161
|
seq?: number;
|
|
1162
|
+
stepId?: string;
|
|
1157
1163
|
toolCallId?: string;
|
|
1158
1164
|
toolId?: string;
|
|
1159
1165
|
toolName?: string;
|
|
@@ -1693,6 +1699,27 @@ type AgentWidgetWebMcpConfig = {
|
|
|
1693
1699
|
*/
|
|
1694
1700
|
onConfirm?: WebMcpConfirmHandler;
|
|
1695
1701
|
};
|
|
1702
|
+
/**
|
|
1703
|
+
* The coordinates needed to resume a durable agent turn after the SSE
|
|
1704
|
+
* connection drops (any resumable, server-persisted execution, e.g. Claude
|
|
1705
|
+
* Managed agents or async/background runs). Held on the session while a
|
|
1706
|
+
* resumable stream is in flight
|
|
1707
|
+
* and surfaced to the host via {@link AgentWidgetConfig.onExecutionState} so it
|
|
1708
|
+
* can persist `{ executionId, lastEventId }` next to its own `conversationId`
|
|
1709
|
+
* for the tab-reload path.
|
|
1710
|
+
*
|
|
1711
|
+
* `conversationId` is intentionally absent: it is host-owned (it lives in the
|
|
1712
|
+
* host's `reconnectStream`/`customFetch` closure; the widget never sees it).
|
|
1713
|
+
*/
|
|
1714
|
+
type ResumableHandle = {
|
|
1715
|
+
/** The durable turn to reconnect to (from `agentMetadata.executionId`). */
|
|
1716
|
+
executionId: string;
|
|
1717
|
+
/** Highest SSE `id:` seq applied: the `?after=` reconnect cursor. */
|
|
1718
|
+
lastEventId: string;
|
|
1719
|
+
/** The open assistant message being streamed into, kept open on resume. */
|
|
1720
|
+
assistantMessageId: string;
|
|
1721
|
+
status: 'running';
|
|
1722
|
+
};
|
|
1696
1723
|
/**
|
|
1697
1724
|
* Metadata attached to messages created during agent execution.
|
|
1698
1725
|
*/
|
|
@@ -3009,6 +3036,10 @@ type AgentWidgetStatusIndicatorConfig = {
|
|
|
3009
3036
|
connectingText?: string;
|
|
3010
3037
|
connectedText?: string;
|
|
3011
3038
|
errorText?: string;
|
|
3039
|
+
/** Status text while a dropped durable stream is awaiting reconnect. */
|
|
3040
|
+
pausedText?: string;
|
|
3041
|
+
/** Status text while a reconnect attempt is in flight. */
|
|
3042
|
+
resumingText?: string;
|
|
3012
3043
|
};
|
|
3013
3044
|
type AgentWidgetVoiceRecognitionConfig = {
|
|
3014
3045
|
enabled?: boolean;
|
|
@@ -5090,6 +5121,65 @@ type AgentWidgetConfig = {
|
|
|
5090
5121
|
* ```
|
|
5091
5122
|
*/
|
|
5092
5123
|
customFetch?: AgentWidgetCustomFetch;
|
|
5124
|
+
/**
|
|
5125
|
+
* Durable-session reconnect transport (host-owned, symmetric to
|
|
5126
|
+
* {@link customFetch}). When a durable agent stream drops mid-turn (any
|
|
5127
|
+
* resumable, server-persisted execution, e.g. Claude Managed agents or
|
|
5128
|
+
* async/background runs), the widget calls this to fetch the read-only
|
|
5129
|
+
* reconnect stream and pipes its body through the normal event pipeline to
|
|
5130
|
+
* resume from where it left off.
|
|
5131
|
+
*
|
|
5132
|
+
* The host closure owns `agentId` / `conversationId` / base URL / auth and
|
|
5133
|
+
* builds the events request, e.g.:
|
|
5134
|
+
*
|
|
5135
|
+
* ```typescript
|
|
5136
|
+
* reconnectStream: ({ executionId, after, signal }) =>
|
|
5137
|
+
* fetch(
|
|
5138
|
+
* `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` +
|
|
5139
|
+
* `/events?conversationId=${conversationId}&after=${after}`,
|
|
5140
|
+
* { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": ver }, signal }
|
|
5141
|
+
* )
|
|
5142
|
+
* ```
|
|
5143
|
+
*
|
|
5144
|
+
* Resolve with a `text/event-stream` `Response`. Throw or resolve non-ok to
|
|
5145
|
+
* signal "this attempt failed" (the widget backs off and retries, then gives
|
|
5146
|
+
* up after the bounded attempts). Reconnect only ever arms on the durable
|
|
5147
|
+
* lane (streams carrying SSE `id:` lines); without this hook a drop finalizes
|
|
5148
|
+
* exactly as before.
|
|
5149
|
+
*/
|
|
5150
|
+
reconnectStream?: (ctx: {
|
|
5151
|
+
executionId: string;
|
|
5152
|
+
after: string;
|
|
5153
|
+
signal: AbortSignal;
|
|
5154
|
+
}) => Promise<Response>;
|
|
5155
|
+
/**
|
|
5156
|
+
* Tuning for the auto-reconnect backoff. Defaults to ~5 attempts over ~30s
|
|
5157
|
+
* (`backoffMs: [1000, 2000, 4000, 8000, 8000]`).
|
|
5158
|
+
*/
|
|
5159
|
+
reconnect?: {
|
|
5160
|
+
/** Max reconnect attempts before giving up and finalizing. @default backoffMs.length */
|
|
5161
|
+
maxAttempts?: number;
|
|
5162
|
+
/** Per-attempt delay (ms) before each retry. @default [1000, 2000, 4000, 8000, 8000] */
|
|
5163
|
+
backoffMs?: number[];
|
|
5164
|
+
};
|
|
5165
|
+
/**
|
|
5166
|
+
* Called whenever the durable resume handle changes: created when a durable
|
|
5167
|
+
* turn starts streaming, updated as the cursor advances (throttled), and
|
|
5168
|
+
* `null` when the turn finishes, errors, or is torn down. Persist
|
|
5169
|
+
* `{ executionId, lastEventId }` next to your `conversationId` and pass it
|
|
5170
|
+
* back via {@link resume} on the next mount to survive a tab reload.
|
|
5171
|
+
*/
|
|
5172
|
+
onExecutionState?: (handle: ResumableHandle | null) => void;
|
|
5173
|
+
/**
|
|
5174
|
+
* Resume a durable turn on boot (tab-reload path). When present alongside
|
|
5175
|
+
* {@link reconnectStream}, the widget enters `resuming` immediately on mount
|
|
5176
|
+
* and reconnects from `after`, replaying everything past the cursor into the
|
|
5177
|
+
* restored conversation.
|
|
5178
|
+
*/
|
|
5179
|
+
resume?: {
|
|
5180
|
+
executionId: string;
|
|
5181
|
+
after: string;
|
|
5182
|
+
};
|
|
5093
5183
|
/**
|
|
5094
5184
|
* Custom SSE event parser for non-standard streaming response formats.
|
|
5095
5185
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
"build:markdown-parsers": "tsup --config tsup.markdown-parsers.config.ts",
|
|
125
125
|
"build:plugin-kit": "tsup src/plugin-kit.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
126
126
|
"build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
127
|
-
"build:theme-editor-preview": "tsup src/theme-editor-preview.ts --format esm,cjs --minify --dts --out-dir dist
|
|
127
|
+
"build:theme-editor-preview": "tsup src/theme-editor-preview.ts --format esm,cjs --minify --dts --out-dir dist",
|
|
128
128
|
"build:testing": "tsup src/testing.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
129
129
|
"build:smart-dom-reader": "tsup src/smart-dom-reader.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
130
130
|
"build:voice-worklet-player": "tsup src/voice-worklet-player.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
|
@@ -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;
|
|
@@ -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
|
+
});
|