@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,235 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * The history rail beside a resizable artifact split: the rail/panel width
5
+ * fallback measures the whole split, not the chat column the artifact pane
6
+ * borrows from, and an artifact drag reserves room for a docked rail plus a
7
+ * readable transcript.
8
+ */
9
+
10
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ import { createAgentExperience } from "./ui";
13
+ import { createHistoryView } from "./components/history-view";
14
+ import { setHistoryViewLoader } from "./history-view-loader";
15
+ import { setHistoryProviderFactory } from "./internal/history-provider-registry";
16
+ import {
17
+ createDemoHistoryProvider,
18
+ type DemoHistoryConversationSeed,
19
+ } from "./internal/demo-history-provider";
20
+ import { ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX } from "./utils/artifact-resize";
21
+
22
+ const SEEDS: DemoHistoryConversationSeed[] = [
23
+ {
24
+ id: "conv-a",
25
+ title: "Order status",
26
+ targetId: null,
27
+ messages: [{ id: "a1", role: "user", content: "where is my order" }],
28
+ },
29
+ ];
30
+
31
+ const RAIL_WIDTH = 260;
32
+ const SPLIT_WIDTH = 1200;
33
+
34
+ beforeAll(() => {
35
+ if (!window.matchMedia) {
36
+ window.matchMedia = ((query: string) => ({
37
+ matches: false,
38
+ media: query,
39
+ onchange: null,
40
+ addListener: () => {},
41
+ removeListener: () => {},
42
+ addEventListener: () => {},
43
+ removeEventListener: () => {},
44
+ dispatchEvent: () => false,
45
+ })) as unknown as typeof window.matchMedia;
46
+ }
47
+ });
48
+
49
+ const mounts: HTMLElement[] = [];
50
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
51
+
52
+ const flush = async (times = 12) => {
53
+ for (let i = 0; i < times; i += 1) {
54
+ // eslint-disable-next-line no-await-in-loop
55
+ await Promise.resolve();
56
+ }
57
+ };
58
+
59
+ /** jsdom measures nothing; every width in these tests is stated outright. */
60
+ const setWidth = (element: HTMLElement, width: number): void => {
61
+ element.getBoundingClientRect = () =>
62
+ ({
63
+ width,
64
+ height: 0,
65
+ top: 0,
66
+ left: 0,
67
+ right: width,
68
+ bottom: 0,
69
+ x: 0,
70
+ y: 0,
71
+ toJSON: () => ({}),
72
+ }) as DOMRect;
73
+ Object.defineProperty(element, "clientWidth", {
74
+ configurable: true,
75
+ get: () => width,
76
+ });
77
+ };
78
+
79
+ const setup = (options: { containerWidth?: number } = {}) => {
80
+ setHistoryProviderFactory(() => createDemoHistoryProvider({ conversations: SEEDS }));
81
+ const mount = document.createElement("div");
82
+ document.body.appendChild(mount);
83
+ mounts.push(mount);
84
+ const controller = createAgentExperience(mount, {
85
+ apiUrl: "https://api.example.com/chat",
86
+ launcher: { enabled: false },
87
+ persistState: { keyPrefix: "persona-artifact-rail-test-" },
88
+ suggestionChips: [],
89
+ features: {
90
+ history: { enabled: true, presentation: "rail" },
91
+ artifacts: {
92
+ enabled: true,
93
+ allowedTypes: ["markdown"],
94
+ layout: { resizable: true },
95
+ },
96
+ },
97
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
98
+ controllers.push(controller);
99
+ const container = mount.querySelector<HTMLElement>(".persona-widget-container")!;
100
+ setWidth(container, options.containerWidth ?? SPLIT_WIDTH);
101
+ setWidth(splitRoot(mount), SPLIT_WIDTH);
102
+ controller.update({});
103
+ return { mount, controller };
104
+ };
105
+
106
+ const splitRoot = (mount: HTMLElement) =>
107
+ mount.querySelector<HTMLElement>(".persona-artifact-split-root")!;
108
+ const paneEl = (mount: HTMLElement) =>
109
+ mount.querySelector<HTMLElement>(".persona-artifact-pane")!;
110
+ const artifactHandle = (mount: HTMLElement) =>
111
+ mount.querySelector<HTMLElement>(".persona-artifact-split-handle")!;
112
+ const railHostOf = (mount: HTMLElement) =>
113
+ mount.querySelector<HTMLElement>(".persona-history-rail-host");
114
+ const historyButton = (mount: HTMLElement) =>
115
+ mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]")!;
116
+ const presentationOf = (mount: HTMLElement) =>
117
+ mount
118
+ .querySelector<HTMLElement>("[data-persona-history-presentation]")
119
+ ?.getAttribute("data-persona-history-presentation") ?? null;
120
+
121
+ const openHistory = async (mount: HTMLElement) => {
122
+ historyButton(mount).dispatchEvent(
123
+ new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })
124
+ );
125
+ await flush();
126
+ };
127
+
128
+ const addArtifact = (controller: ReturnType<typeof createAgentExperience>) => {
129
+ controller.upsertArtifact({
130
+ id: "art-1",
131
+ title: "Draft",
132
+ artifactType: "markdown",
133
+ content: "# Draft",
134
+ });
135
+ };
136
+
137
+ /** Drags the artifact divider by `dx` px (negative widens the pane). */
138
+ const dragArtifact = (mount: HTMLElement, dx: number) => {
139
+ const handle = artifactHandle(mount);
140
+ handle.dispatchEvent(
141
+ new MouseEvent("pointerdown", {
142
+ bubbles: true,
143
+ cancelable: true,
144
+ button: 0,
145
+ clientX: 0,
146
+ })
147
+ );
148
+ document.dispatchEvent(new MouseEvent("pointermove", { clientX: dx }));
149
+ document.dispatchEvent(new MouseEvent("pointerup", { clientX: dx }));
150
+ return Number.parseFloat(paneEl(mount).style.width);
151
+ };
152
+
153
+ describe("history rail beside a resizable artifact split", () => {
154
+ beforeEach(() => {
155
+ window.scrollTo = vi.fn();
156
+ setHistoryViewLoader(async () => ({ createHistoryView }));
157
+ });
158
+
159
+ afterEach(() => {
160
+ setHistoryProviderFactory(null);
161
+ controllers.splice(0).forEach((controller) => {
162
+ try {
163
+ controller.destroy();
164
+ } catch {
165
+ /* already destroyed */
166
+ }
167
+ });
168
+ mounts.splice(0).forEach((mount) => mount.remove());
169
+ document.body.innerHTML = "";
170
+ window.localStorage.clear();
171
+ vi.restoreAllMocks();
172
+ });
173
+
174
+ it("resolves the presentation from the split root, not the borrowed chat width", async () => {
175
+ // The artifact pane has taken most of the row: the chat column is far under
176
+ // the rail's own minimum, the split root is not.
177
+ const { mount } = setup({ containerWidth: 400 });
178
+ await openHistory(mount);
179
+ expect(presentationOf(mount)).toBe("rail");
180
+ expect(railHostOf(mount)).not.toBeNull();
181
+ });
182
+
183
+ it("still flips to panel when the split root itself is narrow", async () => {
184
+ const { mount } = setup({ containerWidth: 400 });
185
+ await openHistory(mount);
186
+ expect(presentationOf(mount)).toBe("rail");
187
+
188
+ // A real host/window narrowing shrinks the split root too.
189
+ setWidth(splitRoot(mount), 600);
190
+ window.dispatchEvent(new Event("resize"));
191
+ await flush();
192
+ expect(presentationOf(mount)).toBe("panel");
193
+ expect(railHostOf(mount)).toBeNull();
194
+ });
195
+
196
+ it("reserves the docked rail plus a readable transcript while dragging", async () => {
197
+ const { mount, controller } = setup();
198
+ await openHistory(mount);
199
+ const railHost = railHostOf(mount)!;
200
+ setWidth(railHost, RAIL_WIDTH);
201
+ addArtifact(controller);
202
+
203
+ const width = dragArtifact(mount, -2000);
204
+ const reserved = RAIL_WIDTH + ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX;
205
+ expect(width).toBeGreaterThan(0);
206
+ expect(width).toBeLessThanOrEqual(SPLIT_WIDTH - reserved);
207
+ // Everything the reserve does not claim is still draggable (gap 0, handle <= 6).
208
+ expect(width).toBeGreaterThanOrEqual(SPLIT_WIDTH - reserved - 6);
209
+ });
210
+
211
+ it("keeps the flat chat minimum when no rail is docked", async () => {
212
+ const { mount, controller } = setup();
213
+ addArtifact(controller);
214
+
215
+ const width = dragArtifact(mount, -2000);
216
+ const reserved = RAIL_WIDTH + ARTIFACT_RESIZE_RAILED_TRANSCRIPT_MIN_PX;
217
+ expect(width).toBeGreaterThan(SPLIT_WIDTH - reserved);
218
+ expect(width).toBeLessThanOrEqual(SPLIT_WIDTH - 200);
219
+ });
220
+
221
+ it("keeps the rail docked after a drag that widened the pane", async () => {
222
+ const { mount, controller } = setup();
223
+ await openHistory(mount);
224
+ setWidth(railHostOf(mount)!, RAIL_WIDTH);
225
+ addArtifact(controller);
226
+ const width = dragArtifact(mount, -2000);
227
+
228
+ // The chat column now measures what the pane left it; the rail must survive.
229
+ setWidth(mount.querySelector<HTMLElement>(".persona-widget-container")!, SPLIT_WIDTH - width);
230
+ window.dispatchEvent(new Event("resize"));
231
+ await flush();
232
+ expect(presentationOf(mount)).toBe("rail");
233
+ expect(railHostOf(mount)).not.toBeNull();
234
+ });
235
+ });
@@ -256,6 +256,7 @@ describe("attachment button live config updates", () => {
256
256
  const controller = createAgentExperience(mount, {
257
257
  apiUrl: "https://api.example.com/chat",
258
258
  launcher: { enabled: false },
259
+ tooltip: { delayMs: 0 },
259
260
  attachments: { enabled: true, buttonIconName: "paperclip", buttonTooltipText: "Attach file" },
260
261
  });
261
262
 
@@ -87,10 +87,10 @@ describe("header stability across unrelated updates", () => {
87
87
  mount
88
88
  .querySelector('button[aria-label="Clear chat"] svg')!
89
89
  .getAttribute("stroke-width");
90
- expect(stroke()).toBe("1");
90
+ expect(stroke()).toBe("1.5");
91
91
 
92
92
  controller.update({ attachments: { enabled: true, maxFiles: 2 } });
93
- expect(stroke()).toBe("1");
93
+ expect(stroke()).toBe("1.5");
94
94
 
95
95
  controller.destroy();
96
96
  });
@@ -0,0 +1,72 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Lazy-chunk transport for the Messages surface. Lives in its own file because
5
+ * `loadHistoryView` memoizes the resolved module for the lifetime of the module
6
+ * graph: a successful load anywhere in a file would mask the failure path.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import { createAgentExperience } from "./ui";
12
+ import { createHistoryView } from "./components/history-view";
13
+ import { setHistoryViewLoader } from "./history-view-loader";
14
+ import { setHistoryProviderFactory } from "./internal/history-provider-registry";
15
+ import { createDemoHistoryProvider } from "./internal/demo-history-provider";
16
+
17
+ const flush = async (times = 12) => {
18
+ for (let i = 0; i < times; i += 1) {
19
+ // eslint-disable-next-line no-await-in-loop
20
+ await Promise.resolve();
21
+ }
22
+ };
23
+
24
+ describe("history lazy chunk", () => {
25
+ let controller: ReturnType<typeof createAgentExperience> | null = null;
26
+ let mount: HTMLElement | null = null;
27
+
28
+ beforeEach(() => {
29
+ window.scrollTo = vi.fn();
30
+ setHistoryProviderFactory(() => createDemoHistoryProvider());
31
+ });
32
+
33
+ afterEach(() => {
34
+ setHistoryProviderFactory(null);
35
+ controller?.destroy();
36
+ controller = null;
37
+ mount?.remove();
38
+ mount = null;
39
+ document.body.innerHTML = "";
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ it("keeps the conversation interactive on a rejected load and retries later", async () => {
44
+ setHistoryViewLoader(async () => {
45
+ throw new Error("chunk rejected");
46
+ });
47
+ mount = document.createElement("div");
48
+ document.body.appendChild(mount);
49
+ controller = createAgentExperience(mount, {
50
+ apiUrl: "https://api.example.com/chat",
51
+ launcher: { enabled: false },
52
+ persistState: false,
53
+ features: { history: { enabled: true } },
54
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
55
+
56
+ mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]")!.click();
57
+ await flush();
58
+
59
+ expect(mount.querySelector(".persona-history-view")).toBeNull();
60
+ expect(controller.isHistoryVisible()).toBe(false);
61
+ const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
62
+ expect(body.style.display).not.toBe("none");
63
+ expect(body.hasAttribute("inert")).toBe(false);
64
+
65
+ // A dropped fetch must not disable the feature for the session.
66
+ setHistoryViewLoader(async () => ({ createHistoryView }));
67
+ mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]")!.click();
68
+ await flush();
69
+ expect(mount.querySelector(".persona-history-view")).not.toBeNull();
70
+ expect(controller.isHistoryVisible()).toBe(true);
71
+ });
72
+ });
@@ -0,0 +1,318 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Public `features.history.provider`: a host-supplied history backend in a
5
+ * custom-backend session (`apiUrl`, no client token), the factory's
6
+ * once-per-instance contract, registry precedence, and the affordance-hiding
7
+ * optional capabilities.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ import { createAgentExperience } from "./ui";
13
+ import { createHistoryView } from "./components/history-view";
14
+ import { setHistoryViewLoader } from "./history-view-loader";
15
+ import { setHistoryProviderFactory } from "./internal/history-provider-registry";
16
+ import {
17
+ createDemoHistoryProvider,
18
+ type DemoHistoryConversationSeed,
19
+ type DemoHistoryProvider,
20
+ } from "./internal/demo-history-provider";
21
+ import type { HistoryProvider } from "./internal/history-provider";
22
+ import { HistoryProviderError, isHistoryProviderError } from "./index-core";
23
+
24
+ const SEEDS: DemoHistoryConversationSeed[] = [
25
+ {
26
+ id: "conv-a",
27
+ title: "Order status",
28
+ targetId: null,
29
+ messages: [
30
+ { id: "a1", role: "user", content: "where is my order" },
31
+ { id: "a2", role: "assistant", content: "it ships tomorrow" },
32
+ ],
33
+ },
34
+ {
35
+ id: "conv-b",
36
+ title: "Refund request",
37
+ targetId: null,
38
+ messages: [{ id: "b1", role: "user", content: "refund please" }],
39
+ },
40
+ ];
41
+
42
+ const flush = async (times = 12) => {
43
+ for (let i = 0; i < times; i += 1) {
44
+ // eslint-disable-next-line no-await-in-loop
45
+ await Promise.resolve();
46
+ }
47
+ };
48
+
49
+ const mounts: HTMLElement[] = [];
50
+ const controllers: Array<ReturnType<typeof createAgentExperience>> = [];
51
+
52
+ /** Custom-backend session: `apiUrl` only, so client-token mode is off. */
53
+ const setup = (
54
+ historyFeature: Record<string, unknown>,
55
+ extraConfig: Record<string, unknown> = {}
56
+ ) => {
57
+ const mount = document.createElement("div");
58
+ document.body.appendChild(mount);
59
+ mounts.push(mount);
60
+ const controller = createAgentExperience(mount, {
61
+ apiUrl: "https://api.example.com/chat",
62
+ launcher: { enabled: false },
63
+ persistState: false,
64
+ suggestionChips: [],
65
+ features: { history: historyFeature },
66
+ ...extraConfig,
67
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
68
+ controllers.push(controller);
69
+ return { mount, controller };
70
+ };
71
+
72
+ /** Drops one optional capability without disturbing the closures behind it. */
73
+ const without = (
74
+ provider: DemoHistoryProvider,
75
+ key: "update" | "resetDevice"
76
+ ): HistoryProvider => {
77
+ const next: HistoryProvider = { ...provider, capabilities: provider.capabilities };
78
+ delete next[key];
79
+ return next;
80
+ };
81
+
82
+ const historyButton = (mount: HTMLElement) =>
83
+ mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]");
84
+ const rowOf = (mount: HTMLElement, id: string) =>
85
+ mount.querySelector<HTMLButtonElement>(
86
+ `[data-persona-history-conversation="${id}"]`
87
+ );
88
+
89
+ const openHistoryUI = async (mount: HTMLElement) => {
90
+ historyButton(mount)!.dispatchEvent(
91
+ new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })
92
+ );
93
+ await flush();
94
+ };
95
+
96
+ const listMenuItems = async (mount: HTMLElement) => {
97
+ mount.querySelector<HTMLButtonElement>(".persona-history-list-options")!.click();
98
+ await flush();
99
+ return Array.from(
100
+ mount.querySelectorAll('.persona-history-caption [role="menuitem"]')
101
+ ).map((item) => item.textContent);
102
+ };
103
+
104
+ const starActiveConversation = async (mount: HTMLElement) => {
105
+ mount
106
+ .querySelector('[data-persona-theme-zone="header"]')!
107
+ .dispatchEvent(
108
+ new CustomEvent("persona:title-menu-builtin", {
109
+ bubbles: true,
110
+ detail: { actionId: "star" },
111
+ })
112
+ );
113
+ await flush();
114
+ };
115
+
116
+ describe("features.history.provider", () => {
117
+ beforeEach(() => {
118
+ window.scrollTo = vi.fn();
119
+ // No registry override: this suite exercises the public config path only.
120
+ setHistoryProviderFactory(null);
121
+ setHistoryViewLoader(async () => ({ createHistoryView }));
122
+ });
123
+
124
+ afterEach(() => {
125
+ setHistoryProviderFactory(null);
126
+ controllers.splice(0).forEach((controller) => {
127
+ try {
128
+ controller.destroy();
129
+ } catch {
130
+ /* already destroyed */
131
+ }
132
+ });
133
+ mounts.splice(0).forEach((mount) => mount.remove());
134
+ document.body.innerHTML = "";
135
+ window.localStorage.clear();
136
+ vi.restoreAllMocks();
137
+ });
138
+
139
+ it("renders the history UI in a custom-backend session and lists conversations", async () => {
140
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
141
+ const { mount, controller } = setup({ enabled: true, provider: () => provider });
142
+
143
+ expect(historyButton(mount)).not.toBeNull();
144
+ await openHistoryUI(mount);
145
+ expect(mount.querySelector(".persona-history-view")).not.toBeNull();
146
+ expect(rowOf(mount, "conv-a")).not.toBeNull();
147
+ expect(rowOf(mount, "conv-b")).not.toBeNull();
148
+
149
+ const page = await controller.listConversations({ limit: 10 });
150
+ expect(page.items.map((item) => item.id)).toEqual(["conv-b", "conv-a"]);
151
+ });
152
+
153
+ it("accepts a provider instance as well as a factory", async () => {
154
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
155
+ const { mount } = setup({ enabled: true, provider });
156
+ await openHistoryUI(mount);
157
+ expect(rowOf(mount, "conv-a")).not.toBeNull();
158
+ });
159
+
160
+ it("calls the factory exactly once per widget instance", async () => {
161
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
162
+ const factory = vi.fn(() => provider);
163
+ const { mount, controller } = setup({ enabled: true, provider: factory });
164
+ expect(factory).toHaveBeenCalledTimes(1);
165
+
166
+ // Opening the surface re-reads the provider; it must not rebuild it.
167
+ await openHistoryUI(mount);
168
+ expect(factory).toHaveBeenCalledTimes(1);
169
+
170
+ // A reinstall that keeps the same factory reuses the built provider.
171
+ controller.update({ features: { history: { enabled: false, provider: factory } } });
172
+ controller.update({ features: { history: { enabled: true, provider: factory } } });
173
+ await flush();
174
+ expect(factory).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it("rebuilds when a new factory identity arrives through update()", async () => {
178
+ const first = vi.fn(() => createDemoHistoryProvider({ conversations: SEEDS }));
179
+ const second = vi.fn(() =>
180
+ createDemoHistoryProvider({
181
+ conversations: [
182
+ {
183
+ id: "conv-next",
184
+ title: "Next",
185
+ targetId: null,
186
+ messages: [{ id: "n1", role: "user", content: "second backend" }],
187
+ },
188
+ ],
189
+ })
190
+ );
191
+ const { mount, controller } = setup({ enabled: true, provider: first });
192
+ expect(first).toHaveBeenCalledTimes(1);
193
+
194
+ controller.update({ features: { history: { enabled: true, provider: second } } });
195
+ await flush();
196
+ expect(second).toHaveBeenCalledTimes(1);
197
+ await openHistoryUI(mount);
198
+ expect(rowOf(mount, "conv-next")).not.toBeNull();
199
+ expect(rowOf(mount, "conv-a")).toBeNull();
200
+ });
201
+
202
+ it("lets the internal registry override a config provider", async () => {
203
+ const registryProvider = createDemoHistoryProvider({ conversations: SEEDS });
204
+ setHistoryProviderFactory(() => registryProvider);
205
+ const configFactory = vi.fn(() =>
206
+ createDemoHistoryProvider({
207
+ conversations: [
208
+ {
209
+ id: "conv-config",
210
+ title: "Config",
211
+ targetId: null,
212
+ messages: [{ id: "c1", role: "user", content: "config backend" }],
213
+ },
214
+ ],
215
+ })
216
+ );
217
+ const { mount } = setup({ enabled: true, provider: configFactory });
218
+
219
+ await openHistoryUI(mount);
220
+ expect(configFactory).not.toHaveBeenCalled();
221
+ expect(rowOf(mount, "conv-a")).not.toBeNull();
222
+ expect(rowOf(mount, "conv-config")).toBeNull();
223
+ });
224
+
225
+ it("narrows the derived scope to what the provider advertises", async () => {
226
+ // getIdentityProof derives "verified-user", but the demo provider serves
227
+ // "browser" only; the derived default must narrow, not fail.
228
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
229
+ const { mount, controller } = setup(
230
+ { enabled: true, provider: () => provider },
231
+ { getIdentityProof: async () => "proof" }
232
+ );
233
+ await openHistoryUI(mount);
234
+ expect(rowOf(mount, "conv-a")).not.toBeNull();
235
+ await controller.openConversation("conv-a");
236
+ expect(provider.getActiveConversationId()).toBe("conv-a");
237
+ });
238
+
239
+ it("fails closed when an explicit scope is outside the provider's set", async () => {
240
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
241
+ const { controller } = setup({
242
+ enabled: true,
243
+ provider: () => provider,
244
+ scope: "verified-user",
245
+ });
246
+ await expect(controller.openConversation("conv-a")).rejects.toMatchObject({
247
+ code: "unsupported_scope",
248
+ });
249
+ });
250
+
251
+ it("hides the star affordance for a provider without update", async () => {
252
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
253
+ const { mount, controller } = setup({
254
+ enabled: true,
255
+ provider: () => without(provider, "update"),
256
+ });
257
+ await controller.openConversation("conv-a");
258
+ await flush();
259
+
260
+ await starActiveConversation(mount);
261
+ const page = await controller.listConversations({ limit: 10 });
262
+ expect(page.items.find((item) => item.id === "conv-a")?.starred).toBe(false);
263
+ await expect(
264
+ controller.setConversationStarred("conv-a", true)
265
+ ).rejects.toThrow();
266
+ await expect(
267
+ controller.renameConversation("conv-a", "Renamed")
268
+ ).rejects.toThrow();
269
+ });
270
+
271
+ it("keeps the star affordance for a provider with update", async () => {
272
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
273
+ const { mount, controller } = setup({ enabled: true, provider: () => provider });
274
+ await controller.openConversation("conv-a");
275
+ await flush();
276
+
277
+ await starActiveConversation(mount);
278
+ const page = await controller.listConversations({ limit: 10 });
279
+ expect(page.items.find((item) => item.id === "conv-a")?.starred).toBe(true);
280
+ });
281
+
282
+ it("hides forget-this-device for a provider without resetDevice", async () => {
283
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
284
+ const { mount } = setup({ enabled: true, provider: () => provider });
285
+ await openHistoryUI(mount);
286
+ expect(await listMenuItems(mount)).toEqual(["Delete all conversations"]);
287
+ });
288
+
289
+ it("offers forget-this-device for a provider with resetDevice", async () => {
290
+ const provider = createDemoHistoryProvider({ conversations: SEEDS });
291
+ const withReset: HistoryProvider = {
292
+ ...provider,
293
+ capabilities: provider.capabilities,
294
+ resetDevice: async () => ({ remoteRevocationConfirmed: false }),
295
+ };
296
+ const { mount } = setup({ enabled: true, provider: () => withReset });
297
+ await openHistoryUI(mount);
298
+ expect(await listMenuItems(mount)).toEqual([
299
+ "Delete all conversations",
300
+ "Forget this device",
301
+ ]);
302
+ });
303
+ });
304
+
305
+ describe("history provider error exports", () => {
306
+ it("round-trips HistoryProviderError through the package entry", () => {
307
+ const error = new HistoryProviderError("rate_limited", "slow down", {
308
+ retryAfterSeconds: 12,
309
+ });
310
+ expect(error).toBeInstanceOf(Error);
311
+ expect(error).toBeInstanceOf(HistoryProviderError);
312
+ expect(error.name).toBe("HistoryProviderError");
313
+ expect(error.code).toBe("rate_limited");
314
+ expect(error.retryAfterSeconds).toBe(12);
315
+ expect(isHistoryProviderError(error)).toBe(true);
316
+ expect(isHistoryProviderError(new Error("nope"))).toBe(false);
317
+ });
318
+ });