@runtypelabs/persona 4.17.0 → 4.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
package/src/index-core.ts CHANGED
@@ -32,6 +32,7 @@ export type {
32
32
  AgentWidgetMessage,
33
33
  AgentWidgetLauncherConfig,
34
34
  AgentWidgetLauncherTeaserConfig,
35
+ AgentWidgetTooltipConfig,
35
36
  AgentWidgetDockConfig,
36
37
  AgentWidgetEvent,
37
38
  AgentWidgetStreamParser,
@@ -93,6 +94,7 @@ export type {
93
94
  // Client token types
94
95
  ClientSession,
95
96
  ClientInitResponse,
97
+ PreparedClientSession,
96
98
  ClientChatRequest,
97
99
  ClientFeedbackRequest,
98
100
  ClientFeedbackType,
@@ -142,6 +144,30 @@ export type {
142
144
  AskUserQuestionOption,
143
145
  AgentWidgetAskUserQuestionFeature,
144
146
  AgentWidgetAskUserQuestionStyles,
147
+ // Visitor conversation history (client-token mode) types
148
+ AgentWidgetHistoryFeature,
149
+ AgentWidgetHistoryListAction,
150
+ AgentWidgetHistoryCopy,
151
+ HistoryScope,
152
+ HistoryIdentityStatus,
153
+ ResolvedHistoryPresentation,
154
+ HistoryReturnSurface,
155
+ HistoryConversationSummary,
156
+ HistoryConversationPatch,
157
+ HistoryConversationPage,
158
+ HistoryConversationDetail,
159
+ HistoryDisplayProjection,
160
+ // History rendering customization (plugin render hooks; types only)
161
+ ResolvedHistoryCopy,
162
+ HistoryViewState,
163
+ HistoryPendingAction,
164
+ AgentWidgetHistoryRenderActions,
165
+ AgentWidgetRenderHistoryViewContext,
166
+ AgentWidgetRenderHistoryHeaderContext,
167
+ AgentWidgetRenderHistoryConversationContext,
168
+ AgentWidgetRenderHistoryStateContext,
169
+ AgentWidgetRenderHistoryOpenErrorContext,
170
+ ClientVisitorGrant,
145
171
  AgentWidgetSuggestion,
146
172
  AgentWidgetSuggestionChipsConfig,
147
173
  AgentWidgetResolvedSuggestion,
@@ -159,6 +185,26 @@ export type {
159
185
  AgentWidgetWelcomeDismiss
160
186
  } from "./types";
161
187
 
188
+ // Custom conversation-history backends (`features.history.provider`). The
189
+ // provider registry beside this module stays private.
190
+ export type {
191
+ HistoryProvider,
192
+ HistoryOperationContext,
193
+ PreparedHistoryActivation,
194
+ HistoryListOptions,
195
+ HistoryListResult,
196
+ HistoryPageOptions,
197
+ HistoryPageResult,
198
+ HistoryDeleteAllOptions,
199
+ HistoryProviderErrorCode,
200
+ } from "./internal/history-provider";
201
+
202
+ // Value export: custom providers construct and `instanceof` this.
203
+ export {
204
+ HistoryProviderError,
205
+ isHistoryProviderError,
206
+ } from "./internal/history-provider";
207
+
162
208
  export {
163
209
  applyFeaturePreferences,
164
210
  parseWidgetPreferenceSlice,
@@ -347,7 +393,9 @@ export type {
347
393
  AgentWidgetTransformSuggestionsContext,
348
394
  AgentWidgetRenderSuggestionContext,
349
395
  AgentWidgetRenderWelcomeContext,
350
- AgentWidgetSuggestionSelectContext
396
+ AgentWidgetSuggestionSelectContext,
397
+ AgentWidgetRailSection,
398
+ AgentWidgetRailSectionContext
351
399
  } from "./plugins/types";
352
400
  // Welcome resolver: `renderWelcome` receives the resolved shape.
353
401
  export { resolveWelcomeConfig, isWelcomeVisible } from "./welcome";
@@ -398,7 +446,7 @@ export { renderLucideIcon } from "./utils/icons";
398
446
  export type { IconName } from "./utils/icons";
399
447
 
400
448
  // Button utility exports
401
- export { createIconButton, createLabelButton, createToggleGroup, createComboButton } from "./utils/buttons";
449
+ export { createIconButton, createLabelButton, createToggleGroup, createComboButton, createSplitButton } from "./utils/buttons";
402
450
  export type {
403
451
  CreateIconButtonOptions,
404
452
  CreateLabelButtonOptions,
@@ -188,3 +188,58 @@ setContextMentionsInlineLoader(() => {
188
188
  }
189
189
  return import(/* @vite-ignore */ chunkUrl);
190
190
  });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Deferred history-view loading.
194
+ //
195
+ // This bundle is built with `@runtypelabs/persona/history-view` external (see
196
+ // `tsup.global.config.ts`): the Messages navigation surface is kept out of the
197
+ // CDN payload and fetched only when the visitor first opens history. Same
198
+ // sibling-URL scheme as the chunks above.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ import { setHistoryViewLoader } from "./history-view-loader";
202
+
203
+ setHistoryViewLoader(() => {
204
+ const chunkUrl = widgetScriptSrc?.replace(
205
+ /index\.global\.js($|\?)/,
206
+ "history-view.js$1",
207
+ );
208
+ if (!chunkUrl || chunkUrl === widgetScriptSrc) {
209
+ return Promise.reject(
210
+ new Error(
211
+ "Could not derive the history-view.js URL from the widget script URL " +
212
+ `(${widgetScriptSrc ?? "unavailable"}). Self-hosted deployments that ` +
213
+ "rename index.global.js should host history-view.js alongside it.",
214
+ ),
215
+ );
216
+ }
217
+ return import(/* @vite-ignore */ chunkUrl);
218
+ });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Deferred event-stream-view loading.
222
+ //
223
+ // Same scheme as history-view: `@runtypelabs/persona/event-stream-view` is
224
+ // external here (see `tsup.global.config.ts`), and the observability panel is
225
+ // fetched from a sibling URL only when the visitor first opens it.
226
+ // ---------------------------------------------------------------------------
227
+
228
+ import { setEventStreamViewLoader } from "./event-stream-view-loader";
229
+
230
+ setEventStreamViewLoader(() => {
231
+ const chunkUrl = widgetScriptSrc?.replace(
232
+ /index\.global\.js($|\?)/,
233
+ "event-stream-view.js$1",
234
+ );
235
+ if (!chunkUrl || chunkUrl === widgetScriptSrc) {
236
+ return Promise.reject(
237
+ new Error(
238
+ "Could not derive the event-stream-view.js URL from the widget script URL " +
239
+ `(${widgetScriptSrc ?? "unavailable"}). Self-hosted deployments that ` +
240
+ "rename index.global.js should host event-stream-view.js alongside it.",
241
+ ),
242
+ );
243
+ }
244
+ return import(/* @vite-ignore */ chunkUrl);
245
+ });
@@ -0,0 +1,402 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createDemoHistoryProvider,
4
+ type DemoHistoryConversationSeed,
5
+ type DemoHistoryProvider,
6
+ } from "./demo-history-provider";
7
+ import {
8
+ HistoryProviderError,
9
+ isHistoryProviderError,
10
+ type HistoryOperationContext,
11
+ } from "./history-provider";
12
+ import { isHistoryDisplayUnavailable } from "../utils/history-messages";
13
+
14
+ const FIXED_NOW = Date.parse("2026-08-10T12:00:00.000Z");
15
+ const now = () => FIXED_NOW;
16
+ const browser: HistoryOperationContext = { scope: "browser" };
17
+ const verified: HistoryOperationContext = { scope: "verified-user" };
18
+
19
+ function provider(
20
+ options: Parameters<typeof createDemoHistoryProvider>[0] = {}
21
+ ): DemoHistoryProvider {
22
+ return createDemoHistoryProvider({ now, ...options });
23
+ }
24
+
25
+ function seedWith(messageCount: number): DemoHistoryConversationSeed[] {
26
+ return [
27
+ {
28
+ id: "c1",
29
+ title: "Paging",
30
+ targetId: "t1",
31
+ messages: Array.from({ length: messageCount }, (_, index) => ({
32
+ role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
33
+ content: `m${index + 1}`,
34
+ createdAt: new Date(FIXED_NOW - (messageCount - index) * 1000).toISOString(),
35
+ })),
36
+ },
37
+ ];
38
+ }
39
+
40
+ afterEach(() => {
41
+ vi.useRealTimers();
42
+ });
43
+
44
+ describe("capabilities", () => {
45
+ it("advertises browser scope only and omits the optional capabilities", () => {
46
+ const demo = provider();
47
+ expect(demo.capabilities.scopes).toEqual(["browser"]);
48
+ expect(demo.resetDevice).toBeUndefined();
49
+ expect(demo.subscribeAvailability).toBeUndefined();
50
+ });
51
+
52
+ it("rejects an unsupported scope locally", async () => {
53
+ const demo = provider();
54
+ await expect(demo.list({ context: verified })).rejects.toMatchObject({
55
+ code: "unsupported_scope",
56
+ });
57
+ });
58
+ });
59
+
60
+ describe("transcript paging", () => {
61
+ it("returns 25 / 25 / rest newest-page-first with oldest-first pages", async () => {
62
+ const demo = provider({ conversations: seedWith(52) });
63
+
64
+ const first = await demo.getPage("c1", { context: browser });
65
+ expect(first.messages).toHaveLength(25);
66
+ expect(first.messages[0].content).toBe("m28");
67
+ expect(first.messages[24].content).toBe("m52");
68
+ expect(first.nextCursor).not.toBeNull();
69
+ expect(first.summary.messageCount).toBe(52);
70
+ expect(first.conversationRevision).toBe(demo.getConversationRevision("c1"));
71
+
72
+ const second = await demo.getPage("c1", {
73
+ context: browser,
74
+ cursor: first.nextCursor as string,
75
+ });
76
+ expect(second.messages).toHaveLength(25);
77
+ expect(second.messages[0].content).toBe("m3");
78
+ expect(second.messages[24].content).toBe("m27");
79
+ expect(second.nextCursor).not.toBeNull();
80
+
81
+ const third = await demo.getPage("c1", {
82
+ context: browser,
83
+ cursor: second.nextCursor as string,
84
+ });
85
+ expect(third.messages.map((m) => m.content)).toEqual(["m1", "m2"]);
86
+ expect(third.nextCursor).toBeNull();
87
+ });
88
+
89
+ it("keeps a single page when the transcript fits and is stable across calls", async () => {
90
+ const demo = provider({ conversations: seedWith(7) });
91
+ const first = await demo.getPage("c1", { context: browser });
92
+ const again = await demo.getPage("c1", { context: browser });
93
+ expect(first.nextCursor).toBeNull();
94
+ expect(first.messages.map((m) => m.id)).toEqual(again.messages.map((m) => m.id));
95
+ expect(first.messages.map((m) => m.content)).toEqual([
96
+ "m1",
97
+ "m2",
98
+ "m3",
99
+ "m4",
100
+ "m5",
101
+ "m6",
102
+ "m7",
103
+ ]);
104
+ });
105
+
106
+ it("rejects an unknown conversation with not_found", async () => {
107
+ const demo = provider();
108
+ const error = await demo
109
+ .getPage("nope", { context: browser })
110
+ .catch((err: unknown) => err);
111
+ expect(isHistoryProviderError(error)).toBe(true);
112
+ expect((error as HistoryProviderError).code).toBe("not_found");
113
+ });
114
+
115
+ it("returns already-mapped messages including parts and the unavailable marker", async () => {
116
+ const demo = provider();
117
+ const refund = await demo.getPage("demo-conv-damaged-mug", { context: browser });
118
+ const oldest = await demo.getPage("demo-conv-damaged-mug", {
119
+ context: browser,
120
+ cursor: refund.nextCursor as string,
121
+ });
122
+ expect(oldest.messages[0].contentParts?.[1]?.type).toBe("image");
123
+ expect(oldest.messages[1].contentParts?.[1]?.type).toBe("file");
124
+
125
+ const bulk = await demo.getPage("demo-conv-bulk-order", { context: browser });
126
+ const withheld = bulk.messages.filter(isHistoryDisplayUnavailable);
127
+ expect(withheld).toHaveLength(1);
128
+ expect(withheld[0].content).toBe("");
129
+ });
130
+ });
131
+
132
+ describe("list paging", () => {
133
+ it("chains cursors in updatedAt-descending order", async () => {
134
+ const demo = provider();
135
+ const all = demo.getConversationIds();
136
+
137
+ const first = await demo.list({ context: browser, limit: 2 });
138
+ expect(first.items.map((item) => item.id)).toEqual(all.slice(0, 2));
139
+ expect(first.nextCursor).not.toBeNull();
140
+
141
+ const second = await demo.list({
142
+ context: browser,
143
+ limit: 2,
144
+ cursor: first.nextCursor as string,
145
+ });
146
+ expect(second.items.map((item) => item.id)).toEqual(all.slice(2, 4));
147
+
148
+ const third = await demo.list({
149
+ context: browser,
150
+ limit: 2,
151
+ cursor: second.nextCursor as string,
152
+ });
153
+ expect(third.items.map((item) => item.id)).toEqual(all.slice(4));
154
+ expect(third.nextCursor).toBeNull();
155
+ });
156
+
157
+ it("filters by targetId and carries previews plus message counts", async () => {
158
+ const demo = provider();
159
+ const filtered = await demo.list({ context: browser, targetId: "demo-support-agent" });
160
+ expect(filtered.items.map((item) => item.id)).toEqual(["demo-conv-gift-wrap"]);
161
+ expect(filtered.items[0].messageCount).toBe(2);
162
+ expect(filtered.items[0].preview).toBeTruthy();
163
+ expect(filtered.items[0].preview?.length).toBeLessThanOrEqual(140);
164
+ });
165
+ });
166
+
167
+ describe("delete semantics", () => {
168
+ it("delete removes exactly one conversation", async () => {
169
+ const demo = provider();
170
+ const before = demo.getConversationIds();
171
+ await demo.delete("demo-conv-bulk-order", { context: browser });
172
+ expect(demo.getConversationIds()).toEqual(
173
+ before.filter((id) => id !== "demo-conv-bulk-order")
174
+ );
175
+ await expect(
176
+ demo.delete("demo-conv-bulk-order", { context: browser })
177
+ ).rejects.toMatchObject({ code: "not_found" });
178
+ });
179
+
180
+ it("deleteAll respects the targetId filter", async () => {
181
+ const demo = provider();
182
+ const result = await demo.deleteAll({ context: browser, targetId: "demo-flow" });
183
+ expect(result.deleted).toBe(4);
184
+ expect(demo.getConversationIds()).toEqual(["demo-conv-gift-wrap"]);
185
+ });
186
+
187
+ it("deleteAll without a targetId clears the whole scope", async () => {
188
+ const demo = provider();
189
+ const result = await demo.deleteAll({ context: browser });
190
+ expect(result.deleted).toBe(5);
191
+ expect(demo.getConversationIds()).toEqual([]);
192
+ });
193
+
194
+ it("deleting the active conversation clears the active id", async () => {
195
+ const demo = provider();
196
+ const prepared = await demo.prepareOpen("demo-conv-order-status", { context: browser });
197
+ await prepared.commit();
198
+ await demo.delete("demo-conv-order-status", { context: browser });
199
+ expect(demo.getActiveConversationId()).toBeNull();
200
+ });
201
+ });
202
+
203
+ describe("prepared activations", () => {
204
+ it("prepareOpen changes nothing until commit", async () => {
205
+ const demo = provider();
206
+ const prepared = await demo.prepareOpen("demo-conv-bulk-order", { context: browser });
207
+ expect(prepared.conversationId).toBe("demo-conv-bulk-order");
208
+ expect(prepared.conversationRevision).toBe(
209
+ demo.getConversationRevision("demo-conv-bulk-order")
210
+ );
211
+ expect(demo.getActiveConversationId()).toBeNull();
212
+
213
+ await prepared.commit();
214
+ expect(demo.getActiveConversationId()).toBe("demo-conv-bulk-order");
215
+ });
216
+
217
+ it("discard is inert and wins over a later commit", async () => {
218
+ const demo = provider();
219
+ const first = await demo.prepareOpen("demo-conv-bulk-order", { context: browser });
220
+ const second = await demo.prepareOpen("demo-conv-gift-wrap", { context: browser });
221
+
222
+ first.discard();
223
+ first.discard();
224
+ expect(demo.getActiveConversationId()).toBeNull();
225
+
226
+ await first.commit();
227
+ expect(demo.getActiveConversationId()).toBeNull();
228
+
229
+ await second.commit();
230
+ await second.commit();
231
+ expect(demo.getActiveConversationId()).toBe("demo-conv-gift-wrap");
232
+ });
233
+
234
+ it("prepareStartNew creates the conversation only on commit", async () => {
235
+ const demo = provider();
236
+ const discarded = await demo.prepareStartNew({ context: browser });
237
+ discarded.discard();
238
+ await discarded.commit();
239
+ expect(demo.getConversationIds()).toHaveLength(5);
240
+ expect(demo.getActiveConversationId()).toBeNull();
241
+
242
+ const prepared = await demo.prepareStartNew({ context: browser });
243
+ await prepared.commit();
244
+ expect(demo.getConversationIds()).toHaveLength(6);
245
+ expect(demo.getActiveConversationId()).toBe(prepared.conversationId);
246
+ // Empty conversations are never listed, matching the API.
247
+ const listed = await demo.list({ context: browser });
248
+ expect(listed.items.map((item) => item.id)).not.toContain(prepared.conversationId);
249
+ });
250
+ });
251
+
252
+ describe("revisions", () => {
253
+ it("advances on every mutation and refreshes the summary", async () => {
254
+ const demo = provider();
255
+ const before = demo.getConversationRevision("demo-conv-order-status");
256
+ demo.appendMessage("demo-conv-order-status", {
257
+ role: "assistant",
258
+ content: "One more thing about your delivery window.",
259
+ });
260
+ const after = demo.getConversationRevision("demo-conv-order-status");
261
+ expect(after).not.toBe(before);
262
+
263
+ demo.appendMessage("demo-conv-order-status", {
264
+ role: "user",
265
+ content: "Understood, thanks.",
266
+ });
267
+ expect(demo.getConversationRevision("demo-conv-order-status")).not.toBe(after);
268
+
269
+ const page = await demo.getPage("demo-conv-order-status", { context: browser });
270
+ expect(page.summary.messageCount).toBe(6);
271
+ expect(page.summary.preview).toBe("Understood, thanks.");
272
+ expect(page.conversationRevision).toBe(
273
+ demo.getConversationRevision("demo-conv-order-status")
274
+ );
275
+ });
276
+
277
+ it("mints distinct revisions per conversation", () => {
278
+ const demo = provider();
279
+ const revisions = demo
280
+ .getConversationIds()
281
+ .map((id) => demo.getConversationRevision(id));
282
+ expect(new Set(revisions).size).toBe(revisions.length);
283
+ });
284
+ });
285
+
286
+ describe("identity status injection", () => {
287
+ it("defaults to browser_only / no_identity_provider", () => {
288
+ expect(provider().getIdentityStatus()).toEqual({
289
+ state: "browser_only",
290
+ reason: "no_identity_provider",
291
+ });
292
+ });
293
+
294
+ it("notifies subscribers on change and dedupes identical states", () => {
295
+ const demo = provider();
296
+ const seen: unknown[] = [];
297
+ const unsubscribe = demo.subscribeIdentityStatus((status) => seen.push(status));
298
+
299
+ demo.setIdentityStatus({ state: "verifying" });
300
+ demo.setIdentityStatus({ state: "verifying" });
301
+ demo.setIdentityStatus({ state: "verified" });
302
+ demo.setIdentityStatus({
303
+ state: "authentication_required",
304
+ reason: "invalid_identity_proof",
305
+ });
306
+ demo.setIdentityStatus({
307
+ state: "authentication_required",
308
+ reason: "proof_unavailable_after_binding",
309
+ });
310
+
311
+ expect(seen).toEqual([
312
+ { state: "verifying" },
313
+ { state: "verified" },
314
+ { state: "authentication_required", reason: "invalid_identity_proof" },
315
+ { state: "authentication_required", reason: "proof_unavailable_after_binding" },
316
+ ]);
317
+ expect(demo.getIdentityStatus()).toEqual({
318
+ state: "authentication_required",
319
+ reason: "proof_unavailable_after_binding",
320
+ });
321
+
322
+ unsubscribe();
323
+ demo.setIdentityStatus({ state: "identity_provider_failed" });
324
+ expect(seen).toHaveLength(4);
325
+ expect(demo.getIdentityStatus()).toEqual({ state: "identity_provider_failed" });
326
+ });
327
+
328
+ it("accepts an injected initial status", () => {
329
+ const demo = provider({ identityStatus: { state: "verified" } });
330
+ expect(demo.getIdentityStatus()).toEqual({ state: "verified" });
331
+ });
332
+ });
333
+
334
+ describe("failure injection", () => {
335
+ it("failNext fails exactly once per queued entry", async () => {
336
+ const demo = provider();
337
+ demo.failNext("list", { code: "rate_limited", retryAfterSeconds: 30 });
338
+ const error = await demo.list({ context: browser }).catch((err: unknown) => err);
339
+ expect((error as HistoryProviderError).code).toBe("rate_limited");
340
+ expect((error as HistoryProviderError).retryAfterSeconds).toBe(30);
341
+
342
+ const recovered = await demo.list({ context: browser });
343
+ expect(recovered.items).toHaveLength(5);
344
+ });
345
+
346
+ it("persistent failures apply until cleared, per operation", async () => {
347
+ const demo = provider({ failures: { getPage: { code: "unavailable" } } });
348
+ await expect(
349
+ demo.getPage("demo-conv-order-status", { context: browser })
350
+ ).rejects.toMatchObject({ code: "unavailable" });
351
+ await expect(
352
+ demo.getPage("demo-conv-order-status", { context: browser })
353
+ ).rejects.toMatchObject({ code: "unavailable" });
354
+ await expect(demo.list({ context: browser })).resolves.toBeTruthy();
355
+
356
+ demo.setFailure("deleteAll", { code: "authentication_required" });
357
+ await expect(demo.deleteAll({ context: browser })).rejects.toMatchObject({
358
+ code: "authentication_required",
359
+ });
360
+
361
+ demo.clearFailures();
362
+ await expect(
363
+ demo.getPage("demo-conv-order-status", { context: browser })
364
+ ).resolves.toBeTruthy();
365
+ await expect(demo.deleteAll({ context: browser })).resolves.toEqual({ deleted: 5 });
366
+ });
367
+
368
+ it("a failed mutation leaves the store untouched", async () => {
369
+ const demo = provider();
370
+ demo.failNext("delete", { code: "authentication_failed" });
371
+ await expect(
372
+ demo.delete("demo-conv-order-status", { context: browser })
373
+ ).rejects.toMatchObject({ code: "authentication_failed" });
374
+ expect(demo.getConversationIds()).toHaveLength(5);
375
+ });
376
+ });
377
+
378
+ describe("latency injection", () => {
379
+ it("delays operations by the configured latency", async () => {
380
+ vi.useFakeTimers();
381
+ const demo = provider({ latencyMs: 250 });
382
+ let settled = false;
383
+ const pending = demo.list({ context: browser }).then((result) => {
384
+ settled = true;
385
+ return result;
386
+ });
387
+
388
+ await vi.advanceTimersByTimeAsync(100);
389
+ expect(settled).toBe(false);
390
+
391
+ await vi.advanceTimersByTimeAsync(200);
392
+ await pending;
393
+ expect(settled).toBe(true);
394
+
395
+ demo.setLatency(0);
396
+ settled = false;
397
+ await demo.list({ context: browser }).then(() => {
398
+ settled = true;
399
+ });
400
+ expect(settled).toBe(true);
401
+ });
402
+ });