@runtypelabs/persona 4.23.2 → 4.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-CI7OckWI.d.cts → types-Db61aI9J.d.cts} +2 -0
- package/dist/animations/{types-CI7OckWI.d.ts → types-Db61aI9J.d.ts} +2 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +2 -0
- package/dist/animations-extra.d.ts +2 -0
- package/dist/approval-ui-3NMYNXBD.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +278 -37
- package/dist/approval-ui.d.ts +278 -37
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
- package/dist/artifacts-ui.d.cts +273 -25
- package/dist/artifacts-ui.d.ts +273 -25
- package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
- package/dist/codegen.cjs +11 -11
- package/dist/codegen.js +13 -13
- package/dist/context-mentions.d.cts +273 -25
- package/dist/context-mentions.d.ts +273 -25
- package/dist/event-stream-view.d.cts +273 -25
- package/dist/event-stream-view.d.ts +273 -25
- package/dist/forms-ui.d.cts +319 -27
- package/dist/forms-ui.d.ts +319 -27
- package/dist/history-view.d.cts +2 -0
- package/dist/history-view.d.ts +2 -0
- package/dist/index.cjs +70 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +344 -39
- package/dist/index.d.ts +344 -39
- package/dist/index.global.js +57 -54
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +70 -69
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/session-reconnect.d.cts +273 -25
- package/dist/session-reconnect.d.ts +273 -25
- package/dist/smart-dom-reader.d.cts +273 -25
- package/dist/smart-dom-reader.d.ts +273 -25
- package/dist/theme-editor-preview.cjs +74 -73
- package/dist/theme-editor-preview.d.cts +280 -25
- package/dist/theme-editor-preview.d.ts +280 -25
- package/dist/theme-editor-preview.js +51 -50
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +293 -27
- package/dist/theme-editor.d.ts +293 -27
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +1522 -11
- package/dist/theme-reference.d.ts +1522 -11
- package/dist/theme-reference.js +1 -1
- package/dist/voice-runtime-7DA7MJH4.js +1 -0
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +33 -0
- package/dist/voice-runtime.d.ts +33 -0
- package/dist/voice-runtime.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +3 -2
- package/src/__fixtures__/defaults-parity.v4.json +776 -0
- package/src/approval-ui-entry.ts +0 -1
- package/src/approval-ui-loader.ts +5 -4
- package/src/approval-ui.ts +0 -1
- package/src/client-identity-proof.test.ts +193 -0
- package/src/client-session-warm.test.ts +216 -0
- package/src/client-visitor-history.test.ts +6 -0
- package/src/client.ts +122 -30
- package/src/codegen.test.ts +24 -0
- package/src/components/activity-row.test.ts +100 -0
- package/src/components/activity-row.ts +136 -0
- package/src/components/approval-actions.test.ts +8 -5
- package/src/components/approval-actions.ts +19 -6
- package/src/components/approval-bubble.test.ts +56 -9
- package/src/components/approval-bubble.ts +36 -41
- package/src/components/composer-builder.ts +5 -2
- package/src/components/composer-parts.ts +10 -2
- package/src/components/header-builder.test.ts +32 -0
- package/src/components/header-builder.ts +7 -4
- package/src/components/header-layouts.test.ts +12 -0
- package/src/components/header-layouts.ts +3 -2
- package/src/components/launcher.ts +11 -2
- package/src/components/message-bubble.test.ts +22 -0
- package/src/components/message-bubble.ts +7 -3
- package/src/components/panel.test.ts +52 -1
- package/src/components/panel.ts +47 -8
- package/src/components/pill-composer-builder.test.ts +2 -0
- package/src/components/pill-composer-builder.ts +0 -1
- package/src/components/reasoning-bubble.ts +29 -17
- package/src/components/suggestions.test.ts +1 -0
- package/src/components/suggestions.ts +0 -16
- package/src/components/tool-bubble.ts +131 -114
- package/src/components/tool-details.test.ts +87 -0
- package/src/components/tool-details.ts +102 -0
- package/src/components/welcome.test.ts +8 -0
- package/src/components/welcome.ts +4 -2
- package/src/defaults-parity.test.ts +158 -0
- package/src/defaults.test.ts +69 -0
- package/src/defaults.ts +128 -49
- package/src/generated/runtype-openapi-contract.ts +2 -2
- package/src/index-core.ts +4 -0
- package/src/install.test.ts +24 -0
- package/src/install.ts +3 -1
- package/src/launcher-global.ts +2 -1
- package/src/launcher-variants.test.ts +52 -0
- package/src/runtime/init-update-reset.test.ts +21 -0
- package/src/session.ts +59 -7
- package/src/session.voice.test.ts +72 -1
- package/src/styles/widget-styles.test.ts +19 -0
- package/src/styles/widget.css +295 -23
- package/src/theme-editor/index.ts +2 -0
- package/src/theme-editor/preview-utils.test.ts +8 -0
- package/src/theme-editor/preview-utils.ts +7 -3
- package/src/theme-editor/sections.test.ts +31 -0
- package/src/theme-editor/sections.ts +55 -1
- package/src/theme-editor/state-preferences.test.ts +49 -0
- package/src/theme-editor/state.ts +73 -15
- package/src/theme-reference.test.ts +20 -0
- package/src/theme-reference.ts +11 -5
- package/src/tool-call-display-defaults.test.ts +33 -1
- package/src/types/theme.ts +80 -3
- package/src/types.ts +207 -22
- package/src/ui.activity-row.test.ts +91 -0
- package/src/ui.approval-plugin.test.ts +63 -0
- package/src/ui.composer-compact.test.ts +24 -2
- package/src/ui.composer-placement.test.ts +23 -0
- package/src/ui.detached-panel.test.ts +33 -6
- package/src/ui.docked.test.ts +19 -0
- package/src/ui.header-update-stability.test.ts +25 -0
- package/src/ui.identity-proof.test.ts +58 -0
- package/src/ui.launcher-teaser.test.ts +25 -0
- package/src/ui.message-width.test.ts +17 -2
- package/src/ui.reasoning-display.test.ts +92 -0
- package/src/ui.rebuild-composer.test.ts +20 -3
- package/src/ui.scroll.test.ts +32 -0
- package/src/ui.session-init.test.ts +312 -0
- package/src/ui.status-indicator.test.ts +82 -0
- package/src/ui.suggest-replies.test.ts +11 -0
- package/src/ui.ts +422 -74
- package/src/ui.v5-defaults.test.ts +144 -0
- package/src/ui.voice-prewarm.test.ts +222 -0
- package/src/ui.welcome.test.ts +92 -3
- package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
- package/src/utils/code-generators.ts +14 -0
- package/src/utils/composer-compact.test.ts +0 -2
- package/src/utils/composer-compact.ts +2 -4
- package/src/utils/composer-placement.ts +2 -3
- package/src/utils/config-merge.test.ts +81 -5
- package/src/utils/config-merge.ts +13 -3
- package/src/utils/defaults-provenance.ts +67 -0
- package/src/utils/dock.ts +7 -1
- package/src/utils/launcher-variant.ts +16 -0
- package/src/utils/panel-config.ts +97 -0
- package/src/utils/status-indicator.ts +39 -0
- package/src/utils/theme.test.ts +182 -0
- package/src/utils/theme.ts +131 -18
- package/src/utils/tokens.test.ts +122 -1
- package/src/utils/tokens.ts +177 -17
- package/src/voice/runtype-voice-provider.ts +238 -27
- package/src/voice/voice.test.ts +272 -2
- package/src/welcome.test.ts +23 -0
- package/src/welcome.ts +16 -9
- package/dist/approval-ui-K7R5Q5OC.js +0 -1
- package/dist/voice-runtime-EFR3R5ES.js +0 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createAgentExperience } from "./ui";
|
|
5
|
+
import type { AgentWidgetConfig } from "./types";
|
|
6
|
+
|
|
7
|
+
let controller: ReturnType<typeof createAgentExperience> | undefined;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
controller?.destroy();
|
|
11
|
+
controller = undefined;
|
|
12
|
+
document.body.replaceChildren();
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
type Reply = () => Response | Promise<Response>;
|
|
18
|
+
|
|
19
|
+
const initResponse = () =>
|
|
20
|
+
Response.json({
|
|
21
|
+
sessionId: "session-1",
|
|
22
|
+
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
23
|
+
flow: { id: "agent-1", name: "Agent", description: null },
|
|
24
|
+
config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function mountWidget(config: Partial<AgentWidgetConfig> = {}, initReplies: Reply[] = []) {
|
|
28
|
+
const inits: string[] = [];
|
|
29
|
+
const chats: string[] = [];
|
|
30
|
+
const other: string[] = [];
|
|
31
|
+
vi.stubGlobal(
|
|
32
|
+
"fetch",
|
|
33
|
+
vi.fn(async (url: string) => {
|
|
34
|
+
if (url.endsWith("/v1/client/init")) {
|
|
35
|
+
const reply = initReplies[inits.length];
|
|
36
|
+
inits.push(url);
|
|
37
|
+
return reply ? reply() : initResponse();
|
|
38
|
+
}
|
|
39
|
+
if (url.endsWith("/v1/client/chat")) chats.push(url);
|
|
40
|
+
else other.push(url);
|
|
41
|
+
return new Response('data: {"type":"done"}\n\n', {
|
|
42
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
43
|
+
});
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
const mount = document.createElement("div");
|
|
47
|
+
document.body.appendChild(mount);
|
|
48
|
+
controller = createAgentExperience(mount, {
|
|
49
|
+
apiUrl: "https://example.com",
|
|
50
|
+
clientToken: "ct_test_session_init",
|
|
51
|
+
agentId: "agent-1",
|
|
52
|
+
launcher: { enabled: false },
|
|
53
|
+
persistState: false,
|
|
54
|
+
...config,
|
|
55
|
+
} as AgentWidgetConfig);
|
|
56
|
+
const textarea = mount.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]")!;
|
|
57
|
+
const form = mount.querySelector<HTMLFormElement>("[data-persona-composer-form]")!;
|
|
58
|
+
const type = (value: string) => {
|
|
59
|
+
textarea.value = value;
|
|
60
|
+
textarea.dispatchEvent(
|
|
61
|
+
new InputEvent("input", { bubbles: true, inputType: "insertText", data: value.slice(-1) })
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
const submit = () =>
|
|
65
|
+
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
66
|
+
return { mount, textarea, form, type, submit, inits, chats, other };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Lets any fire-and-forget init reach fetch before asserting it did not.
|
|
70
|
+
const settle = async () => {
|
|
71
|
+
for (let i = 0; i < 5; i += 1) await new Promise((r) => setTimeout(r, 0));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("sessionInit: 'input' (default)", () => {
|
|
75
|
+
it("does not init on mount or on focus, and inits on the first keystroke", async () => {
|
|
76
|
+
const w = mountWidget({ autoFocusInput: true });
|
|
77
|
+
await settle();
|
|
78
|
+
w.textarea.focus();
|
|
79
|
+
w.textarea.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
|
80
|
+
await settle();
|
|
81
|
+
expect(w.inits).toHaveLength(0);
|
|
82
|
+
|
|
83
|
+
w.type("h");
|
|
84
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
85
|
+
w.type("he");
|
|
86
|
+
w.type("hel");
|
|
87
|
+
await settle();
|
|
88
|
+
expect(w.inits).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("ignores whitespace-only input and programmatic value changes", async () => {
|
|
92
|
+
const w = mountWidget();
|
|
93
|
+
w.type(" ");
|
|
94
|
+
// Widget-internal edits (draft restore, setMessage, history recall)
|
|
95
|
+
// dispatch a plain Event with no inputType.
|
|
96
|
+
w.textarea.value = "restored draft";
|
|
97
|
+
w.textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
98
|
+
controller!.setMessage("from the host");
|
|
99
|
+
await settle();
|
|
100
|
+
expect(w.inits).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a send right after the first keystroke issues exactly one init", async () => {
|
|
104
|
+
const w = mountWidget();
|
|
105
|
+
w.type("hi");
|
|
106
|
+
w.submit();
|
|
107
|
+
await vi.waitFor(() => expect(w.chats).toHaveLength(1));
|
|
108
|
+
expect(w.inits).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a send while the early init is in flight reuses it", async () => {
|
|
112
|
+
let release!: () => void;
|
|
113
|
+
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
114
|
+
const w = mountWidget({}, [
|
|
115
|
+
async () => {
|
|
116
|
+
await gate;
|
|
117
|
+
return initResponse();
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
w.type("hi");
|
|
121
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
122
|
+
w.submit();
|
|
123
|
+
await settle();
|
|
124
|
+
expect(w.chats).toHaveLength(0);
|
|
125
|
+
release();
|
|
126
|
+
await vi.waitFor(() => expect(w.chats).toHaveLength(1));
|
|
127
|
+
expect(w.inits).toHaveLength(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("keeps the server welcome ahead of a send that raced the early init", async () => {
|
|
131
|
+
let release!: () => void;
|
|
132
|
+
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
133
|
+
const w = mountWidget({}, [
|
|
134
|
+
async () => {
|
|
135
|
+
await gate;
|
|
136
|
+
return Response.json({
|
|
137
|
+
sessionId: "session-1",
|
|
138
|
+
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
139
|
+
flow: { id: "agent-1", name: "Agent", description: null },
|
|
140
|
+
config: { welcomeMessage: "Hi! How can I help?", placeholder: "Ask...", theme: null },
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
w.type("hello");
|
|
145
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
146
|
+
w.submit();
|
|
147
|
+
await settle();
|
|
148
|
+
release();
|
|
149
|
+
await vi.waitFor(() => expect(w.chats).toHaveLength(1));
|
|
150
|
+
await vi.waitFor(() =>
|
|
151
|
+
expect(controller!.getMessages().map((m) => [m.role, m.content]).slice(0, 2)).toEqual([
|
|
152
|
+
["assistant", "Hi! How can I help?"],
|
|
153
|
+
["user", "hello"],
|
|
154
|
+
])
|
|
155
|
+
);
|
|
156
|
+
expect(w.inits).toHaveLength(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("swallows an early-init failure and the send retries init", async () => {
|
|
160
|
+
const onSessionExpired = vi.fn();
|
|
161
|
+
const w = mountWidget({ onSessionExpired }, [
|
|
162
|
+
() => Response.json({ error: "boom" }, { status: 500 }),
|
|
163
|
+
]);
|
|
164
|
+
w.type("h");
|
|
165
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
166
|
+
await settle();
|
|
167
|
+
w.type("hi");
|
|
168
|
+
await settle();
|
|
169
|
+
expect(w.inits).toHaveLength(1);
|
|
170
|
+
expect(onSessionExpired).not.toHaveBeenCalled();
|
|
171
|
+
expect(w.mount.querySelector('[data-message-id^="error"]')).toBeNull();
|
|
172
|
+
|
|
173
|
+
w.submit();
|
|
174
|
+
await vi.waitFor(() => expect(w.chats).toHaveLength(1));
|
|
175
|
+
expect(w.inits).toHaveLength(2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("sessionInit: 'focus'", () => {
|
|
180
|
+
it("does not init on autofocus or programmatic focus", async () => {
|
|
181
|
+
const w = mountWidget({ sessionInit: "focus", autoFocusInput: true });
|
|
182
|
+
await settle();
|
|
183
|
+
w.textarea.focus();
|
|
184
|
+
await settle();
|
|
185
|
+
expect(w.inits).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("inits on a pointer focus of the composer", async () => {
|
|
189
|
+
const w = mountWidget({ sessionInit: "focus" });
|
|
190
|
+
w.textarea.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
|
191
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("inits when the visitor tabs into the composer", async () => {
|
|
195
|
+
const w = mountWidget({ sessionInit: "focus" });
|
|
196
|
+
w.textarea.dispatchEvent(new KeyboardEvent("keyup", { key: "Tab", bubbles: true }));
|
|
197
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("sessionInit: 'send'", () => {
|
|
202
|
+
it("inits only on send", async () => {
|
|
203
|
+
const w = mountWidget({ sessionInit: "send" });
|
|
204
|
+
w.textarea.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
|
205
|
+
w.type("hi");
|
|
206
|
+
await settle();
|
|
207
|
+
expect(w.inits).toHaveLength(0);
|
|
208
|
+
w.submit();
|
|
209
|
+
await vi.waitFor(() => expect(w.chats).toHaveLength(1));
|
|
210
|
+
expect(w.inits).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("sessionInit: 'mount'", () => {
|
|
215
|
+
it("inits on mount, as before this option existed", async () => {
|
|
216
|
+
const w = mountWidget({ sessionInit: "mount" });
|
|
217
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("custom sessionInit hooks", () => {
|
|
222
|
+
it("a function trigger inits when the host calls warm (e.g. on hover)", async () => {
|
|
223
|
+
const cleanup = vi.fn();
|
|
224
|
+
const hook = vi.fn(({ warm, mount }: { warm: () => void; mount: HTMLElement }) => {
|
|
225
|
+
mount.addEventListener("pointerenter", warm);
|
|
226
|
+
return cleanup;
|
|
227
|
+
});
|
|
228
|
+
const w = mountWidget({ sessionInit: hook });
|
|
229
|
+
expect(hook).toHaveBeenCalledTimes(1);
|
|
230
|
+
w.type("hi");
|
|
231
|
+
await settle();
|
|
232
|
+
expect(w.inits).toHaveLength(0);
|
|
233
|
+
|
|
234
|
+
w.mount.dispatchEvent(new Event("pointerenter"));
|
|
235
|
+
w.mount.dispatchEvent(new Event("pointerenter"));
|
|
236
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
237
|
+
await settle();
|
|
238
|
+
expect(w.inits).toHaveLength(1);
|
|
239
|
+
|
|
240
|
+
controller!.destroy();
|
|
241
|
+
controller = undefined;
|
|
242
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("update() swaps a custom trigger: old cleanup runs, new hook starts", async () => {
|
|
246
|
+
const firstCleanup = vi.fn();
|
|
247
|
+
const first = vi.fn(() => firstCleanup);
|
|
248
|
+
const secondCleanup = vi.fn();
|
|
249
|
+
const second = vi.fn(({ warm, mount }: { warm: () => void; mount: HTMLElement }) => {
|
|
250
|
+
mount.addEventListener("pointerenter", warm);
|
|
251
|
+
return secondCleanup;
|
|
252
|
+
});
|
|
253
|
+
const w = mountWidget({ sessionInit: first });
|
|
254
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
255
|
+
controller!.update({ sessionInit: second });
|
|
256
|
+
expect(firstCleanup).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
258
|
+
// An unrelated update does not re-run the same hook.
|
|
259
|
+
controller!.update({ debug: false });
|
|
260
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
261
|
+
w.mount.dispatchEvent(new Event("pointerenter"));
|
|
262
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
263
|
+
controller!.update({ sessionInit: "send" });
|
|
264
|
+
expect(secondCleanup).toHaveBeenCalledTimes(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("a throwing hook cleanup does not block the replacement hook or destroy", async () => {
|
|
268
|
+
const throwingCleanup = vi.fn(() => {
|
|
269
|
+
throw new Error("host bug");
|
|
270
|
+
});
|
|
271
|
+
const next = vi.fn();
|
|
272
|
+
mountWidget({ sessionInit: () => throwingCleanup });
|
|
273
|
+
expect(() => controller!.update({ sessionInit: next })).not.toThrow();
|
|
274
|
+
expect(throwingCleanup).toHaveBeenCalledTimes(1);
|
|
275
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
276
|
+
// The failed cleanup is not retried on later updates.
|
|
277
|
+
controller!.update({ sessionInit: "send" });
|
|
278
|
+
expect(throwingCleanup).toHaveBeenCalledTimes(1);
|
|
279
|
+
controller!.update({ sessionInit: () => throwingCleanup });
|
|
280
|
+
expect(() => controller!.destroy()).not.toThrow();
|
|
281
|
+
controller = undefined;
|
|
282
|
+
expect(throwingCleanup).toHaveBeenCalledTimes(2);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("controller.warmSession() inits imperatively", async () => {
|
|
286
|
+
const w = mountWidget({ sessionInit: "send" });
|
|
287
|
+
controller!.warmSession();
|
|
288
|
+
controller!.warmSession();
|
|
289
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
290
|
+
await settle();
|
|
291
|
+
expect(w.inits).toHaveLength(1);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("non-client-token mode", () => {
|
|
296
|
+
it("proxy mode ignores sessionInit and never calls /v1/client/init", async () => {
|
|
297
|
+
const hook = vi.fn();
|
|
298
|
+
const w = mountWidget({
|
|
299
|
+
clientToken: undefined,
|
|
300
|
+
apiUrl: "https://example.com/api/chat/dispatch",
|
|
301
|
+
sessionInit: "mount",
|
|
302
|
+
});
|
|
303
|
+
w.textarea.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
|
304
|
+
w.type("hi");
|
|
305
|
+
controller!.warmSession();
|
|
306
|
+
await settle();
|
|
307
|
+
expect(w.inits).toHaveLength(0);
|
|
308
|
+
expect(w.other).toHaveLength(0);
|
|
309
|
+
controller!.update({ sessionInit: hook });
|
|
310
|
+
expect(hook).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createAgentExperience } from "./ui";
|
|
4
|
+
import type { AgentWidgetConfig } from "./types";
|
|
5
|
+
import { isStatusIndicatorVisible } from "./utils/status-indicator";
|
|
6
|
+
|
|
7
|
+
const controllers: ReturnType<typeof createAgentExperience>[] = [];
|
|
8
|
+
const mountWidget = (config: Partial<AgentWidgetConfig>) => {
|
|
9
|
+
window.scrollTo = vi.fn();
|
|
10
|
+
const mount = document.createElement("div");
|
|
11
|
+
document.body.append(mount);
|
|
12
|
+
const controller = createAgentExperience(mount, {
|
|
13
|
+
apiUrl: "/dispatch", persistState: false, launcher: { enabled: false }, ...config,
|
|
14
|
+
});
|
|
15
|
+
controllers.push(controller);
|
|
16
|
+
return {
|
|
17
|
+
controller,
|
|
18
|
+
status: mount.querySelector<HTMLElement>("[data-persona-composer-status]")!,
|
|
19
|
+
form: mount.querySelector<HTMLElement>("[data-persona-composer-form]")!,
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
controllers.splice(0).forEach((controller) => controller.destroy());
|
|
24
|
+
document.body.replaceChildren();
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe.each([false, true])("status indicator (v5=%s)", (v5Defaults) => {
|
|
29
|
+
it("selects the default mode and placement", () => {
|
|
30
|
+
const { status, form } = mountWidget({ future: { v5Defaults } });
|
|
31
|
+
expect(status.style.display).toBe(v5Defaults ? "none" : "");
|
|
32
|
+
expect(v5Defaults ? status.nextElementSibling : form.nextElementSibling)
|
|
33
|
+
.toBe(v5Defaults ? form : status);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("shows transient connection and error states, hiding idle and streaming", () => {
|
|
37
|
+
const { controller, status, form } = mountWidget({
|
|
38
|
+
future: { v5Defaults }, statusIndicator: { mode: "transient", errorText: "Try again" },
|
|
39
|
+
});
|
|
40
|
+
expect(status.nextElementSibling).toBe(form);
|
|
41
|
+
for (const state of ["connecting", "connected", "error", "idle"] as const) {
|
|
42
|
+
controller.injectTestMessage({ type: "status", status: state });
|
|
43
|
+
expect(status.style.display, state).toBe(state === "connecting" || state === "error" ? "" : "none");
|
|
44
|
+
if (state === "error") expect(status.textContent).toBe("Try again");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("honors explicit visibility and permits live mode changes", () => {
|
|
49
|
+
const { controller, status, form } = mountWidget({
|
|
50
|
+
future: { v5Defaults }, statusIndicator: { mode: "always", idleText: "Ready" },
|
|
51
|
+
});
|
|
52
|
+
expect(form.nextElementSibling).toBe(status);
|
|
53
|
+
expect(status.style.display).toBe("");
|
|
54
|
+
controller.update({ statusIndicator: { mode: "transient" } });
|
|
55
|
+
expect(status.nextElementSibling).toBe(form);
|
|
56
|
+
expect(status.style.display).toBe("none");
|
|
57
|
+
controller.update({ statusIndicator: { visible: false } });
|
|
58
|
+
controller.injectTestMessage({ type: "status", status: "connecting" });
|
|
59
|
+
expect(status.style.display).toBe("none");
|
|
60
|
+
controller.update({ statusIndicator: { mode: "always", visible: true } });
|
|
61
|
+
expect(form.nextElementSibling).toBe(status);
|
|
62
|
+
expect(status.style.display).toBe("");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps a composer lock reason readable in transient mode", () => {
|
|
66
|
+
const { controller, status } = mountWidget({
|
|
67
|
+
future: { v5Defaults }, statusIndicator: { mode: "transient" },
|
|
68
|
+
composer: { inputDisabled: { reason: "Sign in to continue" } },
|
|
69
|
+
});
|
|
70
|
+
expect(status.textContent).toBe("Sign in to continue");
|
|
71
|
+
expect(status.style.display).toBe("");
|
|
72
|
+
controller.injectTestMessage({ type: "status", status: "connected" });
|
|
73
|
+
expect(status.textContent).toBe("Sign in to continue");
|
|
74
|
+
controller.update({ composer: { inputDisabled: false } });
|
|
75
|
+
expect(status.style.display).toBe("none");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each(["paused", "resuming"])("shows reconnect status %s in transient mode", (status) => {
|
|
80
|
+
expect(isStatusIndicatorVisible({ mode: "transient" }, status)).toBe(true);
|
|
81
|
+
expect(isStatusIndicatorVisible({ mode: "transient", visible: false }, status)).toBe(false);
|
|
82
|
+
});
|
|
@@ -189,6 +189,17 @@ describe("suggest_replies chips UI", () => {
|
|
|
189
189
|
scrolling.controller.destroy();
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
it("caps legacy suggestion chips at the same four-item starter default", () => {
|
|
193
|
+
const { mount, controller } = makeController({
|
|
194
|
+
suggestionChips: ["One", "Two", "Three", "Four", "Five"],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(mount.querySelectorAll("button.persona-suggestion")).toHaveLength(4);
|
|
198
|
+
expect(chipButtons(mount, "Five")).toHaveLength(0);
|
|
199
|
+
|
|
200
|
+
controller.destroy();
|
|
201
|
+
});
|
|
202
|
+
|
|
192
203
|
it("wraps follow-ups by default", () => {
|
|
193
204
|
// 2-4 compact chips always fit at widget width; a scroll strip hides
|
|
194
205
|
// most of the set behind a fade, so scroll is opt-in for large sets.
|