@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.
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1212 -14
- package/dist/context-mentions.d.ts +1212 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1987 -17
- package/dist/index.d.ts +1987 -17
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1212 -14
- package/dist/smart-dom-reader.d.ts +1212 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1331 -15
- package/dist/theme-editor-preview.d.ts +1331 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1215 -15
- package/dist/theme-editor.d.ts +1215 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +16 -4
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +1 -0
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- package/dist/chunk-QJ4LV2KW.js +0 -1
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public history controller surface and events
|
|
5
|
+
* (`docs/visitor-history-implementation-plan.md` D6/D8): method semantics,
|
|
6
|
+
* sanitized/deduped events, window mirrors, init-handle forwarding, and the
|
|
7
|
+
* distinct clearChat / startNewConversation / clearConversationHistory /
|
|
8
|
+
* resetHistoryIdentity semantics.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { createAgentExperience } from "./ui";
|
|
14
|
+
import { initAgentWidget } from "./runtime/init";
|
|
15
|
+
import { createHistoryView } from "./components/history-view";
|
|
16
|
+
import { setHistoryViewLoader } from "./history-view-loader";
|
|
17
|
+
import { setHistoryProviderFactory } from "./internal/history-provider-registry";
|
|
18
|
+
import {
|
|
19
|
+
createDemoHistoryProvider,
|
|
20
|
+
type DemoHistoryConversationSeed,
|
|
21
|
+
type DemoHistoryProvider,
|
|
22
|
+
} from "./internal/demo-history-provider";
|
|
23
|
+
import type { HistoryProvider } from "./internal/history-provider";
|
|
24
|
+
|
|
25
|
+
const SEEDS: DemoHistoryConversationSeed[] = [
|
|
26
|
+
{
|
|
27
|
+
id: "conv-a",
|
|
28
|
+
title: "Order status",
|
|
29
|
+
targetId: null,
|
|
30
|
+
messages: [
|
|
31
|
+
{ id: "a1", role: "user", content: "where is my order" },
|
|
32
|
+
{ id: "a2", role: "assistant", content: "it ships tomorrow" },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "conv-b",
|
|
37
|
+
title: "Refund request",
|
|
38
|
+
targetId: null,
|
|
39
|
+
messages: [{ id: "b1", role: "user", content: "refund please" }],
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const flush = async (times = 12) => {
|
|
44
|
+
for (let i = 0; i < times; i += 1) {
|
|
45
|
+
// eslint-disable-next-line no-await-in-loop
|
|
46
|
+
await Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const mounts: HTMLElement[] = [];
|
|
51
|
+
const controllers: Array<{ destroy: () => void }> = [];
|
|
52
|
+
|
|
53
|
+
/** Demo provider plus an injectable `resetDevice`, which it deliberately omits. */
|
|
54
|
+
const withResetDevice = (
|
|
55
|
+
provider: DemoHistoryProvider,
|
|
56
|
+
result: () => Promise<{ remoteRevocationConfirmed: boolean }>
|
|
57
|
+
): HistoryProvider => ({
|
|
58
|
+
...provider,
|
|
59
|
+
capabilities: provider.capabilities,
|
|
60
|
+
resetDevice: result,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const setup = (
|
|
64
|
+
options: {
|
|
65
|
+
provider?: HistoryProvider;
|
|
66
|
+
config?: Record<string, unknown>;
|
|
67
|
+
} = {}
|
|
68
|
+
) => {
|
|
69
|
+
const provider =
|
|
70
|
+
options.provider ?? createDemoHistoryProvider({ conversations: SEEDS });
|
|
71
|
+
setHistoryProviderFactory(() => provider);
|
|
72
|
+
const mount = document.createElement("div");
|
|
73
|
+
document.body.appendChild(mount);
|
|
74
|
+
mounts.push(mount);
|
|
75
|
+
const controller = createAgentExperience(mount, {
|
|
76
|
+
apiUrl: "https://api.example.com/chat",
|
|
77
|
+
launcher: { enabled: false },
|
|
78
|
+
persistState: false,
|
|
79
|
+
suggestionChips: [],
|
|
80
|
+
features: { history: { enabled: true } },
|
|
81
|
+
...options.config,
|
|
82
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
83
|
+
controllers.push(controller);
|
|
84
|
+
return { mount, controller, provider };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe("history controller API", () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
window.scrollTo = vi.fn();
|
|
90
|
+
setHistoryViewLoader(async () => ({ createHistoryView }));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
setHistoryProviderFactory(null);
|
|
95
|
+
controllers.splice(0).forEach((controller) => {
|
|
96
|
+
try {
|
|
97
|
+
controller.destroy();
|
|
98
|
+
} catch {
|
|
99
|
+
/* already destroyed */
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
mounts.splice(0).forEach((mount) => mount.remove());
|
|
103
|
+
document.body.innerHTML = "";
|
|
104
|
+
window.localStorage.clear();
|
|
105
|
+
vi.restoreAllMocks();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("methods", () => {
|
|
109
|
+
it("lists conversations for headless hosts", async () => {
|
|
110
|
+
const { controller } = setup();
|
|
111
|
+
const page = await controller.listConversations({ limit: 10 });
|
|
112
|
+
expect(page.items.map((item) => item.id)).toEqual(["conv-b", "conv-a"]);
|
|
113
|
+
expect(page.nextCursor).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("opens a conversation and hydrates its transcript", async () => {
|
|
117
|
+
const { mount, controller } = setup();
|
|
118
|
+
await controller.openConversation("conv-a");
|
|
119
|
+
await flush();
|
|
120
|
+
expect(controller.getMessages().map((message) => message.id)).toEqual([
|
|
121
|
+
"a1",
|
|
122
|
+
"a2",
|
|
123
|
+
]);
|
|
124
|
+
expect(
|
|
125
|
+
mount.querySelectorAll("#persona-scroll-container [data-message-id]").length
|
|
126
|
+
).toBe(2);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("keeps focus off the composer for programmatic opens, and moves it with focus: true", async () => {
|
|
130
|
+
const { mount, controller } = setup();
|
|
131
|
+
const outside = document.createElement("button");
|
|
132
|
+
document.body.appendChild(outside);
|
|
133
|
+
outside.focus();
|
|
134
|
+
|
|
135
|
+
// A boot-time reopen must not steal focus: in a same-origin iframe the
|
|
136
|
+
// browser would scroll the host page to the widget.
|
|
137
|
+
await controller.openConversation("conv-a");
|
|
138
|
+
await flush();
|
|
139
|
+
expect(document.activeElement).toBe(outside);
|
|
140
|
+
|
|
141
|
+
await controller.startNewConversation();
|
|
142
|
+
await flush();
|
|
143
|
+
expect(document.activeElement).toBe(outside);
|
|
144
|
+
|
|
145
|
+
await controller.openConversation("conv-a", { focus: true });
|
|
146
|
+
await flush();
|
|
147
|
+
expect(document.activeElement).toBe(mount.querySelector("textarea"));
|
|
148
|
+
|
|
149
|
+
outside.focus();
|
|
150
|
+
await controller.startNewConversation({ focus: true });
|
|
151
|
+
await flush();
|
|
152
|
+
expect(document.activeElement).toBe(mount.querySelector("textarea"));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("starts a new server conversation and clears the transcript", async () => {
|
|
156
|
+
const { controller, provider } = setup();
|
|
157
|
+
await controller.openConversation("conv-a");
|
|
158
|
+
await flush();
|
|
159
|
+
await controller.startNewConversation();
|
|
160
|
+
await flush();
|
|
161
|
+
expect(controller.getMessages()).toEqual([]);
|
|
162
|
+
const active = (provider as DemoHistoryProvider).getActiveConversationId();
|
|
163
|
+
expect(active).not.toBe("conv-a");
|
|
164
|
+
expect(active).not.toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("deletes an inactive conversation without disturbing the active one", async () => {
|
|
168
|
+
const { controller, provider } = setup();
|
|
169
|
+
await controller.openConversation("conv-a");
|
|
170
|
+
await flush();
|
|
171
|
+
await controller.deleteConversation("conv-b");
|
|
172
|
+
await flush();
|
|
173
|
+
expect(controller.getMessages().map((message) => message.id)).toEqual([
|
|
174
|
+
"a1",
|
|
175
|
+
"a2",
|
|
176
|
+
]);
|
|
177
|
+
expect(
|
|
178
|
+
(provider as DemoHistoryProvider).getConversationIds()
|
|
179
|
+
).not.toContain("conv-b");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("defaults delete-all to the active target and honors an explicit opt-out", async () => {
|
|
183
|
+
const spy = vi.fn().mockResolvedValue({ deleted: 2 });
|
|
184
|
+
const base = createDemoHistoryProvider({ conversations: SEEDS });
|
|
185
|
+
const provider: HistoryProvider = { ...base, deleteAll: spy };
|
|
186
|
+
const { controller } = setup({ provider });
|
|
187
|
+
|
|
188
|
+
await controller.clearConversationHistory();
|
|
189
|
+
// No client-token session in this harness, so the active target is null
|
|
190
|
+
// and the filter is simply omitted rather than invented.
|
|
191
|
+
expect(spy.mock.calls[0][0]).toEqual(
|
|
192
|
+
expect.objectContaining({ context: { scope: "browser" } })
|
|
193
|
+
);
|
|
194
|
+
expect(spy.mock.calls[0][0].targetId).toBeUndefined();
|
|
195
|
+
|
|
196
|
+
await controller.clearConversationHistory({ targetId: "flow-1" });
|
|
197
|
+
expect(spy.mock.calls[1][0].targetId).toBe("flow-1");
|
|
198
|
+
|
|
199
|
+
await controller.clearConversationHistory({
|
|
200
|
+
targetId: "flow-1",
|
|
201
|
+
allTargets: true,
|
|
202
|
+
});
|
|
203
|
+
expect(spy.mock.calls[2][0].targetId).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("exposes the sanitized identity status synchronously", () => {
|
|
207
|
+
const { controller } = setup();
|
|
208
|
+
expect(controller.getHistoryIdentityStatus()).toEqual({
|
|
209
|
+
state: "browser_only",
|
|
210
|
+
reason: "no_identity_provider",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("reports unavailable identity status without a provider", () => {
|
|
215
|
+
setHistoryProviderFactory(null);
|
|
216
|
+
const mount = document.createElement("div");
|
|
217
|
+
document.body.appendChild(mount);
|
|
218
|
+
mounts.push(mount);
|
|
219
|
+
const controller = createAgentExperience(mount, {
|
|
220
|
+
apiUrl: "https://api.example.com/chat",
|
|
221
|
+
launcher: { enabled: false },
|
|
222
|
+
persistState: false,
|
|
223
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
224
|
+
controllers.push(controller);
|
|
225
|
+
expect(controller.getHistoryIdentityStatus()).toEqual({
|
|
226
|
+
state: "unavailable",
|
|
227
|
+
reason: "history_disabled",
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("shows, reports, and hides the history surface", async () => {
|
|
232
|
+
const { mount, controller } = setup();
|
|
233
|
+
expect(controller.isHistoryVisible()).toBe(false);
|
|
234
|
+
await controller.showHistory();
|
|
235
|
+
await flush();
|
|
236
|
+
expect(controller.isHistoryVisible()).toBe(true);
|
|
237
|
+
expect(mount.querySelector(".persona-history-view")).not.toBeNull();
|
|
238
|
+
controller.hideHistory();
|
|
239
|
+
expect(controller.isHistoryVisible()).toBe(false);
|
|
240
|
+
expect(mount.querySelector(".persona-history-view")).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("records a home return surface supplied by a composition", async () => {
|
|
244
|
+
const { controller } = setup();
|
|
245
|
+
const closed: Array<Record<string, unknown>> = [];
|
|
246
|
+
controller.on("history:closed", (payload) =>
|
|
247
|
+
closed.push(payload as unknown as Record<string, unknown>)
|
|
248
|
+
);
|
|
249
|
+
await controller.showHistory({ returnSurface: "home" });
|
|
250
|
+
await flush();
|
|
251
|
+
controller.hideHistory();
|
|
252
|
+
expect(closed[0]!.returnSurface).toBe("home");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("events", () => {
|
|
257
|
+
it("emits sanitized open/close/opened/deleted/cleared payloads", async () => {
|
|
258
|
+
const { controller } = setup();
|
|
259
|
+
const events: Array<[string, Record<string, unknown>]> = [];
|
|
260
|
+
const record = (name: string) => (payload: unknown) =>
|
|
261
|
+
events.push([name, payload as Record<string, unknown>]);
|
|
262
|
+
controller.on("history:opened", record("opened"));
|
|
263
|
+
controller.on("history:closed", record("closed"));
|
|
264
|
+
controller.on("history:conversationOpened", record("conversationOpened"));
|
|
265
|
+
controller.on("history:conversationDeleted", record("conversationDeleted"));
|
|
266
|
+
controller.on("history:cleared", record("cleared"));
|
|
267
|
+
|
|
268
|
+
await controller.showHistory();
|
|
269
|
+
await flush();
|
|
270
|
+
await controller.openConversation("conv-a");
|
|
271
|
+
await flush();
|
|
272
|
+
await controller.deleteConversation("conv-a");
|
|
273
|
+
await flush();
|
|
274
|
+
await controller.clearConversationHistory();
|
|
275
|
+
await flush();
|
|
276
|
+
|
|
277
|
+
const names = events.map(([name]) => name);
|
|
278
|
+
// Optimistic open: the panel closes on the selection and the
|
|
279
|
+
// conversationOpened commit follows once the transcript hydrates.
|
|
280
|
+
expect(names).toEqual([
|
|
281
|
+
"opened",
|
|
282
|
+
"closed",
|
|
283
|
+
"conversationOpened",
|
|
284
|
+
"conversationDeleted",
|
|
285
|
+
"cleared",
|
|
286
|
+
]);
|
|
287
|
+
expect(events[0]![1]).toEqual({
|
|
288
|
+
presentation: "panel",
|
|
289
|
+
returnSurface: "conversation",
|
|
290
|
+
timestamp: expect.any(Number),
|
|
291
|
+
});
|
|
292
|
+
expect(events[2]![1]).toEqual({
|
|
293
|
+
conversationId: "conv-a",
|
|
294
|
+
title: "Order status",
|
|
295
|
+
scope: "browser",
|
|
296
|
+
timestamp: expect.any(Number),
|
|
297
|
+
});
|
|
298
|
+
expect(events[3]![1]).toEqual({
|
|
299
|
+
conversationId: "conv-a",
|
|
300
|
+
scope: "browser",
|
|
301
|
+
wasActive: true,
|
|
302
|
+
timestamp: expect.any(Number),
|
|
303
|
+
});
|
|
304
|
+
expect(events[4]![1]).toEqual({
|
|
305
|
+
deleted: expect.any(Number),
|
|
306
|
+
scope: "browser",
|
|
307
|
+
targetId: null,
|
|
308
|
+
timestamp: expect.any(Number),
|
|
309
|
+
});
|
|
310
|
+
// No credential, proof, visitor, or backend text anywhere.
|
|
311
|
+
const serialized = JSON.stringify(events);
|
|
312
|
+
expect(serialized).not.toMatch(/token|proof|visitor|endUser/i);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("binds the header title to the active conversation with titleSource", async () => {
|
|
316
|
+
const { controller, mount } = setup({
|
|
317
|
+
config: {
|
|
318
|
+
launcher: { enabled: false, title: "Acme Assistant" },
|
|
319
|
+
layout: { header: { layout: "minimal", titleSource: "conversation" } },
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
// Scoped to the header zone: the history list rows also render titles.
|
|
323
|
+
const titleShown = (text: string): boolean =>
|
|
324
|
+
Array.from(
|
|
325
|
+
mount
|
|
326
|
+
.querySelector('[data-persona-theme-zone="header"]')
|
|
327
|
+
?.querySelectorAll("span") ?? []
|
|
328
|
+
).some((el) => el.textContent === text);
|
|
329
|
+
|
|
330
|
+
await controller.showHistory();
|
|
331
|
+
await flush();
|
|
332
|
+
expect(titleShown("Order status")).toBe(false);
|
|
333
|
+
|
|
334
|
+
await controller.openConversation("conv-a");
|
|
335
|
+
await flush();
|
|
336
|
+
expect(titleShown("Order status")).toBe(true);
|
|
337
|
+
|
|
338
|
+
// A fresh conversation reverts to the static fallback.
|
|
339
|
+
await controller.startNewConversation();
|
|
340
|
+
await flush();
|
|
341
|
+
expect(titleShown("Order status")).toBe(false);
|
|
342
|
+
expect(titleShown("Acme Assistant")).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("renames and stars conversations through the controller", async () => {
|
|
346
|
+
const { controller, mount } = setup({
|
|
347
|
+
config: {
|
|
348
|
+
launcher: { enabled: false, title: "Acme Assistant" },
|
|
349
|
+
layout: { header: { layout: "minimal", titleSource: "conversation" } },
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
await controller.showHistory();
|
|
353
|
+
await flush();
|
|
354
|
+
|
|
355
|
+
const renamed = await controller.renameConversation(
|
|
356
|
+
"conv-a",
|
|
357
|
+
"Renamed thread"
|
|
358
|
+
);
|
|
359
|
+
expect(renamed.title).toBe("Renamed thread");
|
|
360
|
+
await flush();
|
|
361
|
+
// The open list restamps in place.
|
|
362
|
+
expect(
|
|
363
|
+
mount.querySelector('[data-persona-history-conversation="conv-a"]')
|
|
364
|
+
?.textContent
|
|
365
|
+
).toContain("Renamed thread");
|
|
366
|
+
|
|
367
|
+
const starred = await controller.setConversationStarred("conv-a", true);
|
|
368
|
+
expect(starred.starred).toBe(true);
|
|
369
|
+
await flush();
|
|
370
|
+
// Starred rows pin into the leading group.
|
|
371
|
+
expect(
|
|
372
|
+
mount.querySelector('[data-persona-history-group="starred"]')
|
|
373
|
+
).not.toBeNull();
|
|
374
|
+
|
|
375
|
+
await controller.openConversation("conv-a");
|
|
376
|
+
await flush();
|
|
377
|
+
// The titleSource binding shows the renamed title in the header.
|
|
378
|
+
const headerSpans = Array.from(
|
|
379
|
+
mount
|
|
380
|
+
.querySelector('[data-persona-theme-zone="header"]')
|
|
381
|
+
?.querySelectorAll("span") ?? []
|
|
382
|
+
);
|
|
383
|
+
expect(headerSpans.some((el) => el.textContent === "Renamed thread")).toBe(
|
|
384
|
+
true
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("prunes the open list when a delete comes from the controller", async () => {
|
|
389
|
+
const { controller, mount } = setup();
|
|
390
|
+
await controller.showHistory();
|
|
391
|
+
await flush();
|
|
392
|
+
expect(
|
|
393
|
+
mount.querySelector('[data-persona-history-conversation="conv-b"]')
|
|
394
|
+
).not.toBeNull();
|
|
395
|
+
|
|
396
|
+
await controller.deleteConversation("conv-b");
|
|
397
|
+
await flush();
|
|
398
|
+
expect(
|
|
399
|
+
mount.querySelector('[data-persona-history-conversation="conv-b"]')
|
|
400
|
+
).toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("locks the conversation-bound title menu until a conversation is open", async () => {
|
|
404
|
+
const { controller, mount } = setup({
|
|
405
|
+
config: {
|
|
406
|
+
launcher: { enabled: false, title: "Acme Assistant" },
|
|
407
|
+
layout: {
|
|
408
|
+
header: {
|
|
409
|
+
layout: "minimal",
|
|
410
|
+
titleSource: "conversation",
|
|
411
|
+
titleMenu: {
|
|
412
|
+
menuItems: [{ id: "star", label: "Star" }],
|
|
413
|
+
onSelect: () => false,
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
await flush();
|
|
420
|
+
|
|
421
|
+
const combo = () =>
|
|
422
|
+
mount.querySelector<HTMLElement>(".persona-combo-btn");
|
|
423
|
+
expect(combo()?.getAttribute("data-persona-menu-locked")).toBe("true");
|
|
424
|
+
// Locked, a click opens nothing: the combo reads as a plain title.
|
|
425
|
+
combo()!.click();
|
|
426
|
+
expect(
|
|
427
|
+
mount.querySelector(".persona-dropdown-menu:not(.persona-hidden)")
|
|
428
|
+
).toBeNull();
|
|
429
|
+
|
|
430
|
+
await controller.openConversation("conv-a");
|
|
431
|
+
await flush();
|
|
432
|
+
expect(combo()?.getAttribute("data-persona-menu-locked")).toBe("false");
|
|
433
|
+
|
|
434
|
+
// Deleting the active conversation installs a replacement record, so
|
|
435
|
+
// the menu stays available: its actions target the fresh conversation.
|
|
436
|
+
await controller.deleteConversation("conv-a");
|
|
437
|
+
await flush();
|
|
438
|
+
expect(combo()?.getAttribute("data-persona-menu-locked")).toBe("false");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("runs the built-in star toggle from the title-menu event", async () => {
|
|
442
|
+
const { controller, mount } = setup();
|
|
443
|
+
await controller.openConversation("conv-a");
|
|
444
|
+
await flush();
|
|
445
|
+
|
|
446
|
+
mount
|
|
447
|
+
.querySelector('[data-persona-theme-zone="header"]')!
|
|
448
|
+
.dispatchEvent(
|
|
449
|
+
new CustomEvent("persona:title-menu-builtin", {
|
|
450
|
+
bubbles: true,
|
|
451
|
+
detail: { actionId: "star" },
|
|
452
|
+
})
|
|
453
|
+
);
|
|
454
|
+
await flush();
|
|
455
|
+
|
|
456
|
+
const page = await controller.listConversations({ limit: 10 });
|
|
457
|
+
expect(page.items.find((item) => item.id === "conv-a")?.starred).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("emits a sanitized conversationStarted from the controller method", async () => {
|
|
461
|
+
const { controller, provider } = setup();
|
|
462
|
+
const started: Array<Record<string, unknown>> = [];
|
|
463
|
+
controller.on("history:conversationStarted", (payload) =>
|
|
464
|
+
started.push(payload as unknown as Record<string, unknown>)
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
await controller.openConversation("conv-a");
|
|
468
|
+
await flush();
|
|
469
|
+
await controller.startNewConversation();
|
|
470
|
+
await flush();
|
|
471
|
+
|
|
472
|
+
const active = (provider as DemoHistoryProvider).getActiveConversationId();
|
|
473
|
+
expect(started).toEqual([
|
|
474
|
+
{ conversationId: active, timestamp: expect.any(Number) },
|
|
475
|
+
]);
|
|
476
|
+
expect(started[0]!.conversationId).not.toBe("conv-a");
|
|
477
|
+
expect(JSON.stringify(started)).not.toMatch(/token|proof|visitor|endUser/i);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("emits conversationStarted from the header new-conversation action", async () => {
|
|
481
|
+
const { mount, controller, provider } = setup();
|
|
482
|
+
const started: Array<Record<string, unknown>> = [];
|
|
483
|
+
controller.on("history:conversationStarted", (payload) =>
|
|
484
|
+
started.push(payload as unknown as Record<string, unknown>)
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// With history available the clear-chat affordance IS "New conversation".
|
|
488
|
+
const headerAction = mount.querySelector<HTMLButtonElement>(
|
|
489
|
+
".persona-clear-chat-button-wrapper button"
|
|
490
|
+
)!;
|
|
491
|
+
expect(headerAction.getAttribute("aria-label")).toBe("New conversation");
|
|
492
|
+
headerAction.click();
|
|
493
|
+
await flush(20);
|
|
494
|
+
|
|
495
|
+
expect(started.length).toBe(1);
|
|
496
|
+
expect(started[0]!.conversationId).toBe(
|
|
497
|
+
(provider as DemoHistoryProvider).getActiveConversationId()
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("emits conversationStarted before history:closed for the in-panel action", async () => {
|
|
502
|
+
const { mount, controller } = setup();
|
|
503
|
+
const order: string[] = [];
|
|
504
|
+
controller.on("history:conversationStarted", () => order.push("started"));
|
|
505
|
+
controller.on("history:closed", () => order.push("closed"));
|
|
506
|
+
|
|
507
|
+
await controller.showHistory();
|
|
508
|
+
await flush();
|
|
509
|
+
mount.querySelector<HTMLButtonElement>("button.persona-history-new")!.click();
|
|
510
|
+
await flush(20);
|
|
511
|
+
|
|
512
|
+
// The commit is distinguishable from a plain close: it precedes it.
|
|
513
|
+
expect(order).toEqual(["started", "closed"]);
|
|
514
|
+
expect(controller.isHistoryVisible()).toBe(false);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("dedupes identity status changes and carries a timestamp", async () => {
|
|
518
|
+
const provider = createDemoHistoryProvider({ conversations: SEEDS });
|
|
519
|
+
const { controller } = setup({ provider });
|
|
520
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
521
|
+
controller.on("history:identityStatusChanged", (payload) =>
|
|
522
|
+
seen.push(payload as unknown as Record<string, unknown>)
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
provider.setIdentityStatus({ state: "verifying" });
|
|
526
|
+
provider.setIdentityStatus({ state: "verifying" });
|
|
527
|
+
provider.setIdentityStatus({ state: "verified" });
|
|
528
|
+
provider.setIdentityStatus({ state: "verified" });
|
|
529
|
+
|
|
530
|
+
expect(seen).toEqual([
|
|
531
|
+
{ status: { state: "verifying" }, timestamp: expect.any(Number) },
|
|
532
|
+
{ status: { state: "verified" }, timestamp: expect.any(Number) },
|
|
533
|
+
]);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("mirrors show/hide through window events but never identity status", async () => {
|
|
537
|
+
const { mount, controller } = setup();
|
|
538
|
+
window.dispatchEvent(new CustomEvent("persona:showHistory"));
|
|
539
|
+
await flush();
|
|
540
|
+
expect(controller.isHistoryVisible()).toBe(true);
|
|
541
|
+
expect(mount.querySelector(".persona-history-view")).not.toBeNull();
|
|
542
|
+
|
|
543
|
+
window.dispatchEvent(new CustomEvent("persona:hideHistory"));
|
|
544
|
+
await flush();
|
|
545
|
+
expect(controller.isHistoryVisible()).toBe(false);
|
|
546
|
+
|
|
547
|
+
// Identity state stays on the instance bus; there is no window mirror.
|
|
548
|
+
const identityListener = vi.fn();
|
|
549
|
+
window.addEventListener("persona:historyIdentityStatus", identityListener);
|
|
550
|
+
(
|
|
551
|
+
(controller as unknown as { getHistoryIdentityStatus: () => unknown })
|
|
552
|
+
).getHistoryIdentityStatus();
|
|
553
|
+
expect(identityListener).not.toHaveBeenCalled();
|
|
554
|
+
window.removeEventListener("persona:historyIdentityStatus", identityListener);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("forwards every history method through the init handle proxy", async () => {
|
|
558
|
+
const provider = createDemoHistoryProvider({ conversations: SEEDS });
|
|
559
|
+
setHistoryProviderFactory(() => provider);
|
|
560
|
+
const host = document.createElement("div");
|
|
561
|
+
host.id = "history-proxy-host";
|
|
562
|
+
document.body.appendChild(host);
|
|
563
|
+
mounts.push(host);
|
|
564
|
+
const handle = initAgentWidget({
|
|
565
|
+
target: "#history-proxy-host",
|
|
566
|
+
config: {
|
|
567
|
+
apiUrl: "https://api.example.com/chat",
|
|
568
|
+
launcher: { enabled: false },
|
|
569
|
+
persistState: false,
|
|
570
|
+
features: { history: { enabled: true } },
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
controllers.push(handle);
|
|
574
|
+
|
|
575
|
+
for (const method of [
|
|
576
|
+
"listConversations",
|
|
577
|
+
"openConversation",
|
|
578
|
+
"startNewConversation",
|
|
579
|
+
"deleteConversation",
|
|
580
|
+
"clearConversationHistory",
|
|
581
|
+
"resetHistoryIdentity",
|
|
582
|
+
"getHistoryIdentityStatus",
|
|
583
|
+
"showHistory",
|
|
584
|
+
"hideHistory",
|
|
585
|
+
"isHistoryVisible",
|
|
586
|
+
] as const) {
|
|
587
|
+
expect(typeof handle[method]).toBe("function");
|
|
588
|
+
}
|
|
589
|
+
const page = await handle.listConversations();
|
|
590
|
+
expect(page.items.length).toBe(2);
|
|
591
|
+
await handle.showHistory();
|
|
592
|
+
await flush();
|
|
593
|
+
expect(handle.isHistoryVisible()).toBe(true);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
describe("resetHistoryIdentity", () => {
|
|
598
|
+
const resetSetup = (
|
|
599
|
+
remote: () => Promise<{ remoteRevocationConfirmed: boolean }>
|
|
600
|
+
) => {
|
|
601
|
+
const base = createDemoHistoryProvider({ conversations: SEEDS });
|
|
602
|
+
return setup({ provider: withResetDevice(base, remote) });
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
it("wipes local state, closes history, and reports confirmation", async () => {
|
|
606
|
+
const { mount, controller } = resetSetup(async () => ({
|
|
607
|
+
remoteRevocationConfirmed: true,
|
|
608
|
+
}));
|
|
609
|
+
await controller.openConversation("conv-a");
|
|
610
|
+
await flush();
|
|
611
|
+
controller.updatePersistentMetadata(() => ({ sessionId: "sess-1" }));
|
|
612
|
+
await controller.showHistory();
|
|
613
|
+
await flush();
|
|
614
|
+
|
|
615
|
+
const result = await controller.resetHistoryIdentity();
|
|
616
|
+
await flush();
|
|
617
|
+
|
|
618
|
+
expect(result).toEqual({ remoteRevocationConfirmed: true });
|
|
619
|
+
expect(controller.getMessages()).toEqual([]);
|
|
620
|
+
expect(controller.getArtifacts()).toEqual([]);
|
|
621
|
+
expect(controller.getPersistentMetadata()).toEqual({});
|
|
622
|
+
expect(controller.isHistoryVisible()).toBe(false);
|
|
623
|
+
expect(mount.querySelector(".persona-history-view")).toBeNull();
|
|
624
|
+
expect(
|
|
625
|
+
mount.querySelectorAll("#persona-scroll-container [data-message-id]").length
|
|
626
|
+
).toBe(0);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("resolves false after a failed remote revocation and still wipes", async () => {
|
|
630
|
+
const { controller } = resetSetup(async () => ({
|
|
631
|
+
remoteRevocationConfirmed: false,
|
|
632
|
+
}));
|
|
633
|
+
const events: Array<Record<string, unknown>> = [];
|
|
634
|
+
controller.on("history:identityReset", (payload) =>
|
|
635
|
+
events.push(payload as unknown as Record<string, unknown>)
|
|
636
|
+
);
|
|
637
|
+
await controller.openConversation("conv-a");
|
|
638
|
+
await flush();
|
|
639
|
+
|
|
640
|
+
await expect(controller.resetHistoryIdentity()).resolves.toEqual({
|
|
641
|
+
remoteRevocationConfirmed: false,
|
|
642
|
+
});
|
|
643
|
+
expect(controller.getMessages()).toEqual([]);
|
|
644
|
+
expect(events).toEqual([
|
|
645
|
+
{ remoteRevocationConfirmed: false, timestamp: expect.any(Number) },
|
|
646
|
+
]);
|
|
647
|
+
const live = document.querySelector<HTMLElement>("[data-persona-live-region]");
|
|
648
|
+
expect(live!.textContent).toContain("could not confirm");
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("still wipes when a misbehaving provider reset rejects", async () => {
|
|
652
|
+
const { controller } = resetSetup(async () => {
|
|
653
|
+
throw new Error("network down");
|
|
654
|
+
});
|
|
655
|
+
await controller.openConversation("conv-a");
|
|
656
|
+
await flush();
|
|
657
|
+
await expect(controller.resetHistoryIdentity()).rejects.toThrow("network down");
|
|
658
|
+
// The privacy-critical local wipe is unconditional.
|
|
659
|
+
expect(controller.getMessages()).toEqual([]);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("rejects as misuse when the provider cannot reset the device", async () => {
|
|
663
|
+
const { controller } = setup();
|
|
664
|
+
await expect(controller.resetHistoryIdentity()).rejects.toThrow(
|
|
665
|
+
/resetHistoryIdentity/
|
|
666
|
+
);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
describe("distinct clear semantics", () => {
|
|
671
|
+
it("clearChat only clears the local view and never starts a server conversation", async () => {
|
|
672
|
+
const { controller, provider } = setup();
|
|
673
|
+
await controller.openConversation("conv-a");
|
|
674
|
+
await flush();
|
|
675
|
+
const before = (provider as DemoHistoryProvider).getConversationIds();
|
|
676
|
+
|
|
677
|
+
controller.clearChat();
|
|
678
|
+
await flush();
|
|
679
|
+
|
|
680
|
+
expect(controller.getMessages()).toEqual([]);
|
|
681
|
+
expect((provider as DemoHistoryProvider).getConversationIds()).toEqual(before);
|
|
682
|
+
expect((provider as DemoHistoryProvider).getActiveConversationId()).toBe(
|
|
683
|
+
"conv-a"
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("startNewConversation creates a server record and leaves prior records intact", async () => {
|
|
688
|
+
const { controller, provider } = setup();
|
|
689
|
+
const before = (provider as DemoHistoryProvider).getConversationIds();
|
|
690
|
+
await controller.startNewConversation();
|
|
691
|
+
await flush();
|
|
692
|
+
const after = (provider as DemoHistoryProvider).getConversationIds();
|
|
693
|
+
expect(after.length).toBe(before.length + 1);
|
|
694
|
+
expect(after).toEqual(expect.arrayContaining(before));
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("clearConversationHistory deletes the server records and starts fresh", async () => {
|
|
698
|
+
const { controller, provider } = setup();
|
|
699
|
+
await controller.openConversation("conv-a");
|
|
700
|
+
await flush();
|
|
701
|
+
const result = await controller.clearConversationHistory();
|
|
702
|
+
await flush();
|
|
703
|
+
expect(result.deleted).toBe(2);
|
|
704
|
+
expect(controller.getMessages()).toEqual([]);
|
|
705
|
+
// A replacement conversation was prepared, so sending stays possible.
|
|
706
|
+
expect(
|
|
707
|
+
(provider as DemoHistoryProvider).getActiveConversationId()
|
|
708
|
+
).not.toBeNull();
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
});
|