@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
@@ -0,0 +1,325 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+
3
+ import { AgentWidgetClient } from "./client";
4
+ import { AgentWidgetSession } from "./session";
5
+ import type { AgentWidgetMessage, ClientToolDefinition } from "./types";
6
+ import { modelFacingToolName } from "./utils/tool-pair-replay";
7
+
8
+ const SEARCH_TOOL: ClientToolDefinition = {
9
+ name: "search_catalog",
10
+ description: "Search the catalog",
11
+ origin: "webmcp",
12
+ };
13
+
14
+ const RESULT = { content: [{ type: "text", text: "SKU-42 costs $19" }] };
15
+
16
+ const user = (id: string, content: string, at: number): AgentWidgetMessage => ({
17
+ id,
18
+ role: "user",
19
+ content,
20
+ createdAt: new Date(at).toISOString(),
21
+ });
22
+
23
+ const assistant = (id: string, content: string, at: number): AgentWidgetMessage => ({
24
+ id,
25
+ role: "assistant",
26
+ content,
27
+ createdAt: new Date(at).toISOString(),
28
+ variant: "assistant",
29
+ });
30
+
31
+ const answeredTool = (
32
+ toolCallId: string,
33
+ at: number,
34
+ overrides: Partial<NonNullable<AgentWidgetMessage["agentMetadata"]>> = {},
35
+ ): AgentWidgetMessage => ({
36
+ id: `tool-${toolCallId}`,
37
+ role: "assistant",
38
+ content: "",
39
+ createdAt: new Date(at).toISOString(),
40
+ variant: "tool",
41
+ toolCall: {
42
+ id: toolCallId,
43
+ name: "webmcp:search_catalog",
44
+ status: "complete",
45
+ args: { q: "sku 42" },
46
+ result: RESULT,
47
+ },
48
+ agentMetadata: {
49
+ executionId: "exec-1",
50
+ awaitingLocalTool: false,
51
+ webMcpToolCallId: toolCallId,
52
+ clientToolAnswer: {
53
+ toolCallId,
54
+ toolName: "webmcp:search_catalog",
55
+ args: { q: "sku 42" },
56
+ result: RESULT,
57
+ },
58
+ ...overrides,
59
+ },
60
+ });
61
+
62
+ /** Turn 1 (with its answered tool) followed by turn 2's user message. */
63
+ const twoTurnTranscript = (): AgentWidgetMessage[] => [
64
+ user("u1", "What does SKU 42 cost?", 1_000),
65
+ answeredTool("toolu_01", 2_000),
66
+ assistant("a1", "SKU 42 costs $19.", 3_000),
67
+ user("u2", "And what was the SKU again?", 4_000),
68
+ ];
69
+
70
+ function sse(): Response {
71
+ const encoder = new TextEncoder();
72
+ const stream = new ReadableStream({
73
+ start(c) {
74
+ c.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
75
+ c.close();
76
+ },
77
+ });
78
+ return new Response(stream, { headers: { "Content-Type": "text/event-stream" } });
79
+ }
80
+
81
+ let bodies: Array<Record<string, unknown>>;
82
+ beforeEach(() => {
83
+ bodies = [];
84
+ global.fetch = vi.fn().mockImplementation(async (_url: string, init: { body: string }) => {
85
+ bodies.push(JSON.parse(init.body));
86
+ return sse();
87
+ });
88
+ });
89
+
90
+ const withTools = (client: AgentWidgetClient, tools: ClientToolDefinition[]) => {
91
+ (client as unknown as { webMcpBridge: { snapshotForDispatch: () => unknown[] } }).webMcpBridge = {
92
+ snapshotForDispatch: () => tools,
93
+ };
94
+ return client;
95
+ };
96
+
97
+ const EXPECTED_TURN_2 = [
98
+ { role: "user", content: "What does SKU 42 cost?" },
99
+ {
100
+ role: "assistant",
101
+ content: "",
102
+ toolCalls: [{ toolCallId: "toolu_01", toolName: "webmcp_search_catalog", args: { q: "sku 42" } }],
103
+ },
104
+ {
105
+ role: "tool",
106
+ content: "",
107
+ toolResults: [{ toolCallId: "toolu_01", toolName: "webmcp_search_catalog", result: RESULT }],
108
+ },
109
+ { role: "assistant", content: "SKU 42 costs $19." },
110
+ { role: "user", content: "And what was the SKU again?" },
111
+ ];
112
+
113
+ const stripCreatedAt = (messages: unknown) =>
114
+ (messages as Array<Record<string, unknown>>).map(({ createdAt: _createdAt, ...rest }) => rest);
115
+
116
+ describe("tool-pair replay: proxy mode", () => {
117
+ it("sends turn 1's answered call and result, paired and in order, on turn 2", async () => {
118
+ const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
119
+ await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
120
+ expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
121
+ });
122
+
123
+ it("keeps the pair after the page stops offering the tool", async () => {
124
+ const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), []);
125
+ await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
126
+ expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
127
+ expect(bodies[0]!.clientTools).toBeUndefined();
128
+ });
129
+ });
130
+
131
+ describe("tool-pair replay: agent mode", () => {
132
+ it("sends turn 1's answered call and result, paired and in order, on turn 2", async () => {
133
+ const client = withTools(
134
+ new AgentWidgetClient({
135
+ apiUrl: "http://proxy.test/agents",
136
+ agent: { name: "Test", model: "openai:gpt-4o-mini", systemPrompt: "test" },
137
+ }),
138
+ [SEARCH_TOOL],
139
+ );
140
+ await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
141
+ expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
142
+ });
143
+ });
144
+
145
+ describe("tool-pair replay: bubbles that must not replay", () => {
146
+ it("a tool bubble with no accepted answer emits nothing", async () => {
147
+ const pending: AgentWidgetMessage = {
148
+ ...answeredTool("toolu_02", 2_000),
149
+ toolCall: { id: "toolu_02", name: "webmcp:search_catalog", status: "running", args: {} },
150
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "toolu_02" },
151
+ };
152
+ const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
153
+ await client.dispatch(
154
+ { messages: [user("u1", "hi", 1_000), pending, user("u2", "again", 3_000)] },
155
+ () => undefined,
156
+ );
157
+ const messages = bodies[0]!.messages as Array<Record<string, unknown>>;
158
+ expect(messages.map((m) => m.role)).toEqual(["user", "user"]);
159
+ expect(messages.some((m) => "toolCalls" in m || "toolResults" in m)).toBe(false);
160
+ });
161
+
162
+ it("a server-side tool's result stays out (only browser-answered calls replay)", async () => {
163
+ const serverTool: AgentWidgetMessage = {
164
+ ...answeredTool("toolu_03", 2_000),
165
+ agentMetadata: { executionId: "exec-1" },
166
+ };
167
+ const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
168
+ await client.dispatch(
169
+ { messages: [user("u1", "hi", 1_000), serverTool, user("u2", "again", 3_000)] },
170
+ () => undefined,
171
+ );
172
+ expect((bodies[0]!.messages as unknown[]).length).toBe(2);
173
+ });
174
+ });
175
+
176
+ describe("tool-pair replay: parallel and chained calls", () => {
177
+ type Sent = Array<{
178
+ role: string;
179
+ toolCalls?: Array<{ toolCallId: string }>;
180
+ toolResults?: Array<{ toolCallId: string }>;
181
+ }>;
182
+ const inBatch = (toolCallId: string, at: number, batch: string) => {
183
+ const message = answeredTool(toolCallId, at);
184
+ message.agentMetadata!.clientToolAnswer!.batch = batch;
185
+ return message;
186
+ };
187
+
188
+ it("groups answers from one /resume batch into one call message and one result message", async () => {
189
+ const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
190
+ await client.dispatch(
191
+ {
192
+ messages: [
193
+ user("u1", "compare", 1_000),
194
+ inBatch("toolu_a", 2_000, "b1"),
195
+ inBatch("toolu_b", 2_001, "b1"),
196
+ assistant("a1", "done", 3_000),
197
+ ],
198
+ },
199
+ () => undefined,
200
+ );
201
+ const messages = bodies[0]!.messages as Sent;
202
+ expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "tool", "assistant"]);
203
+ expect(messages[1]!.toolCalls!.map((c) => c.toolCallId)).toEqual(["toolu_a", "toolu_b"]);
204
+ expect(messages[2]!.toolResults!.map((r) => r.toolCallId)).toEqual(["toolu_a", "toolu_b"]);
205
+ });
206
+
207
+ it("keeps a chained call (a later /resume batch, no text between) as its own pair", async () => {
208
+ const client = withTools(new AgentWidgetClient({ apiUrl: "http://proxy.test/chat" }), [SEARCH_TOOL]);
209
+ await client.dispatch(
210
+ {
211
+ messages: [
212
+ user("u1", "look it up, then look up the related item", 1_000),
213
+ inBatch("toolu_a", 2_000, "b1"),
214
+ inBatch("toolu_b", 2_500, "b2"),
215
+ assistant("a1", "done", 3_000),
216
+ ],
217
+ },
218
+ () => undefined,
219
+ );
220
+ const messages = bodies[0]!.messages as Sent;
221
+ expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "tool", "assistant", "tool", "assistant"]);
222
+ expect(messages[1]!.toolCalls!.map((c) => c.toolCallId)).toEqual(["toolu_a"]);
223
+ expect(messages[3]!.toolCalls!.map((c) => c.toolCallId)).toEqual(["toolu_b"]);
224
+ });
225
+ });
226
+
227
+ describe("tool-pair replay: requestMiddleware", () => {
228
+ it("keeps replayed pairs when the middleware removes the tools", async () => {
229
+ const client = withTools(
230
+ new AgentWidgetClient({
231
+ apiUrl: "http://proxy.test/chat",
232
+ requestMiddleware: ({ payload }) => ({ ...payload, clientTools: undefined }),
233
+ }),
234
+ [SEARCH_TOOL],
235
+ );
236
+ await client.dispatch({ messages: twoTurnTranscript() }, () => undefined);
237
+ expect(stripCreatedAt(bodies[0]!.messages)).toEqual(EXPECTED_TURN_2);
238
+ });
239
+ });
240
+
241
+ describe("tool-pair replay: client-token mode is server-owned", () => {
242
+ it("sends no toolCalls, toolResults or tool role on /client/chat", async () => {
243
+ const client = withTools(
244
+ new AgentWidgetClient({ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com" }),
245
+ [SEARCH_TOOL],
246
+ );
247
+ (client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
248
+ sessionId: "cs_1",
249
+ expiresAt: new Date(Date.now() + 600_000),
250
+ };
251
+ await client.dispatch({ messages: twoTurnTranscript(), assistantMessageId: "a2" }, () => undefined);
252
+ const messages = bodies[0]!.messages as Array<Record<string, unknown>>;
253
+ expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
254
+ expect(messages.some((m) => "toolCalls" in m || "toolResults" in m)).toBe(false);
255
+ });
256
+ });
257
+
258
+ describe("modelFacingToolName", () => {
259
+ it("matches core's AI-SDK sanitizer", () => {
260
+ expect(modelFacingToolName("webmcp:get_schema")).toBe("webmcp_get_schema");
261
+ expect(modelFacingToolName("webmcp:Get-Slide")).toBe("webmcp_get_slide");
262
+ expect(modelFacingToolName("suggest_replies")).toBe("suggest_replies");
263
+ });
264
+ });
265
+
266
+ describe("session records the accepted client-tool answer", () => {
267
+ const makeSession = (resumeStatus = 200) => {
268
+ const session = new AgentWidgetSession(
269
+ { apiUrl: "http://test", webmcp: { enabled: true } },
270
+ {
271
+ onMessagesChanged: () => undefined,
272
+ onStatusChanged: () => undefined,
273
+ onStreamingChanged: () => undefined,
274
+ },
275
+ );
276
+ const client = (session as unknown as { client: Record<string, unknown> }).client;
277
+ client.executeWebMcpToolCall = vi.fn(async () => RESULT);
278
+ client.isWebMcpOperational = vi.fn(() => true);
279
+ client.resumeFlow = vi.fn(async () => new Response(new Blob([""]), { status: resumeStatus }));
280
+ (session as unknown as { connectStream: () => Promise<void> }).connectStream = vi.fn(async () => undefined);
281
+ return session;
282
+ };
283
+
284
+ const awaiting = (): AgentWidgetMessage => ({
285
+ id: "tool-toolu_01",
286
+ role: "assistant",
287
+ content: "",
288
+ createdAt: new Date().toISOString(),
289
+ variant: "tool",
290
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: true, webMcpToolCallId: "toolu_01" },
291
+ toolCall: { id: "toolu_01", name: "webmcp:search_catalog", status: "running", args: { q: "sku 42" } },
292
+ });
293
+
294
+ const stored = (session: AgentWidgetSession) =>
295
+ (session as unknown as { messages: AgentWidgetMessage[] }).messages.find((m) => m.id === "tool-toolu_01");
296
+
297
+ it("stores the answer after /resume succeeds and keeps it across a tool_complete re-emit", async () => {
298
+ const session = makeSession();
299
+ await session.resolveWebMcpToolCall(awaiting());
300
+ expect(stored(session)?.agentMetadata?.clientToolAnswer).toEqual({
301
+ toolCallId: "toolu_01",
302
+ toolName: "webmcp:search_catalog",
303
+ args: { q: "sku 42" },
304
+ result: RESULT,
305
+ batch: expect.any(String),
306
+ });
307
+
308
+ (session as unknown as { upsertMessage: (m: AgentWidgetMessage) => void }).upsertMessage({
309
+ id: "tool-toolu_01",
310
+ role: "assistant",
311
+ content: "",
312
+ createdAt: new Date().toISOString(),
313
+ variant: "tool",
314
+ agentMetadata: { executionId: "exec-1" },
315
+ toolCall: { id: "toolu_01", name: "webmcp:search_catalog", status: "complete", result: "server copy" },
316
+ });
317
+ expect(stored(session)?.agentMetadata?.clientToolAnswer?.result).toEqual(RESULT);
318
+ });
319
+
320
+ it("stores nothing when /resume fails", async () => {
321
+ const session = makeSession(500);
322
+ await session.resolveWebMcpToolCall(awaiting());
323
+ expect(stored(session)?.agentMetadata?.clientToolAnswer).toBeUndefined();
324
+ });
325
+ });
package/src/client.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  import { resolveTarget } from "./utils/target";
50
50
  import { generateTurnId } from "./utils/message-id";
51
51
  import { builtInClientToolsForDispatch } from "./ask-user-question-tool";
52
+ import { serializeWithToolPairs } from "./utils/tool-pair-replay";
52
53
  import {
53
54
  extractTextFromJson,
54
55
  createPlainTextParser,
@@ -221,6 +222,18 @@ const hasValidContent = (message: AgentWidgetMessage): boolean => {
221
222
  return false;
222
223
  };
223
224
 
225
+ const sortByCreatedAt = (messages: AgentWidgetMessage[]): AgentWidgetMessage[] =>
226
+ messages
227
+ .slice()
228
+ .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
229
+
230
+ // Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
231
+ const toPayloadMessage = (message: AgentWidgetMessage) => ({
232
+ role: message.role,
233
+ content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
234
+ createdAt: message.createdAt
235
+ });
236
+
224
237
  /**
225
238
  * Maps parserType string to the corresponding parser factory function
226
239
  */
@@ -294,6 +307,14 @@ export class AgentWidgetClient {
294
307
  private currentClientTurnId: string | null = null;
295
308
  private clientSession: ClientSession | null = null;
296
309
  private sessionInitPromise: Promise<ClientSession> | null = null;
310
+ /**
311
+ * Early-init latch for `warmSession()`. `false` = armed. Otherwise it holds
312
+ * the session value (`null` or an expired session) the one early attempt was
313
+ * made against, so a failed attempt is not retried on every keystroke. It is
314
+ * re-armed when a session is installed or cleared, so each session lifetime
315
+ * gets one early init.
316
+ */
317
+ private sessionWarmLatch: ClientSession | null | false = false;
297
318
 
298
319
  // Diff-only / send-once WebMCP tool dispatch (client-token mode ONLY).
299
320
  // Fingerprint of the clientTools[] last *sent in full* and confirmed by a
@@ -608,9 +629,41 @@ export class AgentWidgetClient {
608
629
  }
609
630
  }
610
631
 
632
+ /**
633
+ * Fire-and-forget early `initSession()` for client-token mode, driven by the
634
+ * widget's `sessionInit` trigger (or a host calling it directly, e.g. on
635
+ * hover). It fires at most once per session lifetime and is a no-op while a
636
+ * session is live or an init is already in flight. Errors are swallowed, with
637
+ * no `onSessionExpired` and no UI: a send calls `initSession()` again and
638
+ * reuses the in-flight promise, so any failure resurfaces there exactly as it
639
+ * would without the early init. Sends nothing new on the wire. Outside
640
+ * client-token mode this does nothing.
641
+ *
642
+ * Resolves with the session this call initialized, or `null` when it was
643
+ * skipped or failed. It never rejects.
644
+ */
645
+ public async warmSession(): Promise<ClientSession | null> {
646
+ const current = this.clientSession;
647
+ if (
648
+ !this.isClientTokenMode() ||
649
+ this.sessionInitPromise ||
650
+ (current && new Date() < current.expiresAt) ||
651
+ this.sessionWarmLatch === current
652
+ ) {
653
+ return null;
654
+ }
655
+ this.sessionWarmLatch = current;
656
+ // Swallowed on purpose: the send's own initSession() reports failures.
657
+ return this.initSession().catch(() => null);
658
+ }
659
+
611
660
  /**
612
661
  * Initialize session for client token mode.
613
- * Called automatically on first message if not already initialized.
662
+ * Called by the send if no live session exists. The widget normally calls
663
+ * it earlier, when the visitor shows intent to send (see the `sessionInit`
664
+ * config option and `warmSession()`), and concurrent calls share one
665
+ * in-flight request, so a send racing an early init issues a single
666
+ * `/v1/client/init`.
614
667
  */
615
668
  public async initSession(): Promise<ClientSession> {
616
669
  if (!this.isClientTokenMode()) {
@@ -627,21 +680,28 @@ export class AgentWidgetClient {
627
680
  return this.sessionInitPromise;
628
681
  }
629
682
 
630
- this.sessionInitPromise = this._doInitSession();
631
- try {
632
- const session = await this.sessionInitPromise;
633
- this.clientSession = session;
634
- // A freshly-minted session must resend the full WebMCP tool list on its
635
- // next turn: drop any diff-only fingerprint cached under a prior session,
636
- // so we never claim "unchanged" against a session the server didn't store
637
- // the set under. (Belt-and-suspenders with the sessionId comparison in the
638
- // send decision and the server's 409 resend signal.)
639
- this.resetClientToolsFingerprint();
640
- this.config.onSessionInit?.(session);
641
- return session;
642
- } finally {
643
- this.sessionInitPromise = null;
644
- }
683
+ // Callers that dedupe share this promise, so the stale check below covers
684
+ // them too (the send reusing an early init included).
685
+ const pending: Promise<ClientSession> = (this.sessionInitPromise = this._doInitSession()
686
+ .then((session) => {
687
+ // A clear or replacement (credential change, start-new, proof re-init)
688
+ // while this was in flight makes the result stale: never install it.
689
+ if (this.sessionInitPromise !== pending) return this.initSession();
690
+ this.clientSession = session;
691
+ this.sessionWarmLatch = false;
692
+ // A freshly-minted session must resend the full WebMCP tool list on its
693
+ // next turn: drop any diff-only fingerprint cached under a prior session,
694
+ // so we never claim "unchanged" against a session the server didn't store
695
+ // the set under. (Belt-and-suspenders with the sessionId comparison in the
696
+ // send decision and the server's 409 resend signal.)
697
+ this.resetClientToolsFingerprint();
698
+ this.config.onSessionInit?.(session);
699
+ return session;
700
+ })
701
+ .finally(() => {
702
+ if (this.sessionInitPromise === pending) this.sessionInitPromise = null;
703
+ }));
704
+ return pending;
645
705
  }
646
706
 
647
707
  /** Visitor history rides on client-token init only, and latches off after a 403 degrade. */
@@ -953,6 +1013,7 @@ export class AgentWidgetClient {
953
1013
  const installed = this.finishInit(session, previousConversationId, false);
954
1014
  this.clientSession = installed;
955
1015
  this.sessionInitPromise = null;
1016
+ this.sessionWarmLatch = false;
956
1017
  this.resetClientToolsFingerprint();
957
1018
  return installed;
958
1019
  }
@@ -1019,6 +1080,7 @@ export class AgentWidgetClient {
1019
1080
  if (settled) return;
1020
1081
  settled = true;
1021
1082
  this.clientSession = session;
1083
+ this.sessionWarmLatch = false;
1022
1084
  this.resetClientToolsFingerprint();
1023
1085
  },
1024
1086
  discard: () => {
@@ -1089,6 +1151,7 @@ export class AgentWidgetClient {
1089
1151
  public clearClientSession(): void {
1090
1152
  this.clientSession = null;
1091
1153
  this.sessionInitPromise = null;
1154
+ this.sessionWarmLatch = false;
1092
1155
  this.resetClientToolsFingerprint();
1093
1156
  }
1094
1157
 
@@ -1199,6 +1262,7 @@ export class AgentWidgetClient {
1199
1262
 
1200
1263
  if (response.status === 401) {
1201
1264
  this.clientSession = null;
1265
+ this.sessionWarmLatch = false;
1202
1266
  this.config.onSessionExpired?.();
1203
1267
  throw new Error('Session expired. Please refresh to continue.');
1204
1268
  }
@@ -1218,10 +1282,9 @@ export class AgentWidgetClient {
1218
1282
  messageId: string,
1219
1283
  type: 'upvote' | 'downvote' | 'copy'
1220
1284
  ): Promise<void> {
1221
- const session = this.getClientSession();
1222
- if (!session) {
1223
- throw new Error('No active session. Please initialize session first.');
1224
- }
1285
+ // Feedback on a restored transcript can precede any init (`sessionInit`
1286
+ // defers it to intent); the feedback click is intent enough.
1287
+ const session = this.getClientSession() ?? (await this.initSession());
1225
1288
 
1226
1289
  return this.sendFeedback({
1227
1290
  sessionId: session.sessionId,
@@ -1238,10 +1301,7 @@ export class AgentWidgetClient {
1238
1301
  * @param comment - Optional comment
1239
1302
  */
1240
1303
  public async submitCSATFeedback(rating: number, comment?: string): Promise<void> {
1241
- const session = this.getClientSession();
1242
- if (!session) {
1243
- throw new Error('No active session. Please initialize session first.');
1244
- }
1304
+ const session = this.getClientSession() ?? (await this.initSession());
1245
1305
 
1246
1306
  return this.sendFeedback({
1247
1307
  sessionId: session.sessionId,
@@ -1259,10 +1319,7 @@ export class AgentWidgetClient {
1259
1319
  * @param comment - Optional comment
1260
1320
  */
1261
1321
  public async submitNPSFeedback(rating: number, comment?: string): Promise<void> {
1262
- const session = this.getClientSession();
1263
- if (!session) {
1264
- throw new Error('No active session. Please initialize session first.');
1265
- }
1322
+ const session = this.getClientSession() ?? (await this.initSession());
1266
1323
 
1267
1324
  return this.sendFeedback({
1268
1325
  sessionId: session.sessionId,
@@ -1298,6 +1355,40 @@ export class AgentWidgetClient {
1298
1355
  return this.config.getIdentityProof ? 'verified-user' : 'browser';
1299
1356
  }
1300
1357
 
1358
+ private async resolveChatIdentityProof(signal?: AbortSignal): Promise<ClientChatRequest['identityProof']> {
1359
+ signal?.throwIfAborted();
1360
+ const provider = this.config.identityProvider;
1361
+ if (provider === undefined) return undefined;
1362
+ const getIdentityProof = this.config.getIdentityProof;
1363
+ if (!provider.trim() || !getIdentityProof) {
1364
+ throw new Error('Chat identity requires identityProvider and getIdentityProof.');
1365
+ }
1366
+ let token: string | null;
1367
+ let onAbort: (() => void) | undefined;
1368
+ try {
1369
+ const proof = Promise.resolve().then(() => getIdentityProof());
1370
+ token = signal
1371
+ ? await Promise.race([
1372
+ proof,
1373
+ new Promise<never>((_, reject) => {
1374
+ onAbort = () => reject(signal.reason);
1375
+ signal.addEventListener('abort', onAbort, { once: true });
1376
+ if (signal.aborted) onAbort();
1377
+ }),
1378
+ ])
1379
+ : await proof;
1380
+ } catch {
1381
+ signal?.throwIfAborted();
1382
+ throw new Error('The identity proof provider failed.');
1383
+ } finally {
1384
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
1385
+ }
1386
+ if (typeof token !== 'string' || !token.trim()) {
1387
+ throw new Error('A fresh identity proof is required to send this message.');
1388
+ }
1389
+ return { provider, token };
1390
+ }
1391
+
1301
1392
  /**
1302
1393
  * Resolve the proof for one logical request. `null` is an intentional
1303
1394
  * browser-scope fallback only while the visitor has never been bound.
@@ -1837,6 +1928,7 @@ export class AgentWidgetClient {
1837
1928
  }
1838
1929
  session = this.finishInit(renewed, previous.conversationId ?? null, false);
1839
1930
  this.clientSession = session;
1931
+ this.sessionWarmLatch = false;
1840
1932
  this.resetClientToolsFingerprint();
1841
1933
  this.config.onSessionInit?.(session);
1842
1934
  };
@@ -1896,16 +1988,23 @@ export class AgentWidgetClient {
1896
1988
  const recoveryVisitorToken =
1897
1989
  session.durableRecovery?.enabled === true ? await this.readVisitorToken() : null;
1898
1990
  assertCurrentTurn();
1899
- return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
1991
+ return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, async (toolFields) => {
1992
+ assertCurrentTurn();
1993
+ const identityProof = await this.resolveChatIdentityProof(options.signal);
1994
+ assertCurrentTurn();
1900
1995
  const chatRequest: ClientChatRequest = {
1901
1996
  ...baseChatRequest,
1902
1997
  sessionId: session.sessionId,
1903
1998
  ...toolFields,
1999
+ ...(identityProof && { identityProof }),
1904
2000
  };
1905
2001
 
1906
2002
  if (this.debug) {
1907
2003
  // eslint-disable-next-line no-console
1908
- console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
2004
+ console.debug("[AgentWidgetClient] client token dispatch", {
2005
+ ...chatRequest,
2006
+ ...(identityProof && { identityProof: { provider: identityProof.provider, token: '[REDACTED]' } }),
2007
+ });
1909
2008
  }
1910
2009
 
1911
2010
  return fetch(this.getClientApiUrl('chat'), {
@@ -1938,6 +2037,12 @@ export class AgentWidgetClient {
1938
2037
  if (!response.ok) {
1939
2038
  const data = await response.json().catch(() => ({ error: 'Chat request failed' }));
1940
2039
 
2040
+ if (response.status === 401 && data.error === 'invalid_identity_proof') {
2041
+ const error = new HistoryClientError('invalid_identity_proof', PROOF_REJECTED_MESSAGE);
2042
+ forward({ type: 'error', error });
2043
+ throw error;
2044
+ }
2045
+
1941
2046
  if (response.status === 401) {
1942
2047
  // Session expired
1943
2048
  this.clearClientSession();
@@ -2533,22 +2638,17 @@ export class AgentWidgetClient {
2533
2638
  throw new Error('Agent configuration required for agent mode');
2534
2639
  }
2535
2640
 
2536
- // Filter out messages with empty content and normalize
2537
- const normalizedMessages = messages
2538
- .slice()
2539
- .filter(hasValidContent)
2540
- .filter(m => m.role === "user" || m.role === "assistant" || m.role === "system")
2541
- .filter(m => !m.variant || m.variant === "assistant")
2542
- .sort((a, b) => {
2543
- const timeA = new Date(a.createdAt).getTime();
2544
- const timeB = new Date(b.createdAt).getTime();
2545
- return timeA - timeB;
2546
- })
2547
- .map((message) => ({
2548
- role: message.role,
2549
- content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
2550
- createdAt: message.createdAt
2551
- }));
2641
+ // Filter out messages with empty content and normalize; answered
2642
+ // client-tool calls replay as paired toolCalls/toolResults messages.
2643
+ const normalizedMessages = serializeWithToolPairs(
2644
+ sortByCreatedAt(messages),
2645
+ (message) =>
2646
+ hasValidContent(message) &&
2647
+ (message.role === "user" || message.role === "assistant" || message.role === "system") &&
2648
+ (!message.variant || message.variant === "assistant")
2649
+ ? toPayloadMessage(message)
2650
+ : null
2651
+ );
2552
2652
 
2553
2653
  const composer = this.normalizeComposerOptions(composerOptions);
2554
2654
  const payload: AgentWidgetAgentRequestPayload = {
@@ -2590,21 +2690,14 @@ export class AgentWidgetClient {
2590
2690
  messages: AgentWidgetMessage[],
2591
2691
  composerOptions?: ComposerOptionsPayload
2592
2692
  ): Promise<AgentWidgetRequestPayload> {
2593
- // Filter out messages with empty content to prevent validation errors
2594
- const normalizedMessages = messages
2595
- .slice()
2596
- .filter(hasValidContent)
2597
- .sort((a, b) => {
2598
- const timeA = new Date(a.createdAt).getTime();
2599
- const timeB = new Date(b.createdAt).getTime();
2600
- return timeA - timeB;
2601
- })
2602
- .map((message) => ({
2603
- role: message.role,
2604
- // Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
2605
- content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
2606
- createdAt: message.createdAt
2607
- }));
2693
+ // Filter out messages with empty content to prevent validation errors;
2694
+ // answered client-tool calls replay as paired toolCalls/toolResults
2695
+ // messages. Client-token mode maps `options.messages` itself and never
2696
+ // reads these, so the server stays the replay owner there.
2697
+ const normalizedMessages = serializeWithToolPairs(
2698
+ sortByCreatedAt(messages),
2699
+ (message) => (hasValidContent(message) ? toPayloadMessage(message) : null)
2700
+ );
2608
2701
 
2609
2702
  const routed = this.routing();
2610
2703
  const payload: AgentWidgetRequestPayload = {