@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.
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-9Vxm-PPz.d.cts → types-CI7OckWI.d.cts} +23 -2
- package/dist/animations/{types-9Vxm-PPz.d.ts → types-CI7OckWI.d.ts} +23 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +23 -2
- package/dist/animations-extra.d.ts +23 -2
- package/dist/approval-ui.d.cts +46 -3
- package/dist/approval-ui.d.ts +46 -3
- package/dist/artifacts-ui.d.cts +46 -3
- package/dist/artifacts-ui.d.ts +46 -3
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +46 -3
- package/dist/context-mentions.d.ts +46 -3
- package/dist/event-stream-view.d.cts +46 -3
- package/dist/event-stream-view.d.ts +46 -3
- package/dist/forms-ui.d.cts +46 -3
- package/dist/forms-ui.d.ts +46 -3
- package/dist/history-view.d.cts +23 -2
- package/dist/history-view.d.ts +23 -2
- package/dist/index.cjs +43 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -12
- package/dist/index.d.ts +67 -12
- package/dist/index.global.js +28 -28
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +43 -43
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +46 -3
- package/dist/session-reconnect.d.ts +46 -3
- package/dist/smart-dom-reader.d.cts +46 -3
- package/dist/smart-dom-reader.d.ts +46 -3
- package/dist/theme-editor-preview.cjs +49 -49
- package/dist/theme-editor-preview.d.cts +46 -3
- package/dist/theme-editor-preview.d.ts +46 -3
- package/dist/theme-editor-preview.js +33 -33
- package/dist/theme-editor.d.cts +46 -3
- package/dist/theme-editor.d.ts +46 -3
- package/package.json +1 -1
- package/src/client-session-renewal.test.ts +268 -0
- package/src/client.interrupt.test.ts +1 -1
- package/src/client.tool-pair-replay.test.ts +325 -0
- package/src/client.ts +93 -49
- package/src/generated/runtype-openapi-contract.ts +26 -11
- package/src/session.ts +32 -3
- package/src/types.ts +43 -1
- 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
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
|
1873
|
-
|
|
1874
|
-
|
|
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
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
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
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
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
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
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
|