@runtypelabs/persona 4.23.0 → 4.23.2

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 (49) 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-CI7OckWI.d.cts} +23 -2
  4. package/dist/animations/{types-9Vxm-PPz.d.ts → types-CI7OckWI.d.ts} +23 -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 +23 -2
  8. package/dist/animations-extra.d.ts +23 -2
  9. package/dist/approval-ui.d.cts +46 -3
  10. package/dist/approval-ui.d.ts +46 -3
  11. package/dist/artifacts-ui.d.cts +46 -3
  12. package/dist/artifacts-ui.d.ts +46 -3
  13. package/dist/codegen.cjs +1 -1
  14. package/dist/codegen.js +1 -1
  15. package/dist/context-mentions.d.cts +46 -3
  16. package/dist/context-mentions.d.ts +46 -3
  17. package/dist/event-stream-view.d.cts +46 -3
  18. package/dist/event-stream-view.d.ts +46 -3
  19. package/dist/forms-ui.d.cts +46 -3
  20. package/dist/forms-ui.d.ts +46 -3
  21. package/dist/history-view.d.cts +23 -2
  22. package/dist/history-view.d.ts +23 -2
  23. package/dist/index.cjs +43 -43
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +67 -12
  26. package/dist/index.d.ts +67 -12
  27. package/dist/index.global.js +28 -28
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +43 -43
  30. package/dist/index.js.map +1 -1
  31. package/dist/session-reconnect.d.cts +46 -3
  32. package/dist/session-reconnect.d.ts +46 -3
  33. package/dist/smart-dom-reader.d.cts +46 -3
  34. package/dist/smart-dom-reader.d.ts +46 -3
  35. package/dist/theme-editor-preview.cjs +49 -49
  36. package/dist/theme-editor-preview.d.cts +46 -3
  37. package/dist/theme-editor-preview.d.ts +46 -3
  38. package/dist/theme-editor-preview.js +33 -33
  39. package/dist/theme-editor.d.cts +46 -3
  40. package/dist/theme-editor.d.ts +46 -3
  41. package/package.json +1 -1
  42. package/src/client-session-renewal.test.ts +268 -0
  43. package/src/client.interrupt.test.ts +1 -1
  44. package/src/client.tool-pair-replay.test.ts +325 -0
  45. package/src/client.ts +93 -49
  46. package/src/generated/runtype-openapi-contract.ts +26 -11
  47. package/src/session.ts +32 -3
  48. package/src/types.ts +43 -1
  49. package/src/utils/tool-pair-replay.ts +100 -0
@@ -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
  */
@@ -755,6 +768,7 @@ export class AgentWidgetClient {
755
768
  identityProof?: string | null;
756
769
  storedSessionId?: string | null;
757
770
  omitVisitorFields?: boolean;
771
+ signal?: AbortSignal;
758
772
  }): Promise<ClientSession> {
759
773
  const historyCapable = this.isHistoryCapable() && !opts.omitVisitorFields;
760
774
  // Recovery is negotiated independently from the history UI. New servers
@@ -792,6 +806,7 @@ export class AgentWidgetClient {
792
806
  : {}),
793
807
  };
794
808
 
809
+ opts.signal?.throwIfAborted();
795
810
  const response = await fetch(this.getClientApiUrl('init'), {
796
811
  method: 'POST',
797
812
  headers: {
@@ -799,6 +814,7 @@ export class AgentWidgetClient {
799
814
  'X-Persona-Version': VERSION,
800
815
  },
801
816
  body: JSON.stringify(requestBody),
817
+ signal: opts.signal,
802
818
  });
803
819
 
804
820
  if (!response.ok) {
@@ -1805,25 +1821,44 @@ export class AgentWidgetClient {
1805
1821
  onEvent({ type: "status", status: "connecting" });
1806
1822
 
1807
1823
  try {
1808
- // Ensure session is initialized
1809
- const session = await this.initSession();
1810
-
1811
- // Check if session is about to expire (within 1 minute)
1812
- if (new Date() >= new Date(session.expiresAt.getTime() - 60000)) {
1813
- // Session expired or expiring soon
1814
- this.clearClientSession();
1815
- this.config.onSessionExpired?.();
1816
- const error = new Error('Session expired. Please refresh to continue.');
1817
- forward({ type: "error", error });
1818
- throw error;
1824
+ const assertCurrentTurn = () => {
1825
+ options.signal?.throwIfAborted();
1826
+ if (!isCurrentTurn()) throw new DOMException('Turn superseded', 'AbortError');
1827
+ };
1828
+ let session = this.clientSession ?? (await this.initSession());
1829
+ assertCurrentTurn();
1830
+ const renewSession = async () => {
1831
+ assertCurrentTurn();
1832
+ const previous = session;
1833
+ const durable = previous.durableRecovery?.enabled === true;
1834
+ if (durable && (!previous.conversationId || !(await this.readVisitorToken()))) {
1835
+ throw new Error('Renewing this conversation requires its visitor credential.');
1836
+ }
1837
+ assertCurrentTurn();
1838
+ const renewed = await this.createClientSession({
1839
+ ...(durable || this.isHistoryCapable()
1840
+ ? { conversationId: previous.conversationId, durableResume: durable }
1841
+ : { storedSessionId: previous.sessionId }),
1842
+ signal: options.signal,
1843
+ });
1844
+ assertCurrentTurn();
1845
+ if (
1846
+ (previous.conversationId && renewed.conversationId !== previous.conversationId) ||
1847
+ (durable && renewed.durableRecovery?.enabled !== true)
1848
+ ) {
1849
+ throw new Error('Session renewal did not preserve this conversation.');
1850
+ }
1851
+ session = this.finishInit(renewed, previous.conversationId ?? null, false);
1852
+ this.clientSession = session;
1853
+ this.resetClientToolsFingerprint();
1854
+ this.config.onSessionInit?.(session);
1855
+ };
1856
+ if (Date.now() >= session.expiresAt.getTime() - 60000) {
1857
+ await renewSession();
1819
1858
  }
1820
1859
 
1821
1860
  // Build the standard payload to get context/metadata from middleware
1822
1861
  const basePayload = await this.buildPayload(options.messages);
1823
- const recoveryVisitorToken =
1824
- session.durableRecovery?.enabled === true
1825
- ? await this.readVisitorToken()
1826
- : null;
1827
1862
 
1828
1863
  // Build the chat request payload with message IDs for feedback tracking
1829
1864
  // Filter out sessionId from metadata if present (it's only for local storage)
@@ -1869,9 +1904,17 @@ export class AgentWidgetClient {
1869
1904
  // ship it again or just its fingerprint (retrying once on a 409 registry
1870
1905
  // miss). The cache is committed only after a successful stream start
1871
1906
  // (below), so a 409/failure leaves it untouched.
1872
- const { response, commit: commitClientToolsFingerprint } =
1873
- await this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
1874
- const chatRequest: ClientChatRequest = { ...baseChatRequest, ...toolFields };
1907
+ const send = async () => {
1908
+ assertCurrentTurn();
1909
+ const recoveryVisitorToken =
1910
+ session.durableRecovery?.enabled === true ? await this.readVisitorToken() : null;
1911
+ assertCurrentTurn();
1912
+ return this.sendWithClientToolsDiff(session.sessionId, basePayload.clientTools, (toolFields) => {
1913
+ const chatRequest: ClientChatRequest = {
1914
+ ...baseChatRequest,
1915
+ sessionId: session.sessionId,
1916
+ ...toolFields,
1917
+ };
1875
1918
 
1876
1919
  if (this.debug) {
1877
1920
  // eslint-disable-next-line no-console
@@ -1891,6 +1934,19 @@ export class AgentWidgetClient {
1891
1934
  signal: options.signal,
1892
1935
  });
1893
1936
  });
1937
+ };
1938
+ let result = await send();
1939
+ assertCurrentTurn();
1940
+ if (result.response.status === 401) {
1941
+ const rejection = await result.response.clone().json().catch(() => null);
1942
+ assertCurrentTurn();
1943
+ if (rejection?.error === 'Session not found or expired') {
1944
+ await renewSession();
1945
+ result = await send();
1946
+ assertCurrentTurn();
1947
+ }
1948
+ }
1949
+ const { response, commit: commitClientToolsFingerprint } = result;
1894
1950
 
1895
1951
  if (!response.ok) {
1896
1952
  const data = await response.json().catch(() => ({ error: 'Chat request failed' }));
@@ -2490,22 +2546,17 @@ export class AgentWidgetClient {
2490
2546
  throw new Error('Agent configuration required for agent mode');
2491
2547
  }
2492
2548
 
2493
- // Filter out messages with empty content and normalize
2494
- const normalizedMessages = messages
2495
- .slice()
2496
- .filter(hasValidContent)
2497
- .filter(m => m.role === "user" || m.role === "assistant" || m.role === "system")
2498
- .filter(m => !m.variant || m.variant === "assistant")
2499
- .sort((a, b) => {
2500
- const timeA = new Date(a.createdAt).getTime();
2501
- const timeB = new Date(b.createdAt).getTime();
2502
- return timeA - timeB;
2503
- })
2504
- .map((message) => ({
2505
- role: message.role,
2506
- content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
2507
- createdAt: message.createdAt
2508
- }));
2549
+ // Filter out messages with empty content and normalize; answered
2550
+ // client-tool calls replay as paired toolCalls/toolResults messages.
2551
+ const normalizedMessages = serializeWithToolPairs(
2552
+ sortByCreatedAt(messages),
2553
+ (message) =>
2554
+ hasValidContent(message) &&
2555
+ (message.role === "user" || message.role === "assistant" || message.role === "system") &&
2556
+ (!message.variant || message.variant === "assistant")
2557
+ ? toPayloadMessage(message)
2558
+ : null
2559
+ );
2509
2560
 
2510
2561
  const composer = this.normalizeComposerOptions(composerOptions);
2511
2562
  const payload: AgentWidgetAgentRequestPayload = {
@@ -2547,21 +2598,14 @@ export class AgentWidgetClient {
2547
2598
  messages: AgentWidgetMessage[],
2548
2599
  composerOptions?: ComposerOptionsPayload
2549
2600
  ): Promise<AgentWidgetRequestPayload> {
2550
- // Filter out messages with empty content to prevent validation errors
2551
- const normalizedMessages = messages
2552
- .slice()
2553
- .filter(hasValidContent)
2554
- .sort((a, b) => {
2555
- const timeA = new Date(a.createdAt).getTime();
2556
- const timeB = new Date(b.createdAt).getTime();
2557
- return timeA - timeB;
2558
- })
2559
- .map((message) => ({
2560
- role: message.role,
2561
- // Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
2562
- content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
2563
- createdAt: message.createdAt
2564
- }));
2601
+ // Filter out messages with empty content to prevent validation errors;
2602
+ // answered client-tool calls replay as paired toolCalls/toolResults
2603
+ // messages. Client-token mode maps `options.messages` itself and never
2604
+ // reads these, so the server stays the replay owner there.
2605
+ const normalizedMessages = serializeWithToolPairs(
2606
+ sortByCreatedAt(messages),
2607
+ (message) => (hasValidContent(message) ? toPayloadMessage(message) : null)
2608
+ );
2565
2609
 
2566
2610
  const routed = this.routing();
2567
2611
  const payload: AgentWidgetRequestPayload = {
@@ -78,6 +78,7 @@ export type RuntypeExecutionStreamEvent = ({
78
78
  turnIndex?: number;
79
79
  type: "turn_start";
80
80
  }) | ({
81
+ attributes?: Record<string, string | number | boolean>;
81
82
  completedAt?: string;
82
83
  content?: string;
83
84
  cost?: number;
@@ -136,7 +137,8 @@ export type RuntypeExecutionStreamEvent = ({
136
137
  };
137
138
  turnId?: string;
138
139
  type: "model_call_start";
139
- } | {
140
+ } | ({
141
+ attributes?: Record<string, string | number | boolean>;
140
142
  completedAt?: string;
141
143
  cost?: number;
142
144
  durationMs?: number;
@@ -160,10 +162,11 @@ export type RuntypeExecutionStreamEvent = ({
160
162
  cacheWrite?: number;
161
163
  input: number;
162
164
  output: number;
165
+ reasoning?: number;
163
166
  };
164
167
  turnId?: string;
165
168
  type: "model_call_complete";
166
- } | {
169
+ }) | {
167
170
  executionId: string;
168
171
  id: string;
169
172
  index?: number;
@@ -558,38 +561,50 @@ export type RuntypeStopReasonKind = NonNullable<
558
561
  RuntypeTurnCompleteEvent["stopReason"] | RuntypeStepCompleteEvent["stopReason"]
559
562
  >;
560
563
 
561
- export type RuntypeClientInitRequest = {
564
+ export type RuntypeClientInitRequest = ({
562
565
  durableRecovery?: boolean;
563
566
  flowId?: string;
564
- identityProof?: string;
567
+ identityProof?: string | {
568
+ provider: string;
569
+ token: string;
570
+ };
565
571
  token: string;
566
572
  visitorHistory?: boolean;
567
573
  visitorToken?: string;
568
- } | {
574
+ }) | ({
569
575
  durableRecovery?: boolean;
570
576
  flowId?: string;
571
- identityProof?: string;
577
+ identityProof?: string | {
578
+ provider: string;
579
+ token: string;
580
+ };
572
581
  sessionId: string;
573
582
  token: string;
574
583
  visitorHistory?: boolean;
575
584
  visitorToken?: string;
576
- } | {
585
+ }) | ({
577
586
  conversationId: string;
578
587
  durableRecovery?: boolean;
579
588
  flowId?: string;
580
- identityProof?: string;
589
+ identityProof?: string | {
590
+ provider: string;
591
+ token: string;
592
+ };
581
593
  token: string;
582
594
  visitorHistory?: boolean;
583
595
  visitorToken: string;
584
- } | {
596
+ }) | ({
585
597
  conversationId: string;
586
598
  durableRecovery?: boolean;
587
599
  flowId?: string;
588
- identityProof: string;
600
+ identityProof: string | {
601
+ provider: string;
602
+ token: string;
603
+ };
589
604
  token: string;
590
605
  visitorHistory?: boolean;
591
606
  visitorToken?: string;
592
- };
607
+ });
593
608
 
594
609
  export type RuntypeClientInitResponse = {
595
610
  app?: {
package/src/session.ts CHANGED
@@ -3816,16 +3816,34 @@ export class AgentWidgetSession {
3816
3816
  // re-emits don't re-execute the page tool, then complete each bubble.
3817
3817
  // Do this only after /resume HTTP success; if /resume fails, the server
3818
3818
  // may still be paused and the retry path must not show a final result.
3819
+ const batch = ready[0]!.dedupeKey;
3819
3820
  for (const r of ready) {
3820
3821
  this.webMcpResolvedKeys.add(r.dedupeKey);
3822
+ const toolName = r.toolMessage.toolCall?.name;
3823
+ const toolCallId = r.toolMessage.agentMetadata?.webMcpToolCallId;
3821
3824
  this.markWebMcpToolComplete(
3822
3825
  r.toolMessage,
3823
3826
  r.output,
3824
3827
  r.startedAt,
3825
3828
  r.completedAt,
3826
- r.toolMessage.toolCall?.name === SUGGEST_REPLIES_TOOL_NAME
3827
- ? { suggestRepliesResolved: true }
3828
- : undefined,
3829
+ {
3830
+ ...(toolName === SUGGEST_REPLIES_TOOL_NAME
3831
+ ? { suggestRepliesResolved: true }
3832
+ : {}),
3833
+ // Only a provider call id can be replayed: a legacy name-keyed
3834
+ // resume has nothing the model's transcript can reference.
3835
+ ...(toolName && toolCallId
3836
+ ? {
3837
+ clientToolAnswer: {
3838
+ toolCallId,
3839
+ toolName,
3840
+ args: r.toolMessage.toolCall?.args,
3841
+ result: r.output,
3842
+ batch,
3843
+ },
3844
+ }
3845
+ : {}),
3846
+ },
3829
3847
  );
3830
3848
  }
3831
3849
  if (response.body) {
@@ -4928,6 +4946,17 @@ export class AgentWidgetSession {
4928
4946
  awaitingLocalTool: false,
4929
4947
  };
4930
4948
  }
4949
+ // The accepted client-tool answer is what later proxy/agent turns
4950
+ // replay; a post-resume `tool_complete` re-emit carries a fresh
4951
+ // metadata skeleton and must not drop it.
4952
+ const clientToolAnswer = existing.agentMetadata?.clientToolAnswer;
4953
+ if (
4954
+ clientToolAnswer &&
4955
+ merged.agentMetadata &&
4956
+ !merged.agentMetadata.clientToolAnswer
4957
+ ) {
4958
+ merged.agentMetadata = { ...merged.agentMetadata, clientToolAnswer };
4959
+ }
4931
4960
  // Approval equivalent: `approval_complete` carries only the
4932
4961
  // resolution (approvalId, decision, resolvedBy): the runtime does not
4933
4962
  // re-send toolName/description/toolType/reason/parameters, so client.ts