@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/client.ts CHANGED
@@ -23,6 +23,14 @@ import {
23
23
  PersonaArtifactKind,
24
24
  PersonaArtifactFileMeta,
25
25
  ContentPart,
26
+ PreparedClientSession,
27
+ WidgetHistoryInternals,
28
+ HistoryScope,
29
+ HistoryIdentityStatus,
30
+ HistoryConversationSummary,
31
+ HistoryConversationPage,
32
+ HistoryConversationDetail,
33
+ HistoryDisplayProjection,
26
34
  WebMcpConfirmHandler
27
35
  } from "./types";
28
36
  import { WebMcpBridge, computeClientToolsFingerprint, isWebMcpToolName } from "./webmcp-bridge";
@@ -40,6 +48,10 @@ import {
40
48
  createXmlParser
41
49
  } from "./utils/formatting";
42
50
  import { VERSION } from "./version";
51
+
52
+ /** History transcripts stay on the wire shape; `utils/history-messages.ts` maps them. */
53
+ export type { HistoryWireMessage } from "./utils/history-messages";
54
+ import { divergentDisplayProjection } from "./utils/history-messages";
43
55
  // artifactsSidebarEnabled is used in ui.ts to gate the sidebar pane rendering;
44
56
  // artifact events are always processed here regardless of config.
45
57
 
@@ -55,6 +67,89 @@ type SSEHandler = (event: AgentWidgetEvent) => void;
55
67
  const DEFAULT_ENDPOINT = "https://api.runtype.com/v1/dispatch";
56
68
  const DEFAULT_CLIENT_API_BASE = "https://api.runtype.com";
57
69
 
70
+ /** Branch on `code`, never on message text. */
71
+ export type HistoryClientErrorCode =
72
+ | "visitor_required"
73
+ | "not_found"
74
+ | "conversation_credential_missing"
75
+ | "history_disabled"
76
+ /** No stored visitor credential; a retry cannot repair a missing header. */
77
+ | "visitor_token_missing"
78
+ /** The store changed under the request: the response must not be committed. */
79
+ | "credential_changed"
80
+ /** A bound visitor lost its proof, or the server rejected the one we sent. */
81
+ | "authentication_required"
82
+ /** The host's `getIdentityProof` threw or rejected. */
83
+ | "identity_provider_failed"
84
+ | "invalid_identity_proof"
85
+ | "visitor_identity_mismatch"
86
+ /** Admission gate off: verified scope is a misconfiguration, not an outage. */
87
+ | "proof_not_admitted"
88
+ /** Server acknowledgement contradicts what the request actually sent. */
89
+ | "identity_contract_violation"
90
+ | "unauthorized"
91
+ | "rate_limited"
92
+ | "payload_too_large"
93
+ /** Chat 410: the active conversation record was deleted elsewhere. */
94
+ | "conversation_deleted"
95
+ | "request_failed";
96
+
97
+ export class HistoryClientError extends Error {
98
+ public readonly code: HistoryClientErrorCode;
99
+ /** Present on `rate_limited` when the server sent `Retry-After`. */
100
+ public readonly retryAfterSeconds?: number;
101
+ constructor(
102
+ code: HistoryClientErrorCode,
103
+ message: string,
104
+ opts?: { retryAfterSeconds?: number }
105
+ ) {
106
+ super(message);
107
+ this.name = "HistoryClientError";
108
+ this.code = code;
109
+ if (opts?.retryAfterSeconds !== undefined) {
110
+ this.retryAfterSeconds = opts.retryAfterSeconds;
111
+ }
112
+ }
113
+ }
114
+
115
+ /** Per-message / per-batch display-projection caps (contract facts #5, #15). */
116
+ const DISPLAY_PROJECTION_MESSAGE_CAP = 32768;
117
+ const DISPLAY_PROJECTION_BATCH_CAP = 49152;
118
+
119
+ /** Deprecated wire `flowId` is consumed here and never propagated onward. */
120
+ const normalizeHistorySummary = (raw: unknown): HistoryConversationSummary => {
121
+ const row = (raw ?? {}) as Record<string, unknown>;
122
+ const targetId =
123
+ typeof row.targetId === "string"
124
+ ? row.targetId
125
+ : typeof row.flowId === "string"
126
+ ? row.flowId
127
+ : null;
128
+ return {
129
+ id: typeof row.id === "string" ? row.id : "",
130
+ title: typeof row.title === "string" ? row.title : "",
131
+ targetId,
132
+ preview: typeof row.preview === "string" ? row.preview : null,
133
+ messageCount: typeof row.messageCount === "number" ? row.messageCount : 0,
134
+ createdAt: typeof row.createdAt === "string" ? row.createdAt : "",
135
+ updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "",
136
+ };
137
+ };
138
+
139
+ const identityReason = (status: HistoryIdentityStatus): string | undefined =>
140
+ "reason" in status ? status.reason : undefined;
141
+
142
+ const PROOF_NOT_ADMITTED_MESSAGE =
143
+ "The identity proof was not admitted; account history is unavailable";
144
+ const PROOF_REJECTED_MESSAGE = "The identity proof was rejected";
145
+ const NO_VISITOR_CREDENTIAL_MESSAGE = "The request carried no visitor credential";
146
+ const IDENTITY_MISMATCH_MESSAGE = "The stored visitor belongs to a different signed-in user";
147
+
148
+ const isHistoryClientError = (
149
+ error: unknown,
150
+ code: HistoryClientErrorCode
151
+ ): boolean => error instanceof HistoryClientError && error.code === code;
152
+
58
153
  /**
59
154
  * Derive a download filename for `agent_media` parts that are delivered
60
155
  * without one. Maps a few well-known MIME types to friendly extensions and
@@ -195,11 +290,30 @@ export class AgentWidgetClient {
195
290
  // a session change, or a conversation reset.
196
291
  private sentNonEmptyClientToolsSessionId: string | null = null;
197
292
 
293
+ // Visitor history (client-token mode ONLY). `historyUnavailable` latches for
294
+ // the client's lifetime on a 403 `visitor_history_disabled`; `claimInFlight`
295
+ // bounds the immediate first-conversation claim to one extra init.
296
+ private historyUnavailable = false;
297
+ private historyUnavailableWarned = false;
298
+ private claimInFlight = false;
299
+ // Evidence-based identity state. Null means "never moved off the resting
300
+ // state", which is recomputed from config so `update()` stays honest.
301
+ private historyIdentityStatus: HistoryIdentityStatus | null = null;
302
+ // Fan-out beside the single internals callback: the Runtype history provider
303
+ // bridges these into the generic seam. Cleared with the client instance.
304
+ private historyIdentitySubscribers = new Set<
305
+ (status: HistoryIdentityStatus) => void
306
+ >();
307
+ private historyAvailabilitySubscribers = new Set<(available: boolean) => void>();
308
+
198
309
  // WebMCP: page-discovered tool consumption (see ./webmcp-bridge).
199
310
  // Constructed lazily: null when `config.webmcp?.enabled !== true`.
200
311
  private readonly webMcpBridge: WebMcpBridge | null;
201
312
 
202
- constructor(private config: AgentWidgetConfig = {}) {
313
+ constructor(
314
+ private config: AgentWidgetConfig = {},
315
+ private historyInternals: WidgetHistoryInternals = {}
316
+ ) {
203
317
  if (config.target && (config.agentId || config.flowId || config.agent)) {
204
318
  throw new Error(
205
319
  "[Persona] `target` is mutually exclusive with `agentId`, `flowId`, and `agent`. Set only one routing field.",
@@ -245,6 +359,11 @@ export class AgentWidgetClient {
245
359
  this.config = next;
246
360
  }
247
361
 
362
+ /** Re-thread the controller-owned history dependencies (store rebuild). */
363
+ public setHistoryInternals(internals: WidgetHistoryInternals): void {
364
+ this.historyInternals = internals;
365
+ }
366
+
248
367
  /**
249
368
  * Set callback for capturing raw SSE events
250
369
  */
@@ -333,12 +452,24 @@ export class AgentWidgetClient {
333
452
  return !!(this.config.agent || this.routing().agentId);
334
453
  }
335
454
 
455
+ /**
456
+ * Base URL for Runtype client-surface routes (`/v1/client/*`,
457
+ * `/v1/agents/*`). Strips a configured `/v1/dispatch` suffix so an apiUrl
458
+ * pointed at the dispatch endpoint still resolves its sibling routes. Not
459
+ * used for proxy-mode URLs, whose resume route is `${apiUrl}/resume`.
460
+ */
461
+ private clientApiBase(): string {
462
+ return (
463
+ this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') ||
464
+ DEFAULT_CLIENT_API_BASE
465
+ );
466
+ }
467
+
336
468
  /**
337
469
  * Get the appropriate API URL based on mode
338
470
  */
339
471
  private getClientApiUrl(endpoint: 'init' | 'chat' | 'resume'): string {
340
- const baseUrl = this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
341
- return `${baseUrl}/v1/client/${endpoint}`;
472
+ return `${this.clientApiBase()}/v1/client/${endpoint}`;
342
473
  }
343
474
 
344
475
  /**
@@ -384,16 +515,143 @@ export class AgentWidgetClient {
384
515
  }
385
516
  }
386
517
 
387
- private async _doInitSession(): Promise<ClientSession> {
388
- // Get stored session_id if available (for session resumption)
389
- const storedSessionId = this.config.getStoredSessionId?.() || null;
390
-
518
+ /** Visitor history rides on client-token init only, and latches off after a 403 degrade. */
519
+ private isHistoryCapable(): boolean {
520
+ return (
521
+ this.config.features?.history?.enabled === true &&
522
+ this.isClientTokenMode() &&
523
+ !this.historyUnavailable
524
+ );
525
+ }
526
+
527
+ private async readVisitorToken(): Promise<string | null> {
528
+ const store = this.historyInternals.visitorStore;
529
+ if (!store) return null;
530
+ await store.ready;
531
+ return (await store.get()) ?? null;
532
+ }
533
+
534
+ /** One-way, client-lifetime latch: chat keeps working without history. */
535
+ private markHistoryUnavailable(): void {
536
+ if (this.historyUnavailable) return;
537
+ // Announce before the latch flips, or the resting state already matches.
538
+ this.setHistoryIdentityStatus({ state: 'unavailable', reason: 'history_disabled' });
539
+ this.historyUnavailable = true;
540
+ if (!this.historyUnavailableWarned && typeof console !== 'undefined') {
541
+ this.historyUnavailableWarned = true;
542
+ // eslint-disable-next-line no-console
543
+ console.warn(
544
+ '[Persona] Visitor history is disabled for this surface; continuing without it.'
545
+ );
546
+ }
547
+ this.historyInternals.onHistoryAvailabilityChanged?.(false);
548
+ for (const subscriber of [...this.historyAvailabilitySubscribers]) {
549
+ subscriber(false);
550
+ }
551
+ }
552
+
553
+ /** Public, secret-free view of the current identity state. */
554
+ public getHistoryIdentityStatus(): HistoryIdentityStatus {
555
+ return this.historyIdentityStatus ?? this.restingIdentityStatus();
556
+ }
557
+
558
+ /** Deduped status notifications; carries no token, proof, or identity value. */
559
+ public subscribeHistoryIdentityStatus(
560
+ callback: (status: HistoryIdentityStatus) => void
561
+ ): () => void {
562
+ this.historyIdentitySubscribers.add(callback);
563
+ return () => {
564
+ this.historyIdentitySubscribers.delete(callback);
565
+ };
566
+ }
567
+
568
+ /** Fires only on the one-way 403 degrade; availability never returns. */
569
+ public subscribeHistoryAvailability(
570
+ callback: (available: boolean) => void
571
+ ): () => void {
572
+ this.historyAvailabilitySubscribers.add(callback);
573
+ if (this.historyUnavailable) callback(false);
574
+ return () => {
575
+ this.historyAvailabilitySubscribers.delete(callback);
576
+ };
577
+ }
578
+
579
+ /** Config-derived state before any per-operation evidence exists. */
580
+ private restingIdentityStatus(): HistoryIdentityStatus {
581
+ if (!this.isClientTokenMode()) {
582
+ return { state: 'unavailable', reason: 'ineligible_mode' };
583
+ }
584
+ if (this.config.features?.history?.enabled !== true || this.historyUnavailable) {
585
+ return { state: 'unavailable', reason: 'history_disabled' };
586
+ }
587
+ return this.browserOnlyStatus();
588
+ }
589
+
590
+ /** Keeps an already-observed browser_only reason; otherwise derives one. */
591
+ private browserOnlyStatus(): HistoryIdentityStatus {
592
+ const current = this.historyIdentityStatus;
593
+ if (current?.state === 'browser_only') return current;
594
+ return this.derivedBrowserOnly();
595
+ }
596
+
597
+ private derivedBrowserOnly(): HistoryIdentityStatus {
598
+ if (this.config.features?.history?.scope === 'browser') {
599
+ return { state: 'browser_only', reason: 'configured_browser_scope' };
600
+ }
601
+ if (!this.config.getIdentityProof) {
602
+ return { state: 'browser_only', reason: 'no_identity_provider' };
603
+ }
604
+ return { state: 'browser_only', reason: 'proof_unavailable_before_binding' };
605
+ }
606
+
607
+ /** Identical consecutive states are not re-announced. */
608
+ private setHistoryIdentityStatus(next: HistoryIdentityStatus): void {
609
+ const current = this.getHistoryIdentityStatus();
610
+ const unchanged =
611
+ current.state === next.state && identityReason(current) === identityReason(next);
612
+ this.historyIdentityStatus = next;
613
+ if (unchanged) return;
614
+ this.historyInternals.onHistoryIdentityStatusChanged?.(next);
615
+ for (const subscriber of [...this.historyIdentitySubscribers]) subscriber(next);
616
+ }
617
+
618
+ /**
619
+ * The single uncached init primitive: builds one body, performs one fetch.
620
+ * `conversationId` and `sessionId` are mutually exclusive on the wire (strict
621
+ * server union), so a resume never carries the stored session id.
622
+ */
623
+ private async createClientSession(opts: {
624
+ conversationId?: string;
625
+ identityProof?: string | null;
626
+ storedSessionId?: string | null;
627
+ omitVisitorFields?: boolean;
628
+ }): Promise<ClientSession> {
629
+ const historyCapable = this.isHistoryCapable() && !opts.omitVisitorFields;
630
+ let visitorToken: string | null = null;
631
+ if (historyCapable) {
632
+ // Never read persisted state before the controller's stored-state gate.
633
+ await this.historyInternals.historyBootstrapReady;
634
+ visitorToken = await this.readVisitorToken();
635
+ }
636
+
391
637
  const routed = this.routing();
392
638
  const sessionTargetId = routed.agentId ?? routed.flowId;
639
+ const resumeConversationId = historyCapable ? opts.conversationId : undefined;
393
640
  const requestBody: Record<string, unknown> = {
394
641
  token: this.config.clientToken,
395
642
  ...(sessionTargetId && { flowId: sessionTargetId }),
396
- ...(storedSessionId && { sessionId: storedSessionId }),
643
+ ...(historyCapable && { visitorHistory: true }),
644
+ ...(historyCapable && visitorToken ? { visitorToken } : {}),
645
+ // Independent of the strict conversationId/sessionId union: a proof also
646
+ // binds the visitor on an ordinary init.
647
+ ...(historyCapable && opts.identityProof
648
+ ? { identityProof: opts.identityProof }
649
+ : {}),
650
+ ...(resumeConversationId
651
+ ? { conversationId: resumeConversationId }
652
+ : opts.storedSessionId
653
+ ? { sessionId: opts.storedSessionId }
654
+ : {}),
397
655
  };
398
656
 
399
657
  const response = await fetch(this.getClientApiUrl('init'), {
@@ -407,6 +665,23 @@ export class AgentWidgetClient {
407
665
 
408
666
  if (!response.ok) {
409
667
  const error = await response.json().catch(() => ({ error: 'Session initialization failed' }));
668
+ if (historyCapable && response.status === 403 && error.error === 'visitor_history_disabled') {
669
+ this.markHistoryUnavailable();
670
+ // A resume must not silently degrade into some other conversation.
671
+ if (resumeConversationId) {
672
+ throw new HistoryClientError(
673
+ 'history_disabled',
674
+ 'Visitor history is disabled for this surface'
675
+ );
676
+ }
677
+ return this.createClientSession({ ...opts, omitVisitorFields: true });
678
+ }
679
+ if (historyCapable && response.status === 401 && error.error === 'visitor_required') {
680
+ throw new HistoryClientError('visitor_required', 'Visitor credential no longer resolves');
681
+ }
682
+ if (resumeConversationId && response.status === 404) {
683
+ throw new HistoryClientError('not_found', 'Conversation not found');
684
+ }
410
685
  if (response.status === 401) {
411
686
  throw new Error(`Invalid client token: ${error.hint || error.error}`);
412
687
  }
@@ -418,15 +693,24 @@ export class AgentWidgetClient {
418
693
 
419
694
  const data: ClientInitResponse = await response.json();
420
695
 
421
- // Store the new sessionId for future resumption
422
- if (this.config.setStoredSessionId) {
423
- this.config.setStoredSessionId(data.sessionId);
696
+ // First awaited op after parse: the server cannot re-issue a minted secret.
697
+ if (data.visitor?.token) {
698
+ await this.historyInternals.visitorStore?.set(data.visitor.token);
424
699
  }
425
700
 
426
701
  return {
427
702
  sessionId: data.sessionId,
428
703
  expiresAt: new Date(data.expiresAt),
429
704
  flow: data.flow,
705
+ ...(data.conversationId ? { conversationId: data.conversationId } : {}),
706
+ // New history code reads only the normalized field; `flow` stays untouched.
707
+ ...(data.targetId ?? data.flow?.id
708
+ ? { targetId: data.targetId ?? data.flow?.id }
709
+ : {}),
710
+ ...(data.conversationRevision
711
+ ? { conversationRevision: data.conversationRevision }
712
+ : {}),
713
+ ...(data.visitor ? { visitor: data.visitor } : {}),
430
714
  config: {
431
715
  welcomeMessage: data.config.welcomeMessage,
432
716
  placeholder: data.config.placeholder,
@@ -435,6 +719,210 @@ export class AgentWidgetClient {
435
719
  };
436
720
  }
437
721
 
722
+ private async _doInitSession(): Promise<ClientSession> {
723
+ if (!this.isHistoryCapable()) {
724
+ const storedSessionId = this.config.getStoredSessionId?.() || null;
725
+ const session = await this.createClientSession({ storedSessionId });
726
+ this.config.setStoredSessionId?.(session.sessionId);
727
+ return session;
728
+ }
729
+
730
+ await this.historyInternals.historyBootstrapReady;
731
+ const previousConversationId = this.config.getStoredConversationId?.() || null;
732
+ const storedToken = await this.readVisitorToken();
733
+
734
+ // Boot resume: reopening the record beats replaying a possibly idle-expired
735
+ // session id, so benign expiry never forks or wipes the conversation.
736
+ if (previousConversationId && storedToken) {
737
+ try {
738
+ const resumed = await this.createClientSession({
739
+ conversationId: previousConversationId,
740
+ });
741
+ return this.finishInit(resumed, previousConversationId, false);
742
+ } catch (error) {
743
+ if (
744
+ !isHistoryClientError(error, 'not_found') &&
745
+ !isHistoryClientError(error, 'visitor_required')
746
+ ) {
747
+ throw error;
748
+ }
749
+ // Record gone or credential dead: exactly one ordinary fallback, never a loop.
750
+ return this.ordinaryInit(previousConversationId, true);
751
+ }
752
+ }
753
+
754
+ return this.ordinaryInit(previousConversationId, false);
755
+ }
756
+
757
+ /**
758
+ * An empty store means this init may mint a visitor, so it runs under the
759
+ * cross-tab first-init lock: a waiter re-reads inside the lock and joins the
760
+ * winner's visitor instead of minting a second one.
761
+ */
762
+ private async ordinaryInit(
763
+ previousConversationId: string | null,
764
+ continuityBroken: boolean
765
+ ): Promise<ClientSession> {
766
+ const store = this.historyInternals.visitorStore;
767
+ if (store && this.isHistoryCapable() && !(await this.readVisitorToken())) {
768
+ return store.withFirstInitLock(() =>
769
+ this.doOrdinaryInit(previousConversationId, continuityBroken)
770
+ );
771
+ }
772
+ return this.doOrdinaryInit(previousConversationId, continuityBroken);
773
+ }
774
+
775
+ private async doOrdinaryInit(
776
+ previousConversationId: string | null,
777
+ continuityBroken: boolean
778
+ ): Promise<ClientSession> {
779
+ const storedSessionId = this.config.getStoredSessionId?.() || null;
780
+ const first = await this.createClientSession({ storedSessionId });
781
+ const session = await this.claimFirstConversation(first);
782
+ return this.finishInit(session, previousConversationId, continuityBroken);
783
+ }
784
+
785
+ /**
786
+ * Ordinary init carrying an identity proof: binds/rebinds the visitor,
787
+ * persists any replacement token (inside `createClientSession`), and installs
788
+ * the result as the live session.
789
+ */
790
+ private async reinitWithProof(proof: string): Promise<ClientSession> {
791
+ const storedSessionId = this.config.getStoredSessionId?.() || null;
792
+ const previousConversationId = this.config.getStoredConversationId?.() || null;
793
+ const session = await this.createClientSession({
794
+ storedSessionId,
795
+ identityProof: proof,
796
+ });
797
+ const installed = this.finishInit(session, previousConversationId, false);
798
+ this.clientSession = installed;
799
+ this.sessionInitPromise = null;
800
+ this.resetClientToolsFingerprint();
801
+ return installed;
802
+ }
803
+
804
+ /**
805
+ * A minted visitor means the record this session just created is still
806
+ * unowned; one immediate re-init with `{visitorToken, sessionId}` claims it.
807
+ */
808
+ private async claimFirstConversation(first: ClientSession): Promise<ClientSession> {
809
+ if (!first.visitor?.token || this.claimInFlight || !this.isHistoryCapable()) {
810
+ return first;
811
+ }
812
+ this.claimInFlight = true;
813
+ try {
814
+ return await this.createClientSession({ storedSessionId: first.sessionId });
815
+ } catch {
816
+ // Non-fatal: the next page load claims through the normal backend path.
817
+ return first;
818
+ } finally {
819
+ this.claimInFlight = false;
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Continuity guard + id persistence. A different record than the persisted
825
+ * one is a privacy transition, announced before the new id is written.
826
+ */
827
+ private finishInit(
828
+ session: ClientSession,
829
+ previousConversationId: string | null,
830
+ continuityBroken: boolean
831
+ ): ClientSession {
832
+ const conversationId = session.conversationId;
833
+ if (
834
+ conversationId &&
835
+ previousConversationId &&
836
+ (continuityBroken || conversationId !== previousConversationId)
837
+ ) {
838
+ this.historyInternals.onHistoryContinuityChanged?.({
839
+ previousConversationId,
840
+ conversationId,
841
+ });
842
+ }
843
+ this.config.setStoredSessionId?.(session.sessionId);
844
+ if (conversationId) {
845
+ this.config.setStoredConversationId?.(conversationId);
846
+ this.historyInternals.setStoredConversationRevision?.(
847
+ session.conversationRevision ?? null
848
+ );
849
+ }
850
+ return session;
851
+ }
852
+
853
+ /** Wrap an uncached init so nothing installs until the winner commits. */
854
+ private prepared(session: ClientSession): PreparedClientSession {
855
+ let settled = false;
856
+ return {
857
+ session,
858
+ commit: () => {
859
+ if (settled) return;
860
+ settled = true;
861
+ this.clientSession = session;
862
+ this.resetClientToolsFingerprint();
863
+ },
864
+ discard: () => {
865
+ settled = true;
866
+ },
867
+ };
868
+ }
869
+
870
+ /**
871
+ * Transactional reopen of a known conversation. Always bypasses the session
872
+ * cache, never sends the stored session id, and needs a credential the server
873
+ * will accept (stored visitor token or a caller-supplied proof).
874
+ */
875
+ public async prepareConversationSession(
876
+ conversationId: string,
877
+ opts?: { proof?: string | null }
878
+ ): Promise<PreparedClientSession> {
879
+ if (!this.isClientTokenMode()) {
880
+ throw new Error('prepareConversationSession() only available in client token mode');
881
+ }
882
+ if (!this.isHistoryCapable()) {
883
+ throw new HistoryClientError(
884
+ 'history_disabled',
885
+ 'Visitor history is disabled for this surface'
886
+ );
887
+ }
888
+ await this.historyInternals.historyBootstrapReady;
889
+ const proof = opts?.proof ?? null;
890
+ const visitorToken = await this.readVisitorToken();
891
+ if (!visitorToken && !proof) {
892
+ throw new HistoryClientError(
893
+ 'conversation_credential_missing',
894
+ 'Reopening a conversation requires a stored visitor token or an identity proof'
895
+ );
896
+ }
897
+ const session = await this.createClientSession({
898
+ conversationId,
899
+ identityProof: proof,
900
+ });
901
+ return this.prepared(session);
902
+ }
903
+
904
+ /** Transactional new conversation: no stored session id, no conversation id. */
905
+ public async prepareNewConversationSession(): Promise<PreparedClientSession> {
906
+ if (!this.isClientTokenMode()) {
907
+ throw new Error('prepareNewConversationSession() only available in client token mode');
908
+ }
909
+ await this.historyInternals.historyBootstrapReady;
910
+ const session = await this.createClientSession({});
911
+ return this.prepared(session);
912
+ }
913
+
914
+ /**
915
+ * D3 privacy boundary: a sibling tab replaced or revoked the shared visitor
916
+ * credential. Revoking it does not revoke an already-minted chat session, so
917
+ * the cache must go, and whatever this visitor was bound to is gone with it.
918
+ */
919
+ public handleExternalCredentialChange(): void {
920
+ this.clearClientSession();
921
+ if (this.isHistoryCapable()) {
922
+ this.setHistoryIdentityStatus(this.derivedBrowserOnly());
923
+ }
924
+ }
925
+
438
926
  /**
439
927
  * Clear the current client session
440
928
  */
@@ -459,8 +947,7 @@ export class AgentWidgetClient {
459
947
  * Get the feedback API URL
460
948
  */
461
949
  private getFeedbackApiUrl(): string {
462
- const baseUrl = this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
463
- return `${baseUrl}/v1/client/feedback`;
950
+ return `${this.clientApiBase()}/v1/client/feedback`;
464
951
  }
465
952
 
466
953
  /**
@@ -625,6 +1112,513 @@ export class AgentWidgetClient {
625
1112
  });
626
1113
  }
627
1114
 
1115
+ // ==========================================================================
1116
+ // Visitor conversation history REST (client token mode only)
1117
+ // ==========================================================================
1118
+
1119
+ /** `/v1/client/<path>` with a query string; credentials ride in headers. */
1120
+ private historyUrl(
1121
+ path: string,
1122
+ query: Record<string, string | number | undefined>
1123
+ ): string {
1124
+ const params = new URLSearchParams();
1125
+ for (const [key, value] of Object.entries(query)) {
1126
+ if (value === undefined || value === '') continue;
1127
+ params.set(key, String(value));
1128
+ }
1129
+ const search = params.toString();
1130
+ return `${this.clientApiBase()}/v1/client/${path}${search ? `?${search}` : ''}`;
1131
+ }
1132
+
1133
+ /** Explicit per-operation scope wins; otherwise config, then evidence. */
1134
+ private resolveHistoryScope(requested?: HistoryScope): HistoryScope {
1135
+ if (requested) return requested;
1136
+ const configured = this.config.features?.history?.scope;
1137
+ if (configured) return configured;
1138
+ return this.config.getIdentityProof ? 'verified-user' : 'browser';
1139
+ }
1140
+
1141
+ /**
1142
+ * Resolve the proof for one logical request. `null` is an intentional
1143
+ * browser-scope fallback only while the visitor has never been bound.
1144
+ */
1145
+ private async resolveIdentityProof(
1146
+ session: ClientSession,
1147
+ track: boolean
1148
+ ): Promise<string | null> {
1149
+ const provider = this.config.getIdentityProof;
1150
+ if (!provider) {
1151
+ if (track) this.setHistoryIdentityStatus(this.browserOnlyStatus());
1152
+ return null;
1153
+ }
1154
+ if (track) this.setHistoryIdentityStatus({ state: 'verifying' });
1155
+ let proof: string | null;
1156
+ try {
1157
+ proof = (await provider()) ?? null;
1158
+ } catch {
1159
+ if (track) this.setHistoryIdentityStatus({ state: 'identity_provider_failed' });
1160
+ throw new HistoryClientError(
1161
+ 'identity_provider_failed',
1162
+ 'The identity proof provider failed'
1163
+ );
1164
+ }
1165
+ if (proof) return proof;
1166
+ if (session.visitor?.endUserId != null) {
1167
+ // Never downgrade a bound visitor into its own browser scope.
1168
+ if (track) {
1169
+ this.setHistoryIdentityStatus({
1170
+ state: 'authentication_required',
1171
+ reason: 'proof_unavailable_after_binding',
1172
+ });
1173
+ }
1174
+ throw new HistoryClientError(
1175
+ 'authentication_required',
1176
+ 'This browser is bound to a signed-in user and needs a fresh identity proof'
1177
+ );
1178
+ }
1179
+ if (track) {
1180
+ this.setHistoryIdentityStatus({
1181
+ state: 'browser_only',
1182
+ reason: 'proof_unavailable_before_binding',
1183
+ });
1184
+ }
1185
+ return null;
1186
+ }
1187
+
1188
+ /** Bind the visitor before the first verified request; admitted proofs only. */
1189
+ private async bindIdentity(
1190
+ session: ClientSession,
1191
+ proof: string,
1192
+ track: boolean
1193
+ ): Promise<ClientSession> {
1194
+ if (session.visitor?.endUserId != null) return session;
1195
+ const bound = await this.reinitWithProof(proof);
1196
+ const admitted =
1197
+ bound.visitor?.identityStatus === 'admitted' && bound.visitor?.endUserId != null;
1198
+ if (!admitted) {
1199
+ // "ignored", or a pre-acknowledgement server that left endUserId null.
1200
+ if (track) {
1201
+ this.setHistoryIdentityStatus({
1202
+ state: 'configuration_error',
1203
+ reason: 'proof_not_admitted',
1204
+ });
1205
+ }
1206
+ throw new HistoryClientError(
1207
+ 'proof_not_admitted',
1208
+ PROOF_NOT_ADMITTED_MESSAGE
1209
+ );
1210
+ }
1211
+ return bound;
1212
+ }
1213
+
1214
+ private async readErrorCode(response: Response): Promise<string> {
1215
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
1216
+ if (typeof body.error === 'string') return body.error;
1217
+ if (typeof body.message === 'string') return body.message;
1218
+ return '';
1219
+ }
1220
+
1221
+ /**
1222
+ * Selective one-shot 401 recovery. Returns the session to retry under;
1223
+ * anything unrecoverable throws typed and is never retried.
1224
+ */
1225
+ private async recoverFromUnauthorized(
1226
+ reason: string,
1227
+ proof: string | null,
1228
+ track: boolean
1229
+ ): Promise<ClientSession> {
1230
+ const text = reason.toLowerCase();
1231
+ if (text.includes('invalid_identity_proof')) {
1232
+ if (track) {
1233
+ this.setHistoryIdentityStatus({
1234
+ state: 'authentication_required',
1235
+ reason: 'invalid_identity_proof',
1236
+ });
1237
+ }
1238
+ throw new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
1239
+ }
1240
+ if (text.includes('visitor token required')) {
1241
+ throw new HistoryClientError('visitor_token_missing', NO_VISITOR_CREDENTIAL_MESSAGE);
1242
+ }
1243
+ if (text.includes('visitor_identity_mismatch')) {
1244
+ if (!proof) {
1245
+ if (track) {
1246
+ this.setHistoryIdentityStatus({
1247
+ state: 'authentication_required',
1248
+ reason: 'proof_unavailable_after_binding',
1249
+ });
1250
+ }
1251
+ throw new HistoryClientError('visitor_identity_mismatch', IDENTITY_MISMATCH_MESSAGE);
1252
+ }
1253
+ // Same proof: binds this visitor, or gives a different person a clean one.
1254
+ return this.reinitWithProof(proof);
1255
+ }
1256
+ if (text.includes('expired') || text.includes('not found')) {
1257
+ this.clearClientSession();
1258
+ return this.initSession();
1259
+ }
1260
+ throw new HistoryClientError('unauthorized', reason || 'History request was not authorized');
1261
+ }
1262
+
1263
+ private async historyErrorFor(
1264
+ response: Response,
1265
+ track: boolean
1266
+ ): Promise<HistoryClientError> {
1267
+ const code = await this.readErrorCode(response);
1268
+ if (response.status === 404) {
1269
+ return new HistoryClientError('not_found', 'Conversation not found');
1270
+ }
1271
+ if (response.status === 429) {
1272
+ const header = Number.parseInt(response.headers.get('Retry-After') ?? '', 10);
1273
+ return new HistoryClientError('rate_limited', 'Too many history requests', {
1274
+ ...(Number.isFinite(header) ? { retryAfterSeconds: header } : {}),
1275
+ });
1276
+ }
1277
+ if (response.status === 503 && code.includes('identity_proof_not_admitted')) {
1278
+ if (track) {
1279
+ this.setHistoryIdentityStatus({
1280
+ state: 'configuration_error',
1281
+ reason: 'proof_not_admitted',
1282
+ });
1283
+ }
1284
+ return new HistoryClientError(
1285
+ 'proof_not_admitted',
1286
+ PROOF_NOT_ADMITTED_MESSAGE
1287
+ );
1288
+ }
1289
+ if (response.status === 401) {
1290
+ const text = code.toLowerCase();
1291
+ if (text.includes('invalid_identity_proof')) {
1292
+ if (track) {
1293
+ this.setHistoryIdentityStatus({
1294
+ state: 'authentication_required',
1295
+ reason: 'invalid_identity_proof',
1296
+ });
1297
+ }
1298
+ return new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
1299
+ }
1300
+ if (text.includes('visitor token required')) {
1301
+ return new HistoryClientError('visitor_token_missing', NO_VISITOR_CREDENTIAL_MESSAGE);
1302
+ }
1303
+ if (text.includes('visitor_identity_mismatch')) {
1304
+ return new HistoryClientError('visitor_identity_mismatch', IDENTITY_MISMATCH_MESSAGE);
1305
+ }
1306
+ return new HistoryClientError('unauthorized', code || 'History request was not authorized');
1307
+ }
1308
+ return new HistoryClientError(
1309
+ 'request_failed',
1310
+ code || `History request failed (${response.status})`
1311
+ );
1312
+ }
1313
+
1314
+ /**
1315
+ * Validate `X-History-Identity-Status` before any body is committed: the
1316
+ * per-operation acknowledgement, not a prior init, is what proves scope.
1317
+ */
1318
+ private commitIdentityAcknowledgement(
1319
+ header: string | null,
1320
+ proofSent: boolean,
1321
+ track: boolean
1322
+ ): void {
1323
+ const value = header?.toLowerCase() ?? null;
1324
+ if (proofSent) {
1325
+ if (value === 'admitted') {
1326
+ if (track) this.setHistoryIdentityStatus({ state: 'verified' });
1327
+ return;
1328
+ }
1329
+ if (track) {
1330
+ this.setHistoryIdentityStatus({
1331
+ state: 'configuration_error',
1332
+ reason: 'proof_not_admitted',
1333
+ });
1334
+ }
1335
+ if (value === 'ignored') {
1336
+ // The gate-off path must fail before I/O; a 2xx here is a broken server.
1337
+ throw new HistoryClientError(
1338
+ 'identity_contract_violation',
1339
+ 'Server reported an ignored identity proof on a success response'
1340
+ );
1341
+ }
1342
+ throw new HistoryClientError(
1343
+ 'proof_not_admitted',
1344
+ 'The response did not acknowledge the identity proof this request sent'
1345
+ );
1346
+ }
1347
+ if (value === 'admitted') {
1348
+ throw new HistoryClientError(
1349
+ 'identity_contract_violation',
1350
+ 'Server admitted an identity proof that was never sent'
1351
+ );
1352
+ }
1353
+ // "not_provided", a missing header (rolling deploy), or an unknown value:
1354
+ // no verified claim was made either way.
1355
+ if (track) this.setHistoryIdentityStatus(this.browserOnlyStatus());
1356
+ }
1357
+
1358
+ /**
1359
+ * Shared history transport: live session, `sessionId` query param,
1360
+ * `X-Visitor-Token` header, one resolved proof, one-shot 401 recovery, and a
1361
+ * credential-revision guard that discards a response the store outran.
1362
+ */
1363
+ private async historyFetch<T>(
1364
+ path: string,
1365
+ opts: {
1366
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
1367
+ query?: Record<string, string | number | undefined>;
1368
+ scope?: HistoryScope;
1369
+ body?: unknown;
1370
+ /** Reset tolerates a missing credential; every other route requires one. */
1371
+ visitorTokenOptional?: boolean;
1372
+ keepalive?: boolean;
1373
+ /** Transport ops validate the acknowledgement but publish no status. */
1374
+ trackIdentity?: boolean;
1375
+ }
1376
+ ): Promise<T> {
1377
+ this.assertHistoryUsable();
1378
+ const track = opts.trackIdentity !== false;
1379
+ let session = await this.initSession();
1380
+
1381
+ // Resolved once per logical request: a recovery retry reuses the same proof
1382
+ // so one action cannot switch identities midway.
1383
+ let proof: string | null = null;
1384
+ if (this.resolveHistoryScope(opts.scope) === 'verified-user') {
1385
+ proof = await this.resolveIdentityProof(session, track);
1386
+ if (proof) session = await this.bindIdentity(session, proof, track);
1387
+ }
1388
+
1389
+ const store = this.historyInternals.visitorStore;
1390
+ let recovered = false;
1391
+ for (;;) {
1392
+ const capturedRevision = store?.revision() ?? 0;
1393
+ const visitorToken = await this.readVisitorToken();
1394
+ if (!visitorToken && !opts.visitorTokenOptional) {
1395
+ throw new HistoryClientError(
1396
+ 'visitor_token_missing',
1397
+ 'No visitor credential is stored for this browser'
1398
+ );
1399
+ }
1400
+ const response = await fetch(
1401
+ this.historyUrl(path, { ...opts.query, sessionId: session.sessionId }),
1402
+ {
1403
+ method: opts.method,
1404
+ headers: {
1405
+ 'X-Persona-Version': VERSION,
1406
+ ...(visitorToken ? { 'X-Visitor-Token': visitorToken } : {}),
1407
+ ...(proof ? { 'X-Identity-Proof': proof } : {}),
1408
+ ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
1409
+ },
1410
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
1411
+ ...(opts.keepalive ? { keepalive: true } : {}),
1412
+ }
1413
+ );
1414
+
1415
+ if (response.status === 401 && !recovered) {
1416
+ recovered = true;
1417
+ session = await this.recoverFromUnauthorized(
1418
+ await this.readErrorCode(response),
1419
+ proof,
1420
+ track
1421
+ );
1422
+ continue;
1423
+ }
1424
+ if (!response.ok) {
1425
+ throw await this.historyErrorFor(response, track);
1426
+ }
1427
+
1428
+ const acknowledgement = response.headers.get('X-History-Identity-Status');
1429
+ const data = (await response.json().catch(() => undefined)) as T;
1430
+ if (store && store.revision() !== capturedRevision) {
1431
+ // Another tab reset or replaced the credential: this body is stale.
1432
+ throw new HistoryClientError(
1433
+ 'credential_changed',
1434
+ 'The visitor credential changed while the request was in flight'
1435
+ );
1436
+ }
1437
+ this.commitIdentityAcknowledgement(acknowledgement, proof !== null, track);
1438
+ return data;
1439
+ }
1440
+ }
1441
+
1442
+ private assertHistoryUsable(): void {
1443
+ if (!this.isClientTokenMode()) {
1444
+ throw new Error('Conversation history is only available in client token mode');
1445
+ }
1446
+ if (!this.isHistoryCapable()) {
1447
+ throw new HistoryClientError(
1448
+ 'history_disabled',
1449
+ 'Visitor history is disabled for this surface'
1450
+ );
1451
+ }
1452
+ }
1453
+
1454
+ /** One page of the visitor's conversations, newest first. */
1455
+ public async listConversations(opts?: {
1456
+ cursor?: string;
1457
+ limit?: number;
1458
+ targetId?: string;
1459
+ scope?: HistoryScope;
1460
+ }): Promise<HistoryConversationPage> {
1461
+ const page = await this.historyFetch<{
1462
+ data?: unknown[];
1463
+ nextCursor?: string | null;
1464
+ }>('conversations', {
1465
+ method: 'GET',
1466
+ query: {
1467
+ ...(opts?.cursor ? { cursor: opts.cursor } : {}),
1468
+ ...(opts?.limit !== undefined ? { limit: opts.limit } : {}),
1469
+ ...(opts?.targetId ? { targetId: opts.targetId } : {}),
1470
+ },
1471
+ ...(opts?.scope ? { scope: opts.scope } : {}),
1472
+ });
1473
+ return {
1474
+ data: (Array.isArray(page?.data) ? page.data : []).map(normalizeHistorySummary),
1475
+ nextCursor: typeof page?.nextCursor === 'string' ? page.nextCursor : null,
1476
+ };
1477
+ }
1478
+
1479
+ /**
1480
+ * One transcript page (newest first, oldest-first within the page). Messages
1481
+ * stay on the wire shape: `utils/history-messages.ts` owns the mapping.
1482
+ */
1483
+ public async getConversation(
1484
+ conversationId: string,
1485
+ opts?: { messageCursor?: string; scope?: HistoryScope }
1486
+ ): Promise<HistoryConversationDetail> {
1487
+ const detail = await this.historyFetch<Record<string, unknown>>(
1488
+ `conversations/${encodeURIComponent(conversationId)}`,
1489
+ {
1490
+ method: 'GET',
1491
+ query: {
1492
+ ...(opts?.messageCursor ? { messageCursor: opts.messageCursor } : {}),
1493
+ },
1494
+ ...(opts?.scope ? { scope: opts.scope } : {}),
1495
+ }
1496
+ );
1497
+ const summarySource =
1498
+ detail && typeof detail.conversation === 'object' && detail.conversation !== null
1499
+ ? detail.conversation
1500
+ : detail;
1501
+ return {
1502
+ summary: normalizeHistorySummary(summarySource),
1503
+ messages: Array.isArray(detail?.messages)
1504
+ ? (detail.messages as HistoryConversationDetail['messages'])
1505
+ : [],
1506
+ nextMessageCursor:
1507
+ typeof detail?.nextMessageCursor === 'string' ? detail.nextMessageCursor : null,
1508
+ conversationRevision:
1509
+ typeof detail?.conversationRevision === 'string' ? detail.conversationRevision : null,
1510
+ };
1511
+ }
1512
+
1513
+ /**
1514
+ * Finalize browser-derived visitor-visible projections for the ACTIVE
1515
+ * conversation. Transport op, not a history read: current session + browser
1516
+ * scope, never an identity proof.
1517
+ */
1518
+ public async finalizeDisplayProjections(
1519
+ conversationId: string,
1520
+ messages: HistoryDisplayProjection[]
1521
+ ): Promise<{ conversationRevision: string | null }> {
1522
+ this.assertHistoryUsable();
1523
+ let total = 0;
1524
+ for (const message of messages) {
1525
+ const size = message.displayContent.length;
1526
+ if (size > DISPLAY_PROJECTION_MESSAGE_CAP) {
1527
+ throw new HistoryClientError(
1528
+ 'payload_too_large',
1529
+ `displayContent exceeds the ${DISPLAY_PROJECTION_MESSAGE_CAP} character limit`
1530
+ );
1531
+ }
1532
+ total += size;
1533
+ }
1534
+ if (total > DISPLAY_PROJECTION_BATCH_CAP) {
1535
+ throw new HistoryClientError(
1536
+ 'payload_too_large',
1537
+ `Display projection batch exceeds the ${DISPLAY_PROJECTION_BATCH_CAP} character limit`
1538
+ );
1539
+ }
1540
+
1541
+ await this.initSession();
1542
+ const store = this.historyInternals.visitorStore;
1543
+ const capturedRevision = store?.revision() ?? 0;
1544
+ const result = await this.historyFetch<{ conversationRevision?: string }>(
1545
+ `conversations/${encodeURIComponent(conversationId)}/display-projections`,
1546
+ {
1547
+ method: 'PATCH',
1548
+ body: { messages },
1549
+ scope: 'browser',
1550
+ keepalive: total <= DISPLAY_PROJECTION_BATCH_CAP,
1551
+ trackIdentity: false,
1552
+ }
1553
+ );
1554
+ const conversationRevision =
1555
+ typeof result?.conversationRevision === 'string' ? result.conversationRevision : null;
1556
+ // Install only while the same record and the same credential are still live.
1557
+ if (
1558
+ conversationRevision &&
1559
+ this.clientSession?.conversationId === conversationId &&
1560
+ (store?.revision() ?? 0) === capturedRevision
1561
+ ) {
1562
+ this.historyInternals.setStoredConversationRevision?.(conversationRevision);
1563
+ }
1564
+ return { conversationRevision };
1565
+ }
1566
+
1567
+ public async deleteConversation(
1568
+ conversationId: string,
1569
+ opts?: { scope?: HistoryScope }
1570
+ ): Promise<{ deleted: number }> {
1571
+ const result = await this.historyFetch<{ deleted?: number }>(
1572
+ `conversations/${encodeURIComponent(conversationId)}`,
1573
+ {
1574
+ method: 'DELETE',
1575
+ ...(opts?.scope ? { scope: opts.scope } : {}),
1576
+ }
1577
+ );
1578
+ return { deleted: typeof result?.deleted === 'number' ? result.deleted : 0 };
1579
+ }
1580
+
1581
+ /**
1582
+ * Delete every conversation matching `targetId`. An omitted filter means the
1583
+ * whole authorized visitor/client-token scope: headless callers own that
1584
+ * choice, the UI always passes the active `ClientSession.targetId`.
1585
+ */
1586
+ public async deleteAllConversations(opts?: {
1587
+ targetId?: string;
1588
+ scope?: HistoryScope;
1589
+ }): Promise<{ deleted: number }> {
1590
+ const result = await this.historyFetch<{ deleted?: number }>('conversations', {
1591
+ method: 'DELETE',
1592
+ query: {
1593
+ ...(opts?.targetId !== undefined ? { targetId: opts.targetId } : {}),
1594
+ },
1595
+ ...(opts?.scope ? { scope: opts.scope } : {}),
1596
+ });
1597
+ return { deleted: typeof result?.deleted === 'number' ? result.deleted : 0 };
1598
+ }
1599
+
1600
+ /**
1601
+ * Revoke this browser's visitor credential. Always browser scope (the route
1602
+ * accepts no proof) and the local clear is unconditional: a failed remote
1603
+ * revocation still detaches this device.
1604
+ */
1605
+ public async resetVisitor(): Promise<{ reset: true }> {
1606
+ this.assertHistoryUsable();
1607
+ this.setHistoryIdentityStatus({ state: 'resetting' });
1608
+ try {
1609
+ await this.historyFetch<{ reset?: boolean }>('visitor/reset', {
1610
+ method: 'POST',
1611
+ scope: 'browser',
1612
+ visitorTokenOptional: true,
1613
+ });
1614
+ } finally {
1615
+ await this.historyInternals.visitorStore?.clear();
1616
+ // Post-reset resting state: this browser is a never-bound visitor again.
1617
+ this.setHistoryIdentityStatus(this.derivedBrowserOnly());
1618
+ }
1619
+ return { reset: true };
1620
+ }
1621
+
628
1622
  /**
629
1623
  * Send a message - handles both proxy and client token modes
630
1624
  */
@@ -671,15 +1665,24 @@ export class AgentWidgetClient {
671
1665
  : undefined;
672
1666
 
673
1667
  // Common (tools-independent) fields for the chat request.
1668
+ const historyCapable = this.isHistoryCapable();
674
1669
  const baseChatRequest: Omit<ClientChatRequest, 'clientTools' | 'clientToolsFingerprint'> = {
675
1670
  sessionId: session.sessionId,
676
1671
  // Filter out messages with empty content to prevent validation errors
677
- messages: options.messages.filter(hasValidContent).map(m => ({
678
- id: m.id, // Include message ID for tracking
679
- role: m.role,
680
- // Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
681
- content: m.contentParts ?? m.llmContent ?? m.rawContent ?? m.content,
682
- })),
1672
+ messages: options.messages.filter(hasValidContent).map(m => {
1673
+ // The visitor-visible projection rides along only where it diverges
1674
+ // from the model channel, and only on the history-capable plane.
1675
+ const displayContent = historyCapable
1676
+ ? divergentDisplayProjection(m)
1677
+ : undefined;
1678
+ return {
1679
+ id: m.id, // Include message ID for tracking
1680
+ role: m.role,
1681
+ // Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
1682
+ content: m.contentParts ?? m.llmContent ?? m.rawContent ?? m.content,
1683
+ ...(displayContent !== undefined && { displayContent }),
1684
+ };
1685
+ }),
683
1686
  // Include pre-generated assistant message ID if provided
684
1687
  ...(options.assistantMessageId && { assistantMessageId: options.assistantMessageId }),
685
1688
  // Include metadata/context from middleware if present (excluding sessionId)
@@ -731,6 +1734,18 @@ export class AgentWidgetClient {
731
1734
  throw error;
732
1735
  }
733
1736
 
1737
+ // The active record was deleted elsewhere. No client-level retry: the
1738
+ // old payload would recreate the transcript in a fresh record, so
1739
+ // WidgetSession owns recovery.
1740
+ if (response.status === 410 && data.error === 'conversation_deleted') {
1741
+ const error = new HistoryClientError(
1742
+ 'conversation_deleted',
1743
+ 'This conversation was deleted'
1744
+ );
1745
+ onEvent({ type: "error", error });
1746
+ throw error;
1747
+ }
1748
+
734
1749
  const error = new Error(data.error || 'Failed to send message');
735
1750
  onEvent({ type: "error", error });
736
1751
  throw error;
@@ -758,7 +1773,11 @@ export class AgentWidgetClient {
758
1773
  } catch (error) {
759
1774
  const err = error instanceof Error ? error : new Error(String(error));
760
1775
  // Only emit error if it wasn't already emitted
761
- if (!err.message.includes('Session expired') && !err.message.includes('Message limit')) {
1776
+ if (
1777
+ !(err instanceof HistoryClientError) &&
1778
+ !err.message.includes('Session expired') &&
1779
+ !err.message.includes('Message limit')
1780
+ ) {
762
1781
  onEvent({ type: "error", error: err });
763
1782
  }
764
1783
  throw err;
@@ -934,10 +1953,7 @@ export class AgentWidgetClient {
934
1953
  approval: { agentId: string; executionId: string; approvalId: string },
935
1954
  decision: 'approved' | 'denied'
936
1955
  ): Promise<Response> {
937
- const baseUrl = this.config.apiUrl
938
- ?.replace(/\/+$/, '')
939
- .replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
940
- const url = `${baseUrl}/v1/agents/${approval.agentId}/approve`;
1956
+ const url = `${this.clientApiBase()}/v1/agents/${approval.agentId}/approve`;
941
1957
 
942
1958
  let headers: Record<string, string> = {
943
1959
  'Content-Type': 'application/json',