@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.
Files changed (158) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-9Vxm-PPz.d.cts → types-Db61aI9J.d.cts} +25 -2
  4. package/dist/animations/{types-9Vxm-PPz.d.ts → types-Db61aI9J.d.ts} +25 -2
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +25 -2
  8. package/dist/animations-extra.d.ts +25 -2
  9. package/dist/approval-ui-3NMYNXBD.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +261 -40
  12. package/dist/approval-ui.d.ts +261 -40
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
  15. package/dist/artifacts-ui.d.cts +256 -28
  16. package/dist/artifacts-ui.d.ts +256 -28
  17. package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
  18. package/dist/codegen.cjs +11 -11
  19. package/dist/codegen.js +13 -13
  20. package/dist/context-mentions.d.cts +256 -28
  21. package/dist/context-mentions.d.ts +256 -28
  22. package/dist/event-stream-view.d.cts +256 -28
  23. package/dist/event-stream-view.d.ts +256 -28
  24. package/dist/forms-ui.d.cts +291 -30
  25. package/dist/forms-ui.d.ts +291 -30
  26. package/dist/history-view.d.cts +25 -2
  27. package/dist/history-view.d.ts +25 -2
  28. package/dist/index.cjs +70 -69
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +337 -51
  31. package/dist/index.d.ts +337 -51
  32. package/dist/index.global.js +57 -54
  33. package/dist/index.global.js.map +1 -1
  34. package/dist/index.js +70 -69
  35. package/dist/index.js.map +1 -1
  36. package/dist/install.global.js +1 -1
  37. package/dist/install.global.js.map +1 -1
  38. package/dist/launcher.global.js +2 -2
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/session-reconnect.d.cts +256 -28
  41. package/dist/session-reconnect.d.ts +256 -28
  42. package/dist/smart-dom-reader.d.cts +256 -28
  43. package/dist/smart-dom-reader.d.ts +256 -28
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +263 -28
  46. package/dist/theme-editor-preview.d.ts +263 -28
  47. package/dist/theme-editor-preview.js +51 -50
  48. package/dist/theme-editor.cjs +10 -10
  49. package/dist/theme-editor.d.cts +276 -30
  50. package/dist/theme-editor.d.ts +276 -30
  51. package/dist/theme-editor.js +10 -10
  52. package/dist/theme-reference.cjs +1 -1
  53. package/dist/theme-reference.d.cts +1522 -11
  54. package/dist/theme-reference.d.ts +1522 -11
  55. package/dist/theme-reference.js +1 -1
  56. package/dist/widget.css +1 -1
  57. package/package.json +3 -2
  58. package/src/__fixtures__/defaults-parity.v4.json +776 -0
  59. package/src/approval-ui-entry.ts +0 -1
  60. package/src/approval-ui-loader.ts +5 -4
  61. package/src/approval-ui.ts +0 -1
  62. package/src/client-identity-proof.test.ts +193 -0
  63. package/src/client-session-warm.test.ts +216 -0
  64. package/src/client-visitor-history.test.ts +6 -0
  65. package/src/client.tool-pair-replay.test.ts +325 -0
  66. package/src/client.ts +154 -61
  67. package/src/codegen.test.ts +24 -0
  68. package/src/components/activity-row.test.ts +100 -0
  69. package/src/components/activity-row.ts +136 -0
  70. package/src/components/approval-actions.test.ts +8 -5
  71. package/src/components/approval-actions.ts +19 -6
  72. package/src/components/approval-bubble.test.ts +56 -9
  73. package/src/components/approval-bubble.ts +36 -41
  74. package/src/components/composer-builder.ts +5 -2
  75. package/src/components/composer-parts.ts +10 -2
  76. package/src/components/header-builder.test.ts +32 -0
  77. package/src/components/header-builder.ts +7 -4
  78. package/src/components/header-layouts.test.ts +12 -0
  79. package/src/components/header-layouts.ts +3 -2
  80. package/src/components/launcher.ts +11 -2
  81. package/src/components/message-bubble.test.ts +22 -0
  82. package/src/components/message-bubble.ts +7 -3
  83. package/src/components/panel.test.ts +52 -1
  84. package/src/components/panel.ts +47 -8
  85. package/src/components/pill-composer-builder.test.ts +2 -0
  86. package/src/components/pill-composer-builder.ts +0 -1
  87. package/src/components/reasoning-bubble.ts +29 -17
  88. package/src/components/suggestions.test.ts +1 -0
  89. package/src/components/suggestions.ts +0 -16
  90. package/src/components/tool-bubble.ts +131 -114
  91. package/src/components/tool-details.test.ts +87 -0
  92. package/src/components/tool-details.ts +102 -0
  93. package/src/components/welcome.test.ts +8 -0
  94. package/src/components/welcome.ts +4 -2
  95. package/src/defaults-parity.test.ts +158 -0
  96. package/src/defaults.test.ts +69 -0
  97. package/src/defaults.ts +128 -49
  98. package/src/generated/runtype-openapi-contract.ts +28 -13
  99. package/src/index-core.ts +2 -0
  100. package/src/install.test.ts +24 -0
  101. package/src/install.ts +3 -1
  102. package/src/launcher-global.ts +2 -1
  103. package/src/launcher-variants.test.ts +52 -0
  104. package/src/runtime/init-update-reset.test.ts +21 -0
  105. package/src/session.ts +61 -9
  106. package/src/styles/widget-styles.test.ts +19 -0
  107. package/src/styles/widget.css +295 -23
  108. package/src/theme-editor/index.ts +2 -0
  109. package/src/theme-editor/preview-utils.test.ts +8 -0
  110. package/src/theme-editor/preview-utils.ts +7 -3
  111. package/src/theme-editor/sections.test.ts +31 -0
  112. package/src/theme-editor/sections.ts +55 -1
  113. package/src/theme-editor/state-preferences.test.ts +49 -0
  114. package/src/theme-editor/state.ts +73 -15
  115. package/src/theme-reference.test.ts +20 -0
  116. package/src/theme-reference.ts +11 -5
  117. package/src/tool-call-display-defaults.test.ts +33 -1
  118. package/src/types/theme.ts +80 -3
  119. package/src/types.ts +179 -23
  120. package/src/ui.activity-row.test.ts +91 -0
  121. package/src/ui.approval-plugin.test.ts +63 -0
  122. package/src/ui.composer-compact.test.ts +24 -2
  123. package/src/ui.composer-placement.test.ts +23 -0
  124. package/src/ui.detached-panel.test.ts +33 -6
  125. package/src/ui.docked.test.ts +19 -0
  126. package/src/ui.header-update-stability.test.ts +25 -0
  127. package/src/ui.identity-proof.test.ts +58 -0
  128. package/src/ui.launcher-teaser.test.ts +25 -0
  129. package/src/ui.message-width.test.ts +17 -2
  130. package/src/ui.reasoning-display.test.ts +92 -0
  131. package/src/ui.rebuild-composer.test.ts +20 -3
  132. package/src/ui.scroll.test.ts +32 -0
  133. package/src/ui.session-init.test.ts +312 -0
  134. package/src/ui.status-indicator.test.ts +82 -0
  135. package/src/ui.suggest-replies.test.ts +11 -0
  136. package/src/ui.ts +363 -74
  137. package/src/ui.v5-defaults.test.ts +144 -0
  138. package/src/ui.welcome.test.ts +92 -3
  139. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  140. package/src/utils/code-generators.ts +14 -0
  141. package/src/utils/composer-compact.test.ts +0 -2
  142. package/src/utils/composer-compact.ts +2 -4
  143. package/src/utils/composer-placement.ts +2 -3
  144. package/src/utils/config-merge.test.ts +81 -5
  145. package/src/utils/config-merge.ts +13 -3
  146. package/src/utils/defaults-provenance.ts +67 -0
  147. package/src/utils/dock.ts +7 -1
  148. package/src/utils/launcher-variant.ts +16 -0
  149. package/src/utils/panel-config.ts +97 -0
  150. package/src/utils/status-indicator.ts +39 -0
  151. package/src/utils/theme.test.ts +182 -0
  152. package/src/utils/theme.ts +131 -18
  153. package/src/utils/tokens.test.ts +122 -1
  154. package/src/utils/tokens.ts +177 -17
  155. package/src/utils/tool-pair-replay.ts +100 -0
  156. package/src/welcome.test.ts +23 -0
  157. package/src/welcome.ts +16 -9
  158. package/dist/approval-ui-K7R5Q5OC.js +0 -1
@@ -6,7 +6,6 @@
6
6
  * `approval-ui-loader.ts`. See `src/approval-ui.ts` for the transport module.
7
7
  */
8
8
  export {
9
- approvalDetailsExpansionState,
10
9
  createApprovalBubble,
11
10
  humanizeToolName,
12
11
  updateApprovalDetailsUI,
@@ -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
  };
@@ -8,7 +8,6 @@
8
8
  * external subpath.
9
9
  */
10
10
  export {
11
- approvalDetailsExpansionState,
12
11
  createApprovalBubble,
13
12
  createBuiltInApprovalPlugin,
14
13
  humanizeToolName,
@@ -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
  });