@runtypelabs/persona 4.24.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 +243 -8
- package/dist/approval-ui.d.ts +243 -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 +243 -8
- package/dist/artifacts-ui.d.ts +243 -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 +243 -8
- package/dist/context-mentions.d.ts +243 -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 +246 -31
- package/dist/event-stream-view.d.ts +246 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +295 -667
- package/dist/forms-ui.d.ts +295 -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 +301 -738
- package/dist/index.d.ts +301 -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 +243 -8
- package/dist/session-reconnect.d.ts +243 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +243 -8
- package/dist/smart-dom-reader.d.ts +243 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +245 -8
- package/dist/theme-editor-preview.d.ts +245 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +244 -23
- package/dist/theme-editor.d.ts +244 -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 +11643 -192
- package/dist/voice-runtime.d.ts +11643 -192
- 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 +8 -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 +875 -1530
- package/src/session.voice.test.ts +1241 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +269 -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 +399 -2770
- package/src/ui.voice-prewarm.test.ts +222 -0
- 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 +856 -164
- 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 +1250 -7
- 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-EFR3R5ES.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,47 +791,87 @@ 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
|
}
|
|
910
812
|
}
|
|
911
813
|
}
|
|
912
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Warm the voice path ahead of the mic click (`voiceRecognition.prewarm`).
|
|
817
|
+
* Session-owned providers (`runtype`, `custom`) get `provider.prewarm()` once
|
|
818
|
+
* the lazy voice runtime has installed them; the browser (Web Speech) path
|
|
819
|
+
* warms the client-token session instead. Fire-and-forget and silent.
|
|
820
|
+
*/
|
|
821
|
+
public prewarmVoice(): void {
|
|
822
|
+
if (!usesSessionVoice(this.config.voiceRecognition?.provider)) {
|
|
823
|
+
this.warmClientSession();
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const generation = this.#voiceSetupGeneration;
|
|
827
|
+
const run = () => {
|
|
828
|
+
const provider = this.#voiceProvider;
|
|
829
|
+
if (!provider?.prewarm || generation !== this.#voiceSetupGeneration) return;
|
|
830
|
+
if (this.#voiceActive || this.isBargeInActive()) return;
|
|
831
|
+
void Promise.resolve()
|
|
832
|
+
.then(() => provider.prewarm?.())
|
|
833
|
+
.catch(() => {});
|
|
834
|
+
};
|
|
835
|
+
if (!this.#voiceProvider && this.#voiceSetupPromise) {
|
|
836
|
+
void this.#voiceSetupPromise.then(run, () => {});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
run();
|
|
840
|
+
}
|
|
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
|
+
|
|
913
853
|
/**
|
|
914
854
|
* Cleanup voice resources
|
|
915
855
|
*/
|
|
916
856
|
public cleanupVoice() {
|
|
917
|
-
const notifyDisconnected = this
|
|
918
|
-
this
|
|
919
|
-
this
|
|
920
|
-
if (this
|
|
921
|
-
const provider = this
|
|
922
|
-
this
|
|
923
|
-
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);
|
|
924
864
|
}
|
|
925
|
-
this
|
|
926
|
-
this
|
|
927
|
-
this
|
|
928
|
-
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();
|
|
929
871
|
if (notifyDisconnected) this.callbacks.onVoiceStatusChanged?.('disconnected');
|
|
930
872
|
}
|
|
931
873
|
|
|
932
|
-
|
|
874
|
+
async #disconnectVoice(provider: VoiceProvider): Promise<void> {
|
|
933
875
|
try {
|
|
934
876
|
await provider.disconnect();
|
|
935
877
|
} catch (error) {
|
|
@@ -937,13 +879,13 @@ export class AgentWidgetSession {
|
|
|
937
879
|
}
|
|
938
880
|
}
|
|
939
881
|
|
|
940
|
-
|
|
941
|
-
const userId = includeUser ? this
|
|
942
|
-
const assistantId = this
|
|
882
|
+
#settlePendingVoiceTurn(includeUser: boolean): void {
|
|
883
|
+
const userId = includeUser ? this.#pendingVoiceUserMessageId : null;
|
|
884
|
+
const assistantId = this.#pendingVoiceAssistantMessageId;
|
|
943
885
|
if (!userId && !assistantId) return;
|
|
944
|
-
if (assistantId) this
|
|
945
|
-
if (includeUser) this
|
|
946
|
-
this
|
|
886
|
+
if (assistantId) this.#voiceTurns.cancel();
|
|
887
|
+
if (includeUser) this.#pendingVoiceUserMessageId = null;
|
|
888
|
+
this.#pendingVoiceAssistantMessageId = null;
|
|
947
889
|
if (assistantId) this.ttsSpokenMessageIds.add(assistantId);
|
|
948
890
|
this.messages = this.messages.flatMap((message) => {
|
|
949
891
|
if (message.id !== userId && message.id !== assistantId) return [message];
|
|
@@ -951,7 +893,7 @@ export class AgentWidgetSession {
|
|
|
951
893
|
return [{ ...message, streaming: false, voiceProcessing: false }];
|
|
952
894
|
});
|
|
953
895
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
954
|
-
if (assistantId) this
|
|
896
|
+
if (assistantId) this.#setStreaming(false);
|
|
955
897
|
}
|
|
956
898
|
|
|
957
899
|
/**
|
|
@@ -975,7 +917,13 @@ export class AgentWidgetSession {
|
|
|
975
917
|
clientToken: providerConfig.runtype?.clientToken ?? this.config.clientToken,
|
|
976
918
|
host: providerConfig.runtype?.host ?? this.config.apiUrl,
|
|
977
919
|
voiceId: providerConfig.runtype?.voiceId,
|
|
978
|
-
createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine
|
|
920
|
+
createPlaybackEngine: providerConfig.runtype?.createPlaybackEngine,
|
|
921
|
+
prewarmMode: providerConfig.runtype?.prewarmMode,
|
|
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
|
|
979
927
|
}
|
|
980
928
|
};
|
|
981
929
|
|
|
@@ -1017,9 +965,9 @@ export class AgentWidgetSession {
|
|
|
1017
965
|
await this.historyInternals.historyBootstrapReady;
|
|
1018
966
|
// Snapshot before init overwrites it: boot reconciliation compares the
|
|
1019
967
|
// init revision against what the LAST page load persisted.
|
|
1020
|
-
this
|
|
968
|
+
this.#bootConversationRevision =
|
|
1021
969
|
this.historyInternals.getStoredConversationRevision?.() ?? null;
|
|
1022
|
-
this
|
|
970
|
+
this.#bootRevisionCaptured = true;
|
|
1023
971
|
}
|
|
1024
972
|
|
|
1025
973
|
try {
|
|
@@ -1056,7 +1004,7 @@ export class AgentWidgetSession {
|
|
|
1056
1004
|
*/
|
|
1057
1005
|
public setClientSession(session: ClientSession, pristine?: boolean): void {
|
|
1058
1006
|
this.clientSession = session;
|
|
1059
|
-
this
|
|
1007
|
+
this.#injectWelcomeMessage(session, pristine);
|
|
1060
1008
|
}
|
|
1061
1009
|
|
|
1062
1010
|
/**
|
|
@@ -1064,11 +1012,11 @@ export class AgentWidgetSession {
|
|
|
1064
1012
|
* reopening a stored conversation is not a new thread, and the hydrated
|
|
1065
1013
|
* transcript would otherwise race a welcome bubble.
|
|
1066
1014
|
*/
|
|
1067
|
-
|
|
1015
|
+
#injectWelcomeMessage(
|
|
1068
1016
|
session: ClientSession,
|
|
1069
1017
|
pristine = !this.messages.length
|
|
1070
1018
|
): void {
|
|
1071
|
-
if (this
|
|
1019
|
+
if (this.#suppressWelcomeInjection) return;
|
|
1072
1020
|
if (!session.config.welcomeMessage || !pristine) return;
|
|
1073
1021
|
// A late welcome (the first send raced the init) sorts ahead of that turn.
|
|
1074
1022
|
const first = this.messages[0];
|
|
@@ -1077,9 +1025,9 @@ export class AgentWidgetSession {
|
|
|
1077
1025
|
role: "assistant",
|
|
1078
1026
|
content: session.config.welcomeMessage,
|
|
1079
1027
|
createdAt: first?.createdAt ?? new Date().toISOString(),
|
|
1080
|
-
sequence: first ? (first.sequence ?? 0) - 1 : this
|
|
1028
|
+
sequence: first ? (first.sequence ?? 0) - 1 : this.#nextSequence()
|
|
1081
1029
|
};
|
|
1082
|
-
this
|
|
1030
|
+
this.#appendMessage(welcomeMessage);
|
|
1083
1031
|
}
|
|
1084
1032
|
|
|
1085
1033
|
/**
|
|
@@ -1155,17 +1103,17 @@ export class AgentWidgetSession {
|
|
|
1155
1103
|
* client and any rebuilt one share the same store.
|
|
1156
1104
|
*/
|
|
1157
1105
|
public setHistoryInternals(internals: WidgetHistoryInternals): void {
|
|
1158
|
-
this.historyInternals = this
|
|
1106
|
+
this.historyInternals = this.#composeHistoryInternals(internals);
|
|
1159
1107
|
this.client.setHistoryInternals(this.historyInternals);
|
|
1160
|
-
this
|
|
1108
|
+
this.#syncVisitorStoreSubscription();
|
|
1161
1109
|
}
|
|
1162
1110
|
|
|
1163
1111
|
/** Widget teardown: release the visitor-store subscription. */
|
|
1164
1112
|
public destroy(): void {
|
|
1165
1113
|
this.cleanupVoice();
|
|
1166
|
-
this
|
|
1167
|
-
this
|
|
1168
|
-
this
|
|
1114
|
+
this.#visitorStoreUnsubscribe?.();
|
|
1115
|
+
this.#visitorStoreUnsubscribe = null;
|
|
1116
|
+
this.#subscribedVisitorStore = null;
|
|
1169
1117
|
}
|
|
1170
1118
|
|
|
1171
1119
|
// ==========================================================================
|
|
@@ -1177,25 +1125,25 @@ export class AgentWidgetSession {
|
|
|
1177
1125
|
* supplied its own observer: a credential/record break must wipe local state
|
|
1178
1126
|
* before the replacement session becomes sendable.
|
|
1179
1127
|
*/
|
|
1180
|
-
|
|
1128
|
+
#composeHistoryInternals(
|
|
1181
1129
|
internals: WidgetHistoryInternals
|
|
1182
1130
|
): WidgetHistoryInternals {
|
|
1183
1131
|
const hostContinuity = internals.onHistoryContinuityChanged;
|
|
1184
1132
|
return {
|
|
1185
1133
|
...internals,
|
|
1186
1134
|
onHistoryContinuityChanged: (info) => {
|
|
1187
|
-
this
|
|
1135
|
+
this.#handleHistoryContinuityChanged(info);
|
|
1188
1136
|
hostContinuity?.(info);
|
|
1189
1137
|
},
|
|
1190
1138
|
};
|
|
1191
1139
|
}
|
|
1192
1140
|
|
|
1193
|
-
|
|
1141
|
+
get #historyProvider(): HistoryProvider | null {
|
|
1194
1142
|
return this.historyInternals.historyProvider ?? null;
|
|
1195
1143
|
}
|
|
1196
1144
|
|
|
1197
|
-
|
|
1198
|
-
const provider = this
|
|
1145
|
+
#requireHistoryProvider(): HistoryProvider {
|
|
1146
|
+
const provider = this.#historyProvider;
|
|
1199
1147
|
if (!provider) {
|
|
1200
1148
|
throw new SessionHistoryError(
|
|
1201
1149
|
"history_unavailable",
|
|
@@ -1209,8 +1157,8 @@ export class AgentWidgetSession {
|
|
|
1209
1157
|
* One resolved scope per logical action. An explicitly requested scope the
|
|
1210
1158
|
* provider cannot serve fails locally rather than silently downgrading.
|
|
1211
1159
|
*/
|
|
1212
|
-
|
|
1213
|
-
const provider = this
|
|
1160
|
+
#historyContext(scope?: HistoryScope): HistoryOperationContext {
|
|
1161
|
+
const provider = this.#requireHistoryProvider();
|
|
1214
1162
|
const requested = scope ?? this.config.features?.history?.scope;
|
|
1215
1163
|
const derived: HistoryScope = this.config.getIdentityProof
|
|
1216
1164
|
? "verified-user"
|
|
@@ -1233,23 +1181,23 @@ export class AgentWidgetSession {
|
|
|
1233
1181
|
|
|
1234
1182
|
public getHistoryState(): SessionHistoryState {
|
|
1235
1183
|
return {
|
|
1236
|
-
activeConversationId: this
|
|
1237
|
-
conversationRevision: this
|
|
1238
|
-
nextMessageCursor: this
|
|
1239
|
-
recovery: this
|
|
1240
|
-
sendBlocked: this
|
|
1184
|
+
activeConversationId: this.#activeConversationId,
|
|
1185
|
+
conversationRevision: this.#activeConversationRevision,
|
|
1186
|
+
nextMessageCursor: this.#historyNextMessageCursor,
|
|
1187
|
+
recovery: this.#historyRecovery,
|
|
1188
|
+
sendBlocked: this.#historySendBlocked,
|
|
1241
1189
|
};
|
|
1242
1190
|
}
|
|
1243
1191
|
|
|
1244
1192
|
public getActiveConversationId(): string | null {
|
|
1245
|
-
return this
|
|
1193
|
+
return this.#activeConversationId;
|
|
1246
1194
|
}
|
|
1247
1195
|
|
|
1248
|
-
|
|
1196
|
+
#emitHistoryState(): void {
|
|
1249
1197
|
this.callbacks.onHistoryStateChanged?.(this.getHistoryState());
|
|
1250
1198
|
}
|
|
1251
1199
|
|
|
1252
|
-
|
|
1200
|
+
#notifyHistory(
|
|
1253
1201
|
code: SessionHistoryNoticeCode,
|
|
1254
1202
|
message: string
|
|
1255
1203
|
): void {
|
|
@@ -1258,13 +1206,13 @@ export class AgentWidgetSession {
|
|
|
1258
1206
|
|
|
1259
1207
|
/** Pending destructive/reconciliation work every other operation waits on. */
|
|
1260
1208
|
public async awaitHistorySettled(): Promise<void> {
|
|
1261
|
-
const gate = this
|
|
1209
|
+
const gate = this.#historyGate;
|
|
1262
1210
|
if (gate) await gate.catch(() => {});
|
|
1263
|
-
const finalization = this
|
|
1211
|
+
const finalization = this.#projectionFinalizationPromise;
|
|
1264
1212
|
if (finalization) await finalization.catch(() => {});
|
|
1265
1213
|
}
|
|
1266
1214
|
|
|
1267
|
-
|
|
1215
|
+
#assertHistoryIdle(): void {
|
|
1268
1216
|
if (this.streaming || this.status === "paused" || this.status === "resuming") {
|
|
1269
1217
|
throw new SessionHistoryError(
|
|
1270
1218
|
"conversation_busy",
|
|
@@ -1279,12 +1227,12 @@ export class AgentWidgetSession {
|
|
|
1279
1227
|
targetId?: string;
|
|
1280
1228
|
scope?: HistoryScope;
|
|
1281
1229
|
}) {
|
|
1282
|
-
const provider = this
|
|
1230
|
+
const provider = this.#requireHistoryProvider();
|
|
1283
1231
|
return provider.list({
|
|
1284
1232
|
...(opts?.cursor ? { cursor: opts.cursor } : {}),
|
|
1285
1233
|
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
1286
1234
|
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1287
|
-
context: this
|
|
1235
|
+
context: this.#historyContext(opts?.scope),
|
|
1288
1236
|
});
|
|
1289
1237
|
}
|
|
1290
1238
|
|
|
@@ -1300,46 +1248,46 @@ export class AgentWidgetSession {
|
|
|
1300
1248
|
summary: HistoryConversationSummary;
|
|
1301
1249
|
nextMessageCursor: string | null;
|
|
1302
1250
|
}> {
|
|
1303
|
-
const provider = this
|
|
1304
|
-
this
|
|
1305
|
-
const context = this
|
|
1251
|
+
const provider = this.#requireHistoryProvider();
|
|
1252
|
+
this.#assertHistoryIdle();
|
|
1253
|
+
const context = this.#historyContext(opts?.scope);
|
|
1306
1254
|
await this.awaitHistorySettled();
|
|
1307
|
-
const epoch = ++this
|
|
1255
|
+
const epoch = ++this.#historyOpenEpoch;
|
|
1308
1256
|
|
|
1309
1257
|
const page = await provider.getPage(id, { context });
|
|
1310
|
-
if (epoch !== this
|
|
1258
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1311
1259
|
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1312
1260
|
}
|
|
1313
1261
|
|
|
1314
1262
|
const prepared = await provider.prepareOpen(id, { context });
|
|
1315
|
-
if (epoch !== this
|
|
1263
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1316
1264
|
prepared.discard();
|
|
1317
1265
|
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1318
1266
|
}
|
|
1319
1267
|
|
|
1320
1268
|
// The reopened transcript is not a new conversation: never inject welcome.
|
|
1321
|
-
this
|
|
1269
|
+
this.#suppressWelcomeInjection = true;
|
|
1322
1270
|
try {
|
|
1323
|
-
await this
|
|
1271
|
+
await this.#commitActivation(prepared);
|
|
1324
1272
|
} finally {
|
|
1325
|
-
this
|
|
1273
|
+
this.#suppressWelcomeInjection = false;
|
|
1326
1274
|
}
|
|
1327
1275
|
|
|
1328
|
-
this
|
|
1329
|
-
this
|
|
1276
|
+
this.#activeConversationId = prepared.conversationId || id;
|
|
1277
|
+
this.#activeConversationRevision =
|
|
1330
1278
|
prepared.conversationRevision || page.conversationRevision || null;
|
|
1331
|
-
this
|
|
1332
|
-
this
|
|
1333
|
-
this
|
|
1334
|
-
this
|
|
1279
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1280
|
+
this.#historyRecovery = null;
|
|
1281
|
+
this.#historySendBlocked = false;
|
|
1282
|
+
this.#resetConversationScopedState();
|
|
1335
1283
|
// Server projections are by definition acknowledged.
|
|
1336
1284
|
for (const message of page.messages) {
|
|
1337
|
-
this
|
|
1285
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1338
1286
|
}
|
|
1339
1287
|
// Metadata first, transcript last: the transcript emit is what persists.
|
|
1340
|
-
this
|
|
1288
|
+
this.#persistConversationMetadata();
|
|
1341
1289
|
this.hydrateMessages(page.messages);
|
|
1342
|
-
this
|
|
1290
|
+
this.#emitHistoryState();
|
|
1343
1291
|
return { summary: page.summary, nextMessageCursor: page.nextCursor };
|
|
1344
1292
|
}
|
|
1345
1293
|
|
|
@@ -1352,25 +1300,25 @@ export class AgentWidgetSession {
|
|
|
1352
1300
|
cursor?: string | null,
|
|
1353
1301
|
opts?: { scope?: HistoryScope }
|
|
1354
1302
|
): Promise<HistoryOlderPage> {
|
|
1355
|
-
const provider = this
|
|
1356
|
-
const context = this
|
|
1357
|
-
const resolvedCursor = cursor ?? this
|
|
1303
|
+
const provider = this.#requireHistoryProvider();
|
|
1304
|
+
const context = this.#historyContext(opts?.scope);
|
|
1305
|
+
const resolvedCursor = cursor ?? this.#historyNextMessageCursor;
|
|
1358
1306
|
if (!resolvedCursor) {
|
|
1359
1307
|
return Promise.resolve({ messages: [], nextMessageCursor: null });
|
|
1360
1308
|
}
|
|
1361
1309
|
const key = `${id} ${resolvedCursor}`;
|
|
1362
|
-
const inflight = this
|
|
1310
|
+
const inflight = this.#olderPageRequests.get(key);
|
|
1363
1311
|
if (inflight) return inflight;
|
|
1364
1312
|
|
|
1365
|
-
const request = this
|
|
1313
|
+
const request = this.#fetchOlderPage(provider, id, resolvedCursor, context)
|
|
1366
1314
|
.finally(() => {
|
|
1367
|
-
this
|
|
1315
|
+
this.#olderPageRequests.delete(key);
|
|
1368
1316
|
});
|
|
1369
|
-
this
|
|
1317
|
+
this.#olderPageRequests.set(key, request);
|
|
1370
1318
|
return request;
|
|
1371
1319
|
}
|
|
1372
1320
|
|
|
1373
|
-
|
|
1321
|
+
async #fetchOlderPage(
|
|
1374
1322
|
provider: HistoryProvider,
|
|
1375
1323
|
id: string,
|
|
1376
1324
|
cursor: string,
|
|
@@ -1383,20 +1331,20 @@ export class AgentWidgetSession {
|
|
|
1383
1331
|
context,
|
|
1384
1332
|
});
|
|
1385
1333
|
// A conversation switch during the fetch retires this page.
|
|
1386
|
-
if (this
|
|
1334
|
+
if (this.#activeConversationId && this.#activeConversationId !== id) {
|
|
1387
1335
|
return { messages: [], nextMessageCursor: page.nextCursor };
|
|
1388
1336
|
}
|
|
1389
1337
|
const merged = mergeWireMessagesById(this.messages, page.messages);
|
|
1390
|
-
this.messages = this
|
|
1391
|
-
merged.map((message) => this
|
|
1338
|
+
this.messages = this.#sortMessages(
|
|
1339
|
+
merged.map((message) => this.#ensureSequence(message))
|
|
1392
1340
|
);
|
|
1393
|
-
this
|
|
1341
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1394
1342
|
for (const message of page.messages) {
|
|
1395
|
-
this
|
|
1343
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1396
1344
|
}
|
|
1397
1345
|
this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
|
|
1398
1346
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1399
|
-
this
|
|
1347
|
+
this.#emitHistoryState();
|
|
1400
1348
|
return { messages: page.messages, nextMessageCursor: page.nextCursor };
|
|
1401
1349
|
}
|
|
1402
1350
|
|
|
@@ -1407,34 +1355,34 @@ export class AgentWidgetSession {
|
|
|
1407
1355
|
public async startNewConversation(opts?: {
|
|
1408
1356
|
scope?: HistoryScope;
|
|
1409
1357
|
}): Promise<void> {
|
|
1410
|
-
const provider = this
|
|
1411
|
-
const context = this
|
|
1358
|
+
const provider = this.#requireHistoryProvider();
|
|
1359
|
+
const context = this.#historyContext(opts?.scope);
|
|
1412
1360
|
await this.awaitHistorySettled();
|
|
1413
|
-
const epoch = ++this
|
|
1361
|
+
const epoch = ++this.#historyOpenEpoch;
|
|
1414
1362
|
const prepared = await provider.prepareStartNew({ context });
|
|
1415
|
-
if (epoch !== this
|
|
1363
|
+
if (epoch !== this.#historyOpenEpoch) {
|
|
1416
1364
|
prepared.discard();
|
|
1417
1365
|
throw new SessionHistoryError("superseded", "A later action won");
|
|
1418
1366
|
}
|
|
1419
|
-
await this
|
|
1367
|
+
await this.#installFreshConversation(prepared);
|
|
1420
1368
|
}
|
|
1421
1369
|
|
|
1422
1370
|
/**
|
|
1423
1371
|
* Clear local + persisted conversation state, then commit the replacement.
|
|
1424
1372
|
* Welcome injection is deliberately allowed once the transcript is empty.
|
|
1425
1373
|
*/
|
|
1426
|
-
|
|
1374
|
+
async #installFreshConversation(
|
|
1427
1375
|
prepared: PreparedHistoryActivation
|
|
1428
1376
|
): Promise<void> {
|
|
1429
|
-
this
|
|
1430
|
-
await this
|
|
1431
|
-
this
|
|
1432
|
-
this
|
|
1433
|
-
this
|
|
1434
|
-
this
|
|
1435
|
-
this
|
|
1436
|
-
this
|
|
1437
|
-
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();
|
|
1438
1386
|
}
|
|
1439
1387
|
|
|
1440
1388
|
/**
|
|
@@ -1445,12 +1393,12 @@ export class AgentWidgetSession {
|
|
|
1445
1393
|
id: string,
|
|
1446
1394
|
opts?: { scope?: HistoryScope }
|
|
1447
1395
|
): Promise<void> {
|
|
1448
|
-
const provider = this
|
|
1449
|
-
const context = this
|
|
1396
|
+
const provider = this.#requireHistoryProvider();
|
|
1397
|
+
const context = this.#historyContext(opts?.scope);
|
|
1450
1398
|
await this.awaitHistorySettled();
|
|
1451
1399
|
await provider.delete(id, { context });
|
|
1452
|
-
if (id !== this
|
|
1453
|
-
await this
|
|
1400
|
+
if (id !== this.#activeConversationId) return;
|
|
1401
|
+
await this.#recoverFromDestroyedConversation(context);
|
|
1454
1402
|
}
|
|
1455
1403
|
|
|
1456
1404
|
/** Visitor-scoped rename/star. Requires a provider with the update capability. */
|
|
@@ -1459,35 +1407,35 @@ export class AgentWidgetSession {
|
|
|
1459
1407
|
patch: HistoryConversationPatch,
|
|
1460
1408
|
opts?: { scope?: HistoryScope }
|
|
1461
1409
|
): Promise<HistoryConversationSummary> {
|
|
1462
|
-
const provider = this
|
|
1410
|
+
const provider = this.#requireHistoryProvider();
|
|
1463
1411
|
if (!provider.update) {
|
|
1464
1412
|
throw new SessionHistoryError(
|
|
1465
1413
|
"history_unavailable",
|
|
1466
1414
|
"This history provider cannot update conversations"
|
|
1467
1415
|
);
|
|
1468
1416
|
}
|
|
1469
|
-
const context = this
|
|
1417
|
+
const context = this.#historyContext(opts?.scope);
|
|
1470
1418
|
await this.awaitHistorySettled();
|
|
1471
1419
|
return provider.update(id, patch, { context });
|
|
1472
1420
|
}
|
|
1473
1421
|
|
|
1474
1422
|
/** True when the provider supports visitor-scoped rename/star. */
|
|
1475
1423
|
public canUpdateConversations(): boolean {
|
|
1476
|
-
return typeof this
|
|
1424
|
+
return typeof this.#historyProvider?.update === "function";
|
|
1477
1425
|
}
|
|
1478
1426
|
|
|
1479
1427
|
public async clearConversationHistory(opts?: {
|
|
1480
1428
|
targetId?: string;
|
|
1481
1429
|
scope?: HistoryScope;
|
|
1482
1430
|
}): Promise<{ deleted: number }> {
|
|
1483
|
-
const provider = this
|
|
1484
|
-
const context = this
|
|
1431
|
+
const provider = this.#requireHistoryProvider();
|
|
1432
|
+
const context = this.#historyContext(opts?.scope);
|
|
1485
1433
|
await this.awaitHistorySettled();
|
|
1486
1434
|
const result = await provider.deleteAll({
|
|
1487
1435
|
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1488
1436
|
context,
|
|
1489
1437
|
});
|
|
1490
|
-
await this
|
|
1438
|
+
await this.#recoverFromDestroyedConversation(context);
|
|
1491
1439
|
return result;
|
|
1492
1440
|
}
|
|
1493
1441
|
|
|
@@ -1498,65 +1446,65 @@ export class AgentWidgetSession {
|
|
|
1498
1446
|
public async resetHistoryDevice(): Promise<{
|
|
1499
1447
|
remoteRevocationConfirmed: boolean;
|
|
1500
1448
|
}> {
|
|
1501
|
-
const provider = this
|
|
1449
|
+
const provider = this.#requireHistoryProvider();
|
|
1502
1450
|
if (!provider.resetDevice) {
|
|
1503
1451
|
throw new SessionHistoryError(
|
|
1504
1452
|
"history_unavailable",
|
|
1505
1453
|
"This history provider cannot reset the device"
|
|
1506
1454
|
);
|
|
1507
1455
|
}
|
|
1508
|
-
this
|
|
1509
|
-
this
|
|
1456
|
+
this.#historyOpenEpoch += 1;
|
|
1457
|
+
this.#projectionFinalizationPromise = null;
|
|
1510
1458
|
try {
|
|
1511
1459
|
return await provider.resetDevice();
|
|
1512
1460
|
} finally {
|
|
1513
|
-
this
|
|
1514
|
-
this
|
|
1515
|
-
this
|
|
1516
|
-
this
|
|
1517
|
-
this
|
|
1518
|
-
this
|
|
1519
|
-
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();
|
|
1520
1468
|
}
|
|
1521
1469
|
}
|
|
1522
1470
|
|
|
1523
|
-
|
|
1471
|
+
async #recoverFromDestroyedConversation(
|
|
1524
1472
|
context: HistoryOperationContext
|
|
1525
1473
|
): Promise<void> {
|
|
1526
1474
|
// Supersede anything still in flight for the record being destroyed.
|
|
1527
|
-
this
|
|
1528
|
-
this
|
|
1529
|
-
this
|
|
1530
|
-
this
|
|
1531
|
-
this
|
|
1532
|
-
this
|
|
1533
|
-
this
|
|
1534
|
-
this
|
|
1535
|
-
this
|
|
1536
|
-
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);
|
|
1537
1485
|
}
|
|
1538
1486
|
|
|
1539
1487
|
/**
|
|
1540
1488
|
* Never reinstalls the deleted session. A failed preparation leaves an
|
|
1541
1489
|
* explicit recoverable state instead of the old transcript.
|
|
1542
1490
|
*/
|
|
1543
|
-
|
|
1491
|
+
async #prepareReplacementConversation(
|
|
1544
1492
|
context: HistoryOperationContext
|
|
1545
1493
|
): Promise<void> {
|
|
1546
|
-
const provider = this
|
|
1494
|
+
const provider = this.#historyProvider;
|
|
1547
1495
|
if (!provider) {
|
|
1548
|
-
this
|
|
1549
|
-
this
|
|
1496
|
+
this.#historySendBlocked = false;
|
|
1497
|
+
this.#emitHistoryState();
|
|
1550
1498
|
return;
|
|
1551
1499
|
}
|
|
1552
1500
|
try {
|
|
1553
1501
|
const prepared = await provider.prepareStartNew({ context });
|
|
1554
|
-
await this
|
|
1502
|
+
await this.#installFreshConversation(prepared);
|
|
1555
1503
|
} catch {
|
|
1556
|
-
this
|
|
1557
|
-
this
|
|
1558
|
-
this
|
|
1559
|
-
this
|
|
1504
|
+
this.#historySendBlocked = true;
|
|
1505
|
+
this.#historyRecovery = "new_conversation_required";
|
|
1506
|
+
this.#emitHistoryState();
|
|
1507
|
+
this.#notifyHistory(
|
|
1560
1508
|
"new_conversation_required",
|
|
1561
1509
|
"That conversation was deleted. Start a new conversation to continue."
|
|
1562
1510
|
);
|
|
@@ -1564,13 +1512,13 @@ export class AgentWidgetSession {
|
|
|
1564
1512
|
}
|
|
1565
1513
|
|
|
1566
1514
|
/** The next explicit action retries a failed replacement preparation. */
|
|
1567
|
-
|
|
1568
|
-
if (this
|
|
1569
|
-
const provider = this
|
|
1515
|
+
async #retryRequiredConversation(): Promise<void> {
|
|
1516
|
+
if (this.#historyRecovery !== "new_conversation_required") return;
|
|
1517
|
+
const provider = this.#historyProvider;
|
|
1570
1518
|
if (!provider) return;
|
|
1571
|
-
this
|
|
1572
|
-
await this
|
|
1573
|
-
if (this
|
|
1519
|
+
this.#historyRecovery = null;
|
|
1520
|
+
await this.#prepareReplacementConversation(this.#historyContext());
|
|
1521
|
+
if (this.#historyRecovery === "new_conversation_required") {
|
|
1574
1522
|
throw new SessionHistoryError(
|
|
1575
1523
|
"new_conversation_required",
|
|
1576
1524
|
"A new conversation is required before sending"
|
|
@@ -1583,7 +1531,7 @@ export class AgentWidgetSession {
|
|
|
1583
1531
|
* session binding (`bindActivatedSession`); the fallback covers a shell that
|
|
1584
1532
|
* has not wired that callback yet.
|
|
1585
1533
|
*/
|
|
1586
|
-
|
|
1534
|
+
async #commitActivation(
|
|
1587
1535
|
prepared: PreparedHistoryActivation
|
|
1588
1536
|
): Promise<void> {
|
|
1589
1537
|
await prepared.commit();
|
|
@@ -1602,61 +1550,62 @@ export class AgentWidgetSession {
|
|
|
1602
1550
|
this.clientSession = session;
|
|
1603
1551
|
this.config.setStoredSessionId?.(session.sessionId);
|
|
1604
1552
|
if (session.conversationId) {
|
|
1605
|
-
this
|
|
1606
|
-
this
|
|
1553
|
+
this.#activeConversationId = session.conversationId;
|
|
1554
|
+
this.#activeConversationRevision = session.conversationRevision ?? null;
|
|
1607
1555
|
this.config.setStoredConversationId?.(session.conversationId);
|
|
1608
1556
|
this.historyInternals.setStoredConversationRevision?.(
|
|
1609
1557
|
session.conversationRevision ?? null
|
|
1610
1558
|
);
|
|
1611
1559
|
}
|
|
1612
|
-
if (!this
|
|
1560
|
+
if (!this.#suppressWelcomeInjection) this.#injectWelcomeMessage(session);
|
|
1613
1561
|
}
|
|
1614
1562
|
|
|
1615
|
-
|
|
1616
|
-
if (this
|
|
1617
|
-
this.config.setStoredConversationId?.(this
|
|
1563
|
+
#persistConversationMetadata(): void {
|
|
1564
|
+
if (this.#activeConversationId) {
|
|
1565
|
+
this.config.setStoredConversationId?.(this.#activeConversationId);
|
|
1618
1566
|
} else {
|
|
1619
1567
|
this.config.clearStoredConversationId?.();
|
|
1620
1568
|
}
|
|
1621
1569
|
this.historyInternals.setStoredConversationRevision?.(
|
|
1622
|
-
this
|
|
1570
|
+
this.#activeConversationRevision
|
|
1623
1571
|
);
|
|
1624
1572
|
this.historyInternals.setStoredMessageCursor?.(
|
|
1625
|
-
this
|
|
1573
|
+
this.#historyNextMessageCursor
|
|
1626
1574
|
);
|
|
1627
1575
|
}
|
|
1628
1576
|
|
|
1629
1577
|
/** Conversation-scoped caches that must not leak across records. */
|
|
1630
|
-
|
|
1578
|
+
#resetConversationScopedState(): void {
|
|
1631
1579
|
this.historyInternals.setStoredResumableHandle?.(null);
|
|
1632
1580
|
this.agentExecution = null;
|
|
1633
|
-
this
|
|
1581
|
+
this.#clearArtifactState();
|
|
1634
1582
|
this.webMcpInflightKeys.clear();
|
|
1635
1583
|
this.webMcpResolvedKeys.clear();
|
|
1636
1584
|
this.client.resetClientToolsFingerprint();
|
|
1637
|
-
this
|
|
1638
|
-
this
|
|
1585
|
+
this.#acknowledgedProjections.clear();
|
|
1586
|
+
this.#setPendingProjections(null);
|
|
1639
1587
|
}
|
|
1640
1588
|
|
|
1641
1589
|
/**
|
|
1642
1590
|
* Irreversible local + persisted clear. Used by new-conversation, active
|
|
1643
1591
|
* deletion, 410 recovery, reset, and the continuity wipe.
|
|
1644
1592
|
*/
|
|
1645
|
-
|
|
1593
|
+
#discardConversationState(): void {
|
|
1646
1594
|
this.stopSpeaking();
|
|
1647
1595
|
this.abortController?.abort();
|
|
1648
1596
|
this.abortController = null;
|
|
1649
|
-
this
|
|
1650
|
-
this
|
|
1597
|
+
this.#teardownReconnect();
|
|
1598
|
+
this.#abortWebMcpResolves();
|
|
1599
|
+
this.#actionsEpoch++;
|
|
1651
1600
|
this.messages = [];
|
|
1652
|
-
this
|
|
1653
|
-
this
|
|
1601
|
+
this.#resetConversationScopedState();
|
|
1602
|
+
this.#olderPageRequests.clear();
|
|
1654
1603
|
this.config.clearStoredSessionId?.();
|
|
1655
1604
|
this.config.clearStoredConversationId?.();
|
|
1656
1605
|
this.historyInternals.setStoredConversationRevision?.(null);
|
|
1657
1606
|
this.historyInternals.setStoredMessageCursor?.(null);
|
|
1658
|
-
this
|
|
1659
|
-
this
|
|
1607
|
+
this.#setStreaming(false);
|
|
1608
|
+
this.#setStatus("idle");
|
|
1660
1609
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1661
1610
|
}
|
|
1662
1611
|
|
|
@@ -1665,27 +1614,27 @@ export class AgentWidgetSession {
|
|
|
1665
1614
|
* cross-tab convergence, or credential expiry selected a different record.
|
|
1666
1615
|
* Invalidate synchronously; dispatch stays blocked until cleanup completes.
|
|
1667
1616
|
*/
|
|
1668
|
-
|
|
1617
|
+
#handleHistoryContinuityChanged(info: {
|
|
1669
1618
|
previousConversationId: string | null;
|
|
1670
1619
|
conversationId: string;
|
|
1671
1620
|
}): void {
|
|
1672
|
-
this
|
|
1673
|
-
this
|
|
1674
|
-
this
|
|
1675
|
-
this
|
|
1676
|
-
this
|
|
1677
|
-
this
|
|
1678
|
-
this
|
|
1679
|
-
this
|
|
1680
|
-
this
|
|
1681
|
-
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(
|
|
1682
1631
|
"history_continuity_reset",
|
|
1683
1632
|
"This browser was reconnected to a different conversation, so the previous messages were cleared."
|
|
1684
1633
|
);
|
|
1685
|
-
this
|
|
1686
|
-
this
|
|
1687
|
-
this
|
|
1688
|
-
this
|
|
1634
|
+
this.#historyGate = Promise.resolve().then(() => {
|
|
1635
|
+
this.#historySendBlocked = false;
|
|
1636
|
+
this.#historyGate = null;
|
|
1637
|
+
this.#emitHistoryState();
|
|
1689
1638
|
});
|
|
1690
1639
|
}
|
|
1691
1640
|
|
|
@@ -1694,15 +1643,15 @@ export class AgentWidgetSession {
|
|
|
1694
1643
|
* the store. Owned here so the transition sits beside the continuity wipe it
|
|
1695
1644
|
* reuses.
|
|
1696
1645
|
*/
|
|
1697
|
-
|
|
1646
|
+
#syncVisitorStoreSubscription(): void {
|
|
1698
1647
|
const store = this.historyInternals.visitorStore ?? null;
|
|
1699
|
-
if (store === this
|
|
1700
|
-
this
|
|
1701
|
-
this
|
|
1702
|
-
this
|
|
1648
|
+
if (store === this.#subscribedVisitorStore) return;
|
|
1649
|
+
this.#visitorStoreUnsubscribe?.();
|
|
1650
|
+
this.#visitorStoreUnsubscribe = null;
|
|
1651
|
+
this.#subscribedVisitorStore = store;
|
|
1703
1652
|
if (!store) return;
|
|
1704
|
-
this
|
|
1705
|
-
this
|
|
1653
|
+
this.#visitorStoreUnsubscribe = store.subscribe((change) =>
|
|
1654
|
+
this.#handleExternalCredentialChange(change)
|
|
1706
1655
|
);
|
|
1707
1656
|
}
|
|
1708
1657
|
|
|
@@ -1712,40 +1661,40 @@ export class AgentWidgetSession {
|
|
|
1712
1661
|
* A "local" change is this tab's own mint/write and owns its init path; a
|
|
1713
1662
|
* change out of an empty store is first-init convergence, not a break.
|
|
1714
1663
|
*/
|
|
1715
|
-
|
|
1664
|
+
#handleExternalCredentialChange(change: VisitorStoreChange): void {
|
|
1716
1665
|
if (change.source !== "external" || change.previousToken === null) return;
|
|
1717
1666
|
|
|
1718
1667
|
// Nothing may reuse the session minted under the revoked credential.
|
|
1719
1668
|
this.clientSession = null;
|
|
1720
1669
|
this.client.handleExternalCredentialChange();
|
|
1721
|
-
this
|
|
1722
|
-
this
|
|
1723
|
-
this
|
|
1724
|
-
this
|
|
1670
|
+
this.#historyOpenEpoch += 1;
|
|
1671
|
+
this.#projectionFinalizationPromise = null;
|
|
1672
|
+
this.#credentialReinitPending = true;
|
|
1673
|
+
this.#credentialReinitPromise = null;
|
|
1725
1674
|
|
|
1726
1675
|
const hadVisibleTranscript = this.messages.length > 0;
|
|
1727
1676
|
const hadConversationState =
|
|
1728
1677
|
hadVisibleTranscript ||
|
|
1729
|
-
this
|
|
1678
|
+
this.#activeConversationId !== null ||
|
|
1730
1679
|
(this.config.getStoredConversationId?.() ?? null) !== null;
|
|
1731
1680
|
if (hadConversationState) {
|
|
1732
|
-
this
|
|
1681
|
+
this.#discardConversationState();
|
|
1733
1682
|
} else {
|
|
1734
1683
|
// No conversation to wipe, but the revoked visitor's ids must not survive.
|
|
1735
1684
|
this.config.clearStoredSessionId?.();
|
|
1736
1685
|
this.config.clearStoredConversationId?.();
|
|
1737
|
-
this
|
|
1738
|
-
this
|
|
1686
|
+
this.#olderPageRequests.clear();
|
|
1687
|
+
this.#setPendingProjections(null);
|
|
1739
1688
|
}
|
|
1740
|
-
this
|
|
1741
|
-
this
|
|
1742
|
-
this
|
|
1743
|
-
this
|
|
1744
|
-
this
|
|
1689
|
+
this.#activeConversationId = null;
|
|
1690
|
+
this.#activeConversationRevision = null;
|
|
1691
|
+
this.#historyNextMessageCursor = null;
|
|
1692
|
+
this.#historyRecovery = null;
|
|
1693
|
+
this.#emitHistoryState();
|
|
1745
1694
|
// Only a visible wipe announces: a silent reconcile must not push the open
|
|
1746
1695
|
// history view into a refetch that would immediately re-mint a credential.
|
|
1747
1696
|
if (hadVisibleTranscript) {
|
|
1748
|
-
this
|
|
1697
|
+
this.#notifyHistory(
|
|
1749
1698
|
"history_continuity_reset",
|
|
1750
1699
|
"This browser's conversation history was reset in another tab, so the previous messages were cleared."
|
|
1751
1700
|
);
|
|
@@ -1757,26 +1706,26 @@ export class AgentWidgetSession {
|
|
|
1757
1706
|
* Deliberately lazy: re-initializing on the storage event itself would hand
|
|
1758
1707
|
* an idle tab a fresh credential the visitor just deleted elsewhere.
|
|
1759
1708
|
*/
|
|
1760
|
-
|
|
1761
|
-
if (!this
|
|
1762
|
-
if (!this
|
|
1763
|
-
this
|
|
1764
|
-
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;
|
|
1765
1714
|
});
|
|
1766
1715
|
}
|
|
1767
|
-
return this
|
|
1716
|
+
return this.#credentialReinitPromise;
|
|
1768
1717
|
}
|
|
1769
1718
|
|
|
1770
|
-
|
|
1719
|
+
async #runCredentialReinit(): Promise<void> {
|
|
1771
1720
|
if (!this.isClientTokenMode()) {
|
|
1772
|
-
this
|
|
1721
|
+
this.#credentialReinitPending = false;
|
|
1773
1722
|
return;
|
|
1774
1723
|
}
|
|
1775
1724
|
try {
|
|
1776
1725
|
// Reads the store again: a clear mints fresh, a replacement adopts the
|
|
1777
1726
|
// sibling's token.
|
|
1778
1727
|
this.bindActivatedSession(await this.client.initSession());
|
|
1779
|
-
this
|
|
1728
|
+
this.#credentialReinitPending = false;
|
|
1780
1729
|
} catch {
|
|
1781
1730
|
// Still pending: the dispatch's own init reports the failure normally.
|
|
1782
1731
|
}
|
|
@@ -1791,53 +1740,53 @@ export class AgentWidgetSession {
|
|
|
1791
1740
|
public reconcileBootConversation(opts?: {
|
|
1792
1741
|
scope?: HistoryScope;
|
|
1793
1742
|
}): Promise<void> {
|
|
1794
|
-
const provider = this
|
|
1743
|
+
const provider = this.#historyProvider;
|
|
1795
1744
|
const conversationId =
|
|
1796
1745
|
this.clientSession?.conversationId ??
|
|
1797
1746
|
this.config.getStoredConversationId?.() ??
|
|
1798
|
-
this
|
|
1747
|
+
this.#activeConversationId;
|
|
1799
1748
|
if (!provider || !conversationId) return Promise.resolve();
|
|
1800
1749
|
|
|
1801
|
-
const context = this
|
|
1802
|
-
this
|
|
1803
|
-
this
|
|
1804
|
-
const run = this
|
|
1750
|
+
const context = this.#historyContext(opts?.scope);
|
|
1751
|
+
this.#historySendBlocked = true;
|
|
1752
|
+
this.#emitHistoryState();
|
|
1753
|
+
const run = this.#runBootReconciliation(
|
|
1805
1754
|
provider,
|
|
1806
1755
|
conversationId,
|
|
1807
1756
|
context
|
|
1808
1757
|
).finally(() => {
|
|
1809
|
-
this
|
|
1810
|
-
if (this
|
|
1811
|
-
this
|
|
1758
|
+
this.#historySendBlocked = this.#historyRecovery !== null;
|
|
1759
|
+
if (this.#historyGate === run) this.#historyGate = null;
|
|
1760
|
+
this.#emitHistoryState();
|
|
1812
1761
|
});
|
|
1813
|
-
this
|
|
1762
|
+
this.#historyGate = run;
|
|
1814
1763
|
return run;
|
|
1815
1764
|
}
|
|
1816
1765
|
|
|
1817
|
-
|
|
1766
|
+
async #runBootReconciliation(
|
|
1818
1767
|
provider: HistoryProvider,
|
|
1819
1768
|
conversationId: string,
|
|
1820
1769
|
context: HistoryOperationContext
|
|
1821
1770
|
): Promise<void> {
|
|
1822
|
-
this
|
|
1771
|
+
this.#activeConversationId = conversationId;
|
|
1823
1772
|
const initRevision =
|
|
1824
1773
|
this.clientSession?.conversationRevision ??
|
|
1825
|
-
this
|
|
1774
|
+
this.#activeConversationRevision ??
|
|
1826
1775
|
null;
|
|
1827
|
-
const persistedRevision = this
|
|
1828
|
-
? this
|
|
1776
|
+
const persistedRevision = this.#bootRevisionCaptured
|
|
1777
|
+
? this.#bootConversationRevision
|
|
1829
1778
|
: (this.historyInternals.getStoredConversationRevision?.() ?? null);
|
|
1830
|
-
this
|
|
1831
|
-
this
|
|
1779
|
+
this.#bootRevisionCaptured = false;
|
|
1780
|
+
this.#historyNextMessageCursor =
|
|
1832
1781
|
this.historyInternals.getStoredMessageCursor?.() ??
|
|
1833
|
-
this
|
|
1782
|
+
this.#historyNextMessageCursor;
|
|
1834
1783
|
|
|
1835
1784
|
// Any locally finalized projection still owed to the server goes first, so
|
|
1836
1785
|
// display reconciliation cannot complete against a stale server record.
|
|
1837
1786
|
await this.replayPendingProjections();
|
|
1838
1787
|
|
|
1839
1788
|
if (initRevision && persistedRevision && initRevision === persistedRevision) {
|
|
1840
|
-
this
|
|
1789
|
+
this.#activeConversationRevision = initRevision;
|
|
1841
1790
|
return;
|
|
1842
1791
|
}
|
|
1843
1792
|
|
|
@@ -1849,9 +1798,9 @@ export class AgentWidgetSession {
|
|
|
1849
1798
|
let reconciled: AgentWidgetMessage[];
|
|
1850
1799
|
if (!overlaps) {
|
|
1851
1800
|
// No shared id: the local transcript is not this record's newest window.
|
|
1852
|
-
reconciled = page.messages.map((message) => this
|
|
1801
|
+
reconciled = page.messages.map((message) => this.#ensureSequence(message));
|
|
1853
1802
|
} else {
|
|
1854
|
-
const durableId = this
|
|
1803
|
+
const durableId = this.#resumable ? this.#activeAssistantMessageId : null;
|
|
1855
1804
|
const survivors = this.messages.filter((message) => {
|
|
1856
1805
|
if (serverIds.has(message.id)) return true;
|
|
1857
1806
|
if (message.id === durableId) return true;
|
|
@@ -1859,53 +1808,53 @@ export class AgentWidgetSession {
|
|
|
1859
1808
|
return pageOldest !== undefined && message.createdAt < pageOldest;
|
|
1860
1809
|
});
|
|
1861
1810
|
reconciled = mergeWireMessagesById(survivors, page.messages).map(
|
|
1862
|
-
(message) => this
|
|
1811
|
+
(message) => this.#ensureSequence(message)
|
|
1863
1812
|
);
|
|
1864
1813
|
}
|
|
1865
1814
|
|
|
1866
|
-
this
|
|
1867
|
-
this
|
|
1815
|
+
this.#historyNextMessageCursor = page.nextCursor;
|
|
1816
|
+
this.#activeConversationRevision =
|
|
1868
1817
|
page.conversationRevision || initRevision || null;
|
|
1869
|
-
this
|
|
1818
|
+
this.#acknowledgedProjections.clear();
|
|
1870
1819
|
for (const message of page.messages) {
|
|
1871
|
-
this
|
|
1820
|
+
this.#acknowledgedProjections.set(message.id, message.content);
|
|
1872
1821
|
}
|
|
1873
1822
|
// Metadata first, transcript last: one persist covers both.
|
|
1874
|
-
this
|
|
1875
|
-
this.messages = this
|
|
1823
|
+
this.#persistConversationMetadata();
|
|
1824
|
+
this.messages = this.#sortMessages(reconciled);
|
|
1876
1825
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1877
1826
|
}
|
|
1878
1827
|
|
|
1879
1828
|
// ── Display-projection finalization ────────────────────────────
|
|
1880
1829
|
|
|
1881
1830
|
/** Runtype transport op: active session + browser scope, never a proof. */
|
|
1882
|
-
|
|
1831
|
+
#projectionFinalizationEnabled(): boolean {
|
|
1883
1832
|
return (
|
|
1884
1833
|
this.config.features?.history?.enabled === true &&
|
|
1885
1834
|
this.isClientTokenMode()
|
|
1886
1835
|
);
|
|
1887
1836
|
}
|
|
1888
1837
|
|
|
1889
|
-
|
|
1890
|
-
this
|
|
1838
|
+
#setPendingProjections(pending: PendingDisplayProjections | null): void {
|
|
1839
|
+
this.#pendingProjections = pending;
|
|
1891
1840
|
// Memory-only when the shell supplies no persistence seam.
|
|
1892
1841
|
this.historyInternals.setPendingDisplayProjections?.(pending);
|
|
1893
1842
|
}
|
|
1894
1843
|
|
|
1895
|
-
|
|
1844
|
+
#readPendingProjections(): PendingDisplayProjections | null {
|
|
1896
1845
|
return (
|
|
1897
1846
|
this.historyInternals.getPendingDisplayProjections?.() ??
|
|
1898
|
-
this
|
|
1847
|
+
this.#pendingProjections
|
|
1899
1848
|
);
|
|
1900
1849
|
}
|
|
1901
1850
|
|
|
1902
1851
|
/** Record what the dispatch payload carried as each message's projection. */
|
|
1903
|
-
|
|
1904
|
-
if (!this
|
|
1852
|
+
#recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
|
|
1853
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1905
1854
|
for (const message of messages) {
|
|
1906
1855
|
const projection = divergentDisplayProjection(message);
|
|
1907
1856
|
if (projection !== undefined) {
|
|
1908
|
-
this
|
|
1857
|
+
this.#acknowledgedProjections.set(message.id, projection);
|
|
1909
1858
|
}
|
|
1910
1859
|
}
|
|
1911
1860
|
}
|
|
@@ -1914,57 +1863,57 @@ export class AgentWidgetSession {
|
|
|
1914
1863
|
* After a terminal assistant message, finalize any browser-derived projection
|
|
1915
1864
|
* the server cannot have. Painting is never delayed by this.
|
|
1916
1865
|
*/
|
|
1917
|
-
|
|
1918
|
-
if (!this
|
|
1919
|
-
const conversationId = this
|
|
1866
|
+
#scheduleProjectionFinalization(): void {
|
|
1867
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1868
|
+
const conversationId = this.#activeConversationId;
|
|
1920
1869
|
if (!conversationId) return;
|
|
1921
1870
|
const batch: HistoryDisplayProjection[] = [];
|
|
1922
1871
|
for (const message of this.messages) {
|
|
1923
1872
|
if (message.role !== "assistant" || message.streaming) continue;
|
|
1924
1873
|
const projection = divergentDisplayProjection(message);
|
|
1925
1874
|
if (projection === undefined) continue;
|
|
1926
|
-
if (this
|
|
1875
|
+
if (this.#acknowledgedProjections.get(message.id) === projection) continue;
|
|
1927
1876
|
batch.push({ id: message.id, displayContent: projection });
|
|
1928
1877
|
}
|
|
1929
1878
|
if (batch.length === 0) return;
|
|
1930
|
-
this
|
|
1879
|
+
this.#setPendingProjections({
|
|
1931
1880
|
conversationId,
|
|
1932
1881
|
messageIds: batch.map((item) => item.id),
|
|
1933
1882
|
});
|
|
1934
|
-
this
|
|
1883
|
+
this.#enqueueProjectionFinalization(conversationId, batch);
|
|
1935
1884
|
}
|
|
1936
1885
|
|
|
1937
1886
|
/** Serialized: operations await this chain instead of racing it. */
|
|
1938
|
-
|
|
1887
|
+
#enqueueProjectionFinalization(
|
|
1939
1888
|
conversationId: string,
|
|
1940
1889
|
batch: HistoryDisplayProjection[]
|
|
1941
1890
|
): void {
|
|
1942
|
-
const previous = this
|
|
1891
|
+
const previous = this.#projectionFinalizationPromise ?? Promise.resolve();
|
|
1943
1892
|
const chain = previous
|
|
1944
1893
|
.catch(() => {})
|
|
1945
|
-
.then(() => this
|
|
1894
|
+
.then(() => this.#finalizeProjections(conversationId, batch, false))
|
|
1946
1895
|
.finally(() => {
|
|
1947
|
-
if (this
|
|
1948
|
-
this
|
|
1896
|
+
if (this.#projectionFinalizationPromise === chain) {
|
|
1897
|
+
this.#projectionFinalizationPromise = null;
|
|
1949
1898
|
}
|
|
1950
1899
|
});
|
|
1951
|
-
this
|
|
1900
|
+
this.#projectionFinalizationPromise = chain;
|
|
1952
1901
|
}
|
|
1953
1902
|
|
|
1954
|
-
|
|
1903
|
+
async #finalizeProjections(
|
|
1955
1904
|
conversationId: string,
|
|
1956
1905
|
batch: HistoryDisplayProjection[],
|
|
1957
1906
|
retried: boolean
|
|
1958
1907
|
): Promise<void> {
|
|
1959
1908
|
// A switch/reset/delete already invalidated this marker.
|
|
1960
|
-
if (this
|
|
1909
|
+
if (this.#activeConversationId !== conversationId) return;
|
|
1961
1910
|
// Two terminal scans can enqueue the same batch before the first PATCH
|
|
1962
1911
|
// acknowledges; the chain serializes them, so re-filter at run time.
|
|
1963
1912
|
const unacknowledged = batch.filter(
|
|
1964
|
-
(item) => this
|
|
1913
|
+
(item) => this.#acknowledgedProjections.get(item.id) !== item.displayContent
|
|
1965
1914
|
);
|
|
1966
1915
|
if (unacknowledged.length === 0) {
|
|
1967
|
-
this
|
|
1916
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
1968
1917
|
return;
|
|
1969
1918
|
}
|
|
1970
1919
|
try {
|
|
@@ -1973,15 +1922,15 @@ export class AgentWidgetSession {
|
|
|
1973
1922
|
unacknowledged
|
|
1974
1923
|
);
|
|
1975
1924
|
for (const item of unacknowledged) {
|
|
1976
|
-
this
|
|
1925
|
+
this.#acknowledgedProjections.set(item.id, item.displayContent);
|
|
1977
1926
|
}
|
|
1978
|
-
if (this
|
|
1979
|
-
this
|
|
1927
|
+
if (this.#activeConversationId === conversationId && conversationRevision) {
|
|
1928
|
+
this.#activeConversationRevision = conversationRevision;
|
|
1980
1929
|
}
|
|
1981
|
-
this
|
|
1930
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
1982
1931
|
} catch (error) {
|
|
1983
|
-
if (!retried && this
|
|
1984
|
-
await this
|
|
1932
|
+
if (!retried && this.#isTransientProjectionError(error)) {
|
|
1933
|
+
await this.#finalizeProjections(conversationId, unacknowledged, true);
|
|
1985
1934
|
return;
|
|
1986
1935
|
}
|
|
1987
1936
|
if (
|
|
@@ -1989,11 +1938,11 @@ export class AgentWidgetSession {
|
|
|
1989
1938
|
(error.code === "not_found" || error.code === "conversation_deleted")
|
|
1990
1939
|
) {
|
|
1991
1940
|
// The record is gone: drop the projection instead of retrying forever.
|
|
1992
|
-
this
|
|
1941
|
+
this.#clearFinalizedMarker(conversationId, batch);
|
|
1993
1942
|
return;
|
|
1994
1943
|
}
|
|
1995
1944
|
// Marker retained for the next boot/dispatch; the turn itself succeeded.
|
|
1996
|
-
this
|
|
1945
|
+
this.#notifyHistory(
|
|
1997
1946
|
"projection_finalization_failed",
|
|
1998
1947
|
"Some message formatting could not be saved for history; it will be retried."
|
|
1999
1948
|
);
|
|
@@ -2001,20 +1950,20 @@ export class AgentWidgetSession {
|
|
|
2001
1950
|
}
|
|
2002
1951
|
|
|
2003
1952
|
/** Transport-shaped failures only; a contract failure is not retried. */
|
|
2004
|
-
|
|
1953
|
+
#isTransientProjectionError(error: unknown): boolean {
|
|
2005
1954
|
if (error instanceof HistoryClientError) return error.code === "request_failed";
|
|
2006
1955
|
return error instanceof Error;
|
|
2007
1956
|
}
|
|
2008
1957
|
|
|
2009
|
-
|
|
1958
|
+
#clearFinalizedMarker(
|
|
2010
1959
|
conversationId: string,
|
|
2011
1960
|
batch: HistoryDisplayProjection[]
|
|
2012
1961
|
): void {
|
|
2013
|
-
const pending = this
|
|
1962
|
+
const pending = this.#readPendingProjections();
|
|
2014
1963
|
if (!pending || pending.conversationId !== conversationId) return;
|
|
2015
1964
|
const done = new Set(batch.map((item) => item.id));
|
|
2016
1965
|
const remaining = pending.messageIds.filter((id) => !done.has(id));
|
|
2017
|
-
this
|
|
1966
|
+
this.#setPendingProjections(
|
|
2018
1967
|
remaining.length ? { conversationId, messageIds: remaining } : null
|
|
2019
1968
|
);
|
|
2020
1969
|
}
|
|
@@ -2024,15 +1973,15 @@ export class AgentWidgetSession {
|
|
|
2024
1973
|
* message content, then finalize before display reconciliation completes.
|
|
2025
1974
|
*/
|
|
2026
1975
|
public async replayPendingProjections(): Promise<void> {
|
|
2027
|
-
if (!this
|
|
2028
|
-
const pending = this
|
|
1976
|
+
if (!this.#projectionFinalizationEnabled()) return;
|
|
1977
|
+
const pending = this.#readPendingProjections();
|
|
2029
1978
|
if (!pending) return;
|
|
2030
1979
|
if (
|
|
2031
|
-
this
|
|
2032
|
-
pending.conversationId !== this
|
|
1980
|
+
this.#activeConversationId &&
|
|
1981
|
+
pending.conversationId !== this.#activeConversationId
|
|
2033
1982
|
) {
|
|
2034
1983
|
// Stale marker from another record: never replay it into this one.
|
|
2035
|
-
this
|
|
1984
|
+
this.#setPendingProjections(null);
|
|
2036
1985
|
return;
|
|
2037
1986
|
}
|
|
2038
1987
|
const batch: HistoryDisplayProjection[] = [];
|
|
@@ -2042,33 +1991,33 @@ export class AgentWidgetSession {
|
|
|
2042
1991
|
batch.push({ id, displayContent: message.content });
|
|
2043
1992
|
}
|
|
2044
1993
|
if (batch.length === 0) {
|
|
2045
|
-
this
|
|
1994
|
+
this.#setPendingProjections(null);
|
|
2046
1995
|
return;
|
|
2047
1996
|
}
|
|
2048
|
-
this
|
|
2049
|
-
await this
|
|
1997
|
+
this.#enqueueProjectionFinalization(pending.conversationId, batch);
|
|
1998
|
+
await this.#projectionFinalizationPromise?.catch(() => {});
|
|
2050
1999
|
}
|
|
2051
2000
|
|
|
2052
2001
|
/**
|
|
2053
2002
|
* A local transcript mutation whose response carries no new revision: clear
|
|
2054
2003
|
* the persisted one rather than guessing, so the next boot refreshes safely.
|
|
2055
2004
|
*/
|
|
2056
|
-
|
|
2057
|
-
if (!this
|
|
2058
|
-
this
|
|
2005
|
+
#invalidateConversationRevision(): void {
|
|
2006
|
+
if (!this.#activeConversationId) return;
|
|
2007
|
+
this.#activeConversationRevision = null;
|
|
2059
2008
|
this.historyInternals.setStoredConversationRevision?.(null);
|
|
2060
2009
|
}
|
|
2061
2010
|
|
|
2062
2011
|
/** Blocks a send until the owning history transition finishes. */
|
|
2063
|
-
|
|
2012
|
+
async #awaitHistorySendable(): Promise<void> {
|
|
2064
2013
|
await this.awaitHistorySettled();
|
|
2065
|
-
await this
|
|
2066
|
-
if (this
|
|
2067
|
-
await this
|
|
2014
|
+
await this.#completeCredentialReinit();
|
|
2015
|
+
if (this.#historyRecovery === "new_conversation_required") {
|
|
2016
|
+
await this.#retryRequiredConversation();
|
|
2068
2017
|
}
|
|
2069
2018
|
}
|
|
2070
2019
|
|
|
2071
|
-
|
|
2020
|
+
static #isConversationDeleted(error: unknown): boolean {
|
|
2072
2021
|
return (
|
|
2073
2022
|
error instanceof HistoryClientError &&
|
|
2074
2023
|
error.code === "conversation_deleted"
|
|
@@ -2079,14 +2028,14 @@ export class AgentWidgetSession {
|
|
|
2079
2028
|
* Safe 410 recovery (D5). The old payload would recreate the transcript that
|
|
2080
2029
|
* was just deleted, so the retry carries ONLY the just-submitted user turn.
|
|
2081
2030
|
*/
|
|
2082
|
-
|
|
2031
|
+
async #dispatchWithDeletedRecovery(
|
|
2083
2032
|
snapshot: AgentWidgetMessage[],
|
|
2084
2033
|
controller: AbortController,
|
|
2085
2034
|
assistantMessageId: string,
|
|
2086
2035
|
userMessageId: string,
|
|
2087
2036
|
turnOptions?: { composerOptions?: ComposerOptionsPayload; interrupt?: boolean }
|
|
2088
2037
|
): Promise<void> {
|
|
2089
|
-
this
|
|
2038
|
+
this.#recordDispatchedProjections(snapshot);
|
|
2090
2039
|
try {
|
|
2091
2040
|
await this.client.dispatch(
|
|
2092
2041
|
{
|
|
@@ -2100,12 +2049,12 @@ export class AgentWidgetSession {
|
|
|
2100
2049
|
);
|
|
2101
2050
|
return;
|
|
2102
2051
|
} catch (error) {
|
|
2103
|
-
if (!AgentWidgetSession
|
|
2104
|
-
const replacement = await this
|
|
2052
|
+
if (!AgentWidgetSession.#isConversationDeleted(error)) throw error;
|
|
2053
|
+
const replacement = await this.#recoverFromDeletedConversation(userMessageId);
|
|
2105
2054
|
// No recoverable turn (or no fresh record): surface the original 410.
|
|
2106
2055
|
if (!replacement) throw error;
|
|
2107
2056
|
// A second 410 propagates: exactly one recovery attempt per turn.
|
|
2108
|
-
this
|
|
2057
|
+
this.#recordDispatchedProjections([replacement]);
|
|
2109
2058
|
await this.client.dispatch(
|
|
2110
2059
|
{
|
|
2111
2060
|
messages: [replacement],
|
|
@@ -2122,7 +2071,7 @@ export class AgentWidgetSession {
|
|
|
2122
2071
|
* Capture only the newly submitted user message, destroy the deleted record's
|
|
2123
2072
|
* local state, and initialize a fresh owned conversation to retry into.
|
|
2124
2073
|
*/
|
|
2125
|
-
|
|
2074
|
+
async #recoverFromDeletedConversation(
|
|
2126
2075
|
userMessageId: string
|
|
2127
2076
|
): Promise<AgentWidgetMessage | null> {
|
|
2128
2077
|
const submitted = this.messages.find(
|
|
@@ -2131,44 +2080,44 @@ export class AgentWidgetSession {
|
|
|
2131
2080
|
if (!submitted) return null;
|
|
2132
2081
|
const captured: AgentWidgetMessage = { ...submitted };
|
|
2133
2082
|
|
|
2134
|
-
this
|
|
2135
|
-
this
|
|
2136
|
-
this
|
|
2137
|
-
this
|
|
2138
|
-
this
|
|
2139
|
-
this
|
|
2083
|
+
this.#historyOpenEpoch += 1;
|
|
2084
|
+
this.#projectionFinalizationPromise = null;
|
|
2085
|
+
this.#discardConversationState();
|
|
2086
|
+
this.#activeConversationId = null;
|
|
2087
|
+
this.#activeConversationRevision = null;
|
|
2088
|
+
this.#historyNextMessageCursor = null;
|
|
2140
2089
|
|
|
2141
2090
|
// The recovered turn is the whole transcript: no welcome bubble in front.
|
|
2142
|
-
this
|
|
2091
|
+
this.#suppressWelcomeInjection = true;
|
|
2143
2092
|
try {
|
|
2144
|
-
const provider = this
|
|
2093
|
+
const provider = this.#historyProvider;
|
|
2145
2094
|
if (provider) {
|
|
2146
2095
|
const prepared = await provider.prepareStartNew({
|
|
2147
|
-
context: this
|
|
2096
|
+
context: this.#historyContext(),
|
|
2148
2097
|
});
|
|
2149
|
-
await this
|
|
2098
|
+
await this.#installFreshConversation(prepared);
|
|
2150
2099
|
} else {
|
|
2151
2100
|
this.client.clearClientSession();
|
|
2152
2101
|
const session = await this.client.initSession();
|
|
2153
2102
|
this.bindActivatedSession(session);
|
|
2154
2103
|
}
|
|
2155
2104
|
} catch {
|
|
2156
|
-
this
|
|
2157
|
-
this
|
|
2158
|
-
this
|
|
2159
|
-
this
|
|
2105
|
+
this.#historySendBlocked = true;
|
|
2106
|
+
this.#historyRecovery = "new_conversation_required";
|
|
2107
|
+
this.#emitHistoryState();
|
|
2108
|
+
this.#notifyHistory(
|
|
2160
2109
|
"new_conversation_required",
|
|
2161
2110
|
"That conversation was deleted. Start a new conversation to continue."
|
|
2162
2111
|
);
|
|
2163
2112
|
return null;
|
|
2164
2113
|
} finally {
|
|
2165
|
-
this
|
|
2114
|
+
this.#suppressWelcomeInjection = false;
|
|
2166
2115
|
}
|
|
2167
2116
|
|
|
2168
2117
|
// Restore only the user's own turn: it is the entire new transcript.
|
|
2169
|
-
this
|
|
2170
|
-
this
|
|
2171
|
-
this
|
|
2118
|
+
this.#appendMessage(captured);
|
|
2119
|
+
this.#setStreaming(true);
|
|
2120
|
+
this.#notifyHistory(
|
|
2172
2121
|
"conversation_deleted_recovered",
|
|
2173
2122
|
"That conversation was deleted. Started a new conversation."
|
|
2174
2123
|
);
|
|
@@ -2200,7 +2149,7 @@ export class AgentWidgetSession {
|
|
|
2200
2149
|
// turn that's restyling the widget and strand the paused execution.
|
|
2201
2150
|
if (!replaceClient) {
|
|
2202
2151
|
this.client.updateConfig(merged);
|
|
2203
|
-
if (artifactDisplayChanged) this
|
|
2152
|
+
if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
|
|
2204
2153
|
return;
|
|
2205
2154
|
}
|
|
2206
2155
|
|
|
@@ -2211,13 +2160,13 @@ export class AgentWidgetSession {
|
|
|
2211
2160
|
// fresh client: in client-token mode that would POST /resume without a
|
|
2212
2161
|
// valid sessionId and strand the paused turn. Mirrors clearMessages' WebMCP
|
|
2213
2162
|
// reset; the client swap already abandons any in-flight stream regardless.
|
|
2214
|
-
this
|
|
2163
|
+
this.#abortWebMcpResolves();
|
|
2215
2164
|
this.webMcpInflightKeys.clear();
|
|
2216
2165
|
this.webMcpResolvedKeys.clear();
|
|
2217
2166
|
const prevSSECallback = this.client.getSSEEventCallback();
|
|
2218
2167
|
this.client = new AgentWidgetClient(this.config, this.historyInternals);
|
|
2219
|
-
if (artifactDisplayChanged) this
|
|
2220
|
-
this
|
|
2168
|
+
if (artifactDisplayChanged) this.#refreshArtifactReferenceBlocks();
|
|
2169
|
+
this.#wireDefaultWebMcpConfirm();
|
|
2221
2170
|
if (prevSSECallback) {
|
|
2222
2171
|
this.client.setSSEEventCallback(prevSSECallback);
|
|
2223
2172
|
}
|
|
@@ -2278,6 +2227,7 @@ export class AgentWidgetSession {
|
|
|
2278
2227
|
sequence,
|
|
2279
2228
|
streaming = false,
|
|
2280
2229
|
voiceProcessing,
|
|
2230
|
+
voiceCaption,
|
|
2281
2231
|
rawContent
|
|
2282
2232
|
} = options;
|
|
2283
2233
|
|
|
@@ -2295,12 +2245,13 @@ export class AgentWidgetSession {
|
|
|
2295
2245
|
role,
|
|
2296
2246
|
content,
|
|
2297
2247
|
createdAt: createdAt ?? new Date().toISOString(),
|
|
2298
|
-
sequence: sequence ?? this
|
|
2248
|
+
sequence: sequence ?? this.#nextSequence(),
|
|
2299
2249
|
streaming,
|
|
2300
2250
|
// Only include optional fields if provided
|
|
2301
2251
|
...(llmContent !== undefined && { llmContent }),
|
|
2302
2252
|
...(contentParts !== undefined && { contentParts }),
|
|
2303
2253
|
...(voiceProcessing !== undefined && { voiceProcessing }),
|
|
2254
|
+
...(voiceCaption && { voiceCaption }),
|
|
2304
2255
|
...(rawContent !== undefined && { rawContent })
|
|
2305
2256
|
};
|
|
2306
2257
|
|
|
@@ -2398,7 +2349,7 @@ export class AgentWidgetSession {
|
|
|
2398
2349
|
role,
|
|
2399
2350
|
content,
|
|
2400
2351
|
createdAt: createdAt ?? new Date().toISOString(),
|
|
2401
|
-
sequence: sequence ?? this
|
|
2352
|
+
sequence: sequence ?? this.#nextSequence(),
|
|
2402
2353
|
streaming,
|
|
2403
2354
|
...(llmContent !== undefined && { llmContent }),
|
|
2404
2355
|
...(contentParts !== undefined && { contentParts }),
|
|
@@ -2410,7 +2361,7 @@ export class AgentWidgetSession {
|
|
|
2410
2361
|
}
|
|
2411
2362
|
|
|
2412
2363
|
// Add all messages, sort once, notify once
|
|
2413
|
-
this.messages = this
|
|
2364
|
+
this.messages = this.#sortMessages([...this.messages, ...results]);
|
|
2414
2365
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
2415
2366
|
|
|
2416
2367
|
return results;
|
|
@@ -2471,7 +2422,7 @@ export class AgentWidgetSession {
|
|
|
2471
2422
|
* client to namespace into the request `context`. Failures were already
|
|
2472
2423
|
* dropped by `finalize()`.
|
|
2473
2424
|
*/
|
|
2474
|
-
|
|
2425
|
+
async #applyMentionBundle(
|
|
2475
2426
|
userMessage: AgentWidgetMessage,
|
|
2476
2427
|
typedText: string,
|
|
2477
2428
|
finalize: () => Promise<MentionSubmitBundle>
|
|
@@ -2569,6 +2520,13 @@ export class AgentWidgetSession {
|
|
|
2569
2520
|
* (`composer.streamingSubmitBehavior: "interrupt"`).
|
|
2570
2521
|
*/
|
|
2571
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 };
|
|
2572
2530
|
}
|
|
2573
2531
|
) {
|
|
2574
2532
|
const input = rawInput.trim();
|
|
@@ -2588,16 +2546,23 @@ export class AgentWidgetSession {
|
|
|
2588
2546
|
)
|
|
2589
2547
|
return;
|
|
2590
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
|
+
|
|
2591
2556
|
// History transitions (continuity wipe, boot reconciliation, projection
|
|
2592
2557
|
// finalization, replacement init, external credential change) own the
|
|
2593
2558
|
// record this turn would land in.
|
|
2594
2559
|
if (
|
|
2595
|
-
this
|
|
2596
|
-
this
|
|
2597
|
-
this
|
|
2598
|
-
this
|
|
2560
|
+
this.#historyGate ||
|
|
2561
|
+
this.#projectionFinalizationPromise ||
|
|
2562
|
+
this.#historySendBlocked ||
|
|
2563
|
+
this.#credentialReinitPending
|
|
2599
2564
|
) {
|
|
2600
|
-
await this
|
|
2565
|
+
await this.#awaitHistorySendable();
|
|
2601
2566
|
}
|
|
2602
2567
|
|
|
2603
2568
|
this.stopSpeaking();
|
|
@@ -2606,18 +2571,23 @@ export class AgentWidgetSession {
|
|
|
2606
2571
|
// turn. Tear them down here (they own controllers separate from the shared
|
|
2607
2572
|
// one) so a lingering resolve can't race the new dispatch or post a stale
|
|
2608
2573
|
// /resume against a superseded execution.
|
|
2609
|
-
this
|
|
2574
|
+
this.#abortWebMcpResolves();
|
|
2575
|
+
this.#actionsEpoch++;
|
|
2610
2576
|
// A new turn also supersedes any pending durable reconnect from the prior
|
|
2611
2577
|
// turn (cancels backoff/listeners, clears the old resume handle).
|
|
2612
|
-
this
|
|
2578
|
+
this.#teardownReconnect();
|
|
2613
2579
|
|
|
2580
|
+
const voiceBubbleId = options?.voiceTurn?.userMessageId;
|
|
2581
|
+
const voiceBubble = voiceBubbleId
|
|
2582
|
+
? this.messages.find((m) => m.id === voiceBubbleId && m.role === "user")
|
|
2583
|
+
: undefined;
|
|
2614
2584
|
// Generate IDs for both user message and expected assistant response
|
|
2615
|
-
const userMessageId = generateUserMessageId();
|
|
2585
|
+
const userMessageId = voiceBubble?.id ?? generateUserMessageId();
|
|
2616
2586
|
const assistantMessageId = generateAssistantMessageId();
|
|
2617
2587
|
// The active assistant bubble for a durable reconnect is captured from the
|
|
2618
2588
|
// real streamed message events (see handleEvent), not pre-assigned here:
|
|
2619
2589
|
// the proxy path auto-generates a different id than `assistantMessageId`.
|
|
2620
|
-
this
|
|
2590
|
+
this.#activeAssistantMessageId = null;
|
|
2621
2591
|
|
|
2622
2592
|
// Fallback display text ONLY when the sole content is image attachments.
|
|
2623
2593
|
// A mention/command-only submit (empty text + a chip) must NOT read as
|
|
@@ -2632,7 +2602,7 @@ export class AgentWidgetSession {
|
|
|
2632
2602
|
role: "user",
|
|
2633
2603
|
content: input || imageOnlyFallback, // Display text (fallback if only images)
|
|
2634
2604
|
createdAt: new Date().toISOString(),
|
|
2635
|
-
sequence: this
|
|
2605
|
+
sequence: this.#nextSequence(),
|
|
2636
2606
|
viaVoice: options?.viaVoice || false,
|
|
2637
2607
|
// Include contentParts if provided (for multi-modal messages)
|
|
2638
2608
|
...(options?.contentParts && options.contentParts.length > 0 && {
|
|
@@ -2652,8 +2622,17 @@ export class AgentWidgetSession {
|
|
|
2652
2622
|
...(options?.replayFields ?? {})
|
|
2653
2623
|
};
|
|
2654
2624
|
|
|
2655
|
-
|
|
2656
|
-
|
|
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);
|
|
2657
2636
|
|
|
2658
2637
|
// Assign the fresh controller BEFORE the mention await so cancel() (or a
|
|
2659
2638
|
// superseding sendMessage) during finalize() aborts THIS turn, not a stale
|
|
@@ -2670,7 +2649,7 @@ export class AgentWidgetSession {
|
|
|
2670
2649
|
if (options?.mentions) {
|
|
2671
2650
|
const stored =
|
|
2672
2651
|
this.messages.find((m) => m.id === userMessageId) ?? userMessage;
|
|
2673
|
-
await this
|
|
2652
|
+
await this.#applyMentionBundle(stored, input, options.mentions.finalize);
|
|
2674
2653
|
// A cancel() or new sendMessage during finalize aborted this controller
|
|
2675
2654
|
// (and replaced/nulled the shared ref). Bail without dispatching and
|
|
2676
2655
|
// leave whatever idle/streaming state that caller already set.
|
|
@@ -2700,10 +2679,17 @@ export class AgentWidgetSession {
|
|
|
2700
2679
|
}
|
|
2701
2680
|
}
|
|
2702
2681
|
|
|
2703
|
-
|
|
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];
|
|
2704
2690
|
|
|
2705
2691
|
try {
|
|
2706
|
-
await this
|
|
2692
|
+
await this.#dispatchWithDeletedRecovery(
|
|
2707
2693
|
snapshot,
|
|
2708
2694
|
controller,
|
|
2709
2695
|
assistantMessageId,
|
|
@@ -2718,7 +2704,8 @@ export class AgentWidgetSession {
|
|
|
2718
2704
|
// first, which already flipped us into `resuming` and armed reconnect.
|
|
2719
2705
|
// The subsequent dispatch rejection must NOT paint a dispatch-error
|
|
2720
2706
|
// bubble: the turn is being resumed, not failed.
|
|
2721
|
-
if (this.status === "resuming" || this
|
|
2707
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
2708
|
+
if (this.#voiceDelegation) this.#voiceDelegation.failed = true;
|
|
2722
2709
|
// Check if this is an abort error (user canceled, navigated away, etc.)
|
|
2723
2710
|
// In these cases, don't show fallback - the request was intentionally interrupted
|
|
2724
2711
|
const isAbortError =
|
|
@@ -2740,15 +2727,15 @@ export class AgentWidgetSession {
|
|
|
2740
2727
|
role: "assistant",
|
|
2741
2728
|
createdAt: new Date().toISOString(),
|
|
2742
2729
|
content,
|
|
2743
|
-
sequence: this
|
|
2730
|
+
sequence: this.#nextSequence()
|
|
2744
2731
|
};
|
|
2745
2732
|
|
|
2746
|
-
this
|
|
2733
|
+
this.#appendMessage(fallback);
|
|
2747
2734
|
}
|
|
2748
2735
|
}
|
|
2749
2736
|
|
|
2750
|
-
this
|
|
2751
|
-
this
|
|
2737
|
+
this.#setStatus("idle");
|
|
2738
|
+
this.#setStreaming(false);
|
|
2752
2739
|
this.abortController = null;
|
|
2753
2740
|
|
|
2754
2741
|
if (!isAbortError) {
|
|
@@ -2816,7 +2803,7 @@ export class AgentWidgetSession {
|
|
|
2816
2803
|
};
|
|
2817
2804
|
|
|
2818
2805
|
const composerOptions = replacement
|
|
2819
|
-
? this
|
|
2806
|
+
? this.#toComposerOptionsPayload(replacement.options)
|
|
2820
2807
|
: stored.composerOptions;
|
|
2821
2808
|
const contentSegments = replacement
|
|
2822
2809
|
? replacement.contentSegments
|
|
@@ -2843,7 +2830,7 @@ export class AgentWidgetSession {
|
|
|
2843
2830
|
}
|
|
2844
2831
|
|
|
2845
2832
|
/** Submission options → the wire payload shape; quote travels separately. */
|
|
2846
|
-
|
|
2833
|
+
#toComposerOptionsPayload(
|
|
2847
2834
|
options: { selectedModelId?: string; activeModeIds?: string[] } | undefined
|
|
2848
2835
|
): ComposerOptionsPayload | undefined {
|
|
2849
2836
|
if (!options) return undefined;
|
|
@@ -2874,21 +2861,21 @@ export class AgentWidgetSession {
|
|
|
2874
2861
|
// Same ownership rule as sendMessage: a pending history transition owns the
|
|
2875
2862
|
// record this turn would land in.
|
|
2876
2863
|
if (
|
|
2877
|
-
this
|
|
2878
|
-
this
|
|
2879
|
-
this
|
|
2880
|
-
this
|
|
2864
|
+
this.#historyGate ||
|
|
2865
|
+
this.#projectionFinalizationPromise ||
|
|
2866
|
+
this.#historySendBlocked ||
|
|
2867
|
+
this.#credentialReinitPending
|
|
2881
2868
|
) {
|
|
2882
|
-
await this
|
|
2869
|
+
await this.#awaitHistorySendable();
|
|
2883
2870
|
}
|
|
2884
2871
|
|
|
2885
2872
|
this.abortController?.abort();
|
|
2886
|
-
this
|
|
2873
|
+
this.#teardownReconnect();
|
|
2887
2874
|
|
|
2888
2875
|
const assistantMessageId = generateAssistantMessageId();
|
|
2889
|
-
this
|
|
2876
|
+
this.#activeAssistantMessageId = null;
|
|
2890
2877
|
|
|
2891
|
-
this
|
|
2878
|
+
this.#setStreaming(true);
|
|
2892
2879
|
|
|
2893
2880
|
const controller = new AbortController();
|
|
2894
2881
|
this.abortController = controller;
|
|
@@ -2905,7 +2892,7 @@ export class AgentWidgetSession {
|
|
|
2905
2892
|
this.handleEvent
|
|
2906
2893
|
);
|
|
2907
2894
|
} catch (error) {
|
|
2908
|
-
if (this.status === "resuming" || this
|
|
2895
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
2909
2896
|
// Check if this is an abort error (a prior in-flight stream was canceled,
|
|
2910
2897
|
// the user navigated away, etc.). In these cases, don't show fallback or
|
|
2911
2898
|
// fire onError - the request was intentionally interrupted.
|
|
@@ -2928,14 +2915,14 @@ export class AgentWidgetSession {
|
|
|
2928
2915
|
role: "assistant",
|
|
2929
2916
|
createdAt: new Date().toISOString(),
|
|
2930
2917
|
content,
|
|
2931
|
-
sequence: this
|
|
2918
|
+
sequence: this.#nextSequence()
|
|
2932
2919
|
};
|
|
2933
2920
|
|
|
2934
|
-
this
|
|
2921
|
+
this.#appendMessage(fallback);
|
|
2935
2922
|
}
|
|
2936
2923
|
}
|
|
2937
|
-
this
|
|
2938
|
-
this
|
|
2924
|
+
this.#setStatus("idle");
|
|
2925
|
+
this.#setStreaming(false);
|
|
2939
2926
|
this.abortController = null;
|
|
2940
2927
|
if (!isAbortError) {
|
|
2941
2928
|
if (error instanceof Error) {
|
|
@@ -2979,7 +2966,7 @@ export class AgentWidgetSession {
|
|
|
2979
2966
|
// Durable reconnect keeps filling the same bubble: track its id so the
|
|
2980
2967
|
// cursor handler attaches to it.
|
|
2981
2968
|
if (options?.preserveAssistantId && options.assistantMessageId) {
|
|
2982
|
-
this
|
|
2969
|
+
this.#activeAssistantMessageId = options.assistantMessageId;
|
|
2983
2970
|
}
|
|
2984
2971
|
|
|
2985
2972
|
// Finalize any stale streaming messages from the previous stream
|
|
@@ -2999,7 +2986,7 @@ export class AgentWidgetSession {
|
|
|
2999
2986
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
3000
2987
|
}
|
|
3001
2988
|
|
|
3002
|
-
this
|
|
2989
|
+
this.#setStreaming(true);
|
|
3003
2990
|
|
|
3004
2991
|
try {
|
|
3005
2992
|
await this.client.processStream(
|
|
@@ -3012,14 +2999,14 @@ export class AgentWidgetSession {
|
|
|
3012
2999
|
// During a durable reconnect a thrown resume stream is just another drop:
|
|
3013
3000
|
// the reconnect loop owns the retry/backoff. Don't paint an error or tear
|
|
3014
3001
|
// down, stay in `resuming`.
|
|
3015
|
-
if (this.status === "resuming" || this
|
|
3016
|
-
this
|
|
3002
|
+
if (this.status === "resuming" || this.#reconnecting) return;
|
|
3003
|
+
this.#setStatus("error");
|
|
3017
3004
|
// Mirror the idle/error handlers: a failed resume stream must not tear
|
|
3018
3005
|
// down streaming/abortController while another WebMCP resolve is still
|
|
3019
3006
|
// confirming or executing. The in-flight resolve's `finally` owns the
|
|
3020
3007
|
// teardown once `webMcpResolveControllers` drains.
|
|
3021
3008
|
if (this.webMcpResolveControllers.size === 0) {
|
|
3022
|
-
this
|
|
3009
|
+
this.#setStreaming(false);
|
|
3023
3010
|
this.abortController = null;
|
|
3024
3011
|
}
|
|
3025
3012
|
this.callbacks.onError?.(
|
|
@@ -3034,7 +3021,7 @@ export class AgentWidgetSession {
|
|
|
3034
3021
|
* this, the bridge falls back to a blunt `window.confirm`. Safe to call
|
|
3035
3022
|
* repeatedly (e.g. after the client is re-created in `updateConfig`).
|
|
3036
3023
|
*/
|
|
3037
|
-
|
|
3024
|
+
#wireDefaultWebMcpConfirm(): void {
|
|
3038
3025
|
const webmcp = this.config.webmcp;
|
|
3039
3026
|
if (webmcp?.enabled === true && !webmcp.onConfirm) {
|
|
3040
3027
|
this.client.setWebMcpConfirmHandler((info) =>
|
|
@@ -3063,7 +3050,7 @@ export class AgentWidgetSession {
|
|
|
3063
3050
|
}
|
|
3064
3051
|
|
|
3065
3052
|
const approval: AgentWidgetApproval = {
|
|
3066
|
-
id: `webmcp-${++this
|
|
3053
|
+
id: `webmcp-${++this.#webMcpApprovalSeq}`,
|
|
3067
3054
|
status: "pending",
|
|
3068
3055
|
agentId: "",
|
|
3069
3056
|
executionId: "",
|
|
@@ -3119,133 +3106,58 @@ export class AgentWidgetSession {
|
|
|
3119
3106
|
resolve(decision === "approved");
|
|
3120
3107
|
}
|
|
3121
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
|
+
|
|
3122
3148
|
/**
|
|
3123
3149
|
* Resolve a tool approval request (approve or deny).
|
|
3124
3150
|
* Updates the approval message status, calls the API (or custom onDecision),
|
|
3125
3151
|
* and pipes the response stream through connectStream().
|
|
3126
3152
|
*/
|
|
3127
|
-
public
|
|
3153
|
+
public resolveApproval(
|
|
3128
3154
|
approval: AgentWidgetApproval,
|
|
3129
3155
|
decision: 'approved' | 'denied',
|
|
3130
3156
|
options?: AgentWidgetApprovalDecisionOptions
|
|
3131
3157
|
): Promise<void> {
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
...approval,
|
|
3136
|
-
status: decision,
|
|
3137
|
-
resolvedAt: Date.now(),
|
|
3138
|
-
};
|
|
3139
|
-
// Anchor the bubble where the agent paused for permission. An approval is a
|
|
3140
|
-
// timeline checkpoint, not a "now" event, so resolving it must preserve the
|
|
3141
|
-
// original message's createdAt/sequence: otherwise sortMessages (which
|
|
3142
|
-
// orders by createdAt first) would re-stamp it to now and float it past any
|
|
3143
|
-
// message created later (e.g. a long-pending approval resolved after more
|
|
3144
|
-
// conversation, or restored/replayed transcripts).
|
|
3145
|
-
const existing = this.messages.find((m) => m.id === approvalMessageId);
|
|
3146
|
-
const updatedMessage: AgentWidgetMessage = {
|
|
3147
|
-
id: approvalMessageId,
|
|
3148
|
-
role: "assistant",
|
|
3149
|
-
content: "",
|
|
3150
|
-
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
3151
|
-
...(existing?.sequence !== undefined ? { sequence: existing.sequence } : {}),
|
|
3152
|
-
streaming: false,
|
|
3153
|
-
variant: "approval",
|
|
3154
|
-
approval: updatedApproval,
|
|
3155
|
-
};
|
|
3156
|
-
this.upsertMessage(updatedMessage);
|
|
3157
|
-
|
|
3158
|
-
// Show the standalone typing indicator immediately while we wait for the
|
|
3159
|
-
// approval round-trip. Install an abortController so cancel() works during
|
|
3160
|
-
// the silent gap. See `resolveAskUserQuestion` for the same pattern.
|
|
3161
|
-
this.abortController?.abort();
|
|
3162
|
-
this.abortController = new AbortController();
|
|
3163
|
-
this.setStreaming(true);
|
|
3164
|
-
|
|
3165
|
-
// 2. Call onDecision callback if provided, otherwise use client.resolveApproval()
|
|
3166
|
-
const approvalConfig = this.config.approval;
|
|
3167
|
-
const onDecision = approvalConfig && typeof approvalConfig === 'object' ? approvalConfig.onDecision : undefined;
|
|
3168
|
-
|
|
3169
|
-
try {
|
|
3170
|
-
let response: Response | ReadableStream<Uint8Array> | void;
|
|
3171
|
-
|
|
3172
|
-
if (onDecision) {
|
|
3173
|
-
response = await onDecision(
|
|
3174
|
-
{
|
|
3175
|
-
approvalId: approval.id,
|
|
3176
|
-
executionId: approval.executionId,
|
|
3177
|
-
agentId: approval.agentId,
|
|
3178
|
-
toolName: approval.toolName,
|
|
3179
|
-
},
|
|
3180
|
-
decision,
|
|
3181
|
-
options
|
|
3182
|
-
);
|
|
3183
|
-
} else {
|
|
3184
|
-
response = await this.client.resolveApproval(
|
|
3185
|
-
{
|
|
3186
|
-
agentId: approval.agentId,
|
|
3187
|
-
executionId: approval.executionId,
|
|
3188
|
-
approvalId: approval.id,
|
|
3189
|
-
},
|
|
3190
|
-
decision
|
|
3191
|
-
);
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
// 3. Pipe through connectStream if we got a response with a body
|
|
3195
|
-
if (response) {
|
|
3196
|
-
let stream: ReadableStream<Uint8Array> | null = null;
|
|
3197
|
-
if (response instanceof Response) {
|
|
3198
|
-
if (!response.ok) {
|
|
3199
|
-
const errorData = await response.json().catch(() => null);
|
|
3200
|
-
throw new Error(
|
|
3201
|
-
errorData?.error ?? `Approval request failed: ${response.status}`
|
|
3202
|
-
);
|
|
3203
|
-
}
|
|
3204
|
-
stream = response.body;
|
|
3205
|
-
} else if (response instanceof ReadableStream) {
|
|
3206
|
-
stream = response;
|
|
3207
|
-
}
|
|
3208
|
-
|
|
3209
|
-
if (stream) {
|
|
3210
|
-
await this.connectStream(stream, { allowReentry: true });
|
|
3211
|
-
} else {
|
|
3212
|
-
if (decision === 'denied') {
|
|
3213
|
-
// No stream body for denied: inject a denial message
|
|
3214
|
-
this.appendMessage({
|
|
3215
|
-
id: `denial-${approval.id}`,
|
|
3216
|
-
role: "assistant",
|
|
3217
|
-
content: "Tool execution was denied by user.",
|
|
3218
|
-
createdAt: new Date().toISOString(),
|
|
3219
|
-
streaming: false,
|
|
3220
|
-
sequence: this.nextSequence(),
|
|
3221
|
-
});
|
|
3222
|
-
}
|
|
3223
|
-
// No body to pipe: drop the pre-set streaming flag so the indicator
|
|
3224
|
-
// doesn't linger forever.
|
|
3225
|
-
this.setStreaming(false);
|
|
3226
|
-
this.abortController = null;
|
|
3227
|
-
}
|
|
3228
|
-
} else {
|
|
3229
|
-
// onDecision returned void / no response: drop the pre-set flag.
|
|
3230
|
-
this.setStreaming(false);
|
|
3231
|
-
this.abortController = null;
|
|
3232
|
-
}
|
|
3233
|
-
} catch (error) {
|
|
3234
|
-
const isAbortError =
|
|
3235
|
-
error instanceof Error &&
|
|
3236
|
-
(error.name === 'AbortError' ||
|
|
3237
|
-
error.message.includes('aborted') ||
|
|
3238
|
-
error.message.includes('abort'));
|
|
3239
|
-
|
|
3240
|
-
this.setStreaming(false);
|
|
3241
|
-
this.abortController = null;
|
|
3242
|
-
|
|
3243
|
-
if (!isAbortError) {
|
|
3244
|
-
this.callbacks.onError?.(
|
|
3245
|
-
error instanceof Error ? error : new Error(String(error))
|
|
3246
|
-
);
|
|
3247
|
-
}
|
|
3248
|
-
}
|
|
3158
|
+
return this.#withActions((actions, host) =>
|
|
3159
|
+
actions.resolveApproval(host, approval, decision, options)
|
|
3160
|
+
);
|
|
3249
3161
|
}
|
|
3250
3162
|
|
|
3251
3163
|
/**
|
|
@@ -3260,6 +3172,27 @@ export class AgentWidgetSession {
|
|
|
3260
3172
|
* 3. Append a user-visible bubble with the answer text so the
|
|
3261
3173
|
* transcript reads naturally.
|
|
3262
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
|
+
|
|
3263
3196
|
/**
|
|
3264
3197
|
* Persist in-progress answers and the current page index for a multi-question
|
|
3265
3198
|
* `ask_user_question` payload, so a refresh resumes on the same page with
|
|
@@ -3309,167 +3242,6 @@ export class AgentWidgetSession {
|
|
|
3309
3242
|
});
|
|
3310
3243
|
}
|
|
3311
3244
|
|
|
3312
|
-
public async resolveAskUserQuestion(
|
|
3313
|
-
toolMessage: AgentWidgetMessage,
|
|
3314
|
-
answer: string | Record<string, string | string[]>
|
|
3315
|
-
): Promise<void> {
|
|
3316
|
-
// Idempotent: guards against rapid double-clicks on answer pills before
|
|
3317
|
-
// the re-render swaps the card to its collapsed/answered state.
|
|
3318
|
-
const live = this.messages.find((m) => m.id === toolMessage.id);
|
|
3319
|
-
if (live?.agentMetadata?.askUserQuestionAnswered === true) return;
|
|
3320
|
-
|
|
3321
|
-
const executionId = toolMessage.agentMetadata?.executionId;
|
|
3322
|
-
const toolName = toolMessage.toolCall?.name;
|
|
3323
|
-
if (!executionId || !toolName) {
|
|
3324
|
-
this.callbacks.onError?.(
|
|
3325
|
-
new Error(
|
|
3326
|
-
"resolveAskUserQuestion: message is missing executionId or toolCall.name"
|
|
3327
|
-
)
|
|
3328
|
-
);
|
|
3329
|
-
return;
|
|
3330
|
-
}
|
|
3331
|
-
|
|
3332
|
-
// Flip answered flag first so the next render skips the sheet re-mount,
|
|
3333
|
-
// avoiding the race between removeAskUserQuestionSheet's 180ms slide-out
|
|
3334
|
-
// timer and the renders that fire as the resume stream lands. Pass the
|
|
3335
|
-
// structured answer Record (when present) so it's atomically persisted
|
|
3336
|
-
// alongside the flag: the answered-state review card depends on
|
|
3337
|
-
// `agentMetadata.askUserQuestionAnswers` being populated at render time.
|
|
3338
|
-
//
|
|
3339
|
-
// For single-question payloads, callers (built-in pick handler, plugins)
|
|
3340
|
-
// resolve with a plain string. Derive a `{ [questionText]: answer }` Record
|
|
3341
|
-
// from the toolCall args so the answered-card render path is consistent
|
|
3342
|
-
// with grouped flows.
|
|
3343
|
-
let structuredAnswers: Record<string, string | string[]> | undefined =
|
|
3344
|
-
typeof answer === "string" ? undefined : answer;
|
|
3345
|
-
if (structuredAnswers === undefined && typeof answer === "string") {
|
|
3346
|
-
const args = toolMessage.toolCall?.args as
|
|
3347
|
-
| { questions?: Array<{ question?: unknown }> }
|
|
3348
|
-
| undefined;
|
|
3349
|
-
const questions = Array.isArray(args?.questions) ? args!.questions : [];
|
|
3350
|
-
if (questions.length === 1) {
|
|
3351
|
-
const qText = typeof questions[0]?.question === "string"
|
|
3352
|
-
? (questions[0].question as string)
|
|
3353
|
-
: "";
|
|
3354
|
-
if (qText) structuredAnswers = { [qText]: answer };
|
|
3355
|
-
}
|
|
3356
|
-
}
|
|
3357
|
-
this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
|
|
3358
|
-
|
|
3359
|
-
// Show the standalone typing indicator immediately: the network round-trip
|
|
3360
|
-
// to /resume is otherwise silent, which reads as broken. The render
|
|
3361
|
-
// condition in ui.ts already shows the indicator once streaming flips true
|
|
3362
|
-
// and the last message is a user bubble (the answer we inject below).
|
|
3363
|
-
// Install an abortController so cancel() works during this silent gap.
|
|
3364
|
-
this.abortController?.abort();
|
|
3365
|
-
this.abortController = new AbortController();
|
|
3366
|
-
this.setStreaming(true);
|
|
3367
|
-
|
|
3368
|
-
// Inject Q→A pair messages: one assistant bubble per question, one user
|
|
3369
|
-
// bubble per answer, so the transcript reads like a normal conversation.
|
|
3370
|
-
// The original ask_user_question tool message is suppressed by the
|
|
3371
|
-
// renderer once `askUserQuestionAnswered` is true. Skipped questions get
|
|
3372
|
-
// a muted italic `*Skipped*` user bubble (rendered through the standard
|
|
3373
|
-
// markdown pipeline).
|
|
3374
|
-
const toolCallId = toolMessage.toolCall!.id;
|
|
3375
|
-
const args = toolMessage.toolCall?.args as
|
|
3376
|
-
| { questions?: Array<{ question?: unknown; header?: unknown }> }
|
|
3377
|
-
| undefined;
|
|
3378
|
-
const questions = Array.isArray(args?.questions) ? args!.questions : [];
|
|
3379
|
-
if (questions.length === 0) {
|
|
3380
|
-
const fallback =
|
|
3381
|
-
typeof answer === "string"
|
|
3382
|
-
? answer
|
|
3383
|
-
: Object.entries(answer)
|
|
3384
|
-
.map(
|
|
3385
|
-
([q, v]) => `${q}: ${Array.isArray(v) ? v.join(", ") : v}`
|
|
3386
|
-
)
|
|
3387
|
-
.join(" | ");
|
|
3388
|
-
this.appendMessage({
|
|
3389
|
-
id: `ask-user-answer-${toolCallId}`,
|
|
3390
|
-
role: "user",
|
|
3391
|
-
content: fallback,
|
|
3392
|
-
createdAt: new Date().toISOString(),
|
|
3393
|
-
streaming: false,
|
|
3394
|
-
sequence: this.nextSequence(),
|
|
3395
|
-
});
|
|
3396
|
-
} else {
|
|
3397
|
-
const stored = structuredAnswers ?? {};
|
|
3398
|
-
questions.forEach((p, i) => {
|
|
3399
|
-
const qText = typeof p?.question === "string" ? p.question : "";
|
|
3400
|
-
if (!qText) return;
|
|
3401
|
-
const ans = stored[qText];
|
|
3402
|
-
const answerStr = Array.isArray(ans)
|
|
3403
|
-
? ans.join(", ")
|
|
3404
|
-
: typeof ans === "string"
|
|
3405
|
-
? ans
|
|
3406
|
-
: "";
|
|
3407
|
-
this.appendMessage({
|
|
3408
|
-
id: `ask-user-q-${toolCallId}-${i}`,
|
|
3409
|
-
role: "assistant",
|
|
3410
|
-
content: qText,
|
|
3411
|
-
createdAt: new Date().toISOString(),
|
|
3412
|
-
streaming: false,
|
|
3413
|
-
sequence: this.nextSequence(),
|
|
3414
|
-
});
|
|
3415
|
-
this.appendMessage({
|
|
3416
|
-
id: `ask-user-a-${toolCallId}-${i}`,
|
|
3417
|
-
role: "user",
|
|
3418
|
-
content: answerStr || "*Skipped*",
|
|
3419
|
-
createdAt: new Date().toISOString(),
|
|
3420
|
-
streaming: false,
|
|
3421
|
-
sequence: this.nextSequence(),
|
|
3422
|
-
});
|
|
3423
|
-
});
|
|
3424
|
-
}
|
|
3425
|
-
|
|
3426
|
-
try {
|
|
3427
|
-
const response = await this.client.resumeFlow(
|
|
3428
|
-
executionId,
|
|
3429
|
-
{ [toolName]: answer },
|
|
3430
|
-
{
|
|
3431
|
-
after:
|
|
3432
|
-
this.resumable?.executionId === executionId
|
|
3433
|
-
? this.resumable.lastEventId
|
|
3434
|
-
: undefined,
|
|
3435
|
-
},
|
|
3436
|
-
);
|
|
3437
|
-
|
|
3438
|
-
if (!response.ok) {
|
|
3439
|
-
const errorData = await response.json().catch(() => null);
|
|
3440
|
-
throw new Error(
|
|
3441
|
-
errorData?.error ?? `Resume failed: ${response.status}`
|
|
3442
|
-
);
|
|
3443
|
-
}
|
|
3444
|
-
|
|
3445
|
-
if (response.body) {
|
|
3446
|
-
await this.connectStream(response.body, { allowReentry: true });
|
|
3447
|
-
} else {
|
|
3448
|
-
// No body to pipe: drop the pre-set streaming flag so the indicator
|
|
3449
|
-
// doesn't linger forever.
|
|
3450
|
-
this.setStreaming(false);
|
|
3451
|
-
this.abortController = null;
|
|
3452
|
-
}
|
|
3453
|
-
} catch (error) {
|
|
3454
|
-
// Mirror sendMessage: a cancel() during the await aborts the controller
|
|
3455
|
-
// and surfaces an AbortError: don't treat that as a real failure.
|
|
3456
|
-
const isAbortError =
|
|
3457
|
-
error instanceof Error &&
|
|
3458
|
-
(error.name === 'AbortError' ||
|
|
3459
|
-
error.message.includes('aborted') ||
|
|
3460
|
-
error.message.includes('abort'));
|
|
3461
|
-
|
|
3462
|
-
this.setStreaming(false);
|
|
3463
|
-
this.abortController = null;
|
|
3464
|
-
|
|
3465
|
-
if (!isAbortError) {
|
|
3466
|
-
this.callbacks.onError?.(
|
|
3467
|
-
error instanceof Error ? error : new Error(String(error))
|
|
3468
|
-
);
|
|
3469
|
-
}
|
|
3470
|
-
}
|
|
3471
|
-
}
|
|
3472
|
-
|
|
3473
3245
|
/**
|
|
3474
3246
|
* Collect an auto-resolving LOCAL-tool `await` (`webmcp:*` page tools
|
|
3475
3247
|
* and the built-in `suggest_replies`) into a per-executionId batch
|
|
@@ -3488,13 +3260,13 @@ export class AgentWidgetSession {
|
|
|
3488
3260
|
*: route them straight to the single-call path, which surfaces the malformed
|
|
3489
3261
|
* wire shape via `onError` / an `isError` resume.
|
|
3490
3262
|
*/
|
|
3491
|
-
|
|
3263
|
+
#enqueueWebMcpAwait(toolMessage: AgentWidgetMessage): void {
|
|
3492
3264
|
const executionId = toolMessage.agentMetadata?.executionId;
|
|
3493
3265
|
const callId = toolMessage.toolCall?.id;
|
|
3494
3266
|
if (!executionId || !callId) {
|
|
3495
|
-
const queuedEpoch = this
|
|
3267
|
+
const queuedEpoch = this.#webMcpEpoch;
|
|
3496
3268
|
queueMicrotask(() => {
|
|
3497
|
-
if (queuedEpoch !== this
|
|
3269
|
+
if (queuedEpoch !== this.#webMcpEpoch) return;
|
|
3498
3270
|
void this.resolveWebMcpToolCall(toolMessage);
|
|
3499
3271
|
});
|
|
3500
3272
|
return;
|
|
@@ -3526,13 +3298,13 @@ export class AgentWidgetSession {
|
|
|
3526
3298
|
* abortController) settles before a resolve grabs them: the same ordering the
|
|
3527
3299
|
* single-call resolve always relied on.
|
|
3528
3300
|
*/
|
|
3529
|
-
|
|
3301
|
+
#scheduleWebMcpBatchFlush(): void {
|
|
3530
3302
|
if (this.webMcpAwaitBatches.size === 0) return;
|
|
3531
|
-
const queuedEpoch = this
|
|
3303
|
+
const queuedEpoch = this.#webMcpEpoch;
|
|
3532
3304
|
queueMicrotask(() => {
|
|
3533
|
-
if (queuedEpoch !== this
|
|
3305
|
+
if (queuedEpoch !== this.#webMcpEpoch) return;
|
|
3534
3306
|
for (const executionId of [...this.webMcpAwaitBatches.keys()]) {
|
|
3535
|
-
this
|
|
3307
|
+
this.#flushWebMcpAwaitBatch(executionId);
|
|
3536
3308
|
}
|
|
3537
3309
|
});
|
|
3538
3310
|
}
|
|
@@ -3545,7 +3317,7 @@ export class AgentWidgetSession {
|
|
|
3545
3317
|
* so any later sibling re-emit (e.g. from a re-pause) forms a fresh batch
|
|
3546
3318
|
* rather than mutating one already in flight.
|
|
3547
3319
|
*/
|
|
3548
|
-
|
|
3320
|
+
#flushWebMcpAwaitBatch(executionId: string): void {
|
|
3549
3321
|
const batch = this.webMcpAwaitBatches.get(executionId);
|
|
3550
3322
|
if (!batch) return;
|
|
3551
3323
|
this.webMcpAwaitBatches.delete(executionId);
|
|
@@ -3553,498 +3325,9 @@ export class AgentWidgetSession {
|
|
|
3553
3325
|
if (snapshots.length === 1) {
|
|
3554
3326
|
void this.resolveWebMcpToolCall(snapshots[0]);
|
|
3555
3327
|
} else if (snapshots.length > 1) {
|
|
3556
|
-
void this
|
|
3557
|
-
|
|
3558
|
-
}
|
|
3559
|
-
|
|
3560
|
-
private resolveWebMcpToolStartedAt(
|
|
3561
|
-
toolMessage: AgentWidgetMessage,
|
|
3562
|
-
): number {
|
|
3563
|
-
const stored = this.messages.find((m) => m.id === toolMessage.id);
|
|
3564
|
-
const candidates = [
|
|
3565
|
-
stored?.toolCall?.startedAt,
|
|
3566
|
-
toolMessage.toolCall?.startedAt,
|
|
3567
|
-
];
|
|
3568
|
-
for (const candidate of candidates) {
|
|
3569
|
-
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
3570
|
-
return candidate;
|
|
3571
|
-
}
|
|
3572
|
-
}
|
|
3573
|
-
return Date.now();
|
|
3574
|
-
}
|
|
3575
|
-
|
|
3576
|
-
/**
|
|
3577
|
-
* Persisted-resolution guard for `suggest_replies`. The in-memory dedupe
|
|
3578
|
-
* sets (`webMcpInflightKeys` / `webMcpResolvedKeys`) are cleared by
|
|
3579
|
-
* hydrateMessages/clearMessages/cancel, but `suggestRepliesResolved`
|
|
3580
|
-
* survives on the stored message, so a stale `await` re-emit after a
|
|
3581
|
-
* hydration must not re-POST `/resume` for an already-resolved call (the
|
|
3582
|
-
* historical double-resume failure mode the batching work exists to avoid).
|
|
3583
|
-
* Checks the LIVE message first; the handleEvent snapshot is a fresh wire
|
|
3584
|
-
* skeleton whose metadata never carries the flag.
|
|
3585
|
-
*/
|
|
3586
|
-
private isSuggestRepliesAlreadyResolved(
|
|
3587
|
-
toolMessage: AgentWidgetMessage,
|
|
3588
|
-
): boolean {
|
|
3589
|
-
if (toolMessage.toolCall?.name !== SUGGEST_REPLIES_TOOL_NAME) return false;
|
|
3590
|
-
const stored = this.messages.find((m) => m.id === toolMessage.id);
|
|
3591
|
-
return (
|
|
3592
|
-
(stored ?? toolMessage).agentMetadata?.suggestRepliesResolved === true
|
|
3593
|
-
);
|
|
3594
|
-
}
|
|
3595
|
-
|
|
3596
|
-
private markWebMcpToolRunning(
|
|
3597
|
-
toolMessage: AgentWidgetMessage,
|
|
3598
|
-
): number {
|
|
3599
|
-
const startedAt = this.resolveWebMcpToolStartedAt(toolMessage);
|
|
3600
|
-
this.upsertMessage({
|
|
3601
|
-
...toolMessage,
|
|
3602
|
-
streaming: true,
|
|
3603
|
-
agentMetadata: {
|
|
3604
|
-
...toolMessage.agentMetadata,
|
|
3605
|
-
awaitingLocalTool: false,
|
|
3606
|
-
},
|
|
3607
|
-
toolCall: toolMessage.toolCall
|
|
3608
|
-
? {
|
|
3609
|
-
...toolMessage.toolCall,
|
|
3610
|
-
status: "running",
|
|
3611
|
-
startedAt,
|
|
3612
|
-
completedAt: undefined,
|
|
3613
|
-
duration: undefined,
|
|
3614
|
-
durationMs: undefined,
|
|
3615
|
-
}
|
|
3616
|
-
: toolMessage.toolCall,
|
|
3617
|
-
});
|
|
3618
|
-
return startedAt;
|
|
3619
|
-
}
|
|
3620
|
-
|
|
3621
|
-
private markWebMcpToolComplete(
|
|
3622
|
-
toolMessage: AgentWidgetMessage,
|
|
3623
|
-
result: unknown,
|
|
3624
|
-
startedAt: number,
|
|
3625
|
-
completedAt = Date.now(),
|
|
3626
|
-
extraMetadata?: Partial<
|
|
3627
|
-
NonNullable<AgentWidgetMessage["agentMetadata"]>
|
|
3628
|
-
>,
|
|
3629
|
-
): void {
|
|
3630
|
-
// A teardown such as clearMessages()/hydrateMessages()/new send can remove
|
|
3631
|
-
// the bubble while an aborted WebMCP promise is settling. Never resurrect a
|
|
3632
|
-
// cleared message just to mark the old resolve complete.
|
|
3633
|
-
if (!this.messages.some((message) => message.id === toolMessage.id)) return;
|
|
3634
|
-
this.upsertMessage({
|
|
3635
|
-
...toolMessage,
|
|
3636
|
-
streaming: false,
|
|
3637
|
-
agentMetadata: {
|
|
3638
|
-
...toolMessage.agentMetadata,
|
|
3639
|
-
awaitingLocalTool: false,
|
|
3640
|
-
...extraMetadata,
|
|
3641
|
-
},
|
|
3642
|
-
toolCall: toolMessage.toolCall
|
|
3643
|
-
? {
|
|
3644
|
-
...toolMessage.toolCall,
|
|
3645
|
-
status: "complete",
|
|
3646
|
-
result,
|
|
3647
|
-
startedAt,
|
|
3648
|
-
completedAt,
|
|
3649
|
-
duration: undefined,
|
|
3650
|
-
durationMs: Math.max(0, completedAt - startedAt),
|
|
3651
|
-
}
|
|
3652
|
-
: toolMessage.toolCall,
|
|
3653
|
-
});
|
|
3654
|
-
}
|
|
3655
|
-
|
|
3656
|
-
/**
|
|
3657
|
-
* Resolve one or more parallel local-tool awaits sharing one paused
|
|
3658
|
-
* executionId with a SINGLE `/resume` (core#3878); `resolveWebMcpToolCall`
|
|
3659
|
-
* delegates size-1 resolves here after its guards. By default each call is
|
|
3660
|
-
* executed against the page registry concurrently: every gated call renders
|
|
3661
|
-
* its own native approval bubble, and a sibling's confirm Promise never
|
|
3662
|
-
* blocks another's execution. With `webmcp.execution: "sequential"` the
|
|
3663
|
-
* calls run one at a time in emission order instead (see the config docs
|
|
3664
|
-
* for when that matters). Outputs are keyed by per-call `webMcpToolCallId`
|
|
3665
|
-
* (server prefers it over tool name; name-keying remains the fallback for
|
|
3666
|
-
* legacy single/distinct-tool turns), so two calls to the SAME tool no longer
|
|
3667
|
-
* collide. The server is tolerant: any call we omit (declined-after-abort,
|
|
3668
|
-
* dedupe, exec failure) simply re-pauses and is retried on its re-emit.
|
|
3669
|
-
*
|
|
3670
|
-
* Owns the dedupe / abort / streaming machinery for both routes; resolved
|
|
3671
|
-
* keys are marked on the shared resume POST's HTTP OK.
|
|
3672
|
-
*/
|
|
3673
|
-
private async resolveWebMcpToolCallBatch(
|
|
3674
|
-
executionId: string,
|
|
3675
|
-
snapshots: AgentWidgetMessage[],
|
|
3676
|
-
): Promise<void> {
|
|
3677
|
-
type ExecutedWebMcpTool = {
|
|
3678
|
-
dedupeKey: string;
|
|
3679
|
-
resumeKey: string;
|
|
3680
|
-
output: unknown;
|
|
3681
|
-
toolMessage: AgentWidgetMessage;
|
|
3682
|
-
startedAt: number;
|
|
3683
|
-
completedAt: number;
|
|
3684
|
-
};
|
|
3685
|
-
const claimedKeys: string[] = [];
|
|
3686
|
-
// One controller per batch, shared by every execute and the resume fetch.
|
|
3687
|
-
// Teardown (`abortWebMcpResolves`) only ever aborts the whole set, so
|
|
3688
|
-
// finer granularity buys nothing; one registration per resolve op also
|
|
3689
|
-
// keeps `webMcpResolveControllers.size` === in-flight resolve count.
|
|
3690
|
-
const batchController = new AbortController();
|
|
3691
|
-
this.webMcpResolveControllers.add(batchController);
|
|
3692
|
-
this.setStreaming(true);
|
|
3693
|
-
|
|
3694
|
-
// Phase 1: execute every pending call. A null result means the call was
|
|
3695
|
-
// deduped, aborted, or threw; it's omitted from the resume and (per the
|
|
3696
|
-
// tolerant server) re-pauses for retry.
|
|
3697
|
-
const executeOne = async (
|
|
3698
|
-
toolMessage: AgentWidgetMessage,
|
|
3699
|
-
): Promise<ExecutedWebMcpTool | null> => {
|
|
3700
|
-
const wireToolName = toolMessage.toolCall?.name;
|
|
3701
|
-
const callId = toolMessage.toolCall?.id;
|
|
3702
|
-
if (!wireToolName || !callId) return null;
|
|
3703
|
-
|
|
3704
|
-
const dedupeKey = `${executionId}:${callId}`;
|
|
3705
|
-
if (
|
|
3706
|
-
this.webMcpInflightKeys.has(dedupeKey) ||
|
|
3707
|
-
this.webMcpResolvedKeys.has(dedupeKey) ||
|
|
3708
|
-
this.isSuggestRepliesAlreadyResolved(toolMessage)
|
|
3709
|
-
) {
|
|
3710
|
-
return null;
|
|
3711
|
-
}
|
|
3712
|
-
this.webMcpInflightKeys.add(dedupeKey);
|
|
3713
|
-
claimedKeys.push(dedupeKey);
|
|
3714
|
-
|
|
3715
|
-
// Clear the awaiting flag and keep the tool bubble running while the
|
|
3716
|
-
// browser-side WebMCP promise is in flight. The initial `await`
|
|
3717
|
-
// only means the server paused for a local tool; it is not completion.
|
|
3718
|
-
const startedAt = this.markWebMcpToolRunning(toolMessage);
|
|
3719
|
-
|
|
3720
|
-
// Per-call id wins for resume keying; fall back to the wire tool name
|
|
3721
|
-
// for legacy servers that don't emit `webMcpToolCallId`.
|
|
3722
|
-
const resumeKey =
|
|
3723
|
-
toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
|
|
3724
|
-
|
|
3725
|
-
// Built-in fire-and-forget tool: no bridge, no confirm gate, no
|
|
3726
|
-
// browser-side execution: the chips render from the message list and
|
|
3727
|
-
// the canned output joins the batch's single /resume.
|
|
3728
|
-
if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
|
|
3729
|
-
return {
|
|
3730
|
-
dedupeKey,
|
|
3731
|
-
resumeKey,
|
|
3732
|
-
output: suggestRepliesToolResult(),
|
|
3733
|
-
toolMessage,
|
|
3734
|
-
startedAt,
|
|
3735
|
-
completedAt: Date.now(),
|
|
3736
|
-
};
|
|
3737
|
-
}
|
|
3738
|
-
|
|
3739
|
-
const execPromise = this.client.executeWebMcpToolCall(
|
|
3740
|
-
wireToolName,
|
|
3741
|
-
toolMessage.toolCall?.args,
|
|
3742
|
-
batchController.signal,
|
|
3328
|
+
void this.#withActions((actions, host) =>
|
|
3329
|
+
actions.resolveWebMcpToolCallBatch(host, executionId, snapshots)
|
|
3743
3330
|
);
|
|
3744
|
-
|
|
3745
|
-
let output: unknown;
|
|
3746
|
-
if (!execPromise) {
|
|
3747
|
-
output = {
|
|
3748
|
-
isError: true,
|
|
3749
|
-
content: [
|
|
3750
|
-
{ type: "text", text: "WebMCP not enabled on this widget." },
|
|
3751
|
-
],
|
|
3752
|
-
};
|
|
3753
|
-
} else {
|
|
3754
|
-
try {
|
|
3755
|
-
output = await execPromise;
|
|
3756
|
-
} catch (error) {
|
|
3757
|
-
const isAbortError =
|
|
3758
|
-
error instanceof Error &&
|
|
3759
|
-
(error.name === "AbortError" ||
|
|
3760
|
-
error.message.includes("aborted") ||
|
|
3761
|
-
error.message.includes("abort"));
|
|
3762
|
-
if (!isAbortError) {
|
|
3763
|
-
this.callbacks.onError?.(
|
|
3764
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
3765
|
-
);
|
|
3766
|
-
}
|
|
3767
|
-
this.markWebMcpToolComplete(
|
|
3768
|
-
toolMessage,
|
|
3769
|
-
buildWebMcpErrorResult(
|
|
3770
|
-
isAbortError
|
|
3771
|
-
? "Aborted by cancel()"
|
|
3772
|
-
: getWebMcpErrorMessage(error),
|
|
3773
|
-
),
|
|
3774
|
-
startedAt,
|
|
3775
|
-
);
|
|
3776
|
-
// Release the dedupe claim so a re-emit can retry this call.
|
|
3777
|
-
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3778
|
-
return null;
|
|
3779
|
-
}
|
|
3780
|
-
}
|
|
3781
|
-
if (batchController.signal.aborted) {
|
|
3782
|
-
this.markWebMcpToolComplete(
|
|
3783
|
-
toolMessage,
|
|
3784
|
-
buildWebMcpErrorResult("Aborted by cancel()"),
|
|
3785
|
-
startedAt,
|
|
3786
|
-
);
|
|
3787
|
-
this.webMcpInflightKeys.delete(dedupeKey);
|
|
3788
|
-
return null;
|
|
3789
|
-
}
|
|
3790
|
-
return {
|
|
3791
|
-
dedupeKey,
|
|
3792
|
-
resumeKey,
|
|
3793
|
-
output,
|
|
3794
|
-
toolMessage,
|
|
3795
|
-
startedAt,
|
|
3796
|
-
completedAt: Date.now(),
|
|
3797
|
-
};
|
|
3798
|
-
};
|
|
3799
|
-
|
|
3800
|
-
// Parallel (default): a gated sibling's approval never blocks another's
|
|
3801
|
-
// execution. Sequential: one at a time in emission order, so tools that
|
|
3802
|
-
// share page state (a canvas, a form) don't interleave; the next call
|
|
3803
|
-
// starts only once the previous one has settled, approval included.
|
|
3804
|
-
let executed: Array<ExecutedWebMcpTool | null>;
|
|
3805
|
-
if (this.config.webmcp?.execution === "sequential") {
|
|
3806
|
-
executed = [];
|
|
3807
|
-
for (const toolMessage of snapshots) {
|
|
3808
|
-
executed.push(await executeOne(toolMessage));
|
|
3809
|
-
}
|
|
3810
|
-
} else {
|
|
3811
|
-
executed = await Promise.all(snapshots.map(executeOne));
|
|
3812
|
-
}
|
|
3813
|
-
|
|
3814
|
-
let ready: ExecutedWebMcpTool[] = [];
|
|
3815
|
-
try {
|
|
3816
|
-
ready = executed.filter((r): r is ExecutedWebMcpTool => r !== null);
|
|
3817
|
-
// Everything deduped/aborted/failed: nothing to post.
|
|
3818
|
-
if (ready.length === 0) return;
|
|
3819
|
-
|
|
3820
|
-
const toolOutputs: Record<string, unknown> = {};
|
|
3821
|
-
for (const r of ready) {
|
|
3822
|
-
// Two omitted-on-collision safety: if two calls somehow resolve to the
|
|
3823
|
-
// same key (only possible on a legacy name fallback), last write wins: // the server re-pauses the unrepresented call for retry.
|
|
3824
|
-
toolOutputs[r.resumeKey] = r.output;
|
|
3825
|
-
}
|
|
3826
|
-
|
|
3827
|
-
const response = await this.client.resumeFlow(executionId, toolOutputs, {
|
|
3828
|
-
signal: batchController.signal,
|
|
3829
|
-
after:
|
|
3830
|
-
this.resumable?.executionId === executionId
|
|
3831
|
-
? this.resumable.lastEventId
|
|
3832
|
-
: undefined,
|
|
3833
|
-
});
|
|
3834
|
-
if (!response.ok) {
|
|
3835
|
-
const errorData = await response.json().catch(() => null);
|
|
3836
|
-
throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
|
|
3837
|
-
}
|
|
3838
|
-
// Server accepted the batch: mark every included call resolved so stale
|
|
3839
|
-
// re-emits don't re-execute the page tool, then complete each bubble.
|
|
3840
|
-
// Do this only after /resume HTTP success; if /resume fails, the server
|
|
3841
|
-
// may still be paused and the retry path must not show a final result.
|
|
3842
|
-
const batch = ready[0]!.dedupeKey;
|
|
3843
|
-
for (const r of ready) {
|
|
3844
|
-
this.webMcpResolvedKeys.add(r.dedupeKey);
|
|
3845
|
-
const toolName = r.toolMessage.toolCall?.name;
|
|
3846
|
-
const toolCallId = r.toolMessage.agentMetadata?.webMcpToolCallId;
|
|
3847
|
-
this.markWebMcpToolComplete(
|
|
3848
|
-
r.toolMessage,
|
|
3849
|
-
r.output,
|
|
3850
|
-
r.startedAt,
|
|
3851
|
-
r.completedAt,
|
|
3852
|
-
{
|
|
3853
|
-
...(toolName === SUGGEST_REPLIES_TOOL_NAME
|
|
3854
|
-
? { suggestRepliesResolved: true }
|
|
3855
|
-
: {}),
|
|
3856
|
-
// Only a provider call id can be replayed: a legacy name-keyed
|
|
3857
|
-
// resume has nothing the model's transcript can reference.
|
|
3858
|
-
...(toolName && toolCallId
|
|
3859
|
-
? {
|
|
3860
|
-
clientToolAnswer: {
|
|
3861
|
-
toolCallId,
|
|
3862
|
-
toolName,
|
|
3863
|
-
args: r.toolMessage.toolCall?.args,
|
|
3864
|
-
result: r.output,
|
|
3865
|
-
batch,
|
|
3866
|
-
},
|
|
3867
|
-
}
|
|
3868
|
-
: {}),
|
|
3869
|
-
},
|
|
3870
|
-
);
|
|
3871
|
-
}
|
|
3872
|
-
if (response.body) {
|
|
3873
|
-
await this.connectStream(response.body, { allowReentry: true });
|
|
3874
|
-
}
|
|
3875
|
-
} catch (error) {
|
|
3876
|
-
const isAbortError =
|
|
3877
|
-
error instanceof Error &&
|
|
3878
|
-
(error.name === "AbortError" ||
|
|
3879
|
-
error.message.includes("aborted") ||
|
|
3880
|
-
error.message.includes("abort"));
|
|
3881
|
-
if (!isAbortError) {
|
|
3882
|
-
this.callbacks.onError?.(
|
|
3883
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
3884
|
-
);
|
|
3885
|
-
} else {
|
|
3886
|
-
for (const r of ready) {
|
|
3887
|
-
this.markWebMcpToolComplete(
|
|
3888
|
-
r.toolMessage,
|
|
3889
|
-
buildWebMcpErrorResult("Aborted by cancel()"),
|
|
3890
|
-
r.startedAt,
|
|
3891
|
-
);
|
|
3892
|
-
}
|
|
3893
|
-
}
|
|
3894
|
-
} finally {
|
|
3895
|
-
for (const key of claimedKeys) {
|
|
3896
|
-
this.webMcpInflightKeys.delete(key);
|
|
3897
|
-
}
|
|
3898
|
-
this.webMcpResolveControllers.delete(batchController);
|
|
3899
|
-
if (this.webMcpResolveControllers.size === 0 && !this.abortController) {
|
|
3900
|
-
this.setStreaming(false);
|
|
3901
|
-
}
|
|
3902
|
-
}
|
|
3903
|
-
}
|
|
3904
|
-
|
|
3905
|
-
/**
|
|
3906
|
-
* Resolve a paused auto-resolving LOCAL tool call and post the result to
|
|
3907
|
-
* `/resume`: `webmcp:*` calls execute against the host page's tool
|
|
3908
|
-
* registry; the built-in `suggest_replies` skips execution entirely and
|
|
3909
|
-
* resumes with a canned "shown" result (the chips render from the message
|
|
3910
|
-
* list, not from this resolve).
|
|
3911
|
-
*
|
|
3912
|
-
* Triggered automatically from `handleEvent` when an `await`-derived
|
|
3913
|
-
* message arrives for such a tool: the user does not click a pill; the
|
|
3914
|
-
* bridge's confirm-bubble gate (WebMCP only) is the only interactive
|
|
3915
|
-
* surface.
|
|
3916
|
-
*
|
|
3917
|
-
* Idempotent on the message's `toolCall.id`: re-emits of the same await
|
|
3918
|
-
* (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
|
|
3919
|
-
* modes, declined, timed out, throw, unknown tool, all resolve into a
|
|
3920
|
-
* `{ isError: true, content: [...] }` payload that resumes the dispatch
|
|
3921
|
-
* cleanly so the agent can recover.
|
|
3922
|
-
*
|
|
3923
|
-
* After the malformed-wire guards this is a thin gate over
|
|
3924
|
-
* `resolveWebMcpToolCallBatch` with a size-1 batch: the dedupe check runs
|
|
3925
|
-
* synchronously here so a stale re-emit never toggles streaming.
|
|
3926
|
-
*/
|
|
3927
|
-
public async resolveWebMcpToolCall(
|
|
3928
|
-
toolMessage: AgentWidgetMessage,
|
|
3929
|
-
): Promise<void> {
|
|
3930
|
-
const executionId = toolMessage.agentMetadata?.executionId;
|
|
3931
|
-
const wireToolName = toolMessage.toolCall?.name;
|
|
3932
|
-
const toolCallId = toolMessage.toolCall?.id;
|
|
3933
|
-
|
|
3934
|
-
// Malformed await wire shapes shouldn't silently strand the
|
|
3935
|
-
// server-side dispatch. Three failure modes:
|
|
3936
|
-
// - no executionId: no /resume target exists; surface to the host
|
|
3937
|
-
// via onError so an operator can react. This is a server-side
|
|
3938
|
-
// wire-shape bug: Persona can't recover it from the client.
|
|
3939
|
-
// - no wireToolName: defensive guard: handleEvent only calls us
|
|
3940
|
-
// for an auto-resolving local tool name (`webmcp:*` or
|
|
3941
|
-
// `suggest_replies`), so this path indicates a direct caller
|
|
3942
|
-
// misuse. Silent return.
|
|
3943
|
-
// - no toolCallId: dedupe key falls apart, but the server can still
|
|
3944
|
-
// advance if we post an isError for the wireToolName. Do that
|
|
3945
|
-
// and bail before the dedupe path.
|
|
3946
|
-
if (!executionId) {
|
|
3947
|
-
this.callbacks.onError?.(
|
|
3948
|
-
new Error(
|
|
3949
|
-
"WebMCP await missing executionId: dispatch left paused.",
|
|
3950
|
-
),
|
|
3951
|
-
);
|
|
3952
|
-
return;
|
|
3953
|
-
}
|
|
3954
|
-
if (!wireToolName) return;
|
|
3955
|
-
if (!toolCallId) {
|
|
3956
|
-
// No toolCall.id → no per-call dedupe key. Fall back to a synthetic
|
|
3957
|
-
// `(executionId):(wireToolName)` so identical malformed re-emits don't
|
|
3958
|
-
// re-POST /resume. Idempotent on duplicate bad payloads.
|
|
3959
|
-
const malformedKey = `${executionId}:__no_tool_id__:${wireToolName}`;
|
|
3960
|
-
if (
|
|
3961
|
-
this.webMcpInflightKeys.has(malformedKey) ||
|
|
3962
|
-
this.webMcpResolvedKeys.has(malformedKey)
|
|
3963
|
-
) {
|
|
3964
|
-
return;
|
|
3965
|
-
}
|
|
3966
|
-
this.webMcpInflightKeys.add(malformedKey);
|
|
3967
|
-
try {
|
|
3968
|
-
await this.resumeWithToolOutput(executionId, wireToolName, {
|
|
3969
|
-
isError: true,
|
|
3970
|
-
content: [
|
|
3971
|
-
{
|
|
3972
|
-
type: "text",
|
|
3973
|
-
text: "WebMCP await missing toolCall.id: cannot execute the page tool.",
|
|
3974
|
-
},
|
|
3975
|
-
],
|
|
3976
|
-
});
|
|
3977
|
-
this.webMcpResolvedKeys.add(malformedKey);
|
|
3978
|
-
} catch (error) {
|
|
3979
|
-
this.callbacks.onError?.(
|
|
3980
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
3981
|
-
);
|
|
3982
|
-
} finally {
|
|
3983
|
-
this.webMcpInflightKeys.delete(malformedKey);
|
|
3984
|
-
}
|
|
3985
|
-
return;
|
|
3986
|
-
}
|
|
3987
|
-
|
|
3988
|
-
// Dedupe key scoped by executionId: see `webMcpInflightKeys` doc comment
|
|
3989
|
-
// for the failure-recovery + cross-dispatch rationale. The persisted
|
|
3990
|
-
// `suggestRepliesResolved` guard backs the in-memory sets across
|
|
3991
|
-
// hydrations. Checked synchronously HERE so a stale re-emit stays a pure
|
|
3992
|
-
// no-op (no streaming toggle, no controller registration); the batch path
|
|
3993
|
-
// re-checks under its own inflight claim.
|
|
3994
|
-
const dedupeKey = `${executionId}:${toolCallId}`;
|
|
3995
|
-
if (
|
|
3996
|
-
this.webMcpInflightKeys.has(dedupeKey) ||
|
|
3997
|
-
this.webMcpResolvedKeys.has(dedupeKey) ||
|
|
3998
|
-
this.isSuggestRepliesAlreadyResolved(toolMessage)
|
|
3999
|
-
) {
|
|
4000
|
-
return;
|
|
4001
|
-
}
|
|
4002
|
-
return this.resolveWebMcpToolCallBatch(executionId, [toolMessage]);
|
|
4003
|
-
}
|
|
4004
|
-
|
|
4005
|
-
/**
|
|
4006
|
-
* POST `/resume` with a SINGLE tool's output and pipe the resulting SSE
|
|
4007
|
-
* stream back through `connectStream`. Shared by every single-call local-tool
|
|
4008
|
-
* resolve path (ask_user_question and single WebMCP calls). Parallel WebMCP
|
|
4009
|
-
* calls use `resolveWebMcpToolCallBatch`, which posts one resume for many.
|
|
4010
|
-
*
|
|
4011
|
-
* `resumeKey` is the `toolOutputs` map key: the per-call `webMcpToolCallId`
|
|
4012
|
-
* for WebMCP (core#3878), or the tool name for ask_user_question / legacy
|
|
4013
|
-
* servers. `onHttpOk` runs synchronously between the HTTP-status check and the
|
|
4014
|
-
* stream pipe; it lets the WebMCP resolve path commit the dedupe flag at
|
|
4015
|
-
* "server accepted the answer" rather than "stream finished cleanly".
|
|
4016
|
-
*/
|
|
4017
|
-
private async resumeWithToolOutput(
|
|
4018
|
-
executionId: string,
|
|
4019
|
-
resumeKey: string,
|
|
4020
|
-
output: unknown,
|
|
4021
|
-
options?: { onHttpOk?: () => void; signal?: AbortSignal },
|
|
4022
|
-
): Promise<void> {
|
|
4023
|
-
const response = await this.client.resumeFlow(
|
|
4024
|
-
executionId,
|
|
4025
|
-
{ [resumeKey]: output },
|
|
4026
|
-
{
|
|
4027
|
-
signal: options?.signal,
|
|
4028
|
-
after:
|
|
4029
|
-
this.resumable?.executionId === executionId
|
|
4030
|
-
? this.resumable.lastEventId
|
|
4031
|
-
: undefined,
|
|
4032
|
-
},
|
|
4033
|
-
);
|
|
4034
|
-
if (!response.ok) {
|
|
4035
|
-
const errorData = await response.json().catch(() => null);
|
|
4036
|
-
throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
|
|
4037
|
-
}
|
|
4038
|
-
options?.onHttpOk?.();
|
|
4039
|
-
if (response.body) {
|
|
4040
|
-
await this.connectStream(response.body, { allowReentry: true });
|
|
4041
|
-
} else if (this.webMcpResolveControllers.size === 0) {
|
|
4042
|
-
// No stream to pipe. Clear streaming only when no WebMCP resolve is in
|
|
4043
|
-
// flight: for a WebMCP caller the current resolve's controller is still
|
|
4044
|
-
// in the set, so its own `finally` (gated on the set draining) owns the
|
|
4045
|
-
// teardown. Non-WebMCP callers (ask_user_question) keep the old behavior.
|
|
4046
|
-
this.setStreaming(false);
|
|
4047
|
-
this.abortController = null;
|
|
4048
3331
|
}
|
|
4049
3332
|
}
|
|
4050
3333
|
|
|
@@ -4057,7 +3340,7 @@ export class AgentWidgetSession {
|
|
|
4057
3340
|
* controller, so it can't escape this teardown. Called from every stop /
|
|
4058
3341
|
* new-turn boundary (cancel, clearMessages, hydrateMessages, sendMessage).
|
|
4059
3342
|
*/
|
|
4060
|
-
|
|
3343
|
+
#abortWebMcpResolves(): void {
|
|
4061
3344
|
for (const controller of this.webMcpResolveControllers) {
|
|
4062
3345
|
controller.abort();
|
|
4063
3346
|
}
|
|
@@ -4081,7 +3364,7 @@ export class AgentWidgetSession {
|
|
|
4081
3364
|
// being torn down, and a microtask-deferred flush must not survive. The
|
|
4082
3365
|
// epoch bump below also strands an already-scheduled flush.
|
|
4083
3366
|
this.webMcpAwaitBatches.clear();
|
|
4084
|
-
this
|
|
3367
|
+
this.#webMcpEpoch++;
|
|
4085
3368
|
}
|
|
4086
3369
|
|
|
4087
3370
|
public cancel() {
|
|
@@ -4089,32 +3372,34 @@ export class AgentWidgetSession {
|
|
|
4089
3372
|
this.abortController = null;
|
|
4090
3373
|
// A user stop also cancels any pending/in-flight durable reconnect and
|
|
4091
3374
|
// clears the resume handle (the abort above already killed its fetch).
|
|
4092
|
-
this
|
|
3375
|
+
this.#teardownReconnect();
|
|
4093
3376
|
// Tear down every in-flight WebMCP resolve (each owns its own controller,
|
|
4094
3377
|
// independent of the shared one above). Clear the inflight set so retries
|
|
4095
3378
|
// are possible if the user re-issues the same await context.
|
|
4096
|
-
this
|
|
3379
|
+
this.#abortWebMcpResolves();
|
|
3380
|
+
this.#actionsEpoch++;
|
|
4097
3381
|
this.webMcpInflightKeys.clear();
|
|
4098
3382
|
// Stop any in-progress audio too: when the user hits "stop", they want
|
|
4099
3383
|
// the assistant to actually stop talking, not just stop generating tokens.
|
|
4100
3384
|
// Both helpers are safe no-ops when audio isn't configured.
|
|
4101
3385
|
this.stopSpeaking();
|
|
4102
3386
|
this.stopVoicePlayback();
|
|
4103
|
-
this
|
|
4104
|
-
this
|
|
3387
|
+
this.#setStreaming(false);
|
|
3388
|
+
this.#setStatus("idle");
|
|
4105
3389
|
}
|
|
4106
3390
|
|
|
4107
3391
|
public clearMessages() {
|
|
4108
3392
|
this.stopSpeaking();
|
|
4109
3393
|
this.abortController?.abort();
|
|
4110
3394
|
this.abortController = null;
|
|
4111
|
-
this
|
|
3395
|
+
this.#teardownReconnect();
|
|
4112
3396
|
// Tear down every in-flight WebMCP resolve too: their messages are about
|
|
4113
3397
|
// to be wiped, and a microtask-deferred resolve must not survive the clear.
|
|
4114
|
-
this
|
|
3398
|
+
this.#abortWebMcpResolves();
|
|
3399
|
+
this.#actionsEpoch++;
|
|
4115
3400
|
this.messages = [];
|
|
4116
3401
|
this.agentExecution = null;
|
|
4117
|
-
this
|
|
3402
|
+
this.#clearArtifactState();
|
|
4118
3403
|
// Clearing messages also wipes the WebMCP dedupe state: a fresh
|
|
4119
3404
|
// conversation should not refuse to call a webmcp:* tool just because
|
|
4120
3405
|
// a tool with the same key resolved in the prior conversation.
|
|
@@ -4124,8 +3409,8 @@ export class AgentWidgetSession {
|
|
|
4124
3409
|
// turn: drop the diff-only fingerprint cache (server keys by recordId, so
|
|
4125
3410
|
// a new conversation has no stored set to match).
|
|
4126
3411
|
this.client.resetClientToolsFingerprint();
|
|
4127
|
-
this
|
|
4128
|
-
this
|
|
3412
|
+
this.#setStreaming(false);
|
|
3413
|
+
this.#setStatus("idle");
|
|
4129
3414
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4130
3415
|
}
|
|
4131
3416
|
|
|
@@ -4138,16 +3423,16 @@ export class AgentWidgetSession {
|
|
|
4138
3423
|
}
|
|
4139
3424
|
|
|
4140
3425
|
public getSelectedArtifactId(): string | null {
|
|
4141
|
-
return this
|
|
3426
|
+
return this.#selectedArtifactId;
|
|
4142
3427
|
}
|
|
4143
3428
|
|
|
4144
3429
|
public selectArtifact(id: string | null): void {
|
|
4145
|
-
this
|
|
4146
|
-
this
|
|
3430
|
+
this.#selectedArtifactId = id;
|
|
3431
|
+
this.#emitArtifactsState();
|
|
4147
3432
|
}
|
|
4148
3433
|
|
|
4149
3434
|
public clearArtifacts(): void {
|
|
4150
|
-
this
|
|
3435
|
+
this.#clearArtifactState();
|
|
4151
3436
|
}
|
|
4152
3437
|
|
|
4153
3438
|
public upsertArtifact(manual: PersonaArtifactManualUpsert): PersonaArtifactRecord {
|
|
@@ -4175,10 +3460,10 @@ export class AgentWidgetSession {
|
|
|
4175
3460
|
...(manual.presentation ? { presentation: manual.presentation } : {})
|
|
4176
3461
|
};
|
|
4177
3462
|
this.artifacts.set(id, rec);
|
|
4178
|
-
this
|
|
4179
|
-
this
|
|
3463
|
+
this.#selectedArtifactId = id;
|
|
3464
|
+
this.#emitArtifactsState();
|
|
4180
3465
|
if (manual.transcript !== false) {
|
|
4181
|
-
this
|
|
3466
|
+
this.#injectArtifactRefBlock(rec);
|
|
4182
3467
|
}
|
|
4183
3468
|
return rec;
|
|
4184
3469
|
}
|
|
@@ -4199,7 +3484,7 @@ export class AgentWidgetSession {
|
|
|
4199
3484
|
* these props); without this the block would keep the first version's
|
|
4200
3485
|
* content after a refresh.
|
|
4201
3486
|
*/
|
|
4202
|
-
|
|
3487
|
+
#injectArtifactRefBlock(rec: PersonaArtifactRecord): void {
|
|
4203
3488
|
const refId = `artifact-ref-${rec.id}`;
|
|
4204
3489
|
const displayMode = resolveArtifactDisplayMode(
|
|
4205
3490
|
this.config.features?.artifacts,
|
|
@@ -4237,7 +3522,7 @@ export class AgentWidgetSession {
|
|
|
4237
3522
|
* Re-materialize existing transcript artifact blocks after a live display
|
|
4238
3523
|
* preference update. One messages callback keeps conversion atomic.
|
|
4239
3524
|
*/
|
|
4240
|
-
|
|
3525
|
+
#refreshArtifactReferenceBlocks(): void {
|
|
4241
3526
|
let changed = false;
|
|
4242
3527
|
for (const rec of this.artifacts.values()) {
|
|
4243
3528
|
const message = this.messages.find((candidate) => candidate.id === `artifact-ref-${rec.id}`);
|
|
@@ -4265,21 +3550,21 @@ export class AgentWidgetSession {
|
|
|
4265
3550
|
if (changed) this.callbacks.onMessagesChanged([...this.messages]);
|
|
4266
3551
|
}
|
|
4267
3552
|
|
|
4268
|
-
|
|
4269
|
-
if (this.artifacts.size === 0 && this
|
|
3553
|
+
#clearArtifactState(): void {
|
|
3554
|
+
if (this.artifacts.size === 0 && this.#selectedArtifactId === null) return;
|
|
4270
3555
|
this.artifacts.clear();
|
|
4271
|
-
this
|
|
4272
|
-
this
|
|
3556
|
+
this.#selectedArtifactId = null;
|
|
3557
|
+
this.#emitArtifactsState();
|
|
4273
3558
|
}
|
|
4274
3559
|
|
|
4275
|
-
|
|
3560
|
+
#emitArtifactsState(): void {
|
|
4276
3561
|
this.callbacks.onArtifactsState?.({
|
|
4277
3562
|
artifacts: [...this.artifacts.values()],
|
|
4278
|
-
selectedId: this
|
|
3563
|
+
selectedId: this.#selectedArtifactId
|
|
4279
3564
|
});
|
|
4280
3565
|
}
|
|
4281
3566
|
|
|
4282
|
-
|
|
3567
|
+
#applyArtifactStreamEvent(ev: AgentWidgetEvent): void {
|
|
4283
3568
|
switch (ev.type) {
|
|
4284
3569
|
case "artifact_start": {
|
|
4285
3570
|
if (ev.artifactType === "markdown") {
|
|
@@ -4301,7 +3586,7 @@ export class AgentWidgetSession {
|
|
|
4301
3586
|
props: {}
|
|
4302
3587
|
});
|
|
4303
3588
|
}
|
|
4304
|
-
this
|
|
3589
|
+
this.#selectedArtifactId = ev.id;
|
|
4305
3590
|
break;
|
|
4306
3591
|
}
|
|
4307
3592
|
case "artifact_delta": {
|
|
@@ -4327,7 +3612,7 @@ export class AgentWidgetSession {
|
|
|
4327
3612
|
default:
|
|
4328
3613
|
return;
|
|
4329
3614
|
}
|
|
4330
|
-
this
|
|
3615
|
+
this.#emitArtifactsState();
|
|
4331
3616
|
}
|
|
4332
3617
|
|
|
4333
3618
|
public hydrateMessages(messages: AgentWidgetMessage[]) {
|
|
@@ -4335,23 +3620,24 @@ export class AgentWidgetSession {
|
|
|
4335
3620
|
this.abortController = null;
|
|
4336
3621
|
// Hydration replaces the conversation: also cancel any pending reconnect and
|
|
4337
3622
|
// clear the resume handle (a boot resume re-arms via resumeFromHandle after).
|
|
4338
|
-
this
|
|
3623
|
+
this.#teardownReconnect();
|
|
4339
3624
|
// Hydration replaces the conversation: abort and forget every in-flight
|
|
4340
3625
|
// WebMCP resolve; their messages are about to be replaced.
|
|
4341
|
-
this
|
|
3626
|
+
this.#abortWebMcpResolves();
|
|
3627
|
+
this.#actionsEpoch++;
|
|
4342
3628
|
// Wipe the WebMCP dedupe state alongside the message restore: the
|
|
4343
3629
|
// incoming snapshot is treated as a fresh conversation context.
|
|
4344
3630
|
this.webMcpInflightKeys.clear();
|
|
4345
3631
|
this.webMcpResolvedKeys.clear();
|
|
4346
|
-
this.messages = this
|
|
3632
|
+
this.messages = this.#sortMessages(
|
|
4347
3633
|
messages.map((message) => ({
|
|
4348
3634
|
...message,
|
|
4349
3635
|
streaming: false,
|
|
4350
|
-
sequence: message.sequence ?? this
|
|
3636
|
+
sequence: message.sequence ?? this.#nextSequence()
|
|
4351
3637
|
}))
|
|
4352
3638
|
);
|
|
4353
|
-
this
|
|
4354
|
-
this
|
|
3639
|
+
this.#setStreaming(false);
|
|
3640
|
+
this.#setStatus("idle");
|
|
4355
3641
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4356
3642
|
}
|
|
4357
3643
|
|
|
@@ -4363,14 +3649,36 @@ export class AgentWidgetSession {
|
|
|
4363
3649
|
for (const rec of artifacts) {
|
|
4364
3650
|
this.artifacts.set(rec.id, { ...rec, status: "complete" });
|
|
4365
3651
|
}
|
|
4366
|
-
this
|
|
4367
|
-
this
|
|
3652
|
+
this.#selectedArtifactId = selectedId;
|
|
3653
|
+
this.#emitArtifactsState();
|
|
4368
3654
|
}
|
|
4369
3655
|
|
|
4370
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
|
+
}
|
|
4371
3668
|
if (event.type === "message") {
|
|
4372
3669
|
this.upsertMessage(event.message);
|
|
4373
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
|
+
|
|
4374
3682
|
// Track the open assistant text bubble's REAL streamed id so a durable
|
|
4375
3683
|
// reconnect keeps filling the same message. The proxy path auto-generates
|
|
4376
3684
|
// this id (it isn't the session's pre-generated one), so we must read it
|
|
@@ -4380,7 +3688,7 @@ export class AgentWidgetSession {
|
|
|
4380
3688
|
!event.message.variant &&
|
|
4381
3689
|
event.message.streaming
|
|
4382
3690
|
) {
|
|
4383
|
-
this
|
|
3691
|
+
this.#activeAssistantMessageId = event.message.id;
|
|
4384
3692
|
}
|
|
4385
3693
|
|
|
4386
3694
|
// Local-tool auto-resolve: when a await emits a tool-variant
|
|
@@ -4407,6 +3715,16 @@ export class AgentWidgetSession {
|
|
|
4407
3715
|
// the resolved `enabled` is false the widget neither renders chips nor
|
|
4408
3716
|
// resumes: the same parked-execution posture as a server-declared
|
|
4409
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
|
+
|
|
4410
3728
|
const tc = event.message.toolCall;
|
|
4411
3729
|
const autoResolvable =
|
|
4412
3730
|
!!tc?.name &&
|
|
@@ -4421,7 +3739,7 @@ export class AgentWidgetSession {
|
|
|
4421
3739
|
// it on the spot. Parallel same-tool calls (core#3878) arrive as
|
|
4422
3740
|
// separate `await`s in the same stream; batching lets us post ONE
|
|
4423
3741
|
// `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
|
|
4424
|
-
this
|
|
3742
|
+
this.#enqueueWebMcpAwait(event.message);
|
|
4425
3743
|
}
|
|
4426
3744
|
|
|
4427
3745
|
// Track agent execution state from message metadata
|
|
@@ -4444,35 +3762,35 @@ export class AgentWidgetSession {
|
|
|
4444
3762
|
// cursor and (lazily) form the resume handle once the executionId is
|
|
4445
3763
|
// known. Only durable, resumable executions emit these, so this is the
|
|
4446
3764
|
// natural gate: no `id:` lines → no handle → reconnect never arms.
|
|
4447
|
-
this
|
|
3765
|
+
this.#trackCursor(event.id);
|
|
4448
3766
|
} else if (event.type === "status") {
|
|
4449
3767
|
// A plain `idle` (no `terminal`) on the durable lane, while the run is
|
|
4450
3768
|
// genuinely mid-turn, is a dropped connection, not a finish and not an
|
|
4451
3769
|
// intentional `await` pause. Reconnect instead of finalizing.
|
|
4452
|
-
if (event.status === "idle" && !event.terminal && this
|
|
4453
|
-
this
|
|
3770
|
+
if (event.status === "idle" && !event.terminal && this.#isDurableDrop()) {
|
|
3771
|
+
this.#beginReconnect();
|
|
4454
3772
|
return;
|
|
4455
3773
|
}
|
|
4456
|
-
this
|
|
3774
|
+
this.#setStatus(event.status);
|
|
4457
3775
|
if (event.status === "connecting") {
|
|
4458
|
-
this
|
|
3776
|
+
this.#setStreaming(true);
|
|
4459
3777
|
} else if (event.status === "idle" || event.status === "error") {
|
|
4460
3778
|
// A client-tool pause is an intentional stream end but NOT a terminal:
|
|
4461
3779
|
// retain its cursor so `/client/resume` can attach after the await
|
|
4462
3780
|
// frame instead of replaying the whole turn. Real terminals and
|
|
4463
3781
|
// ordinary non-durable idles still clear the handle.
|
|
4464
|
-
if (!this
|
|
3782
|
+
if (!this.#isAwaitPending()) this.#clearResumable();
|
|
4465
3783
|
// Keep the typing indicator up while a WebMCP resolve is still in
|
|
4466
3784
|
// flight: in a chained turn the intermediate resume stream ends with an
|
|
4467
3785
|
// idle status, but the successor tool is still executing. The resolve's
|
|
4468
3786
|
// own `finally` flips streaming off once the resolve set drains.
|
|
4469
3787
|
if (this.webMcpResolveControllers.size === 0) {
|
|
4470
|
-
this
|
|
3788
|
+
this.#setStreaming(false);
|
|
4471
3789
|
this.abortController = null;
|
|
4472
3790
|
// The turn mutated the record and the chat response carries no
|
|
4473
3791
|
// revision; finalize any browser-only projection right after.
|
|
4474
|
-
this
|
|
4475
|
-
this
|
|
3792
|
+
this.#invalidateConversationRevision();
|
|
3793
|
+
this.#scheduleProjectionFinalization();
|
|
4476
3794
|
}
|
|
4477
3795
|
// Mark agent execution as complete when streaming ends: UNLESS local
|
|
4478
3796
|
// tools are still outstanding. A batched WebMCP resume is deferred to
|
|
@@ -4496,19 +3814,19 @@ export class AgentWidgetSession {
|
|
|
4496
3814
|
// batched `/resume` per executionId (deferred: see
|
|
4497
3815
|
// scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
|
|
4498
3816
|
// doesn't fight the end-of-stream streaming/abortController reset.
|
|
4499
|
-
this
|
|
3817
|
+
this.#scheduleWebMcpBatchFlush();
|
|
4500
3818
|
}
|
|
4501
3819
|
} else if (event.type === "error") {
|
|
4502
|
-
this
|
|
3820
|
+
this.#setStatus("error");
|
|
4503
3821
|
// Terminal error: drop the resume handle so no reconnect arms.
|
|
4504
|
-
this
|
|
3822
|
+
this.#clearResumable();
|
|
4505
3823
|
// Mirror the idle/status handler: don't tear down streaming while a
|
|
4506
3824
|
// WebMCP resolve is still confirming/executing on another stream: an
|
|
4507
3825
|
// error on one chained resume stream must not hide the typing indicator
|
|
4508
3826
|
// (or null a controller) for a sibling/successor resolve still in flight.
|
|
4509
3827
|
// The resolve's own `finally` flips streaming off once the set drains.
|
|
4510
3828
|
if (this.webMcpResolveControllers.size === 0) {
|
|
4511
|
-
this
|
|
3829
|
+
this.#setStreaming(false);
|
|
4512
3830
|
this.abortController = null;
|
|
4513
3831
|
}
|
|
4514
3832
|
if (this.agentExecution?.status === 'running') {
|
|
@@ -4521,7 +3839,7 @@ export class AgentWidgetSession {
|
|
|
4521
3839
|
event.type === "artifact_update" ||
|
|
4522
3840
|
event.type === "artifact_complete"
|
|
4523
3841
|
) {
|
|
4524
|
-
this
|
|
3842
|
+
this.#applyArtifactStreamEvent(event);
|
|
4525
3843
|
}
|
|
4526
3844
|
};
|
|
4527
3845
|
|
|
@@ -4533,24 +3851,24 @@ export class AgentWidgetSession {
|
|
|
4533
3851
|
* frame's metadata, handled before this trailing cursor event) and the active
|
|
4534
3852
|
* assistant message id.
|
|
4535
3853
|
*/
|
|
4536
|
-
|
|
3854
|
+
#trackCursor(id: string): void {
|
|
4537
3855
|
const executionId = this.agentExecution?.executionId;
|
|
4538
|
-
if (!executionId || !this
|
|
3856
|
+
if (!executionId || !this.#activeAssistantMessageId) return;
|
|
4539
3857
|
// Only track while the run is live. A graceful terminal sets the execution
|
|
4540
3858
|
// to 'complete'/'error' and clears the handle BEFORE its own frame's
|
|
4541
3859
|
// trailing cursor fires; without this guard that cursor would re-arm the
|
|
4542
3860
|
// handle and the next plain `idle` would look like a spurious drop.
|
|
4543
3861
|
if (this.agentExecution?.status !== "running") return;
|
|
4544
|
-
const isNew = this
|
|
4545
|
-
this
|
|
3862
|
+
const isNew = this.#resumable === null;
|
|
3863
|
+
this.#resumable = {
|
|
4546
3864
|
executionId,
|
|
4547
3865
|
lastEventId: id,
|
|
4548
|
-
assistantMessageId: this
|
|
3866
|
+
assistantMessageId: this.#activeAssistantMessageId,
|
|
4549
3867
|
status: "running",
|
|
4550
3868
|
};
|
|
4551
3869
|
// Fire immediately when the handle first appears (so the host can persist
|
|
4552
3870
|
// the executionId promptly); throttle subsequent cursor advances.
|
|
4553
|
-
this
|
|
3871
|
+
this.#notifyExecutionState(isNew);
|
|
4554
3872
|
}
|
|
4555
3873
|
|
|
4556
3874
|
/**
|
|
@@ -4558,14 +3876,14 @@ export class AgentWidgetSession {
|
|
|
4558
3876
|
* rather than a graceful finish or an intentional `await` pause? ALL must
|
|
4559
3877
|
* hold (see plan §Design overview keystone 3).
|
|
4560
3878
|
*/
|
|
4561
|
-
|
|
3879
|
+
#isDurableDrop(): boolean {
|
|
4562
3880
|
return (
|
|
4563
|
-
this
|
|
3881
|
+
this.#resumable !== null &&
|
|
4564
3882
|
typeof this.config.reconnectStream === "function" &&
|
|
4565
3883
|
this.abortController?.signal.aborted !== true &&
|
|
4566
3884
|
this.webMcpResolveControllers.size === 0 &&
|
|
4567
3885
|
this.webMcpAwaitBatches.size === 0 &&
|
|
4568
|
-
!this
|
|
3886
|
+
!this.#isAwaitPending()
|
|
4569
3887
|
);
|
|
4570
3888
|
}
|
|
4571
3889
|
|
|
@@ -4574,7 +3892,7 @@ export class AgentWidgetSession {
|
|
|
4574
3892
|
* `ask_user_question` / local-tool await, or a pending approval). Those end
|
|
4575
3893
|
* the stream with `idle` and no terminal too, but are NOT drops.
|
|
4576
3894
|
*/
|
|
4577
|
-
|
|
3895
|
+
#isAwaitPending(): boolean {
|
|
4578
3896
|
return this.messages.some((m) => {
|
|
4579
3897
|
if (
|
|
4580
3898
|
m.agentMetadata?.awaitingLocalTool === true &&
|
|
@@ -4594,22 +3912,22 @@ export class AgentWidgetSession {
|
|
|
4594
3912
|
* true, flips to `resuming`, and kicks the bounded backoff loop. Re-entrant
|
|
4595
3913
|
* calls (a second drop while reconnecting) are no-ops.
|
|
4596
3914
|
*/
|
|
4597
|
-
|
|
4598
|
-
if (this
|
|
4599
|
-
if (!this
|
|
3915
|
+
#beginReconnect(): void {
|
|
3916
|
+
if (this.#reconnecting) return;
|
|
3917
|
+
if (!this.#resumable || typeof this.config.reconnectStream !== "function") {
|
|
4600
3918
|
return;
|
|
4601
3919
|
}
|
|
4602
3920
|
// Flip the visible state synchronously — the bubble stays open and a
|
|
4603
3921
|
// `resuming` status is observable immediately (e.g. right after
|
|
4604
3922
|
// `resumeFromHandle`). The heavy backoff loop is loaded lazily below.
|
|
4605
|
-
this
|
|
4606
|
-
this.callbacks.onReconnect?.({ phase: "paused", handle: this
|
|
4607
|
-
this
|
|
4608
|
-
this
|
|
4609
|
-
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) => {
|
|
4610
3928
|
// The run may have been torn down (new turn / cancel / hydrate) while the
|
|
4611
3929
|
// module was loading; only start the loop if we're still reconnecting.
|
|
4612
|
-
if (this
|
|
3930
|
+
if (this.#reconnecting && this.#resumable) controller.begin();
|
|
4613
3931
|
});
|
|
4614
3932
|
}
|
|
4615
3933
|
|
|
@@ -4619,43 +3937,43 @@ export class AgentWidgetSession {
|
|
|
4619
3937
|
* every hot path (`sendMessage` / `cancel` / `teardownReconnect`) so a widget
|
|
4620
3938
|
* that never reconnects never pulls the chunk.
|
|
4621
3939
|
*/
|
|
4622
|
-
|
|
4623
|
-
if (this
|
|
4624
|
-
return Promise.resolve(this
|
|
3940
|
+
#loadReconnectController(): Promise<ReconnectController> {
|
|
3941
|
+
if (this.#reconnectController) {
|
|
3942
|
+
return Promise.resolve(this.#reconnectController);
|
|
4625
3943
|
}
|
|
4626
|
-
if (!this
|
|
4627
|
-
this
|
|
3944
|
+
if (!this.#reconnectControllerPromise) {
|
|
3945
|
+
this.#reconnectControllerPromise = loadSessionReconnect().then(
|
|
4628
3946
|
({ createReconnectController }) => {
|
|
4629
3947
|
const controller = createReconnectController(
|
|
4630
|
-
this
|
|
3948
|
+
this.#buildReconnectHost()
|
|
4631
3949
|
);
|
|
4632
|
-
this
|
|
3950
|
+
this.#reconnectController = controller;
|
|
4633
3951
|
return controller;
|
|
4634
3952
|
}
|
|
4635
3953
|
);
|
|
4636
3954
|
// A failed chunk fetch must not poison the memoized promise: clear it so
|
|
4637
3955
|
// the next reconnect trigger retries the load.
|
|
4638
|
-
this
|
|
4639
|
-
this
|
|
3956
|
+
this.#reconnectControllerPromise.catch(() => {
|
|
3957
|
+
this.#reconnectControllerPromise = null;
|
|
4640
3958
|
});
|
|
4641
3959
|
}
|
|
4642
|
-
return this
|
|
3960
|
+
return this.#reconnectControllerPromise;
|
|
4643
3961
|
}
|
|
4644
3962
|
|
|
4645
3963
|
/** The narrow surface the lazily-loaded reconnect loop drives. */
|
|
4646
|
-
|
|
3964
|
+
#buildReconnectHost(): ReconnectHost {
|
|
4647
3965
|
const session = this;
|
|
4648
3966
|
return {
|
|
4649
3967
|
get config() {
|
|
4650
3968
|
return session.config;
|
|
4651
3969
|
},
|
|
4652
|
-
getResumable: () => session
|
|
4653
|
-
clearResumable: () => session
|
|
3970
|
+
getResumable: () => session.#resumable,
|
|
3971
|
+
clearResumable: () => session.#clearResumable(),
|
|
4654
3972
|
getStatus: () => session.status,
|
|
4655
|
-
setStatus: (status) => session
|
|
4656
|
-
setStreaming: (streaming) => session
|
|
3973
|
+
setStatus: (status) => session.#setStatus(status),
|
|
3974
|
+
setStreaming: (streaming) => session.#setStreaming(streaming),
|
|
4657
3975
|
setReconnecting: (value) => {
|
|
4658
|
-
session
|
|
3976
|
+
session.#reconnecting = value;
|
|
4659
3977
|
},
|
|
4660
3978
|
setAbortController: (controller) => {
|
|
4661
3979
|
session.abortController = controller;
|
|
@@ -4670,8 +3988,8 @@ export class AgentWidgetSession {
|
|
|
4670
3988
|
preserveAssistantId: true,
|
|
4671
3989
|
seedContent,
|
|
4672
3990
|
}),
|
|
4673
|
-
appendMessage: (message) => session
|
|
4674
|
-
nextSequence: () => session
|
|
3991
|
+
appendMessage: (message) => session.#appendMessage(message),
|
|
3992
|
+
nextSequence: () => session.#nextSequence(),
|
|
4675
3993
|
emitReconnect: (event) => session.callbacks.onReconnect?.(event),
|
|
4676
3994
|
buildErrorContent: (message) =>
|
|
4677
3995
|
buildDispatchErrorContent(
|
|
@@ -4684,13 +4002,13 @@ export class AgentWidgetSession {
|
|
|
4684
4002
|
|
|
4685
4003
|
/** Public manual retry (e.g. a "Reconnect" button). */
|
|
4686
4004
|
public reconnectNow(): void {
|
|
4687
|
-
if (this
|
|
4005
|
+
if (this.#reconnecting) {
|
|
4688
4006
|
// Already trying, so just short-circuit the current backoff. (No-op if the
|
|
4689
4007
|
// controller is still loading; the first attempt hasn't slept yet.)
|
|
4690
|
-
this
|
|
4008
|
+
this.#reconnectController?.wake();
|
|
4691
4009
|
return;
|
|
4692
4010
|
}
|
|
4693
|
-
this
|
|
4011
|
+
this.#beginReconnect();
|
|
4694
4012
|
}
|
|
4695
4013
|
|
|
4696
4014
|
/**
|
|
@@ -4700,21 +4018,21 @@ export class AgentWidgetSession {
|
|
|
4700
4018
|
*/
|
|
4701
4019
|
public resumeFromHandle(resume: { executionId: string; after: string }): void {
|
|
4702
4020
|
if (typeof this.config.reconnectStream !== "function") return;
|
|
4703
|
-
if (this
|
|
4021
|
+
if (this.#reconnecting) return;
|
|
4704
4022
|
|
|
4705
|
-
let assistantId = this
|
|
4023
|
+
let assistantId = this.#reopenTrailingAssistant();
|
|
4706
4024
|
if (!assistantId) {
|
|
4707
4025
|
assistantId = generateAssistantMessageId();
|
|
4708
|
-
this
|
|
4026
|
+
this.#appendMessage({
|
|
4709
4027
|
id: assistantId,
|
|
4710
4028
|
role: "assistant",
|
|
4711
4029
|
content: "",
|
|
4712
4030
|
createdAt: new Date().toISOString(),
|
|
4713
4031
|
streaming: true,
|
|
4714
|
-
sequence: this
|
|
4032
|
+
sequence: this.#nextSequence(),
|
|
4715
4033
|
});
|
|
4716
4034
|
}
|
|
4717
|
-
this
|
|
4035
|
+
this.#activeAssistantMessageId = assistantId;
|
|
4718
4036
|
if (!this.agentExecution) {
|
|
4719
4037
|
this.agentExecution = {
|
|
4720
4038
|
executionId: resume.executionId,
|
|
@@ -4725,13 +4043,13 @@ export class AgentWidgetSession {
|
|
|
4725
4043
|
maxTurns: 0,
|
|
4726
4044
|
};
|
|
4727
4045
|
}
|
|
4728
|
-
this
|
|
4046
|
+
this.#resumable = {
|
|
4729
4047
|
executionId: resume.executionId,
|
|
4730
4048
|
lastEventId: resume.after,
|
|
4731
4049
|
assistantMessageId: assistantId,
|
|
4732
4050
|
status: "running",
|
|
4733
4051
|
};
|
|
4734
|
-
this
|
|
4052
|
+
this.#beginReconnect();
|
|
4735
4053
|
}
|
|
4736
4054
|
|
|
4737
4055
|
/**
|
|
@@ -4739,7 +4057,7 @@ export class AgentWidgetSession {
|
|
|
4739
4057
|
* deltas append to it. Returns its id, or null if the last turn doesn't end
|
|
4740
4058
|
* in a plain assistant message.
|
|
4741
4059
|
*/
|
|
4742
|
-
|
|
4060
|
+
#reopenTrailingAssistant(): string | null {
|
|
4743
4061
|
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
4744
4062
|
const m = this.messages[i];
|
|
4745
4063
|
if (m.role === "assistant" && !m.variant) {
|
|
@@ -4757,20 +4075,20 @@ export class AgentWidgetSession {
|
|
|
4757
4075
|
* on every new turn / cancel / hydrate, so it must NOT pull the reconnect
|
|
4758
4076
|
* chunk: it only delegates to the controller if one was already created.
|
|
4759
4077
|
*/
|
|
4760
|
-
|
|
4761
|
-
this
|
|
4762
|
-
this
|
|
4763
|
-
this
|
|
4078
|
+
#teardownReconnect(): void {
|
|
4079
|
+
this.#reconnecting = false;
|
|
4080
|
+
this.#reconnectController?.teardown();
|
|
4081
|
+
this.#clearResumable();
|
|
4764
4082
|
}
|
|
4765
4083
|
|
|
4766
4084
|
/** Drop the resume handle and notify the host (`onExecutionState(null)`). */
|
|
4767
|
-
|
|
4768
|
-
if (this
|
|
4769
|
-
clearTimeout(this
|
|
4770
|
-
this
|
|
4085
|
+
#clearResumable(): void {
|
|
4086
|
+
if (this.#executionStateTimer) {
|
|
4087
|
+
clearTimeout(this.#executionStateTimer);
|
|
4088
|
+
this.#executionStateTimer = null;
|
|
4771
4089
|
}
|
|
4772
|
-
const had = this
|
|
4773
|
-
this
|
|
4090
|
+
const had = this.#resumable !== null;
|
|
4091
|
+
this.#resumable = null;
|
|
4774
4092
|
if (had) this.config.onExecutionState?.(null);
|
|
4775
4093
|
}
|
|
4776
4094
|
|
|
@@ -4779,36 +4097,36 @@ export class AgentWidgetSession {
|
|
|
4779
4097
|
* create/clear; trailing-edge throttled on cursor advances so it isn't called
|
|
4780
4098
|
* per delta.
|
|
4781
4099
|
*/
|
|
4782
|
-
|
|
4100
|
+
#notifyExecutionState(immediate: boolean): void {
|
|
4783
4101
|
const cb = this.config.onExecutionState;
|
|
4784
4102
|
if (!cb) return;
|
|
4785
4103
|
if (immediate) {
|
|
4786
|
-
if (this
|
|
4787
|
-
clearTimeout(this
|
|
4788
|
-
this
|
|
4104
|
+
if (this.#executionStateTimer) {
|
|
4105
|
+
clearTimeout(this.#executionStateTimer);
|
|
4106
|
+
this.#executionStateTimer = null;
|
|
4789
4107
|
}
|
|
4790
|
-
cb(this
|
|
4108
|
+
cb(this.#resumable);
|
|
4791
4109
|
return;
|
|
4792
4110
|
}
|
|
4793
|
-
if (this
|
|
4794
|
-
this
|
|
4795
|
-
this
|
|
4796
|
-
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);
|
|
4797
4115
|
}, 500);
|
|
4798
4116
|
}
|
|
4799
4117
|
|
|
4800
4118
|
/** The current durable resume handle, if any (read-only). */
|
|
4801
4119
|
public getResumableHandle(): ResumableHandle | null {
|
|
4802
|
-
return this
|
|
4120
|
+
return this.#resumable;
|
|
4803
4121
|
}
|
|
4804
4122
|
|
|
4805
|
-
|
|
4123
|
+
#setStatus(status: AgentWidgetSessionStatus) {
|
|
4806
4124
|
if (this.status === status) return;
|
|
4807
4125
|
this.status = status;
|
|
4808
4126
|
this.callbacks.onStatusChanged(status);
|
|
4809
4127
|
}
|
|
4810
4128
|
|
|
4811
|
-
|
|
4129
|
+
#setStreaming(streaming: boolean) {
|
|
4812
4130
|
if (this.streaming === streaming) return;
|
|
4813
4131
|
const wasStreaming = this.streaming;
|
|
4814
4132
|
this.streaming = streaming;
|
|
@@ -4824,7 +4142,8 @@ export class AgentWidgetSession {
|
|
|
4824
4142
|
* Speak the latest assistant message using the Web Speech API
|
|
4825
4143
|
* if text-to-speech is enabled in the config.
|
|
4826
4144
|
*/
|
|
4827
|
-
|
|
4145
|
+
/** `only`: speak it only if the latest assistant message is one of these. */
|
|
4146
|
+
private speakLatestAssistantMessage(only?: string[]) {
|
|
4828
4147
|
const ttsConfig = this.config.textToSpeech;
|
|
4829
4148
|
if (!ttsConfig?.enabled) return;
|
|
4830
4149
|
|
|
@@ -4842,7 +4161,7 @@ export class AgentWidgetSession {
|
|
|
4842
4161
|
.reverse()
|
|
4843
4162
|
.find(m => m.role === 'assistant' && m.content && !m.voiceProcessing);
|
|
4844
4163
|
|
|
4845
|
-
if (!lastAssistant) return;
|
|
4164
|
+
if (!lastAssistant || (only && !only.includes(lastAssistant.id))) return;
|
|
4846
4165
|
|
|
4847
4166
|
// Skip if already spoken by Runtype provider's audio playback
|
|
4848
4167
|
if (this.ttsSpokenMessageIds.has(lastAssistant.id)) {
|
|
@@ -4914,17 +4233,42 @@ export class AgentWidgetSession {
|
|
|
4914
4233
|
}
|
|
4915
4234
|
}
|
|
4916
4235
|
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
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]);
|
|
4920
4264
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
4921
4265
|
}
|
|
4922
4266
|
|
|
4923
4267
|
private upsertMessage(message: AgentWidgetMessage) {
|
|
4924
|
-
const withSequence = this
|
|
4268
|
+
const withSequence = this.#ensureSequence(message);
|
|
4925
4269
|
const index = this.messages.findIndex((m) => m.id === withSequence.id);
|
|
4926
4270
|
if (index === -1) {
|
|
4927
|
-
this
|
|
4271
|
+
this.#appendMessage(withSequence);
|
|
4928
4272
|
return;
|
|
4929
4273
|
}
|
|
4930
4274
|
|
|
@@ -5005,6 +4349,7 @@ export class AgentWidgetSession {
|
|
|
5005
4349
|
toolType: incoming.toolType ?? prior.toolType,
|
|
5006
4350
|
reason: incoming.reason ?? prior.reason,
|
|
5007
4351
|
parameters: incoming.parameters ?? prior.parameters,
|
|
4352
|
+
toolCallId: incoming.toolCallId ?? prior.toolCallId,
|
|
5008
4353
|
};
|
|
5009
4354
|
}
|
|
5010
4355
|
// Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
|
|
@@ -5051,25 +4396,25 @@ export class AgentWidgetSession {
|
|
|
5051
4396
|
}
|
|
5052
4397
|
return merged;
|
|
5053
4398
|
});
|
|
5054
|
-
this.messages = this
|
|
4399
|
+
this.messages = this.#sortMessages(this.messages);
|
|
5055
4400
|
this.callbacks.onMessagesChanged([...this.messages]);
|
|
5056
4401
|
}
|
|
5057
4402
|
|
|
5058
|
-
|
|
4403
|
+
#ensureSequence(message: AgentWidgetMessage): AgentWidgetMessage {
|
|
5059
4404
|
if (message.sequence !== undefined) {
|
|
5060
4405
|
return { ...message };
|
|
5061
4406
|
}
|
|
5062
4407
|
return {
|
|
5063
4408
|
...message,
|
|
5064
|
-
sequence: this
|
|
4409
|
+
sequence: this.#nextSequence()
|
|
5065
4410
|
};
|
|
5066
4411
|
}
|
|
5067
4412
|
|
|
5068
|
-
|
|
5069
|
-
return this
|
|
4413
|
+
#nextSequence() {
|
|
4414
|
+
return this.#sequenceCounter++;
|
|
5070
4415
|
}
|
|
5071
4416
|
|
|
5072
|
-
|
|
4417
|
+
#sortMessages(messages: AgentWidgetMessage[]) {
|
|
5073
4418
|
return [...messages].sort((a, b) => {
|
|
5074
4419
|
// Sort by createdAt timestamp first (chronological order)
|
|
5075
4420
|
const timeA = new Date(a.createdAt).getTime();
|