@runtypelabs/persona 4.23.1 → 4.24.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-9Vxm-PPz.d.cts → types-Db61aI9J.d.cts} +25 -2
- package/dist/animations/{types-9Vxm-PPz.d.ts → types-Db61aI9J.d.ts} +25 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +25 -2
- package/dist/animations-extra.d.ts +25 -2
- package/dist/approval-ui-3NMYNXBD.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +261 -40
- package/dist/approval-ui.d.ts +261 -40
- 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 +256 -28
- package/dist/artifacts-ui.d.ts +256 -28
- 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 +256 -28
- package/dist/context-mentions.d.ts +256 -28
- package/dist/event-stream-view.d.cts +256 -28
- package/dist/event-stream-view.d.ts +256 -28
- package/dist/forms-ui.d.cts +291 -30
- package/dist/forms-ui.d.ts +291 -30
- package/dist/history-view.d.cts +25 -2
- package/dist/history-view.d.ts +25 -2
- package/dist/index.cjs +70 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +337 -51
- package/dist/index.d.ts +337 -51
- 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 +256 -28
- package/dist/session-reconnect.d.ts +256 -28
- package/dist/smart-dom-reader.d.cts +256 -28
- package/dist/smart-dom-reader.d.ts +256 -28
- package/dist/theme-editor-preview.cjs +74 -73
- package/dist/theme-editor-preview.d.cts +263 -28
- package/dist/theme-editor-preview.d.ts +263 -28
- package/dist/theme-editor-preview.js +51 -50
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +276 -30
- package/dist/theme-editor.d.ts +276 -30
- 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/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.tool-pair-replay.test.ts +325 -0
- package/src/client.ts +154 -61
- 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 +28 -13
- package/src/index-core.ts +2 -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 +61 -9
- 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 +179 -23
- 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 +363 -74
- package/src/ui.v5-defaults.test.ts +144 -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/utils/tool-pair-replay.ts +100 -0
- package/src/welcome.test.ts +23 -0
- package/src/welcome.ts +16 -9
- package/dist/approval-ui-K7R5Q5OC.js +0 -1
package/src/approval-ui-entry.ts
CHANGED
|
@@ -12,18 +12,19 @@ import type { ApprovalUiDeps } from "./components/approval-deps";
|
|
|
12
12
|
* `history-view-loader.ts` for the full rationale).
|
|
13
13
|
*/
|
|
14
14
|
export type ApprovalUiModule = {
|
|
15
|
-
approvalDetailsExpansionState: Map<string, boolean>;
|
|
16
15
|
createApprovalBubble: (
|
|
17
16
|
message: AgentWidgetMessage,
|
|
18
|
-
config?: AgentWidgetConfig
|
|
17
|
+
config?: AgentWidgetConfig,
|
|
18
|
+
expansionState?: Map<string, boolean>
|
|
19
19
|
) => HTMLElement;
|
|
20
20
|
updateApprovalDetailsUI: (
|
|
21
21
|
messageId: string,
|
|
22
22
|
bubble: HTMLElement,
|
|
23
|
-
config?: AgentWidgetConfig
|
|
23
|
+
config?: AgentWidgetConfig,
|
|
24
|
+
expansionState?: Map<string, boolean>
|
|
24
25
|
) => void;
|
|
25
26
|
humanizeToolName: (toolName: string) => string;
|
|
26
|
-
createBuiltInApprovalPlugin: () => {
|
|
27
|
+
createBuiltInApprovalPlugin: (expansionState?: Map<string, boolean>) => {
|
|
27
28
|
plugin: AgentWidgetPlugin;
|
|
28
29
|
teardown: () => void;
|
|
29
30
|
};
|
package/src/approval-ui.ts
CHANGED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AgentWidgetClient } from './client';
|
|
3
|
+
import type { AgentWidgetConfig, AgentWidgetEvent } from './types';
|
|
4
|
+
|
|
5
|
+
const success = () => new Response('data: {"type":"done"}\n\n', {
|
|
6
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function setup(config: Partial<AgentWidgetConfig> = {}) {
|
|
10
|
+
const getIdentityProof = vi.fn<() => string | null | Promise<string | null>>()
|
|
11
|
+
.mockReturnValue('fresh-proof');
|
|
12
|
+
const onSessionExpired = vi.fn();
|
|
13
|
+
const client = new AgentWidgetClient({
|
|
14
|
+
apiUrl: 'https://example.com',
|
|
15
|
+
clientToken: 'ct_test_identity',
|
|
16
|
+
identityProvider: 'clerk',
|
|
17
|
+
getIdentityProof,
|
|
18
|
+
onSessionExpired,
|
|
19
|
+
...config,
|
|
20
|
+
});
|
|
21
|
+
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
22
|
+
const chat = vi.fn<() => Response>().mockImplementation(success);
|
|
23
|
+
let sessionNumber = 0;
|
|
24
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
|
|
25
|
+
requests.push({ url, body: JSON.parse(String(init.body)) });
|
|
26
|
+
if (url.endsWith('/init')) {
|
|
27
|
+
return Response.json({
|
|
28
|
+
sessionId: `session-${++sessionNumber}`,
|
|
29
|
+
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
30
|
+
flow: { id: 'agent-1', name: 'Agent', description: null },
|
|
31
|
+
config: { welcomeMessage: null, placeholder: 'Ask...', theme: null },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return chat();
|
|
35
|
+
}));
|
|
36
|
+
const events: AgentWidgetEvent[] = [];
|
|
37
|
+
const run = (signal?: AbortSignal) => client.dispatch({
|
|
38
|
+
messages: [{ id: 'message-1', role: 'user', content: 'Show my orders', createdAt: '2026-09-25' }],
|
|
39
|
+
signal,
|
|
40
|
+
}, (event) => events.push(event));
|
|
41
|
+
const chatBodies = () => requests.filter((r) => r.url.endsWith('/chat')).map((r) => r.body);
|
|
42
|
+
return { client, getIdentityProof, chat, requests, run, chatBodies, events, onSessionExpired };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
vi.unstubAllGlobals();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('client token execution identity', () => {
|
|
51
|
+
it.each([undefined, false, true])('sends the first turn proof with history enabled=%s', async (enabled) => {
|
|
52
|
+
const h = setup({ features: { history: { enabled } } });
|
|
53
|
+
h.chat.mockImplementation(() => h.chatBodies().at(-1)?.identityProof
|
|
54
|
+
? success()
|
|
55
|
+
: Response.json({ error: 'Tenant scope required' }, { status: 403 }));
|
|
56
|
+
await h.run();
|
|
57
|
+
expect(h.chatBodies()[0].identityProof).toEqual({ provider: 'clerk', token: 'fresh-proof' });
|
|
58
|
+
expect(h.getIdentityProof).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('gets a new proof on every turn without persisting it into session state', async () => {
|
|
62
|
+
const setStoredSessionId = vi.fn();
|
|
63
|
+
const h = setup({ setStoredSessionId });
|
|
64
|
+
h.getIdentityProof.mockReturnValueOnce('first-proof').mockReturnValueOnce('second-proof');
|
|
65
|
+
await h.run();
|
|
66
|
+
await h.run();
|
|
67
|
+
expect(h.chatBodies().map((b) => b.identityProof)).toEqual([
|
|
68
|
+
{ provider: 'clerk', token: 'first-proof' },
|
|
69
|
+
{ provider: 'clerk', token: 'second-proof' },
|
|
70
|
+
]);
|
|
71
|
+
expect(JSON.stringify(setStoredSessionId.mock.calls)).not.toContain('proof');
|
|
72
|
+
expect(h.requests.filter((r) => r.url.endsWith('/init'))).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('refreshes the proof when an expired session causes a retry', async () => {
|
|
76
|
+
const h = setup();
|
|
77
|
+
h.getIdentityProof.mockReturnValueOnce('before-renewal').mockReturnValueOnce('after-renewal');
|
|
78
|
+
h.chat.mockImplementationOnce(() => Response.json({ error: 'Session not found or expired' }, { status: 401 }));
|
|
79
|
+
await h.run();
|
|
80
|
+
expect(h.chatBodies().map((b) => b.identityProof)).toEqual([
|
|
81
|
+
{ provider: 'clerk', token: 'before-renewal' },
|
|
82
|
+
{ provider: 'clerk', token: 'after-renewal' },
|
|
83
|
+
]);
|
|
84
|
+
expect(h.chatBodies()[1].sessionId).toBe('session-2');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('refreshes the proof on a tool registry cache-miss retry', async () => {
|
|
88
|
+
const h = setup();
|
|
89
|
+
(h.client as unknown as { webMcpBridge: unknown }).webMcpBridge = {
|
|
90
|
+
snapshotForDispatch: () => [{ name: 'search', description: 'Search', origin: 'webmcp' }],
|
|
91
|
+
};
|
|
92
|
+
await h.run();
|
|
93
|
+
h.getIdentityProof.mockReturnValueOnce('before-retry').mockReturnValueOnce('after-retry');
|
|
94
|
+
h.chat.mockImplementationOnce(() => Response.json({ error: 'client_tools_resend_required' }, { status: 409 }));
|
|
95
|
+
await h.run();
|
|
96
|
+
expect(h.chatBodies().slice(1).map((b) => b.identityProof)).toEqual([
|
|
97
|
+
{ provider: 'clerk', token: 'before-retry' },
|
|
98
|
+
{ provider: 'clerk', token: 'after-retry' },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it.each([null, ''])('does not send a chat request when the configured provider returns %s', async (proof) => {
|
|
103
|
+
const h = setup();
|
|
104
|
+
h.getIdentityProof.mockReturnValue(proof);
|
|
105
|
+
await expect(h.run()).rejects.toThrow('identity proof');
|
|
106
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
107
|
+
expect(h.events.some((event) => event.type === 'error')).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('fails visibly without exposing an exception from the proof callback', async () => {
|
|
111
|
+
const h = setup();
|
|
112
|
+
h.getIdentityProof.mockRejectedValue(new Error('secret-proof-in-provider-error'));
|
|
113
|
+
await expect(h.run()).rejects.toThrow('identity proof provider failed');
|
|
114
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
115
|
+
expect(h.events.filter((e) => e.type === 'error').map((e) => e.error.message).join()).not.toContain('secret-proof');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('requires a callback when a provider is configured', async () => {
|
|
119
|
+
const h = setup({ getIdentityProof: undefined });
|
|
120
|
+
await expect(h.run()).rejects.toThrow('getIdentityProof');
|
|
121
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('preserves history-only callbacks when execution identity is not configured', async () => {
|
|
125
|
+
const h = setup({ identityProvider: undefined });
|
|
126
|
+
await h.run();
|
|
127
|
+
expect(h.getIdentityProof).not.toHaveBeenCalled();
|
|
128
|
+
expect(h.chatBodies()[0]).not.toHaveProperty('identityProof');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not send a turn cancelled while its proof is being obtained', async () => {
|
|
132
|
+
const h = setup();
|
|
133
|
+
let resolveProof!: (proof: string) => void;
|
|
134
|
+
h.getIdentityProof.mockReturnValue(new Promise<string>((resolve) => { resolveProof = resolve; }));
|
|
135
|
+
const abort = new AbortController();
|
|
136
|
+
const running = h.run(abort.signal).catch((error: unknown) => error);
|
|
137
|
+
await vi.waitFor(() => expect(h.getIdentityProof).toHaveBeenCalled());
|
|
138
|
+
abort.abort();
|
|
139
|
+
resolveProof('late-proof');
|
|
140
|
+
expect(await running).toMatchObject({ name: 'AbortError' });
|
|
141
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('settles cancellation even when the identity provider never resolves', async () => {
|
|
145
|
+
const h = setup();
|
|
146
|
+
h.getIdentityProof.mockReturnValue(new Promise<string>(() => {}));
|
|
147
|
+
const abort = new AbortController();
|
|
148
|
+
const settled = vi.fn();
|
|
149
|
+
const running = h.run(abort.signal).then(settled, settled);
|
|
150
|
+
await vi.waitFor(() => expect(h.getIdentityProof).toHaveBeenCalled());
|
|
151
|
+
abort.abort();
|
|
152
|
+
await vi.waitFor(() => expect(settled).toHaveBeenCalledWith(expect.objectContaining({ name: 'AbortError' })));
|
|
153
|
+
await running;
|
|
154
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
155
|
+
|
|
156
|
+
h.getIdentityProof.mockReturnValue('replacement-proof');
|
|
157
|
+
await h.run();
|
|
158
|
+
expect(h.chatBodies()).toHaveLength(1);
|
|
159
|
+
expect(h.chatBodies()[0].identityProof).toEqual({ provider: 'clerk', token: 'replacement-proof' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('handles a provider rejection after cancellation without exposing it', async () => {
|
|
163
|
+
const h = setup();
|
|
164
|
+
let rejectProof!: (error: Error) => void;
|
|
165
|
+
h.getIdentityProof.mockReturnValue(new Promise<string>((_, reject) => { rejectProof = reject; }));
|
|
166
|
+
const abort = new AbortController();
|
|
167
|
+
const settled = vi.fn();
|
|
168
|
+
const running = h.run(abort.signal).then(settled, settled);
|
|
169
|
+
await vi.waitFor(() => expect(h.getIdentityProof).toHaveBeenCalled());
|
|
170
|
+
abort.abort();
|
|
171
|
+
await vi.waitFor(() => expect(settled).toHaveBeenCalledWith(expect.objectContaining({ name: 'AbortError' })));
|
|
172
|
+
rejectProof(new Error('secret-from-late-provider-failure'));
|
|
173
|
+
await running;
|
|
174
|
+
expect(h.chat).not.toHaveBeenCalled();
|
|
175
|
+
expect(h.events.filter((e) => e.type === 'error').map((e) => e.error.message).join()).not.toContain('secret-from');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('does not log the proof in debug mode', async () => {
|
|
179
|
+
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
180
|
+
const h = setup({ debug: true });
|
|
181
|
+
await h.run();
|
|
182
|
+
expect(h.chatBodies()[0].identityProof).toBeDefined();
|
|
183
|
+
expect(JSON.stringify(debug.mock.calls)).not.toContain('fresh-proof');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('reports a rejected proof without calling it a session expiry or retrying', async () => {
|
|
187
|
+
const h = setup();
|
|
188
|
+
h.chat.mockImplementation(() => Response.json({ error: 'invalid_identity_proof', errorDescription: 'invalid_signature' }, { status: 401 }));
|
|
189
|
+
await expect(h.run()).rejects.toThrow('identity proof was rejected');
|
|
190
|
+
expect(h.chat).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(h.onSessionExpired).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "./client";
|
|
3
|
+
import type { ClientSession } from "./types";
|
|
4
|
+
|
|
5
|
+
const initBody = (sessionId: string, ttl = 600_000) => ({
|
|
6
|
+
sessionId,
|
|
7
|
+
expiresAt: new Date(Date.now() + ttl).toISOString(),
|
|
8
|
+
flow: { id: "agent-1", name: "Agent", description: null },
|
|
9
|
+
config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
type Reply = () => Response | Promise<Response>;
|
|
13
|
+
|
|
14
|
+
function setup(replies: Reply[] = []) {
|
|
15
|
+
const initRequests: Array<{ body: Record<string, unknown>; headers: Headers }> = [];
|
|
16
|
+
const chatRequests: string[] = [];
|
|
17
|
+
let n = 0;
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
"fetch",
|
|
20
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
21
|
+
if (url.endsWith("/v1/client/init")) {
|
|
22
|
+
initRequests.push({
|
|
23
|
+
body: JSON.parse(String(init.body)),
|
|
24
|
+
headers: new Headers(init.headers),
|
|
25
|
+
});
|
|
26
|
+
const reply = replies[n++];
|
|
27
|
+
return reply ? reply() : Response.json(initBody(`session-${n}`));
|
|
28
|
+
}
|
|
29
|
+
chatRequests.push(url);
|
|
30
|
+
return new Response('data: {"type":"done"}\n\n', {
|
|
31
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
32
|
+
});
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
const onSessionExpired = vi.fn();
|
|
36
|
+
const onSessionInit = vi.fn();
|
|
37
|
+
const client = new AgentWidgetClient({
|
|
38
|
+
clientToken: "ct_test_warm",
|
|
39
|
+
apiUrl: "https://example.com",
|
|
40
|
+
onSessionExpired,
|
|
41
|
+
onSessionInit,
|
|
42
|
+
});
|
|
43
|
+
return { client, initRequests, chatRequests, onSessionExpired, onSessionInit };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const send = (client: AgentWidgetClient) =>
|
|
47
|
+
client.dispatch(
|
|
48
|
+
{
|
|
49
|
+
messages: [
|
|
50
|
+
{ id: "u1", role: "user", content: "hi", createdAt: new Date().toISOString() },
|
|
51
|
+
],
|
|
52
|
+
assistantMessageId: "a1",
|
|
53
|
+
},
|
|
54
|
+
() => {}
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.unstubAllGlobals();
|
|
59
|
+
vi.restoreAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("AgentWidgetClient.warmSession", () => {
|
|
63
|
+
it("an early init followed immediately by a send issues exactly one init", async () => {
|
|
64
|
+
const { client, initRequests, chatRequests } = setup();
|
|
65
|
+
void client.warmSession();
|
|
66
|
+
await send(client);
|
|
67
|
+
expect(initRequests).toHaveLength(1);
|
|
68
|
+
expect(chatRequests).toHaveLength(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("a send while the early init is still in flight reuses it", async () => {
|
|
72
|
+
let release!: () => void;
|
|
73
|
+
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
74
|
+
const { client, initRequests, chatRequests } = setup([
|
|
75
|
+
async () => {
|
|
76
|
+
await gate;
|
|
77
|
+
return Response.json(initBody("session-slow"));
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
const warmed = client.warmSession();
|
|
81
|
+
const sent = send(client);
|
|
82
|
+
await vi.waitFor(() => expect(initRequests).toHaveLength(1));
|
|
83
|
+
expect(chatRequests).toHaveLength(0);
|
|
84
|
+
release();
|
|
85
|
+
await sent;
|
|
86
|
+
await expect(warmed).resolves.toMatchObject({ sessionId: "session-slow" });
|
|
87
|
+
expect(initRequests).toHaveLength(1);
|
|
88
|
+
expect(chatRequests).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("sends the same init request the send would", async () => {
|
|
92
|
+
const early = setup();
|
|
93
|
+
await early.client.warmSession();
|
|
94
|
+
vi.unstubAllGlobals();
|
|
95
|
+
const lazy = setup();
|
|
96
|
+
await send(lazy.client);
|
|
97
|
+
expect(early.initRequests[0].body).toEqual(lazy.initRequests[0].body);
|
|
98
|
+
const headerMap = (headers: Headers) => {
|
|
99
|
+
const out: Record<string, string> = {};
|
|
100
|
+
headers.forEach((value, key) => (out[key] = value));
|
|
101
|
+
return out;
|
|
102
|
+
};
|
|
103
|
+
expect(headerMap(early.initRequests[0].headers)).toEqual(
|
|
104
|
+
headerMap(lazy.initRequests[0].headers)
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("swallows failures silently and lets the send retry init", async () => {
|
|
109
|
+
const { client, initRequests, chatRequests, onSessionExpired } = setup([
|
|
110
|
+
() => Response.json({ error: "boom" }, { status: 500 }),
|
|
111
|
+
]);
|
|
112
|
+
await expect(client.warmSession()).resolves.toBeNull();
|
|
113
|
+
expect(onSessionExpired).not.toHaveBeenCalled();
|
|
114
|
+
// Latched: further intent signals do not hammer init after a failure.
|
|
115
|
+
await client.warmSession();
|
|
116
|
+
expect(initRequests).toHaveLength(1);
|
|
117
|
+
await send(client);
|
|
118
|
+
expect(initRequests).toHaveLength(2);
|
|
119
|
+
expect(chatRequests).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("is a no-op while a session is live", async () => {
|
|
123
|
+
const { client, initRequests } = setup();
|
|
124
|
+
await client.warmSession();
|
|
125
|
+
await client.warmSession();
|
|
126
|
+
expect(initRequests).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("re-arms when the session is cleared (start-new, credential change)", async () => {
|
|
130
|
+
const { client, initRequests } = setup([
|
|
131
|
+
() => Response.json({ error: "boom" }, { status: 500 }),
|
|
132
|
+
]);
|
|
133
|
+
await client.warmSession();
|
|
134
|
+
await client.warmSession();
|
|
135
|
+
expect(initRequests).toHaveLength(1);
|
|
136
|
+
client.clearClientSession();
|
|
137
|
+
await expect(client.warmSession()).resolves.toMatchObject({ sessionId: "session-2" });
|
|
138
|
+
expect(initRequests).toHaveLength(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("re-arms after start-new-conversation installs a fresh session", async () => {
|
|
142
|
+
const { client, initRequests } = setup([
|
|
143
|
+
() => Response.json({ error: "boom" }, { status: 500 }),
|
|
144
|
+
// Start-new's own session; expired so the next early init is observable.
|
|
145
|
+
() => Response.json(initBody("session-new-conversation", -1_000)),
|
|
146
|
+
]);
|
|
147
|
+
await client.warmSession();
|
|
148
|
+
await client.warmSession();
|
|
149
|
+
expect(initRequests).toHaveLength(1);
|
|
150
|
+
const prepared = await client.prepareNewConversationSession();
|
|
151
|
+
prepared.commit();
|
|
152
|
+
expect(initRequests).toHaveLength(2);
|
|
153
|
+
await expect(client.warmSession()).resolves.toMatchObject({ sessionId: "session-3" });
|
|
154
|
+
expect(initRequests).toHaveLength(3);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("re-arms once the session expires", async () => {
|
|
158
|
+
const { client, initRequests } = setup([
|
|
159
|
+
() => Response.json(initBody("session-short", -1_000)),
|
|
160
|
+
]);
|
|
161
|
+
await client.warmSession();
|
|
162
|
+
expect(initRequests).toHaveLength(1);
|
|
163
|
+
await expect(client.warmSession()).resolves.toMatchObject({ sessionId: "session-2" });
|
|
164
|
+
expect(initRequests).toHaveLength(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("never installs an init that a clear superseded while in flight", async () => {
|
|
168
|
+
let release!: () => void;
|
|
169
|
+
const gate = new Promise<void>((resolve) => (release = resolve));
|
|
170
|
+
const { client, initRequests, onSessionInit } = setup([
|
|
171
|
+
async () => {
|
|
172
|
+
await gate;
|
|
173
|
+
return Response.json(initBody("session-stale"));
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
const warmed = client.warmSession();
|
|
177
|
+
const sent = client.initSession();
|
|
178
|
+
await vi.waitFor(() => expect(initRequests).toHaveLength(1));
|
|
179
|
+
// e.g. a sibling tab replaced the visitor credential
|
|
180
|
+
client.handleExternalCredentialChange();
|
|
181
|
+
release();
|
|
182
|
+
await expect(sent).resolves.toMatchObject({ sessionId: "session-2" });
|
|
183
|
+
await expect(warmed).resolves.toMatchObject({ sessionId: "session-2" });
|
|
184
|
+
expect(client.getClientSession()?.sessionId).toBe("session-2");
|
|
185
|
+
expect(initRequests).toHaveLength(2);
|
|
186
|
+
expect(onSessionInit.mock.calls.map(([s]) => s.sessionId)).toEqual(["session-2"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("does nothing outside client token mode", async () => {
|
|
190
|
+
const fetchMock = vi.fn();
|
|
191
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
192
|
+
const client = new AgentWidgetClient({ apiUrl: "https://example.com/api/chat" });
|
|
193
|
+
await expect(client.warmSession()).resolves.toBeNull();
|
|
194
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("feedback before any init initializes the session on demand", async () => {
|
|
198
|
+
const { client, initRequests } = setup();
|
|
199
|
+
const feedbackUrls: string[] = [];
|
|
200
|
+
const inner = globalThis.fetch as unknown as (u: string, i: RequestInit) => Promise<Response>;
|
|
201
|
+
vi.stubGlobal(
|
|
202
|
+
"fetch",
|
|
203
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
204
|
+
if (url.includes("/feedback")) {
|
|
205
|
+
feedbackUrls.push(url);
|
|
206
|
+
return Response.json({ ok: true });
|
|
207
|
+
}
|
|
208
|
+
return inner(url, init);
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
await client.submitMessageFeedback("m1", "upvote");
|
|
212
|
+
expect(initRequests).toHaveLength(1);
|
|
213
|
+
expect(feedbackUrls).toHaveLength(1);
|
|
214
|
+
expect((client.getClientSession() as ClientSession).sessionId).toBe("session-1");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -755,6 +755,7 @@ describe('controller wiring', () => {
|
|
|
755
755
|
const controller = createAgentExperience(mount, {
|
|
756
756
|
apiUrl: API_URL,
|
|
757
757
|
clientToken: CLIENT_TOKEN,
|
|
758
|
+
sessionInit: 'mount',
|
|
758
759
|
launcher: { enabled: false },
|
|
759
760
|
});
|
|
760
761
|
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
|
@@ -800,6 +801,7 @@ describe('controller wiring', () => {
|
|
|
800
801
|
const controller = createAgentExperience(mount, {
|
|
801
802
|
apiUrl: API_URL,
|
|
802
803
|
clientToken: CLIENT_TOKEN,
|
|
804
|
+
sessionInit: 'mount',
|
|
803
805
|
launcher: { enabled: false },
|
|
804
806
|
});
|
|
805
807
|
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
|
@@ -849,6 +851,7 @@ describe('controller wiring', () => {
|
|
|
849
851
|
const controller = createAgentExperience(mount, {
|
|
850
852
|
apiUrl: API_URL,
|
|
851
853
|
clientToken: CLIENT_TOKEN,
|
|
854
|
+
sessionInit: 'mount',
|
|
852
855
|
launcher: { enabled: false },
|
|
853
856
|
});
|
|
854
857
|
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
|
@@ -907,6 +910,7 @@ describe('controller wiring', () => {
|
|
|
907
910
|
const controller = createAgentExperience(mount, {
|
|
908
911
|
apiUrl: API_URL,
|
|
909
912
|
clientToken: CLIENT_TOKEN,
|
|
913
|
+
sessionInit: 'mount',
|
|
910
914
|
launcher: { enabled: false },
|
|
911
915
|
storageAdapter: {
|
|
912
916
|
save: async (state) => {
|
|
@@ -1037,6 +1041,7 @@ describe('controller wiring', () => {
|
|
|
1037
1041
|
const controller = createAgentExperience(mount, {
|
|
1038
1042
|
apiUrl: API_URL,
|
|
1039
1043
|
clientToken: CLIENT_TOKEN,
|
|
1044
|
+
sessionInit: 'mount',
|
|
1040
1045
|
launcher: { enabled: false },
|
|
1041
1046
|
reconnectStream,
|
|
1042
1047
|
storageAdapter: {
|
|
@@ -1089,6 +1094,7 @@ describe('voice integration visitor credential access', () => {
|
|
|
1089
1094
|
const controller = createAgentExperience(mount, {
|
|
1090
1095
|
apiUrl: API_URL,
|
|
1091
1096
|
clientToken: CLIENT_TOKEN,
|
|
1097
|
+
sessionInit: 'mount',
|
|
1092
1098
|
launcher: { enabled: false },
|
|
1093
1099
|
...config,
|
|
1094
1100
|
});
|