@runtypelabs/persona 4.25.0 → 4.26.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-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
- package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +14 -0
- package/dist/animations-extra.d.ts +14 -0
- package/dist/approval-ui-QAKWNFNM.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +186 -8
- package/dist/approval-ui.d.ts +186 -8
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
- package/dist/artifacts-ui.cjs +4 -4
- package/dist/artifacts-ui.d.cts +186 -8
- package/dist/artifacts-ui.d.ts +186 -8
- package/dist/artifacts-ui.js +5 -5
- package/dist/chunk-2ANTRKTV.js +1 -0
- package/dist/chunk-4HPTPUC6.js +1 -0
- package/dist/chunk-6V3MNEKB.js +1 -0
- package/dist/chunk-GKPCIQPB.js +1 -0
- package/dist/chunk-HGUCGM3K.js +9 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-J4J3Y2P2.js +1 -0
- package/dist/chunk-K6HKIP5K.js +1 -0
- package/dist/chunk-K7NA7G7J.js +1 -0
- package/dist/chunk-MXUTS3LB.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-QRKGQPU7.js +1 -0
- package/dist/chunk-S6PBWE75.js +1 -0
- package/dist/chunk-T7QM7YMF.js +1 -0
- package/dist/chunk-TR2H475E.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/chunk-ZC66NAIR.js +1 -0
- package/dist/client-history-GOZWBDA5.js +1 -0
- package/dist/client-history.js +1 -0
- package/dist/client-stream-42FOI3EC.js +4 -0
- package/dist/client-stream.js +5 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions-E7HJYWO6.js +161 -0
- package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
- package/dist/context-mentions.cjs +7 -7
- package/dist/context-mentions.d.cts +186 -8
- package/dist/context-mentions.d.ts +186 -8
- package/dist/context-mentions.js +8 -8
- package/dist/event-stream-view-FOIJHFQO.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +189 -31
- package/dist/event-stream-view.d.ts +189 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +227 -667
- package/dist/forms-ui.d.ts +227 -667
- package/dist/history-shell-QDQ44PSE.js +3 -0
- package/dist/history-shell.js +3 -0
- package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
- package/dist/history-view.cjs +2 -2
- package/dist/history-view.d.cts +35 -1
- package/dist/history-view.d.ts +35 -1
- package/dist/history-view.js +2 -2
- package/dist/index.cjs +80 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +233 -738
- package/dist/index.d.ts +233 -738
- package/dist/index.global.js +3 -215
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +81 -75
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -67
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +22 -22
- package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-actions-ISSKSI66.js +1 -0
- package/dist/session-actions.js +1 -0
- package/dist/session-reconnect.d.cts +186 -8
- package/dist/session-reconnect.d.ts +186 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +186 -8
- package/dist/smart-dom-reader.d.ts +186 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +188 -8
- package/dist/theme-editor-preview.d.ts +188 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +187 -23
- package/dist/theme-editor.d.ts +187 -23
- package/dist/theme-editor.js +2 -2
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
- package/dist/ui-extras.js +72 -0
- package/dist/voice-runtime-XLU6YK2N.js +17 -0
- package/dist/voice-runtime.cjs +17 -1
- package/dist/voice-runtime.d.cts +11656 -232
- package/dist/voice-runtime.d.ts +11656 -232
- package/dist/voice-runtime.js +17 -1
- package/dist/voice-worklet-player.cjs +19 -4
- package/dist/voice-worklet-player.d.cts +9 -10
- package/dist/voice-worklet-player.d.ts +9 -10
- package/dist/voice-worklet-player.js +19 -4
- package/dist/webmcp-polyfill.js +1 -1
- package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +1 -33
- package/dist/webmcp-runtime.d.ts +1 -33
- package/dist/webmcp-runtime.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +11 -5
- package/src/approval-paused-tool-settle.test.ts +325 -0
- package/src/client-approve.test.ts +311 -0
- package/src/client-history-eager.ts +9 -0
- package/src/client-history-loader.ts +23 -0
- package/src/client-history.ts +579 -0
- package/src/client-resume.ts +177 -0
- package/src/client-stream-eager.ts +12 -0
- package/src/client-stream-loader.ts +25 -0
- package/src/client-stream.ts +1880 -0
- package/src/client.test.ts +31 -1
- package/src/client.ts +293 -2607
- package/src/components/approval-actions.test.ts +27 -0
- package/src/components/approval-actions.ts +27 -3
- package/src/components/message-bubble.test.ts +19 -0
- package/src/components/message-bubble.ts +2 -0
- package/src/components/registry.ts +6 -6
- package/src/components/tool-bubble.test.ts +9 -0
- package/src/components/tool-bubble.ts +1 -0
- package/src/history-shell-bundle.test.ts +54 -0
- package/src/history-shell-eager.ts +12 -0
- package/src/history-shell-loader.ts +27 -0
- package/src/history-shell.ts +2362 -0
- package/src/history-view-loader.ts +2 -0
- package/src/history-view.ts +3 -0
- package/src/index-core.ts +6 -0
- package/src/index-global.ts +90 -248
- package/src/index.ts +13 -0
- package/src/install.ts +6 -4
- package/src/internal/runtype-history-provider.test.ts +1 -0
- package/src/internal/runtype-history-provider.ts +28 -13
- package/src/plugins/registry.ts +8 -8
- package/src/session-actions-chunk.test.ts +55 -0
- package/src/session-actions-eager.ts +11 -0
- package/src/session-actions-loader.ts +37 -0
- package/src/session-actions.ts +909 -0
- package/src/session-history.test.ts +3 -1
- package/src/session.ts +895 -1538
- package/src/session.voice.test.ts +1171 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +204 -5
- package/src/ui-ask-user-sheet.ts +382 -0
- package/src/ui-extras-eager.ts +10 -0
- package/src/ui-extras-entry.ts +9 -0
- package/src/ui-extras-loader.ts +26 -0
- package/src/ui.artifact-rail-resize.test.ts +2 -1
- package/src/ui.ask-user-chunk.test.ts +93 -0
- package/src/ui.history-chunk.test.ts +2 -1
- package/src/ui.history-config-provider.test.ts +2 -1
- package/src/ui.history-confirm-fallback.test.ts +142 -0
- package/src/ui.history-controller.test.ts +2 -1
- package/src/ui.history-lifecycle.test.ts +2 -1
- package/src/ui.history-render-hooks.test.ts +2 -1
- package/src/ui.history-shell.test.ts +2 -1
- package/src/ui.rail-overlay.test.ts +2 -1
- package/src/ui.rail-resize.test.ts +2 -1
- package/src/ui.rail-trigger-pending.test.ts +2 -1
- package/src/ui.ts +340 -2770
- package/src/utils/attachment-manager.ts +67 -67
- package/src/utils/constants.ts +3 -0
- package/src/utils/context-mention-controller.ts +323 -323
- package/src/utils/context-mention-manager.ts +40 -40
- package/src/utils/context-mention-orchestrator.ts +28 -0
- package/src/utils/event-stream-buffer.ts +50 -50
- package/src/utils/event-stream-store.ts +43 -43
- package/src/utils/throughput-tracker.ts +34 -34
- package/src/utils/virtual-scroller.ts +67 -67
- package/src/utils/visitor-store.test.ts +50 -0
- package/src/utils/visitor-store.ts +23 -5
- package/src/voice/__fixtures__/voice-wire/README.md +1 -0
- package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
- package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
- package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
- package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
- package/src/voice/audio-playback-manager.test.ts +57 -0
- package/src/voice/audio-playback-manager.ts +120 -98
- package/src/voice/browser-voice-provider.ts +37 -37
- package/src/voice/fallback-speech-engine.ts +22 -16
- package/src/voice/keyed-voice-transcript.ts +314 -0
- package/src/voice/mic-state-styles.ts +182 -0
- package/src/voice/read-aloud-controller.ts +46 -44
- package/src/voice/runtype-speech-engine.ts +35 -33
- package/src/voice/runtype-voice-provider.ts +680 -189
- package/src/voice/session-voice-wiring.ts +266 -0
- package/src/voice/voice-delegation.test.ts +71 -0
- package/src/voice/voice-delegation.ts +364 -0
- package/src/voice/voice-turn-tracker.ts +6 -6
- package/src/voice/voice.test.ts +1060 -8
- package/src/voice/worklet-playback-engine.test.ts +81 -0
- package/src/voice/worklet-playback-engine.ts +21 -3
- package/src/voice-runtime-loader.ts +4 -1
- package/src/voice-runtime.ts +7 -0
- package/src/webmcp-runtime-entry.ts +41 -39
- package/dist/approval-ui-3NMYNXBD.js +0 -1
- package/dist/chunk-5EIIHQLQ.js +0 -1
- package/dist/chunk-7CO4T5KB.js +0 -1
- package/dist/chunk-EFJBUH5F.js +0 -1
- package/dist/chunk-R2TMIMDI.js +0 -3
- package/dist/chunk-UTVLOTWB.js +0 -9
- package/dist/context-mentions-45MODWOY.js +0 -161
- package/dist/event-stream-view-GL3ADRY2.js +0 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
- package/dist/voice-runtime-7DA7MJH4.js +0 -1
- package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
package/src/session.ts
CHANGED
|
@@ -4,16 +4,22 @@ import {
|
|
|
4
4
|
type SSEEventCallback,
|
|
5
5
|
} from "./client";
|
|
6
6
|
import { isWebMcpToolName } from "./webmcp-bridge";
|
|
7
|
+
import type { SessionActionsHost, SessionActionsInternals } from "./session-actions";
|
|
8
|
+
import {
|
|
9
|
+
getSessionActionsSync,
|
|
10
|
+
loadSessionActions,
|
|
11
|
+
type SessionActionsModule,
|
|
12
|
+
} from "./session-actions-loader";
|
|
7
13
|
import {
|
|
8
14
|
SUGGEST_REPLIES_TOOL_NAME,
|
|
9
15
|
resolveFollowUpsFeature,
|
|
10
|
-
suggestRepliesToolResult,
|
|
11
16
|
} from "./suggest-replies-tool";
|
|
12
17
|
import {
|
|
13
18
|
AgentWidgetConfig,
|
|
14
19
|
AgentWidgetEvent,
|
|
15
20
|
AgentWidgetMessage,
|
|
16
21
|
AgentWidgetApproval,
|
|
22
|
+
AgentWidgetToolCall,
|
|
17
23
|
AgentWidgetApprovalDecisionOptions,
|
|
18
24
|
WebMcpConfirmInfo,
|
|
19
25
|
AgentExecutionState,
|
|
@@ -80,7 +86,10 @@ import {
|
|
|
80
86
|
} from "./voice/read-aloud-controller";
|
|
81
87
|
import { isVoiceSupportedProbe, usesSessionVoice, voiceConnectionChanged } from "./utils/voice-support";
|
|
82
88
|
import { VoiceTurnTracker } from "./voice/voice-turn-tracker";
|
|
83
|
-
import {
|
|
89
|
+
import type { KeyedVoiceTranscript } from "./voice/keyed-voice-transcript";
|
|
90
|
+
import type { VoiceDelegationCapture } from "./voice/voice-delegation";
|
|
91
|
+
import { loadVoiceRuntime, type VoiceRuntimeModule } from "./voice-runtime-loader";
|
|
92
|
+
import type { VoiceWiringSession } from "./voice/session-voice-wiring";
|
|
84
93
|
import { resolveSpeakableText } from "./utils/speech-text";
|
|
85
94
|
import { loadRuntypeTts } from "./voice/runtype-tts-loader";
|
|
86
95
|
// Type-only (erased at build): the runtime reconnect machinery ships in the
|
|
@@ -158,7 +167,7 @@ function connectionConfigChanged(
|
|
|
158
167
|
return CONNECTION_CONFIG_KEYS.some((key) => prev[key] !== next[key]);
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
type SessionCallbacks = {
|
|
170
|
+
export type SessionCallbacks = {
|
|
162
171
|
onMessagesChanged: (messages: AgentWidgetMessage[]) => void;
|
|
163
172
|
onStatusChanged: (status: AgentWidgetSessionStatus) => void;
|
|
164
173
|
onStreamingChanged: (streaming: boolean) => void;
|
|
@@ -262,20 +271,6 @@ function buildDispatchErrorContent(
|
|
|
262
271
|
return err.message ? `${base}\n\n_Details: ${err.message}_` : base;
|
|
263
272
|
}
|
|
264
273
|
|
|
265
|
-
const buildWebMcpErrorResult = (message: string) => ({
|
|
266
|
-
isError: true,
|
|
267
|
-
content: [{ type: "text" as const, text: message }],
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
const getWebMcpErrorMessage = (
|
|
271
|
-
error: unknown,
|
|
272
|
-
fallback = "WebMCP tool execution failed.",
|
|
273
|
-
): string => {
|
|
274
|
-
if (error instanceof Error && error.message) return error.message;
|
|
275
|
-
if (typeof error === "string" && error) return error;
|
|
276
|
-
return fallback;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
274
|
/**
|
|
280
275
|
* Tool names whose `await` the widget resolves automatically (no user
|
|
281
276
|
* pill click): `webmcp:*` page tools and the built-in fire-and-forget
|
|
@@ -290,8 +285,12 @@ export class AgentWidgetSession {
|
|
|
290
285
|
private messages: AgentWidgetMessage[];
|
|
291
286
|
private status: AgentWidgetSessionStatus = "idle";
|
|
292
287
|
private streaming = false;
|
|
288
|
+
/** Approval messages decided on a stream, keyed to that stream so it settles only its own. */
|
|
289
|
+
#approvalSettlesAtStreamEnd = new Map<string, object | undefined>();
|
|
290
|
+
/** The `connectStream` call whose event is being handled, if any. */
|
|
291
|
+
#activeStreamToken: object | undefined;
|
|
293
292
|
private abortController: AbortController | null = null;
|
|
294
|
-
|
|
293
|
+
#sequenceCounter = Date.now();
|
|
295
294
|
|
|
296
295
|
// Client token session management
|
|
297
296
|
private clientSession: ClientSession | null = null;
|
|
@@ -307,23 +306,23 @@ export class AgentWidgetSession {
|
|
|
307
306
|
// cursor climbs; cleared on a graceful terminal, a terminal error, or any
|
|
308
307
|
// teardown. Drives both in-session reconnect and host persistence
|
|
309
308
|
// (`onExecutionState`).
|
|
310
|
-
|
|
309
|
+
#resumable: ResumableHandle | null = null;
|
|
311
310
|
// The assistant message id of the current turn, so a reconnect keeps filling
|
|
312
311
|
// the SAME bubble instead of opening a new one.
|
|
313
|
-
|
|
312
|
+
#activeAssistantMessageId: string | null = null;
|
|
314
313
|
// True while a reconnect run is active (guards against re-entry from a second
|
|
315
314
|
// drop and tells the dispatch/connectStream catches to stay quiet).
|
|
316
|
-
|
|
315
|
+
#reconnecting = false;
|
|
317
316
|
// The reconnect orchestration (backoff loop, wake listeners, give-up
|
|
318
317
|
// finalizer) lives in a lazily-imported module so it stays out of bundles
|
|
319
318
|
// that never opt into durable reconnect. Created on the first reconnect.
|
|
320
|
-
|
|
321
|
-
|
|
319
|
+
#reconnectController: ReconnectController | null = null;
|
|
320
|
+
#reconnectControllerPromise: Promise<ReconnectController> | null = null;
|
|
322
321
|
// Trailing-edge throttle for `onExecutionState` so it isn't fired per delta.
|
|
323
|
-
|
|
322
|
+
#executionStateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
324
323
|
|
|
325
324
|
private artifacts = new Map<string, PersonaArtifactRecord>();
|
|
326
|
-
|
|
325
|
+
#selectedArtifactId: string | null = null;
|
|
327
326
|
|
|
328
327
|
// WebMCP dedupe: keys are `${executionId}:${toolCallId}` so they're
|
|
329
328
|
// naturally scoped to a single dispatch. A later dispatch (new executionId)
|
|
@@ -361,7 +360,7 @@ export class AgentWidgetSession {
|
|
|
361
360
|
// queueMicrotask captures the epoch at queue time and bails if it changed,
|
|
362
361
|
// so a resolve queued just before a teardown can't escape it by installing a
|
|
363
362
|
// fresh controller after the set was already cleared.
|
|
364
|
-
|
|
363
|
+
#webMcpEpoch = 0;
|
|
365
364
|
// WebMCP native approval-bubble gate. When no custom `webmcp.onConfirm` is
|
|
366
365
|
// supplied, the bridge's confirm handler routes here: we inject an
|
|
367
366
|
// approval-variant message and park the bridge on a Promise that resolves
|
|
@@ -369,7 +368,11 @@ export class AgentWidgetSession {
|
|
|
369
368
|
// resolveWebMcpApproval). Resolvers are keyed by the approval message id.
|
|
370
369
|
private webMcpApprovalResolvers: Map<string, (approved: boolean) => void> =
|
|
371
370
|
new Map();
|
|
372
|
-
|
|
371
|
+
#webMcpApprovalSeq = 0;
|
|
372
|
+
// Per approval card: the token of its latest in-flight decision request.
|
|
373
|
+
// Only that request's failure may reopen the card (two decisions can share
|
|
374
|
+
// a Date.now()). Each entry is dropped once its request settles.
|
|
375
|
+
private approvalTokens = new Map<string, object>();
|
|
373
376
|
// Parallel local-tool batching (core#3878). A single model turn can emit
|
|
374
377
|
// multiple `await(local_tool_required)` events for ONE paused
|
|
375
378
|
// executionId: including two PARALLEL calls to the SAME tool ("add SHOE-001
|
|
@@ -387,60 +390,60 @@ export class AgentWidgetSession {
|
|
|
387
390
|
> = new Map();
|
|
388
391
|
|
|
389
392
|
// Voice support
|
|
390
|
-
|
|
393
|
+
#voiceProvider: VoiceProvider | null = null;
|
|
391
394
|
/** In-flight setupVoice chunk load; toggleVoice awaits it when racing setup. */
|
|
392
|
-
|
|
395
|
+
#voiceSetupPromise: Promise<void> | null = null;
|
|
393
396
|
/** Bumped by setupVoice/cleanupVoice so a stale chunk load can't install. */
|
|
394
|
-
|
|
397
|
+
#voiceSetupGeneration = 0;
|
|
395
398
|
/** Latest 0..1 capture amplitude pushed by a provider that reports one. */
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
+
#voiceLevel = 0;
|
|
400
|
+
#voiceActive = false;
|
|
401
|
+
#voiceStatus: VoiceStatus = 'disconnected';
|
|
399
402
|
|
|
400
403
|
// ── Visitor conversation history (D5) ──────────────────────────
|
|
401
404
|
// Identity of the ONE currently open record; not a conversation→session map.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
+
#activeConversationId: string | null = null;
|
|
406
|
+
#activeConversationRevision: string | null = null;
|
|
407
|
+
#historyNextMessageCursor: string | null = null;
|
|
405
408
|
/** Monotonic: a later selection supersedes every earlier fetch/prepare. */
|
|
406
|
-
|
|
409
|
+
#historyOpenEpoch = 0;
|
|
407
410
|
/** Blocks `setClientSession`'s welcome branch across a reopen re-init. */
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
+
#suppressWelcomeInjection = false;
|
|
412
|
+
#historyRecovery: "new_conversation_required" | null = null;
|
|
413
|
+
#historySendBlocked = false;
|
|
411
414
|
/** Continuity wipe / boot reconciliation; dispatch waits on it. */
|
|
412
|
-
|
|
415
|
+
#historyGate: Promise<void> | null = null;
|
|
413
416
|
/** Deduped by `(id, cursor)` so repeated clicks issue one request. */
|
|
414
|
-
|
|
417
|
+
#olderPageRequests = new Map<string, Promise<HistoryOlderPage>>();
|
|
415
418
|
/** Serializes projection finalization against every other operation. */
|
|
416
|
-
|
|
419
|
+
#projectionFinalizationPromise: Promise<void> | null = null;
|
|
417
420
|
/** Message id → projection the server has acknowledged (sent or finalized). */
|
|
418
|
-
|
|
419
|
-
|
|
421
|
+
#acknowledgedProjections = new Map<string, string>();
|
|
422
|
+
#pendingProjections: PendingDisplayProjections | null = null;
|
|
420
423
|
/** Persisted revision as it stood before boot init overwrote it. */
|
|
421
|
-
|
|
422
|
-
|
|
424
|
+
#bootConversationRevision: string | null = null;
|
|
425
|
+
#bootRevisionCaptured = false;
|
|
423
426
|
/** Exactly one visitor-store subscription per instance; re-keyed with the store. */
|
|
424
|
-
|
|
425
|
-
|
|
427
|
+
#visitorStoreUnsubscribe: (() => void) | null = null;
|
|
428
|
+
#subscribedVisitorStore: VisitorStore | null = null;
|
|
426
429
|
/** An external credential change happened; the next dispatch must re-init. */
|
|
427
|
-
|
|
428
|
-
|
|
430
|
+
#credentialReinitPending = false;
|
|
431
|
+
#credentialReinitPromise: Promise<void> | null = null;
|
|
429
432
|
|
|
430
433
|
constructor(
|
|
431
434
|
private config: AgentWidgetConfig = {},
|
|
432
435
|
private callbacks: SessionCallbacks,
|
|
433
436
|
private historyInternals: WidgetHistoryInternals = {}
|
|
434
437
|
) {
|
|
435
|
-
this.historyInternals = this
|
|
438
|
+
this.historyInternals = this.#composeHistoryInternals(historyInternals);
|
|
436
439
|
this.messages = [...(config.initialMessages ?? [])].map((message) => ({
|
|
437
440
|
...message,
|
|
438
|
-
sequence: message.sequence ?? this
|
|
441
|
+
sequence: message.sequence ?? this.#nextSequence()
|
|
439
442
|
}));
|
|
440
|
-
this.messages = this
|
|
443
|
+
this.messages = this.#sortMessages(this.messages);
|
|
441
444
|
this.client = new AgentWidgetClient(config, this.historyInternals);
|
|
442
|
-
this
|
|
443
|
-
this
|
|
445
|
+
this.#syncVisitorStoreSubscription();
|
|
446
|
+
this.#wireDefaultWebMcpConfirm();
|
|
444
447
|
|
|
445
448
|
// Hydrate artifacts from config (mirrors `initialMessages`). Restored
|
|
446
449
|
// records are forced to `status: "complete"`: a mid-stream artifact should
|
|
@@ -449,18 +452,18 @@ export class AgentWidgetSession {
|
|
|
449
452
|
this.artifacts.set(rec.id, { ...rec, status: "complete" });
|
|
450
453
|
}
|
|
451
454
|
if (config.initialSelectedArtifactId != null) {
|
|
452
|
-
this
|
|
455
|
+
this.#selectedArtifactId = config.initialSelectedArtifactId;
|
|
453
456
|
}
|
|
454
457
|
|
|
455
458
|
if (this.messages.length) {
|
|
456
459
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
457
460
|
}
|
|
458
461
|
if (this.artifacts.size > 0) {
|
|
459
|
-
this
|
|
462
|
+
this.#emitArtifactsState();
|
|
460
463
|
}
|
|
461
464
|
this.callbacks.onStatusChanged(this.status);
|
|
462
|
-
this
|
|
463
|
-
this
|
|
465
|
+
this.#prefetchRuntypeTts();
|
|
466
|
+
this.#prefetchVoiceRuntime();
|
|
464
467
|
}
|
|
465
468
|
|
|
466
469
|
/**
|
|
@@ -470,7 +473,7 @@ export class AgentWidgetSession {
|
|
|
470
473
|
* AudioContext still wait for their own triggers. Errors are swallowed: the
|
|
471
474
|
* setupVoice path retries via the chunk loader's rejection-retry.
|
|
472
475
|
*/
|
|
473
|
-
|
|
476
|
+
#prefetchVoiceRuntime(): void {
|
|
474
477
|
if (!this.config.voiceRecognition?.provider) return;
|
|
475
478
|
void loadVoiceRuntime().catch(() => {});
|
|
476
479
|
}
|
|
@@ -484,7 +487,7 @@ export class AgentWidgetSession {
|
|
|
484
487
|
* prefetch just means the chunk loads on first click instead (or, on a hard
|
|
485
488
|
* failure, the browser-voice fallback kicks in).
|
|
486
489
|
*/
|
|
487
|
-
|
|
490
|
+
#prefetchRuntypeTts(): void {
|
|
488
491
|
const tts = this.config.textToSpeech;
|
|
489
492
|
if (tts?.provider !== "runtype" || tts.createEngine) return;
|
|
490
493
|
// Only warm the chunk when the engine will actually be built (same gate as
|
|
@@ -543,14 +546,19 @@ export class AgentWidgetSession {
|
|
|
543
546
|
* Check if voice is currently active
|
|
544
547
|
*/
|
|
545
548
|
public isVoiceActive(): boolean {
|
|
546
|
-
return this
|
|
549
|
+
return this.#voiceActive;
|
|
547
550
|
}
|
|
548
551
|
|
|
549
552
|
/**
|
|
550
553
|
* Get current voice status
|
|
551
554
|
*/
|
|
552
555
|
public getVoiceStatus(): VoiceStatus {
|
|
553
|
-
return this
|
|
556
|
+
return this.#voiceStatus;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The live call's AI-disclosure notice, if it shows one. */
|
|
560
|
+
public getVoiceDisclosure(): string | null {
|
|
561
|
+
return this.#voiceProvider?.getDisclosure?.() ?? null;
|
|
554
562
|
}
|
|
555
563
|
|
|
556
564
|
/**
|
|
@@ -562,12 +570,12 @@ export class AgentWidgetSession {
|
|
|
562
570
|
* always report null and the UI falls back to a fixed midpoint.
|
|
563
571
|
*/
|
|
564
572
|
public getVoiceLevel(): number | null {
|
|
565
|
-
return this
|
|
573
|
+
return this.#voiceProvider?.onLevel ? this.#voiceLevel : null;
|
|
566
574
|
}
|
|
567
575
|
|
|
568
576
|
public getVoiceInterruptionMode(): "none" | "cancel" | "barge-in" {
|
|
569
|
-
if (this
|
|
570
|
-
return this
|
|
577
|
+
if (this.#voiceProvider?.getInterruptionMode) {
|
|
578
|
+
return this.#voiceProvider.getInterruptionMode();
|
|
571
579
|
}
|
|
572
580
|
return "none";
|
|
573
581
|
}
|
|
@@ -577,29 +585,36 @@ export class AgentWidgetSession {
|
|
|
577
585
|
* Returns to idle state.
|
|
578
586
|
*/
|
|
579
587
|
public stopVoicePlayback(): void {
|
|
580
|
-
if (this
|
|
581
|
-
this
|
|
582
|
-
this
|
|
588
|
+
if (this.#voiceProvider?.stopPlayback) {
|
|
589
|
+
this.#voiceTurns.cancel();
|
|
590
|
+
this.#keyedVoice?.cancel();
|
|
591
|
+
this.#voiceProvider.stopPlayback();
|
|
583
592
|
}
|
|
584
593
|
}
|
|
585
594
|
|
|
586
595
|
/** Returns true if the barge-in mic stream is alive (hot mic between turns) */
|
|
587
596
|
public isBargeInActive(): boolean {
|
|
588
|
-
return this
|
|
597
|
+
return this.#voiceProvider?.isBargeInActive?.() ?? false;
|
|
589
598
|
}
|
|
590
599
|
|
|
591
600
|
/** Tear down the barge-in mic pipeline: "hang up" the always-on mic */
|
|
592
601
|
public async deactivateBargeIn(): Promise<void> {
|
|
593
|
-
if (this
|
|
594
|
-
await this
|
|
602
|
+
if (this.#voiceProvider?.deactivateBargeIn) {
|
|
603
|
+
await this.#voiceProvider.deactivateBargeIn();
|
|
595
604
|
}
|
|
596
605
|
}
|
|
597
606
|
|
|
598
607
|
// Pending placeholder IDs for Runtype two-phase voice flow
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
608
|
+
#pendingVoiceUserMessageId: string | null = null;
|
|
609
|
+
#pendingVoiceAssistantMessageId: string | null = null;
|
|
610
|
+
#voiceTurns = new VoiceTurnTracker();
|
|
611
|
+
#voiceDisconnectPromise: Promise<void> = Promise.resolve();
|
|
612
|
+
// Turn-keyed (full-duplex) transcript reconciler, from the lazy voice runtime.
|
|
613
|
+
#keyedVoice: KeyedVoiceTranscript | null = null;
|
|
614
|
+
// The delegated voice turn running through sendMessage: the assistant
|
|
615
|
+
// messages it streams, and whether it failed or was aborted.
|
|
616
|
+
#voiceDelegation: VoiceDelegationCapture | null = null;
|
|
617
|
+
|
|
603
618
|
|
|
604
619
|
// Track message IDs where the Runtype provider already played TTS audio
|
|
605
620
|
// so browser TTS doesn't double-speak them
|
|
@@ -609,7 +624,7 @@ export class AgentWidgetSession {
|
|
|
609
624
|
// message is active, its play/pause state, and the speech engine. The engine
|
|
610
625
|
// is resolved lazily on first playback (inside the user gesture) so a hosted
|
|
611
626
|
// engine via `textToSpeech.createEngine` plugs in without changing this class.
|
|
612
|
-
private readAloud = new ReadAloudController(() => this
|
|
627
|
+
private readAloud = new ReadAloudController(() => this.#createSpeechEngine());
|
|
613
628
|
|
|
614
629
|
/**
|
|
615
630
|
* Resolve the speech engine behind read-aloud (and auto-speak):
|
|
@@ -622,7 +637,7 @@ export class AgentWidgetSession {
|
|
|
622
637
|
* Runtype voices the day the endpoint ships.
|
|
623
638
|
* 3. Otherwise the browser Web Speech API engine.
|
|
624
639
|
*/
|
|
625
|
-
|
|
640
|
+
#createSpeechEngine(): SpeechEngine | Promise<SpeechEngine> | null {
|
|
626
641
|
const tts = this.config.textToSpeech;
|
|
627
642
|
if (tts?.createEngine) return tts.createEngine();
|
|
628
643
|
|
|
@@ -694,7 +709,7 @@ export class AgentWidgetSession {
|
|
|
694
709
|
}
|
|
695
710
|
|
|
696
711
|
this.cleanupVoice();
|
|
697
|
-
const disconnected = this
|
|
712
|
+
const disconnected = this.#voiceDisconnectPromise;
|
|
698
713
|
|
|
699
714
|
// The provider runtime ships in the lazy voice-runtime chunk. The sync
|
|
700
715
|
// signature is preserved: construction + wiring + connect() happen when
|
|
@@ -702,19 +717,19 @@ export class AgentWidgetSession {
|
|
|
702
717
|
// provider is configured, so this is warm in practice). A stale
|
|
703
718
|
// resolution (cleanupVoice or a newer setupVoice ran meanwhile) is
|
|
704
719
|
// discarded via the generation token.
|
|
705
|
-
const generation = ++this
|
|
706
|
-
this
|
|
720
|
+
const generation = ++this.#voiceSetupGeneration;
|
|
721
|
+
this.#voiceSetupPromise = loadVoiceRuntime()
|
|
707
722
|
.then(async (mod) => {
|
|
708
723
|
await disconnected;
|
|
709
|
-
if (generation !== this
|
|
710
|
-
this
|
|
724
|
+
if (generation !== this.#voiceSetupGeneration) return;
|
|
725
|
+
this.#wireVoiceProvider(mod, mod.createVoiceProvider(voiceConfig));
|
|
711
726
|
})
|
|
712
727
|
.catch((error) => {
|
|
713
728
|
console.error('Failed to setup voice:', error);
|
|
714
729
|
})
|
|
715
730
|
.finally(() => {
|
|
716
|
-
if (generation === this
|
|
717
|
-
this
|
|
731
|
+
if (generation === this.#voiceSetupGeneration) {
|
|
732
|
+
this.#voiceSetupPromise = null;
|
|
718
733
|
}
|
|
719
734
|
});
|
|
720
735
|
} catch (error) {
|
|
@@ -722,165 +737,56 @@ export class AgentWidgetSession {
|
|
|
722
737
|
}
|
|
723
738
|
}
|
|
724
739
|
|
|
725
|
-
/**
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
});
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
if (isFinal) {
|
|
779
|
-
this.voiceTurns.start(metadata?.turnId);
|
|
780
|
-
// User finished: the agent is now thinking. Release the user
|
|
781
|
-
// bubble (a new interim starts a fresh turn) and show a typing
|
|
782
|
-
// indicator in a fresh assistant placeholder.
|
|
783
|
-
this.pendingVoiceUserMessageId = null;
|
|
784
|
-
const assistantMsg = this.injectMessage({
|
|
785
|
-
role: 'assistant',
|
|
786
|
-
content: '',
|
|
787
|
-
streaming: true,
|
|
788
|
-
voiceProcessing: true
|
|
789
|
-
});
|
|
790
|
-
this.pendingVoiceAssistantMessageId = assistantMsg.id;
|
|
791
|
-
this.setStreaming(true);
|
|
792
|
-
}
|
|
793
|
-
} else {
|
|
794
|
-
if (!this.voiceTurns.accepts(metadata?.turnId)) return;
|
|
795
|
-
// assistant: runtype sends a single final; the isFinal=false path
|
|
796
|
-
// is reserved for delta-streaming providers (future BYO).
|
|
797
|
-
if (this.pendingVoiceAssistantMessageId) {
|
|
798
|
-
this.upsertMessage({
|
|
799
|
-
id: this.pendingVoiceAssistantMessageId,
|
|
800
|
-
role: 'assistant',
|
|
801
|
-
content: text,
|
|
802
|
-
createdAt: new Date().toISOString(),
|
|
803
|
-
streaming: !isFinal,
|
|
804
|
-
voiceProcessing: !isFinal
|
|
805
|
-
});
|
|
806
|
-
} else {
|
|
807
|
-
const msg = this.injectMessage({
|
|
808
|
-
role: 'assistant',
|
|
809
|
-
content: text,
|
|
810
|
-
streaming: !isFinal,
|
|
811
|
-
voiceProcessing: !isFinal
|
|
812
|
-
});
|
|
813
|
-
this.pendingVoiceAssistantMessageId = msg.id;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
if (isFinal) {
|
|
817
|
-
// The provider plays this reply's audio: mark it spoken so
|
|
818
|
-
// browser TTS doesn't double-speak when streaming ends. Must run
|
|
819
|
-
// BEFORE setStreaming(false), which triggers the TTS check.
|
|
820
|
-
if (this.pendingVoiceAssistantMessageId) {
|
|
821
|
-
this.ttsSpokenMessageIds.add(this.pendingVoiceAssistantMessageId);
|
|
822
|
-
}
|
|
823
|
-
this.setStreaming(false);
|
|
824
|
-
this.pendingVoiceAssistantMessageId = null;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// Live capture amplitude, when the provider owns an audio graph. Stored,
|
|
831
|
-
// not forwarded per callback: the UI samples it on its own frame loop.
|
|
832
|
-
if (this.voiceProvider.onLevel) {
|
|
833
|
-
this.voiceProvider.onLevel((level) => {
|
|
834
|
-
if (!isCurrent()) return;
|
|
835
|
-
this.voiceLevel = Number.isFinite(level)
|
|
836
|
-
? Math.max(0, Math.min(1, level))
|
|
837
|
-
: 0;
|
|
838
|
-
});
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// Surface per-turn latency metrics to the optional config hook.
|
|
842
|
-
if (this.voiceProvider.onMetrics) {
|
|
843
|
-
this.voiceProvider.onMetrics((metrics) => {
|
|
844
|
-
if (!isCurrent()) return;
|
|
845
|
-
this.config.voiceRecognition?.onMetrics?.(metrics);
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
this.voiceProvider.onError((error) => {
|
|
850
|
-
if (!isCurrent()) return;
|
|
851
|
-
console.error('Voice error:', error);
|
|
852
|
-
|
|
853
|
-
// If error occurs while placeholders are pending, update assistant with error text
|
|
854
|
-
if (this.pendingVoiceAssistantMessageId) {
|
|
855
|
-
this.upsertMessage({
|
|
856
|
-
id: this.pendingVoiceAssistantMessageId,
|
|
857
|
-
role: 'assistant',
|
|
858
|
-
content: processingErrorText,
|
|
859
|
-
createdAt: new Date().toISOString(),
|
|
860
|
-
streaming: false,
|
|
861
|
-
voiceProcessing: false
|
|
862
|
-
});
|
|
863
|
-
this.setStreaming(false);
|
|
864
|
-
this.pendingVoiceUserMessageId = null;
|
|
865
|
-
this.pendingVoiceAssistantMessageId = null;
|
|
866
|
-
}
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
this.voiceProvider.onStatusChange((status) => {
|
|
870
|
-
if (!isCurrent()) return;
|
|
871
|
-
this.voiceStatus = status;
|
|
872
|
-
this.voiceActive = status === 'listening';
|
|
873
|
-
if (status === 'listening' || status === 'idle' || status === 'disconnected') {
|
|
874
|
-
this.settlePendingVoiceTurn(status !== 'listening');
|
|
875
|
-
}
|
|
876
|
-
this.callbacks.onVoiceStatusChanged?.(status);
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
this.voiceProvider.connect();
|
|
880
|
-
|
|
881
|
-
} catch (error) {
|
|
882
|
-
console.error('Failed to setup voice:', error);
|
|
883
|
-
}
|
|
740
|
+
/**
|
|
741
|
+
* Install a provider: the wiring itself ships in the lazy voice-runtime
|
|
742
|
+
* chunk (`voice/session-voice-wiring.ts`); this hands it the private state.
|
|
743
|
+
*/
|
|
744
|
+
#wireVoiceProvider(mod: VoiceRuntimeModule, provider: VoiceProvider): void {
|
|
745
|
+
this.#voiceProvider = provider;
|
|
746
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
747
|
+
const session = this;
|
|
748
|
+
mod.wireSessionVoice(
|
|
749
|
+
{
|
|
750
|
+
session: this as unknown as VoiceWiringSession,
|
|
751
|
+
provider: () => session.#voiceProvider,
|
|
752
|
+
generation: () => session.#voiceSetupGeneration,
|
|
753
|
+
get keyed() {
|
|
754
|
+
return session.#keyedVoice;
|
|
755
|
+
},
|
|
756
|
+
set keyed(keyed) {
|
|
757
|
+
session.#keyedVoice = keyed;
|
|
758
|
+
},
|
|
759
|
+
get pendingUser() {
|
|
760
|
+
return session.#pendingVoiceUserMessageId;
|
|
761
|
+
},
|
|
762
|
+
set pendingUser(id) {
|
|
763
|
+
session.#pendingVoiceUserMessageId = id;
|
|
764
|
+
},
|
|
765
|
+
get pendingAssistant() {
|
|
766
|
+
return session.#pendingVoiceAssistantMessageId;
|
|
767
|
+
},
|
|
768
|
+
set pendingAssistant(id) {
|
|
769
|
+
session.#pendingVoiceAssistantMessageId = id;
|
|
770
|
+
},
|
|
771
|
+
turns: () => session.#voiceTurns,
|
|
772
|
+
busy: () => session.#chatTurnBusy(),
|
|
773
|
+
setStreaming: (streaming) => session.#setStreaming(streaming),
|
|
774
|
+
setDelegation: (capture) => {
|
|
775
|
+
session.#voiceDelegation = capture;
|
|
776
|
+
},
|
|
777
|
+
setLevel: (level) => {
|
|
778
|
+
session.#voiceLevel = level;
|
|
779
|
+
},
|
|
780
|
+
setStatus: (status) => {
|
|
781
|
+
session.#voiceStatus = status;
|
|
782
|
+
session.#voiceActive = status === 'listening';
|
|
783
|
+
},
|
|
784
|
+
settlePending: (final) => session.#settlePendingVoiceTurn(final),
|
|
785
|
+
},
|
|
786
|
+
provider,
|
|
787
|
+
mod.KeyedVoiceTranscript,
|
|
788
|
+
mod.createVoiceSessionBridge
|
|
789
|
+
);
|
|
884
790
|
}
|
|
885
791
|
|
|
886
792
|
/**
|
|
@@ -889,21 +795,21 @@ export class AgentWidgetSession {
|
|
|
889
795
|
public async toggleVoice() {
|
|
890
796
|
// A click can race the setup chunk load; wait for it rather than
|
|
891
797
|
// erroring (prefetch at construction makes this window ~one microtask).
|
|
892
|
-
if (!this
|
|
893
|
-
await this
|
|
798
|
+
if (!this.#voiceProvider && this.#voiceSetupPromise) {
|
|
799
|
+
await this.#voiceSetupPromise;
|
|
894
800
|
}
|
|
895
|
-
if (!this
|
|
801
|
+
if (!this.#voiceProvider) {
|
|
896
802
|
console.error('Voice not configured');
|
|
897
803
|
return;
|
|
898
804
|
}
|
|
899
805
|
|
|
900
|
-
if (this
|
|
901
|
-
await this
|
|
806
|
+
if (this.#voiceActive) {
|
|
807
|
+
await this.#voiceProvider.stopListening();
|
|
902
808
|
} else {
|
|
903
809
|
// Stop any in-progress TTS so the mic doesn't pick it up
|
|
904
810
|
this.stopSpeaking();
|
|
905
811
|
try {
|
|
906
|
-
await this
|
|
812
|
+
await this.#voiceProvider.startListening();
|
|
907
813
|
} catch (error) {
|
|
908
814
|
console.error('Failed to start voice:', error);
|
|
909
815
|
}
|
|
@@ -921,42 +827,55 @@ export class AgentWidgetSession {
|
|
|
921
827
|
this.warmClientSession();
|
|
922
828
|
return;
|
|
923
829
|
}
|
|
924
|
-
const generation = this
|
|
830
|
+
const generation = this.#voiceSetupGeneration;
|
|
925
831
|
const run = () => {
|
|
926
|
-
const provider = this
|
|
927
|
-
if (!provider?.prewarm || generation !== this
|
|
928
|
-
if (this
|
|
832
|
+
const provider = this.#voiceProvider;
|
|
833
|
+
if (!provider?.prewarm || generation !== this.#voiceSetupGeneration) return;
|
|
834
|
+
if (this.#voiceActive || this.isBargeInActive()) return;
|
|
929
835
|
void Promise.resolve()
|
|
930
836
|
.then(() => provider.prewarm?.())
|
|
931
837
|
.catch(() => {});
|
|
932
838
|
};
|
|
933
|
-
if (!this
|
|
934
|
-
void this
|
|
839
|
+
if (!this.#voiceProvider && this.#voiceSetupPromise) {
|
|
840
|
+
void this.#voiceSetupPromise.then(run, () => {});
|
|
935
841
|
return;
|
|
936
842
|
}
|
|
937
843
|
run();
|
|
938
844
|
}
|
|
939
845
|
|
|
846
|
+
/** A chat turn is streaming, resuming, or running local (WebMCP) tools. */
|
|
847
|
+
#chatTurnBusy(): boolean {
|
|
848
|
+
return (
|
|
849
|
+
!!this.abortController ||
|
|
850
|
+
this.webMcpResolveControllers.size > 0 ||
|
|
851
|
+
this.webMcpAwaitBatches.size > 0 ||
|
|
852
|
+
this.#reconnecting ||
|
|
853
|
+
this.status === 'resuming'
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
940
857
|
/**
|
|
941
858
|
* Cleanup voice resources
|
|
942
859
|
*/
|
|
943
860
|
public cleanupVoice() {
|
|
944
|
-
const notifyDisconnected = this
|
|
945
|
-
this
|
|
946
|
-
this
|
|
947
|
-
if (this
|
|
948
|
-
const provider = this
|
|
949
|
-
this
|
|
950
|
-
this
|
|
861
|
+
const notifyDisconnected = this.#voiceProvider !== null;
|
|
862
|
+
this.#voiceSetupGeneration++;
|
|
863
|
+
this.#voiceSetupPromise = null;
|
|
864
|
+
if (this.#voiceProvider) {
|
|
865
|
+
const provider = this.#voiceProvider;
|
|
866
|
+
this.#voiceProvider = null;
|
|
867
|
+
this.#voiceDisconnectPromise = this.#disconnectVoice(provider);
|
|
951
868
|
}
|
|
952
|
-
this
|
|
953
|
-
this
|
|
954
|
-
this
|
|
955
|
-
this
|
|
869
|
+
this.#voiceActive = false;
|
|
870
|
+
this.#voiceStatus = 'disconnected';
|
|
871
|
+
this.#settlePendingVoiceTurn(true);
|
|
872
|
+
this.#keyedVoice?.settle();
|
|
873
|
+
this.#keyedVoice = null;
|
|
874
|
+
this.#voiceTurns = new VoiceTurnTracker();
|
|
956
875
|
if (notifyDisconnected) this.callbacks.onVoiceStatusChanged?.('disconnected');
|
|
957
876
|
}
|
|
958
877
|
|
|
959
|
-
|
|
878
|
+
async #disconnectVoice(provider: VoiceProvider): Promise<void> {
|
|
960
879
|
try {
|
|
961
880
|
await provider.disconnect();
|
|
962
881
|
} catch (error) {
|
|
@@ -964,13 +883,13 @@ export class AgentWidgetSession {
|
|
|
964
883
|
}
|
|
965
884
|
}
|
|
966
885
|
|
|
967
|
-
|
|
968
|
-
const userId = includeUser ? this
|
|
969
|
-
const assistantId = this
|
|
886
|
+
#settlePendingVoiceTurn(includeUser: boolean): void {
|
|
887
|
+
const userId = includeUser ? this.#pendingVoiceUserMessageId : null;
|
|
888
|
+
const assistantId = this.#pendingVoiceAssistantMessageId;
|
|
970
889
|
if (!userId && !assistantId) return;
|
|
971
|
-
if (assistantId) this
|
|
972
|
-
if (includeUser) this
|
|
973
|
-
this
|
|
890
|
+
if (assistantId) this.#voiceTurns.cancel();
|
|
891
|
+
if (includeUser) this.#pendingVoiceUserMessageId = null;
|
|
892
|
+
this.#pendingVoiceAssistantMessageId = null;
|
|
974
893
|
if (assistantId) this.ttsSpokenMessageIds.add(assistantId);
|
|
975
894
|
this.messages = this.messages.flatMap((message) => {
|
|
976
895
|
if (message.id !== userId && message.id !== assistantId) return [message];
|
|
@@ -978,7 +897,7 @@ export class AgentWidgetSession {
|
|
|
978
897
|
return [{ ...message, streaming: false, voiceProcessing: false }];
|
|
979
898
|
});
|
|
980
899
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
981
|
-
if (assistantId) this
|
|
900
|
+
if (assistantId) this.#setStreaming(false);
|
|
982
901
|
}
|
|
983
902
|
|
|
984
903
|
/**
|
|
@@ -1004,7 +923,11 @@ export class AgentWidgetSession {
|
|
|
1004
923
|
voiceId: providerConfig.runtype?.voiceId,
|
|
1005
924
|
createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine,
|
|
1006
925
|
prewarmMode: providerConfig.runtype?.prewarmMode,
|
|
1007
|
-
attachIdleMs: providerConfig.runtype?.attachIdleMs
|
|
926
|
+
attachIdleMs: providerConfig.runtype?.attachIdleMs,
|
|
927
|
+
clientDelegation: providerConfig.runtype?.clientDelegation,
|
|
928
|
+
callContext: providerConfig.runtype?.callContext,
|
|
929
|
+
approvalTimeoutMs: providerConfig.runtype?.approvalTimeoutMs,
|
|
930
|
+
disclosureText: providerConfig.runtype?.disclosureText
|
|
1008
931
|
}
|
|
1009
932
|
};
|
|
1010
933
|
|
|
@@ -1046,9 +969,9 @@ export class AgentWidgetSession {
|
|
|
1046
969
|
await this.historyInternals.historyBootstrapReady;
|
|
1047
970
|
// Snapshot before init overwrites it: boot reconciliation compares the
|
|
1048
971
|
// init revision against what the LAST page load persisted.
|
|
1049
|
-
this
|
|
972
|
+
this.#bootConversationRevision =
|
|
1050
973
|
this.historyInternals.getStoredConversationRevision?.() ?? null;
|
|
1051
|
-
this
|
|
974
|
+
this.#bootRevisionCaptured = true;
|
|
1052
975
|
}
|
|
1053
976
|
|
|
1054
977
|
try {
|
|
@@ -1085,7 +1008,7 @@ export class AgentWidgetSession {
|
|
|
1085
1008
|
*/
|
|
1086
1009
|
public setClientSession(session: ClientSession, pristine?: boolean): void {
|
|
1087
1010
|
this.clientSession = session;
|
|
1088
|
-
this
|
|
1011
|
+
this.#injectWelcomeMessage(session, pristine);
|
|
1089
1012
|
}
|
|
1090
1013
|
|
|
1091
1014
|
/**
|
|
@@ -1093,11 +1016,11 @@ export class AgentWidgetSession {
|
|
|
1093
1016
|
* reopening a stored conversation is not a new thread, and the hydrated
|
|
1094
1017
|
* transcript would otherwise race a welcome bubble.
|
|
1095
1018
|
*/
|
|
1096
|
-
|
|
1019
|
+
#injectWelcomeMessage(
|
|
1097
1020
|
session: ClientSession,
|
|
1098
1021
|
pristine = !this.messages.length
|
|
1099
1022
|
): void {
|
|
1100
|
-
if (this
|
|
1023
|
+
if (this.#suppressWelcomeInjection) return;
|
|
1101
1024
|
if (!session.config.welcomeMessage || !pristine) return;
|
|
1102
1025
|
// A late welcome (the first send raced the init) sorts ahead of that turn.
|
|
1103
1026
|
const first = this.messages[0];
|
|
@@ -1106,9 +1029,9 @@ export class AgentWidgetSession {
|
|
|
1106
1029
|
role: "assistant",
|
|
1107
1030
|
content: session.config.welcomeMessage,
|
|
1108
1031
|
createdAt: first?.createdAt ?? new Date().toISOString(),
|
|
1109
|
-
sequence: first ? (first.sequence ?? 0) - 1 : this
|
|
1032
|
+
sequence: first ? (first.sequence ?? 0) - 1 : this.#nextSequence()
|
|
1110
1033
|
};
|
|
1111
|
-
this
|
|
1034
|
+
this.#appendMessage(welcomeMessage);
|
|
1112
1035
|
}
|
|
1113
1036
|
|
|
1114
1037
|
/**
|
|
@@ -1184,17 +1107,17 @@ export class AgentWidgetSession {
|
|
|
1184
1107
|
* client and any rebuilt one share the same store.
|
|
1185
1108
|
*/
|
|
1186
1109
|
public setHistoryInternals(internals: WidgetHistoryInternals): void {
|
|
1187
|
-
this.historyInternals = this
|
|
1110
|
+
this.historyInternals = this.#composeHistoryInternals(internals);
|
|
1188
1111
|
this.client.setHistoryInternals(this.historyInternals);
|
|
1189
|
-
this
|
|
1112
|
+
this.#syncVisitorStoreSubscription();
|
|
1190
1113
|
}
|
|
1191
1114
|
|
|
1192
1115
|
/** Widget teardown: release the visitor-store subscription. */
|
|
1193
1116
|
public destroy(): void {
|
|
1194
1117
|
this.cleanupVoice();
|
|
1195
|
-
this
|
|
1196
|
-
this
|
|
1197
|
-
this
|
|
1118
|
+
this.#visitorStoreUnsubscribe?.();
|
|
1119
|
+
this.#visitorStoreUnsubscribe = null;
|
|
1120
|
+
this.#subscribedVisitorStore = null;
|
|
1198
1121
|
}
|
|
1199
1122
|
|
|
1200
1123
|
// ==========================================================================
|
|
@@ -1206,25 +1129,25 @@ export class AgentWidgetSession {
|
|
|
1206
1129
|
* supplied its own observer: a credential/record break must wipe local state
|
|
1207
1130
|
* before the replacement session becomes sendable.
|
|
1208
1131
|
*/
|
|
1209
|
-
|
|
1132
|
+
#composeHistoryInternals(
|
|
1210
1133
|
internals: WidgetHistoryInternals
|
|
1211
1134
|
): WidgetHistoryInternals {
|
|
1212
1135
|
const hostContinuity = internals.onHistoryContinuityChanged;
|
|
1213
1136
|
return {
|
|
1214
1137
|
...internals,
|
|
1215
1138
|
onHistoryContinuityChanged: (info) => {
|
|
1216
|
-
this
|
|
1139
|
+
this.#handleHistoryContinuityChanged(info);
|
|
1217
1140
|
hostContinuity?.(info);
|
|
1218
1141
|
},
|
|
1219
1142
|
};
|
|
1220
1143
|
}
|
|
1221
1144
|
|
|
1222
|
-
|
|
1145
|
+
get #historyProvider(): HistoryProvider | null {
|
|
1223
1146
|
return this.historyInternals.historyProvider ?? null;
|
|
1224
1147
|
}
|
|
1225
1148
|
|
|
1226
|
-
|
|
1227
|
-
const provider = this
|
|
1149
|
+
#requireHistoryProvider(): HistoryProvider {
|
|
1150
|
+
const provider = this.#historyProvider;
|
|
1228
1151
|
if (!provider) {
|
|
1229
1152
|
throw new SessionHistoryError(
|
|
1230
1153
|
"history_unavailable",
|
|
@@ -1238,8 +1161,8 @@ export class AgentWidgetSession {
|
|
|
1238
1161
|
* One resolved scope per logical action. An explicitly requested scope the
|
|
1239
1162
|
* provider cannot serve fails locally rather than silently downgrading.
|
|
1240
1163
|
*/
|
|
1241
|
-
|
|
1242
|
-
const provider = this
|
|
1164
|
+
#historyContext(scope?: HistoryScope): HistoryOperationContext {
|
|
1165
|
+
const provider = this.#requireHistoryProvider();
|
|
1243
1166
|
const requested = scope ?? this.config.features?.history?.scope;
|
|
1244
1167
|
const derived: HistoryScope = this.config.getIdentityProof
|
|
1245
1168
|
? "verified-user"
|
|
@@ -1262,23 +1185,23 @@ export class AgentWidgetSession {
|
|
|
1262
1185
|
|
|
1263
1186
|
public getHistoryState(): SessionHistoryState {
|
|
1264
1187
|
return {
|
|
1265
|
-
activeConversationId: this
|
|
1266
|
-
conversationRevision: this
|
|
1267
|
-
nextMessageCursor: this
|
|
1268
|
-
recovery: this
|
|
1269
|
-
sendBlocked: this
|
|
1188
|
+
activeConversationId: this.#activeConversationId,
|
|
1189
|
+
conversationRevision: this.#activeConversationRevision,
|
|
1190
|
+
nextMessageCursor: this.#historyNextMessageCursor,
|
|
1191
|
+
recovery: this.#historyRecovery,
|
|
1192
|
+
sendBlocked: this.#historySendBlocked,
|
|
1270
1193
|
};
|
|
1271
1194
|
}
|
|
1272
1195
|
|
|
1273
1196
|
public getActiveConversationId(): string | null {
|
|
1274
|
-
return this
|
|
1197
|
+
return this.#activeConversationId;
|
|
1275
1198
|
}
|
|
1276
1199
|
|
|
1277
|
-
|
|
1200
|
+
#emitHistoryState(): void {
|
|
1278
1201
|
this.callbacks.onHistoryStateChanged?.(this.getHistoryState());
|
|
1279
1202
|
}
|
|
1280
1203
|
|
|
1281
|
-
|
|
1204
|
+
#notifyHistory(
|
|
1282
1205
|
code: SessionHistoryNoticeCode,
|
|
1283
1206
|
message: string
|
|
1284
1207
|
): void {
|
|
@@ -1287,13 +1210,13 @@ export class AgentWidgetSession {
|
|
|
1287
1210
|
|
|
1288
1211
|
/** Pending destructive/reconciliation work every other operation waits on. */
|
|
1289
1212
|
public async awaitHistorySettled(): Promise<void> {
|
|
1290
|
-
const gate = this
|
|
1213
|
+
const gate = this.#historyGate;
|
|
1291
1214
|
if (gate) await gate.catch(() => {});
|
|
1292
|
-
const finalization = this
|
|
1215
|
+
const finalization = this.#projectionFinalizationPromise;
|
|
1293
1216
|
if (finalization) await finalization.catch(() => {});
|
|
1294
1217
|
}
|
|
1295
1218
|
|
|
1296
|
-
|
|
1219
|
+
#assertHistoryIdle(): void {
|
|
1297
1220
|
if (this.streaming || this.status === "paused" || this.status === "resuming") {
|
|
1298
1221
|
throw new SessionHistoryError(
|
|
1299
1222
|
"conversation_busy",
|
|
@@ -1308,12 +1231,12 @@ export class AgentWidgetSession {
|
|
|
1308
1231
|
targetId?: string;
|
|
1309
1232
|
scope?: HistoryScope;
|
|
1310
1233
|
}) {
|
|
1311
|
-
const provider = this
|
|
1234
|
+
const provider = this.#requireHistoryProvider();
|
|
1312
1235
|
return provider.list({
|
|
1313
1236
|
...(opts?.cursor ? { cursor: opts.cursor } : {}),
|
|
1314
1237
|
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
1315
1238
|
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1316
|
-
context: this
|
|
1239
|
+
context: this.#historyContext(opts?.scope),
|
|
1317
1240
|
});
|
|
1318
1241
|
}
|
|
1319
1242
|
|
|
@@ -1329,46 +1252,46 @@ export class AgentWidgetSession {
|
|
|
1329
1252
|
summary: HistoryConversationSummary;
|
|
1330
1253
|
nextMessageCursor: string | null;
|
|
1331
1254
|
}> {
|
|
1332
|
-
const provider = this
|
|
1333
|
-
this
|
|
1334
|
-
const context = this
|
|
1255
|
+
const provider = this.#requireHistoryProvider();
|
|
1256
|
+
this.#assertHistoryIdle();
|
|
1257
|
+
const context = this.#historyContext(opts?.scope);
|
|
1335
1258
|
await this.awaitHistorySettled();
|
|
1336
|
-
const epoch = ++this
|
|
1259
|
+
const epoch = ++this.#historyOpenEpoch;
|
|
1337
1260
|
|
|
1338
1261
|
const page = await provider.getPage(id, { context });
|
|
1339
|
-
if (epoch !== this
|
|
1262
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1340
1263
|
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1341
1264
|
}
|
|
1342
1265
|
|
|
1343
1266
|
const prepared = await provider.prepareOpen(id, { context });
|
|
1344
|
-
if (epoch !== this
|
|
1267
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1345
1268
|
prepared.discard();
|
|
1346
1269
|
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1347
1270
|
}
|
|
1348
1271
|
|
|
1349
1272
|
// The reopened transcript is not a new conversation: never inject welcome.
|
|
1350
|
-
this
|
|
1273
|
+
this.#suppressWelcomeInjection = true;
|
|
1351
1274
|
try {
|
|
1352
|
-
await this
|
|
1275
|
+
await this.#commitActivation(prepared);
|
|
1353
1276
|
} finally {
|
|
1354
|
-
this
|
|
1277
|
+
this.#suppressWelcomeInjection = false;
|
|
1355
1278
|
}
|
|
1356
1279
|
|
|
1357
|
-
this
|
|
1358
|
-
this
|
|
1280
|
+
this.#activeConversationId = prepared.conversationId || id;
|
|
1281
|
+
this.#activeConversationRevision =
|
|
1359
1282
|
prepared.conversationRevision || page.conversationRevision || null;
|
|
1360
|
-
this
|
|
1361
|
-
this
|
|
1362
|
-
this
|
|
1363
|
-
this
|
|
1283
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1284
|
+
this.#historyRecovery = null;
|
|
1285
|
+
this.#historySendBlocked = false;
|
|
1286
|
+
this.#resetConversationScopedState();
|
|
1364
1287
|
// Server projections are by definition acknowledged.
|
|
1365
1288
|
for (const message of page.messages) {
|
|
1366
|
-
this
|
|
1289
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1367
1290
|
}
|
|
1368
1291
|
// Metadata first, transcript last: the transcript emit is what persists.
|
|
1369
|
-
this
|
|
1292
|
+
this.#persistConversationMetadata();
|
|
1370
1293
|
this.hydrateMessages(page.messages);
|
|
1371
|
-
this
|
|
1294
|
+
this.#emitHistoryState();
|
|
1372
1295
|
return { summary: page.summary, nextMessageCursor: page.nextCursor };
|
|
1373
1296
|
}
|
|
1374
1297
|
|
|
@@ -1381,25 +1304,25 @@ export class AgentWidgetSession {
|
|
|
1381
1304
|
cursor?: string | null,
|
|
1382
1305
|
opts?: { scope?: HistoryScope }
|
|
1383
1306
|
): Promise<HistoryOlderPage> {
|
|
1384
|
-
const provider = this
|
|
1385
|
-
const context = this
|
|
1386
|
-
const resolvedCursor = cursor ?? this
|
|
1307
|
+
const provider = this.#requireHistoryProvider();
|
|
1308
|
+
const context = this.#historyContext(opts?.scope);
|
|
1309
|
+
const resolvedCursor = cursor ?? this.#historyNextMessageCursor;
|
|
1387
1310
|
if (!resolvedCursor) {
|
|
1388
1311
|
return Promise.resolve({ messages: [], nextMessageCursor: null });
|
|
1389
1312
|
}
|
|
1390
1313
|
const key = `${id} ${resolvedCursor}`;
|
|
1391
|
-
const inflight = this
|
|
1314
|
+
const inflight = this.#olderPageRequests.get(key);
|
|
1392
1315
|
if (inflight) return inflight;
|
|
1393
1316
|
|
|
1394
|
-
const request = this
|
|
1317
|
+
const request = this.#fetchOlderPage(provider, id, resolvedCursor, context)
|
|
1395
1318
|
.finally(() => {
|
|
1396
|
-
this
|
|
1319
|
+
this.#olderPageRequests.delete(key);
|
|
1397
1320
|
});
|
|
1398
|
-
this
|
|
1321
|
+
this.#olderPageRequests.set(key, request);
|
|
1399
1322
|
return request;
|
|
1400
1323
|
}
|
|
1401
1324
|
|
|
1402
|
-
|
|
1325
|
+
async #fetchOlderPage(
|
|
1403
1326
|
provider: HistoryProvider,
|
|
1404
1327
|
id: string,
|
|
1405
1328
|
cursor: string,
|
|
@@ -1412,20 +1335,20 @@ export class AgentWidgetSession {
|
|
|
1412
1335
|
context,
|
|
1413
1336
|
});
|
|
1414
1337
|
// A conversation switch during the fetch retires this page.
|
|
1415
|
-
if (this
|
|
1338
|
+
if (this.#activeConversationId && this.#activeConversationId !== id) {
|
|
1416
1339
|
return { messages: [], nextMessageCursor: page.nextCursor };
|
|
1417
1340
|
}
|
|
1418
1341
|
const merged = mergeWireMessagesById(this.messages, page.messages);
|
|
1419
|
-
this.messages = this
|
|
1420
|
-
merged.map((message) => this
|
|
1342
|
+
this.messages = this.#sortMessages(
|
|
1343
|
+
merged.map((message) => this.#ensureSequence(message))
|
|
1421
1344
|
);
|
|
1422
|
-
this
|
|
1345
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1423
1346
|
for (const message of page.messages) {
|
|
1424
|
-
this
|
|
1347
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1425
1348
|
}
|
|
1426
1349
|
this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
|
|
1427
1350
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1428
|
-
this
|
|
1351
|
+
this.#emitHistoryState();
|
|
1429
1352
|
return { messages: page.messages, nextMessageCursor: page.nextCursor };
|
|
1430
1353
|
}
|
|
1431
1354
|
|
|
@@ -1436,34 +1359,34 @@ export class AgentWidgetSession {
|
|
|
1436
1359
|
public async startNewConversation(opts?: {
|
|
1437
1360
|
scope?: HistoryScope;
|
|
1438
1361
|
}): Promise<void> {
|
|
1439
|
-
const provider = this
|
|
1440
|
-
const context = this
|
|
1362
|
+
const provider = this.#requireHistoryProvider();
|
|
1363
|
+
const context = this.#historyContext(opts?.scope);
|
|
1441
1364
|
await this.awaitHistorySettled();
|
|
1442
|
-
const epoch = ++this
|
|
1365
|
+
const epoch = ++this.#historyOpenEpoch;
|
|
1443
1366
|
const prepared = await provider.prepareStartNew({ context });
|
|
1444
|
-
if (epoch !== this
|
|
1367
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1445
1368
|
prepared.discard();
|
|
1446
1369
|
throw new SessionHistoryError("superseded", "A later action won");
|
|
1447
1370
|
}
|
|
1448
|
-
await this
|
|
1371
|
+
await this.#installFreshConversation(prepared);
|
|
1449
1372
|
}
|
|
1450
1373
|
|
|
1451
1374
|
/**
|
|
1452
1375
|
* Clear local + persisted conversation state, then commit the replacement.
|
|
1453
1376
|
* Welcome injection is deliberately allowed once the transcript is empty.
|
|
1454
1377
|
*/
|
|
1455
|
-
|
|
1378
|
+
async #installFreshConversation(
|
|
1456
1379
|
prepared: PreparedHistoryActivation
|
|
1457
1380
|
): Promise<void> {
|
|
1458
|
-
this
|
|
1459
|
-
await this
|
|
1460
|
-
this
|
|
1461
|
-
this
|
|
1462
|
-
this
|
|
1463
|
-
this
|
|
1464
|
-
this
|
|
1465
|
-
this
|
|
1466
|
-
this
|
|
1381
|
+
this.#discardConversationState();
|
|
1382
|
+
await this.#commitActivation(prepared);
|
|
1383
|
+
this.#activeConversationId = prepared.conversationId || null;
|
|
1384
|
+
this.#activeConversationRevision = prepared.conversationRevision || null;
|
|
1385
|
+
this.#historyNextMessageCursor = null;
|
|
1386
|
+
this.#historyRecovery = null;
|
|
1387
|
+
this.#historySendBlocked = false;
|
|
1388
|
+
this.#persistConversationMetadata();
|
|
1389
|
+
this.#emitHistoryState();
|
|
1467
1390
|
}
|
|
1468
1391
|
|
|
1469
1392
|
/**
|
|
@@ -1474,12 +1397,12 @@ export class AgentWidgetSession {
|
|
|
1474
1397
|
id: string,
|
|
1475
1398
|
opts?: { scope?: HistoryScope }
|
|
1476
1399
|
): Promise<void> {
|
|
1477
|
-
const provider = this
|
|
1478
|
-
const context = this
|
|
1400
|
+
const provider = this.#requireHistoryProvider();
|
|
1401
|
+
const context = this.#historyContext(opts?.scope);
|
|
1479
1402
|
await this.awaitHistorySettled();
|
|
1480
1403
|
await provider.delete(id, { context });
|
|
1481
|
-
if (id !== this
|
|
1482
|
-
await this
|
|
1404
|
+
if (id !== this.#activeConversationId) return;
|
|
1405
|
+
await this.#recoverFromDestroyedConversation(context);
|
|
1483
1406
|
}
|
|
1484
1407
|
|
|
1485
1408
|
/** Visitor-scoped rename/star. Requires a provider with the update capability. */
|
|
@@ -1488,35 +1411,35 @@ export class AgentWidgetSession {
|
|
|
1488
1411
|
patch: HistoryConversationPatch,
|
|
1489
1412
|
opts?: { scope?: HistoryScope }
|
|
1490
1413
|
): Promise<HistoryConversationSummary> {
|
|
1491
|
-
const provider = this
|
|
1414
|
+
const provider = this.#requireHistoryProvider();
|
|
1492
1415
|
if (!provider.update) {
|
|
1493
1416
|
throw new SessionHistoryError(
|
|
1494
1417
|
"history_unavailable",
|
|
1495
1418
|
"This history provider cannot update conversations"
|
|
1496
1419
|
);
|
|
1497
1420
|
}
|
|
1498
|
-
const context = this
|
|
1421
|
+
const context = this.#historyContext(opts?.scope);
|
|
1499
1422
|
await this.awaitHistorySettled();
|
|
1500
1423
|
return provider.update(id, patch, { context });
|
|
1501
1424
|
}
|
|
1502
1425
|
|
|
1503
1426
|
/** True when the provider supports visitor-scoped rename/star. */
|
|
1504
1427
|
public canUpdateConversations(): boolean {
|
|
1505
|
-
return typeof this
|
|
1428
|
+
return typeof this.#historyProvider?.update === "function";
|
|
1506
1429
|
}
|
|
1507
1430
|
|
|
1508
1431
|
public async clearConversationHistory(opts?: {
|
|
1509
1432
|
targetId?: string;
|
|
1510
1433
|
scope?: HistoryScope;
|
|
1511
1434
|
}): Promise<{ deleted: number }> {
|
|
1512
|
-
const provider = this
|
|
1513
|
-
const context = this
|
|
1435
|
+
const provider = this.#requireHistoryProvider();
|
|
1436
|
+
const context = this.#historyContext(opts?.scope);
|
|
1514
1437
|
await this.awaitHistorySettled();
|
|
1515
1438
|
const result = await provider.deleteAll({
|
|
1516
1439
|
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1517
1440
|
context,
|
|
1518
1441
|
});
|
|
1519
|
-
await this
|
|
1442
|
+
await this.#recoverFromDestroyedConversation(context);
|
|
1520
1443
|
return result;
|
|
1521
1444
|
}
|
|
1522
1445
|
|
|
@@ -1527,65 +1450,65 @@ export class AgentWidgetSession {
|
|
|
1527
1450
|
public async resetHistoryDevice(): Promise<{
|
|
1528
1451
|
remoteRevocationConfirmed: boolean;
|
|
1529
1452
|
}> {
|
|
1530
|
-
const provider = this
|
|
1453
|
+
const provider = this.#requireHistoryProvider();
|
|
1531
1454
|
if (!provider.resetDevice) {
|
|
1532
1455
|
throw new SessionHistoryError(
|
|
1533
1456
|
"history_unavailable",
|
|
1534
1457
|
"This history provider cannot reset the device"
|
|
1535
1458
|
);
|
|
1536
1459
|
}
|
|
1537
|
-
this
|
|
1538
|
-
this
|
|
1460
|
+
this.#historyOpenEpoch += 1;
|
|
1461
|
+
this.#projectionFinalizationPromise = null;
|
|
1539
1462
|
try {
|
|
1540
1463
|
return await provider.resetDevice();
|
|
1541
1464
|
} finally {
|
|
1542
|
-
this
|
|
1543
|
-
this
|
|
1544
|
-
this
|
|
1545
|
-
this
|
|
1546
|
-
this
|
|
1547
|
-
this
|
|
1548
|
-
this
|
|
1465
|
+
this.#discardConversationState();
|
|
1466
|
+
this.#activeConversationId = null;
|
|
1467
|
+
this.#activeConversationRevision = null;
|
|
1468
|
+
this.#historyNextMessageCursor = null;
|
|
1469
|
+
this.#historyRecovery = null;
|
|
1470
|
+
this.#historySendBlocked = false;
|
|
1471
|
+
this.#emitHistoryState();
|
|
1549
1472
|
}
|
|
1550
1473
|
}
|
|
1551
1474
|
|
|
1552
|
-
|
|
1475
|
+
async #recoverFromDestroyedConversation(
|
|
1553
1476
|
context: HistoryOperationContext
|
|
1554
1477
|
): Promise<void> {
|
|
1555
1478
|
// Supersede anything still in flight for the record being destroyed.
|
|
1556
|
-
this
|
|
1557
|
-
this
|
|
1558
|
-
this
|
|
1559
|
-
this
|
|
1560
|
-
this
|
|
1561
|
-
this
|
|
1562
|
-
this
|
|
1563
|
-
this
|
|
1564
|
-
this
|
|
1565
|
-
await this
|
|
1479
|
+
this.#historyOpenEpoch += 1;
|
|
1480
|
+
this.#projectionFinalizationPromise = null;
|
|
1481
|
+
this.#discardConversationState();
|
|
1482
|
+
this.#activeConversationId = null;
|
|
1483
|
+
this.#activeConversationRevision = null;
|
|
1484
|
+
this.#historyNextMessageCursor = null;
|
|
1485
|
+
this.#historySendBlocked = true;
|
|
1486
|
+
this.#historyRecovery = null;
|
|
1487
|
+
this.#emitHistoryState();
|
|
1488
|
+
await this.#prepareReplacementConversation(context);
|
|
1566
1489
|
}
|
|
1567
1490
|
|
|
1568
1491
|
/**
|
|
1569
1492
|
* Never reinstalls the deleted session. A failed preparation leaves an
|
|
1570
1493
|
* explicit recoverable state instead of the old transcript.
|
|
1571
1494
|
*/
|
|
1572
|
-
|
|
1495
|
+
async #prepareReplacementConversation(
|
|
1573
1496
|
context: HistoryOperationContext
|
|
1574
1497
|
): Promise<void> {
|
|
1575
|
-
const provider = this
|
|
1498
|
+
const provider = this.#historyProvider;
|
|
1576
1499
|
if (!provider) {
|
|
1577
|
-
this
|
|
1578
|
-
this
|
|
1500
|
+
this.#historySendBlocked = false;
|
|
1501
|
+
this.#emitHistoryState();
|
|
1579
1502
|
return;
|
|
1580
1503
|
}
|
|
1581
1504
|
try {
|
|
1582
1505
|
const prepared = await provider.prepareStartNew({ context });
|
|
1583
|
-
await this
|
|
1506
|
+
await this.#installFreshConversation(prepared);
|
|
1584
1507
|
} catch {
|
|
1585
|
-
this
|
|
1586
|
-
this
|
|
1587
|
-
this
|
|
1588
|
-
this
|
|
1508
|
+
this.#historySendBlocked = true;
|
|
1509
|
+
this.#historyRecovery = "new_conversation_required";
|
|
1510
|
+
this.#emitHistoryState();
|
|
1511
|
+
this.#notifyHistory(
|
|
1589
1512
|
"new_conversation_required",
|
|
1590
1513
|
"That conversation was deleted. Start a new conversation to continue."
|
|
1591
1514
|
);
|
|
@@ -1593,13 +1516,13 @@ export class AgentWidgetSession {
|
|
|
1593
1516
|
}
|
|
1594
1517
|
|
|
1595
1518
|
/** The next explicit action retries a failed replacement preparation. */
|
|
1596
|
-
|
|
1597
|
-
if (this
|
|
1598
|
-
const provider = this
|
|
1519
|
+
async #retryRequiredConversation(): Promise<void> {
|
|
1520
|
+
if (this.#historyRecovery !== "new_conversation_required") return;
|
|
1521
|
+
const provider = this.#historyProvider;
|
|
1599
1522
|
if (!provider) return;
|
|
1600
|
-
this
|
|
1601
|
-
await this
|
|
1602
|
-
if (this
|
|
1523
|
+
this.#historyRecovery = null;
|
|
1524
|
+
await this.#prepareReplacementConversation(this.#historyContext());
|
|
1525
|
+
if (this.#historyRecovery === "new_conversation_required") {
|
|
1603
1526
|
throw new SessionHistoryError(
|
|
1604
1527
|
"new_conversation_required",
|
|
1605
1528
|
"A new conversation is required before sending"
|
|
@@ -1612,7 +1535,7 @@ export class AgentWidgetSession {
|
|
|
1612
1535
|
* session binding (`bindActivatedSession`); the fallback covers a shell that
|
|
1613
1536
|
* has not wired that callback yet.
|
|
1614
1537
|
*/
|
|
1615
|
-
|
|
1538
|
+
async #commitActivation(
|
|
1616
1539
|
prepared: PreparedHistoryActivation
|
|
1617
1540
|
): Promise<void> {
|
|
1618
1541
|
await prepared.commit();
|
|
@@ -1631,61 +1554,62 @@ export class AgentWidgetSession {
|
|
|
1631
1554
|
this.clientSession = session;
|
|
1632
1555
|
this.config.setStoredSessionId?.(session.sessionId);
|
|
1633
1556
|
if (session.conversationId) {
|
|
1634
|
-
this
|
|
1635
|
-
this
|
|
1557
|
+
this.#activeConversationId = session.conversationId;
|
|
1558
|
+
this.#activeConversationRevision = session.conversationRevision ?? null;
|
|
1636
1559
|
this.config.setStoredConversationId?.(session.conversationId);
|
|
1637
1560
|
this.historyInternals.setStoredConversationRevision?.(
|
|
1638
1561
|
session.conversationRevision ?? null
|
|
1639
1562
|
);
|
|
1640
1563
|
}
|
|
1641
|
-
if (!this
|
|
1564
|
+
if (!this.#suppressWelcomeInjection) this.#injectWelcomeMessage(session);
|
|
1642
1565
|
}
|
|
1643
1566
|
|
|
1644
|
-
|
|
1645
|
-
if (this
|
|
1646
|
-
this.config.setStoredConversationId?.(this
|
|
1567
|
+
#persistConversationMetadata(): void {
|
|
1568
|
+
if (this.#activeConversationId) {
|
|
1569
|
+
this.config.setStoredConversationId?.(this.#activeConversationId);
|
|
1647
1570
|
} else {
|
|
1648
1571
|
this.config.clearStoredConversationId?.();
|
|
1649
1572
|
}
|
|
1650
1573
|
this.historyInternals.setStoredConversationRevision?.(
|
|
1651
|
-
this
|
|
1574
|
+
this.#activeConversationRevision
|
|
1652
1575
|
);
|
|
1653
1576
|
this.historyInternals.setStoredMessageCursor?.(
|
|
1654
|
-
this
|
|
1577
|
+
this.#historyNextMessageCursor
|
|
1655
1578
|
);
|
|
1656
1579
|
}
|
|
1657
1580
|
|
|
1658
1581
|
/** Conversation-scoped caches that must not leak across records. */
|
|
1659
|
-
|
|
1582
|
+
#resetConversationScopedState(): void {
|
|
1660
1583
|
this.historyInternals.setStoredResumableHandle?.(null);
|
|
1661
1584
|
this.agentExecution = null;
|
|
1662
|
-
this
|
|
1585
|
+
this.#clearArtifactState();
|
|
1663
1586
|
this.webMcpInflightKeys.clear();
|
|
1664
1587
|
this.webMcpResolvedKeys.clear();
|
|
1665
1588
|
this.client.resetClientToolsFingerprint();
|
|
1666
|
-
this
|
|
1667
|
-
this
|
|
1589
|
+
this.#acknowledgedProjections.clear();
|
|
1590
|
+
this.#setPendingProjections(null);
|
|
1668
1591
|
}
|
|
1669
1592
|
|
|
1670
1593
|
/**
|
|
1671
1594
|
* Irreversible local + persisted clear. Used by new-conversation, active
|
|
1672
1595
|
* deletion, 410 recovery, reset, and the continuity wipe.
|
|
1673
1596
|
*/
|
|
1674
|
-
|
|
1597
|
+
#discardConversationState(): void {
|
|
1675
1598
|
this.stopSpeaking();
|
|
1676
1599
|
this.abortController?.abort();
|
|
1677
1600
|
this.abortController = null;
|
|
1678
|
-
this
|
|
1679
|
-
this
|
|
1601
|
+
this.#teardownReconnect();
|
|
1602
|
+
this.#abortWebMcpResolves();
|
|
1603
|
+
this.#actionsEpoch++;
|
|
1680
1604
|
this.messages = [];
|
|
1681
|
-
this
|
|
1682
|
-
this
|
|
1605
|
+
this.#resetConversationScopedState();
|
|
1606
|
+
this.#olderPageRequests.clear();
|
|
1683
1607
|
this.config.clearStoredSessionId?.();
|
|
1684
1608
|
this.config.clearStoredConversationId?.();
|
|
1685
1609
|
this.historyInternals.setStoredConversationRevision?.(null);
|
|
1686
1610
|
this.historyInternals.setStoredMessageCursor?.(null);
|
|
1687
|
-
this
|
|
1688
|
-
this
|
|
1611
|
+
this.#setStreaming(false);
|
|
1612
|
+
this.#setStatus("idle");
|
|
1689
1613
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1690
1614
|
}
|
|
1691
1615
|
|
|
@@ -1694,27 +1618,27 @@ export class AgentWidgetSession {
|
|
|
1694
1618
|
* cross-tab convergence, or credential expiry selected a different record.
|
|
1695
1619
|
* Invalidate synchronously; dispatch stays blocked until cleanup completes.
|
|
1696
1620
|
*/
|
|
1697
|
-
|
|
1621
|
+
#handleHistoryContinuityChanged(info: {
|
|
1698
1622
|
previousConversationId: string | null;
|
|
1699
1623
|
conversationId: string;
|
|
1700
1624
|
}): void {
|
|
1701
|
-
this
|
|
1702
|
-
this
|
|
1703
|
-
this
|
|
1704
|
-
this
|
|
1705
|
-
this
|
|
1706
|
-
this
|
|
1707
|
-
this
|
|
1708
|
-
this
|
|
1709
|
-
this
|
|
1710
|
-
this
|
|
1625
|
+
this.#historyOpenEpoch += 1;
|
|
1626
|
+
this.#projectionFinalizationPromise = null;
|
|
1627
|
+
this.#historySendBlocked = true;
|
|
1628
|
+
this.#discardConversationState();
|
|
1629
|
+
this.#activeConversationId = info.conversationId;
|
|
1630
|
+
this.#activeConversationRevision = null;
|
|
1631
|
+
this.#historyNextMessageCursor = null;
|
|
1632
|
+
this.#historyRecovery = null;
|
|
1633
|
+
this.#emitHistoryState();
|
|
1634
|
+
this.#notifyHistory(
|
|
1711
1635
|
"history_continuity_reset",
|
|
1712
1636
|
"This browser was reconnected to a different conversation, so the previous messages were cleared."
|
|
1713
1637
|
);
|
|
1714
|
-
this
|
|
1715
|
-
this
|
|
1716
|
-
this
|
|
1717
|
-
this
|
|
1638
|
+
this.#historyGate = Promise.resolve().then(() => {
|
|
1639
|
+
this.#historySendBlocked = false;
|
|
1640
|
+
this.#historyGate = null;
|
|
1641
|
+
this.#emitHistoryState();
|
|
1718
1642
|
});
|
|
1719
1643
|
}
|
|
1720
1644
|
|
|
@@ -1723,15 +1647,15 @@ export class AgentWidgetSession {
|
|
|
1723
1647
|
* the store. Owned here so the transition sits beside the continuity wipe it
|
|
1724
1648
|
* reuses.
|
|
1725
1649
|
*/
|
|
1726
|
-
|
|
1650
|
+
#syncVisitorStoreSubscription(): void {
|
|
1727
1651
|
const store = this.historyInternals.visitorStore ?? null;
|
|
1728
|
-
if (store === this
|
|
1729
|
-
this
|
|
1730
|
-
this
|
|
1731
|
-
this
|
|
1652
|
+
if (store === this.#subscribedVisitorStore) return;
|
|
1653
|
+
this.#visitorStoreUnsubscribe?.();
|
|
1654
|
+
this.#visitorStoreUnsubscribe = null;
|
|
1655
|
+
this.#subscribedVisitorStore = store;
|
|
1732
1656
|
if (!store) return;
|
|
1733
|
-
this
|
|
1734
|
-
this
|
|
1657
|
+
this.#visitorStoreUnsubscribe = store.subscribe((change) =>
|
|
1658
|
+
this.#handleExternalCredentialChange(change)
|
|
1735
1659
|
);
|
|
1736
1660
|
}
|
|
1737
1661
|
|
|
@@ -1741,40 +1665,40 @@ export class AgentWidgetSession {
|
|
|
1741
1665
|
* A "local" change is this tab's own mint/write and owns its init path; a
|
|
1742
1666
|
* change out of an empty store is first-init convergence, not a break.
|
|
1743
1667
|
*/
|
|
1744
|
-
|
|
1668
|
+
#handleExternalCredentialChange(change: VisitorStoreChange): void {
|
|
1745
1669
|
if (change.source !== "external" || change.previousToken === null) return;
|
|
1746
1670
|
|
|
1747
1671
|
// Nothing may reuse the session minted under the revoked credential.
|
|
1748
1672
|
this.clientSession = null;
|
|
1749
1673
|
this.client.handleExternalCredentialChange();
|
|
1750
|
-
this
|
|
1751
|
-
this
|
|
1752
|
-
this
|
|
1753
|
-
this
|
|
1674
|
+
this.#historyOpenEpoch += 1;
|
|
1675
|
+
this.#projectionFinalizationPromise = null;
|
|
1676
|
+
this.#credentialReinitPending = true;
|
|
1677
|
+
this.#credentialReinitPromise = null;
|
|
1754
1678
|
|
|
1755
1679
|
const hadVisibleTranscript = this.messages.length > 0;
|
|
1756
1680
|
const hadConversationState =
|
|
1757
1681
|
hadVisibleTranscript ||
|
|
1758
|
-
this
|
|
1682
|
+
this.#activeConversationId !== null ||
|
|
1759
1683
|
(this.config.getStoredConversationId?.() ?? null) !== null;
|
|
1760
1684
|
if (hadConversationState) {
|
|
1761
|
-
this
|
|
1685
|
+
this.#discardConversationState();
|
|
1762
1686
|
} else {
|
|
1763
1687
|
// No conversation to wipe, but the revoked visitor's ids must not survive.
|
|
1764
1688
|
this.config.clearStoredSessionId?.();
|
|
1765
1689
|
this.config.clearStoredConversationId?.();
|
|
1766
|
-
this
|
|
1767
|
-
this
|
|
1690
|
+
this.#olderPageRequests.clear();
|
|
1691
|
+
this.#setPendingProjections(null);
|
|
1768
1692
|
}
|
|
1769
|
-
this
|
|
1770
|
-
this
|
|
1771
|
-
this
|
|
1772
|
-
this
|
|
1773
|
-
this
|
|
1693
|
+
this.#activeConversationId = null;
|
|
1694
|
+
this.#activeConversationRevision = null;
|
|
1695
|
+
this.#historyNextMessageCursor = null;
|
|
1696
|
+
this.#historyRecovery = null;
|
|
1697
|
+
this.#emitHistoryState();
|
|
1774
1698
|
// Only a visible wipe announces: a silent reconcile must not push the open
|
|
1775
1699
|
// history view into a refetch that would immediately re-mint a credential.
|
|
1776
1700
|
if (hadVisibleTranscript) {
|
|
1777
|
-
this
|
|
1701
|
+
this.#notifyHistory(
|
|
1778
1702
|
"history_continuity_reset",
|
|
1779
1703
|
"This browser's conversation history was reset in another tab, so the previous messages were cleared."
|
|
1780
1704
|
);
|
|
@@ -1786,26 +1710,26 @@ export class AgentWidgetSession {
|
|
|
1786
1710
|
* Deliberately lazy: re-initializing on the storage event itself would hand
|
|
1787
1711
|
* an idle tab a fresh credential the visitor just deleted elsewhere.
|
|
1788
1712
|
*/
|
|
1789
|
-
|
|
1790
|
-
if (!this
|
|
1791
|
-
if (!this
|
|
1792
|
-
this
|
|
1793
|
-
this
|
|
1713
|
+
#completeCredentialReinit(): Promise<void> {
|
|
1714
|
+
if (!this.#credentialReinitPending) return Promise.resolve();
|
|
1715
|
+
if (!this.#credentialReinitPromise) {
|
|
1716
|
+
this.#credentialReinitPromise = this.#runCredentialReinit().finally(() => {
|
|
1717
|
+
this.#credentialReinitPromise = null;
|
|
1794
1718
|
});
|
|
1795
1719
|
}
|
|
1796
|
-
return this
|
|
1720
|
+
return this.#credentialReinitPromise;
|
|
1797
1721
|
}
|
|
1798
1722
|
|
|
1799
|
-
|
|
1723
|
+
async #runCredentialReinit(): Promise<void> {
|
|
1800
1724
|
if (!this.isClientTokenMode()) {
|
|
1801
|
-
this
|
|
1725
|
+
this.#credentialReinitPending = false;
|
|
1802
1726
|
return;
|
|
1803
1727
|
}
|
|
1804
1728
|
try {
|
|
1805
1729
|
// Reads the store again: a clear mints fresh, a replacement adopts the
|
|
1806
1730
|
// sibling's token.
|
|
1807
1731
|
this.bindActivatedSession(await this.client.initSession());
|
|
1808
|
-
this
|
|
1732
|
+
this.#credentialReinitPending = false;
|
|
1809
1733
|
} catch {
|
|
1810
1734
|
// Still pending: the dispatch's own init reports the failure normally.
|
|
1811
1735
|
}
|
|
@@ -1820,53 +1744,53 @@ export class AgentWidgetSession {
|
|
|
1820
1744
|
public reconcileBootConversation(opts?: {
|
|
1821
1745
|
scope?: HistoryScope;
|
|
1822
1746
|
}): Promise<void> {
|
|
1823
|
-
const provider = this
|
|
1747
|
+
const provider = this.#historyProvider;
|
|
1824
1748
|
const conversationId =
|
|
1825
1749
|
this.clientSession?.conversationId ??
|
|
1826
1750
|
this.config.getStoredConversationId?.() ??
|
|
1827
|
-
this
|
|
1751
|
+
this.#activeConversationId;
|
|
1828
1752
|
if (!provider || !conversationId) return Promise.resolve();
|
|
1829
1753
|
|
|
1830
|
-
const context = this
|
|
1831
|
-
this
|
|
1832
|
-
this
|
|
1833
|
-
const run = this
|
|
1754
|
+
const context = this.#historyContext(opts?.scope);
|
|
1755
|
+
this.#historySendBlocked = true;
|
|
1756
|
+
this.#emitHistoryState();
|
|
1757
|
+
const run = this.#runBootReconciliation(
|
|
1834
1758
|
provider,
|
|
1835
1759
|
conversationId,
|
|
1836
1760
|
context
|
|
1837
1761
|
).finally(() => {
|
|
1838
|
-
this
|
|
1839
|
-
if (this
|
|
1840
|
-
this
|
|
1762
|
+
this.#historySendBlocked = this.#historyRecovery !== null;
|
|
1763
|
+
if (this.#historyGate === run) this.#historyGate = null;
|
|
1764
|
+
this.#emitHistoryState();
|
|
1841
1765
|
});
|
|
1842
|
-
this
|
|
1766
|
+
this.#historyGate = run;
|
|
1843
1767
|
return run;
|
|
1844
1768
|
}
|
|
1845
1769
|
|
|
1846
|
-
|
|
1770
|
+
async #runBootReconciliation(
|
|
1847
1771
|
provider: HistoryProvider,
|
|
1848
1772
|
conversationId: string,
|
|
1849
1773
|
context: HistoryOperationContext
|
|
1850
1774
|
): Promise<void> {
|
|
1851
|
-
this
|
|
1775
|
+
this.#activeConversationId = conversationId;
|
|
1852
1776
|
const initRevision =
|
|
1853
1777
|
this.clientSession?.conversationRevision ??
|
|
1854
|
-
this
|
|
1778
|
+
this.#activeConversationRevision ??
|
|
1855
1779
|
null;
|
|
1856
|
-
const persistedRevision = this
|
|
1857
|
-
? this
|
|
1780
|
+
const persistedRevision = this.#bootRevisionCaptured
|
|
1781
|
+
? this.#bootConversationRevision
|
|
1858
1782
|
: (this.historyInternals.getStoredConversationRevision?.() ?? null);
|
|
1859
|
-
this
|
|
1860
|
-
this
|
|
1783
|
+
this.#bootRevisionCaptured = false;
|
|
1784
|
+
this.#historyNextMessageCursor =
|
|
1861
1785
|
this.historyInternals.getStoredMessageCursor?.() ??
|
|
1862
|
-
this
|
|
1786
|
+
this.#historyNextMessageCursor;
|
|
1863
1787
|
|
|
1864
1788
|
// Any locally finalized projection still owed to the server goes first, so
|
|
1865
1789
|
// display reconciliation cannot complete against a stale server record.
|
|
1866
1790
|
await this.replayPendingProjections();
|
|
1867
1791
|
|
|
1868
1792
|
if (initRevision && persistedRevision && initRevision === persistedRevision) {
|
|
1869
|
-
this
|
|
1793
|
+
this.#activeConversationRevision = initRevision;
|
|
1870
1794
|
return;
|
|
1871
1795
|
}
|
|
1872
1796
|
|
|
@@ -1878,9 +1802,9 @@ export class AgentWidgetSession {
|
|
|
1878
1802
|
let reconciled: AgentWidgetMessage[];
|
|
1879
1803
|
if (!overlaps) {
|
|
1880
1804
|
// No shared id: the local transcript is not this record's newest window.
|
|
1881
|
-
reconciled = page.messages.map((message) => this
|
|
1805
|
+
reconciled = page.messages.map((message) => this.#ensureSequence(message));
|
|
1882
1806
|
} else {
|
|
1883
|
-
const durableId = this
|
|
1807
|
+
const durableId = this.#resumable ? this.#activeAssistantMessageId : null;
|
|
1884
1808
|
const survivors = this.messages.filter((message) => {
|
|
1885
1809
|
if (serverIds.has(message.id)) return true;
|
|
1886
1810
|
if (message.id === durableId) return true;
|
|
@@ -1888,53 +1812,53 @@ export class AgentWidgetSession {
|
|
|
1888
1812
|
return pageOldest !== undefined && message.createdAt < pageOldest;
|
|
1889
1813
|
});
|
|
1890
1814
|
reconciled = mergeWireMessagesById(survivors, page.messages).map(
|
|
1891
|
-
(message) => this
|
|
1815
|
+
(message) => this.#ensureSequence(message)
|
|
1892
1816
|
);
|
|
1893
1817
|
}
|
|
1894
1818
|
|
|
1895
|
-
this
|
|
1896
|
-
this
|
|
1819
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1820
|
+
this.#activeConversationRevision =
|
|
1897
1821
|
page.conversationRevision || initRevision || null;
|
|
1898
|
-
this
|
|
1822
|
+
this.#acknowledgedProjections.clear();
|
|
1899
1823
|
for (const message of page.messages) {
|
|
1900
|
-
this
|
|
1824
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1901
1825
|
}
|
|
1902
1826
|
// Metadata first, transcript last: one persist covers both.
|
|
1903
|
-
this
|
|
1904
|
-
this.messages = this
|
|
1827
|
+
this.#persistConversationMetadata();
|
|
1828
|
+
this.messages = this.#sortMessages(reconciled);
|
|
1905
1829
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1906
1830
|
}
|
|
1907
1831
|
|
|
1908
1832
|
// ── Display-projection finalization ────────────────────────────
|
|
1909
1833
|
|
|
1910
1834
|
/** Runtype transport op: active session + browser scope, never a proof. */
|
|
1911
|
-
|
|
1835
|
+
#projectionFinalizationEnabled(): boolean {
|
|
1912
1836
|
return (
|
|
1913
1837
|
this.config.features?.history?.enabled === true &&
|
|
1914
1838
|
this.isClientTokenMode()
|
|
1915
1839
|
);
|
|
1916
1840
|
}
|
|
1917
1841
|
|
|
1918
|
-
|
|
1919
|
-
this
|
|
1842
|
+
#setPendingProjections(pending: PendingDisplayProjections | null): void {
|
|
1843
|
+
this.#pendingProjections = pending;
|
|
1920
1844
|
// Memory-only when the shell supplies no persistence seam.
|
|
1921
1845
|
this.historyInternals.setPendingDisplayProjections?.(pending);
|
|
1922
1846
|
}
|
|
1923
1847
|
|
|
1924
|
-
|
|
1848
|
+
#readPendingProjections(): PendingDisplayProjections | null {
|
|
1925
1849
|
return (
|
|
1926
1850
|
this.historyInternals.getPendingDisplayProjections?.() ??
|
|
1927
|
-
this
|
|
1851
|
+
this.#pendingProjections
|
|
1928
1852
|
);
|
|
1929
1853
|
}
|
|
1930
1854
|
|
|
1931
1855
|
/** Record what the dispatch payload carried as each message's projection. */
|
|
1932
|
-
|
|
1933
|
-
if (!this
|
|
1856
|
+
#recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
|
|
1857
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1934
1858
|
for (const message of messages) {
|
|
1935
1859
|
const projection = divergentDisplayProjection(message);
|
|
1936
1860
|
if (projection !== undefined) {
|
|
1937
|
-
this
|
|
1861
|
+
this.#acknowledgedProjections.set(message.id, projection);
|
|
1938
1862
|
}
|
|
1939
1863
|
}
|
|
1940
1864
|
}
|
|
@@ -1943,57 +1867,57 @@ export class AgentWidgetSession {
|
|
|
1943
1867
|
* After a terminal assistant message, finalize any browser-derived projection
|
|
1944
1868
|
* the server cannot have. Painting is never delayed by this.
|
|
1945
1869
|
*/
|
|
1946
|
-
|
|
1947
|
-
if (!this
|
|
1948
|
-
const conversationId = this
|
|
1870
|
+
#scheduleProjectionFinalization(): void {
|
|
1871
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1872
|
+
const conversationId = this.#activeConversationId;
|
|
1949
1873
|
if (!conversationId) return;
|
|
1950
1874
|
const batch: HistoryDisplayProjection[] = [];
|
|
1951
1875
|
for (const message of this.messages) {
|
|
1952
1876
|
if (message.role !== "assistant" || message.streaming) continue;
|
|
1953
1877
|
const projection = divergentDisplayProjection(message);
|
|
1954
1878
|
if (projection === undefined) continue;
|
|
1955
|
-
if (this
|
|
1879
|
+
if (this.#acknowledgedProjections.get(message.id) === projection) continue;
|
|
1956
1880
|
batch.push({ id: message.id, displayContent: projection });
|
|
1957
1881
|
}
|
|
1958
1882
|
if (batch.length === 0) return;
|
|
1959
|
-
this
|
|
1883
|
+
this.#setPendingProjections({
|
|
1960
1884
|
conversationId,
|
|
1961
1885
|
messageIds: batch.map((item) => item.id),
|
|
1962
1886
|
});
|
|
1963
|
-
this
|
|
1887
|
+
this.#enqueueProjectionFinalization(conversationId, batch);
|
|
1964
1888
|
}
|
|
1965
1889
|
|
|
1966
1890
|
/** Serialized: operations await this chain instead of racing it. */
|
|
1967
|
-
|
|
1891
|
+
#enqueueProjectionFinalization(
|
|
1968
1892
|
conversationId: string,
|
|
1969
1893
|
batch: HistoryDisplayProjection[]
|
|
1970
1894
|
): void {
|
|
1971
|
-
const previous = this
|
|
1895
|
+
const previous = this.#projectionFinalizationPromise ?? Promise.resolve();
|
|
1972
1896
|
const chain = previous
|
|
1973
1897
|
.catch(() => {})
|
|
1974
|
-
.then(() => this
|
|
1898
|
+
.then(() => this.#finalizeProjections(conversationId, batch, false))
|
|
1975
1899
|
.finally(() => {
|
|
1976
|
-
if (this
|
|
1977
|
-
this
|
|
1900
|
+
if (this.#projectionFinalizationPromise === chain) {
|
|
1901
|
+
this.#projectionFinalizationPromise = null;
|
|
1978
1902
|
}
|
|
1979
1903
|
});
|
|
1980
|
-
this
|
|
1904
|
+
this.#projectionFinalizationPromise = chain;
|
|
1981
1905
|
}
|
|
1982
1906
|
|
|
1983
|
-
|
|
1907
|
+
async #finalizeProjections(
|
|
1984
1908
|
conversationId: string,
|
|
1985
1909
|
batch: HistoryDisplayProjection[],
|
|
1986
1910
|
retried: boolean
|
|
1987
1911
|
): Promise<void> {
|
|
1988
1912
|
// A switch/reset/delete already invalidated this marker.
|
|
1989
|
-
if (this
|
|
1913
|
+
if (this.#activeConversationId !== conversationId) return;
|
|
1990
1914
|
// Two terminal scans can enqueue the same batch before the first PATCH
|
|
1991
1915
|
// acknowledges; the chain serializes them, so re-filter at run time.
|
|
1992
1916
|
const unacknowledged = batch.filter(
|
|
1993
|
-
(item) => this
|
|
1917
|
+
(item) => this.#acknowledgedProjections.get(item.id) !== item.displayContent
|
|
1994
1918
|
);
|
|
1995
1919
|
if (unacknowledged.length === 0) {
|
|
1996
|
-
this
|
|
1920
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
1997
1921
|
return;
|
|
1998
1922
|
}
|
|
1999
1923
|
try {
|
|
@@ -2002,15 +1926,15 @@ export class AgentWidgetSession {
|
|
|
2002
1926
|
unacknowledged
|
|
2003
1927
|
);
|
|
2004
1928
|
for (const item of unacknowledged) {
|
|
2005
|
-
this
|
|
1929
|
+
this.#acknowledgedProjections.set(item.id, item.displayContent);
|
|
2006
1930
|
}
|
|
2007
|
-
if (this
|
|
2008
|
-
this
|
|
1931
|
+
if (this.#activeConversationId === conversationId && conversationRevision) {
|
|
1932
|
+
this.#activeConversationRevision = conversationRevision;
|
|
2009
1933
|
}
|
|
2010
|
-
this
|
|
1934
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
2011
1935
|
} catch (error) {
|
|
2012
|
-
if (!retried && this
|
|
2013
|
-
await this
|
|
1936
|
+
if (!retried && this.#isTransientProjectionError(error)) {
|
|
1937
|
+
await this.#finalizeProjections(conversationId, unacknowledged, true);
|
|
2014
1938
|
return;
|
|
2015
1939
|
}
|
|
2016
1940
|
if (
|
|
@@ -2018,11 +1942,11 @@ export class AgentWidgetSession {
|
|
|
2018
1942
|
(error.code === "not_found" || error.code === "conversation_deleted")
|
|
2019
1943
|
) {
|
|
2020
1944
|
// The record is gone: drop the projection instead of retrying forever.
|
|
2021
|
-
this
|
|
1945
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
2022
1946
|
return;
|
|
2023
1947
|
}
|
|
2024
1948
|
// Marker retained for the next boot/dispatch; the turn itself succeeded.
|
|
2025
|
-
this
|
|
1949
|
+
this.#notifyHistory(
|
|
2026
1950
|
"projection_finalization_failed",
|
|
2027
1951
|
"Some message formatting could not be saved for history; it will be retried."
|
|
2028
1952
|
);
|
|
@@ -2030,20 +1954,20 @@ export class AgentWidgetSession {
|
|
|
2030
1954
|
}
|
|
2031
1955
|
|
|
2032
1956
|
/** Transport-shaped failures only; a contract failure is not retried. */
|
|
2033
|
-
|
|
1957
|
+
#isTransientProjectionError(error: unknown): boolean {
|
|
2034
1958
|
if (error instanceof HistoryClientError) return error.code === "request_failed";
|
|
2035
1959
|
return error instanceof Error;
|
|
2036
1960
|
}
|
|
2037
1961
|
|
|
2038
|
-
|
|
1962
|
+
#clearFinalizedMarker(
|
|
2039
1963
|
conversationId: string,
|
|
2040
1964
|
batch: HistoryDisplayProjection[]
|
|
2041
1965
|
): void {
|
|
2042
|
-
const pending = this
|
|
1966
|
+
const pending = this.#readPendingProjections();
|
|
2043
1967
|
if (!pending || pending.conversationId !== conversationId) return;
|
|
2044
1968
|
const done = new Set(batch.map((item) => item.id));
|
|
2045
1969
|
const remaining = pending.messageIds.filter((id) => !done.has(id));
|
|
2046
|
-
this
|
|
1970
|
+
this.#setPendingProjections(
|
|
2047
1971
|
remaining.length ? { conversationId, messageIds: remaining } : null
|
|
2048
1972
|
);
|
|
2049
1973
|
}
|
|
@@ -2053,15 +1977,15 @@ export class AgentWidgetSession {
|
|
|
2053
1977
|
* message content, then finalize before display reconciliation completes.
|
|
2054
1978
|
*/
|
|
2055
1979
|
public async replayPendingProjections(): Promise<void> {
|
|
2056
|
-
if (!this
|
|
2057
|
-
const pending = this
|
|
1980
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1981
|
+
const pending = this.#readPendingProjections();
|
|
2058
1982
|
if (!pending) return;
|
|
2059
1983
|
if (
|
|
2060
|
-
this
|
|
2061
|
-
pending.conversationId !== this
|
|
1984
|
+
this.#activeConversationId &&
|
|
1985
|
+
pending.conversationId !== this.#activeConversationId
|
|
2062
1986
|
) {
|
|
2063
1987
|
// Stale marker from another record: never replay it into this one.
|
|
2064
|
-
this
|
|
1988
|
+
this.#setPendingProjections(null);
|
|
2065
1989
|
return;
|
|
2066
1990
|
}
|
|
2067
1991
|
const batch: HistoryDisplayProjection[] = [];
|
|
@@ -2071,33 +1995,33 @@ export class AgentWidgetSession {
|
|
|
2071
1995
|
batch.push({ id, displayContent: message.content });
|
|
2072
1996
|
}
|
|
2073
1997
|
if (batch.length === 0) {
|
|
2074
|
-
this
|
|
1998
|
+
this.#setPendingProjections(null);
|
|
2075
1999
|
return;
|
|
2076
2000
|
}
|
|
2077
|
-
this
|
|
2078
|
-
await this
|
|
2001
|
+
this.#enqueueProjectionFinalization(pending.conversationId, batch);
|
|
2002
|
+
await this.#projectionFinalizationPromise?.catch(() => {});
|
|
2079
2003
|
}
|
|
2080
2004
|
|
|
2081
2005
|
/**
|
|
2082
2006
|
* A local transcript mutation whose response carries no new revision: clear
|
|
2083
2007
|
* the persisted one rather than guessing, so the next boot refreshes safely.
|
|
2084
2008
|
*/
|
|
2085
|
-
|
|
2086
|
-
if (!this
|
|
2087
|
-
this
|
|
2009
|
+
#invalidateConversationRevision(): void {
|
|
2010
|
+
if (!this.#activeConversationId) return;
|
|
2011
|
+
this.#activeConversationRevision = null;
|
|
2088
2012
|
this.historyInternals.setStoredConversationRevision?.(null);
|
|
2089
2013
|
}
|
|
2090
2014
|
|
|
2091
2015
|
/** Blocks a send until the owning history transition finishes. */
|
|
2092
|
-
|
|
2016
|
+
async #awaitHistorySendable(): Promise<void> {
|
|
2093
2017
|
await this.awaitHistorySettled();
|
|
2094
|
-
await this
|
|
2095
|
-
if (this
|
|
2096
|
-
await this
|
|
2018
|
+
await this.#completeCredentialReinit();
|
|
2019
|
+
if (this.#historyRecovery === "new_conversation_required") {
|
|
2020
|
+
await this.#retryRequiredConversation();
|
|
2097
2021
|
}
|
|
2098
2022
|
}
|
|
2099
2023
|
|
|
2100
|
-
|
|
2024
|
+
static #isConversationDeleted(error: unknown): boolean {
|
|
2101
2025
|
return (
|
|
2102
2026
|
error instanceof HistoryClientError &&
|
|
2103
2027
|
error.code === "conversation_deleted"
|
|
@@ -2108,14 +2032,14 @@ export class AgentWidgetSession {
|
|
|
2108
2032
|
* Safe 410 recovery (D5). The old payload would recreate the transcript that
|
|
2109
2033
|
* was just deleted, so the retry carries ONLY the just-submitted user turn.
|
|
2110
2034
|
*/
|
|
2111
|
-
|
|
2035
|
+
async #dispatchWithDeletedRecovery(
|
|
2112
2036
|
snapshot: AgentWidgetMessage[],
|
|
2113
2037
|
controller: AbortController,
|
|
2114
2038
|
assistantMessageId: string,
|
|
2115
2039
|
userMessageId: string,
|
|
2116
2040
|
turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean }
|
|
2117
2041
|
): Promise<void> {
|
|
2118
|
-
this
|
|
2042
|
+
this.#recordDispatchedProjections(snapshot);
|
|
2119
2043
|
try {
|
|
2120
2044
|
await this.client.dispatch(
|
|
2121
2045
|
{
|
|
@@ -2129,12 +2053,12 @@ export class AgentWidgetSession {
|
|
|
2129
2053
|
);
|
|
2130
2054
|
return;
|
|
2131
2055
|
} catch (error) {
|
|
2132
|
-
if (!AgentWidgetSession
|
|
2133
|
-
const replacement = await this
|
|
2056
|
+
if (!AgentWidgetSession.#isConversationDeleted(error)) throw error;
|
|
2057
|
+
const replacement = await this.#recoverFromDeletedConversation(userMessageId);
|
|
2134
2058
|
// No recoverable turn (or no fresh record): surface the original 410.
|
|
2135
2059
|
if (!replacement) throw error;
|
|
2136
2060
|
// A second 410 propagates: exactly one recovery attempt per turn.
|
|
2137
|
-
this
|
|
2061
|
+
this.#recordDispatchedProjections([replacement]);
|
|
2138
2062
|
await this.client.dispatch(
|
|
2139
2063
|
{
|
|
2140
2064
|
messages: [replacement],
|
|
@@ -2151,7 +2075,7 @@ export class AgentWidgetSession {
|
|
|
2151
2075
|
* Capture only the newly submitted user message, destroy the deleted record's
|
|
2152
2076
|
* local state, and initialize a fresh owned conversation to retry into.
|
|
2153
2077
|
*/
|
|
2154
|
-
|
|
2078
|
+
async #recoverFromDeletedConversation(
|
|
2155
2079
|
userMessageId: string
|
|
2156
2080
|
): Promise<AgentWidgetMessage | null> {
|
|
2157
2081
|
const submitted = this.messages.find(
|
|
@@ -2160,44 +2084,44 @@ export class AgentWidgetSession {
|
|
|
2160
2084
|
if (!submitted) return null;
|
|
2161
2085
|
const captured: AgentWidgetMessage = { ...submitted };
|
|
2162
2086
|
|
|
2163
|
-
this
|
|
2164
|
-
this
|
|
2165
|
-
this
|
|
2166
|
-
this
|
|
2167
|
-
this
|
|
2168
|
-
this
|
|
2087
|
+
this.#historyOpenEpoch += 1;
|
|
2088
|
+
this.#projectionFinalizationPromise = null;
|
|
2089
|
+
this.#discardConversationState();
|
|
2090
|
+
this.#activeConversationId = null;
|
|
2091
|
+
this.#activeConversationRevision = null;
|
|
2092
|
+
this.#historyNextMessageCursor = null;
|
|
2169
2093
|
|
|
2170
2094
|
// The recovered turn is the whole transcript: no welcome bubble in front.
|
|
2171
|
-
this
|
|
2095
|
+
this.#suppressWelcomeInjection = true;
|
|
2172
2096
|
try {
|
|
2173
|
-
const provider = this
|
|
2097
|
+
const provider = this.#historyProvider;
|
|
2174
2098
|
if (provider) {
|
|
2175
2099
|
const prepared = await provider.prepareStartNew({
|
|
2176
|
-
context: this
|
|
2100
|
+
context: this.#historyContext(),
|
|
2177
2101
|
});
|
|
2178
|
-
await this
|
|
2102
|
+
await this.#installFreshConversation(prepared);
|
|
2179
2103
|
} else {
|
|
2180
2104
|
this.client.clearClientSession();
|
|
2181
2105
|
const session = await this.client.initSession();
|
|
2182
2106
|
this.bindActivatedSession(session);
|
|
2183
2107
|
}
|
|
2184
2108
|
} catch {
|
|
2185
|
-
this
|
|
2186
|
-
this
|
|
2187
|
-
this
|
|
2188
|
-
this
|
|
2109
|
+
this.#historySendBlocked = true;
|
|
2110
|
+
this.#historyRecovery = "new_conversation_required";
|
|
2111
|
+
this.#emitHistoryState();
|
|
2112
|
+
this.#notifyHistory(
|
|
2189
2113
|
"new_conversation_required",
|
|
2190
2114
|
"That conversation was deleted. Start a new conversation to continue."
|
|
2191
2115
|
);
|
|
2192
2116
|
return null;
|
|
2193
2117
|
} finally {
|
|
2194
|
-
this
|
|
2118
|
+
this.#suppressWelcomeInjection = false;
|
|
2195
2119
|
}
|
|
2196
2120
|
|
|
2197
2121
|
// Restore only the user's own turn: it is the entire new transcript.
|
|
2198
|
-
this
|
|
2199
|
-
this
|
|
2200
|
-
this
|
|
2122
|
+
this.#appendMessage(captured);
|
|
2123
|
+
this.#setStreaming(true);
|
|
2124
|
+
this.#notifyHistory(
|
|
2201
2125
|
"conversation_deleted_recovered",
|
|
2202
2126
|
"That conversation was deleted. Started a new conversation."
|
|
2203
2127
|
);
|
|
@@ -2229,7 +2153,7 @@ export class AgentWidgetSession {
|
|
|
2229
2153
|
// turn that's restyling the widget and strand the paused execution.
|
|
2230
2154
|
if (!replaceClient) {
|
|
2231
2155
|
this.client.updateConfig(merged);
|
|
2232
|
-
if (artifactDisplayChanged) this
|
|
2156
|
+
if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
|
|
2233
2157
|
return;
|
|
2234
2158
|
}
|
|
2235
2159
|
|
|
@@ -2240,13 +2164,13 @@ export class AgentWidgetSession {
|
|
|
2240
2164
|
// fresh client: in client-token mode that would POST /resume without a
|
|
2241
2165
|
// valid sessionId and strand the paused turn. Mirrors clearMessages' WebMCP
|
|
2242
2166
|
// reset; the client swap already abandons any in-flight stream regardless.
|
|
2243
|
-
this
|
|
2167
|
+
this.#abortWebMcpResolves();
|
|
2244
2168
|
this.webMcpInflightKeys.clear();
|
|
2245
2169
|
this.webMcpResolvedKeys.clear();
|
|
2246
2170
|
const prevSSECallback = this.client.getSSEEventCallback();
|
|
2247
2171
|
this.client = new AgentWidgetClient(this.config, this.historyInternals);
|
|
2248
|
-
if (artifactDisplayChanged) this
|
|
2249
|
-
this
|
|
2172
|
+
if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
|
|
2173
|
+
this.#wireDefaultWebMcpConfirm();
|
|
2250
2174
|
if (prevSSECallback) {
|
|
2251
2175
|
this.client.setSSEEventCallback(prevSSECallback);
|
|
2252
2176
|
}
|
|
@@ -2307,6 +2231,7 @@ export class AgentWidgetSession {
|
|
|
2307
2231
|
sequence,
|
|
2308
2232
|
streaming = false,
|
|
2309
2233
|
voiceProcessing,
|
|
2234
|
+
voiceCaption,
|
|
2310
2235
|
rawContent
|
|
2311
2236
|
} = options;
|
|
2312
2237
|
|
|
@@ -2324,12 +2249,13 @@ export class AgentWidgetSession {
|
|
|
2324
2249
|
role,
|
|
2325
2250
|
content,
|
|
2326
2251
|
createdAt: createdAt ?? new Date().toISOString(),
|
|
2327
|
-
sequence: sequence ?? this
|
|
2252
|
+
sequence: sequence ?? this.#nextSequence(),
|
|
2328
2253
|
streaming,
|
|
2329
2254
|
// Only include optional fields if provided
|
|
2330
2255
|
...(llmContent !== undefined && { llmContent }),
|
|
2331
2256
|
...(contentParts !== undefined && { contentParts }),
|
|
2332
2257
|
...(voiceProcessing !== undefined && { voiceProcessing }),
|
|
2258
|
+
...(voiceCaption && { voiceCaption }),
|
|
2333
2259
|
...(rawContent !== undefined && { rawContent })
|
|
2334
2260
|
};
|
|
2335
2261
|
|
|
@@ -2427,7 +2353,7 @@ export class AgentWidgetSession {
|
|
|
2427
2353
|
role,
|
|
2428
2354
|
content,
|
|
2429
2355
|
createdAt: createdAt ?? new Date().toISOString(),
|
|
2430
|
-
sequence: sequence ?? this
|
|
2356
|
+
sequence: sequence ?? this.#nextSequence(),
|
|
2431
2357
|
streaming,
|
|
2432
2358
|
...(llmContent !== undefined && { llmContent }),
|
|
2433
2359
|
...(contentParts !== undefined && { contentParts }),
|
|
@@ -2439,7 +2365,7 @@ export class AgentWidgetSession {
|
|
|
2439
2365
|
}
|
|
2440
2366
|
|
|
2441
2367
|
// Add all messages, sort once, notify once
|
|
2442
|
-
this.messages = this
|
|
2368
|
+
this.messages = this.#sortMessages([...this.messages, ...results]);
|
|
2443
2369
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
2444
2370
|
|
|
2445
2371
|
return results;
|
|
@@ -2500,7 +2426,7 @@ export class AgentWidgetSession {
|
|
|
2500
2426
|
* client to namespace into the request `context`. Failures were already
|
|
2501
2427
|
* dropped by `finalize()`.
|
|
2502
2428
|
*/
|
|
2503
|
-
|
|
2429
|
+
async #applyMentionBundle(
|
|
2504
2430
|
userMessage: AgentWidgetMessage,
|
|
2505
2431
|
typedText: string,
|
|
2506
2432
|
finalize: () => Promise<MentionSubmitBundle>
|
|
@@ -2598,6 +2524,13 @@ export class AgentWidgetSession {
|
|
|
2598
2524
|
* (`composer.streamingSubmitBehavior: "interrupt"`).
|
|
2599
2525
|
*/
|
|
2600
2526
|
interrupt?: boolean;
|
|
2527
|
+
/**
|
|
2528
|
+
* Internal (voice client delegation): this is the delegated voice turn.
|
|
2529
|
+
* `userMessageId` names an existing transcript bubble to submit as the
|
|
2530
|
+
* turn's user message instead of appending a new one, so the spoken
|
|
2531
|
+
* request appears (and is sent) exactly once.
|
|
2532
|
+
*/
|
|
2533
|
+
voiceTurn?: { userMessageId?: string };
|
|
2601
2534
|
}
|
|
2602
2535
|
) {
|
|
2603
2536
|
const input = rawInput.trim();
|
|
@@ -2617,16 +2550,23 @@ export class AgentWidgetSession {
|
|
|
2617
2550
|
)
|
|
2618
2551
|
return;
|
|
2619
2552
|
|
|
2553
|
+
// Any other send replaces a delegated voice turn still in flight: that
|
|
2554
|
+
// turn fails, and this one's reply is neither its answer nor voice-spoken.
|
|
2555
|
+
if (this.#voiceDelegation && !options?.voiceTurn) {
|
|
2556
|
+
this.#voiceDelegation.failed = true;
|
|
2557
|
+
this.#voiceDelegation = null;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2620
2560
|
// History transitions (continuity wipe, boot reconciliation, projection
|
|
2621
2561
|
// finalization, replacement init, external credential change) own the
|
|
2622
2562
|
// record this turn would land in.
|
|
2623
2563
|
if (
|
|
2624
|
-
this
|
|
2625
|
-
this
|
|
2626
|
-
this
|
|
2627
|
-
this
|
|
2564
|
+
this.#historyGate ||
|
|
2565
|
+
this.#projectionFinalizationPromise ||
|
|
2566
|
+
this.#historySendBlocked ||
|
|
2567
|
+
this.#credentialReinitPending
|
|
2628
2568
|
) {
|
|
2629
|
-
await this
|
|
2569
|
+
await this.#awaitHistorySendable();
|
|
2630
2570
|
}
|
|
2631
2571
|
|
|
2632
2572
|
this.stopSpeaking();
|
|
@@ -2635,18 +2575,23 @@ export class AgentWidgetSession {
|
|
|
2635
2575
|
// turn. Tear them down here (they own controllers separate from the shared
|
|
2636
2576
|
// one) so a lingering resolve can't race the new dispatch or post a stale
|
|
2637
2577
|
// /resume against a superseded execution.
|
|
2638
|
-
this
|
|
2578
|
+
this.#abortWebMcpResolves();
|
|
2579
|
+
this.#actionsEpoch++;
|
|
2639
2580
|
// A new turn also supersedes any pending durable reconnect from the prior
|
|
2640
2581
|
// turn (cancels backoff/listeners, clears the old resume handle).
|
|
2641
|
-
this
|
|
2582
|
+
this.#teardownReconnect();
|
|
2642
2583
|
|
|
2584
|
+
const voiceBubbleId = options?.voiceTurn?.userMessageId;
|
|
2585
|
+
const voiceBubble = voiceBubbleId
|
|
2586
|
+
? this.messages.find((m) => m.id === voiceBubbleId && m.role === "user")
|
|
2587
|
+
: undefined;
|
|
2643
2588
|
// Generate IDs for both user message and expected assistant response
|
|
2644
|
-
const userMessageId = generateUserMessageId();
|
|
2589
|
+
const userMessageId = voiceBubble?.id ?? generateUserMessageId();
|
|
2645
2590
|
const assistantMessageId = generateAssistantMessageId();
|
|
2646
2591
|
// The active assistant bubble for a durable reconnect is captured from the
|
|
2647
2592
|
// real streamed message events (see handleEvent), not pre-assigned here:
|
|
2648
2593
|
// the proxy path auto-generates a different id than `assistantMessageId`.
|
|
2649
|
-
this
|
|
2594
|
+
this.#activeAssistantMessageId = null;
|
|
2650
2595
|
|
|
2651
2596
|
// Fallback display text ONLY when the sole content is image attachments.
|
|
2652
2597
|
// A mention/command-only submit (empty text + a chip) must NOT read as
|
|
@@ -2661,7 +2606,7 @@ export class AgentWidgetSession {
|
|
|
2661
2606
|
role: "user",
|
|
2662
2607
|
content: input || imageOnlyFallback, // Display text (fallback if only images)
|
|
2663
2608
|
createdAt: new Date().toISOString(),
|
|
2664
|
-
sequence: this
|
|
2609
|
+
sequence: this.#nextSequence(),
|
|
2665
2610
|
viaVoice: options?.viaVoice || false,
|
|
2666
2611
|
// Include contentParts if provided (for multi-modal messages)
|
|
2667
2612
|
...(options?.contentParts && options.contentParts.length > 0 && {
|
|
@@ -2681,8 +2626,17 @@ export class AgentWidgetSession {
|
|
|
2681
2626
|
...(options?.replayFields ?? {})
|
|
2682
2627
|
};
|
|
2683
2628
|
|
|
2684
|
-
|
|
2685
|
-
|
|
2629
|
+
if (voiceBubble) {
|
|
2630
|
+
// The transcript bubble is the user message: the model gets the request
|
|
2631
|
+
// text the voice model delegated, the bubble keeps what was heard.
|
|
2632
|
+
voiceBubble.viaVoice = true;
|
|
2633
|
+
delete voiceBubble.voiceCaption;
|
|
2634
|
+
if (voiceBubble.content.trim() !== input) voiceBubble.llmContent = input;
|
|
2635
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
2636
|
+
} else {
|
|
2637
|
+
this.#appendMessage(userMessage);
|
|
2638
|
+
}
|
|
2639
|
+
this.#setStreaming(true);
|
|
2686
2640
|
|
|
2687
2641
|
// Assign the fresh controller BEFORE the mention await so cancel() (or a
|
|
2688
2642
|
// superseding sendMessage) during finalize() aborts THIS turn, not a stale
|
|
@@ -2699,7 +2653,7 @@ export class AgentWidgetSession {
|
|
|
2699
2653
|
if (options?.mentions) {
|
|
2700
2654
|
const stored =
|
|
2701
2655
|
this.messages.find((m) => m.id === userMessageId) ?? userMessage;
|
|
2702
|
-
await this
|
|
2656
|
+
await this.#applyMentionBundle(stored, input, options.mentions.finalize);
|
|
2703
2657
|
// A cancel() or new sendMessage during finalize aborted this controller
|
|
2704
2658
|
// (and replaced/nulled the shared ref). Bail without dispatching and
|
|
2705
2659
|
// leave whatever idle/streaming state that caller already set.
|
|
@@ -2729,10 +2683,17 @@ export class AgentWidgetSession {
|
|
|
2729
2683
|
}
|
|
2730
2684
|
}
|
|
2731
2685
|
|
|
2732
|
-
|
|
2686
|
+
// A submitted voice bubble may sit above captions or a turn that finished
|
|
2687
|
+
// while it waited: the request still ends on it.
|
|
2688
|
+
const snapshot = voiceBubble
|
|
2689
|
+
? [
|
|
2690
|
+
...this.messages.filter((m) => m !== voiceBubble),
|
|
2691
|
+
{ ...voiceBubble, createdAt: new Date().toISOString() }
|
|
2692
|
+
]
|
|
2693
|
+
: [...this.messages];
|
|
2733
2694
|
|
|
2734
2695
|
try {
|
|
2735
|
-
await this
|
|
2696
|
+
await this.#dispatchWithDeletedRecovery(
|
|
2736
2697
|
snapshot,
|
|
2737
2698
|
controller,
|
|
2738
2699
|
assistantMessageId,
|
|
@@ -2747,7 +2708,8 @@ export class AgentWidgetSession {
|
|
|
2747
2708
|
// first, which already flipped us into `resuming` and armed reconnect.
|
|
2748
2709
|
// The subsequent dispatch rejection must NOT paint a dispatch-error
|
|
2749
2710
|
// bubble: the turn is being resumed, not failed.
|
|
2750
|
-
if (this.status === "resuming" || this
|
|
2711
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
2712
|
+
if (this.#voiceDelegation) this.#voiceDelegation.failed = true;
|
|
2751
2713
|
// Check if this is an abort error (user canceled, navigated away, etc.)
|
|
2752
2714
|
// In these cases, don't show fallback - the request was intentionally interrupted
|
|
2753
2715
|
const isAbortError =
|
|
@@ -2769,15 +2731,15 @@ export class AgentWidgetSession {
|
|
|
2769
2731
|
role: "assistant",
|
|
2770
2732
|
createdAt: new Date().toISOString(),
|
|
2771
2733
|
content,
|
|
2772
|
-
sequence: this
|
|
2734
|
+
sequence: this.#nextSequence()
|
|
2773
2735
|
};
|
|
2774
2736
|
|
|
2775
|
-
this
|
|
2737
|
+
this.#appendMessage(fallback);
|
|
2776
2738
|
}
|
|
2777
2739
|
}
|
|
2778
2740
|
|
|
2779
|
-
this
|
|
2780
|
-
this
|
|
2741
|
+
this.#setStatus("idle");
|
|
2742
|
+
this.#setStreaming(false);
|
|
2781
2743
|
this.abortController = null;
|
|
2782
2744
|
|
|
2783
2745
|
if (!isAbortError) {
|
|
@@ -2845,7 +2807,7 @@ export class AgentWidgetSession {
|
|
|
2845
2807
|
};
|
|
2846
2808
|
|
|
2847
2809
|
const composerOptions = replacement
|
|
2848
|
-
? this
|
|
2810
|
+
? this.#toComposerOptionsPayload(replacement.options)
|
|
2849
2811
|
: stored.composerOptions;
|
|
2850
2812
|
const contentSegments = replacement
|
|
2851
2813
|
? replacement.contentSegments
|
|
@@ -2872,7 +2834,7 @@ export class AgentWidgetSession {
|
|
|
2872
2834
|
}
|
|
2873
2835
|
|
|
2874
2836
|
/** Submission options → the wire payload shape; quote travels separately. */
|
|
2875
|
-
|
|
2837
|
+
#toComposerOptionsPayload(
|
|
2876
2838
|
options: { selectedModelId?: string; activeModeIds?: string[] } | undefined
|
|
2877
2839
|
): ComposerOptionsPayload | undefined {
|
|
2878
2840
|
if (!options) return undefined;
|
|
@@ -2903,21 +2865,21 @@ export class AgentWidgetSession {
|
|
|
2903
2865
|
// Same ownership rule as sendMessage: a pending history transition owns the
|
|
2904
2866
|
// record this turn would land in.
|
|
2905
2867
|
if (
|
|
2906
|
-
this
|
|
2907
|
-
this
|
|
2908
|
-
this
|
|
2909
|
-
this
|
|
2868
|
+
this.#historyGate ||
|
|
2869
|
+
this.#projectionFinalizationPromise ||
|
|
2870
|
+
this.#historySendBlocked ||
|
|
2871
|
+
this.#credentialReinitPending
|
|
2910
2872
|
) {
|
|
2911
|
-
await this
|
|
2873
|
+
await this.#awaitHistorySendable();
|
|
2912
2874
|
}
|
|
2913
2875
|
|
|
2914
2876
|
this.abortController?.abort();
|
|
2915
|
-
this
|
|
2877
|
+
this.#teardownReconnect();
|
|
2916
2878
|
|
|
2917
2879
|
const assistantMessageId = generateAssistantMessageId();
|
|
2918
|
-
this
|
|
2880
|
+
this.#activeAssistantMessageId = null;
|
|
2919
2881
|
|
|
2920
|
-
this
|
|
2882
|
+
this.#setStreaming(true);
|
|
2921
2883
|
|
|
2922
2884
|
const controller = new AbortController();
|
|
2923
2885
|
this.abortController = controller;
|
|
@@ -2934,7 +2896,7 @@ export class AgentWidgetSession {
|
|
|
2934
2896
|
this.handleEvent
|
|
2935
2897
|
);
|
|
2936
2898
|
} catch (error) {
|
|
2937
|
-
if (this.status === "resuming" || this
|
|
2899
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
2938
2900
|
// Check if this is an abort error (a prior in-flight stream was canceled,
|
|
2939
2901
|
// the user navigated away, etc.). In these cases, don't show fallback or
|
|
2940
2902
|
// fire onError - the request was intentionally interrupted.
|
|
@@ -2957,14 +2919,14 @@ export class AgentWidgetSession {
|
|
|
2957
2919
|
role: "assistant",
|
|
2958
2920
|
createdAt: new Date().toISOString(),
|
|
2959
2921
|
content,
|
|
2960
|
-
sequence: this
|
|
2922
|
+
sequence: this.#nextSequence()
|
|
2961
2923
|
};
|
|
2962
2924
|
|
|
2963
|
-
this
|
|
2925
|
+
this.#appendMessage(fallback);
|
|
2964
2926
|
}
|
|
2965
2927
|
}
|
|
2966
|
-
this
|
|
2967
|
-
this
|
|
2928
|
+
this.#setStatus("idle");
|
|
2929
|
+
this.#setStreaming(false);
|
|
2968
2930
|
this.abortController = null;
|
|
2969
2931
|
if (!isAbortError) {
|
|
2970
2932
|
if (error instanceof Error) {
|
|
@@ -3008,7 +2970,7 @@ export class AgentWidgetSession {
|
|
|
3008
2970
|
// Durable reconnect keeps filling the same bubble: track its id so the
|
|
3009
2971
|
// cursor handler attaches to it.
|
|
3010
2972
|
if (options?.preserveAssistantId && options.assistantMessageId) {
|
|
3011
|
-
this
|
|
2973
|
+
this.#activeAssistantMessageId = options.assistantMessageId;
|
|
3012
2974
|
}
|
|
3013
2975
|
|
|
3014
2976
|
// Finalize any stale streaming messages from the previous stream
|
|
@@ -3028,27 +2990,42 @@ export class AgentWidgetSession {
|
|
|
3028
2990
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
3029
2991
|
}
|
|
3030
2992
|
|
|
3031
|
-
this
|
|
2993
|
+
this.#setStreaming(true);
|
|
3032
2994
|
|
|
2995
|
+
const streamToken = {};
|
|
3033
2996
|
try {
|
|
3034
2997
|
await this.client.processStream(
|
|
3035
2998
|
stream,
|
|
3036
|
-
|
|
2999
|
+
(event) => {
|
|
3000
|
+
const previous = this.#activeStreamToken;
|
|
3001
|
+
this.#activeStreamToken = streamToken;
|
|
3002
|
+
try {
|
|
3003
|
+
this.handleEvent(event);
|
|
3004
|
+
} finally {
|
|
3005
|
+
this.#activeStreamToken = previous;
|
|
3006
|
+
}
|
|
3007
|
+
},
|
|
3037
3008
|
options?.assistantMessageId,
|
|
3038
3009
|
options?.seedContent
|
|
3039
3010
|
);
|
|
3011
|
+
this.#flushApprovalSettles(streamToken);
|
|
3040
3012
|
} catch (error) {
|
|
3041
3013
|
// During a durable reconnect a thrown resume stream is just another drop:
|
|
3042
3014
|
// the reconnect loop owns the retry/backoff. Don't paint an error or tear
|
|
3043
|
-
// down, stay in `resuming`.
|
|
3044
|
-
|
|
3045
|
-
this.
|
|
3015
|
+
// down, stay in `resuming`. The reconnect may still deliver the paused
|
|
3016
|
+
// call's result, so its approval settles wait for streaming to end.
|
|
3017
|
+
if (this.status === "resuming" || this.#reconnecting) {
|
|
3018
|
+
this.#releaseApprovalSettles(streamToken);
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
this.#flushApprovalSettles(streamToken);
|
|
3022
|
+
this.#setStatus("error");
|
|
3046
3023
|
// Mirror the idle/error handlers: a failed resume stream must not tear
|
|
3047
3024
|
// down streaming/abortController while another WebMCP resolve is still
|
|
3048
3025
|
// confirming or executing. The in-flight resolve's `finally` owns the
|
|
3049
3026
|
// teardown once `webMcpResolveControllers` drains.
|
|
3050
3027
|
if (this.webMcpResolveControllers.size === 0) {
|
|
3051
|
-
this
|
|
3028
|
+
this.#setStreaming(false);
|
|
3052
3029
|
this.abortController = null;
|
|
3053
3030
|
}
|
|
3054
3031
|
this.callbacks.onError?.(
|
|
@@ -3063,7 +3040,7 @@ export class AgentWidgetSession {
|
|
|
3063
3040
|
* this, the bridge falls back to a blunt `window.confirm`. Safe to call
|
|
3064
3041
|
* repeatedly (e.g. after the client is re-created in `updateConfig`).
|
|
3065
3042
|
*/
|
|
3066
|
-
|
|
3043
|
+
#wireDefaultWebMcpConfirm(): void {
|
|
3067
3044
|
const webmcp = this.config.webmcp;
|
|
3068
3045
|
if (webmcp?.enabled === true && !webmcp.onConfirm) {
|
|
3069
3046
|
this.client.setWebMcpConfirmHandler((info) =>
|
|
@@ -3092,7 +3069,7 @@ export class AgentWidgetSession {
|
|
|
3092
3069
|
}
|
|
3093
3070
|
|
|
3094
3071
|
const approval: AgentWidgetApproval = {
|
|
3095
|
-
id: `webmcp-${++this
|
|
3072
|
+
id: `webmcp-${++this.#webMcpApprovalSeq}`,
|
|
3096
3073
|
status: "pending",
|
|
3097
3074
|
agentId: "",
|
|
3098
3075
|
executionId: "",
|
|
@@ -3148,133 +3125,58 @@ export class AgentWidgetSession {
|
|
|
3148
3125
|
resolve(decision === "approved");
|
|
3149
3126
|
}
|
|
3150
3127
|
|
|
3128
|
+
#actionsHost: SessionActionsHost | null = null;
|
|
3129
|
+
// Advanced when the turn is stopped or replaced (cancel, clear, new send,
|
|
3130
|
+
// hydrate, conversation switch). A resolve waiting on the lazy chunk checks
|
|
3131
|
+
// it after the load so a stopped turn never resumes late.
|
|
3132
|
+
#actionsEpoch = 0;
|
|
3133
|
+
|
|
3134
|
+
/**
|
|
3135
|
+
* Run a resolve path from `session-actions.ts` (the lazy
|
|
3136
|
+
* `session-actions.js` chunk in the IIFE build). Synchronous when the module
|
|
3137
|
+
* is already loaded or provided, so the call's timing matches an inline
|
|
3138
|
+
* method; otherwise it waits for the chunk and reports a failed load via
|
|
3139
|
+
* `onError` (the resolve never started, so no state needs unwinding). A turn
|
|
3140
|
+
* stopped or replaced while the chunk loads drops the resolve.
|
|
3141
|
+
*/
|
|
3142
|
+
#withActions(
|
|
3143
|
+
run: (actions: SessionActionsModule, host: SessionActionsHost) => Promise<void>
|
|
3144
|
+
): Promise<void> {
|
|
3145
|
+
const host = (this.#actionsHost ??= {
|
|
3146
|
+
s: this as unknown as SessionActionsInternals,
|
|
3147
|
+
setStreaming: (streaming) => this.#setStreaming(streaming),
|
|
3148
|
+
appendMessage: (message) => this.#appendMessage(message),
|
|
3149
|
+
nextSequence: () => this.#nextSequence(),
|
|
3150
|
+
settleApprovalPausedToolCall: (id) => this.#settleApprovalPausedToolCall(id),
|
|
3151
|
+
resumeAfter: (executionId) =>
|
|
3152
|
+
this.#resumable?.executionId === executionId
|
|
3153
|
+
? this.#resumable.lastEventId
|
|
3154
|
+
: undefined,
|
|
3155
|
+
});
|
|
3156
|
+
const actions = getSessionActionsSync();
|
|
3157
|
+
if (actions) return run(actions, host);
|
|
3158
|
+
const epoch = this.#actionsEpoch;
|
|
3159
|
+
return loadSessionActions().then(
|
|
3160
|
+
(loaded) => (epoch === this.#actionsEpoch ? run(loaded, host) : undefined),
|
|
3161
|
+
(error) => {
|
|
3162
|
+
this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
3163
|
+
}
|
|
3164
|
+
);
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3151
3167
|
/**
|
|
3152
3168
|
* Resolve a tool approval request (approve or deny).
|
|
3153
3169
|
* Updates the approval message status, calls the API (or custom onDecision),
|
|
3154
3170
|
* and pipes the response stream through connectStream().
|
|
3155
3171
|
*/
|
|
3156
|
-
public
|
|
3172
|
+
public resolveApproval(
|
|
3157
3173
|
approval: AgentWidgetApproval,
|
|
3158
3174
|
decision: 'approved' | 'denied',
|
|
3159
3175
|
options?: AgentWidgetApprovalDecisionOptions
|
|
3160
3176
|
): Promise<void> {
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
...approval,
|
|
3165
|
-
status: decision,
|
|
3166
|
-
resolvedAt: Date.now(),
|
|
3167
|
-
};
|
|
3168
|
-
// Anchor the bubble where the agent paused for permission. An approval is a
|
|
3169
|
-
// timeline checkpoint, not a "now" event, so resolving it must preserve the
|
|
3170
|
-
// original message's createdAt/sequence: otherwise sortMessages (which
|
|
3171
|
-
// orders by createdAt first) would re-stamp it to now and float it past any
|
|
3172
|
-
// message created later (e.g. a long-pending approval resolved after more
|
|
3173
|
-
// conversation, or restored/replayed transcripts).
|
|
3174
|
-
const existing = this.messages.find((m) => m.id === approvalMessageId);
|
|
3175
|
-
const updatedMessage: AgentWidgetMessage = {
|
|
3176
|
-
id: approvalMessageId,
|
|
3177
|
-
role: "assistant",
|
|
3178
|
-
content: "",
|
|
3179
|
-
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
3180
|
-
...(existing?.sequence !== undefined ? { sequence: existing.sequence } : {}),
|
|
3181
|
-
streaming: false,
|
|
3182
|
-
variant: "approval",
|
|
3183
|
-
approval: updatedApproval,
|
|
3184
|
-
};
|
|
3185
|
-
this.upsertMessage(updatedMessage);
|
|
3186
|
-
|
|
3187
|
-
// Show the standalone typing indicator immediately while we wait for the
|
|
3188
|
-
// approval round-trip. Install an abortController so cancel() works during
|
|
3189
|
-
// the silent gap. See `resolveAskUserQuestion` for the same pattern.
|
|
3190
|
-
this.abortController?.abort();
|
|
3191
|
-
this.abortController = new AbortController();
|
|
3192
|
-
this.setStreaming(true);
|
|
3193
|
-
|
|
3194
|
-
// 2. Call onDecision callback if provided, otherwise use client.resolveApproval()
|
|
3195
|
-
const approvalConfig = this.config.approval;
|
|
3196
|
-
const onDecision = approvalConfig && typeof approvalConfig === 'object' ? approvalConfig.onDecision : undefined;
|
|
3197
|
-
|
|
3198
|
-
try {
|
|
3199
|
-
let response: Response | ReadableStream<Uint8Array> | void;
|
|
3200
|
-
|
|
3201
|
-
if (onDecision) {
|
|
3202
|
-
response = await onDecision(
|
|
3203
|
-
{
|
|
3204
|
-
approvalId: approval.id,
|
|
3205
|
-
executionId: approval.executionId,
|
|
3206
|
-
agentId: approval.agentId,
|
|
3207
|
-
toolName: approval.toolName,
|
|
3208
|
-
},
|
|
3209
|
-
decision,
|
|
3210
|
-
options
|
|
3211
|
-
);
|
|
3212
|
-
} else {
|
|
3213
|
-
response = await this.client.resolveApproval(
|
|
3214
|
-
{
|
|
3215
|
-
agentId: approval.agentId,
|
|
3216
|
-
executionId: approval.executionId,
|
|
3217
|
-
approvalId: approval.id,
|
|
3218
|
-
},
|
|
3219
|
-
decision
|
|
3220
|
-
);
|
|
3221
|
-
}
|
|
3222
|
-
|
|
3223
|
-
// 3. Pipe through connectStream if we got a response with a body
|
|
3224
|
-
if (response) {
|
|
3225
|
-
let stream: ReadableStream<Uint8Array> | null = null;
|
|
3226
|
-
if (response instanceof Response) {
|
|
3227
|
-
if (!response.ok) {
|
|
3228
|
-
const errorData = await response.json().catch(() => null);
|
|
3229
|
-
throw new Error(
|
|
3230
|
-
errorData?.error ?? `Approval request failed: ${response.status}`
|
|
3231
|
-
);
|
|
3232
|
-
}
|
|
3233
|
-
stream = response.body;
|
|
3234
|
-
} else if (response instanceof ReadableStream) {
|
|
3235
|
-
stream = response;
|
|
3236
|
-
}
|
|
3237
|
-
|
|
3238
|
-
if (stream) {
|
|
3239
|
-
await this.connectStream(stream, { allowReentry: true });
|
|
3240
|
-
} else {
|
|
3241
|
-
if (decision === 'denied') {
|
|
3242
|
-
// No stream body for denied: inject a denial message
|
|
3243
|
-
this.appendMessage({
|
|
3244
|
-
id: `denial-${approval.id}`,
|
|
3245
|
-
role: "assistant",
|
|
3246
|
-
content: "Tool execution was denied by user.",
|
|
3247
|
-
createdAt: new Date().toISOString(),
|
|
3248
|
-
streaming: false,
|
|
3249
|
-
sequence: this.nextSequence(),
|
|
3250
|
-
});
|
|
3251
|
-
}
|
|
3252
|
-
// No body to pipe: drop the pre-set streaming flag so the indicator
|
|
3253
|
-
// doesn't linger forever.
|
|
3254
|
-
this.setStreaming(false);
|
|
3255
|
-
this.abortController = null;
|
|
3256
|
-
}
|
|
3257
|
-
} else {
|
|
3258
|
-
// onDecision returned void / no response: drop the pre-set flag.
|
|
3259
|
-
this.setStreaming(false);
|
|
3260
|
-
this.abortController = null;
|
|
3261
|
-
}
|
|
3262
|
-
} catch (error) {
|
|
3263
|
-
const isAbortError =
|
|
3264
|
-
error instanceof Error &&
|
|
3265
|
-
(error.name === 'AbortError' ||
|
|
3266
|
-
error.message.includes('aborted') ||
|
|
3267
|
-
error.message.includes('abort'));
|
|
3268
|
-
|
|
3269
|
-
this.setStreaming(false);
|
|
3270
|
-
this.abortController = null;
|
|
3271
|
-
|
|
3272
|
-
if (!isAbortError) {
|
|
3273
|
-
this.callbacks.onError?.(
|
|
3274
|
-
error instanceof Error ? error : new Error(String(error))
|
|
3275
|
-
);
|
|
3276
|
-
}
|
|
3277
|
-
}
|
|
3177
|
+
return this.#withActions((actions, host) =>
|
|
3178
|
+
actions.resolveApproval(host, approval, decision, options)
|
|
3179
|
+
);
|
|
3278
3180
|
}
|
|
3279
3181
|
|
|
3280
3182
|
/**
|
|
@@ -3289,6 +3191,27 @@ export class AgentWidgetSession {
|
|
|
3289
3191
|
* 3. Append a user-visible bubble with the answer text so the
|
|
3290
3192
|
* transcript reads naturally.
|
|
3291
3193
|
*/
|
|
3194
|
+
public resolveAskUserQuestion(
|
|
3195
|
+
toolMessage: AgentWidgetMessage,
|
|
3196
|
+
answer: string | Record<string, string | string[]>
|
|
3197
|
+
): Promise<void> {
|
|
3198
|
+
return this.#withActions((actions, host) =>
|
|
3199
|
+
actions.resolveAskUserQuestion(host, toolMessage, answer)
|
|
3200
|
+
);
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
/**
|
|
3204
|
+
* Resolve a paused auto-resolving LOCAL tool call (`webmcp:*` page tools
|
|
3205
|
+
* and the built-in `suggest_replies`) and post the result to `/resume`.
|
|
3206
|
+
* Triggered automatically from `handleEvent`; idempotent on the message's
|
|
3207
|
+
* `toolCall.id`. See `resolveWebMcpToolCall` in `session-actions.ts`.
|
|
3208
|
+
*/
|
|
3209
|
+
public resolveWebMcpToolCall(toolMessage: AgentWidgetMessage): Promise<void> {
|
|
3210
|
+
return this.#withActions((actions, host) =>
|
|
3211
|
+
actions.resolveWebMcpToolCall(host, toolMessage)
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3292
3215
|
/**
|
|
3293
3216
|
* Persist in-progress answers and the current page index for a multi-question
|
|
3294
3217
|
* `ask_user_question` payload, so a refresh resumes on the same page with
|
|
@@ -3338,167 +3261,6 @@ export class AgentWidgetSession {
|
|
|
3338
3261
|
});
|
|
3339
3262
|
}
|
|
3340
3263
|
|
|
3341
|
-
public async resolveAskUserQuestion(
|
|
3342
|
-
toolMessage: AgentWidgetMessage,
|
|
3343
|
-
answer: string | Record<string, string | string[]>
|
|
3344
|
-
): Promise<void> {
|
|
3345
|
-
// Idempotent: guards against rapid double-clicks on answer pills before
|
|
3346
|
-
// the re-render swaps the card to its collapsed/answered state.
|
|
3347
|
-
const live = this.messages.find((m) => m.id === toolMessage.id);
|
|
3348
|
-
if (live?.agentMetadata?.askUserQuestionAnswered === true) return;
|
|
3349
|
-
|
|
3350
|
-
const executionId = toolMessage.agentMetadata?.executionId;
|
|
3351
|
-
const toolName = toolMessage.toolCall?.name;
|
|
3352
|
-
if (!executionId || !toolName) {
|
|
3353
|
-
this.callbacks.onError?.(
|
|
3354
|
-
new Error(
|
|
3355
|
-
"resolveAskUserQuestion: message is missing executionId or toolCall.name"
|
|
3356
|
-
)
|
|
3357
|
-
);
|
|
3358
|
-
return;
|
|
3359
|
-
}
|
|
3360
|
-
|
|
3361
|
-
// Flip answered flag first so the next render skips the sheet re-mount,
|
|
3362
|
-
// avoiding the race between removeAskUserQuestionSheet's 180ms slide-out
|
|
3363
|
-
// timer and the renders that fire as the resume stream lands. Pass the
|
|
3364
|
-
// structured answer Record (when present) so it's atomically persisted
|
|
3365
|
-
// alongside the flag: the answered-state review card depends on
|
|
3366
|
-
// `agentMetadata.askUserQuestionAnswers` being populated at render time.
|
|
3367
|
-
//
|
|
3368
|
-
// For single-question payloads, callers (built-in pick handler, plugins)
|
|
3369
|
-
// resolve with a plain string. Derive a `{ [questionText]: answer }` Record
|
|
3370
|
-
// from the toolCall args so the answered-card render path is consistent
|
|
3371
|
-
// with grouped flows.
|
|
3372
|
-
let structuredAnswers: Record<string, string | string[]> | undefined =
|
|
3373
|
-
typeof answer === "string" ? undefined : answer;
|
|
3374
|
-
if (structuredAnswers === undefined && typeof answer === "string") {
|
|
3375
|
-
const args = toolMessage.toolCall?.args as
|
|
3376
|
-
| { questions?: Array<{ question?: unknown }> }
|
|
3377
|
-
| undefined;
|
|
3378
|
-
const questions = Array.isArray(args?.questions) ? args!.questions : [];
|
|
3379
|
-
if (questions.length === 1) {
|
|
3380
|
-
const qText = typeof questions[0]?.question === "string"
|
|
3381
|
-
? (questions[0].question as string)
|
|
3382
|
-
: "";
|
|
3383
|
-
if (qText) structuredAnswers = { [qText]: answer };
|
|
3384
|
-
}
|
|
3385
|
-
}
|
|
3386
|
-
this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
|
|
3387
|
-
|
|
3388
|
-
// Show the standalone typing indicator immediately: the network round-trip
|
|
3389
|
-
// to /resume is otherwise silent, which reads as broken. The render
|
|
3390
|
-
// condition in ui.ts already shows the indicator once streaming flips true
|
|
3391
|
-
// and the last message is a user bubble (the answer we inject below).
|
|
3392
|
-
// Install an abortController so cancel() works during this silent gap.
|
|
3393
|
-
this.abortController?.abort();
|
|
3394
|
-
this.abortController = new AbortController();
|
|
3395
|
-
this.setStreaming(true);
|
|
3396
|
-
|
|
3397
|
-
// Inject Q→A pair messages: one assistant bubble per question, one user
|
|
3398
|
-
// bubble per answer, so the transcript reads like a normal conversation.
|
|
3399
|
-
// The original ask_user_question tool message is suppressed by the
|
|
3400
|
-
// renderer once `askUserQuestionAnswered` is true. Skipped questions get
|
|
3401
|
-
// a muted italic `*Skipped*` user bubble (rendered through the standard
|
|
3402
|
-
// markdown pipeline).
|
|
3403
|
-
const toolCallId = toolMessage.toolCall!.id;
|
|
3404
|
-
const args = toolMessage.toolCall?.args as
|
|
3405
|
-
| { questions?: Array<{ question?: unknown; header?: unknown }> }
|
|
3406
|
-
| undefined;
|
|
3407
|
-
const questions = Array.isArray(args?.questions) ? args!.questions : [];
|
|
3408
|
-
if (questions.length === 0) {
|
|
3409
|
-
const fallback =
|
|
3410
|
-
typeof answer === "string"
|
|
3411
|
-
? answer
|
|
3412
|
-
: Object.entries(answer)
|
|
3413
|
-
.map(
|
|
3414
|
-
([q, v]) => `${q}: ${Array.isArray(v) ? v.join(", ") : v}`
|
|
3415
|
-
)
|
|
3416
|
-
.join(" | ");
|
|
3417
|
-
this.appendMessage({
|
|
3418
|
-
id: `ask-user-answer-${toolCallId}`,
|
|
3419
|
-
role: "user",
|
|
3420
|
-
content: fallback,
|
|
3421
|
-
createdAt: new Date().toISOString(),
|
|
3422
|
-
streaming: false,
|
|
3423
|
-
sequence: this.nextSequence(),
|
|
3424
|
-
});
|
|
3425
|
-
} else {
|
|
3426
|
-
const stored = structuredAnswers ?? {};
|
|
3427
|
-
questions.forEach((p, i) => {
|
|
3428
|
-
const qText = typeof p?.question === "string" ? p.question : "";
|
|
3429
|
-
if (!qText) return;
|
|
3430
|
-
const ans = stored[qText];
|
|
3431
|
-
const answerStr = Array.isArray(ans)
|
|
3432
|
-
? ans.join(", ")
|
|
3433
|
-
: typeof ans === "string"
|
|
3434
|
-
? ans
|
|
3435
|
-
: "";
|
|
3436
|
-
this.appendMessage({
|
|
3437
|
-
id: `ask-user-q-${toolCallId}-${i}`,
|
|
3438
|
-
role: "assistant",
|
|
3439
|
-
content: qText,
|
|
3440
|
-
createdAt: new Date().toISOString(),
|
|
3441
|
-
streaming: false,
|
|
3442
|
-
sequence: this.nextSequence(),
|
|
3443
|
-
});
|
|
3444
|
-
this.appendMessage({
|
|
3445
|
-
id: `ask-user-a-${toolCallId}-${i}`,
|
|
3446
|
-
role: "user",
|
|
3447
|
-
content: answerStr || "*Skipped*",
|
|
3448
|
-
createdAt: new Date().toISOString(),
|
|
3449
|
-
streaming: false,
|
|
3450
|
-
sequence: this.nextSequence(),
|
|
3451
|
-
});
|
|
3452
|
-
});
|
|
3453
|
-
}
|
|
3454
|
-
|
|
3455
|
-
try {
|
|
3456
|
-
const response = await this.client.resumeFlow(
|
|
3457
|
-
executionId,
|
|
3458
|
-
{ [toolName]: answer },
|
|
3459
|
-
{
|
|
3460
|
-
after:
|
|
3461
|
-
this.resumable?.executionId === executionId
|
|
3462
|
-
? this.resumable.lastEventId
|
|
3463
|
-
: undefined,
|
|
3464
|
-
},
|
|
3465
|
-
);
|
|
3466
|
-
|
|
3467
|
-
if (!response.ok) {
|
|
3468
|
-
const errorData = await response.json().catch(() => null);
|
|
3469
|
-
throw new Error(
|
|
3470
|
-
errorData?.error ?? `Resume failed: ${response.status}`
|
|
3471
|
-
);
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
if (response.body) {
|
|
3475
|
-
await this.connectStream(response.body, { allowReentry: true });
|
|
3476
|
-
} else {
|
|
3477
|
-
// No body to pipe: drop the pre-set streaming flag so the indicator
|
|
3478
|
-
// doesn't linger forever.
|
|
3479
|
-
this.setStreaming(false);
|
|
3480
|
-
this.abortController = null;
|
|
3481
|
-
}
|
|
3482
|
-
} catch (error) {
|
|
3483
|
-
// Mirror sendMessage: a cancel() during the await aborts the controller
|
|
3484
|
-
// and surfaces an AbortError: don't treat that as a real failure.
|
|
3485
|
-
const isAbortError =
|
|
3486
|
-
error instanceof Error &&
|
|
3487
|
-
(error.name === 'AbortError' ||
|
|
3488
|
-
error.message.includes('aborted') ||
|
|
3489
|
-
error.message.includes('abort'));
|
|
3490
|
-
|
|
3491
|
-
this.setStreaming(false);
|
|
3492
|
-
this.abortController = null;
|
|
3493
|
-
|
|
3494
|
-
if (!isAbortError) {
|
|
3495
|
-
this.callbacks.onError?.(
|
|
3496
|
-
error instanceof Error ? error : new Error(String(error))
|
|
3497
|
-
);
|
|
3498
|
-
}
|
|
3499
|
-
}
|
|
3500
|
-
}
|
|
3501
|
-
|
|
3502
3264
|
/**
|
|
3503
3265
|
* Collect an auto-resolving LOCAL-tool `await` (`webmcp:*` page tools
|
|
3504
3266
|
* and the built-in `suggest_replies`) into a per-executionId batch
|
|
@@ -3517,13 +3279,13 @@ export class AgentWidgetSession {
|
|
|
3517
3279
|
*: route them straight to the single-call path, which surfaces the malformed
|
|
3518
3280
|
* wire shape via `onError` / an `isError` resume.
|
|
3519
3281
|
*/
|
|
3520
|
-
|
|
3282
|
+
#enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
|
|
3521
3283
|
const executionId = toolMessage.agentMetadata?.executionId;
|
|
3522
3284
|
const callId = toolMessage.toolCall?.id;
|
|
3523
3285
|
if (!executionId || !callId) {
|
|
3524
|
-
const queuedEpoch = this
|
|
3286
|
+
const queuedEpoch = this.#webMcpEpoch;
|
|
3525
3287
|
queueMicrotask(() => {
|
|
3526
|
-
if (queuedEpoch !== this
|
|
3288
|
+
if (queuedEpoch !== this.#webMcpEpoch) return;
|
|
3527
3289
|
void this.resolveWebMcpToolCall(toolMessage);
|
|
3528
3290
|
});
|
|
3529
3291
|
return;
|
|
@@ -3555,13 +3317,13 @@ export class AgentWidgetSession {
|
|
|
3555
3317
|
* abortController) settles before a resolve grabs them: the same ordering the
|
|
3556
3318
|
* single-call resolve always relied on.
|
|
3557
3319
|
*/
|
|
3558
|
-
|
|
3320
|
+
#scheduleWebMcpBatchFlush(): void {
|
|
3559
3321
|
if (this.webMcpAwaitBatches.size === 0) return;
|
|
3560
|
-
const queuedEpoch = this
|
|
3322
|
+
const queuedEpoch = this.#webMcpEpoch;
|
|
3561
3323
|
queueMicrotask(() => {
|
|
3562
|
-
if (queuedEpoch !== this
|
|
3324
|
+
if (queuedEpoch !== this.#webMcpEpoch) return;
|
|
3563
3325
|
for (const executionId of [...this.webMcpAwaitBatches.keys()]) {
|
|
3564
|
-
this
|
|
3326
|
+
this.#flushWebMcpAwaitBatch(executionId);
|
|
3565
3327
|
}
|
|
3566
3328
|
});
|
|
3567
3329
|
}
|
|
@@ -3574,7 +3336,7 @@ export class AgentWidgetSession {
|
|
|
3574
3336
|
* so any later sibling re-emit (e.g. from a re-pause) forms a fresh batch
|
|
3575
3337
|
* rather than mutating one already in flight.
|
|
3576
3338
|
*/
|
|
3577
|
-
|
|
3339
|
+
#flushWebMcpAwaitBatch(executionId: string): void {
|
|
3578
3340
|
const batch = this.webMcpAwaitBatches.get(executionId);
|
|
3579
3341
|
if (!batch) return;
|
|
3580
3342
|
this.webMcpAwaitBatches.delete(executionId);
|
|
@@ -3582,498 +3344,9 @@ export class AgentWidgetSession {
|
|
|
3582
3344
|
if (snapshots.length === 1) {
|
|
3583
3345
|
void this.resolveWebMcpToolCall(snapshots[0]);
|
|
3584
3346
|
} else if (snapshots.length > 1) {
|
|
3585
|
-
void this
|
|
3586
|
-
|
|
3587
|
-
}
|
|
3588
|
-
|
|
3589
|
-
private resolveWebMcpToolStartedAt(
|
|
3590
|
-
toolMessage: AgentWidgetMessage,
|
|
3591
|
-
): number {
|
|
3592
|
-
const stored = this.messages.find((m) => m.id === toolMessage.id);
|
|
3593
|
-
const candidates = [
|
|
3594
|
-
stored?.toolCall?.startedAt,
|
|
3595
|
-
toolMessage.toolCall?.startedAt,
|
|
3596
|
-
];
|
|
3597
|
-
for (const candidate of candidates) {
|
|
3598
|
-
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
3599
|
-
return candidate;
|
|
3600
|
-
}
|
|
3601
|
-
}
|
|
3602
|
-
return Date.now();
|
|
3603
|
-
}
|
|
3604
|
-
|
|
3605
|
-
/**
|
|
3606
|
-
* Persisted-resolution guard for `suggest_replies`. The in-memory dedupe
|
|
3607
|
-
* sets (`webMcpInflightKeys` / `webMcpResolvedKeys`) are cleared by
|
|
3608
|
-
* hydrateMessages/clearMessages/cancel, but `suggestRepliesResolved`
|
|
3609
|
-
* survives on the stored message, so a stale `await` re-emit after a
|
|
3610
|
-
* hydration must not re-POST `/resume` for an already-resolved call (the
|
|
3611
|
-
* historical double-resume failure mode the batching work exists to avoid).
|
|
3612
|
-
* Checks the LIVE message first; the handleEvent snapshot is a fresh wire
|
|
3613
|
-
* skeleton whose metadata never carries the flag.
|
|
3614
|
-
*/
|
|
3615
|
-
private isSuggestRepliesAlreadyResolved(
|
|
3616
|
-
toolMessage: AgentWidgetMessage,
|
|
3617
|
-
): boolean {
|
|
3618
|
-
if (toolMessage.toolCall?.name !== SUGGEST_REPLIES_TOOL_NAME) return false;
|
|
3619
|
-
const stored = this.messages.find((m) => m.id === toolMessage.id);
|
|
3620
|
-
return (
|
|
3621
|
-
(stored ?? toolMessage).agentMetadata?.suggestRepliesResolved === true
|
|
3622
|
-
);
|
|
3623
|
-
}
|
|
3624
|
-
|
|
3625
|
-
private markWebMcpToolRunning(
|
|
3626
|
-
toolMessage: AgentWidgetMessage,
|
|
3627
|
-
): number {
|
|
3628
|
-
const startedAt = this.resolveWebMcpToolStartedAt(toolMessage);
|
|
3629
|
-
this.upsertMessage({
|
|
3630
|
-
...toolMessage,
|
|
3631
|
-
streaming: true,
|
|
3632
|
-
agentMetadata: {
|
|
3633
|
-
...toolMessage.agentMetadata,
|
|
3634
|
-
awaitingLocalTool: false,
|
|
3635
|
-
},
|
|
3636
|
-
toolCall: toolMessage.toolCall
|
|
3637
|
-
? {
|
|
3638
|
-
...toolMessage.toolCall,
|
|
3639
|
-
status: "running",
|
|
3640
|
-
startedAt,
|
|
3641
|
-
completedAt: undefined,
|
|
3642
|
-
duration: undefined,
|
|
3643
|
-
durationMs: undefined,
|
|
3644
|
-
}
|
|
3645
|
-
: toolMessage.toolCall,
|
|
3646
|
-
});
|
|
3647
|
-
return startedAt;
|
|
3648
|
-
}
|
|
3649
|
-
|
|
3650
|
-
private markWebMcpToolComplete(
|
|
3651
|
-
toolMessage: AgentWidgetMessage,
|
|
3652
|
-
result: unknown,
|
|
3653
|
-
startedAt: number,
|
|
3654
|
-
completedAt = Date.now(),
|
|
3655
|
-
extraMetadata?: Partial<
|
|
3656
|
-
NonNullable<AgentWidgetMessage["agentMetadata"]>
|
|
3657
|
-
>,
|
|
3658
|
-
): void {
|
|
3659
|
-
// A teardown such as clearMessages()/hydrateMessages()/new send can remove
|
|
3660
|
-
// the bubble while an aborted WebMCP promise is settling. Never resurrect a
|
|
3661
|
-
// cleared message just to mark the old resolve complete.
|
|
3662
|
-
if (!this.messages.some((message) => message.id === toolMessage.id)) return;
|
|
3663
|
-
this.upsertMessage({
|
|
3664
|
-
...toolMessage,
|
|
3665
|
-
streaming: false,
|
|
3666
|
-
agentMetadata: {
|
|
3667
|
-
...toolMessage.agentMetadata,
|
|
3668
|
-
awaitingLocalTool: false,
|
|
3669
|
-
...extraMetadata,
|
|
3670
|
-
},
|
|
3671
|
-
toolCall: toolMessage.toolCall
|
|
3672
|
-
? {
|
|
3673
|
-
...toolMessage.toolCall,
|
|
3674
|
-
status: "complete",
|
|
3675
|
-
result,
|
|
3676
|
-
startedAt,
|
|
3677
|
-
completedAt,
|
|
3678
|
-
duration: undefined,
|
|
3679
|
-
durationMs: Math.max(0, completedAt - startedAt),
|
|
3680
|
-
}
|
|
3681
|
-
: toolMessage.toolCall,
|
|
3682
|
-
});
|
|
3683
|
-
}
|
|
3684
|
-
|
|
3685
|
-
/**
|
|
3686
|
-
* Resolve one or more parallel local-tool awaits sharing one paused
|
|
3687
|
-
* executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
|
|
3688
|
-
* delegates size-1 resolves here after its guards. By default each call is
|
|
3689
|
-
* executed against the page registry concurrently: every gated call renders
|
|
3690
|
-
* its own native approval bubble, and a sibling's confirm Promise never
|
|
3691
|
-
* blocks another's execution. With `webmcp.execution: "sequential"` the
|
|
3692
|
-
* calls run one at a time in emission order instead (see the config docs
|
|
3693
|
-
* for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
|
|
3694
|
-
* (server prefers it over tool name; name-keying remains the fallback for
|
|
3695
|
-
* legacy single/distinct-tool turns), so two calls to the SAME tool no longer
|
|
3696
|
-
* collide. The server is tolerant: any call we omit (declined-after-abort,
|
|
3697
|
-
* dedupe, exec failure) simply re-pauses and is retried on its re-emit.
|
|
3698
|
-
*
|
|
3699
|
-
* Owns the dedupe / abort / streaming machinery for both routes; resolved
|
|
3700
|
-
* keys are marked on the shared resume POST's HTTP OK.
|
|
3701
|
-
*/
|
|
3702
|
-
private async resolveWebMcpToolCallBatch(
|
|
3703
|
-
executionId: string,
|
|
3704
|
-
snapshots: AgentWidgetMessage[],
|
|
3705
|
-
): Promise<void> {
|
|
3706
|
-
type ExecutedWebMcpTool = {
|
|
3707
|
-
dedupeKey: string;
|
|
3708
|
-
resumeKey: string;
|
|
3709
|
-
output: unknown;
|
|
3710
|
-
toolMessage: AgentWidgetMessage;
|
|
3711
|
-
startedAt: number;
|
|
3712
|
-
completedAt: number;
|
|
3713
|
-
};
|
|
3714
|
-
const claimedKeys: string[] = [];
|
|
3715
|
-
// One controller per batch, shared by every execute and the resume fetch.
|
|
3716
|
-
// Teardown (`abortWebMcpResolves`) only ever aborts the whole set, so
|
|
3717
|
-
// finer granularity buys nothing; one registration per resolve op also
|
|
3718
|
-
// keeps `webMcpResolveControllers.size` === in-flight resolve count.
|
|
3719
|
-
const batchController = new AbortController();
|
|
3720
|
-
this.webMcpResolveControllers.add(batchController);
|
|
3721
|
-
this.setStreaming(true);
|
|
3722
|
-
|
|
3723
|
-
// Phase 1: execute every pending call. A null result means the call was
|
|
3724
|
-
// deduped, aborted, or threw; it's omitted from the resume and (per the
|
|
3725
|
-
// tolerant server) re-pauses for retry.
|
|
3726
|
-
const executeOne = async (
|
|
3727
|
-
toolMessage: AgentWidgetMessage,
|
|
3728
|
-
): Promise<ExecutedWebMcpTool | null> => {
|
|
3729
|
-
const wireToolName = toolMessage.toolCall?.name;
|
|
3730
|
-
const callId = toolMessage.toolCall?.id;
|
|
3731
|
-
if (!wireToolName || !callId) return null;
|
|
3732
|
-
|
|
3733
|
-
const dedupeKey = `${executionId}:${callId}`;
|
|
3734
|
-
if (
|
|
3735
|
-
this.webMcpInflightKeys.has(dedupeKey) ||
|
|
3736
|
-
this.webMcpResolvedKeys.has(dedupeKey) ||
|
|
3737
|
-
this.isSuggestRepliesAlreadyResolved(toolMessage)
|
|
3738
|
-
) {
|
|
3739
|
-
return null;
|
|
3740
|
-
}
|
|
3741
|
-
this.webMcpInflightKeys.add(dedupeKey);
|
|
3742
|
-
claimedKeys.push(dedupeKey);
|
|
3743
|
-
|
|
3744
|
-
// Clear the awaiting flag and keep the tool bubble running while the
|
|
3745
|
-
// browser-side WebMCP promise is in flight. The initial `await`
|
|
3746
|
-
// only means the server paused for a local tool; it is not completion.
|
|
3747
|
-
const startedAt = this.markWebMcpToolRunning(toolMessage);
|
|
3748
|
-
|
|
3749
|
-
// Per-call id wins for resume keying; fall back to the wire tool name
|
|
3750
|
-
// for legacy servers that don't emit `webMcpToolCallId`.
|
|
3751
|
-
const resumeKey =
|
|
3752
|
-
toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
|
|
3753
|
-
|
|
3754
|
-
// Built-in fire-and-forget tool: no bridge, no confirm gate, no
|
|
3755
|
-
// browser-side execution: the chips render from the message list and
|
|
3756
|
-
// the canned output joins the batch's single /resume.
|
|
3757
|
-
if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
|
|
3758
|
-
return {
|
|
3759
|
-
dedupeKey,
|
|
3760
|
-
resumeKey,
|
|
3761
|
-
output: suggestRepliesToolResult(),
|
|
3762
|
-
toolMessage,
|
|
3763
|
-
startedAt,
|
|
3764
|
-
completedAt: Date.now(),
|
|
3765
|
-
};
|
|
3766
|
-
}
|
|
3767
|
-
|
|
3768
|
-
const execPromise = this.client.executeWebMcpToolCall(
|
|
3769
|
-
wireToolName,
|
|
3770
|
-
toolMessage.toolCall?.args,
|
|
3771
|
-
batchController.signal,
|
|
3347
|
+
void this.#withActions((actions, host) =>
|
|
3348
|
+
actions.resolveWebMcpToolCallBatch(host, executionId, snapshots)
|
|
3772
3349
|
);
|
|
3773
|
-
|
|
3774
|
-
let output: unknown;
|
|
3775
|
-
if (!execPromise) {
|
|
3776
|
-
output = {
|
|
3777
|
-
isError: true,
|
|
3778
|
-
content: [
|
|
3779
|
-
{ type: "text", text: "WebMCP not enabled on this widget." },
|
|
3780
|
-
],
|
|
3781
|
-
};
|
|
3782
|
-
} else {
|
|
3783
|
-
try {
|
|
3784
|
-
output = await execPromise;
|
|
3785
|
-
} catch (error) {
|
|
3786
|
-
const isAbortError =
|
|
3787
|
-
error instanceof Error &&
|
|
3788
|
-
(error.name === "AbortError" ||
|
|
3789
|
-
error.message.includes("aborted") ||
|
|
3790
|
-
error.message.includes("abort"));
|
|
3791
|
-
if (!isAbortError) {
|
|
3792
|
-
this.callbacks.onError?.(
|
|
3793
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
3794
|
-
);
|
|
3795
|
-
}
|
|
3796
|
-
this.markWebMcpToolComplete(
|
|
3797
|
-
toolMessage,
|
|
3798
|
-
buildWebMcpErrorResult(
|
|
3799
|
-
isAbortError
|
|
3800
|
-
? "Aborted by cancel()"
|
|
3801
|
-
: getWebMcpErrorMessage(error),
|
|
3802
|
-
),
|
|
3803
|
-
startedAt,
|
|
3804
|
-
);
|
|
3805
|
-
// Release the dedupe claim so a re-emit can retry this call.
|
|
3806
|
-
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3807
|
-
return null;
|
|
3808
|
-
}
|
|
3809
|
-
}
|
|
3810
|
-
if (batchController.signal.aborted) {
|
|
3811
|
-
this.markWebMcpToolComplete(
|
|
3812
|
-
toolMessage,
|
|
3813
|
-
buildWebMcpErrorResult("Aborted by cancel()"),
|
|
3814
|
-
startedAt,
|
|
3815
|
-
);
|
|
3816
|
-
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3817
|
-
return null;
|
|
3818
|
-
}
|
|
3819
|
-
return {
|
|
3820
|
-
dedupeKey,
|
|
3821
|
-
resumeKey,
|
|
3822
|
-
output,
|
|
3823
|
-
toolMessage,
|
|
3824
|
-
startedAt,
|
|
3825
|
-
completedAt: Date.now(),
|
|
3826
|
-
};
|
|
3827
|
-
};
|
|
3828
|
-
|
|
3829
|
-
// Parallel (default): a gated sibling's approval never blocks another's
|
|
3830
|
-
// execution. Sequential: one at a time in emission order, so tools that
|
|
3831
|
-
// share page state (a canvas, a form) don't interleave; the next call
|
|
3832
|
-
// starts only once the previous one has settled, approval included.
|
|
3833
|
-
let executed: Array<ExecutedWebMcpTool | null>;
|
|
3834
|
-
if (this.config.webmcp?.execution === "sequential") {
|
|
3835
|
-
executed = [];
|
|
3836
|
-
for (const toolMessage of snapshots) {
|
|
3837
|
-
executed.push(await executeOne(toolMessage));
|
|
3838
|
-
}
|
|
3839
|
-
} else {
|
|
3840
|
-
executed = await Promise.all(snapshots.map(executeOne));
|
|
3841
|
-
}
|
|
3842
|
-
|
|
3843
|
-
let ready: ExecutedWebMcpTool[] = [];
|
|
3844
|
-
try {
|
|
3845
|
-
ready = executed.filter((r): r is ExecutedWebMcpTool => r !== null);
|
|
3846
|
-
// Everything deduped/aborted/failed: nothing to post.
|
|
3847
|
-
if (ready.length === 0) return;
|
|
3848
|
-
|
|
3849
|
-
const toolOutputs: Record<string, unknown> = {};
|
|
3850
|
-
for (const r of ready) {
|
|
3851
|
-
// Two omitted-on-collision safety: if two calls somehow resolve to the
|
|
3852
|
-
// same key (only possible on a legacy name fallback), last write wins: // the server re-pauses the unrepresented call for retry.
|
|
3853
|
-
toolOutputs[r.resumeKey] = r.output;
|
|
3854
|
-
}
|
|
3855
|
-
|
|
3856
|
-
const response = await this.client.resumeFlow(executionId, toolOutputs, {
|
|
3857
|
-
signal: batchController.signal,
|
|
3858
|
-
after:
|
|
3859
|
-
this.resumable?.executionId === executionId
|
|
3860
|
-
? this.resumable.lastEventId
|
|
3861
|
-
: undefined,
|
|
3862
|
-
});
|
|
3863
|
-
if (!response.ok) {
|
|
3864
|
-
const errorData = await response.json().catch(() => null);
|
|
3865
|
-
throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
|
|
3866
|
-
}
|
|
3867
|
-
// Server accepted the batch: mark every included call resolved so stale
|
|
3868
|
-
// re-emits don't re-execute the page tool, then complete each bubble.
|
|
3869
|
-
// Do this only after /resume HTTP success; if /resume fails, the server
|
|
3870
|
-
// may still be paused and the retry path must not show a final result.
|
|
3871
|
-
const batch = ready[0]!.dedupeKey;
|
|
3872
|
-
for (const r of ready) {
|
|
3873
|
-
this.webMcpResolvedKeys.add(r.dedupeKey);
|
|
3874
|
-
const toolName = r.toolMessage.toolCall?.name;
|
|
3875
|
-
const toolCallId = r.toolMessage.agentMetadata?.webMcpToolCallId;
|
|
3876
|
-
this.markWebMcpToolComplete(
|
|
3877
|
-
r.toolMessage,
|
|
3878
|
-
r.output,
|
|
3879
|
-
r.startedAt,
|
|
3880
|
-
r.completedAt,
|
|
3881
|
-
{
|
|
3882
|
-
...(toolName === SUGGEST_REPLIES_TOOL_NAME
|
|
3883
|
-
? { suggestRepliesResolved: true }
|
|
3884
|
-
: {}),
|
|
3885
|
-
// Only a provider call id can be replayed: a legacy name-keyed
|
|
3886
|
-
// resume has nothing the model's transcript can reference.
|
|
3887
|
-
...(toolName && toolCallId
|
|
3888
|
-
? {
|
|
3889
|
-
clientToolAnswer: {
|
|
3890
|
-
toolCallId,
|
|
3891
|
-
toolName,
|
|
3892
|
-
args: r.toolMessage.toolCall?.args,
|
|
3893
|
-
result: r.output,
|
|
3894
|
-
batch,
|
|
3895
|
-
},
|
|
3896
|
-
}
|
|
3897
|
-
: {}),
|
|
3898
|
-
},
|
|
3899
|
-
);
|
|
3900
|
-
}
|
|
3901
|
-
if (response.body) {
|
|
3902
|
-
await this.connectStream(response.body, { allowReentry: true });
|
|
3903
|
-
}
|
|
3904
|
-
} catch (error) {
|
|
3905
|
-
const isAbortError =
|
|
3906
|
-
error instanceof Error &&
|
|
3907
|
-
(error.name === "AbortError" ||
|
|
3908
|
-
error.message.includes("aborted") ||
|
|
3909
|
-
error.message.includes("abort"));
|
|
3910
|
-
if (!isAbortError) {
|
|
3911
|
-
this.callbacks.onError?.(
|
|
3912
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
3913
|
-
);
|
|
3914
|
-
} else {
|
|
3915
|
-
for (const r of ready) {
|
|
3916
|
-
this.markWebMcpToolComplete(
|
|
3917
|
-
r.toolMessage,
|
|
3918
|
-
buildWebMcpErrorResult("Aborted by cancel()"),
|
|
3919
|
-
r.startedAt,
|
|
3920
|
-
);
|
|
3921
|
-
}
|
|
3922
|
-
}
|
|
3923
|
-
} finally {
|
|
3924
|
-
for (const key of claimedKeys) {
|
|
3925
|
-
this.webMcpInflightKeys.delete(key);
|
|
3926
|
-
}
|
|
3927
|
-
this.webMcpResolveControllers.delete(batchController);
|
|
3928
|
-
if (this.webMcpResolveControllers.size === 0 && !this.abortController) {
|
|
3929
|
-
this.setStreaming(false);
|
|
3930
|
-
}
|
|
3931
|
-
}
|
|
3932
|
-
}
|
|
3933
|
-
|
|
3934
|
-
/**
|
|
3935
|
-
* Resolve a paused auto-resolving LOCAL tool call and post the result to
|
|
3936
|
-
* `/resume`: `webmcp:*` calls execute against the host page's tool
|
|
3937
|
-
* registry; the built-in `suggest_replies` skips execution entirely and
|
|
3938
|
-
* resumes with a canned "shown" result (the chips render from the message
|
|
3939
|
-
* list, not from this resolve).
|
|
3940
|
-
*
|
|
3941
|
-
* Triggered automatically from `handleEvent` when an `await`-derived
|
|
3942
|
-
* message arrives for such a tool: the user does not click a pill; the
|
|
3943
|
-
* bridge's confirm-bubble gate (WebMCP only) is the only interactive
|
|
3944
|
-
* surface.
|
|
3945
|
-
*
|
|
3946
|
-
* Idempotent on the message's `toolCall.id`: re-emits of the same await
|
|
3947
|
-
* (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
|
|
3948
|
-
* modes, declined, timed out, throw, unknown tool, all resolve into a
|
|
3949
|
-
* `{ isError: true, content: [...] }` payload that resumes the dispatch
|
|
3950
|
-
* cleanly so the agent can recover.
|
|
3951
|
-
*
|
|
3952
|
-
* After the malformed-wire guards this is a thin gate over
|
|
3953
|
-
* `resolveWebMcpToolCallBatch` with a size-1 batch: the dedupe check runs
|
|
3954
|
-
* synchronously here so a stale re-emit never toggles streaming.
|
|
3955
|
-
*/
|
|
3956
|
-
public async resolveWebMcpToolCall(
|
|
3957
|
-
toolMessage: AgentWidgetMessage,
|
|
3958
|
-
): Promise<void> {
|
|
3959
|
-
const executionId = toolMessage.agentMetadata?.executionId;
|
|
3960
|
-
const wireToolName = toolMessage.toolCall?.name;
|
|
3961
|
-
const toolCallId = toolMessage.toolCall?.id;
|
|
3962
|
-
|
|
3963
|
-
// Malformed await wire shapes shouldn't silently strand the
|
|
3964
|
-
// server-side dispatch. Three failure modes:
|
|
3965
|
-
// - no executionId: no /resume target exists; surface to the host
|
|
3966
|
-
// via onError so an operator can react. This is a server-side
|
|
3967
|
-
// wire-shape bug: Persona can't recover it from the client.
|
|
3968
|
-
// - no wireToolName: defensive guard: handleEvent only calls us
|
|
3969
|
-
// for an auto-resolving local tool name (`webmcp:*` or
|
|
3970
|
-
// `suggest_replies`), so this path indicates a direct caller
|
|
3971
|
-
// misuse. Silent return.
|
|
3972
|
-
// - no toolCallId: dedupe key falls apart, but the server can still
|
|
3973
|
-
// advance if we post an isError for the wireToolName. Do that
|
|
3974
|
-
// and bail before the dedupe path.
|
|
3975
|
-
if (!executionId) {
|
|
3976
|
-
this.callbacks.onError?.(
|
|
3977
|
-
new Error(
|
|
3978
|
-
"WebMCP await missing executionId: dispatch left paused.",
|
|
3979
|
-
),
|
|
3980
|
-
);
|
|
3981
|
-
return;
|
|
3982
|
-
}
|
|
3983
|
-
if (!wireToolName) return;
|
|
3984
|
-
if (!toolCallId) {
|
|
3985
|
-
// No toolCall.id → no per-call dedupe key. Fall back to a synthetic
|
|
3986
|
-
// `(executionId):(wireToolName)` so identical malformed re-emits don't
|
|
3987
|
-
// re-POST /resume. Idempotent on duplicate bad payloads.
|
|
3988
|
-
const malformedKey = `${executionId}:__no_tool_id__:${wireToolName}`;
|
|
3989
|
-
if (
|
|
3990
|
-
this.webMcpInflightKeys.has(malformedKey) ||
|
|
3991
|
-
this.webMcpResolvedKeys.has(malformedKey)
|
|
3992
|
-
) {
|
|
3993
|
-
return;
|
|
3994
|
-
}
|
|
3995
|
-
this.webMcpInflightKeys.add(malformedKey);
|
|
3996
|
-
try {
|
|
3997
|
-
await this.resumeWithToolOutput(executionId, wireToolName, {
|
|
3998
|
-
isError: true,
|
|
3999
|
-
content: [
|
|
4000
|
-
{
|
|
4001
|
-
type: "text",
|
|
4002
|
-
text: "WebMCP await missing toolCall.id: cannot execute the page tool.",
|
|
4003
|
-
},
|
|
4004
|
-
],
|
|
4005
|
-
});
|
|
4006
|
-
this.webMcpResolvedKeys.add(malformedKey);
|
|
4007
|
-
} catch (error) {
|
|
4008
|
-
this.callbacks.onError?.(
|
|
4009
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
4010
|
-
);
|
|
4011
|
-
} finally {
|
|
4012
|
-
this.webMcpInflightKeys.delete(malformedKey);
|
|
4013
|
-
}
|
|
4014
|
-
return;
|
|
4015
|
-
}
|
|
4016
|
-
|
|
4017
|
-
// Dedupe key scoped by executionId: see `webMcpInflightKeys` doc comment
|
|
4018
|
-
// for the failure-recovery + cross-dispatch rationale. The persisted
|
|
4019
|
-
// `suggestRepliesResolved` guard backs the in-memory sets across
|
|
4020
|
-
// hydrations. Checked synchronously HERE so a stale re-emit stays a pure
|
|
4021
|
-
// no-op (no streaming toggle, no controller registration); the batch path
|
|
4022
|
-
// re-checks under its own inflight claim.
|
|
4023
|
-
const dedupeKey = `${executionId}:${toolCallId}`;
|
|
4024
|
-
if (
|
|
4025
|
-
this.webMcpInflightKeys.has(dedupeKey) ||
|
|
4026
|
-
this.webMcpResolvedKeys.has(dedupeKey) ||
|
|
4027
|
-
this.isSuggestRepliesAlreadyResolved(toolMessage)
|
|
4028
|
-
) {
|
|
4029
|
-
return;
|
|
4030
|
-
}
|
|
4031
|
-
return this.resolveWebMcpToolCallBatch(executionId, [toolMessage]);
|
|
4032
|
-
}
|
|
4033
|
-
|
|
4034
|
-
/**
|
|
4035
|
-
* POST `/resume` with a SINGLE tool's output and pipe the resulting SSE
|
|
4036
|
-
* stream back through `connectStream`. Shared by every single-call local-tool
|
|
4037
|
-
* resolve path (ask_user_question and single WebMCP calls). Parallel WebMCP
|
|
4038
|
-
* calls use `resolveWebMcpToolCallBatch`, which posts one resume for many.
|
|
4039
|
-
*
|
|
4040
|
-
* `resumeKey` is the `toolOutputs` map key: the per-call `webMcpToolCallId`
|
|
4041
|
-
* for WebMCP (core#3878), or the tool name for ask_user_question / legacy
|
|
4042
|
-
* servers. `onHttpOk` runs synchronously between the HTTP-status check and the
|
|
4043
|
-
* stream pipe; it lets the WebMCP resolve path commit the dedupe flag at
|
|
4044
|
-
* "server accepted the answer" rather than "stream finished cleanly".
|
|
4045
|
-
*/
|
|
4046
|
-
private async resumeWithToolOutput(
|
|
4047
|
-
executionId: string,
|
|
4048
|
-
resumeKey: string,
|
|
4049
|
-
output: unknown,
|
|
4050
|
-
options?: { onHttpOk?: () => void; signal?: AbortSignal },
|
|
4051
|
-
): Promise<void> {
|
|
4052
|
-
const response = await this.client.resumeFlow(
|
|
4053
|
-
executionId,
|
|
4054
|
-
{ [resumeKey]: output },
|
|
4055
|
-
{
|
|
4056
|
-
signal: options?.signal,
|
|
4057
|
-
after:
|
|
4058
|
-
this.resumable?.executionId === executionId
|
|
4059
|
-
? this.resumable.lastEventId
|
|
4060
|
-
: undefined,
|
|
4061
|
-
},
|
|
4062
|
-
);
|
|
4063
|
-
if (!response.ok) {
|
|
4064
|
-
const errorData = await response.json().catch(() => null);
|
|
4065
|
-
throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
|
|
4066
|
-
}
|
|
4067
|
-
options?.onHttpOk?.();
|
|
4068
|
-
if (response.body) {
|
|
4069
|
-
await this.connectStream(response.body, { allowReentry: true });
|
|
4070
|
-
} else if (this.webMcpResolveControllers.size === 0) {
|
|
4071
|
-
// No stream to pipe. Clear streaming only when no WebMCP resolve is in
|
|
4072
|
-
// flight: for a WebMCP caller the current resolve's controller is still
|
|
4073
|
-
// in the set, so its own `finally` (gated on the set draining) owns the
|
|
4074
|
-
// teardown. Non-WebMCP callers (ask_user_question) keep the old behavior.
|
|
4075
|
-
this.setStreaming(false);
|
|
4076
|
-
this.abortController = null;
|
|
4077
3350
|
}
|
|
4078
3351
|
}
|
|
4079
3352
|
|
|
@@ -4086,7 +3359,7 @@ export class AgentWidgetSession {
|
|
|
4086
3359
|
* controller, so it can't escape this teardown. Called from every stop /
|
|
4087
3360
|
* new-turn boundary (cancel, clearMessages, hydrateMessages, sendMessage).
|
|
4088
3361
|
*/
|
|
4089
|
-
|
|
3362
|
+
#abortWebMcpResolves(): void {
|
|
4090
3363
|
for (const controller of this.webMcpResolveControllers) {
|
|
4091
3364
|
controller.abort();
|
|
4092
3365
|
}
|
|
@@ -4110,7 +3383,7 @@ export class AgentWidgetSession {
|
|
|
4110
3383
|
// being torn down, and a microtask-deferred flush must not survive. The
|
|
4111
3384
|
// epoch bump below also strands an already-scheduled flush.
|
|
4112
3385
|
this.webMcpAwaitBatches.clear();
|
|
4113
|
-
this
|
|
3386
|
+
this.#webMcpEpoch++;
|
|
4114
3387
|
}
|
|
4115
3388
|
|
|
4116
3389
|
public cancel() {
|
|
@@ -4118,32 +3391,34 @@ export class AgentWidgetSession {
|
|
|
4118
3391
|
this.abortController = null;
|
|
4119
3392
|
// A user stop also cancels any pending/in-flight durable reconnect and
|
|
4120
3393
|
// clears the resume handle (the abort above already killed its fetch).
|
|
4121
|
-
this
|
|
3394
|
+
this.#teardownReconnect();
|
|
4122
3395
|
// Tear down every in-flight WebMCP resolve (each owns its own controller,
|
|
4123
3396
|
// independent of the shared one above). Clear the inflight set so retries
|
|
4124
3397
|
// are possible if the user re-issues the same await context.
|
|
4125
|
-
this
|
|
3398
|
+
this.#abortWebMcpResolves();
|
|
3399
|
+
this.#actionsEpoch++;
|
|
4126
3400
|
this.webMcpInflightKeys.clear();
|
|
4127
3401
|
// Stop any in-progress audio too: when the user hits "stop", they want
|
|
4128
3402
|
// the assistant to actually stop talking, not just stop generating tokens.
|
|
4129
3403
|
// Both helpers are safe no-ops when audio isn't configured.
|
|
4130
3404
|
this.stopSpeaking();
|
|
4131
3405
|
this.stopVoicePlayback();
|
|
4132
|
-
this
|
|
4133
|
-
this
|
|
3406
|
+
this.#setStreaming(false);
|
|
3407
|
+
this.#setStatus("idle");
|
|
4134
3408
|
}
|
|
4135
3409
|
|
|
4136
3410
|
public clearMessages() {
|
|
4137
3411
|
this.stopSpeaking();
|
|
4138
3412
|
this.abortController?.abort();
|
|
4139
3413
|
this.abortController = null;
|
|
4140
|
-
this
|
|
3414
|
+
this.#teardownReconnect();
|
|
4141
3415
|
// Tear down every in-flight WebMCP resolve too: their messages are about
|
|
4142
3416
|
// to be wiped, and a microtask-deferred resolve must not survive the clear.
|
|
4143
|
-
this
|
|
3417
|
+
this.#abortWebMcpResolves();
|
|
3418
|
+
this.#actionsEpoch++;
|
|
4144
3419
|
this.messages = [];
|
|
4145
3420
|
this.agentExecution = null;
|
|
4146
|
-
this
|
|
3421
|
+
this.#clearArtifactState();
|
|
4147
3422
|
// Clearing messages also wipes the WebMCP dedupe state: a fresh
|
|
4148
3423
|
// conversation should not refuse to call a webmcp:* tool just because
|
|
4149
3424
|
// a tool with the same key resolved in the prior conversation.
|
|
@@ -4153,8 +3428,8 @@ export class AgentWidgetSession {
|
|
|
4153
3428
|
// turn: drop the diff-only fingerprint cache (server keys by recordId, so
|
|
4154
3429
|
// a new conversation has no stored set to match).
|
|
4155
3430
|
this.client.resetClientToolsFingerprint();
|
|
4156
|
-
this
|
|
4157
|
-
this
|
|
3431
|
+
this.#setStreaming(false);
|
|
3432
|
+
this.#setStatus("idle");
|
|
4158
3433
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4159
3434
|
}
|
|
4160
3435
|
|
|
@@ -4167,16 +3442,16 @@ export class AgentWidgetSession {
|
|
|
4167
3442
|
}
|
|
4168
3443
|
|
|
4169
3444
|
public getSelectedArtifactId(): string | null {
|
|
4170
|
-
return this
|
|
3445
|
+
return this.#selectedArtifactId;
|
|
4171
3446
|
}
|
|
4172
3447
|
|
|
4173
3448
|
public selectArtifact(id: string | null): void {
|
|
4174
|
-
this
|
|
4175
|
-
this
|
|
3449
|
+
this.#selectedArtifactId = id;
|
|
3450
|
+
this.#emitArtifactsState();
|
|
4176
3451
|
}
|
|
4177
3452
|
|
|
4178
3453
|
public clearArtifacts(): void {
|
|
4179
|
-
this
|
|
3454
|
+
this.#clearArtifactState();
|
|
4180
3455
|
}
|
|
4181
3456
|
|
|
4182
3457
|
public upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord {
|
|
@@ -4204,10 +3479,10 @@ export class AgentWidgetSession {
|
|
|
4204
3479
|
...(manual.presentation ? { presentation: manual.presentation } : {})
|
|
4205
3480
|
};
|
|
4206
3481
|
this.artifacts.set(id, rec);
|
|
4207
|
-
this
|
|
4208
|
-
this
|
|
3482
|
+
this.#selectedArtifactId = id;
|
|
3483
|
+
this.#emitArtifactsState();
|
|
4209
3484
|
if (manual.transcript !== false) {
|
|
4210
|
-
this
|
|
3485
|
+
this.#injectArtifactRefBlock(rec);
|
|
4211
3486
|
}
|
|
4212
3487
|
return rec;
|
|
4213
3488
|
}
|
|
@@ -4228,7 +3503,7 @@ export class AgentWidgetSession {
|
|
|
4228
3503
|
* these props); without this the block would keep the first version's
|
|
4229
3504
|
* content after a refresh.
|
|
4230
3505
|
*/
|
|
4231
|
-
|
|
3506
|
+
#injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
|
|
4232
3507
|
const refId = `artifact-ref-${rec.id}`;
|
|
4233
3508
|
const displayMode = resolveArtifactDisplayMode(
|
|
4234
3509
|
this.config.features?.artifacts,
|
|
@@ -4266,7 +3541,7 @@ export class AgentWidgetSession {
|
|
|
4266
3541
|
* Re-materialize existing transcript artifact blocks after a live display
|
|
4267
3542
|
* preference update. One messages callback keeps conversion atomic.
|
|
4268
3543
|
*/
|
|
4269
|
-
|
|
3544
|
+
#refreshArtifactReferenceBlocks(): void {
|
|
4270
3545
|
let changed = false;
|
|
4271
3546
|
for (const rec of this.artifacts.values()) {
|
|
4272
3547
|
const message = this.messages.find((candidate) => candidate.id === `artifact-ref-${rec.id}`);
|
|
@@ -4294,21 +3569,21 @@ export class AgentWidgetSession {
|
|
|
4294
3569
|
if (changed) this.callbacks.onMessagesChanged([...this.messages]);
|
|
4295
3570
|
}
|
|
4296
3571
|
|
|
4297
|
-
|
|
4298
|
-
if (this.artifacts.size === 0 && this
|
|
3572
|
+
#clearArtifactState(): void {
|
|
3573
|
+
if (this.artifacts.size === 0 && this.#selectedArtifactId === null) return;
|
|
4299
3574
|
this.artifacts.clear();
|
|
4300
|
-
this
|
|
4301
|
-
this
|
|
3575
|
+
this.#selectedArtifactId = null;
|
|
3576
|
+
this.#emitArtifactsState();
|
|
4302
3577
|
}
|
|
4303
3578
|
|
|
4304
|
-
|
|
3579
|
+
#emitArtifactsState(): void {
|
|
4305
3580
|
this.callbacks.onArtifactsState?.({
|
|
4306
3581
|
artifacts: [...this.artifacts.values()],
|
|
4307
|
-
selectedId: this
|
|
3582
|
+
selectedId: this.#selectedArtifactId
|
|
4308
3583
|
});
|
|
4309
3584
|
}
|
|
4310
3585
|
|
|
4311
|
-
|
|
3586
|
+
#applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
|
|
4312
3587
|
switch (ev.type) {
|
|
4313
3588
|
case "artifact_start": {
|
|
4314
3589
|
if (ev.artifactType === "markdown") {
|
|
@@ -4330,7 +3605,7 @@ export class AgentWidgetSession {
|
|
|
4330
3605
|
props: {}
|
|
4331
3606
|
});
|
|
4332
3607
|
}
|
|
4333
|
-
this
|
|
3608
|
+
this.#selectedArtifactId = ev.id;
|
|
4334
3609
|
break;
|
|
4335
3610
|
}
|
|
4336
3611
|
case "artifact_delta": {
|
|
@@ -4356,7 +3631,7 @@ export class AgentWidgetSession {
|
|
|
4356
3631
|
default:
|
|
4357
3632
|
return;
|
|
4358
3633
|
}
|
|
4359
|
-
this
|
|
3634
|
+
this.#emitArtifactsState();
|
|
4360
3635
|
}
|
|
4361
3636
|
|
|
4362
3637
|
public hydrateMessages(messages: AgentWidgetMessage[]) {
|
|
@@ -4364,23 +3639,24 @@ export class AgentWidgetSession {
|
|
|
4364
3639
|
this.abortController = null;
|
|
4365
3640
|
// Hydration replaces the conversation: also cancel any pending reconnect and
|
|
4366
3641
|
// clear the resume handle (a boot resume re-arms via resumeFromHandle after).
|
|
4367
|
-
this
|
|
3642
|
+
this.#teardownReconnect();
|
|
4368
3643
|
// Hydration replaces the conversation: abort and forget every in-flight
|
|
4369
3644
|
// WebMCP resolve; their messages are about to be replaced.
|
|
4370
|
-
this
|
|
3645
|
+
this.#abortWebMcpResolves();
|
|
3646
|
+
this.#actionsEpoch++;
|
|
4371
3647
|
// Wipe the WebMCP dedupe state alongside the message restore: the
|
|
4372
3648
|
// incoming snapshot is treated as a fresh conversation context.
|
|
4373
3649
|
this.webMcpInflightKeys.clear();
|
|
4374
3650
|
this.webMcpResolvedKeys.clear();
|
|
4375
|
-
this.messages = this
|
|
3651
|
+
this.messages = this.#sortMessages(
|
|
4376
3652
|
messages.map((message) => ({
|
|
4377
3653
|
...message,
|
|
4378
3654
|
streaming: false,
|
|
4379
|
-
sequence: message.sequence ?? this
|
|
3655
|
+
sequence: message.sequence ?? this.#nextSequence()
|
|
4380
3656
|
}))
|
|
4381
3657
|
);
|
|
4382
|
-
this
|
|
4383
|
-
this
|
|
3658
|
+
this.#setStreaming(false);
|
|
3659
|
+
this.#setStatus("idle");
|
|
4384
3660
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4385
3661
|
}
|
|
4386
3662
|
|
|
@@ -4392,14 +3668,37 @@ export class AgentWidgetSession {
|
|
|
4392
3668
|
for (const rec of artifacts) {
|
|
4393
3669
|
this.artifacts.set(rec.id, { ...rec, status: "complete" });
|
|
4394
3670
|
}
|
|
4395
|
-
this
|
|
4396
|
-
this
|
|
3671
|
+
this.#selectedArtifactId = selectedId;
|
|
3672
|
+
this.#emitArtifactsState();
|
|
4397
3673
|
}
|
|
4398
3674
|
|
|
4399
3675
|
private handleEvent = (event: AgentWidgetEvent) => {
|
|
3676
|
+
const delegation = this.#voiceDelegation;
|
|
3677
|
+
if (delegation) {
|
|
3678
|
+
if (event.type === "error" || (event.type === "status" && event.status === "error")) {
|
|
3679
|
+
delegation.failed = true;
|
|
3680
|
+
} else if (event.type === "message" && event.message.role === "assistant") {
|
|
3681
|
+
// The voice model reads this answer aloud: browser TTS must skip it,
|
|
3682
|
+
// unless the server dropped the result (then nobody speaks it).
|
|
3683
|
+
if (!delegation.dropped) this.ttsSpokenMessageIds.add(event.message.id);
|
|
3684
|
+
if (!delegation.ids.includes(event.message.id)) delegation.ids.push(event.message.id);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
4400
3687
|
if (event.type === "message") {
|
|
4401
3688
|
this.upsertMessage(event.message);
|
|
4402
3689
|
|
|
3690
|
+
// A resolved approval reaches this handler only from the server's
|
|
3691
|
+
// `approval_complete` (resolveApproval's optimistic update upserts
|
|
3692
|
+
// directly). The server may now finish the paused call itself on this
|
|
3693
|
+
// stream, so the bubble settles at stream end only if it is still open.
|
|
3694
|
+
if (
|
|
3695
|
+
event.message.variant === "approval" &&
|
|
3696
|
+
event.message.approval &&
|
|
3697
|
+
event.message.approval.status !== "pending"
|
|
3698
|
+
) {
|
|
3699
|
+
this.#approvalSettlesAtStreamEnd.set(event.message.id, this.#activeStreamToken);
|
|
3700
|
+
}
|
|
3701
|
+
|
|
4403
3702
|
// Track the open assistant text bubble's REAL streamed id so a durable
|
|
4404
3703
|
// reconnect keeps filling the same message. The proxy path auto-generates
|
|
4405
3704
|
// this id (it isn't the session's pre-generated one), so we must read it
|
|
@@ -4409,7 +3708,7 @@ export class AgentWidgetSession {
|
|
|
4409
3708
|
!event.message.variant &&
|
|
4410
3709
|
event.message.streaming
|
|
4411
3710
|
) {
|
|
4412
|
-
this
|
|
3711
|
+
this.#activeAssistantMessageId = event.message.id;
|
|
4413
3712
|
}
|
|
4414
3713
|
|
|
4415
3714
|
// Local-tool auto-resolve: when a await emits a tool-variant
|
|
@@ -4436,6 +3735,16 @@ export class AgentWidgetSession {
|
|
|
4436
3735
|
// the resolved `enabled` is false the widget neither renders chips nor
|
|
4437
3736
|
// resumes: the same parked-execution posture as a server-declared
|
|
4438
3737
|
// ask_user_question with its sheet disabled.
|
|
3738
|
+
// The agent paused for an approval or a local tool: warm the resolve
|
|
3739
|
+
// paths (the lazy `session-actions.js` chunk in the IIFE build) so the
|
|
3740
|
+
// resume doesn't wait on a fetch.
|
|
3741
|
+
if (
|
|
3742
|
+
event.message.variant === "approval" ||
|
|
3743
|
+
event.message.agentMetadata?.awaitingLocalTool === true
|
|
3744
|
+
) {
|
|
3745
|
+
loadSessionActions().catch(() => {});
|
|
3746
|
+
}
|
|
3747
|
+
|
|
4439
3748
|
const tc = event.message.toolCall;
|
|
4440
3749
|
const autoResolvable =
|
|
4441
3750
|
!!tc?.name &&
|
|
@@ -4450,7 +3759,7 @@ export class AgentWidgetSession {
|
|
|
4450
3759
|
// it on the spot. Parallel same-tool calls (core#3878) arrive as
|
|
4451
3760
|
// separate `await`s in the same stream; batching lets us post ONE
|
|
4452
3761
|
// `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
|
|
4453
|
-
this
|
|
3762
|
+
this.#enqueueWebMcpAwait(event.message);
|
|
4454
3763
|
}
|
|
4455
3764
|
|
|
4456
3765
|
// Track agent execution state from message metadata
|
|
@@ -4473,35 +3782,35 @@ export class AgentWidgetSession {
|
|
|
4473
3782
|
// cursor and (lazily) form the resume handle once the executionId is
|
|
4474
3783
|
// known. Only durable, resumable executions emit these, so this is the
|
|
4475
3784
|
// natural gate: no `id:` lines → no handle → reconnect never arms.
|
|
4476
|
-
this
|
|
3785
|
+
this.#trackCursor(event.id);
|
|
4477
3786
|
} else if (event.type === "status") {
|
|
4478
3787
|
// A plain `idle` (no `terminal`) on the durable lane, while the run is
|
|
4479
3788
|
// genuinely mid-turn, is a dropped connection, not a finish and not an
|
|
4480
3789
|
// intentional `await` pause. Reconnect instead of finalizing.
|
|
4481
|
-
if (event.status === "idle" && !event.terminal && this
|
|
4482
|
-
this
|
|
3790
|
+
if (event.status === "idle" && !event.terminal && this.#isDurableDrop()) {
|
|
3791
|
+
this.#beginReconnect();
|
|
4483
3792
|
return;
|
|
4484
3793
|
}
|
|
4485
|
-
this
|
|
3794
|
+
this.#setStatus(event.status);
|
|
4486
3795
|
if (event.status === "connecting") {
|
|
4487
|
-
this
|
|
3796
|
+
this.#setStreaming(true);
|
|
4488
3797
|
} else if (event.status === "idle" || event.status === "error") {
|
|
4489
3798
|
// A client-tool pause is an intentional stream end but NOT a terminal:
|
|
4490
3799
|
// retain its cursor so `/client/resume` can attach after the await
|
|
4491
3800
|
// frame instead of replaying the whole turn. Real terminals and
|
|
4492
3801
|
// ordinary non-durable idles still clear the handle.
|
|
4493
|
-
if (!this
|
|
3802
|
+
if (!this.#isAwaitPending()) this.#clearResumable();
|
|
4494
3803
|
// Keep the typing indicator up while a WebMCP resolve is still in
|
|
4495
3804
|
// flight: in a chained turn the intermediate resume stream ends with an
|
|
4496
3805
|
// idle status, but the successor tool is still executing. The resolve's
|
|
4497
3806
|
// own `finally` flips streaming off once the resolve set drains.
|
|
4498
3807
|
if (this.webMcpResolveControllers.size === 0) {
|
|
4499
|
-
this
|
|
3808
|
+
this.#setStreaming(false);
|
|
4500
3809
|
this.abortController = null;
|
|
4501
3810
|
// The turn mutated the record and the chat response carries no
|
|
4502
3811
|
// revision; finalize any browser-only projection right after.
|
|
4503
|
-
this
|
|
4504
|
-
this
|
|
3812
|
+
this.#invalidateConversationRevision();
|
|
3813
|
+
this.#scheduleProjectionFinalization();
|
|
4505
3814
|
}
|
|
4506
3815
|
// Mark agent execution as complete when streaming ends: UNLESS local
|
|
4507
3816
|
// tools are still outstanding. A batched WebMCP resume is deferred to
|
|
@@ -4525,19 +3834,19 @@ export class AgentWidgetSession {
|
|
|
4525
3834
|
// batched `/resume` per executionId (deferred: see
|
|
4526
3835
|
// scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
|
|
4527
3836
|
// doesn't fight the end-of-stream streaming/abortController reset.
|
|
4528
|
-
this
|
|
3837
|
+
this.#scheduleWebMcpBatchFlush();
|
|
4529
3838
|
}
|
|
4530
3839
|
} else if (event.type === "error") {
|
|
4531
|
-
this
|
|
3840
|
+
this.#setStatus("error");
|
|
4532
3841
|
// Terminal error: drop the resume handle so no reconnect arms.
|
|
4533
|
-
this
|
|
3842
|
+
this.#clearResumable();
|
|
4534
3843
|
// Mirror the idle/status handler: don't tear down streaming while a
|
|
4535
3844
|
// WebMCP resolve is still confirming/executing on another stream: an
|
|
4536
3845
|
// error on one chained resume stream must not hide the typing indicator
|
|
4537
3846
|
// (or null a controller) for a sibling/successor resolve still in flight.
|
|
4538
3847
|
// The resolve's own `finally` flips streaming off once the set drains.
|
|
4539
3848
|
if (this.webMcpResolveControllers.size === 0) {
|
|
4540
|
-
this
|
|
3849
|
+
this.#setStreaming(false);
|
|
4541
3850
|
this.abortController = null;
|
|
4542
3851
|
}
|
|
4543
3852
|
if (this.agentExecution?.status === 'running') {
|
|
@@ -4550,7 +3859,7 @@ export class AgentWidgetSession {
|
|
|
4550
3859
|
event.type === "artifact_update" ||
|
|
4551
3860
|
event.type === "artifact_complete"
|
|
4552
3861
|
) {
|
|
4553
|
-
this
|
|
3862
|
+
this.#applyArtifactStreamEvent(event);
|
|
4554
3863
|
}
|
|
4555
3864
|
};
|
|
4556
3865
|
|
|
@@ -4562,24 +3871,24 @@ export class AgentWidgetSession {
|
|
|
4562
3871
|
* frame's metadata, handled before this trailing cursor event) and the active
|
|
4563
3872
|
* assistant message id.
|
|
4564
3873
|
*/
|
|
4565
|
-
|
|
3874
|
+
#trackCursor(id: string): void {
|
|
4566
3875
|
const executionId = this.agentExecution?.executionId;
|
|
4567
|
-
if (!executionId || !this
|
|
3876
|
+
if (!executionId || !this.#activeAssistantMessageId) return;
|
|
4568
3877
|
// Only track while the run is live. A graceful terminal sets the execution
|
|
4569
3878
|
// to 'complete'/'error' and clears the handle BEFORE its own frame's
|
|
4570
3879
|
// trailing cursor fires; without this guard that cursor would re-arm the
|
|
4571
3880
|
// handle and the next plain `idle` would look like a spurious drop.
|
|
4572
3881
|
if (this.agentExecution?.status !== "running") return;
|
|
4573
|
-
const isNew = this
|
|
4574
|
-
this
|
|
3882
|
+
const isNew = this.#resumable === null;
|
|
3883
|
+
this.#resumable = {
|
|
4575
3884
|
executionId,
|
|
4576
3885
|
lastEventId: id,
|
|
4577
|
-
assistantMessageId: this
|
|
3886
|
+
assistantMessageId: this.#activeAssistantMessageId,
|
|
4578
3887
|
status: "running",
|
|
4579
3888
|
};
|
|
4580
3889
|
// Fire immediately when the handle first appears (so the host can persist
|
|
4581
3890
|
// the executionId promptly); throttle subsequent cursor advances.
|
|
4582
|
-
this
|
|
3891
|
+
this.#notifyExecutionState(isNew);
|
|
4583
3892
|
}
|
|
4584
3893
|
|
|
4585
3894
|
/**
|
|
@@ -4587,14 +3896,14 @@ export class AgentWidgetSession {
|
|
|
4587
3896
|
* rather than a graceful finish or an intentional `await` pause? ALL must
|
|
4588
3897
|
* hold (see plan §Design overview keystone 3).
|
|
4589
3898
|
*/
|
|
4590
|
-
|
|
3899
|
+
#isDurableDrop(): boolean {
|
|
4591
3900
|
return (
|
|
4592
|
-
this
|
|
3901
|
+
this.#resumable !== null &&
|
|
4593
3902
|
typeof this.config.reconnectStream === "function" &&
|
|
4594
3903
|
this.abortController?.signal.aborted !== true &&
|
|
4595
3904
|
this.webMcpResolveControllers.size === 0 &&
|
|
4596
3905
|
this.webMcpAwaitBatches.size === 0 &&
|
|
4597
|
-
!this
|
|
3906
|
+
!this.#isAwaitPending()
|
|
4598
3907
|
);
|
|
4599
3908
|
}
|
|
4600
3909
|
|
|
@@ -4603,7 +3912,7 @@ export class AgentWidgetSession {
|
|
|
4603
3912
|
* `ask_user_question` / local-tool await, or a pending approval). Those end
|
|
4604
3913
|
* the stream with `idle` and no terminal too, but are NOT drops.
|
|
4605
3914
|
*/
|
|
4606
|
-
|
|
3915
|
+
#isAwaitPending(): boolean {
|
|
4607
3916
|
return this.messages.some((m) => {
|
|
4608
3917
|
if (
|
|
4609
3918
|
m.agentMetadata?.awaitingLocalTool === true &&
|
|
@@ -4623,22 +3932,22 @@ export class AgentWidgetSession {
|
|
|
4623
3932
|
* true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
|
|
4624
3933
|
* calls (a second drop while reconnecting) are no-ops.
|
|
4625
3934
|
*/
|
|
4626
|
-
|
|
4627
|
-
if (this
|
|
4628
|
-
if (!this
|
|
3935
|
+
#beginReconnect(): void {
|
|
3936
|
+
if (this.#reconnecting) return;
|
|
3937
|
+
if (!this.#resumable || typeof this.config.reconnectStream !== "function") {
|
|
4629
3938
|
return;
|
|
4630
3939
|
}
|
|
4631
3940
|
// Flip the visible state synchronously — the bubble stays open and a
|
|
4632
3941
|
// `resuming` status is observable immediately (e.g. right after
|
|
4633
3942
|
// `resumeFromHandle`). The heavy backoff loop is loaded lazily below.
|
|
4634
|
-
this
|
|
4635
|
-
this.callbacks.onReconnect?.({ phase: "paused", handle: this
|
|
4636
|
-
this
|
|
4637
|
-
this
|
|
4638
|
-
void this
|
|
3943
|
+
this.#reconnecting = true;
|
|
3944
|
+
this.callbacks.onReconnect?.({ phase: "paused", handle: this.#resumable });
|
|
3945
|
+
this.#setStreaming(true);
|
|
3946
|
+
this.#setStatus("resuming");
|
|
3947
|
+
void this.#loadReconnectController().then((controller) => {
|
|
4639
3948
|
// The run may have been torn down (new turn / cancel / hydrate) while the
|
|
4640
3949
|
// module was loading; only start the loop if we're still reconnecting.
|
|
4641
|
-
if (this
|
|
3950
|
+
if (this.#reconnecting && this.#resumable) controller.begin();
|
|
4642
3951
|
});
|
|
4643
3952
|
}
|
|
4644
3953
|
|
|
@@ -4648,43 +3957,43 @@ export class AgentWidgetSession {
|
|
|
4648
3957
|
* every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
|
|
4649
3958
|
* that never reconnects never pulls the chunk.
|
|
4650
3959
|
*/
|
|
4651
|
-
|
|
4652
|
-
if (this
|
|
4653
|
-
return Promise.resolve(this
|
|
3960
|
+
#loadReconnectController(): Promise<ReconnectController> {
|
|
3961
|
+
if (this.#reconnectController) {
|
|
3962
|
+
return Promise.resolve(this.#reconnectController);
|
|
4654
3963
|
}
|
|
4655
|
-
if (!this
|
|
4656
|
-
this
|
|
3964
|
+
if (!this.#reconnectControllerPromise) {
|
|
3965
|
+
this.#reconnectControllerPromise = loadSessionReconnect().then(
|
|
4657
3966
|
({ createReconnectController }) => {
|
|
4658
3967
|
const controller = createReconnectController(
|
|
4659
|
-
this
|
|
3968
|
+
this.#buildReconnectHost()
|
|
4660
3969
|
);
|
|
4661
|
-
this
|
|
3970
|
+
this.#reconnectController = controller;
|
|
4662
3971
|
return controller;
|
|
4663
3972
|
}
|
|
4664
3973
|
);
|
|
4665
3974
|
// A failed chunk fetch must not poison the memoized promise: clear it so
|
|
4666
3975
|
// the next reconnect trigger retries the load.
|
|
4667
|
-
this
|
|
4668
|
-
this
|
|
3976
|
+
this.#reconnectControllerPromise.catch(() => {
|
|
3977
|
+
this.#reconnectControllerPromise = null;
|
|
4669
3978
|
});
|
|
4670
3979
|
}
|
|
4671
|
-
return this
|
|
3980
|
+
return this.#reconnectControllerPromise;
|
|
4672
3981
|
}
|
|
4673
3982
|
|
|
4674
3983
|
/** The narrow surface the lazily-loaded reconnect loop drives. */
|
|
4675
|
-
|
|
3984
|
+
#buildReconnectHost(): ReconnectHost {
|
|
4676
3985
|
const session = this;
|
|
4677
3986
|
return {
|
|
4678
3987
|
get config() {
|
|
4679
3988
|
return session.config;
|
|
4680
3989
|
},
|
|
4681
|
-
getResumable: () => session
|
|
4682
|
-
clearResumable: () => session
|
|
3990
|
+
getResumable: () => session.#resumable,
|
|
3991
|
+
clearResumable: () => session.#clearResumable(),
|
|
4683
3992
|
getStatus: () => session.status,
|
|
4684
|
-
setStatus: (status) => session
|
|
4685
|
-
setStreaming: (streaming) => session
|
|
3993
|
+
setStatus: (status) => session.#setStatus(status),
|
|
3994
|
+
setStreaming: (streaming) => session.#setStreaming(streaming),
|
|
4686
3995
|
setReconnecting: (value) => {
|
|
4687
|
-
session
|
|
3996
|
+
session.#reconnecting = value;
|
|
4688
3997
|
},
|
|
4689
3998
|
setAbortController: (controller) => {
|
|
4690
3999
|
session.abortController = controller;
|
|
@@ -4699,8 +4008,8 @@ export class AgentWidgetSession {
|
|
|
4699
4008
|
preserveAssistantId: true,
|
|
4700
4009
|
seedContent,
|
|
4701
4010
|
}),
|
|
4702
|
-
appendMessage: (message) => session
|
|
4703
|
-
nextSequence: () => session
|
|
4011
|
+
appendMessage: (message) => session.#appendMessage(message),
|
|
4012
|
+
nextSequence: () => session.#nextSequence(),
|
|
4704
4013
|
emitReconnect: (event) => session.callbacks.onReconnect?.(event),
|
|
4705
4014
|
buildErrorContent: (message) =>
|
|
4706
4015
|
buildDispatchErrorContent(
|
|
@@ -4713,13 +4022,13 @@ export class AgentWidgetSession {
|
|
|
4713
4022
|
|
|
4714
4023
|
/** Public manual retry (e.g. a "Reconnect" button). */
|
|
4715
4024
|
public reconnectNow(): void {
|
|
4716
|
-
if (this
|
|
4025
|
+
if (this.#reconnecting) {
|
|
4717
4026
|
// Already trying, so just short-circuit the current backoff. (No-op if the
|
|
4718
4027
|
// controller is still loading; the first attempt hasn't slept yet.)
|
|
4719
|
-
this
|
|
4028
|
+
this.#reconnectController?.wake();
|
|
4720
4029
|
return;
|
|
4721
4030
|
}
|
|
4722
|
-
this
|
|
4031
|
+
this.#beginReconnect();
|
|
4723
4032
|
}
|
|
4724
4033
|
|
|
4725
4034
|
/**
|
|
@@ -4729,21 +4038,21 @@ export class AgentWidgetSession {
|
|
|
4729
4038
|
*/
|
|
4730
4039
|
public resumeFromHandle(resume: { executionId: string; after: string }): void {
|
|
4731
4040
|
if (typeof this.config.reconnectStream !== "function") return;
|
|
4732
|
-
if (this
|
|
4041
|
+
if (this.#reconnecting) return;
|
|
4733
4042
|
|
|
4734
|
-
let assistantId = this
|
|
4043
|
+
let assistantId = this.#reopenTrailingAssistant();
|
|
4735
4044
|
if (!assistantId) {
|
|
4736
4045
|
assistantId = generateAssistantMessageId();
|
|
4737
|
-
this
|
|
4046
|
+
this.#appendMessage({
|
|
4738
4047
|
id: assistantId,
|
|
4739
4048
|
role: "assistant",
|
|
4740
4049
|
content: "",
|
|
4741
4050
|
createdAt: new Date().toISOString(),
|
|
4742
4051
|
streaming: true,
|
|
4743
|
-
sequence: this
|
|
4052
|
+
sequence: this.#nextSequence(),
|
|
4744
4053
|
});
|
|
4745
4054
|
}
|
|
4746
|
-
this
|
|
4055
|
+
this.#activeAssistantMessageId = assistantId;
|
|
4747
4056
|
if (!this.agentExecution) {
|
|
4748
4057
|
this.agentExecution = {
|
|
4749
4058
|
executionId: resume.executionId,
|
|
@@ -4754,13 +4063,13 @@ export class AgentWidgetSession {
|
|
|
4754
4063
|
maxTurns: 0,
|
|
4755
4064
|
};
|
|
4756
4065
|
}
|
|
4757
|
-
this
|
|
4066
|
+
this.#resumable = {
|
|
4758
4067
|
executionId: resume.executionId,
|
|
4759
4068
|
lastEventId: resume.after,
|
|
4760
4069
|
assistantMessageId: assistantId,
|
|
4761
4070
|
status: "running",
|
|
4762
4071
|
};
|
|
4763
|
-
this
|
|
4072
|
+
this.#beginReconnect();
|
|
4764
4073
|
}
|
|
4765
4074
|
|
|
4766
4075
|
/**
|
|
@@ -4768,7 +4077,7 @@ export class AgentWidgetSession {
|
|
|
4768
4077
|
* deltas append to it. Returns its id, or null if the last turn doesn't end
|
|
4769
4078
|
* in a plain assistant message.
|
|
4770
4079
|
*/
|
|
4771
|
-
|
|
4080
|
+
#reopenTrailingAssistant(): string | null {
|
|
4772
4081
|
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
4773
4082
|
const m = this.messages[i];
|
|
4774
4083
|
if (m.role === "assistant" && !m.variant) {
|
|
@@ -4786,20 +4095,20 @@ export class AgentWidgetSession {
|
|
|
4786
4095
|
* on every new turn / cancel / hydrate, so it must NOT pull the reconnect
|
|
4787
4096
|
* chunk: it only delegates to the controller if one was already created.
|
|
4788
4097
|
*/
|
|
4789
|
-
|
|
4790
|
-
this
|
|
4791
|
-
this
|
|
4792
|
-
this
|
|
4098
|
+
#teardownReconnect(): void {
|
|
4099
|
+
this.#reconnecting = false;
|
|
4100
|
+
this.#reconnectController?.teardown();
|
|
4101
|
+
this.#clearResumable();
|
|
4793
4102
|
}
|
|
4794
4103
|
|
|
4795
4104
|
/** Drop the resume handle and notify the host (`onExecutionState(null)`). */
|
|
4796
|
-
|
|
4797
|
-
if (this
|
|
4798
|
-
clearTimeout(this
|
|
4799
|
-
this
|
|
4105
|
+
#clearResumable(): void {
|
|
4106
|
+
if (this.#executionStateTimer) {
|
|
4107
|
+
clearTimeout(this.#executionStateTimer);
|
|
4108
|
+
this.#executionStateTimer = null;
|
|
4800
4109
|
}
|
|
4801
|
-
const had = this
|
|
4802
|
-
this
|
|
4110
|
+
const had = this.#resumable !== null;
|
|
4111
|
+
this.#resumable = null;
|
|
4803
4112
|
if (had) this.config.onExecutionState?.(null);
|
|
4804
4113
|
}
|
|
4805
4114
|
|
|
@@ -4808,36 +4117,36 @@ export class AgentWidgetSession {
|
|
|
4808
4117
|
* create/clear; trailing-edge throttled on cursor advances so it isn't called
|
|
4809
4118
|
* per delta.
|
|
4810
4119
|
*/
|
|
4811
|
-
|
|
4120
|
+
#notifyExecutionState(immediate: boolean): void {
|
|
4812
4121
|
const cb = this.config.onExecutionState;
|
|
4813
4122
|
if (!cb) return;
|
|
4814
4123
|
if (immediate) {
|
|
4815
|
-
if (this
|
|
4816
|
-
clearTimeout(this
|
|
4817
|
-
this
|
|
4124
|
+
if (this.#executionStateTimer) {
|
|
4125
|
+
clearTimeout(this.#executionStateTimer);
|
|
4126
|
+
this.#executionStateTimer = null;
|
|
4818
4127
|
}
|
|
4819
|
-
cb(this
|
|
4128
|
+
cb(this.#resumable);
|
|
4820
4129
|
return;
|
|
4821
4130
|
}
|
|
4822
|
-
if (this
|
|
4823
|
-
this
|
|
4824
|
-
this
|
|
4825
|
-
this.config.onExecutionState?.(this
|
|
4131
|
+
if (this.#executionStateTimer) return; // a trailing call is already queued
|
|
4132
|
+
this.#executionStateTimer = setTimeout(() => {
|
|
4133
|
+
this.#executionStateTimer = null;
|
|
4134
|
+
this.config.onExecutionState?.(this.#resumable);
|
|
4826
4135
|
}, 500);
|
|
4827
4136
|
}
|
|
4828
4137
|
|
|
4829
4138
|
/** The current durable resume handle, if any (read-only). */
|
|
4830
4139
|
public getResumableHandle(): ResumableHandle | null {
|
|
4831
|
-
return this
|
|
4140
|
+
return this.#resumable;
|
|
4832
4141
|
}
|
|
4833
4142
|
|
|
4834
|
-
|
|
4143
|
+
#setStatus(status: AgentWidgetSessionStatus) {
|
|
4835
4144
|
if (this.status === status) return;
|
|
4836
4145
|
this.status = status;
|
|
4837
4146
|
this.callbacks.onStatusChanged(status);
|
|
4838
4147
|
}
|
|
4839
4148
|
|
|
4840
|
-
|
|
4149
|
+
#setStreaming(streaming: boolean) {
|
|
4841
4150
|
if (this.streaming === streaming) return;
|
|
4842
4151
|
const wasStreaming = this.streaming;
|
|
4843
4152
|
this.streaming = streaming;
|
|
@@ -4845,6 +4154,7 @@ export class AgentWidgetSession {
|
|
|
4845
4154
|
|
|
4846
4155
|
// Speak the latest assistant message when streaming completes
|
|
4847
4156
|
if (wasStreaming && !streaming) {
|
|
4157
|
+
this.#flushApprovalSettles(undefined);
|
|
4848
4158
|
this.speakLatestAssistantMessage();
|
|
4849
4159
|
}
|
|
4850
4160
|
}
|
|
@@ -4853,7 +4163,8 @@ export class AgentWidgetSession {
|
|
|
4853
4163
|
* Speak the latest assistant message using the Web Speech API
|
|
4854
4164
|
* if text-to-speech is enabled in the config.
|
|
4855
4165
|
*/
|
|
4856
|
-
|
|
4166
|
+
/** `only`: speak it only if the latest assistant message is one of these. */
|
|
4167
|
+
private speakLatestAssistantMessage(only?: string[]) {
|
|
4857
4168
|
const ttsConfig = this.config.textToSpeech;
|
|
4858
4169
|
if (!ttsConfig?.enabled) return;
|
|
4859
4170
|
|
|
@@ -4871,7 +4182,7 @@ export class AgentWidgetSession {
|
|
|
4871
4182
|
.reverse()
|
|
4872
4183
|
.find(m => m.role === 'assistant' && m.content && !m.voiceProcessing);
|
|
4873
4184
|
|
|
4874
|
-
if (!lastAssistant) return;
|
|
4185
|
+
if (!lastAssistant || (only && !only.includes(lastAssistant.id))) return;
|
|
4875
4186
|
|
|
4876
4187
|
// Skip if already spoken by Runtype provider's audio playback
|
|
4877
4188
|
if (this.ttsSpokenMessageIds.has(lastAssistant.id)) {
|
|
@@ -4943,17 +4254,62 @@ export class AgentWidgetSession {
|
|
|
4943
4254
|
}
|
|
4944
4255
|
}
|
|
4945
4256
|
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4257
|
+
/**
|
|
4258
|
+
* Settle the approval-paused bubbles one stream decided but did not
|
|
4259
|
+
* complete. `undefined` settles those no live `connectStream` owns.
|
|
4260
|
+
*/
|
|
4261
|
+
#flushApprovalSettles(streamToken: object | undefined) {
|
|
4262
|
+
for (const [id, token] of [...this.#approvalSettlesAtStreamEnd]) {
|
|
4263
|
+
if (token !== streamToken) continue;
|
|
4264
|
+
this.#approvalSettlesAtStreamEnd.delete(id);
|
|
4265
|
+
this.#settleApprovalPausedToolCall(id);
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
/** Hand a dropped stream's pending settles to the end of all streaming. */
|
|
4270
|
+
#releaseApprovalSettles(streamToken: object) {
|
|
4271
|
+
for (const [id, token] of this.#approvalSettlesAtStreamEnd) {
|
|
4272
|
+
if (token === streamToken) this.#approvalSettlesAtStreamEnd.set(id, undefined);
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
/**
|
|
4277
|
+
* Settle the tool bubble an approval paused when no terminal tool frame
|
|
4278
|
+
* arrived for it. Current servers run the paused call itself under its
|
|
4279
|
+
* original id; older ones re-issue it under a new id. Denied and timed-out
|
|
4280
|
+
* calls settle as failed; an approved call settles as superseded. A call
|
|
4281
|
+
* already complete is left alone.
|
|
4282
|
+
*/
|
|
4283
|
+
|
|
4284
|
+
#settleApprovalPausedToolCall(approvalMessageId: string) {
|
|
4285
|
+
const approval = this.messages.find((m) => m.id === approvalMessageId)?.approval;
|
|
4286
|
+
const id = approval?.toolCallId;
|
|
4287
|
+
const toolMessage = id && this.messages.find((m) => m.toolCall?.id === id);
|
|
4288
|
+
const tool = toolMessage && toolMessage.toolCall;
|
|
4289
|
+
if (!tool || tool.status === "complete" || approval.status === "pending") return;
|
|
4290
|
+
const settled: AgentWidgetToolCall = {
|
|
4291
|
+
...tool,
|
|
4292
|
+
status: "complete",
|
|
4293
|
+
approvalStatus: approval.status,
|
|
4294
|
+
completedAt: Date.now(),
|
|
4295
|
+
...(approval.status === "approved"
|
|
4296
|
+
? { superseded: true }
|
|
4297
|
+
: { success: false, error: approval.status === "denied" ? "Denied" : "Approval timed out" }),
|
|
4298
|
+
};
|
|
4299
|
+
this.upsertMessage({ ...toolMessage, streaming: false, toolCall: settled });
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
#appendMessage(message: AgentWidgetMessage) {
|
|
4303
|
+
const withSequence = this.#ensureSequence(message);
|
|
4304
|
+
this.messages = this.#sortMessages([...this.messages, withSequence]);
|
|
4949
4305
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4950
4306
|
}
|
|
4951
4307
|
|
|
4952
4308
|
private upsertMessage(message: AgentWidgetMessage) {
|
|
4953
|
-
const withSequence = this
|
|
4309
|
+
const withSequence = this.#ensureSequence(message);
|
|
4954
4310
|
const index = this.messages.findIndex((m) => m.id === withSequence.id);
|
|
4955
4311
|
if (index === -1) {
|
|
4956
|
-
this
|
|
4312
|
+
this.#appendMessage(withSequence);
|
|
4957
4313
|
return;
|
|
4958
4314
|
}
|
|
4959
4315
|
|
|
@@ -5034,6 +4390,7 @@ export class AgentWidgetSession {
|
|
|
5034
4390
|
toolType: incoming.toolType ?? prior.toolType,
|
|
5035
4391
|
reason: incoming.reason ?? prior.reason,
|
|
5036
4392
|
parameters: incoming.parameters ?? prior.parameters,
|
|
4393
|
+
toolCallId: incoming.toolCallId ?? prior.toolCallId,
|
|
5037
4394
|
};
|
|
5038
4395
|
}
|
|
5039
4396
|
// Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
|
|
@@ -5080,25 +4437,25 @@ export class AgentWidgetSession {
|
|
|
5080
4437
|
}
|
|
5081
4438
|
return merged;
|
|
5082
4439
|
});
|
|
5083
|
-
this.messages = this
|
|
4440
|
+
this.messages = this.#sortMessages(this.messages);
|
|
5084
4441
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
5085
4442
|
}
|
|
5086
4443
|
|
|
5087
|
-
|
|
4444
|
+
#ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
|
|
5088
4445
|
if (message.sequence !== undefined) {
|
|
5089
4446
|
return { ...message };
|
|
5090
4447
|
}
|
|
5091
4448
|
return {
|
|
5092
4449
|
...message,
|
|
5093
|
-
sequence: this
|
|
4450
|
+
sequence: this.#nextSequence()
|
|
5094
4451
|
};
|
|
5095
4452
|
}
|
|
5096
4453
|
|
|
5097
|
-
|
|
5098
|
-
return this
|
|
4454
|
+
#nextSequence() {
|
|
4455
|
+
return this.#sequenceCounter++;
|
|
5099
4456
|
}
|
|
5100
4457
|
|
|
5101
|
-
|
|
4458
|
+
#sortMessages(messages: AgentWidgetMessage[]) {
|
|
5102
4459
|
return [...messages].sort((a, b) => {
|
|
5103
4460
|
// Sort by createdAt timestamp first (chronological order)
|
|
5104
4461
|
const timeA = new Date(a.createdAt).getTime();
|