@runtypelabs/persona 4.25.0 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-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 +180 -8
- package/dist/approval-ui.d.ts +180 -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 +180 -8
- package/dist/artifacts-ui.d.ts +180 -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-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-QPPJUSZH.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-YPEG45Q4.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 +180 -8
- package/dist/context-mentions.d.ts +180 -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 +183 -31
- package/dist/event-stream-view.d.ts +183 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +221 -667
- package/dist/forms-ui.d.ts +221 -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 +227 -738
- package/dist/index.d.ts +227 -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 +180 -8
- package/dist/session-reconnect.d.ts +180 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +180 -8
- package/dist/smart-dom-reader.d.ts +180 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +182 -8
- package/dist/theme-editor-preview.d.ts +182 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +181 -23
- package/dist/theme-editor.d.ts +181 -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-UOQOFAFY.js +17 -0
- package/dist/voice-runtime.cjs +17 -1
- package/dist/voice-runtime.d.cts +11650 -232
- package/dist/voice-runtime.d.ts +11650 -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 +221 -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 +852 -1536
- package/src/session.voice.test.ts +1170 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +198 -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 +670 -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 +981 -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
|
|
@@ -291,7 +286,7 @@ export class AgentWidgetSession {
|
|
|
291
286
|
private status: AgentWidgetSessionStatus = "idle";
|
|
292
287
|
private streaming = false;
|
|
293
288
|
private abortController: AbortController | null = null;
|
|
294
|
-
|
|
289
|
+
#sequenceCounter = Date.now();
|
|
295
290
|
|
|
296
291
|
// Client token session management
|
|
297
292
|
private clientSession: ClientSession | null = null;
|
|
@@ -307,23 +302,23 @@ export class AgentWidgetSession {
|
|
|
307
302
|
// cursor climbs; cleared on a graceful terminal, a terminal error, or any
|
|
308
303
|
// teardown. Drives both in-session reconnect and host persistence
|
|
309
304
|
// (`onExecutionState`).
|
|
310
|
-
|
|
305
|
+
#resumable: ResumableHandle | null = null;
|
|
311
306
|
// The assistant message id of the current turn, so a reconnect keeps filling
|
|
312
307
|
// the SAME bubble instead of opening a new one.
|
|
313
|
-
|
|
308
|
+
#activeAssistantMessageId: string | null = null;
|
|
314
309
|
// True while a reconnect run is active (guards against re-entry from a second
|
|
315
310
|
// drop and tells the dispatch/connectStream catches to stay quiet).
|
|
316
|
-
|
|
311
|
+
#reconnecting = false;
|
|
317
312
|
// The reconnect orchestration (backoff loop, wake listeners, give-up
|
|
318
313
|
// finalizer) lives in a lazily-imported module so it stays out of bundles
|
|
319
314
|
// that never opt into durable reconnect. Created on the first reconnect.
|
|
320
|
-
|
|
321
|
-
|
|
315
|
+
#reconnectController: ReconnectController | null = null;
|
|
316
|
+
#reconnectControllerPromise: Promise<ReconnectController> | null = null;
|
|
322
317
|
// Trailing-edge throttle for `onExecutionState` so it isn't fired per delta.
|
|
323
|
-
|
|
318
|
+
#executionStateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
324
319
|
|
|
325
320
|
private artifacts = new Map<string, PersonaArtifactRecord>();
|
|
326
|
-
|
|
321
|
+
#selectedArtifactId: string | null = null;
|
|
327
322
|
|
|
328
323
|
// WebMCP dedupe: keys are `${executionId}:${toolCallId}` so they're
|
|
329
324
|
// naturally scoped to a single dispatch. A later dispatch (new executionId)
|
|
@@ -361,7 +356,7 @@ export class AgentWidgetSession {
|
|
|
361
356
|
// queueMicrotask captures the epoch at queue time and bails if it changed,
|
|
362
357
|
// so a resolve queued just before a teardown can't escape it by installing a
|
|
363
358
|
// fresh controller after the set was already cleared.
|
|
364
|
-
|
|
359
|
+
#webMcpEpoch = 0;
|
|
365
360
|
// WebMCP native approval-bubble gate. When no custom `webmcp.onConfirm` is
|
|
366
361
|
// supplied, the bridge's confirm handler routes here: we inject an
|
|
367
362
|
// approval-variant message and park the bridge on a Promise that resolves
|
|
@@ -369,7 +364,11 @@ export class AgentWidgetSession {
|
|
|
369
364
|
// resolveWebMcpApproval). Resolvers are keyed by the approval message id.
|
|
370
365
|
private webMcpApprovalResolvers: Map<string, (approved: boolean) => void> =
|
|
371
366
|
new Map();
|
|
372
|
-
|
|
367
|
+
#webMcpApprovalSeq = 0;
|
|
368
|
+
// Per approval card: the token of its latest in-flight decision request.
|
|
369
|
+
// Only that request's failure may reopen the card (two decisions can share
|
|
370
|
+
// a Date.now()). Each entry is dropped once its request settles.
|
|
371
|
+
private approvalTokens = new Map<string, object>();
|
|
373
372
|
// Parallel local-tool batching (core#3878). A single model turn can emit
|
|
374
373
|
// multiple `await(local_tool_required)` events for ONE paused
|
|
375
374
|
// executionId: including two PARALLEL calls to the SAME tool ("add SHOE-001
|
|
@@ -387,60 +386,60 @@ export class AgentWidgetSession {
|
|
|
387
386
|
> = new Map();
|
|
388
387
|
|
|
389
388
|
// Voice support
|
|
390
|
-
|
|
389
|
+
#voiceProvider: VoiceProvider | null = null;
|
|
391
390
|
/** In-flight setupVoice chunk load; toggleVoice awaits it when racing setup. */
|
|
392
|
-
|
|
391
|
+
#voiceSetupPromise: Promise<void> | null = null;
|
|
393
392
|
/** Bumped by setupVoice/cleanupVoice so a stale chunk load can't install. */
|
|
394
|
-
|
|
393
|
+
#voiceSetupGeneration = 0;
|
|
395
394
|
/** Latest 0..1 capture amplitude pushed by a provider that reports one. */
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
395
|
+
#voiceLevel = 0;
|
|
396
|
+
#voiceActive = false;
|
|
397
|
+
#voiceStatus: VoiceStatus = 'disconnected';
|
|
399
398
|
|
|
400
399
|
// ── Visitor conversation history (D5) ──────────────────────────
|
|
401
400
|
// Identity of the ONE currently open record; not a conversation→session map.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
401
|
+
#activeConversationId: string | null = null;
|
|
402
|
+
#activeConversationRevision: string | null = null;
|
|
403
|
+
#historyNextMessageCursor: string | null = null;
|
|
405
404
|
/** Monotonic: a later selection supersedes every earlier fetch/prepare. */
|
|
406
|
-
|
|
405
|
+
#historyOpenEpoch = 0;
|
|
407
406
|
/** Blocks `setClientSession`'s welcome branch across a reopen re-init. */
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
407
|
+
#suppressWelcomeInjection = false;
|
|
408
|
+
#historyRecovery: "new_conversation_required" | null = null;
|
|
409
|
+
#historySendBlocked = false;
|
|
411
410
|
/** Continuity wipe / boot reconciliation; dispatch waits on it. */
|
|
412
|
-
|
|
411
|
+
#historyGate: Promise<void> | null = null;
|
|
413
412
|
/** Deduped by `(id, cursor)` so repeated clicks issue one request. */
|
|
414
|
-
|
|
413
|
+
#olderPageRequests = new Map<string, Promise<HistoryOlderPage>>();
|
|
415
414
|
/** Serializes projection finalization against every other operation. */
|
|
416
|
-
|
|
415
|
+
#projectionFinalizationPromise: Promise<void> | null = null;
|
|
417
416
|
/** Message id → projection the server has acknowledged (sent or finalized). */
|
|
418
|
-
|
|
419
|
-
|
|
417
|
+
#acknowledgedProjections = new Map<string, string>();
|
|
418
|
+
#pendingProjections: PendingDisplayProjections | null = null;
|
|
420
419
|
/** Persisted revision as it stood before boot init overwrote it. */
|
|
421
|
-
|
|
422
|
-
|
|
420
|
+
#bootConversationRevision: string | null = null;
|
|
421
|
+
#bootRevisionCaptured = false;
|
|
423
422
|
/** Exactly one visitor-store subscription per instance; re-keyed with the store. */
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
#visitorStoreUnsubscribe: (() => void) | null = null;
|
|
424
|
+
#subscribedVisitorStore: VisitorStore | null = null;
|
|
426
425
|
/** An external credential change happened; the next dispatch must re-init. */
|
|
427
|
-
|
|
428
|
-
|
|
426
|
+
#credentialReinitPending = false;
|
|
427
|
+
#credentialReinitPromise: Promise<void> | null = null;
|
|
429
428
|
|
|
430
429
|
constructor(
|
|
431
430
|
private config: AgentWidgetConfig = {},
|
|
432
431
|
private callbacks: SessionCallbacks,
|
|
433
432
|
private historyInternals: WidgetHistoryInternals = {}
|
|
434
433
|
) {
|
|
435
|
-
this.historyInternals = this
|
|
434
|
+
this.historyInternals = this.#composeHistoryInternals(historyInternals);
|
|
436
435
|
this.messages = [...(config.initialMessages ?? [])].map((message) => ({
|
|
437
436
|
...message,
|
|
438
|
-
sequence: message.sequence ?? this
|
|
437
|
+
sequence: message.sequence ?? this.#nextSequence()
|
|
439
438
|
}));
|
|
440
|
-
this.messages = this
|
|
439
|
+
this.messages = this.#sortMessages(this.messages);
|
|
441
440
|
this.client = new AgentWidgetClient(config, this.historyInternals);
|
|
442
|
-
this
|
|
443
|
-
this
|
|
441
|
+
this.#syncVisitorStoreSubscription();
|
|
442
|
+
this.#wireDefaultWebMcpConfirm();
|
|
444
443
|
|
|
445
444
|
// Hydrate artifacts from config (mirrors `initialMessages`). Restored
|
|
446
445
|
// records are forced to `status: "complete"`: a mid-stream artifact should
|
|
@@ -449,18 +448,18 @@ export class AgentWidgetSession {
|
|
|
449
448
|
this.artifacts.set(rec.id, { ...rec, status: "complete" });
|
|
450
449
|
}
|
|
451
450
|
if (config.initialSelectedArtifactId != null) {
|
|
452
|
-
this
|
|
451
|
+
this.#selectedArtifactId = config.initialSelectedArtifactId;
|
|
453
452
|
}
|
|
454
453
|
|
|
455
454
|
if (this.messages.length) {
|
|
456
455
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
457
456
|
}
|
|
458
457
|
if (this.artifacts.size > 0) {
|
|
459
|
-
this
|
|
458
|
+
this.#emitArtifactsState();
|
|
460
459
|
}
|
|
461
460
|
this.callbacks.onStatusChanged(this.status);
|
|
462
|
-
this
|
|
463
|
-
this
|
|
461
|
+
this.#prefetchRuntypeTts();
|
|
462
|
+
this.#prefetchVoiceRuntime();
|
|
464
463
|
}
|
|
465
464
|
|
|
466
465
|
/**
|
|
@@ -470,7 +469,7 @@ export class AgentWidgetSession {
|
|
|
470
469
|
* AudioContext still wait for their own triggers. Errors are swallowed: the
|
|
471
470
|
* setupVoice path retries via the chunk loader's rejection-retry.
|
|
472
471
|
*/
|
|
473
|
-
|
|
472
|
+
#prefetchVoiceRuntime(): void {
|
|
474
473
|
if (!this.config.voiceRecognition?.provider) return;
|
|
475
474
|
void loadVoiceRuntime().catch(() => {});
|
|
476
475
|
}
|
|
@@ -484,7 +483,7 @@ export class AgentWidgetSession {
|
|
|
484
483
|
* prefetch just means the chunk loads on first click instead (or, on a hard
|
|
485
484
|
* failure, the browser-voice fallback kicks in).
|
|
486
485
|
*/
|
|
487
|
-
|
|
486
|
+
#prefetchRuntypeTts(): void {
|
|
488
487
|
const tts = this.config.textToSpeech;
|
|
489
488
|
if (tts?.provider !== "runtype" || tts.createEngine) return;
|
|
490
489
|
// Only warm the chunk when the engine will actually be built (same gate as
|
|
@@ -543,14 +542,19 @@ export class AgentWidgetSession {
|
|
|
543
542
|
* Check if voice is currently active
|
|
544
543
|
*/
|
|
545
544
|
public isVoiceActive(): boolean {
|
|
546
|
-
return this
|
|
545
|
+
return this.#voiceActive;
|
|
547
546
|
}
|
|
548
547
|
|
|
549
548
|
/**
|
|
550
549
|
* Get current voice status
|
|
551
550
|
*/
|
|
552
551
|
public getVoiceStatus(): VoiceStatus {
|
|
553
|
-
return this
|
|
552
|
+
return this.#voiceStatus;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The live call's AI-disclosure notice, if it shows one. */
|
|
556
|
+
public getVoiceDisclosure(): string | null {
|
|
557
|
+
return this.#voiceProvider?.getDisclosure?.() ?? null;
|
|
554
558
|
}
|
|
555
559
|
|
|
556
560
|
/**
|
|
@@ -562,12 +566,12 @@ export class AgentWidgetSession {
|
|
|
562
566
|
* always report null and the UI falls back to a fixed midpoint.
|
|
563
567
|
*/
|
|
564
568
|
public getVoiceLevel(): number | null {
|
|
565
|
-
return this
|
|
569
|
+
return this.#voiceProvider?.onLevel ? this.#voiceLevel : null;
|
|
566
570
|
}
|
|
567
571
|
|
|
568
572
|
public getVoiceInterruptionMode(): "none" | "cancel" | "barge-in" {
|
|
569
|
-
if (this
|
|
570
|
-
return this
|
|
573
|
+
if (this.#voiceProvider?.getInterruptionMode) {
|
|
574
|
+
return this.#voiceProvider.getInterruptionMode();
|
|
571
575
|
}
|
|
572
576
|
return "none";
|
|
573
577
|
}
|
|
@@ -577,29 +581,36 @@ export class AgentWidgetSession {
|
|
|
577
581
|
* Returns to idle state.
|
|
578
582
|
*/
|
|
579
583
|
public stopVoicePlayback(): void {
|
|
580
|
-
if (this
|
|
581
|
-
this
|
|
582
|
-
this
|
|
584
|
+
if (this.#voiceProvider?.stopPlayback) {
|
|
585
|
+
this.#voiceTurns.cancel();
|
|
586
|
+
this.#keyedVoice?.cancel();
|
|
587
|
+
this.#voiceProvider.stopPlayback();
|
|
583
588
|
}
|
|
584
589
|
}
|
|
585
590
|
|
|
586
591
|
/** Returns true if the barge-in mic stream is alive (hot mic between turns) */
|
|
587
592
|
public isBargeInActive(): boolean {
|
|
588
|
-
return this
|
|
593
|
+
return this.#voiceProvider?.isBargeInActive?.() ?? false;
|
|
589
594
|
}
|
|
590
595
|
|
|
591
596
|
/** Tear down the barge-in mic pipeline: "hang up" the always-on mic */
|
|
592
597
|
public async deactivateBargeIn(): Promise<void> {
|
|
593
|
-
if (this
|
|
594
|
-
await this
|
|
598
|
+
if (this.#voiceProvider?.deactivateBargeIn) {
|
|
599
|
+
await this.#voiceProvider.deactivateBargeIn();
|
|
595
600
|
}
|
|
596
601
|
}
|
|
597
602
|
|
|
598
603
|
// Pending placeholder IDs for Runtype two-phase voice flow
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
604
|
+
#pendingVoiceUserMessageId: string | null = null;
|
|
605
|
+
#pendingVoiceAssistantMessageId: string | null = null;
|
|
606
|
+
#voiceTurns = new VoiceTurnTracker();
|
|
607
|
+
#voiceDisconnectPromise: Promise<void> = Promise.resolve();
|
|
608
|
+
// Turn-keyed (full-duplex) transcript reconciler, from the lazy voice runtime.
|
|
609
|
+
#keyedVoice: KeyedVoiceTranscript | null = null;
|
|
610
|
+
// The delegated voice turn running through sendMessage: the assistant
|
|
611
|
+
// messages it streams, and whether it failed or was aborted.
|
|
612
|
+
#voiceDelegation: VoiceDelegationCapture | null = null;
|
|
613
|
+
|
|
603
614
|
|
|
604
615
|
// Track message IDs where the Runtype provider already played TTS audio
|
|
605
616
|
// so browser TTS doesn't double-speak them
|
|
@@ -609,7 +620,7 @@ export class AgentWidgetSession {
|
|
|
609
620
|
// message is active, its play/pause state, and the speech engine. The engine
|
|
610
621
|
// is resolved lazily on first playback (inside the user gesture) so a hosted
|
|
611
622
|
// engine via `textToSpeech.createEngine` plugs in without changing this class.
|
|
612
|
-
private readAloud = new ReadAloudController(() => this
|
|
623
|
+
private readAloud = new ReadAloudController(() => this.#createSpeechEngine());
|
|
613
624
|
|
|
614
625
|
/**
|
|
615
626
|
* Resolve the speech engine behind read-aloud (and auto-speak):
|
|
@@ -622,7 +633,7 @@ export class AgentWidgetSession {
|
|
|
622
633
|
* Runtype voices the day the endpoint ships.
|
|
623
634
|
* 3. Otherwise the browser Web Speech API engine.
|
|
624
635
|
*/
|
|
625
|
-
|
|
636
|
+
#createSpeechEngine(): SpeechEngine | Promise<SpeechEngine> | null {
|
|
626
637
|
const tts = this.config.textToSpeech;
|
|
627
638
|
if (tts?.createEngine) return tts.createEngine();
|
|
628
639
|
|
|
@@ -694,7 +705,7 @@ export class AgentWidgetSession {
|
|
|
694
705
|
}
|
|
695
706
|
|
|
696
707
|
this.cleanupVoice();
|
|
697
|
-
const disconnected = this
|
|
708
|
+
const disconnected = this.#voiceDisconnectPromise;
|
|
698
709
|
|
|
699
710
|
// The provider runtime ships in the lazy voice-runtime chunk. The sync
|
|
700
711
|
// signature is preserved: construction + wiring + connect() happen when
|
|
@@ -702,19 +713,19 @@ export class AgentWidgetSession {
|
|
|
702
713
|
// provider is configured, so this is warm in practice). A stale
|
|
703
714
|
// resolution (cleanupVoice or a newer setupVoice ran meanwhile) is
|
|
704
715
|
// discarded via the generation token.
|
|
705
|
-
const generation = ++this
|
|
706
|
-
this
|
|
716
|
+
const generation = ++this.#voiceSetupGeneration;
|
|
717
|
+
this.#voiceSetupPromise = loadVoiceRuntime()
|
|
707
718
|
.then(async (mod) => {
|
|
708
719
|
await disconnected;
|
|
709
|
-
if (generation !== this
|
|
710
|
-
this
|
|
720
|
+
if (generation !== this.#voiceSetupGeneration) return;
|
|
721
|
+
this.#wireVoiceProvider(mod, mod.createVoiceProvider(voiceConfig));
|
|
711
722
|
})
|
|
712
723
|
.catch((error) => {
|
|
713
724
|
console.error('Failed to setup voice:', error);
|
|
714
725
|
})
|
|
715
726
|
.finally(() => {
|
|
716
|
-
if (generation === this
|
|
717
|
-
this
|
|
727
|
+
if (generation === this.#voiceSetupGeneration) {
|
|
728
|
+
this.#voiceSetupPromise = null;
|
|
718
729
|
}
|
|
719
730
|
});
|
|
720
731
|
} catch (error) {
|
|
@@ -722,165 +733,56 @@ export class AgentWidgetSession {
|
|
|
722
733
|
}
|
|
723
734
|
}
|
|
724
735
|
|
|
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
|
-
}
|
|
736
|
+
/**
|
|
737
|
+
* Install a provider: the wiring itself ships in the lazy voice-runtime
|
|
738
|
+
* chunk (`voice/session-voice-wiring.ts`); this hands it the private state.
|
|
739
|
+
*/
|
|
740
|
+
#wireVoiceProvider(mod: VoiceRuntimeModule, provider: VoiceProvider): void {
|
|
741
|
+
this.#voiceProvider = provider;
|
|
742
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
743
|
+
const session = this;
|
|
744
|
+
mod.wireSessionVoice(
|
|
745
|
+
{
|
|
746
|
+
session: this as unknown as VoiceWiringSession,
|
|
747
|
+
provider: () => session.#voiceProvider,
|
|
748
|
+
generation: () => session.#voiceSetupGeneration,
|
|
749
|
+
get keyed() {
|
|
750
|
+
return session.#keyedVoice;
|
|
751
|
+
},
|
|
752
|
+
set keyed(keyed) {
|
|
753
|
+
session.#keyedVoice = keyed;
|
|
754
|
+
},
|
|
755
|
+
get pendingUser() {
|
|
756
|
+
return session.#pendingVoiceUserMessageId;
|
|
757
|
+
},
|
|
758
|
+
set pendingUser(id) {
|
|
759
|
+
session.#pendingVoiceUserMessageId = id;
|
|
760
|
+
},
|
|
761
|
+
get pendingAssistant() {
|
|
762
|
+
return session.#pendingVoiceAssistantMessageId;
|
|
763
|
+
},
|
|
764
|
+
set pendingAssistant(id) {
|
|
765
|
+
session.#pendingVoiceAssistantMessageId = id;
|
|
766
|
+
},
|
|
767
|
+
turns: () => session.#voiceTurns,
|
|
768
|
+
busy: () => session.#chatTurnBusy(),
|
|
769
|
+
setStreaming: (streaming) => session.#setStreaming(streaming),
|
|
770
|
+
setDelegation: (capture) => {
|
|
771
|
+
session.#voiceDelegation = capture;
|
|
772
|
+
},
|
|
773
|
+
setLevel: (level) => {
|
|
774
|
+
session.#voiceLevel = level;
|
|
775
|
+
},
|
|
776
|
+
setStatus: (status) => {
|
|
777
|
+
session.#voiceStatus = status;
|
|
778
|
+
session.#voiceActive = status === 'listening';
|
|
779
|
+
},
|
|
780
|
+
settlePending: (final) => session.#settlePendingVoiceTurn(final),
|
|
781
|
+
},
|
|
782
|
+
provider,
|
|
783
|
+
mod.KeyedVoiceTranscript,
|
|
784
|
+
mod.createVoiceSessionBridge
|
|
785
|
+
);
|
|
884
786
|
}
|
|
885
787
|
|
|
886
788
|
/**
|
|
@@ -889,21 +791,21 @@ export class AgentWidgetSession {
|
|
|
889
791
|
public async toggleVoice() {
|
|
890
792
|
// A click can race the setup chunk load; wait for it rather than
|
|
891
793
|
// erroring (prefetch at construction makes this window ~one microtask).
|
|
892
|
-
if (!this
|
|
893
|
-
await this
|
|
794
|
+
if (!this.#voiceProvider && this.#voiceSetupPromise) {
|
|
795
|
+
await this.#voiceSetupPromise;
|
|
894
796
|
}
|
|
895
|
-
if (!this
|
|
797
|
+
if (!this.#voiceProvider) {
|
|
896
798
|
console.error('Voice not configured');
|
|
897
799
|
return;
|
|
898
800
|
}
|
|
899
801
|
|
|
900
|
-
if (this
|
|
901
|
-
await this
|
|
802
|
+
if (this.#voiceActive) {
|
|
803
|
+
await this.#voiceProvider.stopListening();
|
|
902
804
|
} else {
|
|
903
805
|
// Stop any in-progress TTS so the mic doesn't pick it up
|
|
904
806
|
this.stopSpeaking();
|
|
905
807
|
try {
|
|
906
|
-
await this
|
|
808
|
+
await this.#voiceProvider.startListening();
|
|
907
809
|
} catch (error) {
|
|
908
810
|
console.error('Failed to start voice:', error);
|
|
909
811
|
}
|
|
@@ -921,42 +823,55 @@ export class AgentWidgetSession {
|
|
|
921
823
|
this.warmClientSession();
|
|
922
824
|
return;
|
|
923
825
|
}
|
|
924
|
-
const generation = this
|
|
826
|
+
const generation = this.#voiceSetupGeneration;
|
|
925
827
|
const run = () => {
|
|
926
|
-
const provider = this
|
|
927
|
-
if (!provider?.prewarm || generation !== this
|
|
928
|
-
if (this
|
|
828
|
+
const provider = this.#voiceProvider;
|
|
829
|
+
if (!provider?.prewarm || generation !== this.#voiceSetupGeneration) return;
|
|
830
|
+
if (this.#voiceActive || this.isBargeInActive()) return;
|
|
929
831
|
void Promise.resolve()
|
|
930
832
|
.then(() => provider.prewarm?.())
|
|
931
833
|
.catch(() => {});
|
|
932
834
|
};
|
|
933
|
-
if (!this
|
|
934
|
-
void this
|
|
835
|
+
if (!this.#voiceProvider && this.#voiceSetupPromise) {
|
|
836
|
+
void this.#voiceSetupPromise.then(run, () => {});
|
|
935
837
|
return;
|
|
936
838
|
}
|
|
937
839
|
run();
|
|
938
840
|
}
|
|
939
841
|
|
|
842
|
+
/** A chat turn is streaming, resuming, or running local (WebMCP) tools. */
|
|
843
|
+
#chatTurnBusy(): boolean {
|
|
844
|
+
return (
|
|
845
|
+
!!this.abortController ||
|
|
846
|
+
this.webMcpResolveControllers.size > 0 ||
|
|
847
|
+
this.webMcpAwaitBatches.size > 0 ||
|
|
848
|
+
this.#reconnecting ||
|
|
849
|
+
this.status === 'resuming'
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
940
853
|
/**
|
|
941
854
|
* Cleanup voice resources
|
|
942
855
|
*/
|
|
943
856
|
public cleanupVoice() {
|
|
944
|
-
const notifyDisconnected = this
|
|
945
|
-
this
|
|
946
|
-
this
|
|
947
|
-
if (this
|
|
948
|
-
const provider = this
|
|
949
|
-
this
|
|
950
|
-
this
|
|
857
|
+
const notifyDisconnected = this.#voiceProvider !== null;
|
|
858
|
+
this.#voiceSetupGeneration++;
|
|
859
|
+
this.#voiceSetupPromise = null;
|
|
860
|
+
if (this.#voiceProvider) {
|
|
861
|
+
const provider = this.#voiceProvider;
|
|
862
|
+
this.#voiceProvider = null;
|
|
863
|
+
this.#voiceDisconnectPromise = this.#disconnectVoice(provider);
|
|
951
864
|
}
|
|
952
|
-
this
|
|
953
|
-
this
|
|
954
|
-
this
|
|
955
|
-
this
|
|
865
|
+
this.#voiceActive = false;
|
|
866
|
+
this.#voiceStatus = 'disconnected';
|
|
867
|
+
this.#settlePendingVoiceTurn(true);
|
|
868
|
+
this.#keyedVoice?.settle();
|
|
869
|
+
this.#keyedVoice = null;
|
|
870
|
+
this.#voiceTurns = new VoiceTurnTracker();
|
|
956
871
|
if (notifyDisconnected) this.callbacks.onVoiceStatusChanged?.('disconnected');
|
|
957
872
|
}
|
|
958
873
|
|
|
959
|
-
|
|
874
|
+
async #disconnectVoice(provider: VoiceProvider): Promise<void> {
|
|
960
875
|
try {
|
|
961
876
|
await provider.disconnect();
|
|
962
877
|
} catch (error) {
|
|
@@ -964,13 +879,13 @@ export class AgentWidgetSession {
|
|
|
964
879
|
}
|
|
965
880
|
}
|
|
966
881
|
|
|
967
|
-
|
|
968
|
-
const userId = includeUser ? this
|
|
969
|
-
const assistantId = this
|
|
882
|
+
#settlePendingVoiceTurn(includeUser: boolean): void {
|
|
883
|
+
const userId = includeUser ? this.#pendingVoiceUserMessageId : null;
|
|
884
|
+
const assistantId = this.#pendingVoiceAssistantMessageId;
|
|
970
885
|
if (!userId && !assistantId) return;
|
|
971
|
-
if (assistantId) this
|
|
972
|
-
if (includeUser) this
|
|
973
|
-
this
|
|
886
|
+
if (assistantId) this.#voiceTurns.cancel();
|
|
887
|
+
if (includeUser) this.#pendingVoiceUserMessageId = null;
|
|
888
|
+
this.#pendingVoiceAssistantMessageId = null;
|
|
974
889
|
if (assistantId) this.ttsSpokenMessageIds.add(assistantId);
|
|
975
890
|
this.messages = this.messages.flatMap((message) => {
|
|
976
891
|
if (message.id !== userId && message.id !== assistantId) return [message];
|
|
@@ -978,7 +893,7 @@ export class AgentWidgetSession {
|
|
|
978
893
|
return [{ ...message, streaming: false, voiceProcessing: false }];
|
|
979
894
|
});
|
|
980
895
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
981
|
-
if (assistantId) this
|
|
896
|
+
if (assistantId) this.#setStreaming(false);
|
|
982
897
|
}
|
|
983
898
|
|
|
984
899
|
/**
|
|
@@ -1004,7 +919,11 @@ export class AgentWidgetSession {
|
|
|
1004
919
|
voiceId: providerConfig.runtype?.voiceId,
|
|
1005
920
|
createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine,
|
|
1006
921
|
prewarmMode: providerConfig.runtype?.prewarmMode,
|
|
1007
|
-
attachIdleMs: providerConfig.runtype?.attachIdleMs
|
|
922
|
+
attachIdleMs: providerConfig.runtype?.attachIdleMs,
|
|
923
|
+
clientDelegation: providerConfig.runtype?.clientDelegation,
|
|
924
|
+
callContext: providerConfig.runtype?.callContext,
|
|
925
|
+
approvalTimeoutMs: providerConfig.runtype?.approvalTimeoutMs,
|
|
926
|
+
disclosureText: providerConfig.runtype?.disclosureText
|
|
1008
927
|
}
|
|
1009
928
|
};
|
|
1010
929
|
|
|
@@ -1046,9 +965,9 @@ export class AgentWidgetSession {
|
|
|
1046
965
|
await this.historyInternals.historyBootstrapReady;
|
|
1047
966
|
// Snapshot before init overwrites it: boot reconciliation compares the
|
|
1048
967
|
// init revision against what the LAST page load persisted.
|
|
1049
|
-
this
|
|
968
|
+
this.#bootConversationRevision =
|
|
1050
969
|
this.historyInternals.getStoredConversationRevision?.() ?? null;
|
|
1051
|
-
this
|
|
970
|
+
this.#bootRevisionCaptured = true;
|
|
1052
971
|
}
|
|
1053
972
|
|
|
1054
973
|
try {
|
|
@@ -1085,7 +1004,7 @@ export class AgentWidgetSession {
|
|
|
1085
1004
|
*/
|
|
1086
1005
|
public setClientSession(session: ClientSession, pristine?: boolean): void {
|
|
1087
1006
|
this.clientSession = session;
|
|
1088
|
-
this
|
|
1007
|
+
this.#injectWelcomeMessage(session, pristine);
|
|
1089
1008
|
}
|
|
1090
1009
|
|
|
1091
1010
|
/**
|
|
@@ -1093,11 +1012,11 @@ export class AgentWidgetSession {
|
|
|
1093
1012
|
* reopening a stored conversation is not a new thread, and the hydrated
|
|
1094
1013
|
* transcript would otherwise race a welcome bubble.
|
|
1095
1014
|
*/
|
|
1096
|
-
|
|
1015
|
+
#injectWelcomeMessage(
|
|
1097
1016
|
session: ClientSession,
|
|
1098
1017
|
pristine = !this.messages.length
|
|
1099
1018
|
): void {
|
|
1100
|
-
if (this
|
|
1019
|
+
if (this.#suppressWelcomeInjection) return;
|
|
1101
1020
|
if (!session.config.welcomeMessage || !pristine) return;
|
|
1102
1021
|
// A late welcome (the first send raced the init) sorts ahead of that turn.
|
|
1103
1022
|
const first = this.messages[0];
|
|
@@ -1106,9 +1025,9 @@ export class AgentWidgetSession {
|
|
|
1106
1025
|
role: "assistant",
|
|
1107
1026
|
content: session.config.welcomeMessage,
|
|
1108
1027
|
createdAt: first?.createdAt ?? new Date().toISOString(),
|
|
1109
|
-
sequence: first ? (first.sequence ?? 0) - 1 : this
|
|
1028
|
+
sequence: first ? (first.sequence ?? 0) - 1 : this.#nextSequence()
|
|
1110
1029
|
};
|
|
1111
|
-
this
|
|
1030
|
+
this.#appendMessage(welcomeMessage);
|
|
1112
1031
|
}
|
|
1113
1032
|
|
|
1114
1033
|
/**
|
|
@@ -1184,17 +1103,17 @@ export class AgentWidgetSession {
|
|
|
1184
1103
|
* client and any rebuilt one share the same store.
|
|
1185
1104
|
*/
|
|
1186
1105
|
public setHistoryInternals(internals: WidgetHistoryInternals): void {
|
|
1187
|
-
this.historyInternals = this
|
|
1106
|
+
this.historyInternals = this.#composeHistoryInternals(internals);
|
|
1188
1107
|
this.client.setHistoryInternals(this.historyInternals);
|
|
1189
|
-
this
|
|
1108
|
+
this.#syncVisitorStoreSubscription();
|
|
1190
1109
|
}
|
|
1191
1110
|
|
|
1192
1111
|
/** Widget teardown: release the visitor-store subscription. */
|
|
1193
1112
|
public destroy(): void {
|
|
1194
1113
|
this.cleanupVoice();
|
|
1195
|
-
this
|
|
1196
|
-
this
|
|
1197
|
-
this
|
|
1114
|
+
this.#visitorStoreUnsubscribe?.();
|
|
1115
|
+
this.#visitorStoreUnsubscribe = null;
|
|
1116
|
+
this.#subscribedVisitorStore = null;
|
|
1198
1117
|
}
|
|
1199
1118
|
|
|
1200
1119
|
// ==========================================================================
|
|
@@ -1206,25 +1125,25 @@ export class AgentWidgetSession {
|
|
|
1206
1125
|
* supplied its own observer: a credential/record break must wipe local state
|
|
1207
1126
|
* before the replacement session becomes sendable.
|
|
1208
1127
|
*/
|
|
1209
|
-
|
|
1128
|
+
#composeHistoryInternals(
|
|
1210
1129
|
internals: WidgetHistoryInternals
|
|
1211
1130
|
): WidgetHistoryInternals {
|
|
1212
1131
|
const hostContinuity = internals.onHistoryContinuityChanged;
|
|
1213
1132
|
return {
|
|
1214
1133
|
...internals,
|
|
1215
1134
|
onHistoryContinuityChanged: (info) => {
|
|
1216
|
-
this
|
|
1135
|
+
this.#handleHistoryContinuityChanged(info);
|
|
1217
1136
|
hostContinuity?.(info);
|
|
1218
1137
|
},
|
|
1219
1138
|
};
|
|
1220
1139
|
}
|
|
1221
1140
|
|
|
1222
|
-
|
|
1141
|
+
get #historyProvider(): HistoryProvider | null {
|
|
1223
1142
|
return this.historyInternals.historyProvider ?? null;
|
|
1224
1143
|
}
|
|
1225
1144
|
|
|
1226
|
-
|
|
1227
|
-
const provider = this
|
|
1145
|
+
#requireHistoryProvider(): HistoryProvider {
|
|
1146
|
+
const provider = this.#historyProvider;
|
|
1228
1147
|
if (!provider) {
|
|
1229
1148
|
throw new SessionHistoryError(
|
|
1230
1149
|
"history_unavailable",
|
|
@@ -1238,8 +1157,8 @@ export class AgentWidgetSession {
|
|
|
1238
1157
|
* One resolved scope per logical action. An explicitly requested scope the
|
|
1239
1158
|
* provider cannot serve fails locally rather than silently downgrading.
|
|
1240
1159
|
*/
|
|
1241
|
-
|
|
1242
|
-
const provider = this
|
|
1160
|
+
#historyContext(scope?: HistoryScope): HistoryOperationContext {
|
|
1161
|
+
const provider = this.#requireHistoryProvider();
|
|
1243
1162
|
const requested = scope ?? this.config.features?.history?.scope;
|
|
1244
1163
|
const derived: HistoryScope = this.config.getIdentityProof
|
|
1245
1164
|
? "verified-user"
|
|
@@ -1262,23 +1181,23 @@ export class AgentWidgetSession {
|
|
|
1262
1181
|
|
|
1263
1182
|
public getHistoryState(): SessionHistoryState {
|
|
1264
1183
|
return {
|
|
1265
|
-
activeConversationId: this
|
|
1266
|
-
conversationRevision: this
|
|
1267
|
-
nextMessageCursor: this
|
|
1268
|
-
recovery: this
|
|
1269
|
-
sendBlocked: this
|
|
1184
|
+
activeConversationId: this.#activeConversationId,
|
|
1185
|
+
conversationRevision: this.#activeConversationRevision,
|
|
1186
|
+
nextMessageCursor: this.#historyNextMessageCursor,
|
|
1187
|
+
recovery: this.#historyRecovery,
|
|
1188
|
+
sendBlocked: this.#historySendBlocked,
|
|
1270
1189
|
};
|
|
1271
1190
|
}
|
|
1272
1191
|
|
|
1273
1192
|
public getActiveConversationId(): string | null {
|
|
1274
|
-
return this
|
|
1193
|
+
return this.#activeConversationId;
|
|
1275
1194
|
}
|
|
1276
1195
|
|
|
1277
|
-
|
|
1196
|
+
#emitHistoryState(): void {
|
|
1278
1197
|
this.callbacks.onHistoryStateChanged?.(this.getHistoryState());
|
|
1279
1198
|
}
|
|
1280
1199
|
|
|
1281
|
-
|
|
1200
|
+
#notifyHistory(
|
|
1282
1201
|
code: SessionHistoryNoticeCode,
|
|
1283
1202
|
message: string
|
|
1284
1203
|
): void {
|
|
@@ -1287,13 +1206,13 @@ export class AgentWidgetSession {
|
|
|
1287
1206
|
|
|
1288
1207
|
/** Pending destructive/reconciliation work every other operation waits on. */
|
|
1289
1208
|
public async awaitHistorySettled(): Promise<void> {
|
|
1290
|
-
const gate = this
|
|
1209
|
+
const gate = this.#historyGate;
|
|
1291
1210
|
if (gate) await gate.catch(() => {});
|
|
1292
|
-
const finalization = this
|
|
1211
|
+
const finalization = this.#projectionFinalizationPromise;
|
|
1293
1212
|
if (finalization) await finalization.catch(() => {});
|
|
1294
1213
|
}
|
|
1295
1214
|
|
|
1296
|
-
|
|
1215
|
+
#assertHistoryIdle(): void {
|
|
1297
1216
|
if (this.streaming || this.status === "paused" || this.status === "resuming") {
|
|
1298
1217
|
throw new SessionHistoryError(
|
|
1299
1218
|
"conversation_busy",
|
|
@@ -1308,12 +1227,12 @@ export class AgentWidgetSession {
|
|
|
1308
1227
|
targetId?: string;
|
|
1309
1228
|
scope?: HistoryScope;
|
|
1310
1229
|
}) {
|
|
1311
|
-
const provider = this
|
|
1230
|
+
const provider = this.#requireHistoryProvider();
|
|
1312
1231
|
return provider.list({
|
|
1313
1232
|
...(opts?.cursor ? { cursor: opts.cursor } : {}),
|
|
1314
1233
|
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
1315
1234
|
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1316
|
-
context: this
|
|
1235
|
+
context: this.#historyContext(opts?.scope),
|
|
1317
1236
|
});
|
|
1318
1237
|
}
|
|
1319
1238
|
|
|
@@ -1329,46 +1248,46 @@ export class AgentWidgetSession {
|
|
|
1329
1248
|
summary: HistoryConversationSummary;
|
|
1330
1249
|
nextMessageCursor: string | null;
|
|
1331
1250
|
}> {
|
|
1332
|
-
const provider = this
|
|
1333
|
-
this
|
|
1334
|
-
const context = this
|
|
1251
|
+
const provider = this.#requireHistoryProvider();
|
|
1252
|
+
this.#assertHistoryIdle();
|
|
1253
|
+
const context = this.#historyContext(opts?.scope);
|
|
1335
1254
|
await this.awaitHistorySettled();
|
|
1336
|
-
const epoch = ++this
|
|
1255
|
+
const epoch = ++this.#historyOpenEpoch;
|
|
1337
1256
|
|
|
1338
1257
|
const page = await provider.getPage(id, { context });
|
|
1339
|
-
if (epoch !== this
|
|
1258
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1340
1259
|
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1341
1260
|
}
|
|
1342
1261
|
|
|
1343
1262
|
const prepared = await provider.prepareOpen(id, { context });
|
|
1344
|
-
if (epoch !== this
|
|
1263
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1345
1264
|
prepared.discard();
|
|
1346
1265
|
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1347
1266
|
}
|
|
1348
1267
|
|
|
1349
1268
|
// The reopened transcript is not a new conversation: never inject welcome.
|
|
1350
|
-
this
|
|
1269
|
+
this.#suppressWelcomeInjection = true;
|
|
1351
1270
|
try {
|
|
1352
|
-
await this
|
|
1271
|
+
await this.#commitActivation(prepared);
|
|
1353
1272
|
} finally {
|
|
1354
|
-
this
|
|
1273
|
+
this.#suppressWelcomeInjection = false;
|
|
1355
1274
|
}
|
|
1356
1275
|
|
|
1357
|
-
this
|
|
1358
|
-
this
|
|
1276
|
+
this.#activeConversationId = prepared.conversationId || id;
|
|
1277
|
+
this.#activeConversationRevision =
|
|
1359
1278
|
prepared.conversationRevision || page.conversationRevision || null;
|
|
1360
|
-
this
|
|
1361
|
-
this
|
|
1362
|
-
this
|
|
1363
|
-
this
|
|
1279
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1280
|
+
this.#historyRecovery = null;
|
|
1281
|
+
this.#historySendBlocked = false;
|
|
1282
|
+
this.#resetConversationScopedState();
|
|
1364
1283
|
// Server projections are by definition acknowledged.
|
|
1365
1284
|
for (const message of page.messages) {
|
|
1366
|
-
this
|
|
1285
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1367
1286
|
}
|
|
1368
1287
|
// Metadata first, transcript last: the transcript emit is what persists.
|
|
1369
|
-
this
|
|
1288
|
+
this.#persistConversationMetadata();
|
|
1370
1289
|
this.hydrateMessages(page.messages);
|
|
1371
|
-
this
|
|
1290
|
+
this.#emitHistoryState();
|
|
1372
1291
|
return { summary: page.summary, nextMessageCursor: page.nextCursor };
|
|
1373
1292
|
}
|
|
1374
1293
|
|
|
@@ -1381,25 +1300,25 @@ export class AgentWidgetSession {
|
|
|
1381
1300
|
cursor?: string | null,
|
|
1382
1301
|
opts?: { scope?: HistoryScope }
|
|
1383
1302
|
): Promise<HistoryOlderPage> {
|
|
1384
|
-
const provider = this
|
|
1385
|
-
const context = this
|
|
1386
|
-
const resolvedCursor = cursor ?? this
|
|
1303
|
+
const provider = this.#requireHistoryProvider();
|
|
1304
|
+
const context = this.#historyContext(opts?.scope);
|
|
1305
|
+
const resolvedCursor = cursor ?? this.#historyNextMessageCursor;
|
|
1387
1306
|
if (!resolvedCursor) {
|
|
1388
1307
|
return Promise.resolve({ messages: [], nextMessageCursor: null });
|
|
1389
1308
|
}
|
|
1390
1309
|
const key = `${id} ${resolvedCursor}`;
|
|
1391
|
-
const inflight = this
|
|
1310
|
+
const inflight = this.#olderPageRequests.get(key);
|
|
1392
1311
|
if (inflight) return inflight;
|
|
1393
1312
|
|
|
1394
|
-
const request = this
|
|
1313
|
+
const request = this.#fetchOlderPage(provider, id, resolvedCursor, context)
|
|
1395
1314
|
.finally(() => {
|
|
1396
|
-
this
|
|
1315
|
+
this.#olderPageRequests.delete(key);
|
|
1397
1316
|
});
|
|
1398
|
-
this
|
|
1317
|
+
this.#olderPageRequests.set(key, request);
|
|
1399
1318
|
return request;
|
|
1400
1319
|
}
|
|
1401
1320
|
|
|
1402
|
-
|
|
1321
|
+
async #fetchOlderPage(
|
|
1403
1322
|
provider: HistoryProvider,
|
|
1404
1323
|
id: string,
|
|
1405
1324
|
cursor: string,
|
|
@@ -1412,20 +1331,20 @@ export class AgentWidgetSession {
|
|
|
1412
1331
|
context,
|
|
1413
1332
|
});
|
|
1414
1333
|
// A conversation switch during the fetch retires this page.
|
|
1415
|
-
if (this
|
|
1334
|
+
if (this.#activeConversationId && this.#activeConversationId !== id) {
|
|
1416
1335
|
return { messages: [], nextMessageCursor: page.nextCursor };
|
|
1417
1336
|
}
|
|
1418
1337
|
const merged = mergeWireMessagesById(this.messages, page.messages);
|
|
1419
|
-
this.messages = this
|
|
1420
|
-
merged.map((message) => this
|
|
1338
|
+
this.messages = this.#sortMessages(
|
|
1339
|
+
merged.map((message) => this.#ensureSequence(message))
|
|
1421
1340
|
);
|
|
1422
|
-
this
|
|
1341
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1423
1342
|
for (const message of page.messages) {
|
|
1424
|
-
this
|
|
1343
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1425
1344
|
}
|
|
1426
1345
|
this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
|
|
1427
1346
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1428
|
-
this
|
|
1347
|
+
this.#emitHistoryState();
|
|
1429
1348
|
return { messages: page.messages, nextMessageCursor: page.nextCursor };
|
|
1430
1349
|
}
|
|
1431
1350
|
|
|
@@ -1436,34 +1355,34 @@ export class AgentWidgetSession {
|
|
|
1436
1355
|
public async startNewConversation(opts?: {
|
|
1437
1356
|
scope?: HistoryScope;
|
|
1438
1357
|
}): Promise<void> {
|
|
1439
|
-
const provider = this
|
|
1440
|
-
const context = this
|
|
1358
|
+
const provider = this.#requireHistoryProvider();
|
|
1359
|
+
const context = this.#historyContext(opts?.scope);
|
|
1441
1360
|
await this.awaitHistorySettled();
|
|
1442
|
-
const epoch = ++this
|
|
1361
|
+
const epoch = ++this.#historyOpenEpoch;
|
|
1443
1362
|
const prepared = await provider.prepareStartNew({ context });
|
|
1444
|
-
if (epoch !== this
|
|
1363
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1445
1364
|
prepared.discard();
|
|
1446
1365
|
throw new SessionHistoryError("superseded", "A later action won");
|
|
1447
1366
|
}
|
|
1448
|
-
await this
|
|
1367
|
+
await this.#installFreshConversation(prepared);
|
|
1449
1368
|
}
|
|
1450
1369
|
|
|
1451
1370
|
/**
|
|
1452
1371
|
* Clear local + persisted conversation state, then commit the replacement.
|
|
1453
1372
|
* Welcome injection is deliberately allowed once the transcript is empty.
|
|
1454
1373
|
*/
|
|
1455
|
-
|
|
1374
|
+
async #installFreshConversation(
|
|
1456
1375
|
prepared: PreparedHistoryActivation
|
|
1457
1376
|
): Promise<void> {
|
|
1458
|
-
this
|
|
1459
|
-
await this
|
|
1460
|
-
this
|
|
1461
|
-
this
|
|
1462
|
-
this
|
|
1463
|
-
this
|
|
1464
|
-
this
|
|
1465
|
-
this
|
|
1466
|
-
this
|
|
1377
|
+
this.#discardConversationState();
|
|
1378
|
+
await this.#commitActivation(prepared);
|
|
1379
|
+
this.#activeConversationId = prepared.conversationId || null;
|
|
1380
|
+
this.#activeConversationRevision = prepared.conversationRevision || null;
|
|
1381
|
+
this.#historyNextMessageCursor = null;
|
|
1382
|
+
this.#historyRecovery = null;
|
|
1383
|
+
this.#historySendBlocked = false;
|
|
1384
|
+
this.#persistConversationMetadata();
|
|
1385
|
+
this.#emitHistoryState();
|
|
1467
1386
|
}
|
|
1468
1387
|
|
|
1469
1388
|
/**
|
|
@@ -1474,12 +1393,12 @@ export class AgentWidgetSession {
|
|
|
1474
1393
|
id: string,
|
|
1475
1394
|
opts?: { scope?: HistoryScope }
|
|
1476
1395
|
): Promise<void> {
|
|
1477
|
-
const provider = this
|
|
1478
|
-
const context = this
|
|
1396
|
+
const provider = this.#requireHistoryProvider();
|
|
1397
|
+
const context = this.#historyContext(opts?.scope);
|
|
1479
1398
|
await this.awaitHistorySettled();
|
|
1480
1399
|
await provider.delete(id, { context });
|
|
1481
|
-
if (id !== this
|
|
1482
|
-
await this
|
|
1400
|
+
if (id !== this.#activeConversationId) return;
|
|
1401
|
+
await this.#recoverFromDestroyedConversation(context);
|
|
1483
1402
|
}
|
|
1484
1403
|
|
|
1485
1404
|
/** Visitor-scoped rename/star. Requires a provider with the update capability. */
|
|
@@ -1488,35 +1407,35 @@ export class AgentWidgetSession {
|
|
|
1488
1407
|
patch: HistoryConversationPatch,
|
|
1489
1408
|
opts?: { scope?: HistoryScope }
|
|
1490
1409
|
): Promise<HistoryConversationSummary> {
|
|
1491
|
-
const provider = this
|
|
1410
|
+
const provider = this.#requireHistoryProvider();
|
|
1492
1411
|
if (!provider.update) {
|
|
1493
1412
|
throw new SessionHistoryError(
|
|
1494
1413
|
"history_unavailable",
|
|
1495
1414
|
"This history provider cannot update conversations"
|
|
1496
1415
|
);
|
|
1497
1416
|
}
|
|
1498
|
-
const context = this
|
|
1417
|
+
const context = this.#historyContext(opts?.scope);
|
|
1499
1418
|
await this.awaitHistorySettled();
|
|
1500
1419
|
return provider.update(id, patch, { context });
|
|
1501
1420
|
}
|
|
1502
1421
|
|
|
1503
1422
|
/** True when the provider supports visitor-scoped rename/star. */
|
|
1504
1423
|
public canUpdateConversations(): boolean {
|
|
1505
|
-
return typeof this
|
|
1424
|
+
return typeof this.#historyProvider?.update === "function";
|
|
1506
1425
|
}
|
|
1507
1426
|
|
|
1508
1427
|
public async clearConversationHistory(opts?: {
|
|
1509
1428
|
targetId?: string;
|
|
1510
1429
|
scope?: HistoryScope;
|
|
1511
1430
|
}): Promise<{ deleted: number }> {
|
|
1512
|
-
const provider = this
|
|
1513
|
-
const context = this
|
|
1431
|
+
const provider = this.#requireHistoryProvider();
|
|
1432
|
+
const context = this.#historyContext(opts?.scope);
|
|
1514
1433
|
await this.awaitHistorySettled();
|
|
1515
1434
|
const result = await provider.deleteAll({
|
|
1516
1435
|
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1517
1436
|
context,
|
|
1518
1437
|
});
|
|
1519
|
-
await this
|
|
1438
|
+
await this.#recoverFromDestroyedConversation(context);
|
|
1520
1439
|
return result;
|
|
1521
1440
|
}
|
|
1522
1441
|
|
|
@@ -1527,65 +1446,65 @@ export class AgentWidgetSession {
|
|
|
1527
1446
|
public async resetHistoryDevice(): Promise<{
|
|
1528
1447
|
remoteRevocationConfirmed: boolean;
|
|
1529
1448
|
}> {
|
|
1530
|
-
const provider = this
|
|
1449
|
+
const provider = this.#requireHistoryProvider();
|
|
1531
1450
|
if (!provider.resetDevice) {
|
|
1532
1451
|
throw new SessionHistoryError(
|
|
1533
1452
|
"history_unavailable",
|
|
1534
1453
|
"This history provider cannot reset the device"
|
|
1535
1454
|
);
|
|
1536
1455
|
}
|
|
1537
|
-
this
|
|
1538
|
-
this
|
|
1456
|
+
this.#historyOpenEpoch += 1;
|
|
1457
|
+
this.#projectionFinalizationPromise = null;
|
|
1539
1458
|
try {
|
|
1540
1459
|
return await provider.resetDevice();
|
|
1541
1460
|
} finally {
|
|
1542
|
-
this
|
|
1543
|
-
this
|
|
1544
|
-
this
|
|
1545
|
-
this
|
|
1546
|
-
this
|
|
1547
|
-
this
|
|
1548
|
-
this
|
|
1461
|
+
this.#discardConversationState();
|
|
1462
|
+
this.#activeConversationId = null;
|
|
1463
|
+
this.#activeConversationRevision = null;
|
|
1464
|
+
this.#historyNextMessageCursor = null;
|
|
1465
|
+
this.#historyRecovery = null;
|
|
1466
|
+
this.#historySendBlocked = false;
|
|
1467
|
+
this.#emitHistoryState();
|
|
1549
1468
|
}
|
|
1550
1469
|
}
|
|
1551
1470
|
|
|
1552
|
-
|
|
1471
|
+
async #recoverFromDestroyedConversation(
|
|
1553
1472
|
context: HistoryOperationContext
|
|
1554
1473
|
): Promise<void> {
|
|
1555
1474
|
// 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
|
|
1475
|
+
this.#historyOpenEpoch += 1;
|
|
1476
|
+
this.#projectionFinalizationPromise = null;
|
|
1477
|
+
this.#discardConversationState();
|
|
1478
|
+
this.#activeConversationId = null;
|
|
1479
|
+
this.#activeConversationRevision = null;
|
|
1480
|
+
this.#historyNextMessageCursor = null;
|
|
1481
|
+
this.#historySendBlocked = true;
|
|
1482
|
+
this.#historyRecovery = null;
|
|
1483
|
+
this.#emitHistoryState();
|
|
1484
|
+
await this.#prepareReplacementConversation(context);
|
|
1566
1485
|
}
|
|
1567
1486
|
|
|
1568
1487
|
/**
|
|
1569
1488
|
* Never reinstalls the deleted session. A failed preparation leaves an
|
|
1570
1489
|
* explicit recoverable state instead of the old transcript.
|
|
1571
1490
|
*/
|
|
1572
|
-
|
|
1491
|
+
async #prepareReplacementConversation(
|
|
1573
1492
|
context: HistoryOperationContext
|
|
1574
1493
|
): Promise<void> {
|
|
1575
|
-
const provider = this
|
|
1494
|
+
const provider = this.#historyProvider;
|
|
1576
1495
|
if (!provider) {
|
|
1577
|
-
this
|
|
1578
|
-
this
|
|
1496
|
+
this.#historySendBlocked = false;
|
|
1497
|
+
this.#emitHistoryState();
|
|
1579
1498
|
return;
|
|
1580
1499
|
}
|
|
1581
1500
|
try {
|
|
1582
1501
|
const prepared = await provider.prepareStartNew({ context });
|
|
1583
|
-
await this
|
|
1502
|
+
await this.#installFreshConversation(prepared);
|
|
1584
1503
|
} catch {
|
|
1585
|
-
this
|
|
1586
|
-
this
|
|
1587
|
-
this
|
|
1588
|
-
this
|
|
1504
|
+
this.#historySendBlocked = true;
|
|
1505
|
+
this.#historyRecovery = "new_conversation_required";
|
|
1506
|
+
this.#emitHistoryState();
|
|
1507
|
+
this.#notifyHistory(
|
|
1589
1508
|
"new_conversation_required",
|
|
1590
1509
|
"That conversation was deleted. Start a new conversation to continue."
|
|
1591
1510
|
);
|
|
@@ -1593,13 +1512,13 @@ export class AgentWidgetSession {
|
|
|
1593
1512
|
}
|
|
1594
1513
|
|
|
1595
1514
|
/** The next explicit action retries a failed replacement preparation. */
|
|
1596
|
-
|
|
1597
|
-
if (this
|
|
1598
|
-
const provider = this
|
|
1515
|
+
async #retryRequiredConversation(): Promise<void> {
|
|
1516
|
+
if (this.#historyRecovery !== "new_conversation_required") return;
|
|
1517
|
+
const provider = this.#historyProvider;
|
|
1599
1518
|
if (!provider) return;
|
|
1600
|
-
this
|
|
1601
|
-
await this
|
|
1602
|
-
if (this
|
|
1519
|
+
this.#historyRecovery = null;
|
|
1520
|
+
await this.#prepareReplacementConversation(this.#historyContext());
|
|
1521
|
+
if (this.#historyRecovery === "new_conversation_required") {
|
|
1603
1522
|
throw new SessionHistoryError(
|
|
1604
1523
|
"new_conversation_required",
|
|
1605
1524
|
"A new conversation is required before sending"
|
|
@@ -1612,7 +1531,7 @@ export class AgentWidgetSession {
|
|
|
1612
1531
|
* session binding (`bindActivatedSession`); the fallback covers a shell that
|
|
1613
1532
|
* has not wired that callback yet.
|
|
1614
1533
|
*/
|
|
1615
|
-
|
|
1534
|
+
async #commitActivation(
|
|
1616
1535
|
prepared: PreparedHistoryActivation
|
|
1617
1536
|
): Promise<void> {
|
|
1618
1537
|
await prepared.commit();
|
|
@@ -1631,61 +1550,62 @@ export class AgentWidgetSession {
|
|
|
1631
1550
|
this.clientSession = session;
|
|
1632
1551
|
this.config.setStoredSessionId?.(session.sessionId);
|
|
1633
1552
|
if (session.conversationId) {
|
|
1634
|
-
this
|
|
1635
|
-
this
|
|
1553
|
+
this.#activeConversationId = session.conversationId;
|
|
1554
|
+
this.#activeConversationRevision = session.conversationRevision ?? null;
|
|
1636
1555
|
this.config.setStoredConversationId?.(session.conversationId);
|
|
1637
1556
|
this.historyInternals.setStoredConversationRevision?.(
|
|
1638
1557
|
session.conversationRevision ?? null
|
|
1639
1558
|
);
|
|
1640
1559
|
}
|
|
1641
|
-
if (!this
|
|
1560
|
+
if (!this.#suppressWelcomeInjection) this.#injectWelcomeMessage(session);
|
|
1642
1561
|
}
|
|
1643
1562
|
|
|
1644
|
-
|
|
1645
|
-
if (this
|
|
1646
|
-
this.config.setStoredConversationId?.(this
|
|
1563
|
+
#persistConversationMetadata(): void {
|
|
1564
|
+
if (this.#activeConversationId) {
|
|
1565
|
+
this.config.setStoredConversationId?.(this.#activeConversationId);
|
|
1647
1566
|
} else {
|
|
1648
1567
|
this.config.clearStoredConversationId?.();
|
|
1649
1568
|
}
|
|
1650
1569
|
this.historyInternals.setStoredConversationRevision?.(
|
|
1651
|
-
this
|
|
1570
|
+
this.#activeConversationRevision
|
|
1652
1571
|
);
|
|
1653
1572
|
this.historyInternals.setStoredMessageCursor?.(
|
|
1654
|
-
this
|
|
1573
|
+
this.#historyNextMessageCursor
|
|
1655
1574
|
);
|
|
1656
1575
|
}
|
|
1657
1576
|
|
|
1658
1577
|
/** Conversation-scoped caches that must not leak across records. */
|
|
1659
|
-
|
|
1578
|
+
#resetConversationScopedState(): void {
|
|
1660
1579
|
this.historyInternals.setStoredResumableHandle?.(null);
|
|
1661
1580
|
this.agentExecution = null;
|
|
1662
|
-
this
|
|
1581
|
+
this.#clearArtifactState();
|
|
1663
1582
|
this.webMcpInflightKeys.clear();
|
|
1664
1583
|
this.webMcpResolvedKeys.clear();
|
|
1665
1584
|
this.client.resetClientToolsFingerprint();
|
|
1666
|
-
this
|
|
1667
|
-
this
|
|
1585
|
+
this.#acknowledgedProjections.clear();
|
|
1586
|
+
this.#setPendingProjections(null);
|
|
1668
1587
|
}
|
|
1669
1588
|
|
|
1670
1589
|
/**
|
|
1671
1590
|
* Irreversible local + persisted clear. Used by new-conversation, active
|
|
1672
1591
|
* deletion, 410 recovery, reset, and the continuity wipe.
|
|
1673
1592
|
*/
|
|
1674
|
-
|
|
1593
|
+
#discardConversationState(): void {
|
|
1675
1594
|
this.stopSpeaking();
|
|
1676
1595
|
this.abortController?.abort();
|
|
1677
1596
|
this.abortController = null;
|
|
1678
|
-
this
|
|
1679
|
-
this
|
|
1597
|
+
this.#teardownReconnect();
|
|
1598
|
+
this.#abortWebMcpResolves();
|
|
1599
|
+
this.#actionsEpoch++;
|
|
1680
1600
|
this.messages = [];
|
|
1681
|
-
this
|
|
1682
|
-
this
|
|
1601
|
+
this.#resetConversationScopedState();
|
|
1602
|
+
this.#olderPageRequests.clear();
|
|
1683
1603
|
this.config.clearStoredSessionId?.();
|
|
1684
1604
|
this.config.clearStoredConversationId?.();
|
|
1685
1605
|
this.historyInternals.setStoredConversationRevision?.(null);
|
|
1686
1606
|
this.historyInternals.setStoredMessageCursor?.(null);
|
|
1687
|
-
this
|
|
1688
|
-
this
|
|
1607
|
+
this.#setStreaming(false);
|
|
1608
|
+
this.#setStatus("idle");
|
|
1689
1609
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1690
1610
|
}
|
|
1691
1611
|
|
|
@@ -1694,27 +1614,27 @@ export class AgentWidgetSession {
|
|
|
1694
1614
|
* cross-tab convergence, or credential expiry selected a different record.
|
|
1695
1615
|
* Invalidate synchronously; dispatch stays blocked until cleanup completes.
|
|
1696
1616
|
*/
|
|
1697
|
-
|
|
1617
|
+
#handleHistoryContinuityChanged(info: {
|
|
1698
1618
|
previousConversationId: string | null;
|
|
1699
1619
|
conversationId: string;
|
|
1700
1620
|
}): void {
|
|
1701
|
-
this
|
|
1702
|
-
this
|
|
1703
|
-
this
|
|
1704
|
-
this
|
|
1705
|
-
this
|
|
1706
|
-
this
|
|
1707
|
-
this
|
|
1708
|
-
this
|
|
1709
|
-
this
|
|
1710
|
-
this
|
|
1621
|
+
this.#historyOpenEpoch += 1;
|
|
1622
|
+
this.#projectionFinalizationPromise = null;
|
|
1623
|
+
this.#historySendBlocked = true;
|
|
1624
|
+
this.#discardConversationState();
|
|
1625
|
+
this.#activeConversationId = info.conversationId;
|
|
1626
|
+
this.#activeConversationRevision = null;
|
|
1627
|
+
this.#historyNextMessageCursor = null;
|
|
1628
|
+
this.#historyRecovery = null;
|
|
1629
|
+
this.#emitHistoryState();
|
|
1630
|
+
this.#notifyHistory(
|
|
1711
1631
|
"history_continuity_reset",
|
|
1712
1632
|
"This browser was reconnected to a different conversation, so the previous messages were cleared."
|
|
1713
1633
|
);
|
|
1714
|
-
this
|
|
1715
|
-
this
|
|
1716
|
-
this
|
|
1717
|
-
this
|
|
1634
|
+
this.#historyGate = Promise.resolve().then(() => {
|
|
1635
|
+
this.#historySendBlocked = false;
|
|
1636
|
+
this.#historyGate = null;
|
|
1637
|
+
this.#emitHistoryState();
|
|
1718
1638
|
});
|
|
1719
1639
|
}
|
|
1720
1640
|
|
|
@@ -1723,15 +1643,15 @@ export class AgentWidgetSession {
|
|
|
1723
1643
|
* the store. Owned here so the transition sits beside the continuity wipe it
|
|
1724
1644
|
* reuses.
|
|
1725
1645
|
*/
|
|
1726
|
-
|
|
1646
|
+
#syncVisitorStoreSubscription(): void {
|
|
1727
1647
|
const store = this.historyInternals.visitorStore ?? null;
|
|
1728
|
-
if (store === this
|
|
1729
|
-
this
|
|
1730
|
-
this
|
|
1731
|
-
this
|
|
1648
|
+
if (store === this.#subscribedVisitorStore) return;
|
|
1649
|
+
this.#visitorStoreUnsubscribe?.();
|
|
1650
|
+
this.#visitorStoreUnsubscribe = null;
|
|
1651
|
+
this.#subscribedVisitorStore = store;
|
|
1732
1652
|
if (!store) return;
|
|
1733
|
-
this
|
|
1734
|
-
this
|
|
1653
|
+
this.#visitorStoreUnsubscribe = store.subscribe((change) =>
|
|
1654
|
+
this.#handleExternalCredentialChange(change)
|
|
1735
1655
|
);
|
|
1736
1656
|
}
|
|
1737
1657
|
|
|
@@ -1741,40 +1661,40 @@ export class AgentWidgetSession {
|
|
|
1741
1661
|
* A "local" change is this tab's own mint/write and owns its init path; a
|
|
1742
1662
|
* change out of an empty store is first-init convergence, not a break.
|
|
1743
1663
|
*/
|
|
1744
|
-
|
|
1664
|
+
#handleExternalCredentialChange(change: VisitorStoreChange): void {
|
|
1745
1665
|
if (change.source !== "external" || change.previousToken === null) return;
|
|
1746
1666
|
|
|
1747
1667
|
// Nothing may reuse the session minted under the revoked credential.
|
|
1748
1668
|
this.clientSession = null;
|
|
1749
1669
|
this.client.handleExternalCredentialChange();
|
|
1750
|
-
this
|
|
1751
|
-
this
|
|
1752
|
-
this
|
|
1753
|
-
this
|
|
1670
|
+
this.#historyOpenEpoch += 1;
|
|
1671
|
+
this.#projectionFinalizationPromise = null;
|
|
1672
|
+
this.#credentialReinitPending = true;
|
|
1673
|
+
this.#credentialReinitPromise = null;
|
|
1754
1674
|
|
|
1755
1675
|
const hadVisibleTranscript = this.messages.length > 0;
|
|
1756
1676
|
const hadConversationState =
|
|
1757
1677
|
hadVisibleTranscript ||
|
|
1758
|
-
this
|
|
1678
|
+
this.#activeConversationId !== null ||
|
|
1759
1679
|
(this.config.getStoredConversationId?.() ?? null) !== null;
|
|
1760
1680
|
if (hadConversationState) {
|
|
1761
|
-
this
|
|
1681
|
+
this.#discardConversationState();
|
|
1762
1682
|
} else {
|
|
1763
1683
|
// No conversation to wipe, but the revoked visitor's ids must not survive.
|
|
1764
1684
|
this.config.clearStoredSessionId?.();
|
|
1765
1685
|
this.config.clearStoredConversationId?.();
|
|
1766
|
-
this
|
|
1767
|
-
this
|
|
1686
|
+
this.#olderPageRequests.clear();
|
|
1687
|
+
this.#setPendingProjections(null);
|
|
1768
1688
|
}
|
|
1769
|
-
this
|
|
1770
|
-
this
|
|
1771
|
-
this
|
|
1772
|
-
this
|
|
1773
|
-
this
|
|
1689
|
+
this.#activeConversationId = null;
|
|
1690
|
+
this.#activeConversationRevision = null;
|
|
1691
|
+
this.#historyNextMessageCursor = null;
|
|
1692
|
+
this.#historyRecovery = null;
|
|
1693
|
+
this.#emitHistoryState();
|
|
1774
1694
|
// Only a visible wipe announces: a silent reconcile must not push the open
|
|
1775
1695
|
// history view into a refetch that would immediately re-mint a credential.
|
|
1776
1696
|
if (hadVisibleTranscript) {
|
|
1777
|
-
this
|
|
1697
|
+
this.#notifyHistory(
|
|
1778
1698
|
"history_continuity_reset",
|
|
1779
1699
|
"This browser's conversation history was reset in another tab, so the previous messages were cleared."
|
|
1780
1700
|
);
|
|
@@ -1786,26 +1706,26 @@ export class AgentWidgetSession {
|
|
|
1786
1706
|
* Deliberately lazy: re-initializing on the storage event itself would hand
|
|
1787
1707
|
* an idle tab a fresh credential the visitor just deleted elsewhere.
|
|
1788
1708
|
*/
|
|
1789
|
-
|
|
1790
|
-
if (!this
|
|
1791
|
-
if (!this
|
|
1792
|
-
this
|
|
1793
|
-
this
|
|
1709
|
+
#completeCredentialReinit(): Promise<void> {
|
|
1710
|
+
if (!this.#credentialReinitPending) return Promise.resolve();
|
|
1711
|
+
if (!this.#credentialReinitPromise) {
|
|
1712
|
+
this.#credentialReinitPromise = this.#runCredentialReinit().finally(() => {
|
|
1713
|
+
this.#credentialReinitPromise = null;
|
|
1794
1714
|
});
|
|
1795
1715
|
}
|
|
1796
|
-
return this
|
|
1716
|
+
return this.#credentialReinitPromise;
|
|
1797
1717
|
}
|
|
1798
1718
|
|
|
1799
|
-
|
|
1719
|
+
async #runCredentialReinit(): Promise<void> {
|
|
1800
1720
|
if (!this.isClientTokenMode()) {
|
|
1801
|
-
this
|
|
1721
|
+
this.#credentialReinitPending = false;
|
|
1802
1722
|
return;
|
|
1803
1723
|
}
|
|
1804
1724
|
try {
|
|
1805
1725
|
// Reads the store again: a clear mints fresh, a replacement adopts the
|
|
1806
1726
|
// sibling's token.
|
|
1807
1727
|
this.bindActivatedSession(await this.client.initSession());
|
|
1808
|
-
this
|
|
1728
|
+
this.#credentialReinitPending = false;
|
|
1809
1729
|
} catch {
|
|
1810
1730
|
// Still pending: the dispatch's own init reports the failure normally.
|
|
1811
1731
|
}
|
|
@@ -1820,53 +1740,53 @@ export class AgentWidgetSession {
|
|
|
1820
1740
|
public reconcileBootConversation(opts?: {
|
|
1821
1741
|
scope?: HistoryScope;
|
|
1822
1742
|
}): Promise<void> {
|
|
1823
|
-
const provider = this
|
|
1743
|
+
const provider = this.#historyProvider;
|
|
1824
1744
|
const conversationId =
|
|
1825
1745
|
this.clientSession?.conversationId ??
|
|
1826
1746
|
this.config.getStoredConversationId?.() ??
|
|
1827
|
-
this
|
|
1747
|
+
this.#activeConversationId;
|
|
1828
1748
|
if (!provider || !conversationId) return Promise.resolve();
|
|
1829
1749
|
|
|
1830
|
-
const context = this
|
|
1831
|
-
this
|
|
1832
|
-
this
|
|
1833
|
-
const run = this
|
|
1750
|
+
const context = this.#historyContext(opts?.scope);
|
|
1751
|
+
this.#historySendBlocked = true;
|
|
1752
|
+
this.#emitHistoryState();
|
|
1753
|
+
const run = this.#runBootReconciliation(
|
|
1834
1754
|
provider,
|
|
1835
1755
|
conversationId,
|
|
1836
1756
|
context
|
|
1837
1757
|
).finally(() => {
|
|
1838
|
-
this
|
|
1839
|
-
if (this
|
|
1840
|
-
this
|
|
1758
|
+
this.#historySendBlocked = this.#historyRecovery !== null;
|
|
1759
|
+
if (this.#historyGate === run) this.#historyGate = null;
|
|
1760
|
+
this.#emitHistoryState();
|
|
1841
1761
|
});
|
|
1842
|
-
this
|
|
1762
|
+
this.#historyGate = run;
|
|
1843
1763
|
return run;
|
|
1844
1764
|
}
|
|
1845
1765
|
|
|
1846
|
-
|
|
1766
|
+
async #runBootReconciliation(
|
|
1847
1767
|
provider: HistoryProvider,
|
|
1848
1768
|
conversationId: string,
|
|
1849
1769
|
context: HistoryOperationContext
|
|
1850
1770
|
): Promise<void> {
|
|
1851
|
-
this
|
|
1771
|
+
this.#activeConversationId = conversationId;
|
|
1852
1772
|
const initRevision =
|
|
1853
1773
|
this.clientSession?.conversationRevision ??
|
|
1854
|
-
this
|
|
1774
|
+
this.#activeConversationRevision ??
|
|
1855
1775
|
null;
|
|
1856
|
-
const persistedRevision = this
|
|
1857
|
-
? this
|
|
1776
|
+
const persistedRevision = this.#bootRevisionCaptured
|
|
1777
|
+
? this.#bootConversationRevision
|
|
1858
1778
|
: (this.historyInternals.getStoredConversationRevision?.() ?? null);
|
|
1859
|
-
this
|
|
1860
|
-
this
|
|
1779
|
+
this.#bootRevisionCaptured = false;
|
|
1780
|
+
this.#historyNextMessageCursor =
|
|
1861
1781
|
this.historyInternals.getStoredMessageCursor?.() ??
|
|
1862
|
-
this
|
|
1782
|
+
this.#historyNextMessageCursor;
|
|
1863
1783
|
|
|
1864
1784
|
// Any locally finalized projection still owed to the server goes first, so
|
|
1865
1785
|
// display reconciliation cannot complete against a stale server record.
|
|
1866
1786
|
await this.replayPendingProjections();
|
|
1867
1787
|
|
|
1868
1788
|
if (initRevision && persistedRevision && initRevision === persistedRevision) {
|
|
1869
|
-
this
|
|
1789
|
+
this.#activeConversationRevision = initRevision;
|
|
1870
1790
|
return;
|
|
1871
1791
|
}
|
|
1872
1792
|
|
|
@@ -1878,9 +1798,9 @@ export class AgentWidgetSession {
|
|
|
1878
1798
|
let reconciled: AgentWidgetMessage[];
|
|
1879
1799
|
if (!overlaps) {
|
|
1880
1800
|
// No shared id: the local transcript is not this record's newest window.
|
|
1881
|
-
reconciled = page.messages.map((message) => this
|
|
1801
|
+
reconciled = page.messages.map((message) => this.#ensureSequence(message));
|
|
1882
1802
|
} else {
|
|
1883
|
-
const durableId = this
|
|
1803
|
+
const durableId = this.#resumable ? this.#activeAssistantMessageId : null;
|
|
1884
1804
|
const survivors = this.messages.filter((message) => {
|
|
1885
1805
|
if (serverIds.has(message.id)) return true;
|
|
1886
1806
|
if (message.id === durableId) return true;
|
|
@@ -1888,53 +1808,53 @@ export class AgentWidgetSession {
|
|
|
1888
1808
|
return pageOldest !== undefined && message.createdAt < pageOldest;
|
|
1889
1809
|
});
|
|
1890
1810
|
reconciled = mergeWireMessagesById(survivors, page.messages).map(
|
|
1891
|
-
(message) => this
|
|
1811
|
+
(message) => this.#ensureSequence(message)
|
|
1892
1812
|
);
|
|
1893
1813
|
}
|
|
1894
1814
|
|
|
1895
|
-
this
|
|
1896
|
-
this
|
|
1815
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1816
|
+
this.#activeConversationRevision =
|
|
1897
1817
|
page.conversationRevision || initRevision || null;
|
|
1898
|
-
this
|
|
1818
|
+
this.#acknowledgedProjections.clear();
|
|
1899
1819
|
for (const message of page.messages) {
|
|
1900
|
-
this
|
|
1820
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1901
1821
|
}
|
|
1902
1822
|
// Metadata first, transcript last: one persist covers both.
|
|
1903
|
-
this
|
|
1904
|
-
this.messages = this
|
|
1823
|
+
this.#persistConversationMetadata();
|
|
1824
|
+
this.messages = this.#sortMessages(reconciled);
|
|
1905
1825
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1906
1826
|
}
|
|
1907
1827
|
|
|
1908
1828
|
// ── Display-projection finalization ────────────────────────────
|
|
1909
1829
|
|
|
1910
1830
|
/** Runtype transport op: active session + browser scope, never a proof. */
|
|
1911
|
-
|
|
1831
|
+
#projectionFinalizationEnabled(): boolean {
|
|
1912
1832
|
return (
|
|
1913
1833
|
this.config.features?.history?.enabled === true &&
|
|
1914
1834
|
this.isClientTokenMode()
|
|
1915
1835
|
);
|
|
1916
1836
|
}
|
|
1917
1837
|
|
|
1918
|
-
|
|
1919
|
-
this
|
|
1838
|
+
#setPendingProjections(pending: PendingDisplayProjections | null): void {
|
|
1839
|
+
this.#pendingProjections = pending;
|
|
1920
1840
|
// Memory-only when the shell supplies no persistence seam.
|
|
1921
1841
|
this.historyInternals.setPendingDisplayProjections?.(pending);
|
|
1922
1842
|
}
|
|
1923
1843
|
|
|
1924
|
-
|
|
1844
|
+
#readPendingProjections(): PendingDisplayProjections | null {
|
|
1925
1845
|
return (
|
|
1926
1846
|
this.historyInternals.getPendingDisplayProjections?.() ??
|
|
1927
|
-
this
|
|
1847
|
+
this.#pendingProjections
|
|
1928
1848
|
);
|
|
1929
1849
|
}
|
|
1930
1850
|
|
|
1931
1851
|
/** Record what the dispatch payload carried as each message's projection. */
|
|
1932
|
-
|
|
1933
|
-
if (!this
|
|
1852
|
+
#recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
|
|
1853
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1934
1854
|
for (const message of messages) {
|
|
1935
1855
|
const projection = divergentDisplayProjection(message);
|
|
1936
1856
|
if (projection !== undefined) {
|
|
1937
|
-
this
|
|
1857
|
+
this.#acknowledgedProjections.set(message.id, projection);
|
|
1938
1858
|
}
|
|
1939
1859
|
}
|
|
1940
1860
|
}
|
|
@@ -1943,57 +1863,57 @@ export class AgentWidgetSession {
|
|
|
1943
1863
|
* After a terminal assistant message, finalize any browser-derived projection
|
|
1944
1864
|
* the server cannot have. Painting is never delayed by this.
|
|
1945
1865
|
*/
|
|
1946
|
-
|
|
1947
|
-
if (!this
|
|
1948
|
-
const conversationId = this
|
|
1866
|
+
#scheduleProjectionFinalization(): void {
|
|
1867
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1868
|
+
const conversationId = this.#activeConversationId;
|
|
1949
1869
|
if (!conversationId) return;
|
|
1950
1870
|
const batch: HistoryDisplayProjection[] = [];
|
|
1951
1871
|
for (const message of this.messages) {
|
|
1952
1872
|
if (message.role !== "assistant" || message.streaming) continue;
|
|
1953
1873
|
const projection = divergentDisplayProjection(message);
|
|
1954
1874
|
if (projection === undefined) continue;
|
|
1955
|
-
if (this
|
|
1875
|
+
if (this.#acknowledgedProjections.get(message.id) === projection) continue;
|
|
1956
1876
|
batch.push({ id: message.id, displayContent: projection });
|
|
1957
1877
|
}
|
|
1958
1878
|
if (batch.length === 0) return;
|
|
1959
|
-
this
|
|
1879
|
+
this.#setPendingProjections({
|
|
1960
1880
|
conversationId,
|
|
1961
1881
|
messageIds: batch.map((item) => item.id),
|
|
1962
1882
|
});
|
|
1963
|
-
this
|
|
1883
|
+
this.#enqueueProjectionFinalization(conversationId, batch);
|
|
1964
1884
|
}
|
|
1965
1885
|
|
|
1966
1886
|
/** Serialized: operations await this chain instead of racing it. */
|
|
1967
|
-
|
|
1887
|
+
#enqueueProjectionFinalization(
|
|
1968
1888
|
conversationId: string,
|
|
1969
1889
|
batch: HistoryDisplayProjection[]
|
|
1970
1890
|
): void {
|
|
1971
|
-
const previous = this
|
|
1891
|
+
const previous = this.#projectionFinalizationPromise ?? Promise.resolve();
|
|
1972
1892
|
const chain = previous
|
|
1973
1893
|
.catch(() => {})
|
|
1974
|
-
.then(() => this
|
|
1894
|
+
.then(() => this.#finalizeProjections(conversationId, batch, false))
|
|
1975
1895
|
.finally(() => {
|
|
1976
|
-
if (this
|
|
1977
|
-
this
|
|
1896
|
+
if (this.#projectionFinalizationPromise === chain) {
|
|
1897
|
+
this.#projectionFinalizationPromise = null;
|
|
1978
1898
|
}
|
|
1979
1899
|
});
|
|
1980
|
-
this
|
|
1900
|
+
this.#projectionFinalizationPromise = chain;
|
|
1981
1901
|
}
|
|
1982
1902
|
|
|
1983
|
-
|
|
1903
|
+
async #finalizeProjections(
|
|
1984
1904
|
conversationId: string,
|
|
1985
1905
|
batch: HistoryDisplayProjection[],
|
|
1986
1906
|
retried: boolean
|
|
1987
1907
|
): Promise<void> {
|
|
1988
1908
|
// A switch/reset/delete already invalidated this marker.
|
|
1989
|
-
if (this
|
|
1909
|
+
if (this.#activeConversationId !== conversationId) return;
|
|
1990
1910
|
// Two terminal scans can enqueue the same batch before the first PATCH
|
|
1991
1911
|
// acknowledges; the chain serializes them, so re-filter at run time.
|
|
1992
1912
|
const unacknowledged = batch.filter(
|
|
1993
|
-
(item) => this
|
|
1913
|
+
(item) => this.#acknowledgedProjections.get(item.id) !== item.displayContent
|
|
1994
1914
|
);
|
|
1995
1915
|
if (unacknowledged.length === 0) {
|
|
1996
|
-
this
|
|
1916
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
1997
1917
|
return;
|
|
1998
1918
|
}
|
|
1999
1919
|
try {
|
|
@@ -2002,15 +1922,15 @@ export class AgentWidgetSession {
|
|
|
2002
1922
|
unacknowledged
|
|
2003
1923
|
);
|
|
2004
1924
|
for (const item of unacknowledged) {
|
|
2005
|
-
this
|
|
1925
|
+
this.#acknowledgedProjections.set(item.id, item.displayContent);
|
|
2006
1926
|
}
|
|
2007
|
-
if (this
|
|
2008
|
-
this
|
|
1927
|
+
if (this.#activeConversationId === conversationId && conversationRevision) {
|
|
1928
|
+
this.#activeConversationRevision = conversationRevision;
|
|
2009
1929
|
}
|
|
2010
|
-
this
|
|
1930
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
2011
1931
|
} catch (error) {
|
|
2012
|
-
if (!retried && this
|
|
2013
|
-
await this
|
|
1932
|
+
if (!retried && this.#isTransientProjectionError(error)) {
|
|
1933
|
+
await this.#finalizeProjections(conversationId, unacknowledged, true);
|
|
2014
1934
|
return;
|
|
2015
1935
|
}
|
|
2016
1936
|
if (
|
|
@@ -2018,11 +1938,11 @@ export class AgentWidgetSession {
|
|
|
2018
1938
|
(error.code === "not_found" || error.code === "conversation_deleted")
|
|
2019
1939
|
) {
|
|
2020
1940
|
// The record is gone: drop the projection instead of retrying forever.
|
|
2021
|
-
this
|
|
1941
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
2022
1942
|
return;
|
|
2023
1943
|
}
|
|
2024
1944
|
// Marker retained for the next boot/dispatch; the turn itself succeeded.
|
|
2025
|
-
this
|
|
1945
|
+
this.#notifyHistory(
|
|
2026
1946
|
"projection_finalization_failed",
|
|
2027
1947
|
"Some message formatting could not be saved for history; it will be retried."
|
|
2028
1948
|
);
|
|
@@ -2030,20 +1950,20 @@ export class AgentWidgetSession {
|
|
|
2030
1950
|
}
|
|
2031
1951
|
|
|
2032
1952
|
/** Transport-shaped failures only; a contract failure is not retried. */
|
|
2033
|
-
|
|
1953
|
+
#isTransientProjectionError(error: unknown): boolean {
|
|
2034
1954
|
if (error instanceof HistoryClientError) return error.code === "request_failed";
|
|
2035
1955
|
return error instanceof Error;
|
|
2036
1956
|
}
|
|
2037
1957
|
|
|
2038
|
-
|
|
1958
|
+
#clearFinalizedMarker(
|
|
2039
1959
|
conversationId: string,
|
|
2040
1960
|
batch: HistoryDisplayProjection[]
|
|
2041
1961
|
): void {
|
|
2042
|
-
const pending = this
|
|
1962
|
+
const pending = this.#readPendingProjections();
|
|
2043
1963
|
if (!pending || pending.conversationId !== conversationId) return;
|
|
2044
1964
|
const done = new Set(batch.map((item) => item.id));
|
|
2045
1965
|
const remaining = pending.messageIds.filter((id) => !done.has(id));
|
|
2046
|
-
this
|
|
1966
|
+
this.#setPendingProjections(
|
|
2047
1967
|
remaining.length ? { conversationId, messageIds: remaining } : null
|
|
2048
1968
|
);
|
|
2049
1969
|
}
|
|
@@ -2053,15 +1973,15 @@ export class AgentWidgetSession {
|
|
|
2053
1973
|
* message content, then finalize before display reconciliation completes.
|
|
2054
1974
|
*/
|
|
2055
1975
|
public async replayPendingProjections(): Promise<void> {
|
|
2056
|
-
if (!this
|
|
2057
|
-
const pending = this
|
|
1976
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1977
|
+
const pending = this.#readPendingProjections();
|
|
2058
1978
|
if (!pending) return;
|
|
2059
1979
|
if (
|
|
2060
|
-
this
|
|
2061
|
-
pending.conversationId !== this
|
|
1980
|
+
this.#activeConversationId &&
|
|
1981
|
+
pending.conversationId !== this.#activeConversationId
|
|
2062
1982
|
) {
|
|
2063
1983
|
// Stale marker from another record: never replay it into this one.
|
|
2064
|
-
this
|
|
1984
|
+
this.#setPendingProjections(null);
|
|
2065
1985
|
return;
|
|
2066
1986
|
}
|
|
2067
1987
|
const batch: HistoryDisplayProjection[] = [];
|
|
@@ -2071,33 +1991,33 @@ export class AgentWidgetSession {
|
|
|
2071
1991
|
batch.push({ id, displayContent: message.content });
|
|
2072
1992
|
}
|
|
2073
1993
|
if (batch.length === 0) {
|
|
2074
|
-
this
|
|
1994
|
+
this.#setPendingProjections(null);
|
|
2075
1995
|
return;
|
|
2076
1996
|
}
|
|
2077
|
-
this
|
|
2078
|
-
await this
|
|
1997
|
+
this.#enqueueProjectionFinalization(pending.conversationId, batch);
|
|
1998
|
+
await this.#projectionFinalizationPromise?.catch(() => {});
|
|
2079
1999
|
}
|
|
2080
2000
|
|
|
2081
2001
|
/**
|
|
2082
2002
|
* A local transcript mutation whose response carries no new revision: clear
|
|
2083
2003
|
* the persisted one rather than guessing, so the next boot refreshes safely.
|
|
2084
2004
|
*/
|
|
2085
|
-
|
|
2086
|
-
if (!this
|
|
2087
|
-
this
|
|
2005
|
+
#invalidateConversationRevision(): void {
|
|
2006
|
+
if (!this.#activeConversationId) return;
|
|
2007
|
+
this.#activeConversationRevision = null;
|
|
2088
2008
|
this.historyInternals.setStoredConversationRevision?.(null);
|
|
2089
2009
|
}
|
|
2090
2010
|
|
|
2091
2011
|
/** Blocks a send until the owning history transition finishes. */
|
|
2092
|
-
|
|
2012
|
+
async #awaitHistorySendable(): Promise<void> {
|
|
2093
2013
|
await this.awaitHistorySettled();
|
|
2094
|
-
await this
|
|
2095
|
-
if (this
|
|
2096
|
-
await this
|
|
2014
|
+
await this.#completeCredentialReinit();
|
|
2015
|
+
if (this.#historyRecovery === "new_conversation_required") {
|
|
2016
|
+
await this.#retryRequiredConversation();
|
|
2097
2017
|
}
|
|
2098
2018
|
}
|
|
2099
2019
|
|
|
2100
|
-
|
|
2020
|
+
static #isConversationDeleted(error: unknown): boolean {
|
|
2101
2021
|
return (
|
|
2102
2022
|
error instanceof HistoryClientError &&
|
|
2103
2023
|
error.code === "conversation_deleted"
|
|
@@ -2108,14 +2028,14 @@ export class AgentWidgetSession {
|
|
|
2108
2028
|
* Safe 410 recovery (D5). The old payload would recreate the transcript that
|
|
2109
2029
|
* was just deleted, so the retry carries ONLY the just-submitted user turn.
|
|
2110
2030
|
*/
|
|
2111
|
-
|
|
2031
|
+
async #dispatchWithDeletedRecovery(
|
|
2112
2032
|
snapshot: AgentWidgetMessage[],
|
|
2113
2033
|
controller: AbortController,
|
|
2114
2034
|
assistantMessageId: string,
|
|
2115
2035
|
userMessageId: string,
|
|
2116
2036
|
turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean }
|
|
2117
2037
|
): Promise<void> {
|
|
2118
|
-
this
|
|
2038
|
+
this.#recordDispatchedProjections(snapshot);
|
|
2119
2039
|
try {
|
|
2120
2040
|
await this.client.dispatch(
|
|
2121
2041
|
{
|
|
@@ -2129,12 +2049,12 @@ export class AgentWidgetSession {
|
|
|
2129
2049
|
);
|
|
2130
2050
|
return;
|
|
2131
2051
|
} catch (error) {
|
|
2132
|
-
if (!AgentWidgetSession
|
|
2133
|
-
const replacement = await this
|
|
2052
|
+
if (!AgentWidgetSession.#isConversationDeleted(error)) throw error;
|
|
2053
|
+
const replacement = await this.#recoverFromDeletedConversation(userMessageId);
|
|
2134
2054
|
// No recoverable turn (or no fresh record): surface the original 410.
|
|
2135
2055
|
if (!replacement) throw error;
|
|
2136
2056
|
// A second 410 propagates: exactly one recovery attempt per turn.
|
|
2137
|
-
this
|
|
2057
|
+
this.#recordDispatchedProjections([replacement]);
|
|
2138
2058
|
await this.client.dispatch(
|
|
2139
2059
|
{
|
|
2140
2060
|
messages: [replacement],
|
|
@@ -2151,7 +2071,7 @@ export class AgentWidgetSession {
|
|
|
2151
2071
|
* Capture only the newly submitted user message, destroy the deleted record's
|
|
2152
2072
|
* local state, and initialize a fresh owned conversation to retry into.
|
|
2153
2073
|
*/
|
|
2154
|
-
|
|
2074
|
+
async #recoverFromDeletedConversation(
|
|
2155
2075
|
userMessageId: string
|
|
2156
2076
|
): Promise<AgentWidgetMessage | null> {
|
|
2157
2077
|
const submitted = this.messages.find(
|
|
@@ -2160,44 +2080,44 @@ export class AgentWidgetSession {
|
|
|
2160
2080
|
if (!submitted) return null;
|
|
2161
2081
|
const captured: AgentWidgetMessage = { ...submitted };
|
|
2162
2082
|
|
|
2163
|
-
this
|
|
2164
|
-
this
|
|
2165
|
-
this
|
|
2166
|
-
this
|
|
2167
|
-
this
|
|
2168
|
-
this
|
|
2083
|
+
this.#historyOpenEpoch += 1;
|
|
2084
|
+
this.#projectionFinalizationPromise = null;
|
|
2085
|
+
this.#discardConversationState();
|
|
2086
|
+
this.#activeConversationId = null;
|
|
2087
|
+
this.#activeConversationRevision = null;
|
|
2088
|
+
this.#historyNextMessageCursor = null;
|
|
2169
2089
|
|
|
2170
2090
|
// The recovered turn is the whole transcript: no welcome bubble in front.
|
|
2171
|
-
this
|
|
2091
|
+
this.#suppressWelcomeInjection = true;
|
|
2172
2092
|
try {
|
|
2173
|
-
const provider = this
|
|
2093
|
+
const provider = this.#historyProvider;
|
|
2174
2094
|
if (provider) {
|
|
2175
2095
|
const prepared = await provider.prepareStartNew({
|
|
2176
|
-
context: this
|
|
2096
|
+
context: this.#historyContext(),
|
|
2177
2097
|
});
|
|
2178
|
-
await this
|
|
2098
|
+
await this.#installFreshConversation(prepared);
|
|
2179
2099
|
} else {
|
|
2180
2100
|
this.client.clearClientSession();
|
|
2181
2101
|
const session = await this.client.initSession();
|
|
2182
2102
|
this.bindActivatedSession(session);
|
|
2183
2103
|
}
|
|
2184
2104
|
} catch {
|
|
2185
|
-
this
|
|
2186
|
-
this
|
|
2187
|
-
this
|
|
2188
|
-
this
|
|
2105
|
+
this.#historySendBlocked = true;
|
|
2106
|
+
this.#historyRecovery = "new_conversation_required";
|
|
2107
|
+
this.#emitHistoryState();
|
|
2108
|
+
this.#notifyHistory(
|
|
2189
2109
|
"new_conversation_required",
|
|
2190
2110
|
"That conversation was deleted. Start a new conversation to continue."
|
|
2191
2111
|
);
|
|
2192
2112
|
return null;
|
|
2193
2113
|
} finally {
|
|
2194
|
-
this
|
|
2114
|
+
this.#suppressWelcomeInjection = false;
|
|
2195
2115
|
}
|
|
2196
2116
|
|
|
2197
2117
|
// Restore only the user's own turn: it is the entire new transcript.
|
|
2198
|
-
this
|
|
2199
|
-
this
|
|
2200
|
-
this
|
|
2118
|
+
this.#appendMessage(captured);
|
|
2119
|
+
this.#setStreaming(true);
|
|
2120
|
+
this.#notifyHistory(
|
|
2201
2121
|
"conversation_deleted_recovered",
|
|
2202
2122
|
"That conversation was deleted. Started a new conversation."
|
|
2203
2123
|
);
|
|
@@ -2229,7 +2149,7 @@ export class AgentWidgetSession {
|
|
|
2229
2149
|
// turn that's restyling the widget and strand the paused execution.
|
|
2230
2150
|
if (!replaceClient) {
|
|
2231
2151
|
this.client.updateConfig(merged);
|
|
2232
|
-
if (artifactDisplayChanged) this
|
|
2152
|
+
if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
|
|
2233
2153
|
return;
|
|
2234
2154
|
}
|
|
2235
2155
|
|
|
@@ -2240,13 +2160,13 @@ export class AgentWidgetSession {
|
|
|
2240
2160
|
// fresh client: in client-token mode that would POST /resume without a
|
|
2241
2161
|
// valid sessionId and strand the paused turn. Mirrors clearMessages' WebMCP
|
|
2242
2162
|
// reset; the client swap already abandons any in-flight stream regardless.
|
|
2243
|
-
this
|
|
2163
|
+
this.#abortWebMcpResolves();
|
|
2244
2164
|
this.webMcpInflightKeys.clear();
|
|
2245
2165
|
this.webMcpResolvedKeys.clear();
|
|
2246
2166
|
const prevSSECallback = this.client.getSSEEventCallback();
|
|
2247
2167
|
this.client = new AgentWidgetClient(this.config, this.historyInternals);
|
|
2248
|
-
if (artifactDisplayChanged) this
|
|
2249
|
-
this
|
|
2168
|
+
if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
|
|
2169
|
+
this.#wireDefaultWebMcpConfirm();
|
|
2250
2170
|
if (prevSSECallback) {
|
|
2251
2171
|
this.client.setSSEEventCallback(prevSSECallback);
|
|
2252
2172
|
}
|
|
@@ -2307,6 +2227,7 @@ export class AgentWidgetSession {
|
|
|
2307
2227
|
sequence,
|
|
2308
2228
|
streaming = false,
|
|
2309
2229
|
voiceProcessing,
|
|
2230
|
+
voiceCaption,
|
|
2310
2231
|
rawContent
|
|
2311
2232
|
} = options;
|
|
2312
2233
|
|
|
@@ -2324,12 +2245,13 @@ export class AgentWidgetSession {
|
|
|
2324
2245
|
role,
|
|
2325
2246
|
content,
|
|
2326
2247
|
createdAt: createdAt ?? new Date().toISOString(),
|
|
2327
|
-
sequence: sequence ?? this
|
|
2248
|
+
sequence: sequence ?? this.#nextSequence(),
|
|
2328
2249
|
streaming,
|
|
2329
2250
|
// Only include optional fields if provided
|
|
2330
2251
|
...(llmContent !== undefined && { llmContent }),
|
|
2331
2252
|
...(contentParts !== undefined && { contentParts }),
|
|
2332
2253
|
...(voiceProcessing !== undefined && { voiceProcessing }),
|
|
2254
|
+
...(voiceCaption && { voiceCaption }),
|
|
2333
2255
|
...(rawContent !== undefined && { rawContent })
|
|
2334
2256
|
};
|
|
2335
2257
|
|
|
@@ -2427,7 +2349,7 @@ export class AgentWidgetSession {
|
|
|
2427
2349
|
role,
|
|
2428
2350
|
content,
|
|
2429
2351
|
createdAt: createdAt ?? new Date().toISOString(),
|
|
2430
|
-
sequence: sequence ?? this
|
|
2352
|
+
sequence: sequence ?? this.#nextSequence(),
|
|
2431
2353
|
streaming,
|
|
2432
2354
|
...(llmContent !== undefined && { llmContent }),
|
|
2433
2355
|
...(contentParts !== undefined && { contentParts }),
|
|
@@ -2439,7 +2361,7 @@ export class AgentWidgetSession {
|
|
|
2439
2361
|
}
|
|
2440
2362
|
|
|
2441
2363
|
// Add all messages, sort once, notify once
|
|
2442
|
-
this.messages = this
|
|
2364
|
+
this.messages = this.#sortMessages([...this.messages, ...results]);
|
|
2443
2365
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
2444
2366
|
|
|
2445
2367
|
return results;
|
|
@@ -2500,7 +2422,7 @@ export class AgentWidgetSession {
|
|
|
2500
2422
|
* client to namespace into the request `context`. Failures were already
|
|
2501
2423
|
* dropped by `finalize()`.
|
|
2502
2424
|
*/
|
|
2503
|
-
|
|
2425
|
+
async #applyMentionBundle(
|
|
2504
2426
|
userMessage: AgentWidgetMessage,
|
|
2505
2427
|
typedText: string,
|
|
2506
2428
|
finalize: () => Promise<MentionSubmitBundle>
|
|
@@ -2598,6 +2520,13 @@ export class AgentWidgetSession {
|
|
|
2598
2520
|
* (`composer.streamingSubmitBehavior: "interrupt"`).
|
|
2599
2521
|
*/
|
|
2600
2522
|
interrupt?: boolean;
|
|
2523
|
+
/**
|
|
2524
|
+
* Internal (voice client delegation): this is the delegated voice turn.
|
|
2525
|
+
* `userMessageId` names an existing transcript bubble to submit as the
|
|
2526
|
+
* turn's user message instead of appending a new one, so the spoken
|
|
2527
|
+
* request appears (and is sent) exactly once.
|
|
2528
|
+
*/
|
|
2529
|
+
voiceTurn?: { userMessageId?: string };
|
|
2601
2530
|
}
|
|
2602
2531
|
) {
|
|
2603
2532
|
const input = rawInput.trim();
|
|
@@ -2617,16 +2546,23 @@ export class AgentWidgetSession {
|
|
|
2617
2546
|
)
|
|
2618
2547
|
return;
|
|
2619
2548
|
|
|
2549
|
+
// Any other send replaces a delegated voice turn still in flight: that
|
|
2550
|
+
// turn fails, and this one's reply is neither its answer nor voice-spoken.
|
|
2551
|
+
if (this.#voiceDelegation && !options?.voiceTurn) {
|
|
2552
|
+
this.#voiceDelegation.failed = true;
|
|
2553
|
+
this.#voiceDelegation = null;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2620
2556
|
// History transitions (continuity wipe, boot reconciliation, projection
|
|
2621
2557
|
// finalization, replacement init, external credential change) own the
|
|
2622
2558
|
// record this turn would land in.
|
|
2623
2559
|
if (
|
|
2624
|
-
this
|
|
2625
|
-
this
|
|
2626
|
-
this
|
|
2627
|
-
this
|
|
2560
|
+
this.#historyGate ||
|
|
2561
|
+
this.#projectionFinalizationPromise ||
|
|
2562
|
+
this.#historySendBlocked ||
|
|
2563
|
+
this.#credentialReinitPending
|
|
2628
2564
|
) {
|
|
2629
|
-
await this
|
|
2565
|
+
await this.#awaitHistorySendable();
|
|
2630
2566
|
}
|
|
2631
2567
|
|
|
2632
2568
|
this.stopSpeaking();
|
|
@@ -2635,18 +2571,23 @@ export class AgentWidgetSession {
|
|
|
2635
2571
|
// turn. Tear them down here (they own controllers separate from the shared
|
|
2636
2572
|
// one) so a lingering resolve can't race the new dispatch or post a stale
|
|
2637
2573
|
// /resume against a superseded execution.
|
|
2638
|
-
this
|
|
2574
|
+
this.#abortWebMcpResolves();
|
|
2575
|
+
this.#actionsEpoch++;
|
|
2639
2576
|
// A new turn also supersedes any pending durable reconnect from the prior
|
|
2640
2577
|
// turn (cancels backoff/listeners, clears the old resume handle).
|
|
2641
|
-
this
|
|
2578
|
+
this.#teardownReconnect();
|
|
2642
2579
|
|
|
2580
|
+
const voiceBubbleId = options?.voiceTurn?.userMessageId;
|
|
2581
|
+
const voiceBubble = voiceBubbleId
|
|
2582
|
+
? this.messages.find((m) => m.id === voiceBubbleId && m.role === "user")
|
|
2583
|
+
: undefined;
|
|
2643
2584
|
// Generate IDs for both user message and expected assistant response
|
|
2644
|
-
const userMessageId = generateUserMessageId();
|
|
2585
|
+
const userMessageId = voiceBubble?.id ?? generateUserMessageId();
|
|
2645
2586
|
const assistantMessageId = generateAssistantMessageId();
|
|
2646
2587
|
// The active assistant bubble for a durable reconnect is captured from the
|
|
2647
2588
|
// real streamed message events (see handleEvent), not pre-assigned here:
|
|
2648
2589
|
// the proxy path auto-generates a different id than `assistantMessageId`.
|
|
2649
|
-
this
|
|
2590
|
+
this.#activeAssistantMessageId = null;
|
|
2650
2591
|
|
|
2651
2592
|
// Fallback display text ONLY when the sole content is image attachments.
|
|
2652
2593
|
// A mention/command-only submit (empty text + a chip) must NOT read as
|
|
@@ -2661,7 +2602,7 @@ export class AgentWidgetSession {
|
|
|
2661
2602
|
role: "user",
|
|
2662
2603
|
content: input || imageOnlyFallback, // Display text (fallback if only images)
|
|
2663
2604
|
createdAt: new Date().toISOString(),
|
|
2664
|
-
sequence: this
|
|
2605
|
+
sequence: this.#nextSequence(),
|
|
2665
2606
|
viaVoice: options?.viaVoice || false,
|
|
2666
2607
|
// Include contentParts if provided (for multi-modal messages)
|
|
2667
2608
|
...(options?.contentParts && options.contentParts.length > 0 && {
|
|
@@ -2681,8 +2622,17 @@ export class AgentWidgetSession {
|
|
|
2681
2622
|
...(options?.replayFields ?? {})
|
|
2682
2623
|
};
|
|
2683
2624
|
|
|
2684
|
-
|
|
2685
|
-
|
|
2625
|
+
if (voiceBubble) {
|
|
2626
|
+
// The transcript bubble is the user message: the model gets the request
|
|
2627
|
+
// text the voice model delegated, the bubble keeps what was heard.
|
|
2628
|
+
voiceBubble.viaVoice = true;
|
|
2629
|
+
delete voiceBubble.voiceCaption;
|
|
2630
|
+
if (voiceBubble.content.trim() !== input) voiceBubble.llmContent = input;
|
|
2631
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
2632
|
+
} else {
|
|
2633
|
+
this.#appendMessage(userMessage);
|
|
2634
|
+
}
|
|
2635
|
+
this.#setStreaming(true);
|
|
2686
2636
|
|
|
2687
2637
|
// Assign the fresh controller BEFORE the mention await so cancel() (or a
|
|
2688
2638
|
// superseding sendMessage) during finalize() aborts THIS turn, not a stale
|
|
@@ -2699,7 +2649,7 @@ export class AgentWidgetSession {
|
|
|
2699
2649
|
if (options?.mentions) {
|
|
2700
2650
|
const stored =
|
|
2701
2651
|
this.messages.find((m) => m.id === userMessageId) ?? userMessage;
|
|
2702
|
-
await this
|
|
2652
|
+
await this.#applyMentionBundle(stored, input, options.mentions.finalize);
|
|
2703
2653
|
// A cancel() or new sendMessage during finalize aborted this controller
|
|
2704
2654
|
// (and replaced/nulled the shared ref). Bail without dispatching and
|
|
2705
2655
|
// leave whatever idle/streaming state that caller already set.
|
|
@@ -2729,10 +2679,17 @@ export class AgentWidgetSession {
|
|
|
2729
2679
|
}
|
|
2730
2680
|
}
|
|
2731
2681
|
|
|
2732
|
-
|
|
2682
|
+
// A submitted voice bubble may sit above captions or a turn that finished
|
|
2683
|
+
// while it waited: the request still ends on it.
|
|
2684
|
+
const snapshot = voiceBubble
|
|
2685
|
+
? [
|
|
2686
|
+
...this.messages.filter((m) => m !== voiceBubble),
|
|
2687
|
+
{ ...voiceBubble, createdAt: new Date().toISOString() }
|
|
2688
|
+
]
|
|
2689
|
+
: [...this.messages];
|
|
2733
2690
|
|
|
2734
2691
|
try {
|
|
2735
|
-
await this
|
|
2692
|
+
await this.#dispatchWithDeletedRecovery(
|
|
2736
2693
|
snapshot,
|
|
2737
2694
|
controller,
|
|
2738
2695
|
assistantMessageId,
|
|
@@ -2747,7 +2704,8 @@ export class AgentWidgetSession {
|
|
|
2747
2704
|
// first, which already flipped us into `resuming` and armed reconnect.
|
|
2748
2705
|
// The subsequent dispatch rejection must NOT paint a dispatch-error
|
|
2749
2706
|
// bubble: the turn is being resumed, not failed.
|
|
2750
|
-
if (this.status === "resuming" || this
|
|
2707
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
2708
|
+
if (this.#voiceDelegation) this.#voiceDelegation.failed = true;
|
|
2751
2709
|
// Check if this is an abort error (user canceled, navigated away, etc.)
|
|
2752
2710
|
// In these cases, don't show fallback - the request was intentionally interrupted
|
|
2753
2711
|
const isAbortError =
|
|
@@ -2769,15 +2727,15 @@ export class AgentWidgetSession {
|
|
|
2769
2727
|
role: "assistant",
|
|
2770
2728
|
createdAt: new Date().toISOString(),
|
|
2771
2729
|
content,
|
|
2772
|
-
sequence: this
|
|
2730
|
+
sequence: this.#nextSequence()
|
|
2773
2731
|
};
|
|
2774
2732
|
|
|
2775
|
-
this
|
|
2733
|
+
this.#appendMessage(fallback);
|
|
2776
2734
|
}
|
|
2777
2735
|
}
|
|
2778
2736
|
|
|
2779
|
-
this
|
|
2780
|
-
this
|
|
2737
|
+
this.#setStatus("idle");
|
|
2738
|
+
this.#setStreaming(false);
|
|
2781
2739
|
this.abortController = null;
|
|
2782
2740
|
|
|
2783
2741
|
if (!isAbortError) {
|
|
@@ -2845,7 +2803,7 @@ export class AgentWidgetSession {
|
|
|
2845
2803
|
};
|
|
2846
2804
|
|
|
2847
2805
|
const composerOptions = replacement
|
|
2848
|
-
? this
|
|
2806
|
+
? this.#toComposerOptionsPayload(replacement.options)
|
|
2849
2807
|
: stored.composerOptions;
|
|
2850
2808
|
const contentSegments = replacement
|
|
2851
2809
|
? replacement.contentSegments
|
|
@@ -2872,7 +2830,7 @@ export class AgentWidgetSession {
|
|
|
2872
2830
|
}
|
|
2873
2831
|
|
|
2874
2832
|
/** Submission options → the wire payload shape; quote travels separately. */
|
|
2875
|
-
|
|
2833
|
+
#toComposerOptionsPayload(
|
|
2876
2834
|
options: { selectedModelId?: string; activeModeIds?: string[] } | undefined
|
|
2877
2835
|
): ComposerOptionsPayload | undefined {
|
|
2878
2836
|
if (!options) return undefined;
|
|
@@ -2903,21 +2861,21 @@ export class AgentWidgetSession {
|
|
|
2903
2861
|
// Same ownership rule as sendMessage: a pending history transition owns the
|
|
2904
2862
|
// record this turn would land in.
|
|
2905
2863
|
if (
|
|
2906
|
-
this
|
|
2907
|
-
this
|
|
2908
|
-
this
|
|
2909
|
-
this
|
|
2864
|
+
this.#historyGate ||
|
|
2865
|
+
this.#projectionFinalizationPromise ||
|
|
2866
|
+
this.#historySendBlocked ||
|
|
2867
|
+
this.#credentialReinitPending
|
|
2910
2868
|
) {
|
|
2911
|
-
await this
|
|
2869
|
+
await this.#awaitHistorySendable();
|
|
2912
2870
|
}
|
|
2913
2871
|
|
|
2914
2872
|
this.abortController?.abort();
|
|
2915
|
-
this
|
|
2873
|
+
this.#teardownReconnect();
|
|
2916
2874
|
|
|
2917
2875
|
const assistantMessageId = generateAssistantMessageId();
|
|
2918
|
-
this
|
|
2876
|
+
this.#activeAssistantMessageId = null;
|
|
2919
2877
|
|
|
2920
|
-
this
|
|
2878
|
+
this.#setStreaming(true);
|
|
2921
2879
|
|
|
2922
2880
|
const controller = new AbortController();
|
|
2923
2881
|
this.abortController = controller;
|
|
@@ -2934,7 +2892,7 @@ export class AgentWidgetSession {
|
|
|
2934
2892
|
this.handleEvent
|
|
2935
2893
|
);
|
|
2936
2894
|
} catch (error) {
|
|
2937
|
-
if (this.status === "resuming" || this
|
|
2895
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
2938
2896
|
// Check if this is an abort error (a prior in-flight stream was canceled,
|
|
2939
2897
|
// the user navigated away, etc.). In these cases, don't show fallback or
|
|
2940
2898
|
// fire onError - the request was intentionally interrupted.
|
|
@@ -2957,14 +2915,14 @@ export class AgentWidgetSession {
|
|
|
2957
2915
|
role: "assistant",
|
|
2958
2916
|
createdAt: new Date().toISOString(),
|
|
2959
2917
|
content,
|
|
2960
|
-
sequence: this
|
|
2918
|
+
sequence: this.#nextSequence()
|
|
2961
2919
|
};
|
|
2962
2920
|
|
|
2963
|
-
this
|
|
2921
|
+
this.#appendMessage(fallback);
|
|
2964
2922
|
}
|
|
2965
2923
|
}
|
|
2966
|
-
this
|
|
2967
|
-
this
|
|
2924
|
+
this.#setStatus("idle");
|
|
2925
|
+
this.#setStreaming(false);
|
|
2968
2926
|
this.abortController = null;
|
|
2969
2927
|
if (!isAbortError) {
|
|
2970
2928
|
if (error instanceof Error) {
|
|
@@ -3008,7 +2966,7 @@ export class AgentWidgetSession {
|
|
|
3008
2966
|
// Durable reconnect keeps filling the same bubble: track its id so the
|
|
3009
2967
|
// cursor handler attaches to it.
|
|
3010
2968
|
if (options?.preserveAssistantId && options.assistantMessageId) {
|
|
3011
|
-
this
|
|
2969
|
+
this.#activeAssistantMessageId = options.assistantMessageId;
|
|
3012
2970
|
}
|
|
3013
2971
|
|
|
3014
2972
|
// Finalize any stale streaming messages from the previous stream
|
|
@@ -3028,7 +2986,7 @@ export class AgentWidgetSession {
|
|
|
3028
2986
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
3029
2987
|
}
|
|
3030
2988
|
|
|
3031
|
-
this
|
|
2989
|
+
this.#setStreaming(true);
|
|
3032
2990
|
|
|
3033
2991
|
try {
|
|
3034
2992
|
await this.client.processStream(
|
|
@@ -3041,14 +2999,14 @@ export class AgentWidgetSession {
|
|
|
3041
2999
|
// During a durable reconnect a thrown resume stream is just another drop:
|
|
3042
3000
|
// the reconnect loop owns the retry/backoff. Don't paint an error or tear
|
|
3043
3001
|
// down, stay in `resuming`.
|
|
3044
|
-
if (this.status === "resuming" || this
|
|
3045
|
-
this
|
|
3002
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
3003
|
+
this.#setStatus("error");
|
|
3046
3004
|
// Mirror the idle/error handlers: a failed resume stream must not tear
|
|
3047
3005
|
// down streaming/abortController while another WebMCP resolve is still
|
|
3048
3006
|
// confirming or executing. The in-flight resolve's `finally` owns the
|
|
3049
3007
|
// teardown once `webMcpResolveControllers` drains.
|
|
3050
3008
|
if (this.webMcpResolveControllers.size === 0) {
|
|
3051
|
-
this
|
|
3009
|
+
this.#setStreaming(false);
|
|
3052
3010
|
this.abortController = null;
|
|
3053
3011
|
}
|
|
3054
3012
|
this.callbacks.onError?.(
|
|
@@ -3063,7 +3021,7 @@ export class AgentWidgetSession {
|
|
|
3063
3021
|
* this, the bridge falls back to a blunt `window.confirm`. Safe to call
|
|
3064
3022
|
* repeatedly (e.g. after the client is re-created in `updateConfig`).
|
|
3065
3023
|
*/
|
|
3066
|
-
|
|
3024
|
+
#wireDefaultWebMcpConfirm(): void {
|
|
3067
3025
|
const webmcp = this.config.webmcp;
|
|
3068
3026
|
if (webmcp?.enabled === true && !webmcp.onConfirm) {
|
|
3069
3027
|
this.client.setWebMcpConfirmHandler((info) =>
|
|
@@ -3092,7 +3050,7 @@ export class AgentWidgetSession {
|
|
|
3092
3050
|
}
|
|
3093
3051
|
|
|
3094
3052
|
const approval: AgentWidgetApproval = {
|
|
3095
|
-
id: `webmcp-${++this
|
|
3053
|
+
id: `webmcp-${++this.#webMcpApprovalSeq}`,
|
|
3096
3054
|
status: "pending",
|
|
3097
3055
|
agentId: "",
|
|
3098
3056
|
executionId: "",
|
|
@@ -3148,133 +3106,58 @@ export class AgentWidgetSession {
|
|
|
3148
3106
|
resolve(decision === "approved");
|
|
3149
3107
|
}
|
|
3150
3108
|
|
|
3109
|
+
#actionsHost: SessionActionsHost | null = null;
|
|
3110
|
+
// Advanced when the turn is stopped or replaced (cancel, clear, new send,
|
|
3111
|
+
// hydrate, conversation switch). A resolve waiting on the lazy chunk checks
|
|
3112
|
+
// it after the load so a stopped turn never resumes late.
|
|
3113
|
+
#actionsEpoch = 0;
|
|
3114
|
+
|
|
3115
|
+
/**
|
|
3116
|
+
* Run a resolve path from `session-actions.ts` (the lazy
|
|
3117
|
+
* `session-actions.js` chunk in the IIFE build). Synchronous when the module
|
|
3118
|
+
* is already loaded or provided, so the call's timing matches an inline
|
|
3119
|
+
* method; otherwise it waits for the chunk and reports a failed load via
|
|
3120
|
+
* `onError` (the resolve never started, so no state needs unwinding). A turn
|
|
3121
|
+
* stopped or replaced while the chunk loads drops the resolve.
|
|
3122
|
+
*/
|
|
3123
|
+
#withActions(
|
|
3124
|
+
run: (actions: SessionActionsModule, host: SessionActionsHost) => Promise<void>
|
|
3125
|
+
): Promise<void> {
|
|
3126
|
+
const host = (this.#actionsHost ??= {
|
|
3127
|
+
s: this as unknown as SessionActionsInternals,
|
|
3128
|
+
setStreaming: (streaming) => this.#setStreaming(streaming),
|
|
3129
|
+
appendMessage: (message) => this.#appendMessage(message),
|
|
3130
|
+
nextSequence: () => this.#nextSequence(),
|
|
3131
|
+
settleApprovalPausedToolCall: (id) => this.#settleApprovalPausedToolCall(id),
|
|
3132
|
+
resumeAfter: (executionId) =>
|
|
3133
|
+
this.#resumable?.executionId === executionId
|
|
3134
|
+
? this.#resumable.lastEventId
|
|
3135
|
+
: undefined,
|
|
3136
|
+
});
|
|
3137
|
+
const actions = getSessionActionsSync();
|
|
3138
|
+
if (actions) return run(actions, host);
|
|
3139
|
+
const epoch = this.#actionsEpoch;
|
|
3140
|
+
return loadSessionActions().then(
|
|
3141
|
+
(loaded) => (epoch === this.#actionsEpoch ? run(loaded, host) : undefined),
|
|
3142
|
+
(error) => {
|
|
3143
|
+
this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
3144
|
+
}
|
|
3145
|
+
);
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3151
3148
|
/**
|
|
3152
3149
|
* Resolve a tool approval request (approve or deny).
|
|
3153
3150
|
* Updates the approval message status, calls the API (or custom onDecision),
|
|
3154
3151
|
* and pipes the response stream through connectStream().
|
|
3155
3152
|
*/
|
|
3156
|
-
public
|
|
3153
|
+
public resolveApproval(
|
|
3157
3154
|
approval: AgentWidgetApproval,
|
|
3158
3155
|
decision: 'approved' | 'denied',
|
|
3159
3156
|
options?: AgentWidgetApprovalDecisionOptions
|
|
3160
3157
|
): 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
|
-
}
|
|
3158
|
+
return this.#withActions((actions, host) =>
|
|
3159
|
+
actions.resolveApproval(host, approval, decision, options)
|
|
3160
|
+
);
|
|
3278
3161
|
}
|
|
3279
3162
|
|
|
3280
3163
|
/**
|
|
@@ -3289,6 +3172,27 @@ export class AgentWidgetSession {
|
|
|
3289
3172
|
* 3. Append a user-visible bubble with the answer text so the
|
|
3290
3173
|
* transcript reads naturally.
|
|
3291
3174
|
*/
|
|
3175
|
+
public resolveAskUserQuestion(
|
|
3176
|
+
toolMessage: AgentWidgetMessage,
|
|
3177
|
+
answer: string | Record<string, string | string[]>
|
|
3178
|
+
): Promise<void> {
|
|
3179
|
+
return this.#withActions((actions, host) =>
|
|
3180
|
+
actions.resolveAskUserQuestion(host, toolMessage, answer)
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/**
|
|
3185
|
+
* Resolve a paused auto-resolving LOCAL tool call (`webmcp:*` page tools
|
|
3186
|
+
* and the built-in `suggest_replies`) and post the result to `/resume`.
|
|
3187
|
+
* Triggered automatically from `handleEvent`; idempotent on the message's
|
|
3188
|
+
* `toolCall.id`. See `resolveWebMcpToolCall` in `session-actions.ts`.
|
|
3189
|
+
*/
|
|
3190
|
+
public resolveWebMcpToolCall(toolMessage: AgentWidgetMessage): Promise<void> {
|
|
3191
|
+
return this.#withActions((actions, host) =>
|
|
3192
|
+
actions.resolveWebMcpToolCall(host, toolMessage)
|
|
3193
|
+
);
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3292
3196
|
/**
|
|
3293
3197
|
* Persist in-progress answers and the current page index for a multi-question
|
|
3294
3198
|
* `ask_user_question` payload, so a refresh resumes on the same page with
|
|
@@ -3338,167 +3242,6 @@ export class AgentWidgetSession {
|
|
|
3338
3242
|
});
|
|
3339
3243
|
}
|
|
3340
3244
|
|
|
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
3245
|
/**
|
|
3503
3246
|
* Collect an auto-resolving LOCAL-tool `await` (`webmcp:*` page tools
|
|
3504
3247
|
* and the built-in `suggest_replies`) into a per-executionId batch
|
|
@@ -3517,13 +3260,13 @@ export class AgentWidgetSession {
|
|
|
3517
3260
|
*: route them straight to the single-call path, which surfaces the malformed
|
|
3518
3261
|
* wire shape via `onError` / an `isError` resume.
|
|
3519
3262
|
*/
|
|
3520
|
-
|
|
3263
|
+
#enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
|
|
3521
3264
|
const executionId = toolMessage.agentMetadata?.executionId;
|
|
3522
3265
|
const callId = toolMessage.toolCall?.id;
|
|
3523
3266
|
if (!executionId || !callId) {
|
|
3524
|
-
const queuedEpoch = this
|
|
3267
|
+
const queuedEpoch = this.#webMcpEpoch;
|
|
3525
3268
|
queueMicrotask(() => {
|
|
3526
|
-
if (queuedEpoch !== this
|
|
3269
|
+
if (queuedEpoch !== this.#webMcpEpoch) return;
|
|
3527
3270
|
void this.resolveWebMcpToolCall(toolMessage);
|
|
3528
3271
|
});
|
|
3529
3272
|
return;
|
|
@@ -3555,13 +3298,13 @@ export class AgentWidgetSession {
|
|
|
3555
3298
|
* abortController) settles before a resolve grabs them: the same ordering the
|
|
3556
3299
|
* single-call resolve always relied on.
|
|
3557
3300
|
*/
|
|
3558
|
-
|
|
3301
|
+
#scheduleWebMcpBatchFlush(): void {
|
|
3559
3302
|
if (this.webMcpAwaitBatches.size === 0) return;
|
|
3560
|
-
const queuedEpoch = this
|
|
3303
|
+
const queuedEpoch = this.#webMcpEpoch;
|
|
3561
3304
|
queueMicrotask(() => {
|
|
3562
|
-
if (queuedEpoch !== this
|
|
3305
|
+
if (queuedEpoch !== this.#webMcpEpoch) return;
|
|
3563
3306
|
for (const executionId of [...this.webMcpAwaitBatches.keys()]) {
|
|
3564
|
-
this
|
|
3307
|
+
this.#flushWebMcpAwaitBatch(executionId);
|
|
3565
3308
|
}
|
|
3566
3309
|
});
|
|
3567
3310
|
}
|
|
@@ -3574,7 +3317,7 @@ export class AgentWidgetSession {
|
|
|
3574
3317
|
* so any later sibling re-emit (e.g. from a re-pause) forms a fresh batch
|
|
3575
3318
|
* rather than mutating one already in flight.
|
|
3576
3319
|
*/
|
|
3577
|
-
|
|
3320
|
+
#flushWebMcpAwaitBatch(executionId: string): void {
|
|
3578
3321
|
const batch = this.webMcpAwaitBatches.get(executionId);
|
|
3579
3322
|
if (!batch) return;
|
|
3580
3323
|
this.webMcpAwaitBatches.delete(executionId);
|
|
@@ -3582,498 +3325,9 @@ export class AgentWidgetSession {
|
|
|
3582
3325
|
if (snapshots.length === 1) {
|
|
3583
3326
|
void this.resolveWebMcpToolCall(snapshots[0]);
|
|
3584
3327
|
} 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,
|
|
3772
|
-
);
|
|
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
|
-
),
|
|
3328
|
+
void this.#withActions((actions, host) =>
|
|
3329
|
+
actions.resolveWebMcpToolCallBatch(host, executionId, snapshots)
|
|
3980
3330
|
);
|
|
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
3331
|
}
|
|
4078
3332
|
}
|
|
4079
3333
|
|
|
@@ -4086,7 +3340,7 @@ export class AgentWidgetSession {
|
|
|
4086
3340
|
* controller, so it can't escape this teardown. Called from every stop /
|
|
4087
3341
|
* new-turn boundary (cancel, clearMessages, hydrateMessages, sendMessage).
|
|
4088
3342
|
*/
|
|
4089
|
-
|
|
3343
|
+
#abortWebMcpResolves(): void {
|
|
4090
3344
|
for (const controller of this.webMcpResolveControllers) {
|
|
4091
3345
|
controller.abort();
|
|
4092
3346
|
}
|
|
@@ -4110,7 +3364,7 @@ export class AgentWidgetSession {
|
|
|
4110
3364
|
// being torn down, and a microtask-deferred flush must not survive. The
|
|
4111
3365
|
// epoch bump below also strands an already-scheduled flush.
|
|
4112
3366
|
this.webMcpAwaitBatches.clear();
|
|
4113
|
-
this
|
|
3367
|
+
this.#webMcpEpoch++;
|
|
4114
3368
|
}
|
|
4115
3369
|
|
|
4116
3370
|
public cancel() {
|
|
@@ -4118,32 +3372,34 @@ export class AgentWidgetSession {
|
|
|
4118
3372
|
this.abortController = null;
|
|
4119
3373
|
// A user stop also cancels any pending/in-flight durable reconnect and
|
|
4120
3374
|
// clears the resume handle (the abort above already killed its fetch).
|
|
4121
|
-
this
|
|
3375
|
+
this.#teardownReconnect();
|
|
4122
3376
|
// Tear down every in-flight WebMCP resolve (each owns its own controller,
|
|
4123
3377
|
// independent of the shared one above). Clear the inflight set so retries
|
|
4124
3378
|
// are possible if the user re-issues the same await context.
|
|
4125
|
-
this
|
|
3379
|
+
this.#abortWebMcpResolves();
|
|
3380
|
+
this.#actionsEpoch++;
|
|
4126
3381
|
this.webMcpInflightKeys.clear();
|
|
4127
3382
|
// Stop any in-progress audio too: when the user hits "stop", they want
|
|
4128
3383
|
// the assistant to actually stop talking, not just stop generating tokens.
|
|
4129
3384
|
// Both helpers are safe no-ops when audio isn't configured.
|
|
4130
3385
|
this.stopSpeaking();
|
|
4131
3386
|
this.stopVoicePlayback();
|
|
4132
|
-
this
|
|
4133
|
-
this
|
|
3387
|
+
this.#setStreaming(false);
|
|
3388
|
+
this.#setStatus("idle");
|
|
4134
3389
|
}
|
|
4135
3390
|
|
|
4136
3391
|
public clearMessages() {
|
|
4137
3392
|
this.stopSpeaking();
|
|
4138
3393
|
this.abortController?.abort();
|
|
4139
3394
|
this.abortController = null;
|
|
4140
|
-
this
|
|
3395
|
+
this.#teardownReconnect();
|
|
4141
3396
|
// Tear down every in-flight WebMCP resolve too: their messages are about
|
|
4142
3397
|
// to be wiped, and a microtask-deferred resolve must not survive the clear.
|
|
4143
|
-
this
|
|
3398
|
+
this.#abortWebMcpResolves();
|
|
3399
|
+
this.#actionsEpoch++;
|
|
4144
3400
|
this.messages = [];
|
|
4145
3401
|
this.agentExecution = null;
|
|
4146
|
-
this
|
|
3402
|
+
this.#clearArtifactState();
|
|
4147
3403
|
// Clearing messages also wipes the WebMCP dedupe state: a fresh
|
|
4148
3404
|
// conversation should not refuse to call a webmcp:* tool just because
|
|
4149
3405
|
// a tool with the same key resolved in the prior conversation.
|
|
@@ -4153,8 +3409,8 @@ export class AgentWidgetSession {
|
|
|
4153
3409
|
// turn: drop the diff-only fingerprint cache (server keys by recordId, so
|
|
4154
3410
|
// a new conversation has no stored set to match).
|
|
4155
3411
|
this.client.resetClientToolsFingerprint();
|
|
4156
|
-
this
|
|
4157
|
-
this
|
|
3412
|
+
this.#setStreaming(false);
|
|
3413
|
+
this.#setStatus("idle");
|
|
4158
3414
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4159
3415
|
}
|
|
4160
3416
|
|
|
@@ -4167,16 +3423,16 @@ export class AgentWidgetSession {
|
|
|
4167
3423
|
}
|
|
4168
3424
|
|
|
4169
3425
|
public getSelectedArtifactId(): string | null {
|
|
4170
|
-
return this
|
|
3426
|
+
return this.#selectedArtifactId;
|
|
4171
3427
|
}
|
|
4172
3428
|
|
|
4173
3429
|
public selectArtifact(id: string | null): void {
|
|
4174
|
-
this
|
|
4175
|
-
this
|
|
3430
|
+
this.#selectedArtifactId = id;
|
|
3431
|
+
this.#emitArtifactsState();
|
|
4176
3432
|
}
|
|
4177
3433
|
|
|
4178
3434
|
public clearArtifacts(): void {
|
|
4179
|
-
this
|
|
3435
|
+
this.#clearArtifactState();
|
|
4180
3436
|
}
|
|
4181
3437
|
|
|
4182
3438
|
public upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord {
|
|
@@ -4204,10 +3460,10 @@ export class AgentWidgetSession {
|
|
|
4204
3460
|
...(manual.presentation ? { presentation: manual.presentation } : {})
|
|
4205
3461
|
};
|
|
4206
3462
|
this.artifacts.set(id, rec);
|
|
4207
|
-
this
|
|
4208
|
-
this
|
|
3463
|
+
this.#selectedArtifactId = id;
|
|
3464
|
+
this.#emitArtifactsState();
|
|
4209
3465
|
if (manual.transcript !== false) {
|
|
4210
|
-
this
|
|
3466
|
+
this.#injectArtifactRefBlock(rec);
|
|
4211
3467
|
}
|
|
4212
3468
|
return rec;
|
|
4213
3469
|
}
|
|
@@ -4228,7 +3484,7 @@ export class AgentWidgetSession {
|
|
|
4228
3484
|
* these props); without this the block would keep the first version's
|
|
4229
3485
|
* content after a refresh.
|
|
4230
3486
|
*/
|
|
4231
|
-
|
|
3487
|
+
#injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
|
|
4232
3488
|
const refId = `artifact-ref-${rec.id}`;
|
|
4233
3489
|
const displayMode = resolveArtifactDisplayMode(
|
|
4234
3490
|
this.config.features?.artifacts,
|
|
@@ -4266,7 +3522,7 @@ export class AgentWidgetSession {
|
|
|
4266
3522
|
* Re-materialize existing transcript artifact blocks after a live display
|
|
4267
3523
|
* preference update. One messages callback keeps conversion atomic.
|
|
4268
3524
|
*/
|
|
4269
|
-
|
|
3525
|
+
#refreshArtifactReferenceBlocks(): void {
|
|
4270
3526
|
let changed = false;
|
|
4271
3527
|
for (const rec of this.artifacts.values()) {
|
|
4272
3528
|
const message = this.messages.find((candidate) => candidate.id === `artifact-ref-${rec.id}`);
|
|
@@ -4294,21 +3550,21 @@ export class AgentWidgetSession {
|
|
|
4294
3550
|
if (changed) this.callbacks.onMessagesChanged([...this.messages]);
|
|
4295
3551
|
}
|
|
4296
3552
|
|
|
4297
|
-
|
|
4298
|
-
if (this.artifacts.size === 0 && this
|
|
3553
|
+
#clearArtifactState(): void {
|
|
3554
|
+
if (this.artifacts.size === 0 && this.#selectedArtifactId === null) return;
|
|
4299
3555
|
this.artifacts.clear();
|
|
4300
|
-
this
|
|
4301
|
-
this
|
|
3556
|
+
this.#selectedArtifactId = null;
|
|
3557
|
+
this.#emitArtifactsState();
|
|
4302
3558
|
}
|
|
4303
3559
|
|
|
4304
|
-
|
|
3560
|
+
#emitArtifactsState(): void {
|
|
4305
3561
|
this.callbacks.onArtifactsState?.({
|
|
4306
3562
|
artifacts: [...this.artifacts.values()],
|
|
4307
|
-
selectedId: this
|
|
3563
|
+
selectedId: this.#selectedArtifactId
|
|
4308
3564
|
});
|
|
4309
3565
|
}
|
|
4310
3566
|
|
|
4311
|
-
|
|
3567
|
+
#applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
|
|
4312
3568
|
switch (ev.type) {
|
|
4313
3569
|
case "artifact_start": {
|
|
4314
3570
|
if (ev.artifactType === "markdown") {
|
|
@@ -4330,7 +3586,7 @@ export class AgentWidgetSession {
|
|
|
4330
3586
|
props: {}
|
|
4331
3587
|
});
|
|
4332
3588
|
}
|
|
4333
|
-
this
|
|
3589
|
+
this.#selectedArtifactId = ev.id;
|
|
4334
3590
|
break;
|
|
4335
3591
|
}
|
|
4336
3592
|
case "artifact_delta": {
|
|
@@ -4356,7 +3612,7 @@ export class AgentWidgetSession {
|
|
|
4356
3612
|
default:
|
|
4357
3613
|
return;
|
|
4358
3614
|
}
|
|
4359
|
-
this
|
|
3615
|
+
this.#emitArtifactsState();
|
|
4360
3616
|
}
|
|
4361
3617
|
|
|
4362
3618
|
public hydrateMessages(messages: AgentWidgetMessage[]) {
|
|
@@ -4364,23 +3620,24 @@ export class AgentWidgetSession {
|
|
|
4364
3620
|
this.abortController = null;
|
|
4365
3621
|
// Hydration replaces the conversation: also cancel any pending reconnect and
|
|
4366
3622
|
// clear the resume handle (a boot resume re-arms via resumeFromHandle after).
|
|
4367
|
-
this
|
|
3623
|
+
this.#teardownReconnect();
|
|
4368
3624
|
// Hydration replaces the conversation: abort and forget every in-flight
|
|
4369
3625
|
// WebMCP resolve; their messages are about to be replaced.
|
|
4370
|
-
this
|
|
3626
|
+
this.#abortWebMcpResolves();
|
|
3627
|
+
this.#actionsEpoch++;
|
|
4371
3628
|
// Wipe the WebMCP dedupe state alongside the message restore: the
|
|
4372
3629
|
// incoming snapshot is treated as a fresh conversation context.
|
|
4373
3630
|
this.webMcpInflightKeys.clear();
|
|
4374
3631
|
this.webMcpResolvedKeys.clear();
|
|
4375
|
-
this.messages = this
|
|
3632
|
+
this.messages = this.#sortMessages(
|
|
4376
3633
|
messages.map((message) => ({
|
|
4377
3634
|
...message,
|
|
4378
3635
|
streaming: false,
|
|
4379
|
-
sequence: message.sequence ?? this
|
|
3636
|
+
sequence: message.sequence ?? this.#nextSequence()
|
|
4380
3637
|
}))
|
|
4381
3638
|
);
|
|
4382
|
-
this
|
|
4383
|
-
this
|
|
3639
|
+
this.#setStreaming(false);
|
|
3640
|
+
this.#setStatus("idle");
|
|
4384
3641
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4385
3642
|
}
|
|
4386
3643
|
|
|
@@ -4392,14 +3649,36 @@ export class AgentWidgetSession {
|
|
|
4392
3649
|
for (const rec of artifacts) {
|
|
4393
3650
|
this.artifacts.set(rec.id, { ...rec, status: "complete" });
|
|
4394
3651
|
}
|
|
4395
|
-
this
|
|
4396
|
-
this
|
|
3652
|
+
this.#selectedArtifactId = selectedId;
|
|
3653
|
+
this.#emitArtifactsState();
|
|
4397
3654
|
}
|
|
4398
3655
|
|
|
4399
3656
|
private handleEvent = (event: AgentWidgetEvent) => {
|
|
3657
|
+
const delegation = this.#voiceDelegation;
|
|
3658
|
+
if (delegation) {
|
|
3659
|
+
if (event.type === "error" || (event.type === "status" && event.status === "error")) {
|
|
3660
|
+
delegation.failed = true;
|
|
3661
|
+
} else if (event.type === "message" && event.message.role === "assistant") {
|
|
3662
|
+
// The voice model reads this answer aloud: browser TTS must skip it,
|
|
3663
|
+
// unless the server dropped the result (then nobody speaks it).
|
|
3664
|
+
if (!delegation.dropped) this.ttsSpokenMessageIds.add(event.message.id);
|
|
3665
|
+
if (!delegation.ids.includes(event.message.id)) delegation.ids.push(event.message.id);
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
4400
3668
|
if (event.type === "message") {
|
|
4401
3669
|
this.upsertMessage(event.message);
|
|
4402
3670
|
|
|
3671
|
+
// A resolved approval reaches this handler only from the server's
|
|
3672
|
+
// `approval_complete` (resolveApproval's optimistic update upserts
|
|
3673
|
+
// directly), so it is safe to settle the paused tool bubble here.
|
|
3674
|
+
if (
|
|
3675
|
+
event.message.variant === "approval" &&
|
|
3676
|
+
event.message.approval &&
|
|
3677
|
+
event.message.approval.status !== "pending"
|
|
3678
|
+
) {
|
|
3679
|
+
this.#settleApprovalPausedToolCall(event.message.id);
|
|
3680
|
+
}
|
|
3681
|
+
|
|
4403
3682
|
// Track the open assistant text bubble's REAL streamed id so a durable
|
|
4404
3683
|
// reconnect keeps filling the same message. The proxy path auto-generates
|
|
4405
3684
|
// this id (it isn't the session's pre-generated one), so we must read it
|
|
@@ -4409,7 +3688,7 @@ export class AgentWidgetSession {
|
|
|
4409
3688
|
!event.message.variant &&
|
|
4410
3689
|
event.message.streaming
|
|
4411
3690
|
) {
|
|
4412
|
-
this
|
|
3691
|
+
this.#activeAssistantMessageId = event.message.id;
|
|
4413
3692
|
}
|
|
4414
3693
|
|
|
4415
3694
|
// Local-tool auto-resolve: when a await emits a tool-variant
|
|
@@ -4436,6 +3715,16 @@ export class AgentWidgetSession {
|
|
|
4436
3715
|
// the resolved `enabled` is false the widget neither renders chips nor
|
|
4437
3716
|
// resumes: the same parked-execution posture as a server-declared
|
|
4438
3717
|
// ask_user_question with its sheet disabled.
|
|
3718
|
+
// The agent paused for an approval or a local tool: warm the resolve
|
|
3719
|
+
// paths (the lazy `session-actions.js` chunk in the IIFE build) so the
|
|
3720
|
+
// resume doesn't wait on a fetch.
|
|
3721
|
+
if (
|
|
3722
|
+
event.message.variant === "approval" ||
|
|
3723
|
+
event.message.agentMetadata?.awaitingLocalTool === true
|
|
3724
|
+
) {
|
|
3725
|
+
loadSessionActions().catch(() => {});
|
|
3726
|
+
}
|
|
3727
|
+
|
|
4439
3728
|
const tc = event.message.toolCall;
|
|
4440
3729
|
const autoResolvable =
|
|
4441
3730
|
!!tc?.name &&
|
|
@@ -4450,7 +3739,7 @@ export class AgentWidgetSession {
|
|
|
4450
3739
|
// it on the spot. Parallel same-tool calls (core#3878) arrive as
|
|
4451
3740
|
// separate `await`s in the same stream; batching lets us post ONE
|
|
4452
3741
|
// `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
|
|
4453
|
-
this
|
|
3742
|
+
this.#enqueueWebMcpAwait(event.message);
|
|
4454
3743
|
}
|
|
4455
3744
|
|
|
4456
3745
|
// Track agent execution state from message metadata
|
|
@@ -4473,35 +3762,35 @@ export class AgentWidgetSession {
|
|
|
4473
3762
|
// cursor and (lazily) form the resume handle once the executionId is
|
|
4474
3763
|
// known. Only durable, resumable executions emit these, so this is the
|
|
4475
3764
|
// natural gate: no `id:` lines → no handle → reconnect never arms.
|
|
4476
|
-
this
|
|
3765
|
+
this.#trackCursor(event.id);
|
|
4477
3766
|
} else if (event.type === "status") {
|
|
4478
3767
|
// A plain `idle` (no `terminal`) on the durable lane, while the run is
|
|
4479
3768
|
// genuinely mid-turn, is a dropped connection, not a finish and not an
|
|
4480
3769
|
// intentional `await` pause. Reconnect instead of finalizing.
|
|
4481
|
-
if (event.status === "idle" && !event.terminal && this
|
|
4482
|
-
this
|
|
3770
|
+
if (event.status === "idle" && !event.terminal && this.#isDurableDrop()) {
|
|
3771
|
+
this.#beginReconnect();
|
|
4483
3772
|
return;
|
|
4484
3773
|
}
|
|
4485
|
-
this
|
|
3774
|
+
this.#setStatus(event.status);
|
|
4486
3775
|
if (event.status === "connecting") {
|
|
4487
|
-
this
|
|
3776
|
+
this.#setStreaming(true);
|
|
4488
3777
|
} else if (event.status === "idle" || event.status === "error") {
|
|
4489
3778
|
// A client-tool pause is an intentional stream end but NOT a terminal:
|
|
4490
3779
|
// retain its cursor so `/client/resume` can attach after the await
|
|
4491
3780
|
// frame instead of replaying the whole turn. Real terminals and
|
|
4492
3781
|
// ordinary non-durable idles still clear the handle.
|
|
4493
|
-
if (!this
|
|
3782
|
+
if (!this.#isAwaitPending()) this.#clearResumable();
|
|
4494
3783
|
// Keep the typing indicator up while a WebMCP resolve is still in
|
|
4495
3784
|
// flight: in a chained turn the intermediate resume stream ends with an
|
|
4496
3785
|
// idle status, but the successor tool is still executing. The resolve's
|
|
4497
3786
|
// own `finally` flips streaming off once the resolve set drains.
|
|
4498
3787
|
if (this.webMcpResolveControllers.size === 0) {
|
|
4499
|
-
this
|
|
3788
|
+
this.#setStreaming(false);
|
|
4500
3789
|
this.abortController = null;
|
|
4501
3790
|
// The turn mutated the record and the chat response carries no
|
|
4502
3791
|
// revision; finalize any browser-only projection right after.
|
|
4503
|
-
this
|
|
4504
|
-
this
|
|
3792
|
+
this.#invalidateConversationRevision();
|
|
3793
|
+
this.#scheduleProjectionFinalization();
|
|
4505
3794
|
}
|
|
4506
3795
|
// Mark agent execution as complete when streaming ends: UNLESS local
|
|
4507
3796
|
// tools are still outstanding. A batched WebMCP resume is deferred to
|
|
@@ -4525,19 +3814,19 @@ export class AgentWidgetSession {
|
|
|
4525
3814
|
// batched `/resume` per executionId (deferred: see
|
|
4526
3815
|
// scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
|
|
4527
3816
|
// doesn't fight the end-of-stream streaming/abortController reset.
|
|
4528
|
-
this
|
|
3817
|
+
this.#scheduleWebMcpBatchFlush();
|
|
4529
3818
|
}
|
|
4530
3819
|
} else if (event.type === "error") {
|
|
4531
|
-
this
|
|
3820
|
+
this.#setStatus("error");
|
|
4532
3821
|
// Terminal error: drop the resume handle so no reconnect arms.
|
|
4533
|
-
this
|
|
3822
|
+
this.#clearResumable();
|
|
4534
3823
|
// Mirror the idle/status handler: don't tear down streaming while a
|
|
4535
3824
|
// WebMCP resolve is still confirming/executing on another stream: an
|
|
4536
3825
|
// error on one chained resume stream must not hide the typing indicator
|
|
4537
3826
|
// (or null a controller) for a sibling/successor resolve still in flight.
|
|
4538
3827
|
// The resolve's own `finally` flips streaming off once the set drains.
|
|
4539
3828
|
if (this.webMcpResolveControllers.size === 0) {
|
|
4540
|
-
this
|
|
3829
|
+
this.#setStreaming(false);
|
|
4541
3830
|
this.abortController = null;
|
|
4542
3831
|
}
|
|
4543
3832
|
if (this.agentExecution?.status === 'running') {
|
|
@@ -4550,7 +3839,7 @@ export class AgentWidgetSession {
|
|
|
4550
3839
|
event.type === "artifact_update" ||
|
|
4551
3840
|
event.type === "artifact_complete"
|
|
4552
3841
|
) {
|
|
4553
|
-
this
|
|
3842
|
+
this.#applyArtifactStreamEvent(event);
|
|
4554
3843
|
}
|
|
4555
3844
|
};
|
|
4556
3845
|
|
|
@@ -4562,24 +3851,24 @@ export class AgentWidgetSession {
|
|
|
4562
3851
|
* frame's metadata, handled before this trailing cursor event) and the active
|
|
4563
3852
|
* assistant message id.
|
|
4564
3853
|
*/
|
|
4565
|
-
|
|
3854
|
+
#trackCursor(id: string): void {
|
|
4566
3855
|
const executionId = this.agentExecution?.executionId;
|
|
4567
|
-
if (!executionId || !this
|
|
3856
|
+
if (!executionId || !this.#activeAssistantMessageId) return;
|
|
4568
3857
|
// Only track while the run is live. A graceful terminal sets the execution
|
|
4569
3858
|
// to 'complete'/'error' and clears the handle BEFORE its own frame's
|
|
4570
3859
|
// trailing cursor fires; without this guard that cursor would re-arm the
|
|
4571
3860
|
// handle and the next plain `idle` would look like a spurious drop.
|
|
4572
3861
|
if (this.agentExecution?.status !== "running") return;
|
|
4573
|
-
const isNew = this
|
|
4574
|
-
this
|
|
3862
|
+
const isNew = this.#resumable === null;
|
|
3863
|
+
this.#resumable = {
|
|
4575
3864
|
executionId,
|
|
4576
3865
|
lastEventId: id,
|
|
4577
|
-
assistantMessageId: this
|
|
3866
|
+
assistantMessageId: this.#activeAssistantMessageId,
|
|
4578
3867
|
status: "running",
|
|
4579
3868
|
};
|
|
4580
3869
|
// Fire immediately when the handle first appears (so the host can persist
|
|
4581
3870
|
// the executionId promptly); throttle subsequent cursor advances.
|
|
4582
|
-
this
|
|
3871
|
+
this.#notifyExecutionState(isNew);
|
|
4583
3872
|
}
|
|
4584
3873
|
|
|
4585
3874
|
/**
|
|
@@ -4587,14 +3876,14 @@ export class AgentWidgetSession {
|
|
|
4587
3876
|
* rather than a graceful finish or an intentional `await` pause? ALL must
|
|
4588
3877
|
* hold (see plan §Design overview keystone 3).
|
|
4589
3878
|
*/
|
|
4590
|
-
|
|
3879
|
+
#isDurableDrop(): boolean {
|
|
4591
3880
|
return (
|
|
4592
|
-
this
|
|
3881
|
+
this.#resumable !== null &&
|
|
4593
3882
|
typeof this.config.reconnectStream === "function" &&
|
|
4594
3883
|
this.abortController?.signal.aborted !== true &&
|
|
4595
3884
|
this.webMcpResolveControllers.size === 0 &&
|
|
4596
3885
|
this.webMcpAwaitBatches.size === 0 &&
|
|
4597
|
-
!this
|
|
3886
|
+
!this.#isAwaitPending()
|
|
4598
3887
|
);
|
|
4599
3888
|
}
|
|
4600
3889
|
|
|
@@ -4603,7 +3892,7 @@ export class AgentWidgetSession {
|
|
|
4603
3892
|
* `ask_user_question` / local-tool await, or a pending approval). Those end
|
|
4604
3893
|
* the stream with `idle` and no terminal too, but are NOT drops.
|
|
4605
3894
|
*/
|
|
4606
|
-
|
|
3895
|
+
#isAwaitPending(): boolean {
|
|
4607
3896
|
return this.messages.some((m) => {
|
|
4608
3897
|
if (
|
|
4609
3898
|
m.agentMetadata?.awaitingLocalTool === true &&
|
|
@@ -4623,22 +3912,22 @@ export class AgentWidgetSession {
|
|
|
4623
3912
|
* true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
|
|
4624
3913
|
* calls (a second drop while reconnecting) are no-ops.
|
|
4625
3914
|
*/
|
|
4626
|
-
|
|
4627
|
-
if (this
|
|
4628
|
-
if (!this
|
|
3915
|
+
#beginReconnect(): void {
|
|
3916
|
+
if (this.#reconnecting) return;
|
|
3917
|
+
if (!this.#resumable || typeof this.config.reconnectStream !== "function") {
|
|
4629
3918
|
return;
|
|
4630
3919
|
}
|
|
4631
3920
|
// Flip the visible state synchronously — the bubble stays open and a
|
|
4632
3921
|
// `resuming` status is observable immediately (e.g. right after
|
|
4633
3922
|
// `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
|
|
3923
|
+
this.#reconnecting = true;
|
|
3924
|
+
this.callbacks.onReconnect?.({ phase: "paused", handle: this.#resumable });
|
|
3925
|
+
this.#setStreaming(true);
|
|
3926
|
+
this.#setStatus("resuming");
|
|
3927
|
+
void this.#loadReconnectController().then((controller) => {
|
|
4639
3928
|
// The run may have been torn down (new turn / cancel / hydrate) while the
|
|
4640
3929
|
// module was loading; only start the loop if we're still reconnecting.
|
|
4641
|
-
if (this
|
|
3930
|
+
if (this.#reconnecting && this.#resumable) controller.begin();
|
|
4642
3931
|
});
|
|
4643
3932
|
}
|
|
4644
3933
|
|
|
@@ -4648,43 +3937,43 @@ export class AgentWidgetSession {
|
|
|
4648
3937
|
* every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
|
|
4649
3938
|
* that never reconnects never pulls the chunk.
|
|
4650
3939
|
*/
|
|
4651
|
-
|
|
4652
|
-
if (this
|
|
4653
|
-
return Promise.resolve(this
|
|
3940
|
+
#loadReconnectController(): Promise<ReconnectController> {
|
|
3941
|
+
if (this.#reconnectController) {
|
|
3942
|
+
return Promise.resolve(this.#reconnectController);
|
|
4654
3943
|
}
|
|
4655
|
-
if (!this
|
|
4656
|
-
this
|
|
3944
|
+
if (!this.#reconnectControllerPromise) {
|
|
3945
|
+
this.#reconnectControllerPromise = loadSessionReconnect().then(
|
|
4657
3946
|
({ createReconnectController }) => {
|
|
4658
3947
|
const controller = createReconnectController(
|
|
4659
|
-
this
|
|
3948
|
+
this.#buildReconnectHost()
|
|
4660
3949
|
);
|
|
4661
|
-
this
|
|
3950
|
+
this.#reconnectController = controller;
|
|
4662
3951
|
return controller;
|
|
4663
3952
|
}
|
|
4664
3953
|
);
|
|
4665
3954
|
// A failed chunk fetch must not poison the memoized promise: clear it so
|
|
4666
3955
|
// the next reconnect trigger retries the load.
|
|
4667
|
-
this
|
|
4668
|
-
this
|
|
3956
|
+
this.#reconnectControllerPromise.catch(() => {
|
|
3957
|
+
this.#reconnectControllerPromise = null;
|
|
4669
3958
|
});
|
|
4670
3959
|
}
|
|
4671
|
-
return this
|
|
3960
|
+
return this.#reconnectControllerPromise;
|
|
4672
3961
|
}
|
|
4673
3962
|
|
|
4674
3963
|
/** The narrow surface the lazily-loaded reconnect loop drives. */
|
|
4675
|
-
|
|
3964
|
+
#buildReconnectHost(): ReconnectHost {
|
|
4676
3965
|
const session = this;
|
|
4677
3966
|
return {
|
|
4678
3967
|
get config() {
|
|
4679
3968
|
return session.config;
|
|
4680
3969
|
},
|
|
4681
|
-
getResumable: () => session
|
|
4682
|
-
clearResumable: () => session
|
|
3970
|
+
getResumable: () => session.#resumable,
|
|
3971
|
+
clearResumable: () => session.#clearResumable(),
|
|
4683
3972
|
getStatus: () => session.status,
|
|
4684
|
-
setStatus: (status) => session
|
|
4685
|
-
setStreaming: (streaming) => session
|
|
3973
|
+
setStatus: (status) => session.#setStatus(status),
|
|
3974
|
+
setStreaming: (streaming) => session.#setStreaming(streaming),
|
|
4686
3975
|
setReconnecting: (value) => {
|
|
4687
|
-
session
|
|
3976
|
+
session.#reconnecting = value;
|
|
4688
3977
|
},
|
|
4689
3978
|
setAbortController: (controller) => {
|
|
4690
3979
|
session.abortController = controller;
|
|
@@ -4699,8 +3988,8 @@ export class AgentWidgetSession {
|
|
|
4699
3988
|
preserveAssistantId: true,
|
|
4700
3989
|
seedContent,
|
|
4701
3990
|
}),
|
|
4702
|
-
appendMessage: (message) => session
|
|
4703
|
-
nextSequence: () => session
|
|
3991
|
+
appendMessage: (message) => session.#appendMessage(message),
|
|
3992
|
+
nextSequence: () => session.#nextSequence(),
|
|
4704
3993
|
emitReconnect: (event) => session.callbacks.onReconnect?.(event),
|
|
4705
3994
|
buildErrorContent: (message) =>
|
|
4706
3995
|
buildDispatchErrorContent(
|
|
@@ -4713,13 +4002,13 @@ export class AgentWidgetSession {
|
|
|
4713
4002
|
|
|
4714
4003
|
/** Public manual retry (e.g. a "Reconnect" button). */
|
|
4715
4004
|
public reconnectNow(): void {
|
|
4716
|
-
if (this
|
|
4005
|
+
if (this.#reconnecting) {
|
|
4717
4006
|
// Already trying, so just short-circuit the current backoff. (No-op if the
|
|
4718
4007
|
// controller is still loading; the first attempt hasn't slept yet.)
|
|
4719
|
-
this
|
|
4008
|
+
this.#reconnectController?.wake();
|
|
4720
4009
|
return;
|
|
4721
4010
|
}
|
|
4722
|
-
this
|
|
4011
|
+
this.#beginReconnect();
|
|
4723
4012
|
}
|
|
4724
4013
|
|
|
4725
4014
|
/**
|
|
@@ -4729,21 +4018,21 @@ export class AgentWidgetSession {
|
|
|
4729
4018
|
*/
|
|
4730
4019
|
public resumeFromHandle(resume: { executionId: string; after: string }): void {
|
|
4731
4020
|
if (typeof this.config.reconnectStream !== "function") return;
|
|
4732
|
-
if (this
|
|
4021
|
+
if (this.#reconnecting) return;
|
|
4733
4022
|
|
|
4734
|
-
let assistantId = this
|
|
4023
|
+
let assistantId = this.#reopenTrailingAssistant();
|
|
4735
4024
|
if (!assistantId) {
|
|
4736
4025
|
assistantId = generateAssistantMessageId();
|
|
4737
|
-
this
|
|
4026
|
+
this.#appendMessage({
|
|
4738
4027
|
id: assistantId,
|
|
4739
4028
|
role: "assistant",
|
|
4740
4029
|
content: "",
|
|
4741
4030
|
createdAt: new Date().toISOString(),
|
|
4742
4031
|
streaming: true,
|
|
4743
|
-
sequence: this
|
|
4032
|
+
sequence: this.#nextSequence(),
|
|
4744
4033
|
});
|
|
4745
4034
|
}
|
|
4746
|
-
this
|
|
4035
|
+
this.#activeAssistantMessageId = assistantId;
|
|
4747
4036
|
if (!this.agentExecution) {
|
|
4748
4037
|
this.agentExecution = {
|
|
4749
4038
|
executionId: resume.executionId,
|
|
@@ -4754,13 +4043,13 @@ export class AgentWidgetSession {
|
|
|
4754
4043
|
maxTurns: 0,
|
|
4755
4044
|
};
|
|
4756
4045
|
}
|
|
4757
|
-
this
|
|
4046
|
+
this.#resumable = {
|
|
4758
4047
|
executionId: resume.executionId,
|
|
4759
4048
|
lastEventId: resume.after,
|
|
4760
4049
|
assistantMessageId: assistantId,
|
|
4761
4050
|
status: "running",
|
|
4762
4051
|
};
|
|
4763
|
-
this
|
|
4052
|
+
this.#beginReconnect();
|
|
4764
4053
|
}
|
|
4765
4054
|
|
|
4766
4055
|
/**
|
|
@@ -4768,7 +4057,7 @@ export class AgentWidgetSession {
|
|
|
4768
4057
|
* deltas append to it. Returns its id, or null if the last turn doesn't end
|
|
4769
4058
|
* in a plain assistant message.
|
|
4770
4059
|
*/
|
|
4771
|
-
|
|
4060
|
+
#reopenTrailingAssistant(): string | null {
|
|
4772
4061
|
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
4773
4062
|
const m = this.messages[i];
|
|
4774
4063
|
if (m.role === "assistant" && !m.variant) {
|
|
@@ -4786,20 +4075,20 @@ export class AgentWidgetSession {
|
|
|
4786
4075
|
* on every new turn / cancel / hydrate, so it must NOT pull the reconnect
|
|
4787
4076
|
* chunk: it only delegates to the controller if one was already created.
|
|
4788
4077
|
*/
|
|
4789
|
-
|
|
4790
|
-
this
|
|
4791
|
-
this
|
|
4792
|
-
this
|
|
4078
|
+
#teardownReconnect(): void {
|
|
4079
|
+
this.#reconnecting = false;
|
|
4080
|
+
this.#reconnectController?.teardown();
|
|
4081
|
+
this.#clearResumable();
|
|
4793
4082
|
}
|
|
4794
4083
|
|
|
4795
4084
|
/** Drop the resume handle and notify the host (`onExecutionState(null)`). */
|
|
4796
|
-
|
|
4797
|
-
if (this
|
|
4798
|
-
clearTimeout(this
|
|
4799
|
-
this
|
|
4085
|
+
#clearResumable(): void {
|
|
4086
|
+
if (this.#executionStateTimer) {
|
|
4087
|
+
clearTimeout(this.#executionStateTimer);
|
|
4088
|
+
this.#executionStateTimer = null;
|
|
4800
4089
|
}
|
|
4801
|
-
const had = this
|
|
4802
|
-
this
|
|
4090
|
+
const had = this.#resumable !== null;
|
|
4091
|
+
this.#resumable = null;
|
|
4803
4092
|
if (had) this.config.onExecutionState?.(null);
|
|
4804
4093
|
}
|
|
4805
4094
|
|
|
@@ -4808,36 +4097,36 @@ export class AgentWidgetSession {
|
|
|
4808
4097
|
* create/clear; trailing-edge throttled on cursor advances so it isn't called
|
|
4809
4098
|
* per delta.
|
|
4810
4099
|
*/
|
|
4811
|
-
|
|
4100
|
+
#notifyExecutionState(immediate: boolean): void {
|
|
4812
4101
|
const cb = this.config.onExecutionState;
|
|
4813
4102
|
if (!cb) return;
|
|
4814
4103
|
if (immediate) {
|
|
4815
|
-
if (this
|
|
4816
|
-
clearTimeout(this
|
|
4817
|
-
this
|
|
4104
|
+
if (this.#executionStateTimer) {
|
|
4105
|
+
clearTimeout(this.#executionStateTimer);
|
|
4106
|
+
this.#executionStateTimer = null;
|
|
4818
4107
|
}
|
|
4819
|
-
cb(this
|
|
4108
|
+
cb(this.#resumable);
|
|
4820
4109
|
return;
|
|
4821
4110
|
}
|
|
4822
|
-
if (this
|
|
4823
|
-
this
|
|
4824
|
-
this
|
|
4825
|
-
this.config.onExecutionState?.(this
|
|
4111
|
+
if (this.#executionStateTimer) return; // a trailing call is already queued
|
|
4112
|
+
this.#executionStateTimer = setTimeout(() => {
|
|
4113
|
+
this.#executionStateTimer = null;
|
|
4114
|
+
this.config.onExecutionState?.(this.#resumable);
|
|
4826
4115
|
}, 500);
|
|
4827
4116
|
}
|
|
4828
4117
|
|
|
4829
4118
|
/** The current durable resume handle, if any (read-only). */
|
|
4830
4119
|
public getResumableHandle(): ResumableHandle | null {
|
|
4831
|
-
return this
|
|
4120
|
+
return this.#resumable;
|
|
4832
4121
|
}
|
|
4833
4122
|
|
|
4834
|
-
|
|
4123
|
+
#setStatus(status: AgentWidgetSessionStatus) {
|
|
4835
4124
|
if (this.status === status) return;
|
|
4836
4125
|
this.status = status;
|
|
4837
4126
|
this.callbacks.onStatusChanged(status);
|
|
4838
4127
|
}
|
|
4839
4128
|
|
|
4840
|
-
|
|
4129
|
+
#setStreaming(streaming: boolean) {
|
|
4841
4130
|
if (this.streaming === streaming) return;
|
|
4842
4131
|
const wasStreaming = this.streaming;
|
|
4843
4132
|
this.streaming = streaming;
|
|
@@ -4853,7 +4142,8 @@ export class AgentWidgetSession {
|
|
|
4853
4142
|
* Speak the latest assistant message using the Web Speech API
|
|
4854
4143
|
* if text-to-speech is enabled in the config.
|
|
4855
4144
|
*/
|
|
4856
|
-
|
|
4145
|
+
/** `only`: speak it only if the latest assistant message is one of these. */
|
|
4146
|
+
private speakLatestAssistantMessage(only?: string[]) {
|
|
4857
4147
|
const ttsConfig = this.config.textToSpeech;
|
|
4858
4148
|
if (!ttsConfig?.enabled) return;
|
|
4859
4149
|
|
|
@@ -4871,7 +4161,7 @@ export class AgentWidgetSession {
|
|
|
4871
4161
|
.reverse()
|
|
4872
4162
|
.find(m => m.role === 'assistant' && m.content && !m.voiceProcessing);
|
|
4873
4163
|
|
|
4874
|
-
if (!lastAssistant) return;
|
|
4164
|
+
if (!lastAssistant || (only && !only.includes(lastAssistant.id))) return;
|
|
4875
4165
|
|
|
4876
4166
|
// Skip if already spoken by Runtype provider's audio playback
|
|
4877
4167
|
if (this.ttsSpokenMessageIds.has(lastAssistant.id)) {
|
|
@@ -4943,17 +4233,42 @@ export class AgentWidgetSession {
|
|
|
4943
4233
|
}
|
|
4944
4234
|
}
|
|
4945
4235
|
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4236
|
+
/**
|
|
4237
|
+
* Settle the tool bubble an approval paused. The server resumes an approval
|
|
4238
|
+
* by re-running the agent, which re-issues the call under a new id, so no
|
|
4239
|
+
* terminal tool frame ever arrives for the paused one. Denied and timed-out
|
|
4240
|
+
* calls settle as failed; an approved call settles as superseded, since the
|
|
4241
|
+
* re-issued call carries its result. A call already complete is left alone.
|
|
4242
|
+
*/
|
|
4243
|
+
#settleApprovalPausedToolCall(approvalMessageId: string) {
|
|
4244
|
+
const approval = this.messages.find((m) => m.id === approvalMessageId)?.approval;
|
|
4245
|
+
const id = approval?.toolCallId;
|
|
4246
|
+
const toolMessage = id && this.messages.find((m) => m.toolCall?.id === id);
|
|
4247
|
+
const tool = toolMessage && toolMessage.toolCall;
|
|
4248
|
+
if (!tool || tool.status === "complete" || approval.status === "pending") return;
|
|
4249
|
+
const settled: AgentWidgetToolCall = {
|
|
4250
|
+
...tool,
|
|
4251
|
+
status: "complete",
|
|
4252
|
+
approvalStatus: approval.status,
|
|
4253
|
+
completedAt: Date.now(),
|
|
4254
|
+
...(approval.status === "approved"
|
|
4255
|
+
? { superseded: true }
|
|
4256
|
+
: { success: false, error: approval.status === "denied" ? "Denied" : "Approval timed out" }),
|
|
4257
|
+
};
|
|
4258
|
+
this.upsertMessage({ ...toolMessage, streaming: false, toolCall: settled });
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
#appendMessage(message: AgentWidgetMessage) {
|
|
4262
|
+
const withSequence = this.#ensureSequence(message);
|
|
4263
|
+
this.messages = this.#sortMessages([...this.messages, withSequence]);
|
|
4949
4264
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4950
4265
|
}
|
|
4951
4266
|
|
|
4952
4267
|
private upsertMessage(message: AgentWidgetMessage) {
|
|
4953
|
-
const withSequence = this
|
|
4268
|
+
const withSequence = this.#ensureSequence(message);
|
|
4954
4269
|
const index = this.messages.findIndex((m) => m.id === withSequence.id);
|
|
4955
4270
|
if (index === -1) {
|
|
4956
|
-
this
|
|
4271
|
+
this.#appendMessage(withSequence);
|
|
4957
4272
|
return;
|
|
4958
4273
|
}
|
|
4959
4274
|
|
|
@@ -5034,6 +4349,7 @@ export class AgentWidgetSession {
|
|
|
5034
4349
|
toolType: incoming.toolType ?? prior.toolType,
|
|
5035
4350
|
reason: incoming.reason ?? prior.reason,
|
|
5036
4351
|
parameters: incoming.parameters ?? prior.parameters,
|
|
4352
|
+
toolCallId: incoming.toolCallId ?? prior.toolCallId,
|
|
5037
4353
|
};
|
|
5038
4354
|
}
|
|
5039
4355
|
// Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
|
|
@@ -5080,25 +4396,25 @@ export class AgentWidgetSession {
|
|
|
5080
4396
|
}
|
|
5081
4397
|
return merged;
|
|
5082
4398
|
});
|
|
5083
|
-
this.messages = this
|
|
4399
|
+
this.messages = this.#sortMessages(this.messages);
|
|
5084
4400
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
5085
4401
|
}
|
|
5086
4402
|
|
|
5087
|
-
|
|
4403
|
+
#ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
|
|
5088
4404
|
if (message.sequence !== undefined) {
|
|
5089
4405
|
return { ...message };
|
|
5090
4406
|
}
|
|
5091
4407
|
return {
|
|
5092
4408
|
...message,
|
|
5093
|
-
sequence: this
|
|
4409
|
+
sequence: this.#nextSequence()
|
|
5094
4410
|
};
|
|
5095
4411
|
}
|
|
5096
4412
|
|
|
5097
|
-
|
|
5098
|
-
return this
|
|
4413
|
+
#nextSequence() {
|
|
4414
|
+
return this.#sequenceCounter++;
|
|
5099
4415
|
}
|
|
5100
4416
|
|
|
5101
|
-
|
|
4417
|
+
#sortMessages(messages: AgentWidgetMessage[]) {
|
|
5102
4418
|
return [...messages].sort((a, b) => {
|
|
5103
4419
|
// Sort by createdAt timestamp first (chronological order)
|
|
5104
4420
|
const timeA = new Date(a.createdAt).getTime();
|