@runtypelabs/persona 3.31.1 → 3.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -10
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-quh7NmYD.d.cts → types-CthJFfNx.d.cts} +7 -0
- package/dist/animations/{types-quh7NmYD.d.ts → types-CthJFfNx.d.ts} +7 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +43 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +320 -8
- package/dist/index.d.ts +320 -8
- package/dist/index.global.js +193 -192
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +43 -43
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +117 -117
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +110 -2
- package/dist/smart-dom-reader.d.ts +110 -2
- package/dist/theme-editor.cjs +30 -30
- package/dist/theme-editor.d.cts +124 -5
- package/dist/theme-editor.d.ts +124 -5
- package/dist/theme-editor.js +30 -30
- package/package.json +3 -3
- package/src/ask-user-question-tool.test.ts +148 -0
- package/src/ask-user-question-tool.ts +138 -0
- package/src/client.ts +43 -9
- package/src/codegen.test.ts +0 -14
- package/src/components/messages.ts +10 -0
- package/src/components/suggestions.ts +49 -6
- package/src/index-core.ts +15 -0
- package/src/runtime/host-layout.test.ts +206 -9
- package/src/runtime/host-layout.ts +121 -8
- package/src/runtime/init.test.ts +2 -2
- package/src/session.ts +188 -32
- package/src/session.webmcp.test.ts +48 -0
- package/src/suggest-replies-tool.test.ts +445 -0
- package/src/suggest-replies-tool.ts +152 -0
- package/src/theme-editor/index.ts +2 -0
- package/src/theme-editor/webmcp/index.ts +2 -0
- package/src/theme-editor/webmcp/types.ts +16 -3
- package/src/types.ts +108 -2
- package/src/ui.docked.test.ts +2 -2
- package/src/ui.suggest-replies.test.ts +237 -0
- package/src/ui.ts +57 -13
- package/src/utils/dock.test.ts +23 -1
- package/src/utils/dock.ts +2 -0
- package/src/voice/voice.test.ts +0 -51
- package/src/install-config.test.ts +0 -38
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SUGGEST_REPLIES_CLIENT_TOOL,
|
|
4
|
+
SUGGEST_REPLIES_MAX,
|
|
5
|
+
SUGGEST_REPLIES_PARAMETERS_SCHEMA,
|
|
6
|
+
SUGGEST_REPLIES_TOOL_NAME,
|
|
7
|
+
latestAgentSuggestions,
|
|
8
|
+
parseSuggestRepliesPayload,
|
|
9
|
+
} from "./suggest-replies-tool";
|
|
10
|
+
import {
|
|
11
|
+
ASK_USER_QUESTION_CLIENT_TOOL,
|
|
12
|
+
builtInClientToolsForDispatch,
|
|
13
|
+
} from "./ask-user-question-tool";
|
|
14
|
+
import { AgentWidgetClient } from "./client";
|
|
15
|
+
import { AgentWidgetSession } from "./session";
|
|
16
|
+
import { computeClientToolsFingerprint } from "./webmcp-bridge";
|
|
17
|
+
import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
|
|
18
|
+
|
|
19
|
+
describe("SUGGEST_REPLIES_CLIENT_TOOL definition", () => {
|
|
20
|
+
it("matches the tool name and origin/annotation contract", () => {
|
|
21
|
+
expect(SUGGEST_REPLIES_CLIENT_TOOL.name).toBe(SUGGEST_REPLIES_TOOL_NAME);
|
|
22
|
+
// `'sdk'` keeps the bare name on the wire (the server only prefixes
|
|
23
|
+
// `origin: 'webmcp'` tools) so the step_await routes to the widget's
|
|
24
|
+
// auto-resolve, not the WebMCP bridge.
|
|
25
|
+
expect(SUGGEST_REPLIES_CLIENT_TOOL.origin).toBe("sdk");
|
|
26
|
+
expect(SUGGEST_REPLIES_CLIENT_TOOL.annotations?.readOnlyHint).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("bounds suggestions to 1-4 short strings", () => {
|
|
30
|
+
const suggestions =
|
|
31
|
+
SUGGEST_REPLIES_PARAMETERS_SCHEMA.properties.suggestions;
|
|
32
|
+
expect(suggestions.minItems).toBe(1);
|
|
33
|
+
expect(suggestions.maxItems).toBe(SUGGEST_REPLIES_MAX);
|
|
34
|
+
expect(suggestions.items.maxLength).toBe(60);
|
|
35
|
+
expect(SUGGEST_REPLIES_PARAMETERS_SCHEMA.required).toEqual(["suggestions"]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("parseSuggestRepliesPayload", () => {
|
|
40
|
+
it("parses object args and JSON-string args", () => {
|
|
41
|
+
expect(parseSuggestRepliesPayload({ suggestions: ["A", "B"] })).toEqual([
|
|
42
|
+
"A",
|
|
43
|
+
"B",
|
|
44
|
+
]);
|
|
45
|
+
expect(
|
|
46
|
+
parseSuggestRepliesPayload(JSON.stringify({ suggestions: ["A"] })),
|
|
47
|
+
).toEqual(["A"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("drops non-strings and empty strings, trims whitespace", () => {
|
|
51
|
+
expect(
|
|
52
|
+
parseSuggestRepliesPayload({
|
|
53
|
+
suggestions: [" A ", 42, "", null, "B", " "],
|
|
54
|
+
}),
|
|
55
|
+
).toEqual(["A", "B"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("truncates past the cap with a console warning", () => {
|
|
59
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
60
|
+
const result = parseSuggestRepliesPayload({
|
|
61
|
+
suggestions: ["1", "2", "3", "4", "5", "6"],
|
|
62
|
+
});
|
|
63
|
+
expect(result).toEqual(["1", "2", "3", "4"]);
|
|
64
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
65
|
+
warn.mockRestore();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns [] for malformed payloads", () => {
|
|
69
|
+
expect(parseSuggestRepliesPayload(undefined)).toEqual([]);
|
|
70
|
+
expect(parseSuggestRepliesPayload("not json")).toEqual([]);
|
|
71
|
+
expect(parseSuggestRepliesPayload({ suggestions: "oops" })).toEqual([]);
|
|
72
|
+
expect(parseSuggestRepliesPayload({})).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("latestAgentSuggestions", () => {
|
|
77
|
+
const msg = (
|
|
78
|
+
overrides: Partial<AgentWidgetMessage> & { id: string },
|
|
79
|
+
): AgentWidgetMessage => ({
|
|
80
|
+
role: "assistant",
|
|
81
|
+
content: "",
|
|
82
|
+
createdAt: "2026-06-10T00:00:00.000Z",
|
|
83
|
+
...overrides,
|
|
84
|
+
});
|
|
85
|
+
const suggest = (id: string, suggestions: string[]): AgentWidgetMessage =>
|
|
86
|
+
msg({
|
|
87
|
+
id,
|
|
88
|
+
variant: "tool",
|
|
89
|
+
toolCall: {
|
|
90
|
+
id: `tc-${id}`,
|
|
91
|
+
name: SUGGEST_REPLIES_TOOL_NAME,
|
|
92
|
+
status: "complete",
|
|
93
|
+
args: { suggestions },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const user = (id: string): AgentWidgetMessage =>
|
|
97
|
+
msg({ id, role: "user", content: "hi" });
|
|
98
|
+
|
|
99
|
+
it("returns null when no suggest_replies message exists", () => {
|
|
100
|
+
expect(latestAgentSuggestions([])).toBeNull();
|
|
101
|
+
expect(latestAgentSuggestions([user("u1"), msg({ id: "a1" })])).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns the chips of the latest call (latest wins)", () => {
|
|
105
|
+
expect(
|
|
106
|
+
latestAgentSuggestions([
|
|
107
|
+
user("u1"),
|
|
108
|
+
suggest("s1", ["Old A"]),
|
|
109
|
+
suggest("s2", ["New A", "New B"]),
|
|
110
|
+
]),
|
|
111
|
+
).toEqual(["New A", "New B"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("hides chips once a user message follows them", () => {
|
|
115
|
+
expect(
|
|
116
|
+
latestAgentSuggestions([user("u1"), suggest("s1", ["A"]), user("u2")]),
|
|
117
|
+
).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps chips visible through trailing assistant text", () => {
|
|
121
|
+
expect(
|
|
122
|
+
latestAgentSuggestions([
|
|
123
|
+
user("u1"),
|
|
124
|
+
suggest("s1", ["A"]),
|
|
125
|
+
msg({ id: "a1", content: "anything else?" }),
|
|
126
|
+
]),
|
|
127
|
+
).toEqual(["A"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns null when the latest call's payload is unparseable", () => {
|
|
131
|
+
expect(
|
|
132
|
+
latestAgentSuggestions([
|
|
133
|
+
msg({
|
|
134
|
+
id: "s1",
|
|
135
|
+
variant: "tool",
|
|
136
|
+
toolCall: {
|
|
137
|
+
id: "tc-s1",
|
|
138
|
+
name: SUGGEST_REPLIES_TOOL_NAME,
|
|
139
|
+
status: "complete",
|
|
140
|
+
args: { nope: true },
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
]),
|
|
144
|
+
).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("builtInClientToolsForDispatch - suggest_replies gating", () => {
|
|
149
|
+
it("returns nothing by default (expose is opt-in)", () => {
|
|
150
|
+
expect(builtInClientToolsForDispatch(undefined)).toEqual([]);
|
|
151
|
+
expect(
|
|
152
|
+
builtInClientToolsForDispatch({
|
|
153
|
+
features: { suggestReplies: {} },
|
|
154
|
+
} as AgentWidgetConfig),
|
|
155
|
+
).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns the tool when expose is true", () => {
|
|
159
|
+
expect(
|
|
160
|
+
builtInClientToolsForDispatch({
|
|
161
|
+
features: { suggestReplies: { expose: true } },
|
|
162
|
+
} as AgentWidgetConfig),
|
|
163
|
+
).toEqual([SUGGEST_REPLIES_CLIENT_TOOL]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("ignores expose when the feature is disabled", () => {
|
|
167
|
+
// Exposing the tool with the feature off would park the execution on a
|
|
168
|
+
// generic tool bubble awaiting a fire-and-forget resume that never comes.
|
|
169
|
+
expect(
|
|
170
|
+
builtInClientToolsForDispatch({
|
|
171
|
+
features: { suggestReplies: { expose: true, enabled: false } },
|
|
172
|
+
} as AgentWidgetConfig),
|
|
173
|
+
).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("composes with ask_user_question, ask first", () => {
|
|
177
|
+
expect(
|
|
178
|
+
builtInClientToolsForDispatch({
|
|
179
|
+
features: {
|
|
180
|
+
askUserQuestion: { expose: true },
|
|
181
|
+
suggestReplies: { expose: true },
|
|
182
|
+
},
|
|
183
|
+
} as AgentWidgetConfig),
|
|
184
|
+
).toEqual([ASK_USER_QUESTION_CLIENT_TOOL, SUGGEST_REPLIES_CLIENT_TOOL]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("AgentWidgetClient - built-in suggest_replies exposure", () => {
|
|
189
|
+
const captureDispatchBody = () => {
|
|
190
|
+
const captured: { body: string | null } = { body: null };
|
|
191
|
+
global.fetch = vi
|
|
192
|
+
.fn()
|
|
193
|
+
.mockImplementation(async (_url: string, options: { body: string }) => {
|
|
194
|
+
captured.body = options.body;
|
|
195
|
+
const encoder = new TextEncoder();
|
|
196
|
+
const stream = new ReadableStream({
|
|
197
|
+
start(controller) {
|
|
198
|
+
controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
199
|
+
controller.close();
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return new Response(stream, {
|
|
203
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
return captured;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const userMessage = () => ({
|
|
210
|
+
id: "u1",
|
|
211
|
+
role: "user" as const,
|
|
212
|
+
content: "hi",
|
|
213
|
+
createdAt: new Date().toISOString(),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("ships suggest_replies on clientTools when expose is on", async () => {
|
|
217
|
+
const captured = captureDispatchBody();
|
|
218
|
+
const client = new AgentWidgetClient({
|
|
219
|
+
apiUrl: "http://localhost:8000",
|
|
220
|
+
features: { suggestReplies: { expose: true } },
|
|
221
|
+
});
|
|
222
|
+
await client.dispatch({ messages: [userMessage()] }, () => undefined);
|
|
223
|
+
const parsed = JSON.parse(captured.body!);
|
|
224
|
+
expect(parsed.clientTools).toEqual([SUGGEST_REPLIES_CLIENT_TOOL]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("omits clientTools entirely when expose is off and no WebMCP tools exist", async () => {
|
|
228
|
+
const captured = captureDispatchBody();
|
|
229
|
+
const client = new AgentWidgetClient({
|
|
230
|
+
apiUrl: "http://localhost:8000",
|
|
231
|
+
});
|
|
232
|
+
await client.dispatch({ messages: [userMessage()] }, () => undefined);
|
|
233
|
+
const parsed = JSON.parse(captured.body!);
|
|
234
|
+
expect(parsed.clientTools).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("changes the clientTools fingerprint when toggled (diff-only resend)", () => {
|
|
238
|
+
const webMcpOnly = [{ name: "search", description: "s" }];
|
|
239
|
+
const withBuiltIn = [SUGGEST_REPLIES_CLIENT_TOOL, ...webMcpOnly];
|
|
240
|
+
expect(computeClientToolsFingerprint(withBuiltIn)).not.toBe(
|
|
241
|
+
computeClientToolsFingerprint(webMcpOnly),
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("AgentWidgetSession - suggest_replies fire-and-forget auto-resolve", () => {
|
|
247
|
+
beforeEach(() => {
|
|
248
|
+
vi.clearAllMocks();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Mirrors session.webmcp.test.ts's harness: spy the client's resume/execute
|
|
252
|
+
// seams and stub connectStream so no real network or SSE parsing happens.
|
|
253
|
+
const makeSession = (config?: Partial<AgentWidgetConfig>) => {
|
|
254
|
+
const session = new AgentWidgetSession(
|
|
255
|
+
{ apiUrl: "http://test", ...config },
|
|
256
|
+
{
|
|
257
|
+
onMessagesChanged: () => undefined,
|
|
258
|
+
onStatusChanged: () => undefined,
|
|
259
|
+
onStreamingChanged: () => undefined,
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
const client = (session as unknown as { client: Record<string, unknown> })
|
|
263
|
+
.client;
|
|
264
|
+
const executeSpy = vi.fn();
|
|
265
|
+
client.executeWebMcpToolCall = executeSpy;
|
|
266
|
+
const resumeSpy = vi.fn(
|
|
267
|
+
async () => new Response(new Blob([""]), { status: 200 }),
|
|
268
|
+
);
|
|
269
|
+
client.resumeFlow = resumeSpy;
|
|
270
|
+
(
|
|
271
|
+
session as unknown as { connectStream: () => Promise<void> }
|
|
272
|
+
).connectStream = vi.fn(async () => undefined);
|
|
273
|
+
return { session, executeSpy, resumeSpy };
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const suggestAwait = (
|
|
277
|
+
toolCallId: string,
|
|
278
|
+
executionId = "exec-sr",
|
|
279
|
+
): AgentWidgetMessage => ({
|
|
280
|
+
id: `tool-${toolCallId}`,
|
|
281
|
+
role: "assistant",
|
|
282
|
+
content: "",
|
|
283
|
+
createdAt: new Date().toISOString(),
|
|
284
|
+
agentMetadata: {
|
|
285
|
+
executionId,
|
|
286
|
+
awaitingLocalTool: true,
|
|
287
|
+
webMcpToolCallId: toolCallId,
|
|
288
|
+
},
|
|
289
|
+
toolCall: {
|
|
290
|
+
id: toolCallId,
|
|
291
|
+
name: SUGGEST_REPLIES_TOOL_NAME,
|
|
292
|
+
status: "complete",
|
|
293
|
+
args: { suggestions: ["Tell me more", "Show pricing"] },
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const webMcpAwait = (
|
|
298
|
+
toolCallId: string,
|
|
299
|
+
executionId = "exec-sr",
|
|
300
|
+
): AgentWidgetMessage => ({
|
|
301
|
+
id: `tool-${toolCallId}`,
|
|
302
|
+
role: "assistant",
|
|
303
|
+
content: "",
|
|
304
|
+
createdAt: new Date().toISOString(),
|
|
305
|
+
agentMetadata: {
|
|
306
|
+
executionId,
|
|
307
|
+
awaitingLocalTool: true,
|
|
308
|
+
webMcpToolCallId: toolCallId,
|
|
309
|
+
},
|
|
310
|
+
toolCall: {
|
|
311
|
+
id: toolCallId,
|
|
312
|
+
name: "webmcp:search",
|
|
313
|
+
status: "complete",
|
|
314
|
+
args: { q: "shoes" },
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const feed = (session: AgentWidgetSession, msg: AgentWidgetMessage) =>
|
|
319
|
+
(session as unknown as { handleEvent: (e: unknown) => void }).handleEvent({
|
|
320
|
+
type: "message",
|
|
321
|
+
message: msg,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const endStream = (session: AgentWidgetSession) =>
|
|
325
|
+
(session as unknown as { handleEvent: (e: unknown) => void }).handleEvent({
|
|
326
|
+
type: "status",
|
|
327
|
+
status: "idle",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const flushMicrotasks = async () => {
|
|
331
|
+
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
it("auto-POSTs ONE /resume with the canned output after stream idle — no bridge, no WebMCP config", async () => {
|
|
335
|
+
const { session, executeSpy, resumeSpy } = makeSession();
|
|
336
|
+
|
|
337
|
+
feed(session, suggestAwait("toolu_S"));
|
|
338
|
+
// Not resolved at step_await receipt — only after the stream ends.
|
|
339
|
+
expect(resumeSpy).not.toHaveBeenCalled();
|
|
340
|
+
|
|
341
|
+
endStream(session);
|
|
342
|
+
await flushMicrotasks();
|
|
343
|
+
|
|
344
|
+
expect(executeSpy).not.toHaveBeenCalled();
|
|
345
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
346
|
+
const [execId, toolOutputs] = resumeSpy.mock.calls[0]! as unknown as [
|
|
347
|
+
string,
|
|
348
|
+
Record<string, { content: { type: string; text: string }[] }>,
|
|
349
|
+
];
|
|
350
|
+
expect(execId).toBe("exec-sr");
|
|
351
|
+
expect(toolOutputs["toolu_S"]).toEqual({
|
|
352
|
+
content: [{ type: "text", text: "Suggestions shown to the user." }],
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("marks suggestRepliesResolved and clears awaitingLocalTool on resume OK", async () => {
|
|
357
|
+
const { session } = makeSession();
|
|
358
|
+
feed(session, suggestAwait("toolu_S"));
|
|
359
|
+
endStream(session);
|
|
360
|
+
await flushMicrotasks();
|
|
361
|
+
|
|
362
|
+
const stored = (
|
|
363
|
+
session as unknown as { messages: AgentWidgetMessage[] }
|
|
364
|
+
).messages.find((m) => m.toolCall?.id === "toolu_S");
|
|
365
|
+
expect(stored?.agentMetadata?.suggestRepliesResolved).toBe(true);
|
|
366
|
+
expect(stored?.agentMetadata?.awaitingLocalTool).toBe(false);
|
|
367
|
+
expect(stored?.toolCall?.status).toBe("complete");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("dedupes a duplicate step_await re-emit (no double resume)", async () => {
|
|
371
|
+
const { session, resumeSpy } = makeSession();
|
|
372
|
+
feed(session, suggestAwait("toolu_S"));
|
|
373
|
+
feed(session, suggestAwait("toolu_S")); // same-batch duplicate
|
|
374
|
+
endStream(session);
|
|
375
|
+
await flushMicrotasks();
|
|
376
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
377
|
+
|
|
378
|
+
// A stale re-emit after resolution must not trigger a second resume.
|
|
379
|
+
feed(session, suggestAwait("toolu_S"));
|
|
380
|
+
endStream(session);
|
|
381
|
+
await flushMicrotasks();
|
|
382
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("joins a parallel webmcp await in ONE batched /resume", async () => {
|
|
386
|
+
const { session, executeSpy, resumeSpy } = makeSession({
|
|
387
|
+
webmcp: { enabled: true },
|
|
388
|
+
});
|
|
389
|
+
executeSpy.mockImplementation(() =>
|
|
390
|
+
Promise.resolve({ content: [{ type: "text", text: "found" }] }),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
feed(session, webMcpAwait("toolu_W"));
|
|
394
|
+
feed(session, suggestAwait("toolu_S"));
|
|
395
|
+
endStream(session);
|
|
396
|
+
await flushMicrotasks();
|
|
397
|
+
|
|
398
|
+
// The page tool executed; suggest_replies did not touch the bridge.
|
|
399
|
+
expect(executeSpy).toHaveBeenCalledTimes(1);
|
|
400
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
401
|
+
const [, toolOutputs] = resumeSpy.mock.calls[0]! as unknown as [
|
|
402
|
+
string,
|
|
403
|
+
Record<string, unknown>,
|
|
404
|
+
];
|
|
405
|
+
expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_S", "toolu_W"]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("does NOT auto-resume when the feature is disabled", async () => {
|
|
409
|
+
const { session, resumeSpy } = makeSession({
|
|
410
|
+
features: { suggestReplies: { enabled: false } },
|
|
411
|
+
});
|
|
412
|
+
feed(session, suggestAwait("toolu_S"));
|
|
413
|
+
endStream(session);
|
|
414
|
+
await flushMicrotasks();
|
|
415
|
+
expect(resumeSpy).not.toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("never re-resumes after hydration clears the in-memory dedupe keys (persisted flag wins)", async () => {
|
|
419
|
+
const { session, resumeSpy } = makeSession();
|
|
420
|
+
feed(session, suggestAwait("toolu_S"));
|
|
421
|
+
endStream(session);
|
|
422
|
+
await flushMicrotasks();
|
|
423
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
424
|
+
|
|
425
|
+
// Hydration (e.g. storage restore) wipes webMcpInflightKeys /
|
|
426
|
+
// webMcpResolvedKeys — only the suggestRepliesResolved metadata persisted
|
|
427
|
+
// on the message survives to block a replayed step_await.
|
|
428
|
+
const resolved = (
|
|
429
|
+
session as unknown as { messages: AgentWidgetMessage[] }
|
|
430
|
+
).messages;
|
|
431
|
+
session.hydrateMessages(resolved);
|
|
432
|
+
// Force-clear both dedupe sets so only the persisted flag can block.
|
|
433
|
+
const internals = session as unknown as {
|
|
434
|
+
webMcpInflightKeys: Set<string>;
|
|
435
|
+
webMcpResolvedKeys: Set<string>;
|
|
436
|
+
};
|
|
437
|
+
internals.webMcpInflightKeys.clear();
|
|
438
|
+
internals.webMcpResolvedKeys.clear();
|
|
439
|
+
|
|
440
|
+
feed(session, suggestAwait("toolu_S"));
|
|
441
|
+
endStream(session);
|
|
442
|
+
await flushMicrotasks();
|
|
443
|
+
expect(resumeSpy).toHaveBeenCalledTimes(1);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in `suggest_replies` client tool.
|
|
3
|
+
*
|
|
4
|
+
* The widget can advertise this tool to the agent on every dispatch via
|
|
5
|
+
* `clientTools[]` (set `features.suggestReplies.expose: true`) — the same
|
|
6
|
+
* wire surface as `ask_user_question` and WebMCP page tools. When the model
|
|
7
|
+
* calls it, the execution pauses with a `step_await`
|
|
8
|
+
* (`awaitReason: "local_tool_required"`); unlike `ask_user_question`, the
|
|
9
|
+
* widget resolves it FIRE-AND-FORGET — it renders the suggestions as tappable
|
|
10
|
+
* chips above the composer and immediately resumes the execution with a
|
|
11
|
+
* canned "shown" result, so the agent's turn completes without waiting on the
|
|
12
|
+
* user. Tapping a chip sends its text verbatim as the user's next message.
|
|
13
|
+
*
|
|
14
|
+
* Chip visibility is DERIVED state, not imperative show/hide: the chips of
|
|
15
|
+
* the last `suggest_replies` tool message with no user message after it are
|
|
16
|
+
* shown (see {@link latestAgentSuggestions}). That single rule covers
|
|
17
|
+
* soft-dismiss on any user message (typed, voice, or chip click), restore on
|
|
18
|
+
* reload/hydration, and latest-wins when a turn carries multiple calls.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
AgentWidgetConfig,
|
|
23
|
+
AgentWidgetMessage,
|
|
24
|
+
ClientToolDefinition,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
export const SUGGEST_REPLIES_TOOL_NAME = "suggest_replies";
|
|
28
|
+
|
|
29
|
+
/** Renderer cap — payloads beyond this are truncated with a console warning. */
|
|
30
|
+
export const SUGGEST_REPLIES_MAX = 4;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* JSON Schema for the tool's parameters. Mirrors what
|
|
34
|
+
* {@link parseSuggestRepliesPayload} hydrates the chips from, so the schema
|
|
35
|
+
* the model is held to and the shape the renderer expects can never drift.
|
|
36
|
+
*/
|
|
37
|
+
export const SUGGEST_REPLIES_PARAMETERS_SCHEMA = {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
suggestions: {
|
|
41
|
+
type: "array",
|
|
42
|
+
minItems: 1,
|
|
43
|
+
maxItems: SUGGEST_REPLIES_MAX,
|
|
44
|
+
description:
|
|
45
|
+
"1-4 short, distinct follow-up replies, phrased in the user's voice.",
|
|
46
|
+
items: { type: "string", minLength: 1, maxLength: 60 },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
required: ["suggestions"],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `ClientToolDefinition` shipped on `dispatch.clientTools[]` when
|
|
55
|
+
* `features.suggestReplies.expose` is on. Exported so integrators who prefer
|
|
56
|
+
* declaring the tool server-side (a flow's `runtimeTools`) can reuse the same
|
|
57
|
+
* description and schema instead of hand-writing them.
|
|
58
|
+
*/
|
|
59
|
+
export const SUGGEST_REPLIES_CLIENT_TOOL: ClientToolDefinition = {
|
|
60
|
+
name: SUGGEST_REPLIES_TOOL_NAME,
|
|
61
|
+
description:
|
|
62
|
+
"Offer the user tappable quick-reply suggestions for their next message. " +
|
|
63
|
+
"Call at most once per turn, as the LAST action after your reply text is " +
|
|
64
|
+
"complete. Each suggestion is sent verbatim as the user's next message, " +
|
|
65
|
+
'so phrase suggestions in the user\'s voice (e.g. "Tell me more about ' +
|
|
66
|
+
'pricing"). Keep them short and distinct. The result only confirms the ' +
|
|
67
|
+
"suggestions were shown — do not add further commentary after calling " +
|
|
68
|
+
"this tool; end your turn.",
|
|
69
|
+
parametersSchema: SUGGEST_REPLIES_PARAMETERS_SCHEMA,
|
|
70
|
+
origin: "sdk",
|
|
71
|
+
annotations: { readOnlyHint: true },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The canned tool output posted to `/resume` the moment the chips render.
|
|
76
|
+
* MCP content shape, matching what the WebMCP resume path posts for page
|
|
77
|
+
* tools. Built fresh per call so a caller can't mutate a shared object.
|
|
78
|
+
*/
|
|
79
|
+
export const suggestRepliesToolResult = (): {
|
|
80
|
+
content: { type: "text"; text: string }[];
|
|
81
|
+
} => ({
|
|
82
|
+
content: [{ type: "text", text: "Suggestions shown to the user." }],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** A tool-variant message produced by a `suggest_replies` call. */
|
|
86
|
+
export const isSuggestRepliesMessage = (
|
|
87
|
+
message: AgentWidgetMessage,
|
|
88
|
+
): boolean =>
|
|
89
|
+
message.variant === "tool" &&
|
|
90
|
+
message.toolCall?.name === SUGGEST_REPLIES_TOOL_NAME;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Tolerant parse of a `suggest_replies` tool call's args into chip labels:
|
|
94
|
+
* accepts a JSON string or object, coerces items to trimmed strings, drops
|
|
95
|
+
* empties, and truncates past the renderer cap with a console warning.
|
|
96
|
+
*/
|
|
97
|
+
export const parseSuggestRepliesPayload = (args: unknown): string[] => {
|
|
98
|
+
let parsed: unknown = args;
|
|
99
|
+
if (typeof parsed === "string") {
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(parsed);
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const raw = (parsed as { suggestions?: unknown } | null | undefined)
|
|
107
|
+
?.suggestions;
|
|
108
|
+
if (!Array.isArray(raw)) return [];
|
|
109
|
+
const chips = raw
|
|
110
|
+
.filter((item): item is string => typeof item === "string")
|
|
111
|
+
.map((item) => item.trim())
|
|
112
|
+
.filter((item) => item.length > 0);
|
|
113
|
+
if (chips.length > SUGGEST_REPLIES_MAX) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`[persona] suggest_replies: ${chips.length} suggestions exceeds the cap of ${SUGGEST_REPLIES_MAX}; extra suggestions dropped.`,
|
|
116
|
+
);
|
|
117
|
+
return chips.slice(0, SUGGEST_REPLIES_MAX);
|
|
118
|
+
}
|
|
119
|
+
return chips;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The chips to show right now: those of the LAST `suggest_replies` tool
|
|
124
|
+
* message with NO user message after it, or `null` when none apply. All
|
|
125
|
+
* calls in a turn still get resumed (the server awaits each); only the
|
|
126
|
+
* latest renders.
|
|
127
|
+
*/
|
|
128
|
+
export const latestAgentSuggestions = (
|
|
129
|
+
messages: AgentWidgetMessage[],
|
|
130
|
+
): string[] | null => {
|
|
131
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
132
|
+
const message = messages[i];
|
|
133
|
+
if (message.role === "user") return null;
|
|
134
|
+
if (!isSuggestRepliesMessage(message)) continue;
|
|
135
|
+
const chips = parseSuggestRepliesPayload(message.toolCall?.args);
|
|
136
|
+
return chips.length > 0 ? chips : null;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Gate for advertising the tool: `expose` opts it into the agent's catalog,
|
|
143
|
+
* and `enabled !== false` guarantees the widget will actually auto-resolve
|
|
144
|
+
* and render chips for it — exposing the tool with the feature disabled
|
|
145
|
+
* would park the execution on a generic tool bubble with no resume coming.
|
|
146
|
+
*/
|
|
147
|
+
export const shouldExposeSuggestReplies = (
|
|
148
|
+
config: AgentWidgetConfig | undefined,
|
|
149
|
+
): boolean => {
|
|
150
|
+
const feature = config?.features?.suggestReplies;
|
|
151
|
+
return feature?.expose === true && feature.enabled !== false;
|
|
152
|
+
};
|
|
@@ -20,8 +20,17 @@ export interface ToolTextContent {
|
|
|
20
20
|
text: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** MCP image content block: raw base64 (no `data:` prefix) plus its MIME type. */
|
|
24
|
+
export interface ToolImageContent {
|
|
25
|
+
type: 'image';
|
|
26
|
+
data: string;
|
|
27
|
+
mimeType: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ToolContent = ToolTextContent | ToolImageContent;
|
|
31
|
+
|
|
23
32
|
export interface ToolResult {
|
|
24
|
-
content:
|
|
33
|
+
content: ToolContent[];
|
|
25
34
|
/** Optional machine-readable mirror of the text content. */
|
|
26
35
|
structuredContent?: unknown;
|
|
27
36
|
isError?: boolean;
|
|
@@ -45,10 +54,14 @@ export interface WebMcpTool {
|
|
|
45
54
|
execute: ToolExecute;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Wrap a JSON-serializable payload in the MCP tool-result envelope. Compact
|
|
59
|
+
* JSON (no indentation) — the text is consumed by a model, where pretty-print
|
|
60
|
+
* whitespace is pure token overhead.
|
|
61
|
+
*/
|
|
49
62
|
export function toolResult(payload: unknown): ToolResult {
|
|
50
63
|
return {
|
|
51
|
-
content: [{ type: 'text', text: JSON.stringify(payload
|
|
64
|
+
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
|
52
65
|
structuredContent: payload,
|
|
53
66
|
};
|
|
54
67
|
}
|