@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.
Files changed (165) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
package/src/session.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { AgentWidgetClient, type SSEEventCallback } from "./client";
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
- // Optionally add welcome message from session config
783
- if (session.config.welcomeMessage && this.messages.length === 0) {
784
- const welcomeMessage: AgentWidgetMessage = {
785
- id: `welcome-${Date.now()}`,
786
- role: "assistant",
787
- content: session.config.welcomeMessage,
788
- createdAt: new Date().toISOString(),
789
- sequence: this.nextSequence()
790
- };
791
- this.appendMessage(welcomeMessage);
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.client.dispatch(
1310
- {
1311
- messages: snapshot,
1312
- signal: controller.signal,
1313
- assistantMessageId // Pass expected assistant message ID for tracking
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