@runtypelabs/persona 4.17.1 → 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 (158) hide show
  1. package/dist/chunk-3I2S3I5P.js +1 -0
  2. package/dist/chunk-76IELU7F.js +1 -0
  3. package/dist/chunk-EFJBUH5F.js +1 -0
  4. package/dist/chunk-PDPVORUF.js +1 -0
  5. package/dist/chunk-Q735PSZL.js +1 -0
  6. package/dist/chunk-VK6JOIYJ.js +1 -0
  7. package/dist/codegen.cjs +4 -4
  8. package/dist/codegen.js +4 -4
  9. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  10. package/dist/context-mentions.cjs +9 -8
  11. package/dist/context-mentions.d.cts +1212 -14
  12. package/dist/context-mentions.d.ts +1212 -14
  13. package/dist/context-mentions.js +9 -8
  14. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  15. package/dist/event-stream-view.cjs +129 -0
  16. package/dist/event-stream-view.d.cts +8384 -0
  17. package/dist/event-stream-view.d.ts +8384 -0
  18. package/dist/event-stream-view.js +129 -0
  19. package/dist/history-view-BD6OXLNA.js +898 -0
  20. package/dist/history-view.cjs +898 -0
  21. package/dist/history-view.d.cts +1461 -0
  22. package/dist/history-view.d.ts +1461 -0
  23. package/dist/history-view.js +898 -0
  24. package/dist/index.cjs +77 -77
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +1987 -17
  27. package/dist/index.d.ts +1987 -17
  28. package/dist/index.global.js +67 -66
  29. package/dist/index.global.js.map +1 -1
  30. package/dist/index.js +77 -77
  31. package/dist/index.js.map +1 -1
  32. package/dist/launcher.global.js +5 -4
  33. package/dist/launcher.global.js.map +1 -1
  34. package/dist/plugin-kit.cjs +1 -1
  35. package/dist/plugin-kit.d.cts +0 -8
  36. package/dist/plugin-kit.d.ts +0 -8
  37. package/dist/plugin-kit.js +1 -1
  38. package/dist/smart-dom-reader.d.cts +1212 -14
  39. package/dist/smart-dom-reader.d.ts +1212 -14
  40. package/dist/theme-editor-preview.cjs +970 -73
  41. package/dist/theme-editor-preview.d.cts +1331 -15
  42. package/dist/theme-editor-preview.d.ts +1331 -15
  43. package/dist/theme-editor-preview.js +81 -81
  44. package/dist/theme-editor.cjs +7 -7
  45. package/dist/theme-editor.d.cts +1215 -15
  46. package/dist/theme-editor.d.ts +1215 -15
  47. package/dist/theme-editor.js +9 -9
  48. package/dist/theme-reference.cjs +1 -1
  49. package/dist/theme-reference.d.cts +226 -7
  50. package/dist/theme-reference.d.ts +226 -7
  51. package/dist/theme-reference.js +1 -1
  52. package/dist/widget.css +1 -1
  53. package/package.json +16 -4
  54. package/src/client-history-rest.test.ts +1034 -0
  55. package/src/client-visitor-history.test.ts +596 -0
  56. package/src/client.ts +1040 -24
  57. package/src/components/artifact-inline.ts +8 -1
  58. package/src/components/artifact-pane.test.ts +278 -1
  59. package/src/components/artifact-pane.ts +135 -67
  60. package/src/components/artifact-preview.test.ts +14 -1
  61. package/src/components/artifact-preview.ts +9 -0
  62. package/src/components/header-builder.ts +3 -0
  63. package/src/components/header-layouts.test.ts +171 -7
  64. package/src/components/header-layouts.ts +83 -53
  65. package/src/components/header-parts.test.ts +154 -1
  66. package/src/components/header-parts.ts +266 -138
  67. package/src/components/history-confirm-dialog.ts +177 -0
  68. package/src/components/history-shell-copy.ts +99 -0
  69. package/src/components/history-view/announcer.ts +40 -0
  70. package/src/components/history-view/copy.ts +105 -0
  71. package/src/components/history-view/css.ts +1078 -0
  72. package/src/components/history-view/grouping.test.ts +142 -0
  73. package/src/components/history-view/grouping.ts +142 -0
  74. package/src/components/history-view/icons.ts +73 -0
  75. package/src/components/history-view/rows.ts +341 -0
  76. package/src/components/history-view/state.ts +309 -0
  77. package/src/components/history-view.test.ts +2660 -0
  78. package/src/components/history-view.ts +1891 -0
  79. package/src/components/launcher-surface.test.ts +133 -0
  80. package/src/components/launcher.test.ts +18 -0
  81. package/src/components/launcher.ts +7 -7
  82. package/src/components/panel.ts +17 -7
  83. package/src/components/welcome.ts +72 -2
  84. package/src/defaults.ts +36 -3
  85. package/src/event-stream-view-bundle.test.ts +69 -0
  86. package/src/event-stream-view-entry.ts +19 -0
  87. package/src/event-stream-view-loader.ts +31 -0
  88. package/src/event-stream-view.ts +19 -0
  89. package/src/generated/runtype-openapi-contract.ts +1 -0
  90. package/src/history-render.ts +325 -0
  91. package/src/history-view-bundle.test.ts +78 -0
  92. package/src/history-view-entry.ts +41 -0
  93. package/src/history-view-loader.test.ts +30 -0
  94. package/src/history-view-loader.ts +32 -0
  95. package/src/history-view.ts +28 -0
  96. package/src/index-core.ts +50 -2
  97. package/src/index-global.ts +55 -0
  98. package/src/internal/demo-history-provider.test.ts +402 -0
  99. package/src/internal/demo-history-provider.ts +689 -0
  100. package/src/internal/history-provider-registry.ts +25 -0
  101. package/src/internal/history-provider.ts +150 -0
  102. package/src/internal/runtype-history-provider.test.ts +313 -0
  103. package/src/internal/runtype-history-provider.ts +240 -0
  104. package/src/plugin-kit.test.ts +16 -0
  105. package/src/plugin-kit.ts +13 -5
  106. package/src/plugins/types.ts +177 -1
  107. package/src/session-history.test.ts +1397 -0
  108. package/src/session.ts +1200 -26
  109. package/src/styles/widget-styles.test.ts +133 -0
  110. package/src/styles/widget.css +227 -8
  111. package/src/theme-editor/preview-utils.test.ts +19 -0
  112. package/src/theme-editor/preview-utils.ts +15 -1
  113. package/src/theme-editor/preview.ts +7 -1
  114. package/src/theme-editor/role-mappings.ts +4 -1
  115. package/src/theme-editor/sections.test.ts +168 -1
  116. package/src/theme-editor/sections.ts +136 -7
  117. package/src/theme-editor/webmcp/summary.ts +16 -2
  118. package/src/theme-reference.ts +20 -6
  119. package/src/types/theme.ts +213 -7
  120. package/src/types.ts +927 -7
  121. package/src/ui.artifact-rail-resize.test.ts +235 -0
  122. package/src/ui.attachments-drop.test.ts +1 -0
  123. package/src/ui.header-update-stability.test.ts +2 -2
  124. package/src/ui.history-chunk.test.ts +72 -0
  125. package/src/ui.history-config-provider.test.ts +318 -0
  126. package/src/ui.history-controller.test.ts +711 -0
  127. package/src/ui.history-lifecycle.test.ts +456 -0
  128. package/src/ui.history-render-hooks.test.ts +904 -0
  129. package/src/ui.history-shell.test.ts +1887 -0
  130. package/src/ui.rail-overlay.test.ts +573 -0
  131. package/src/ui.rail-resize.test.ts +284 -0
  132. package/src/ui.rebuild-composer.test.ts +21 -0
  133. package/src/ui.render-welcome.test.ts +42 -0
  134. package/src/ui.shortcuts.test.ts +166 -0
  135. package/src/ui.ts +3071 -240
  136. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  137. package/src/utils/artifact-file.ts +15 -0
  138. package/src/utils/artifact-gate.test.ts +4 -0
  139. package/src/utils/artifact-gate.ts +13 -17
  140. package/src/utils/artifact-resize.test.ts +28 -0
  141. package/src/utils/artifact-resize.ts +9 -2
  142. package/src/utils/buttons.ts +188 -1
  143. package/src/utils/config-merge.test.ts +91 -0
  144. package/src/utils/dropdown.ts +10 -1
  145. package/src/utils/history-messages.test.ts +284 -0
  146. package/src/utils/history-messages.ts +284 -0
  147. package/src/utils/icons.ts +3 -0
  148. package/src/utils/shortcuts.test.ts +288 -0
  149. package/src/utils/shortcuts.ts +221 -0
  150. package/src/utils/theme.test.ts +501 -4
  151. package/src/utils/theme.ts +42 -3
  152. package/src/utils/tokens.ts +153 -15
  153. package/src/utils/tooltip-timing.ts +4 -0
  154. package/src/utils/tooltip.test.ts +249 -7
  155. package/src/utils/tooltip.ts +146 -4
  156. package/src/utils/visitor-store.test.ts +425 -0
  157. package/src/utils/visitor-store.ts +337 -0
  158. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,150 @@
1
+ /**
2
+ * History provider seam (`docs/visitor-history-implementation-plan.md` D9).
3
+ * Session/UI history code depends on this interface, never on
4
+ * `AgentWidgetClient`.
5
+ *
6
+ * The module stays under `internal/` but its contract is public: `index-core.ts`
7
+ * re-exports these declarations so integrators can implement
8
+ * `features.history.provider`. The provider REGISTRY beside it stays private.
9
+ */
10
+
11
+ import type {
12
+ AgentWidgetMessage,
13
+ HistoryConversationPatch,
14
+ HistoryConversationSummary,
15
+ HistoryIdentityStatus,
16
+ HistoryScope,
17
+ } from "../types";
18
+
19
+ // Row/patch shapes are declared in `types.ts` (one declaration, two importers).
20
+ export type { HistoryConversationPatch, HistoryConversationSummary };
21
+
22
+ /** Fixed for the lifetime of one opened history view/action chain. */
23
+ export interface HistoryOperationContext {
24
+ scope: HistoryScope;
25
+ }
26
+
27
+ /**
28
+ * An authorized-but-not-yet-applied conversation activation. The provider must
29
+ * not mutate the active chat until `commit()`.
30
+ */
31
+ export interface PreparedHistoryActivation {
32
+ conversationId: string;
33
+ conversationRevision: string;
34
+ /** Apply the already-authorized provider-specific transport/session binding. */
35
+ commit(): void | Promise<void>;
36
+ /** Idempotently abandon a stale/superseded activation without changing chat. */
37
+ discard(): void;
38
+ }
39
+
40
+ export interface HistoryListOptions {
41
+ cursor?: string;
42
+ limit?: number;
43
+ targetId?: string;
44
+ context: HistoryOperationContext;
45
+ }
46
+
47
+ export interface HistoryListResult {
48
+ items: HistoryConversationSummary[];
49
+ nextCursor: string | null;
50
+ }
51
+
52
+ export interface HistoryPageOptions {
53
+ cursor?: string;
54
+ /** Lets the provider synthesize stable timestamps across page boundaries. */
55
+ beforeCreatedAt?: string;
56
+ context: HistoryOperationContext;
57
+ }
58
+
59
+ export interface HistoryPageResult {
60
+ summary: HistoryConversationSummary;
61
+ /** Already mapped; the visitor-visible projection lives in `content`. */
62
+ messages: AgentWidgetMessage[];
63
+ conversationRevision: string;
64
+ /** Follow for the next older page. `null` at the start of the transcript. */
65
+ nextCursor: string | null;
66
+ }
67
+
68
+ export interface HistoryDeleteAllOptions {
69
+ /** Absent means the whole authorized scope, never the UI default. */
70
+ targetId?: string;
71
+ context: HistoryOperationContext;
72
+ }
73
+
74
+ /** The contract a `features.history.provider` implementation satisfies. */
75
+ export interface HistoryProvider {
76
+ readonly capabilities: {
77
+ scopes: readonly HistoryScope[];
78
+ };
79
+ getIdentityStatus(): HistoryIdentityStatus;
80
+ subscribeIdentityStatus(
81
+ callback: (status: HistoryIdentityStatus) => void
82
+ ): () => void;
83
+ list(opts: HistoryListOptions): Promise<HistoryListResult>;
84
+ getPage(id: string, opts: HistoryPageOptions): Promise<HistoryPageResult>;
85
+ prepareOpen(
86
+ id: string,
87
+ opts: { context: HistoryOperationContext }
88
+ ): Promise<PreparedHistoryActivation>;
89
+ prepareStartNew(opts: {
90
+ context: HistoryOperationContext;
91
+ }): Promise<PreparedHistoryActivation>;
92
+ delete(id: string, opts: { context: HistoryOperationContext }): Promise<void>;
93
+ deleteAll(opts: HistoryDeleteAllOptions): Promise<{ deleted: number }>;
94
+ /**
95
+ * Optional capability: visitor-scoped rename/star. Absence hides every
96
+ * update affordance (star glyphs, built-in title-menu actions). A user-set
97
+ * title pins: the provider must never let a later auto-generated title
98
+ * overwrite one set through this call.
99
+ */
100
+ update?(
101
+ id: string,
102
+ patch: HistoryConversationPatch,
103
+ opts: { context: HistoryOperationContext }
104
+ ): Promise<HistoryConversationSummary>;
105
+ /**
106
+ * Optional capability. Absence hides "forget this device". Resolves (never
107
+ * rejects) on remote failure; credentials are cleared in `finally` either way.
108
+ */
109
+ resetDevice?(): Promise<{ remoteRevocationConfirmed: boolean }>;
110
+ subscribeAvailability?(callback: (available: boolean) => void): () => void;
111
+ }
112
+
113
+ /**
114
+ * The one domain-error vocabulary for provider failures. HTTP strings and
115
+ * status codes never reach the view. Branch on `code`, never on message text.
116
+ */
117
+ export type HistoryProviderErrorCode =
118
+ | "not_found"
119
+ | "rate_limited"
120
+ | "authentication_failed"
121
+ | "authentication_required"
122
+ | "identity_provider_failed"
123
+ | "proof_not_admitted"
124
+ | "unsupported_scope"
125
+ | "unavailable";
126
+
127
+ export class HistoryProviderError extends Error {
128
+ public readonly code: HistoryProviderErrorCode;
129
+ /** Meaningful only for `rate_limited`. */
130
+ public readonly retryAfterSeconds?: number;
131
+
132
+ constructor(
133
+ code: HistoryProviderErrorCode,
134
+ message: string,
135
+ options?: { retryAfterSeconds?: number }
136
+ ) {
137
+ super(message);
138
+ this.name = "HistoryProviderError";
139
+ this.code = code;
140
+ if (options?.retryAfterSeconds !== undefined) {
141
+ this.retryAfterSeconds = options.retryAfterSeconds;
142
+ }
143
+ }
144
+ }
145
+
146
+ export function isHistoryProviderError(
147
+ value: unknown
148
+ ): value is HistoryProviderError {
149
+ return value instanceof HistoryProviderError;
150
+ }
@@ -0,0 +1,313 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { AgentWidgetClient, HistoryClientError } from '../client';
6
+ import { createVisitorStore, type VisitorStore } from '../utils/visitor-store';
7
+ import type { AgentWidgetConfig, ClientSession, WidgetHistoryInternals } from '../types';
8
+ import { HistoryProviderError, type HistoryOperationContext } from './history-provider';
9
+ import { createRuntypeHistoryProvider } from './runtype-history-provider';
10
+
11
+ const CLIENT_TOKEN = 'ct_provider_test';
12
+ const API_URL = 'https://api.runtype.com';
13
+ const BROWSER: HistoryOperationContext = { scope: 'browser' };
14
+
15
+ const futureIso = () => new Date(Date.now() + 5 * 60_000).toISOString();
16
+
17
+ const respond = (
18
+ status: number,
19
+ body: unknown,
20
+ headers: Record<string, string> = { 'X-History-Identity-Status': 'not_provided' }
21
+ ) => {
22
+ const lowered: Record<string, string> = {};
23
+ for (const [key, value] of Object.entries(headers)) lowered[key.toLowerCase()] = value;
24
+ return {
25
+ ok: status >= 200 && status < 300,
26
+ status,
27
+ headers: { get: (name: string) => lowered[name.toLowerCase()] ?? null },
28
+ json: async () => body,
29
+ };
30
+ };
31
+
32
+ const initOk = () =>
33
+ respond(200, {
34
+ sessionId: 'sess_1',
35
+ expiresAt: futureIso(),
36
+ flow: { id: 'flow_1', name: 'Flow', description: null },
37
+ conversationId: 'conv_1',
38
+ targetId: 'flow_1',
39
+ conversationRevision: 'rev_1',
40
+ config: { welcomeMessage: null, placeholder: '', theme: null },
41
+ });
42
+
43
+ describe('createRuntypeHistoryProvider', () => {
44
+ let store: VisitorStore;
45
+ let fetchMock: ReturnType<typeof vi.fn>;
46
+ let committed: ClientSession[];
47
+
48
+ const build = async (config: Partial<AgentWidgetConfig> = {}) => {
49
+ const internals: WidgetHistoryInternals = { visitorStore: store };
50
+ const client = new AgentWidgetClient(
51
+ {
52
+ clientToken: CLIENT_TOKEN,
53
+ apiUrl: API_URL,
54
+ features: { history: { enabled: true } },
55
+ ...config,
56
+ },
57
+ internals
58
+ );
59
+ const provider = createRuntypeHistoryProvider({
60
+ client,
61
+ getIdentityProofConfigured: () => Boolean(config.getIdentityProof),
62
+ onActivationCommitted: (session) => {
63
+ committed.push(session);
64
+ },
65
+ });
66
+ return { client, provider };
67
+ };
68
+
69
+ beforeEach(async () => {
70
+ window.localStorage.clear();
71
+ committed = [];
72
+ store = createVisitorStore(CLIENT_TOKEN, 'persona-', false);
73
+ await store.set('cvt_test');
74
+ fetchMock = vi.fn();
75
+ vi.stubGlobal('fetch', fetchMock);
76
+ });
77
+
78
+ afterEach(() => {
79
+ store.destroy();
80
+ vi.unstubAllGlobals();
81
+ vi.restoreAllMocks();
82
+ });
83
+
84
+ it('advertises verified-user only when an identity proof callback exists', async () => {
85
+ const browserOnly = await build();
86
+ expect(browserOnly.provider.capabilities.scopes).toEqual(['browser']);
87
+
88
+ const verified = await build({ getIdentityProof: () => 'rt_eu_token' });
89
+ expect(verified.provider.capabilities.scopes).toEqual([
90
+ 'browser',
91
+ 'verified-user',
92
+ ]);
93
+ });
94
+
95
+ it('fails locally with unsupported_scope instead of issuing a request', async () => {
96
+ const { provider } = await build();
97
+ fetchMock.mockImplementation(initOk);
98
+
99
+ await expect(
100
+ provider.list({ context: { scope: 'verified-user' } })
101
+ ).rejects.toMatchObject({ code: 'unsupported_scope' });
102
+ expect(fetchMock).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('passes targetId through on list and delete-all', async () => {
106
+ const { provider } = await build();
107
+ fetchMock.mockImplementation((url: string, init?: RequestInit) => {
108
+ if (url.includes('/v1/client/init')) return initOk();
109
+ if (init?.method === 'DELETE') return respond(200, { deleted: 3 });
110
+ return respond(200, { data: [], nextCursor: null });
111
+ });
112
+
113
+ await provider.list({ targetId: 'flow_1', limit: 10, context: BROWSER });
114
+ await provider.deleteAll({ targetId: 'flow_1', context: BROWSER });
115
+
116
+ const urls = fetchMock.mock.calls.map((call) => String(call[0]));
117
+ expect(urls.some((url) => url.includes('targetId=flow_1') && url.includes('limit=10'))).toBe(
118
+ true
119
+ );
120
+ const deleteCall = fetchMock.mock.calls.find(
121
+ (call) => (call[1] as RequestInit | undefined)?.method === 'DELETE'
122
+ );
123
+ expect(String(deleteCall?.[0])).toContain('targetId=flow_1');
124
+ });
125
+
126
+ it('maps wire messages with the display projection winning and dedupes by id', async () => {
127
+ const { provider } = await build();
128
+ fetchMock.mockImplementation((url: string) => {
129
+ if (url.includes('/v1/client/init')) return initOk();
130
+ return respond(200, {
131
+ conversation: {
132
+ id: 'conv_1',
133
+ title: 'Saved',
134
+ targetId: 'flow_1',
135
+ preview: 'Saved preview',
136
+ messageCount: 3,
137
+ createdAt: '2026-01-01T00:00:00.000Z',
138
+ updatedAt: '2026-01-01T00:02:00.000Z',
139
+ },
140
+ messages: [
141
+ {
142
+ id: 'm1',
143
+ role: 'user',
144
+ content: '{"intent":"order_status"}',
145
+ displayContent: 'Where is my order?',
146
+ displayAvailable: true,
147
+ timestamp: '2026-01-01T00:00:00.000Z',
148
+ },
149
+ {
150
+ id: 'm1',
151
+ role: 'user',
152
+ content: 'duplicate row from a cursor overlap',
153
+ displayContent: 'Where is my order?',
154
+ displayAvailable: true,
155
+ timestamp: '2026-01-01T00:00:00.000Z',
156
+ },
157
+ {
158
+ id: 'm2',
159
+ role: 'assistant',
160
+ displayAvailable: false,
161
+ timestamp: '2026-01-01T00:01:00.000Z',
162
+ },
163
+ ],
164
+ nextMessageCursor: 'cursor_older',
165
+ conversationRevision: 'rev_9',
166
+ });
167
+ });
168
+
169
+ const page = await provider.getPage('conv_1', { context: BROWSER });
170
+
171
+ expect(page.messages).toHaveLength(2);
172
+ expect(page.messages[0].content).toBe('Where is my order?');
173
+ expect(page.messages[0].llmContent).toBeUndefined();
174
+ expect(page.messages[1].content).toBe('');
175
+ expect(page.conversationRevision).toBe('rev_9');
176
+ expect(page.nextCursor).toBe('cursor_older');
177
+ expect(page.summary.targetId).toBe('flow_1');
178
+ });
179
+
180
+ it('forwards beforeCreatedAt so a prepended page stays earlier than the transcript', async () => {
181
+ const { provider } = await build();
182
+ fetchMock.mockImplementation((url: string) => {
183
+ if (url.includes('/v1/client/init')) return initOk();
184
+ return respond(200, {
185
+ conversation: { id: 'conv_1', title: 'Saved', targetId: null, messageCount: 1 },
186
+ // No timestamps: the mapper must synthesize them under the boundary.
187
+ messages: [{ id: 'old1', role: 'user', content: 'older', displayAvailable: true }],
188
+ nextMessageCursor: null,
189
+ conversationRevision: 'rev_2',
190
+ });
191
+ });
192
+
193
+ const page = await provider.getPage('conv_1', {
194
+ cursor: 'c1',
195
+ beforeCreatedAt: '2026-01-01T00:00:00.000Z',
196
+ context: BROWSER,
197
+ });
198
+
199
+ expect(new Date(page.messages[0].createdAt).getTime()).toBeLessThan(
200
+ new Date('2026-01-01T00:00:00.000Z').getTime()
201
+ );
202
+ });
203
+
204
+ it('commits a prepared open through the session binding, and discard changes nothing', async () => {
205
+ const { client, provider } = await build();
206
+ fetchMock.mockImplementation(initOk);
207
+
208
+ const prepared = await provider.prepareOpen('conv_1', { context: BROWSER });
209
+ expect(prepared.conversationId).toBe('conv_1');
210
+ expect(prepared.conversationRevision).toBe('rev_1');
211
+ expect(client.getClientSession()).toBeNull();
212
+ expect(committed).toHaveLength(0);
213
+
214
+ await prepared.commit();
215
+ expect(client.getClientSession()?.sessionId).toBe('sess_1');
216
+ expect(committed).toHaveLength(1);
217
+
218
+ // Settled once: a late discard cannot unwind a winning commit.
219
+ prepared.discard();
220
+ expect(client.getClientSession()?.sessionId).toBe('sess_1');
221
+
222
+ const superseded = await provider.prepareStartNew({ context: BROWSER });
223
+ superseded.discard();
224
+ await superseded.commit();
225
+ expect(committed).toHaveLength(1);
226
+ });
227
+
228
+ it('maps client errors onto the domain vocabulary without leaking HTTP text', async () => {
229
+ const { provider } = await build();
230
+ const cases: Array<[number, unknown, string, Record<string, string>]> = [
231
+ [404, { error: 'not_found' }, 'not_found', {}],
232
+ [429, { error: 'rate_limited' }, 'rate_limited', { 'Retry-After': '10' }],
233
+ [503, { error: 'identity_proof_not_admitted' }, 'proof_not_admitted', {}],
234
+ [500, { error: 'boom: upstream 500 at /v1/client/conversations' }, 'unavailable', {}],
235
+ ];
236
+
237
+ for (const [status, body, expected, headers] of cases) {
238
+ fetchMock.mockReset();
239
+ fetchMock.mockImplementation((url: string) => {
240
+ if (url.includes('/v1/client/init')) return initOk();
241
+ return respond(status, body, {
242
+ 'X-History-Identity-Status': 'not_provided',
243
+ ...headers,
244
+ });
245
+ });
246
+ const error = await provider
247
+ .list({ context: BROWSER })
248
+ .catch((caught: unknown) => caught);
249
+ expect(error).toBeInstanceOf(HistoryProviderError);
250
+ const domain = error as HistoryProviderError;
251
+ expect(domain.code).toBe(expected);
252
+ expect(domain.message).not.toMatch(/\d{3}/);
253
+ expect(domain.message.toLowerCase()).not.toContain('http');
254
+ expect(domain.message).not.toContain('/v1/');
255
+ if (expected === 'rate_limited') {
256
+ expect(domain.retryAfterSeconds).toBe(10);
257
+ }
258
+ }
259
+ });
260
+
261
+ it('resolves resetDevice with remoteRevocationConfirmed false instead of rejecting', async () => {
262
+ const { client, provider } = await build();
263
+ const clearSpy = vi.spyOn(store, 'clear');
264
+ fetchMock.mockImplementation((url: string) => {
265
+ if (url.includes('/v1/client/init')) return initOk();
266
+ return respond(500, { error: 'reset failed' });
267
+ });
268
+
269
+ await expect(provider.resetDevice?.()).resolves.toEqual({
270
+ remoteRevocationConfirmed: false,
271
+ });
272
+ // The client clears credentials in its own `finally` regardless.
273
+ expect(clearSpy).toHaveBeenCalled();
274
+ expect(client.getHistoryIdentityStatus().state).toBe('browser_only');
275
+ });
276
+
277
+ it('bridges identity status and availability subscriptions', async () => {
278
+ const { client, provider } = await build();
279
+ const statuses: string[] = [];
280
+ const availability: boolean[] = [];
281
+ const unsubscribe = provider.subscribeIdentityStatus((status) =>
282
+ statuses.push(status.state)
283
+ );
284
+ provider.subscribeAvailability?.((available) => availability.push(available));
285
+
286
+ fetchMock.mockImplementation((url: string) => {
287
+ if (url.includes('/v1/client/init')) {
288
+ return respond(403, { error: 'visitor_history_disabled' });
289
+ }
290
+ return respond(200, { data: [], nextCursor: null });
291
+ });
292
+
293
+ await provider.list({ context: BROWSER }).catch(() => undefined);
294
+ expect(availability).toEqual([false]);
295
+ expect(statuses).toContain('unavailable');
296
+ expect(provider.getIdentityStatus()).toEqual(
297
+ client.getHistoryIdentityStatus()
298
+ );
299
+
300
+ unsubscribe();
301
+ });
302
+
303
+ it('never lets a raw HistoryClientError escape the seam', async () => {
304
+ const { provider } = await build();
305
+ fetchMock.mockImplementation(() => respond(404, { error: 'not_found' }));
306
+
307
+ const error = await provider
308
+ .getPage('missing', { context: BROWSER })
309
+ .catch((caught: unknown) => caught);
310
+ expect(error).toBeInstanceOf(HistoryProviderError);
311
+ expect(error).not.toBeInstanceOf(HistoryClientError);
312
+ });
313
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Runtype `HistoryProvider`: the only production provider in v1. Wraps the
3
+ * Phase 1 `AgentWidgetClient` history methods behind the generic seam
4
+ * (`docs/visitor-history-implementation-plan.md` D9).
5
+ *
6
+ * Credential-shaped concerns (visitor store, proofs, 401 recovery, claim, 403
7
+ * degrade, remote reset) stay inside `AgentWidgetClient`; this file only
8
+ * adapts shapes and maps HTTP/client errors onto the one domain vocabulary.
9
+ * No HTTP string or status code escapes into the view.
10
+ *
11
+ * Not public and not in any tsup entry or package export.
12
+ */
13
+
14
+ import { HistoryClientError, type AgentWidgetClient } from "../client";
15
+ import type { ClientSession, HistoryScope } from "../types";
16
+ import {
17
+ mapWireMessages,
18
+ mergeWireMessagesById,
19
+ } from "../utils/history-messages";
20
+ import {
21
+ HistoryProviderError,
22
+ type HistoryConversationSummary,
23
+ type HistoryDeleteAllOptions,
24
+ type HistoryListOptions,
25
+ type HistoryListResult,
26
+ type HistoryPageOptions,
27
+ type HistoryPageResult,
28
+ type HistoryProvider,
29
+ type HistoryProviderErrorCode,
30
+ type PreparedHistoryActivation,
31
+ } from "./history-provider";
32
+
33
+ export interface RuntypeHistoryProviderOptions {
34
+ client: AgentWidgetClient;
35
+ /** Advertise `verified-user` only when the host supplies a proof callback. */
36
+ getIdentityProofConfigured: () => boolean;
37
+ /**
38
+ * Session-owned activation binding. Runs inside a winning `commit()`, after
39
+ * the prepared client session is installed and before the caller continues.
40
+ */
41
+ onActivationCommitted: (session: ClientSession) => void | Promise<void>;
42
+ /** Live client rebuilds hand the provider its replacement. */
43
+ getClient?: () => AgentWidgetClient;
44
+ }
45
+
46
+ /** Client error code -> domain code. Anything unmapped is `unavailable`. */
47
+ const ERROR_CODE_MAP = new Map<string, HistoryProviderErrorCode>([
48
+ ["not_found", "not_found"],
49
+ ["conversation_deleted", "not_found"],
50
+ ["rate_limited", "rate_limited"],
51
+ ["authentication_required", "authentication_required"],
52
+ ["invalid_identity_proof", "authentication_required"],
53
+ ["visitor_identity_mismatch", "authentication_required"],
54
+ ["visitor_required", "authentication_required"],
55
+ ["identity_provider_failed", "identity_provider_failed"],
56
+ ["proof_not_admitted", "proof_not_admitted"],
57
+ ["identity_contract_violation", "proof_not_admitted"],
58
+ ["unauthorized", "authentication_failed"],
59
+ ["visitor_token_missing", "authentication_failed"],
60
+ ["conversation_credential_missing", "authentication_failed"],
61
+ ]);
62
+
63
+ /** Secret-free, HTTP-free messages: the view branches on `code` anyway. */
64
+ const DOMAIN_MESSAGE = new Map<HistoryProviderErrorCode, string>([
65
+ ["not_found", "That conversation is no longer available."],
66
+ ["rate_limited", "Too many history requests."],
67
+ ["authentication_failed", "History access was not authorized."],
68
+ ["authentication_required", "Sign in again to view account history."],
69
+ ["identity_provider_failed", "The identity provider did not respond."],
70
+ [
71
+ "proof_not_admitted",
72
+ "Account history is not available for this configuration.",
73
+ ],
74
+ ["unsupported_scope", "This history scope is not supported."],
75
+ ["unavailable", "Conversation history is unavailable right now."],
76
+ ]);
77
+
78
+ const domainMessage = (code: HistoryProviderErrorCode): string =>
79
+ DOMAIN_MESSAGE.get(code) ?? "Conversation history is unavailable right now.";
80
+
81
+ export function toHistoryProviderError(error: unknown): HistoryProviderError {
82
+ if (error instanceof HistoryProviderError) return error;
83
+ if (error instanceof HistoryClientError) {
84
+ const code = ERROR_CODE_MAP.get(error.code) ?? "unavailable";
85
+ return new HistoryProviderError(code, domainMessage(code), {
86
+ ...(error.retryAfterSeconds !== undefined
87
+ ? { retryAfterSeconds: error.retryAfterSeconds }
88
+ : {}),
89
+ });
90
+ }
91
+ return new HistoryProviderError("unavailable", domainMessage("unavailable"));
92
+ }
93
+
94
+ export function createRuntypeHistoryProvider(
95
+ options: RuntypeHistoryProviderOptions
96
+ ): HistoryProvider {
97
+ const client = (): AgentWidgetClient =>
98
+ options.getClient?.() ?? options.client;
99
+
100
+ const scopes: readonly HistoryScope[] = options.getIdentityProofConfigured()
101
+ ? ["browser", "verified-user"]
102
+ : ["browser"];
103
+ const capabilities = { scopes };
104
+
105
+ const assertScope = (scope: HistoryScope): void => {
106
+ if (!scopes.includes(scope)) {
107
+ throw new HistoryProviderError(
108
+ "unsupported_scope",
109
+ domainMessage("unsupported_scope")
110
+ );
111
+ }
112
+ };
113
+
114
+ const run = async <T>(scope: HistoryScope, fn: () => Promise<T>): Promise<T> => {
115
+ assertScope(scope);
116
+ try {
117
+ return await fn();
118
+ } catch (error) {
119
+ throw toHistoryProviderError(error);
120
+ }
121
+ };
122
+
123
+ /** Commit installs the client session, then runs the session binding. */
124
+ const wrap = (
125
+ prepared: { session: ClientSession; commit(): void; discard(): void }
126
+ ): PreparedHistoryActivation => {
127
+ let settled = false;
128
+ return {
129
+ conversationId: prepared.session.conversationId ?? "",
130
+ conversationRevision: prepared.session.conversationRevision ?? "",
131
+ async commit() {
132
+ if (settled) return;
133
+ settled = true;
134
+ prepared.commit();
135
+ await options.onActivationCommitted(prepared.session);
136
+ },
137
+ discard() {
138
+ if (settled) return;
139
+ settled = true;
140
+ prepared.discard();
141
+ },
142
+ };
143
+ };
144
+
145
+ return {
146
+ capabilities,
147
+
148
+ getIdentityStatus() {
149
+ return client().getHistoryIdentityStatus();
150
+ },
151
+
152
+ subscribeIdentityStatus(callback) {
153
+ return client().subscribeHistoryIdentityStatus(callback);
154
+ },
155
+
156
+ subscribeAvailability(callback) {
157
+ return client().subscribeHistoryAvailability(callback);
158
+ },
159
+
160
+ async list(opts: HistoryListOptions): Promise<HistoryListResult> {
161
+ return run(opts.context.scope, async () => {
162
+ const page = await client().listConversations({
163
+ ...(opts.cursor ? { cursor: opts.cursor } : {}),
164
+ ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
165
+ ...(opts.targetId ? { targetId: opts.targetId } : {}),
166
+ scope: opts.context.scope,
167
+ });
168
+ return {
169
+ items: page.data as HistoryConversationSummary[],
170
+ nextCursor: page.nextCursor,
171
+ };
172
+ });
173
+ },
174
+
175
+ /**
176
+ * Newest page first; `beforeCreatedAt` is the transcript boundary the
177
+ * mapper needs to synthesize missing timestamps for a prepended page.
178
+ */
179
+ async getPage(id: string, opts: HistoryPageOptions): Promise<HistoryPageResult> {
180
+ return run(opts.context.scope, async () => {
181
+ const detail = await client().getConversation(id, {
182
+ ...(opts.cursor ? { messageCursor: opts.cursor } : {}),
183
+ scope: opts.context.scope,
184
+ });
185
+ const mapped = mapWireMessages(detail.messages, {
186
+ ...(opts.beforeCreatedAt
187
+ ? { beforeCreatedAt: opts.beforeCreatedAt }
188
+ : {}),
189
+ });
190
+ return {
191
+ summary: detail.summary as HistoryConversationSummary,
192
+ // Cursor overlap can repeat rows: dedupe by id before returning.
193
+ messages: mergeWireMessagesById([], mapped),
194
+ conversationRevision: detail.conversationRevision ?? "",
195
+ nextCursor: detail.nextMessageCursor,
196
+ };
197
+ });
198
+ },
199
+
200
+ async prepareOpen(id, opts) {
201
+ return run(opts.context.scope, async () =>
202
+ wrap(await client().prepareConversationSession(id))
203
+ );
204
+ },
205
+
206
+ async prepareStartNew(opts) {
207
+ return run(opts.context.scope, async () =>
208
+ wrap(await client().prepareNewConversationSession())
209
+ );
210
+ },
211
+
212
+ async delete(id, opts) {
213
+ await run(opts.context.scope, () =>
214
+ client().deleteConversation(id, { scope: opts.context.scope })
215
+ );
216
+ },
217
+
218
+ async deleteAll(opts: HistoryDeleteAllOptions) {
219
+ return run(opts.context.scope, () =>
220
+ client().deleteAllConversations({
221
+ ...(opts.targetId !== undefined ? { targetId: opts.targetId } : {}),
222
+ scope: opts.context.scope,
223
+ })
224
+ );
225
+ },
226
+
227
+ /**
228
+ * Never rejects on remote failure: the client clears credentials in its own
229
+ * `finally`, so an unconfirmed revocation still detaches this browser.
230
+ */
231
+ async resetDevice() {
232
+ try {
233
+ await client().resetVisitor();
234
+ return { remoteRevocationConfirmed: true };
235
+ } catch {
236
+ return { remoteRevocationConfirmed: false };
237
+ }
238
+ },
239
+ };
240
+ }