@runtypelabs/persona 4.17.0 → 4.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
- package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1224 -14
- package/dist/context-mentions.d.ts +1224 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2040 -21
- package/dist/index.d.ts +2040 -21
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1224 -14
- package/dist/smart-dom-reader.d.ts +1224 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1343 -15
- package/dist/theme-editor-preview.d.ts +1343 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1227 -15
- package/dist/theme-editor.d.ts +1227 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +18 -6
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +54 -4
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- package/dist/chunk-QJ4LV2KW.js +0 -1
package/src/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(
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
...(
|
|
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
|
-
//
|
|
422
|
-
if (
|
|
423
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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 (
|
|
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
|
|
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',
|