@runtypelabs/persona 4.17.0 → 4.18.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-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
- package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1224 -14
- package/dist/context-mentions.d.ts +1224 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2040 -21
- package/dist/index.d.ts +2040 -21
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1224 -14
- package/dist/smart-dom-reader.d.ts +1224 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1343 -15
- package/dist/theme-editor-preview.d.ts +1343 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1227 -15
- package/dist/theme-editor.d.ts +1227 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +18 -6
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +54 -4
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- package/dist/chunk-QJ4LV2KW.js +0 -1
package/src/session.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AgentWidgetClient,
|
|
3
|
+
HistoryClientError,
|
|
4
|
+
type SSEEventCallback,
|
|
5
|
+
} from "./client";
|
|
2
6
|
import { isWebMcpToolName } from "./webmcp-bridge";
|
|
3
7
|
import {
|
|
4
8
|
SUGGEST_REPLIES_TOOL_NAME,
|
|
@@ -22,7 +26,10 @@ import {
|
|
|
22
26
|
InjectSystemMessageOptions,
|
|
23
27
|
InjectComponentDirectiveOptions,
|
|
24
28
|
PersonaArtifactRecord,
|
|
25
|
-
PersonaArtifactManualUpsert
|
|
29
|
+
PersonaArtifactManualUpsert,
|
|
30
|
+
HistoryDisplayProjection,
|
|
31
|
+
HistoryScope,
|
|
32
|
+
PendingDisplayProjections
|
|
26
33
|
} from "./types";
|
|
27
34
|
import {
|
|
28
35
|
generateUserMessageId,
|
|
@@ -40,9 +47,23 @@ import type {
|
|
|
40
47
|
ReadAloudState,
|
|
41
48
|
SpeechEngine,
|
|
42
49
|
AgentWidgetContentSegment,
|
|
43
|
-
AgentWidgetContextMentionRef
|
|
50
|
+
AgentWidgetContextMentionRef,
|
|
51
|
+
WidgetHistoryInternals
|
|
44
52
|
} from "./types";
|
|
45
53
|
import type { MentionSubmitBundle } from "./utils/context-mention-manager";
|
|
54
|
+
import type { VisitorStore, VisitorStoreChange } from "./utils/visitor-store";
|
|
55
|
+
import { HistoryProviderError } from "./internal/history-provider";
|
|
56
|
+
import type {
|
|
57
|
+
HistoryConversationPatch,
|
|
58
|
+
HistoryConversationSummary,
|
|
59
|
+
HistoryOperationContext,
|
|
60
|
+
HistoryProvider,
|
|
61
|
+
PreparedHistoryActivation,
|
|
62
|
+
} from "./internal/history-provider";
|
|
63
|
+
import {
|
|
64
|
+
divergentDisplayProjection,
|
|
65
|
+
mergeWireMessagesById,
|
|
66
|
+
} from "./utils/history-messages";
|
|
46
67
|
import {
|
|
47
68
|
createVoiceProvider,
|
|
48
69
|
isVoiceSupported,
|
|
@@ -111,6 +132,11 @@ const CONNECTION_CONFIG_KEYS = [
|
|
|
111
132
|
"onSessionExpired",
|
|
112
133
|
"getStoredSessionId",
|
|
113
134
|
"setStoredSessionId",
|
|
135
|
+
"getIdentityProof",
|
|
136
|
+
"getStoredConversationId",
|
|
137
|
+
"setStoredConversationId",
|
|
138
|
+
"clearStoredConversationId",
|
|
139
|
+
"clearStoredSessionId",
|
|
114
140
|
] as const satisfies ReadonlyArray<keyof AgentWidgetConfig>;
|
|
115
141
|
|
|
116
142
|
function connectionConfigChanged(
|
|
@@ -141,8 +167,64 @@ type SessionCallbacks = {
|
|
|
141
167
|
handle: ResumableHandle;
|
|
142
168
|
attempt?: number;
|
|
143
169
|
}) => void;
|
|
170
|
+
/** Active-record/sendability changes the history UI renders. */
|
|
171
|
+
onHistoryStateChanged?: (state: SessionHistoryState) => void;
|
|
172
|
+
/** Non-error, secret-free status notices (recovery, continuity, diagnostics). */
|
|
173
|
+
onHistoryNotice?: (notice: SessionHistoryNotice) => void;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Session-owned history state; carries no credential or identity value. */
|
|
177
|
+
export type SessionHistoryState = {
|
|
178
|
+
activeConversationId: string | null;
|
|
179
|
+
conversationRevision: string | null;
|
|
180
|
+
/** Cursor for "show earlier messages"; `null` at the start of the transcript. */
|
|
181
|
+
nextMessageCursor: string | null;
|
|
182
|
+
/** Sending is refused until the recoverable state clears. */
|
|
183
|
+
recovery: "new_conversation_required" | null;
|
|
184
|
+
sendBlocked: boolean;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** One prepended history page, plus the cursor for the next older one. */
|
|
188
|
+
export type HistoryOlderPage = {
|
|
189
|
+
messages: AgentWidgetMessage[];
|
|
190
|
+
nextMessageCursor: string | null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export type SessionHistoryNoticeCode =
|
|
194
|
+
/** A 410 was recovered into a fresh conversation carrying only the new turn. */
|
|
195
|
+
| "conversation_deleted_recovered"
|
|
196
|
+
/** The replacement conversation could not be prepared; Retry is required. */
|
|
197
|
+
| "new_conversation_required"
|
|
198
|
+
/** A boot/continuity transition invalidated the local transcript. */
|
|
199
|
+
| "history_continuity_reset"
|
|
200
|
+
/** Projection finalization failed after its one retry; the marker is kept. */
|
|
201
|
+
| "projection_finalization_failed";
|
|
202
|
+
|
|
203
|
+
export type SessionHistoryNotice = {
|
|
204
|
+
code: SessionHistoryNoticeCode;
|
|
205
|
+
message: string;
|
|
144
206
|
};
|
|
145
207
|
|
|
208
|
+
/** Session-layer history failures. Provider I/O failures use `HistoryProviderError`. */
|
|
209
|
+
export type SessionHistoryErrorCode =
|
|
210
|
+
/** A turn is streaming or resuming; switching would abandon a live answer. */
|
|
211
|
+
| "conversation_busy"
|
|
212
|
+
/** No history provider is installed for this widget. */
|
|
213
|
+
| "history_unavailable"
|
|
214
|
+
/** A later selection won; this result was discarded without side effects. */
|
|
215
|
+
| "superseded"
|
|
216
|
+
/** The active record is gone and a replacement could not be prepared. */
|
|
217
|
+
| "new_conversation_required";
|
|
218
|
+
|
|
219
|
+
export class SessionHistoryError extends Error {
|
|
220
|
+
public readonly code: SessionHistoryErrorCode;
|
|
221
|
+
constructor(code: SessionHistoryErrorCode, message: string) {
|
|
222
|
+
super(message);
|
|
223
|
+
this.name = "SessionHistoryError";
|
|
224
|
+
this.code = code;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
146
228
|
/**
|
|
147
229
|
* Build the user-facing content shown when a dispatch fails before any
|
|
148
230
|
* assistant content streamed back. This fires on real network/server errors
|
|
@@ -297,16 +379,49 @@ export class AgentWidgetSession {
|
|
|
297
379
|
private voiceActive = false;
|
|
298
380
|
private voiceStatus: VoiceStatus = 'disconnected';
|
|
299
381
|
|
|
382
|
+
// ── Visitor conversation history (D5) ──────────────────────────
|
|
383
|
+
// Identity of the ONE currently open record; not a conversation→session map.
|
|
384
|
+
private activeConversationId: string | null = null;
|
|
385
|
+
private activeConversationRevision: string | null = null;
|
|
386
|
+
private historyNextMessageCursor: string | null = null;
|
|
387
|
+
/** Monotonic: a later selection supersedes every earlier fetch/prepare. */
|
|
388
|
+
private historyOpenEpoch = 0;
|
|
389
|
+
/** Blocks `setClientSession`'s welcome branch across a reopen re-init. */
|
|
390
|
+
private suppressWelcomeInjection = false;
|
|
391
|
+
private historyRecovery: "new_conversation_required" | null = null;
|
|
392
|
+
private historySendBlocked = false;
|
|
393
|
+
/** Continuity wipe / boot reconciliation; dispatch waits on it. */
|
|
394
|
+
private historyGate: Promise<void> | null = null;
|
|
395
|
+
/** Deduped by `(id, cursor)` so repeated clicks issue one request. */
|
|
396
|
+
private olderPageRequests = new Map<string, Promise<HistoryOlderPage>>();
|
|
397
|
+
/** Serializes projection finalization against every other operation. */
|
|
398
|
+
private projectionFinalizationPromise: Promise<void> | null = null;
|
|
399
|
+
/** Message id → projection the server has acknowledged (sent or finalized). */
|
|
400
|
+
private acknowledgedProjections = new Map<string, string>();
|
|
401
|
+
private pendingProjections: PendingDisplayProjections | null = null;
|
|
402
|
+
/** Persisted revision as it stood before boot init overwrote it. */
|
|
403
|
+
private bootConversationRevision: string | null = null;
|
|
404
|
+
private bootRevisionCaptured = false;
|
|
405
|
+
/** Exactly one visitor-store subscription per instance; re-keyed with the store. */
|
|
406
|
+
private visitorStoreUnsubscribe: (() => void) | null = null;
|
|
407
|
+
private subscribedVisitorStore: VisitorStore | null = null;
|
|
408
|
+
/** An external credential change happened; the next dispatch must re-init. */
|
|
409
|
+
private credentialReinitPending = false;
|
|
410
|
+
private credentialReinitPromise: Promise<void> | null = null;
|
|
411
|
+
|
|
300
412
|
constructor(
|
|
301
413
|
private config: AgentWidgetConfig = {},
|
|
302
|
-
private callbacks: SessionCallbacks
|
|
414
|
+
private callbacks: SessionCallbacks,
|
|
415
|
+
private historyInternals: WidgetHistoryInternals = {}
|
|
303
416
|
) {
|
|
417
|
+
this.historyInternals = this.composeHistoryInternals(historyInternals);
|
|
304
418
|
this.messages = [...(config.initialMessages ?? [])].map((message) => ({
|
|
305
419
|
...message,
|
|
306
420
|
sequence: message.sequence ?? this.nextSequence()
|
|
307
421
|
}));
|
|
308
422
|
this.messages = this.sortMessages(this.messages);
|
|
309
|
-
this.client = new AgentWidgetClient(config);
|
|
423
|
+
this.client = new AgentWidgetClient(config, this.historyInternals);
|
|
424
|
+
this.syncVisitorStoreSubscription();
|
|
310
425
|
this.wireDefaultWebMcpConfirm();
|
|
311
426
|
|
|
312
427
|
// Hydrate artifacts from config (mirrors `initialMessages`). Restored
|
|
@@ -760,7 +875,18 @@ export class AgentWidgetSession {
|
|
|
760
875
|
if (!this.isClientTokenMode()) {
|
|
761
876
|
return null;
|
|
762
877
|
}
|
|
763
|
-
|
|
878
|
+
|
|
879
|
+
// Belt-and-braces with the client-side gate: no init request may observe
|
|
880
|
+
// stored ids before the controller has applied restored state.
|
|
881
|
+
if (this.config.features?.history?.enabled) {
|
|
882
|
+
await this.historyInternals.historyBootstrapReady;
|
|
883
|
+
// Snapshot before init overwrites it: boot reconciliation compares the
|
|
884
|
+
// init revision against what the LAST page load persisted.
|
|
885
|
+
this.bootConversationRevision =
|
|
886
|
+
this.historyInternals.getStoredConversationRevision?.() ?? null;
|
|
887
|
+
this.bootRevisionCaptured = true;
|
|
888
|
+
}
|
|
889
|
+
|
|
764
890
|
try {
|
|
765
891
|
const session = await this.client.initSession();
|
|
766
892
|
this.setClientSession(session);
|
|
@@ -778,18 +904,25 @@ export class AgentWidgetSession {
|
|
|
778
904
|
*/
|
|
779
905
|
public setClientSession(session: ClientSession): void {
|
|
780
906
|
this.clientSession = session;
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
907
|
+
this.injectWelcomeMessage(session);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Welcome injection is skipped while `suppressWelcomeInjection` is latched:
|
|
912
|
+
* reopening a stored conversation is not a new thread, and the hydrated
|
|
913
|
+
* transcript would otherwise race a welcome bubble.
|
|
914
|
+
*/
|
|
915
|
+
private injectWelcomeMessage(session: ClientSession): void {
|
|
916
|
+
if (this.suppressWelcomeInjection) return;
|
|
917
|
+
if (!session.config.welcomeMessage || this.messages.length !== 0) return;
|
|
918
|
+
const welcomeMessage: AgentWidgetMessage = {
|
|
919
|
+
id: `welcome-${Date.now()}`,
|
|
920
|
+
role: "assistant",
|
|
921
|
+
content: session.config.welcomeMessage,
|
|
922
|
+
createdAt: new Date().toISOString(),
|
|
923
|
+
sequence: this.nextSequence()
|
|
924
|
+
};
|
|
925
|
+
this.appendMessage(welcomeMessage);
|
|
793
926
|
}
|
|
794
927
|
|
|
795
928
|
/**
|
|
@@ -859,6 +992,1022 @@ export class AgentWidgetSession {
|
|
|
859
992
|
return this.client.submitNPSFeedback(rating, comment);
|
|
860
993
|
}
|
|
861
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Re-thread the controller-owned history dependencies. Called before
|
|
997
|
+
* `updateConfig` when the visitor-store identity tuple changed, so the live
|
|
998
|
+
* client and any rebuilt one share the same store.
|
|
999
|
+
*/
|
|
1000
|
+
public setHistoryInternals(internals: WidgetHistoryInternals): void {
|
|
1001
|
+
this.historyInternals = this.composeHistoryInternals(internals);
|
|
1002
|
+
this.client.setHistoryInternals(this.historyInternals);
|
|
1003
|
+
this.syncVisitorStoreSubscription();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Widget teardown: release the visitor-store subscription. */
|
|
1007
|
+
public destroy(): void {
|
|
1008
|
+
this.visitorStoreUnsubscribe?.();
|
|
1009
|
+
this.visitorStoreUnsubscribe = null;
|
|
1010
|
+
this.subscribedVisitorStore = null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// ==========================================================================
|
|
1014
|
+
// Visitor conversation history (docs/visitor-history-implementation-plan D5)
|
|
1015
|
+
// ==========================================================================
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The session always owns the continuity transition, whether or not the shell
|
|
1019
|
+
* supplied its own observer: a credential/record break must wipe local state
|
|
1020
|
+
* before the replacement session becomes sendable.
|
|
1021
|
+
*/
|
|
1022
|
+
private composeHistoryInternals(
|
|
1023
|
+
internals: WidgetHistoryInternals
|
|
1024
|
+
): WidgetHistoryInternals {
|
|
1025
|
+
const hostContinuity = internals.onHistoryContinuityChanged;
|
|
1026
|
+
return {
|
|
1027
|
+
...internals,
|
|
1028
|
+
onHistoryContinuityChanged: (info) => {
|
|
1029
|
+
this.handleHistoryContinuityChanged(info);
|
|
1030
|
+
hostContinuity?.(info);
|
|
1031
|
+
},
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private get historyProvider(): HistoryProvider | null {
|
|
1036
|
+
return this.historyInternals.historyProvider ?? null;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private requireHistoryProvider(): HistoryProvider {
|
|
1040
|
+
const provider = this.historyProvider;
|
|
1041
|
+
if (!provider) {
|
|
1042
|
+
throw new SessionHistoryError(
|
|
1043
|
+
"history_unavailable",
|
|
1044
|
+
"No conversation history provider is installed"
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
return provider;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* One resolved scope per logical action. An explicitly requested scope the
|
|
1052
|
+
* provider cannot serve fails locally rather than silently downgrading.
|
|
1053
|
+
*/
|
|
1054
|
+
private historyContext(scope?: HistoryScope): HistoryOperationContext {
|
|
1055
|
+
const provider = this.requireHistoryProvider();
|
|
1056
|
+
const requested = scope ?? this.config.features?.history?.scope;
|
|
1057
|
+
const derived: HistoryScope = this.config.getIdentityProof
|
|
1058
|
+
? "verified-user"
|
|
1059
|
+
: "browser";
|
|
1060
|
+
// Only a requested scope fails closed; the derived default narrows to what
|
|
1061
|
+
// the provider advertises.
|
|
1062
|
+
const resolved =
|
|
1063
|
+
requested ??
|
|
1064
|
+
(provider.capabilities.scopes.includes(derived)
|
|
1065
|
+
? derived
|
|
1066
|
+
: (provider.capabilities.scopes[0] ?? derived));
|
|
1067
|
+
if (!provider.capabilities.scopes.includes(resolved)) {
|
|
1068
|
+
throw new HistoryProviderError(
|
|
1069
|
+
"unsupported_scope",
|
|
1070
|
+
"This history scope is not supported."
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
return { scope: resolved };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
public getHistoryState(): SessionHistoryState {
|
|
1077
|
+
return {
|
|
1078
|
+
activeConversationId: this.activeConversationId,
|
|
1079
|
+
conversationRevision: this.activeConversationRevision,
|
|
1080
|
+
nextMessageCursor: this.historyNextMessageCursor,
|
|
1081
|
+
recovery: this.historyRecovery,
|
|
1082
|
+
sendBlocked: this.historySendBlocked,
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
public getActiveConversationId(): string | null {
|
|
1087
|
+
return this.activeConversationId;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private emitHistoryState(): void {
|
|
1091
|
+
this.callbacks.onHistoryStateChanged?.(this.getHistoryState());
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
private notifyHistory(
|
|
1095
|
+
code: SessionHistoryNoticeCode,
|
|
1096
|
+
message: string
|
|
1097
|
+
): void {
|
|
1098
|
+
this.callbacks.onHistoryNotice?.({ code, message });
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Pending destructive/reconciliation work every other operation waits on. */
|
|
1102
|
+
public async awaitHistorySettled(): Promise<void> {
|
|
1103
|
+
const gate = this.historyGate;
|
|
1104
|
+
if (gate) await gate.catch(() => {});
|
|
1105
|
+
const finalization = this.projectionFinalizationPromise;
|
|
1106
|
+
if (finalization) await finalization.catch(() => {});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private assertHistoryIdle(): void {
|
|
1110
|
+
if (this.streaming || this.status === "paused" || this.status === "resuming") {
|
|
1111
|
+
throw new SessionHistoryError(
|
|
1112
|
+
"conversation_busy",
|
|
1113
|
+
"Finish or stop the current turn before switching conversations"
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
public listConversations(opts?: {
|
|
1119
|
+
cursor?: string;
|
|
1120
|
+
limit?: number;
|
|
1121
|
+
targetId?: string;
|
|
1122
|
+
scope?: HistoryScope;
|
|
1123
|
+
}) {
|
|
1124
|
+
const provider = this.requireHistoryProvider();
|
|
1125
|
+
return provider.list({
|
|
1126
|
+
...(opts?.cursor ? { cursor: opts.cursor } : {}),
|
|
1127
|
+
...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
|
|
1128
|
+
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1129
|
+
context: this.historyContext(opts?.scope),
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Transactional reopen: read the newest page, prepare an authorized
|
|
1135
|
+
* activation, then commit only if this selection still wins. Nothing mutates
|
|
1136
|
+
* the active conversation before `commit()`.
|
|
1137
|
+
*/
|
|
1138
|
+
public async openConversation(
|
|
1139
|
+
id: string,
|
|
1140
|
+
opts?: { scope?: HistoryScope }
|
|
1141
|
+
): Promise<{
|
|
1142
|
+
summary: HistoryConversationSummary;
|
|
1143
|
+
nextMessageCursor: string | null;
|
|
1144
|
+
}> {
|
|
1145
|
+
const provider = this.requireHistoryProvider();
|
|
1146
|
+
this.assertHistoryIdle();
|
|
1147
|
+
const context = this.historyContext(opts?.scope);
|
|
1148
|
+
await this.awaitHistorySettled();
|
|
1149
|
+
const epoch = ++this.historyOpenEpoch;
|
|
1150
|
+
|
|
1151
|
+
const page = await provider.getPage(id, { context });
|
|
1152
|
+
if (epoch !== this.historyOpenEpoch) {
|
|
1153
|
+
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const prepared = await provider.prepareOpen(id, { context });
|
|
1157
|
+
if (epoch !== this.historyOpenEpoch) {
|
|
1158
|
+
prepared.discard();
|
|
1159
|
+
throw new SessionHistoryError("superseded", "A later selection won");
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// The reopened transcript is not a new conversation: never inject welcome.
|
|
1163
|
+
this.suppressWelcomeInjection = true;
|
|
1164
|
+
try {
|
|
1165
|
+
await this.commitActivation(prepared);
|
|
1166
|
+
} finally {
|
|
1167
|
+
this.suppressWelcomeInjection = false;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
this.activeConversationId = prepared.conversationId || id;
|
|
1171
|
+
this.activeConversationRevision =
|
|
1172
|
+
prepared.conversationRevision || page.conversationRevision || null;
|
|
1173
|
+
this.historyNextMessageCursor = page.nextCursor;
|
|
1174
|
+
this.historyRecovery = null;
|
|
1175
|
+
this.historySendBlocked = false;
|
|
1176
|
+
this.resetConversationScopedState();
|
|
1177
|
+
// Server projections are by definition acknowledged.
|
|
1178
|
+
for (const message of page.messages) {
|
|
1179
|
+
this.acknowledgedProjections.set(message.id, message.content);
|
|
1180
|
+
}
|
|
1181
|
+
// Metadata first, transcript last: the transcript emit is what persists.
|
|
1182
|
+
this.persistConversationMetadata();
|
|
1183
|
+
this.hydrateMessages(page.messages);
|
|
1184
|
+
this.emitHistoryState();
|
|
1185
|
+
return { summary: page.summary, nextMessageCursor: page.nextCursor };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Prepend one older page. Deduped by `(id, cursor)`; merges by id and repaints
|
|
1190
|
+
* once (the `injectMessageBatch` single-render pattern).
|
|
1191
|
+
*/
|
|
1192
|
+
public loadOlderMessages(
|
|
1193
|
+
id: string,
|
|
1194
|
+
cursor?: string | null,
|
|
1195
|
+
opts?: { scope?: HistoryScope }
|
|
1196
|
+
): Promise<HistoryOlderPage> {
|
|
1197
|
+
const provider = this.requireHistoryProvider();
|
|
1198
|
+
const context = this.historyContext(opts?.scope);
|
|
1199
|
+
const resolvedCursor = cursor ?? this.historyNextMessageCursor;
|
|
1200
|
+
if (!resolvedCursor) {
|
|
1201
|
+
return Promise.resolve({ messages: [], nextMessageCursor: null });
|
|
1202
|
+
}
|
|
1203
|
+
const key = `${id} ${resolvedCursor}`;
|
|
1204
|
+
const inflight = this.olderPageRequests.get(key);
|
|
1205
|
+
if (inflight) return inflight;
|
|
1206
|
+
|
|
1207
|
+
const request = this.fetchOlderPage(provider, id, resolvedCursor, context)
|
|
1208
|
+
.finally(() => {
|
|
1209
|
+
this.olderPageRequests.delete(key);
|
|
1210
|
+
});
|
|
1211
|
+
this.olderPageRequests.set(key, request);
|
|
1212
|
+
return request;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private async fetchOlderPage(
|
|
1216
|
+
provider: HistoryProvider,
|
|
1217
|
+
id: string,
|
|
1218
|
+
cursor: string,
|
|
1219
|
+
context: HistoryOperationContext
|
|
1220
|
+
): Promise<HistoryOlderPage> {
|
|
1221
|
+
const oldest = this.messages[0]?.createdAt;
|
|
1222
|
+
const page = await provider.getPage(id, {
|
|
1223
|
+
cursor,
|
|
1224
|
+
...(oldest ? { beforeCreatedAt: oldest } : {}),
|
|
1225
|
+
context,
|
|
1226
|
+
});
|
|
1227
|
+
// A conversation switch during the fetch retires this page.
|
|
1228
|
+
if (this.activeConversationId && this.activeConversationId !== id) {
|
|
1229
|
+
return { messages: [], nextMessageCursor: page.nextCursor };
|
|
1230
|
+
}
|
|
1231
|
+
const merged = mergeWireMessagesById(this.messages, page.messages);
|
|
1232
|
+
this.messages = this.sortMessages(
|
|
1233
|
+
merged.map((message) => this.ensureSequence(message))
|
|
1234
|
+
);
|
|
1235
|
+
this.historyNextMessageCursor = page.nextCursor;
|
|
1236
|
+
for (const message of page.messages) {
|
|
1237
|
+
this.acknowledgedProjections.set(message.id, message.content);
|
|
1238
|
+
}
|
|
1239
|
+
this.historyInternals.setStoredMessageCursor?.(page.nextCursor);
|
|
1240
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1241
|
+
this.emitHistoryState();
|
|
1242
|
+
return { messages: page.messages, nextMessageCursor: page.nextCursor };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* The canonical new-thread operation. A failed prepare leaves the current
|
|
1247
|
+
* conversation completely intact.
|
|
1248
|
+
*/
|
|
1249
|
+
public async startNewConversation(opts?: {
|
|
1250
|
+
scope?: HistoryScope;
|
|
1251
|
+
}): Promise<void> {
|
|
1252
|
+
const provider = this.requireHistoryProvider();
|
|
1253
|
+
const context = this.historyContext(opts?.scope);
|
|
1254
|
+
await this.awaitHistorySettled();
|
|
1255
|
+
const epoch = ++this.historyOpenEpoch;
|
|
1256
|
+
const prepared = await provider.prepareStartNew({ context });
|
|
1257
|
+
if (epoch !== this.historyOpenEpoch) {
|
|
1258
|
+
prepared.discard();
|
|
1259
|
+
throw new SessionHistoryError("superseded", "A later action won");
|
|
1260
|
+
}
|
|
1261
|
+
await this.installFreshConversation(prepared);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Clear local + persisted conversation state, then commit the replacement.
|
|
1266
|
+
* Welcome injection is deliberately allowed once the transcript is empty.
|
|
1267
|
+
*/
|
|
1268
|
+
private async installFreshConversation(
|
|
1269
|
+
prepared: PreparedHistoryActivation
|
|
1270
|
+
): Promise<void> {
|
|
1271
|
+
this.discardConversationState();
|
|
1272
|
+
await this.commitActivation(prepared);
|
|
1273
|
+
this.activeConversationId = prepared.conversationId || null;
|
|
1274
|
+
this.activeConversationRevision = prepared.conversationRevision || null;
|
|
1275
|
+
this.historyNextMessageCursor = null;
|
|
1276
|
+
this.historyRecovery = null;
|
|
1277
|
+
this.historySendBlocked = false;
|
|
1278
|
+
this.persistConversationMetadata();
|
|
1279
|
+
this.emitHistoryState();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Deleting the active record (or everything) is already irreversible: the
|
|
1284
|
+
* transcript is destroyed first, then a replacement session is prepared.
|
|
1285
|
+
*/
|
|
1286
|
+
public async deleteConversation(
|
|
1287
|
+
id: string,
|
|
1288
|
+
opts?: { scope?: HistoryScope }
|
|
1289
|
+
): Promise<void> {
|
|
1290
|
+
const provider = this.requireHistoryProvider();
|
|
1291
|
+
const context = this.historyContext(opts?.scope);
|
|
1292
|
+
await this.awaitHistorySettled();
|
|
1293
|
+
await provider.delete(id, { context });
|
|
1294
|
+
if (id !== this.activeConversationId) return;
|
|
1295
|
+
await this.recoverFromDestroyedConversation(context);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Visitor-scoped rename/star. Requires a provider with the update capability. */
|
|
1299
|
+
public async updateConversation(
|
|
1300
|
+
id: string,
|
|
1301
|
+
patch: HistoryConversationPatch,
|
|
1302
|
+
opts?: { scope?: HistoryScope }
|
|
1303
|
+
): Promise<HistoryConversationSummary> {
|
|
1304
|
+
const provider = this.requireHistoryProvider();
|
|
1305
|
+
if (!provider.update) {
|
|
1306
|
+
throw new SessionHistoryError(
|
|
1307
|
+
"history_unavailable",
|
|
1308
|
+
"This history provider cannot update conversations"
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
const context = this.historyContext(opts?.scope);
|
|
1312
|
+
await this.awaitHistorySettled();
|
|
1313
|
+
return provider.update(id, patch, { context });
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** True when the provider supports visitor-scoped rename/star. */
|
|
1317
|
+
public canUpdateConversations(): boolean {
|
|
1318
|
+
return typeof this.historyProvider?.update === "function";
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
public async clearConversationHistory(opts?: {
|
|
1322
|
+
targetId?: string;
|
|
1323
|
+
scope?: HistoryScope;
|
|
1324
|
+
}): Promise<{ deleted: number }> {
|
|
1325
|
+
const provider = this.requireHistoryProvider();
|
|
1326
|
+
const context = this.historyContext(opts?.scope);
|
|
1327
|
+
await this.awaitHistorySettled();
|
|
1328
|
+
const result = await provider.deleteAll({
|
|
1329
|
+
...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
|
|
1330
|
+
context,
|
|
1331
|
+
});
|
|
1332
|
+
await this.recoverFromDestroyedConversation(context);
|
|
1333
|
+
return result;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Revoke this browser's history credential. Resolves (never rejects) on
|
|
1338
|
+
* remote failure; the local wipe is unconditional either way.
|
|
1339
|
+
*/
|
|
1340
|
+
public async resetHistoryDevice(): Promise<{
|
|
1341
|
+
remoteRevocationConfirmed: boolean;
|
|
1342
|
+
}> {
|
|
1343
|
+
const provider = this.requireHistoryProvider();
|
|
1344
|
+
if (!provider.resetDevice) {
|
|
1345
|
+
throw new SessionHistoryError(
|
|
1346
|
+
"history_unavailable",
|
|
1347
|
+
"This history provider cannot reset the device"
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
this.historyOpenEpoch += 1;
|
|
1351
|
+
this.projectionFinalizationPromise = null;
|
|
1352
|
+
try {
|
|
1353
|
+
return await provider.resetDevice();
|
|
1354
|
+
} finally {
|
|
1355
|
+
this.discardConversationState();
|
|
1356
|
+
this.activeConversationId = null;
|
|
1357
|
+
this.activeConversationRevision = null;
|
|
1358
|
+
this.historyNextMessageCursor = null;
|
|
1359
|
+
this.historyRecovery = null;
|
|
1360
|
+
this.historySendBlocked = false;
|
|
1361
|
+
this.emitHistoryState();
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private async recoverFromDestroyedConversation(
|
|
1366
|
+
context: HistoryOperationContext
|
|
1367
|
+
): Promise<void> {
|
|
1368
|
+
// Supersede anything still in flight for the record being destroyed.
|
|
1369
|
+
this.historyOpenEpoch += 1;
|
|
1370
|
+
this.projectionFinalizationPromise = null;
|
|
1371
|
+
this.discardConversationState();
|
|
1372
|
+
this.activeConversationId = null;
|
|
1373
|
+
this.activeConversationRevision = null;
|
|
1374
|
+
this.historyNextMessageCursor = null;
|
|
1375
|
+
this.historySendBlocked = true;
|
|
1376
|
+
this.historyRecovery = null;
|
|
1377
|
+
this.emitHistoryState();
|
|
1378
|
+
await this.prepareReplacementConversation(context);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Never reinstalls the deleted session. A failed preparation leaves an
|
|
1383
|
+
* explicit recoverable state instead of the old transcript.
|
|
1384
|
+
*/
|
|
1385
|
+
private async prepareReplacementConversation(
|
|
1386
|
+
context: HistoryOperationContext
|
|
1387
|
+
): Promise<void> {
|
|
1388
|
+
const provider = this.historyProvider;
|
|
1389
|
+
if (!provider) {
|
|
1390
|
+
this.historySendBlocked = false;
|
|
1391
|
+
this.emitHistoryState();
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
try {
|
|
1395
|
+
const prepared = await provider.prepareStartNew({ context });
|
|
1396
|
+
await this.installFreshConversation(prepared);
|
|
1397
|
+
} catch {
|
|
1398
|
+
this.historySendBlocked = true;
|
|
1399
|
+
this.historyRecovery = "new_conversation_required";
|
|
1400
|
+
this.emitHistoryState();
|
|
1401
|
+
this.notifyHistory(
|
|
1402
|
+
"new_conversation_required",
|
|
1403
|
+
"That conversation was deleted. Start a new conversation to continue."
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** The next explicit action retries a failed replacement preparation. */
|
|
1409
|
+
private async retryRequiredConversation(): Promise<void> {
|
|
1410
|
+
if (this.historyRecovery !== "new_conversation_required") return;
|
|
1411
|
+
const provider = this.historyProvider;
|
|
1412
|
+
if (!provider) return;
|
|
1413
|
+
this.historyRecovery = null;
|
|
1414
|
+
await this.prepareReplacementConversation(this.historyContext());
|
|
1415
|
+
if (this.historyRecovery === "new_conversation_required") {
|
|
1416
|
+
throw new SessionHistoryError(
|
|
1417
|
+
"new_conversation_required",
|
|
1418
|
+
"A new conversation is required before sending"
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Install a prepared activation. The provider's commit also invokes the
|
|
1425
|
+
* session binding (`bindActivatedSession`); the fallback covers a shell that
|
|
1426
|
+
* has not wired that callback yet.
|
|
1427
|
+
*/
|
|
1428
|
+
private async commitActivation(
|
|
1429
|
+
prepared: PreparedHistoryActivation
|
|
1430
|
+
): Promise<void> {
|
|
1431
|
+
await prepared.commit();
|
|
1432
|
+
const installed = this.client.getClientSession();
|
|
1433
|
+
if (installed && installed.sessionId !== this.clientSession?.sessionId) {
|
|
1434
|
+
this.bindActivatedSession(installed);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Session-owned binding for a winning prepared activation. Installs the
|
|
1440
|
+
* client session WITHOUT the ordinary welcome branch while the reopen latch
|
|
1441
|
+
* is set, and persists the replacement ids through the normal storage path.
|
|
1442
|
+
*/
|
|
1443
|
+
public bindActivatedSession(session: ClientSession): void {
|
|
1444
|
+
this.clientSession = session;
|
|
1445
|
+
this.config.setStoredSessionId?.(session.sessionId);
|
|
1446
|
+
if (session.conversationId) {
|
|
1447
|
+
this.activeConversationId = session.conversationId;
|
|
1448
|
+
this.activeConversationRevision = session.conversationRevision ?? null;
|
|
1449
|
+
this.config.setStoredConversationId?.(session.conversationId);
|
|
1450
|
+
this.historyInternals.setStoredConversationRevision?.(
|
|
1451
|
+
session.conversationRevision ?? null
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
if (!this.suppressWelcomeInjection) this.injectWelcomeMessage(session);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
private persistConversationMetadata(): void {
|
|
1458
|
+
if (this.activeConversationId) {
|
|
1459
|
+
this.config.setStoredConversationId?.(this.activeConversationId);
|
|
1460
|
+
} else {
|
|
1461
|
+
this.config.clearStoredConversationId?.();
|
|
1462
|
+
}
|
|
1463
|
+
this.historyInternals.setStoredConversationRevision?.(
|
|
1464
|
+
this.activeConversationRevision
|
|
1465
|
+
);
|
|
1466
|
+
this.historyInternals.setStoredMessageCursor?.(
|
|
1467
|
+
this.historyNextMessageCursor
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/** Conversation-scoped caches that must not leak across records. */
|
|
1472
|
+
private resetConversationScopedState(): void {
|
|
1473
|
+
this.agentExecution = null;
|
|
1474
|
+
this.clearArtifactState();
|
|
1475
|
+
this.webMcpInflightKeys.clear();
|
|
1476
|
+
this.webMcpResolvedKeys.clear();
|
|
1477
|
+
this.client.resetClientToolsFingerprint();
|
|
1478
|
+
this.acknowledgedProjections.clear();
|
|
1479
|
+
this.setPendingProjections(null);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Irreversible local + persisted clear. Used by new-conversation, active
|
|
1484
|
+
* deletion, 410 recovery, reset, and the continuity wipe.
|
|
1485
|
+
*/
|
|
1486
|
+
private discardConversationState(): void {
|
|
1487
|
+
this.stopSpeaking();
|
|
1488
|
+
this.abortController?.abort();
|
|
1489
|
+
this.abortController = null;
|
|
1490
|
+
this.teardownReconnect();
|
|
1491
|
+
this.abortWebMcpResolves();
|
|
1492
|
+
this.messages = [];
|
|
1493
|
+
this.resetConversationScopedState();
|
|
1494
|
+
this.olderPageRequests.clear();
|
|
1495
|
+
this.config.clearStoredSessionId?.();
|
|
1496
|
+
this.config.clearStoredConversationId?.();
|
|
1497
|
+
this.historyInternals.setStoredConversationRevision?.(null);
|
|
1498
|
+
this.historyInternals.setStoredMessageCursor?.(null);
|
|
1499
|
+
this.setStreaming(false);
|
|
1500
|
+
this.setStatus("idle");
|
|
1501
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* D6 continuity wipe (session side): a replacement visitor, identity rebind,
|
|
1506
|
+
* cross-tab convergence, or credential expiry selected a different record.
|
|
1507
|
+
* Invalidate synchronously; dispatch stays blocked until cleanup completes.
|
|
1508
|
+
*/
|
|
1509
|
+
private handleHistoryContinuityChanged(info: {
|
|
1510
|
+
previousConversationId: string | null;
|
|
1511
|
+
conversationId: string;
|
|
1512
|
+
}): void {
|
|
1513
|
+
this.historyOpenEpoch += 1;
|
|
1514
|
+
this.projectionFinalizationPromise = null;
|
|
1515
|
+
this.historySendBlocked = true;
|
|
1516
|
+
this.discardConversationState();
|
|
1517
|
+
this.activeConversationId = info.conversationId;
|
|
1518
|
+
this.activeConversationRevision = null;
|
|
1519
|
+
this.historyNextMessageCursor = null;
|
|
1520
|
+
this.historyRecovery = null;
|
|
1521
|
+
this.emitHistoryState();
|
|
1522
|
+
this.notifyHistory(
|
|
1523
|
+
"history_continuity_reset",
|
|
1524
|
+
"This browser was reconnected to a different conversation, so the previous messages were cleared."
|
|
1525
|
+
);
|
|
1526
|
+
this.historyGate = Promise.resolve().then(() => {
|
|
1527
|
+
this.historySendBlocked = false;
|
|
1528
|
+
this.historyGate = null;
|
|
1529
|
+
this.emitHistoryState();
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* One subscription per widget instance, re-keyed when the controller swaps
|
|
1535
|
+
* the store. Owned here so the transition sits beside the continuity wipe it
|
|
1536
|
+
* reuses.
|
|
1537
|
+
*/
|
|
1538
|
+
private syncVisitorStoreSubscription(): void {
|
|
1539
|
+
const store = this.historyInternals.visitorStore ?? null;
|
|
1540
|
+
if (store === this.subscribedVisitorStore) return;
|
|
1541
|
+
this.visitorStoreUnsubscribe?.();
|
|
1542
|
+
this.visitorStoreUnsubscribe = null;
|
|
1543
|
+
this.subscribedVisitorStore = store;
|
|
1544
|
+
if (!store) return;
|
|
1545
|
+
this.visitorStoreUnsubscribe = store.subscribe((change) =>
|
|
1546
|
+
this.handleExternalCredentialChange(change)
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* D3 privacy boundary: a sibling tab cleared or replaced the shared visitor
|
|
1552
|
+
* credential. Privacy wins over turn continuity, so a live turn is cancelled.
|
|
1553
|
+
* A "local" change is this tab's own mint/write and owns its init path; a
|
|
1554
|
+
* change out of an empty store is first-init convergence, not a break.
|
|
1555
|
+
*/
|
|
1556
|
+
private handleExternalCredentialChange(change: VisitorStoreChange): void {
|
|
1557
|
+
if (change.source !== "external" || change.previousToken === null) return;
|
|
1558
|
+
|
|
1559
|
+
// Nothing may reuse the session minted under the revoked credential.
|
|
1560
|
+
this.clientSession = null;
|
|
1561
|
+
this.client.handleExternalCredentialChange();
|
|
1562
|
+
this.historyOpenEpoch += 1;
|
|
1563
|
+
this.projectionFinalizationPromise = null;
|
|
1564
|
+
this.credentialReinitPending = true;
|
|
1565
|
+
this.credentialReinitPromise = null;
|
|
1566
|
+
|
|
1567
|
+
const hadVisibleTranscript = this.messages.length > 0;
|
|
1568
|
+
const hadConversationState =
|
|
1569
|
+
hadVisibleTranscript ||
|
|
1570
|
+
this.activeConversationId !== null ||
|
|
1571
|
+
(this.config.getStoredConversationId?.() ?? null) !== null;
|
|
1572
|
+
if (hadConversationState) {
|
|
1573
|
+
this.discardConversationState();
|
|
1574
|
+
} else {
|
|
1575
|
+
// No conversation to wipe, but the revoked visitor's ids must not survive.
|
|
1576
|
+
this.config.clearStoredSessionId?.();
|
|
1577
|
+
this.config.clearStoredConversationId?.();
|
|
1578
|
+
this.olderPageRequests.clear();
|
|
1579
|
+
this.setPendingProjections(null);
|
|
1580
|
+
}
|
|
1581
|
+
this.activeConversationId = null;
|
|
1582
|
+
this.activeConversationRevision = null;
|
|
1583
|
+
this.historyNextMessageCursor = null;
|
|
1584
|
+
this.historyRecovery = null;
|
|
1585
|
+
this.emitHistoryState();
|
|
1586
|
+
// Only a visible wipe announces: a silent reconcile must not push the open
|
|
1587
|
+
// history view into a refetch that would immediately re-mint a credential.
|
|
1588
|
+
if (hadVisibleTranscript) {
|
|
1589
|
+
this.notifyHistory(
|
|
1590
|
+
"history_continuity_reset",
|
|
1591
|
+
"This browser's conversation history was reset in another tab, so the previous messages were cleared."
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Dispatch may not proceed until a session exists under the NEW credential.
|
|
1598
|
+
* Deliberately lazy: re-initializing on the storage event itself would hand
|
|
1599
|
+
* an idle tab a fresh credential the visitor just deleted elsewhere.
|
|
1600
|
+
*/
|
|
1601
|
+
private completeCredentialReinit(): Promise<void> {
|
|
1602
|
+
if (!this.credentialReinitPending) return Promise.resolve();
|
|
1603
|
+
if (!this.credentialReinitPromise) {
|
|
1604
|
+
this.credentialReinitPromise = this.runCredentialReinit().finally(() => {
|
|
1605
|
+
this.credentialReinitPromise = null;
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
return this.credentialReinitPromise;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
private async runCredentialReinit(): Promise<void> {
|
|
1612
|
+
if (!this.isClientTokenMode()) {
|
|
1613
|
+
this.credentialReinitPending = false;
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
try {
|
|
1617
|
+
// Reads the store again: a clear mints fresh, a replacement adopts the
|
|
1618
|
+
// sibling's token.
|
|
1619
|
+
this.bindActivatedSession(await this.client.initSession());
|
|
1620
|
+
this.credentialReinitPending = false;
|
|
1621
|
+
} catch {
|
|
1622
|
+
// Still pending: the dispatch's own init reports the failure normally.
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* Authoritative boot reconciliation (D4). Runs after a `conversationId` boot
|
|
1628
|
+
* resume and before chat becomes sendable: an unchanged revision needs no
|
|
1629
|
+
* detail fetch; a changed or absent one refreshes from the server's newest
|
|
1630
|
+
* page under the same scope.
|
|
1631
|
+
*/
|
|
1632
|
+
public reconcileBootConversation(opts?: {
|
|
1633
|
+
scope?: HistoryScope;
|
|
1634
|
+
}): Promise<void> {
|
|
1635
|
+
const provider = this.historyProvider;
|
|
1636
|
+
const conversationId =
|
|
1637
|
+
this.clientSession?.conversationId ??
|
|
1638
|
+
this.config.getStoredConversationId?.() ??
|
|
1639
|
+
this.activeConversationId;
|
|
1640
|
+
if (!provider || !conversationId) return Promise.resolve();
|
|
1641
|
+
|
|
1642
|
+
const context = this.historyContext(opts?.scope);
|
|
1643
|
+
this.historySendBlocked = true;
|
|
1644
|
+
this.emitHistoryState();
|
|
1645
|
+
const run = this.runBootReconciliation(
|
|
1646
|
+
provider,
|
|
1647
|
+
conversationId,
|
|
1648
|
+
context
|
|
1649
|
+
).finally(() => {
|
|
1650
|
+
this.historySendBlocked = this.historyRecovery !== null;
|
|
1651
|
+
if (this.historyGate === run) this.historyGate = null;
|
|
1652
|
+
this.emitHistoryState();
|
|
1653
|
+
});
|
|
1654
|
+
this.historyGate = run;
|
|
1655
|
+
return run;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
private async runBootReconciliation(
|
|
1659
|
+
provider: HistoryProvider,
|
|
1660
|
+
conversationId: string,
|
|
1661
|
+
context: HistoryOperationContext
|
|
1662
|
+
): Promise<void> {
|
|
1663
|
+
this.activeConversationId = conversationId;
|
|
1664
|
+
const initRevision =
|
|
1665
|
+
this.clientSession?.conversationRevision ??
|
|
1666
|
+
this.activeConversationRevision ??
|
|
1667
|
+
null;
|
|
1668
|
+
const persistedRevision = this.bootRevisionCaptured
|
|
1669
|
+
? this.bootConversationRevision
|
|
1670
|
+
: (this.historyInternals.getStoredConversationRevision?.() ?? null);
|
|
1671
|
+
this.bootRevisionCaptured = false;
|
|
1672
|
+
this.historyNextMessageCursor =
|
|
1673
|
+
this.historyInternals.getStoredMessageCursor?.() ??
|
|
1674
|
+
this.historyNextMessageCursor;
|
|
1675
|
+
|
|
1676
|
+
// Any locally finalized projection still owed to the server goes first, so
|
|
1677
|
+
// display reconciliation cannot complete against a stale server record.
|
|
1678
|
+
await this.replayPendingProjections();
|
|
1679
|
+
|
|
1680
|
+
if (initRevision && persistedRevision && initRevision === persistedRevision) {
|
|
1681
|
+
this.activeConversationRevision = initRevision;
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const page = await provider.getPage(conversationId, { context });
|
|
1686
|
+
const serverIds = new Set(page.messages.map((message) => message.id));
|
|
1687
|
+
const overlaps = this.messages.some((message) => serverIds.has(message.id));
|
|
1688
|
+
const pageOldest = page.messages[0]?.createdAt;
|
|
1689
|
+
|
|
1690
|
+
let reconciled: AgentWidgetMessage[];
|
|
1691
|
+
if (!overlaps) {
|
|
1692
|
+
// No shared id: the local transcript is not this record's newest window.
|
|
1693
|
+
reconciled = page.messages.map((message) => this.ensureSequence(message));
|
|
1694
|
+
} else {
|
|
1695
|
+
const durableId = this.resumable ? this.activeAssistantMessageId : null;
|
|
1696
|
+
const survivors = this.messages.filter((message) => {
|
|
1697
|
+
if (serverIds.has(message.id)) return true;
|
|
1698
|
+
if (message.id === durableId) return true;
|
|
1699
|
+
// Older than the server window is legitimately absent from this page.
|
|
1700
|
+
return pageOldest !== undefined && message.createdAt < pageOldest;
|
|
1701
|
+
});
|
|
1702
|
+
reconciled = mergeWireMessagesById(survivors, page.messages).map(
|
|
1703
|
+
(message) => this.ensureSequence(message)
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
this.historyNextMessageCursor = page.nextCursor;
|
|
1708
|
+
this.activeConversationRevision =
|
|
1709
|
+
page.conversationRevision || initRevision || null;
|
|
1710
|
+
this.acknowledgedProjections.clear();
|
|
1711
|
+
for (const message of page.messages) {
|
|
1712
|
+
this.acknowledgedProjections.set(message.id, message.content);
|
|
1713
|
+
}
|
|
1714
|
+
// Metadata first, transcript last: one persist covers both.
|
|
1715
|
+
this.persistConversationMetadata();
|
|
1716
|
+
this.messages = this.sortMessages(reconciled);
|
|
1717
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// ── Display-projection finalization ────────────────────────────
|
|
1721
|
+
|
|
1722
|
+
/** Runtype transport op: active session + browser scope, never a proof. */
|
|
1723
|
+
private projectionFinalizationEnabled(): boolean {
|
|
1724
|
+
return (
|
|
1725
|
+
this.config.features?.history?.enabled === true &&
|
|
1726
|
+
this.isClientTokenMode()
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
private setPendingProjections(pending: PendingDisplayProjections | null): void {
|
|
1731
|
+
this.pendingProjections = pending;
|
|
1732
|
+
// Memory-only when the shell supplies no persistence seam.
|
|
1733
|
+
this.historyInternals.setPendingDisplayProjections?.(pending);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
private readPendingProjections(): PendingDisplayProjections | null {
|
|
1737
|
+
return (
|
|
1738
|
+
this.historyInternals.getPendingDisplayProjections?.() ??
|
|
1739
|
+
this.pendingProjections
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/** Record what the dispatch payload carried as each message's projection. */
|
|
1744
|
+
private recordDispatchedProjections(messages: AgentWidgetMessage[]): void {
|
|
1745
|
+
if (!this.projectionFinalizationEnabled()) return;
|
|
1746
|
+
for (const message of messages) {
|
|
1747
|
+
const projection = divergentDisplayProjection(message);
|
|
1748
|
+
if (projection !== undefined) {
|
|
1749
|
+
this.acknowledgedProjections.set(message.id, projection);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* After a terminal assistant message, finalize any browser-derived projection
|
|
1756
|
+
* the server cannot have. Painting is never delayed by this.
|
|
1757
|
+
*/
|
|
1758
|
+
private scheduleProjectionFinalization(): void {
|
|
1759
|
+
if (!this.projectionFinalizationEnabled()) return;
|
|
1760
|
+
const conversationId = this.activeConversationId;
|
|
1761
|
+
if (!conversationId) return;
|
|
1762
|
+
const batch: HistoryDisplayProjection[] = [];
|
|
1763
|
+
for (const message of this.messages) {
|
|
1764
|
+
if (message.role !== "assistant" || message.streaming) continue;
|
|
1765
|
+
const projection = divergentDisplayProjection(message);
|
|
1766
|
+
if (projection === undefined) continue;
|
|
1767
|
+
if (this.acknowledgedProjections.get(message.id) === projection) continue;
|
|
1768
|
+
batch.push({ id: message.id, displayContent: projection });
|
|
1769
|
+
}
|
|
1770
|
+
if (batch.length === 0) return;
|
|
1771
|
+
this.setPendingProjections({
|
|
1772
|
+
conversationId,
|
|
1773
|
+
messageIds: batch.map((item) => item.id),
|
|
1774
|
+
});
|
|
1775
|
+
this.enqueueProjectionFinalization(conversationId, batch);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/** Serialized: operations await this chain instead of racing it. */
|
|
1779
|
+
private enqueueProjectionFinalization(
|
|
1780
|
+
conversationId: string,
|
|
1781
|
+
batch: HistoryDisplayProjection[]
|
|
1782
|
+
): void {
|
|
1783
|
+
const previous = this.projectionFinalizationPromise ?? Promise.resolve();
|
|
1784
|
+
const chain = previous
|
|
1785
|
+
.catch(() => {})
|
|
1786
|
+
.then(() => this.finalizeProjections(conversationId, batch, false))
|
|
1787
|
+
.finally(() => {
|
|
1788
|
+
if (this.projectionFinalizationPromise === chain) {
|
|
1789
|
+
this.projectionFinalizationPromise = null;
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
this.projectionFinalizationPromise = chain;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
private async finalizeProjections(
|
|
1796
|
+
conversationId: string,
|
|
1797
|
+
batch: HistoryDisplayProjection[],
|
|
1798
|
+
retried: boolean
|
|
1799
|
+
): Promise<void> {
|
|
1800
|
+
// A switch/reset/delete already invalidated this marker.
|
|
1801
|
+
if (this.activeConversationId !== conversationId) return;
|
|
1802
|
+
// Two terminal scans can enqueue the same batch before the first PATCH
|
|
1803
|
+
// acknowledges; the chain serializes them, so re-filter at run time.
|
|
1804
|
+
const unacknowledged = batch.filter(
|
|
1805
|
+
(item) => this.acknowledgedProjections.get(item.id) !== item.displayContent
|
|
1806
|
+
);
|
|
1807
|
+
if (unacknowledged.length === 0) {
|
|
1808
|
+
this.clearFinalizedMarker(conversationId, batch);
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
try {
|
|
1812
|
+
const { conversationRevision } = await this.client.finalizeDisplayProjections(
|
|
1813
|
+
conversationId,
|
|
1814
|
+
unacknowledged
|
|
1815
|
+
);
|
|
1816
|
+
for (const item of unacknowledged) {
|
|
1817
|
+
this.acknowledgedProjections.set(item.id, item.displayContent);
|
|
1818
|
+
}
|
|
1819
|
+
if (this.activeConversationId === conversationId && conversationRevision) {
|
|
1820
|
+
this.activeConversationRevision = conversationRevision;
|
|
1821
|
+
}
|
|
1822
|
+
this.clearFinalizedMarker(conversationId, batch);
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
if (!retried && this.isTransientProjectionError(error)) {
|
|
1825
|
+
await this.finalizeProjections(conversationId, unacknowledged, true);
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (
|
|
1829
|
+
error instanceof HistoryClientError &&
|
|
1830
|
+
(error.code === "not_found" || error.code === "conversation_deleted")
|
|
1831
|
+
) {
|
|
1832
|
+
// The record is gone: drop the projection instead of retrying forever.
|
|
1833
|
+
this.clearFinalizedMarker(conversationId, batch);
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
// Marker retained for the next boot/dispatch; the turn itself succeeded.
|
|
1837
|
+
this.notifyHistory(
|
|
1838
|
+
"projection_finalization_failed",
|
|
1839
|
+
"Some message formatting could not be saved for history; it will be retried."
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Transport-shaped failures only; a contract failure is not retried. */
|
|
1845
|
+
private isTransientProjectionError(error: unknown): boolean {
|
|
1846
|
+
if (error instanceof HistoryClientError) return error.code === "request_failed";
|
|
1847
|
+
return error instanceof Error;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
private clearFinalizedMarker(
|
|
1851
|
+
conversationId: string,
|
|
1852
|
+
batch: HistoryDisplayProjection[]
|
|
1853
|
+
): void {
|
|
1854
|
+
const pending = this.readPendingProjections();
|
|
1855
|
+
if (!pending || pending.conversationId !== conversationId) return;
|
|
1856
|
+
const done = new Set(batch.map((item) => item.id));
|
|
1857
|
+
const remaining = pending.messageIds.filter((id) => !done.has(id));
|
|
1858
|
+
this.setPendingProjections(
|
|
1859
|
+
remaining.length ? { conversationId, messageIds: remaining } : null
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Boot replay: rebuild the batch from the persisted ids plus the local
|
|
1865
|
+
* message content, then finalize before display reconciliation completes.
|
|
1866
|
+
*/
|
|
1867
|
+
public async replayPendingProjections(): Promise<void> {
|
|
1868
|
+
if (!this.projectionFinalizationEnabled()) return;
|
|
1869
|
+
const pending = this.readPendingProjections();
|
|
1870
|
+
if (!pending) return;
|
|
1871
|
+
if (
|
|
1872
|
+
this.activeConversationId &&
|
|
1873
|
+
pending.conversationId !== this.activeConversationId
|
|
1874
|
+
) {
|
|
1875
|
+
// Stale marker from another record: never replay it into this one.
|
|
1876
|
+
this.setPendingProjections(null);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const batch: HistoryDisplayProjection[] = [];
|
|
1880
|
+
for (const id of pending.messageIds) {
|
|
1881
|
+
const message = this.messages.find((candidate) => candidate.id === id);
|
|
1882
|
+
if (!message || typeof message.content !== "string") continue;
|
|
1883
|
+
batch.push({ id, displayContent: message.content });
|
|
1884
|
+
}
|
|
1885
|
+
if (batch.length === 0) {
|
|
1886
|
+
this.setPendingProjections(null);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
this.enqueueProjectionFinalization(pending.conversationId, batch);
|
|
1890
|
+
await this.projectionFinalizationPromise?.catch(() => {});
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* A local transcript mutation whose response carries no new revision: clear
|
|
1895
|
+
* the persisted one rather than guessing, so the next boot refreshes safely.
|
|
1896
|
+
*/
|
|
1897
|
+
private invalidateConversationRevision(): void {
|
|
1898
|
+
if (!this.activeConversationId) return;
|
|
1899
|
+
this.activeConversationRevision = null;
|
|
1900
|
+
this.historyInternals.setStoredConversationRevision?.(null);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/** Blocks a send until the owning history transition finishes. */
|
|
1904
|
+
private async awaitHistorySendable(): Promise<void> {
|
|
1905
|
+
await this.awaitHistorySettled();
|
|
1906
|
+
await this.completeCredentialReinit();
|
|
1907
|
+
if (this.historyRecovery === "new_conversation_required") {
|
|
1908
|
+
await this.retryRequiredConversation();
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
private static isConversationDeleted(error: unknown): boolean {
|
|
1913
|
+
return (
|
|
1914
|
+
error instanceof HistoryClientError &&
|
|
1915
|
+
error.code === "conversation_deleted"
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Safe 410 recovery (D5). The old payload would recreate the transcript that
|
|
1921
|
+
* was just deleted, so the retry carries ONLY the just-submitted user turn.
|
|
1922
|
+
*/
|
|
1923
|
+
private async dispatchWithDeletedRecovery(
|
|
1924
|
+
snapshot: AgentWidgetMessage[],
|
|
1925
|
+
controller: AbortController,
|
|
1926
|
+
assistantMessageId: string,
|
|
1927
|
+
userMessageId: string
|
|
1928
|
+
): Promise<void> {
|
|
1929
|
+
this.recordDispatchedProjections(snapshot);
|
|
1930
|
+
try {
|
|
1931
|
+
await this.client.dispatch(
|
|
1932
|
+
{ messages: snapshot, signal: controller.signal, assistantMessageId },
|
|
1933
|
+
this.handleEvent
|
|
1934
|
+
);
|
|
1935
|
+
return;
|
|
1936
|
+
} catch (error) {
|
|
1937
|
+
if (!AgentWidgetSession.isConversationDeleted(error)) throw error;
|
|
1938
|
+
const replacement = await this.recoverFromDeletedConversation(userMessageId);
|
|
1939
|
+
// No recoverable turn (or no fresh record): surface the original 410.
|
|
1940
|
+
if (!replacement) throw error;
|
|
1941
|
+
// A second 410 propagates: exactly one recovery attempt per turn.
|
|
1942
|
+
this.recordDispatchedProjections([replacement]);
|
|
1943
|
+
await this.client.dispatch(
|
|
1944
|
+
{
|
|
1945
|
+
messages: [replacement],
|
|
1946
|
+
signal: controller.signal,
|
|
1947
|
+
assistantMessageId,
|
|
1948
|
+
},
|
|
1949
|
+
this.handleEvent
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* Capture only the newly submitted user message, destroy the deleted record's
|
|
1956
|
+
* local state, and initialize a fresh owned conversation to retry into.
|
|
1957
|
+
*/
|
|
1958
|
+
private async recoverFromDeletedConversation(
|
|
1959
|
+
userMessageId: string
|
|
1960
|
+
): Promise<AgentWidgetMessage | null> {
|
|
1961
|
+
const submitted = this.messages.find(
|
|
1962
|
+
(message) => message.id === userMessageId
|
|
1963
|
+
);
|
|
1964
|
+
if (!submitted) return null;
|
|
1965
|
+
const captured: AgentWidgetMessage = { ...submitted };
|
|
1966
|
+
|
|
1967
|
+
this.historyOpenEpoch += 1;
|
|
1968
|
+
this.projectionFinalizationPromise = null;
|
|
1969
|
+
this.discardConversationState();
|
|
1970
|
+
this.activeConversationId = null;
|
|
1971
|
+
this.activeConversationRevision = null;
|
|
1972
|
+
this.historyNextMessageCursor = null;
|
|
1973
|
+
|
|
1974
|
+
// The recovered turn is the whole transcript: no welcome bubble in front.
|
|
1975
|
+
this.suppressWelcomeInjection = true;
|
|
1976
|
+
try {
|
|
1977
|
+
const provider = this.historyProvider;
|
|
1978
|
+
if (provider) {
|
|
1979
|
+
const prepared = await provider.prepareStartNew({
|
|
1980
|
+
context: this.historyContext(),
|
|
1981
|
+
});
|
|
1982
|
+
await this.installFreshConversation(prepared);
|
|
1983
|
+
} else {
|
|
1984
|
+
this.client.clearClientSession();
|
|
1985
|
+
const session = await this.client.initSession();
|
|
1986
|
+
this.bindActivatedSession(session);
|
|
1987
|
+
}
|
|
1988
|
+
} catch {
|
|
1989
|
+
this.historySendBlocked = true;
|
|
1990
|
+
this.historyRecovery = "new_conversation_required";
|
|
1991
|
+
this.emitHistoryState();
|
|
1992
|
+
this.notifyHistory(
|
|
1993
|
+
"new_conversation_required",
|
|
1994
|
+
"That conversation was deleted. Start a new conversation to continue."
|
|
1995
|
+
);
|
|
1996
|
+
return null;
|
|
1997
|
+
} finally {
|
|
1998
|
+
this.suppressWelcomeInjection = false;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Restore only the user's own turn: it is the entire new transcript.
|
|
2002
|
+
this.appendMessage(captured);
|
|
2003
|
+
this.setStreaming(true);
|
|
2004
|
+
this.notifyHistory(
|
|
2005
|
+
"conversation_deleted_recovered",
|
|
2006
|
+
"That conversation was deleted. Started a new conversation."
|
|
2007
|
+
);
|
|
2008
|
+
return this.messages.find((message) => message.id === captured.id) ?? captured;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
862
2011
|
public updateConfig(next: AgentWidgetConfig) {
|
|
863
2012
|
const previousArtifactDisplay = this.config.features?.artifacts?.display;
|
|
864
2013
|
const merged = { ...this.config, ...next };
|
|
@@ -892,7 +2041,7 @@ export class AgentWidgetSession {
|
|
|
892
2041
|
this.webMcpResolvedKeys.clear();
|
|
893
2042
|
const prevSSECallback = this.client.getSSEEventCallback();
|
|
894
2043
|
this.config = merged;
|
|
895
|
-
this.client = new AgentWidgetClient(this.config);
|
|
2044
|
+
this.client = new AgentWidgetClient(this.config, this.historyInternals);
|
|
896
2045
|
if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
|
|
897
2046
|
this.wireDefaultWebMcpConfirm();
|
|
898
2047
|
if (prevSSECallback) {
|
|
@@ -1227,6 +2376,18 @@ export class AgentWidgetSession {
|
|
|
1227
2376
|
)
|
|
1228
2377
|
return;
|
|
1229
2378
|
|
|
2379
|
+
// History transitions (continuity wipe, boot reconciliation, projection
|
|
2380
|
+
// finalization, replacement init, external credential change) own the
|
|
2381
|
+
// record this turn would land in.
|
|
2382
|
+
if (
|
|
2383
|
+
this.historyGate ||
|
|
2384
|
+
this.projectionFinalizationPromise ||
|
|
2385
|
+
this.historySendBlocked ||
|
|
2386
|
+
this.credentialReinitPending
|
|
2387
|
+
) {
|
|
2388
|
+
await this.awaitHistorySendable();
|
|
2389
|
+
}
|
|
2390
|
+
|
|
1230
2391
|
this.stopSpeaking();
|
|
1231
2392
|
this.abortController?.abort();
|
|
1232
2393
|
// A new user turn supersedes any in-flight WebMCP resolve from the prior
|
|
@@ -1306,13 +2467,11 @@ export class AgentWidgetSession {
|
|
|
1306
2467
|
const snapshot = [...this.messages];
|
|
1307
2468
|
|
|
1308
2469
|
try {
|
|
1309
|
-
await this.
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
},
|
|
1315
|
-
this.handleEvent
|
|
2470
|
+
await this.dispatchWithDeletedRecovery(
|
|
2471
|
+
snapshot,
|
|
2472
|
+
controller,
|
|
2473
|
+
assistantMessageId,
|
|
2474
|
+
userMessageId
|
|
1316
2475
|
);
|
|
1317
2476
|
} catch (error) {
|
|
1318
2477
|
// A durable drop fired the dispatch wrapper's `finally` (plain `idle`)
|
|
@@ -1378,6 +2537,17 @@ export class AgentWidgetSession {
|
|
|
1378
2537
|
// Don't continue if already streaming
|
|
1379
2538
|
if (this.streaming) return;
|
|
1380
2539
|
|
|
2540
|
+
// Same ownership rule as sendMessage: a pending history transition owns the
|
|
2541
|
+
// record this turn would land in.
|
|
2542
|
+
if (
|
|
2543
|
+
this.historyGate ||
|
|
2544
|
+
this.projectionFinalizationPromise ||
|
|
2545
|
+
this.historySendBlocked ||
|
|
2546
|
+
this.credentialReinitPending
|
|
2547
|
+
) {
|
|
2548
|
+
await this.awaitHistorySendable();
|
|
2549
|
+
}
|
|
2550
|
+
|
|
1381
2551
|
this.abortController?.abort();
|
|
1382
2552
|
this.teardownReconnect();
|
|
1383
2553
|
|
|
@@ -3048,6 +4218,10 @@ export class AgentWidgetSession {
|
|
|
3048
4218
|
if (this.webMcpResolveControllers.size === 0) {
|
|
3049
4219
|
this.setStreaming(false);
|
|
3050
4220
|
this.abortController = null;
|
|
4221
|
+
// The turn mutated the record and the chat response carries no
|
|
4222
|
+
// revision; finalize any browser-only projection right after.
|
|
4223
|
+
this.invalidateConversationRevision();
|
|
4224
|
+
this.scheduleProjectionFinalization();
|
|
3051
4225
|
}
|
|
3052
4226
|
// Mark agent execution as complete when streaming ends: UNLESS local
|
|
3053
4227
|
// tools are still outstanding. A batched WebMCP resume is deferred to
|