@runtypelabs/persona 3.29.0 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +55 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CxvHw0X6.d.cts → types-B_Qazlm4.d.cts} +6 -0
  5. package/dist/animations/{types-CxvHw0X6.d.ts → types-B_Qazlm4.d.ts} +6 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +6 -6
  9. package/dist/codegen.js +4 -4
  10. package/dist/index.cjs +47 -47
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +127 -7
  13. package/dist/index.d.ts +127 -7
  14. package/dist/index.global.js +60 -60
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +47 -47
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js +2 -2
  19. package/dist/launcher.global.js.map +1 -1
  20. package/dist/plugin-kit.cjs +1 -0
  21. package/dist/plugin-kit.d.cts +98 -0
  22. package/dist/plugin-kit.d.ts +98 -0
  23. package/dist/plugin-kit.js +1 -0
  24. package/dist/smart-dom-reader.d.cts +113 -4
  25. package/dist/smart-dom-reader.d.ts +113 -4
  26. package/dist/theme-editor.cjs +38 -38
  27. package/dist/theme-editor.d.cts +117 -6
  28. package/dist/theme-editor.d.ts +117 -6
  29. package/dist/theme-editor.js +38 -38
  30. package/dist/theme-reference.cjs +1 -1
  31. package/dist/theme-reference.d.cts +2 -0
  32. package/dist/theme-reference.d.ts +2 -0
  33. package/dist/theme-reference.js +1 -1
  34. package/package.json +8 -2
  35. package/src/client.test.ts +40 -0
  36. package/src/client.ts +18 -4
  37. package/src/components/approval-bubble.test.ts +268 -0
  38. package/src/components/approval-bubble.ts +170 -20
  39. package/src/components/launcher.ts +6 -3
  40. package/src/components/tool-bubble.test.ts +39 -0
  41. package/src/components/tool-bubble.ts +4 -0
  42. package/src/generated/runtype-openapi-contract.ts +12 -0
  43. package/src/index-core.ts +1 -0
  44. package/src/plugin-kit.test.ts +230 -0
  45. package/src/plugin-kit.ts +294 -0
  46. package/src/plugins/types.ts +49 -2
  47. package/src/session.test.ts +99 -0
  48. package/src/session.ts +204 -32
  49. package/src/session.webmcp.test.ts +326 -6
  50. package/src/theme-editor/preview-utils.test.ts +10 -0
  51. package/src/theme-editor/preview-utils.ts +29 -1
  52. package/src/theme-editor/sections.test.ts +17 -0
  53. package/src/theme-editor/sections.ts +31 -0
  54. package/src/theme-reference.ts +1 -1
  55. package/src/types/theme.ts +2 -0
  56. package/src/types.ts +59 -2
  57. package/src/ui.approval-plugin.test.ts +204 -0
  58. package/src/ui.ts +149 -16
  59. package/src/utils/theme.test.ts +6 -2
  60. package/src/utils/theme.ts +0 -8
  61. package/src/utils/tokens.ts +6 -1
  62. package/src/webmcp-bridge.test.ts +66 -0
  63. package/src/webmcp-bridge.ts +49 -0
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
 
3
3
  import { AgentWidgetSession } from "./session";
4
4
  import type {
@@ -80,6 +80,10 @@ describe("AgentWidgetSession — WebMCP resolve", () => {
80
80
  vi.clearAllMocks();
81
81
  });
82
82
 
83
+ afterEach(() => {
84
+ vi.useRealTimers();
85
+ });
86
+
83
87
  it("posts result to /resume on the happy path", async () => {
84
88
  const { session, executeSpy, resumeSpy } = makeSession({
85
89
  executeReturn: {
@@ -97,6 +101,33 @@ describe("AgentWidgetSession — WebMCP resolve", () => {
97
101
  );
98
102
  });
99
103
 
104
+ it("records the elapsed time of the resolved browser WebMCP promise", async () => {
105
+ vi.useFakeTimers();
106
+ vi.setSystemTime(1_000);
107
+ const { session } = makeSession({
108
+ executeImpl: async () => {
109
+ vi.advanceTimersByTime(1_250);
110
+ return { content: [{ type: "text", text: "loaded jade" }] };
111
+ },
112
+ });
113
+
114
+ await session.resolveWebMcpToolCall(
115
+ awaitingMessage("tool-1", "webmcp:get_product_by_url"),
116
+ );
117
+
118
+ const stored = (
119
+ session as unknown as { messages: AgentWidgetMessage[] }
120
+ ).messages.find((m) => m.toolCall?.id === "tool-1");
121
+ expect(stored?.agentMetadata?.awaitingLocalTool).toBe(false);
122
+ expect(stored?.toolCall?.status).toBe("complete");
123
+ expect(stored?.toolCall?.startedAt).toBe(1_000);
124
+ expect(stored?.toolCall?.completedAt).toBe(2_250);
125
+ expect(stored?.toolCall?.durationMs).toBe(1_250);
126
+ expect(stored?.toolCall?.result).toEqual({
127
+ content: [{ type: "text", text: "loaded jade" }],
128
+ });
129
+ });
130
+
100
131
  it("still resumes (with isError) when the bridge is not operational", async () => {
101
132
  // BugBot finding #1: previously, handleEvent skipped resolveWebMcpToolCall
102
133
  // entirely when `isWebMcpOperational()` was false — leaving the dispatch
@@ -152,6 +183,13 @@ describe("AgentWidgetSession — WebMCP resolve", () => {
152
183
 
153
184
  const msg = awaitingMessage("tool-1", "webmcp:search");
154
185
  await session.resolveWebMcpToolCall(msg);
186
+ const afterFailedResume = (
187
+ session as unknown as { messages: AgentWidgetMessage[] }
188
+ ).messages.find((m) => m.toolCall?.id === "tool-1");
189
+ expect(afterFailedResume?.toolCall?.status).toBe("running");
190
+ expect(afterFailedResume?.toolCall?.result).toBeUndefined();
191
+ expect(afterFailedResume?.toolCall?.durationMs).toBeUndefined();
192
+
155
193
  await session.resolveWebMcpToolCall(msg); // retry — must be allowed
156
194
  await session.resolveWebMcpToolCall(msg); // post-success — must be blocked
157
195
 
@@ -159,6 +197,29 @@ describe("AgentWidgetSession — WebMCP resolve", () => {
159
197
  expect(resumeSpy).toHaveBeenCalledTimes(2);
160
198
  });
161
199
 
200
+ it("marks the bubble complete with an error when browser execution throws before /resume", async () => {
201
+ const { session, resumeSpy } = makeSession({
202
+ executeImpl: async () => {
203
+ throw new Error("page tool exploded");
204
+ },
205
+ });
206
+
207
+ await session.resolveWebMcpToolCall(
208
+ awaitingMessage("tool-1", "webmcp:search"),
209
+ );
210
+
211
+ const stored = (
212
+ session as unknown as { messages: AgentWidgetMessage[] }
213
+ ).messages.find((m) => m.toolCall?.id === "tool-1");
214
+ expect(resumeSpy).not.toHaveBeenCalled();
215
+ expect(stored?.toolCall?.status).toBe("complete");
216
+ expect(stored?.toolCall?.durationMs).toBeGreaterThanOrEqual(0);
217
+ expect(stored?.toolCall?.result).toMatchObject({
218
+ isError: true,
219
+ content: [{ type: "text", text: "page tool exploded" }],
220
+ });
221
+ });
222
+
162
223
  it("threads an AbortSignal into resumeFlow", async () => {
163
224
  // BugBot finding #6: cancel() needs to propagate into /resume.
164
225
  const { session, resumeSpy } = makeSession();
@@ -189,6 +250,14 @@ describe("AgentWidgetSession — WebMCP resolve", () => {
189
250
  release();
190
251
  await inflight;
191
252
  expect(resumeSpy).not.toHaveBeenCalled();
253
+ const stored = (
254
+ session as unknown as { messages: AgentWidgetMessage[] }
255
+ ).messages.find((m) => m.toolCall?.id === "tool-1");
256
+ expect(stored?.toolCall?.status).toBe("complete");
257
+ expect(stored?.toolCall?.result).toMatchObject({
258
+ isError: true,
259
+ content: [{ type: "text", text: "Aborted by cancel()" }],
260
+ });
192
261
  });
193
262
 
194
263
  it("does NOT abort the shared session abortController", async () => {
@@ -282,11 +351,12 @@ describe("AgentWidgetSession — WebMCP resolve", () => {
282
351
  expect(executeSpy).not.toHaveBeenCalled();
283
352
  });
284
353
 
285
- it("a stale step_await re-emit does not resurrect awaitingLocalTool once resolved", () => {
354
+ it("a stale step_await re-emit does not resurrect awaitingLocalTool or reset completed tool state once resolved", () => {
286
355
  // BugBot: a duplicate step_await (awaitingLocalTool:true) for an
287
356
  // already-resolved webmcp tool must not flip the message back to awaiting
288
- // and show a stuck local-tool wait. upsertMessage clears it when the
289
- // tool's `${executionId}:${toolCallId}` key is inflight/resolved.
357
+ // or running and show a stuck local-tool wait / lose the measured duration.
358
+ // upsertMessage clears it when the tool's `${executionId}:${toolCallId}`
359
+ // key is inflight/resolved.
290
360
  const session = makeSession().session;
291
361
  const s = session as unknown as {
292
362
  webMcpResolvedKeys: Set<string>;
@@ -298,11 +368,182 @@ describe("AgentWidgetSession — WebMCP resolve", () => {
298
368
  s.upsertMessage({
299
369
  ...awaitingMessage("tool-1", "webmcp:search"),
300
370
  agentMetadata: { executionId: "exec-1", awaitingLocalTool: false },
371
+ streaming: false,
372
+ toolCall: {
373
+ id: "tool-1",
374
+ name: "webmcp:search",
375
+ status: "complete",
376
+ args: { q: "shoes" },
377
+ result: { content: [{ type: "text", text: "ok" }] },
378
+ startedAt: 1_000,
379
+ completedAt: 2_200,
380
+ durationMs: 1_200,
381
+ },
382
+ });
383
+ // Stale re-emit flips awaiting back to true and carries the running state
384
+ // emitted by client.ts for WebMCP step_await events.
385
+ s.upsertMessage({
386
+ ...awaitingMessage("tool-1", "webmcp:search"),
387
+ streaming: false,
388
+ toolCall: {
389
+ id: "tool-1",
390
+ name: "webmcp:search",
391
+ status: "running",
392
+ args: { q: "shoes" },
393
+ startedAt: 3_000,
394
+ },
395
+ });
396
+ const stored = s.messages.find((m) => m.toolCall?.id === "tool-1");
397
+ expect(stored?.agentMetadata?.awaitingLocalTool).toBe(false);
398
+ expect(stored?.streaming).toBe(false);
399
+ expect(stored?.toolCall?.status).toBe("complete");
400
+ expect(stored?.toolCall?.result).toEqual({
401
+ content: [{ type: "text", text: "ok" }],
402
+ });
403
+ expect(stored?.toolCall?.startedAt).toBe(1_000);
404
+ expect(stored?.toolCall?.completedAt).toBe(2_200);
405
+ expect(stored?.toolCall?.durationMs).toBe(1_200);
406
+ });
407
+
408
+ it("a stale step_await re-emit does not reset in-flight WebMCP tool timing", () => {
409
+ const session = makeSession().session;
410
+ const s = session as unknown as {
411
+ webMcpInflightKeys: Set<string>;
412
+ upsertMessage: (m: AgentWidgetMessage) => void;
413
+ messages: AgentWidgetMessage[];
414
+ };
415
+ s.webMcpInflightKeys.add("exec-1:tool-1");
416
+ s.upsertMessage({
417
+ ...awaitingMessage("tool-1", "webmcp:search"),
418
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: false },
419
+ streaming: true,
420
+ toolCall: {
421
+ id: "tool-1",
422
+ name: "webmcp:search",
423
+ status: "running",
424
+ args: { q: "shoes" },
425
+ startedAt: 1_000,
426
+ },
427
+ });
428
+
429
+ s.upsertMessage({
430
+ ...awaitingMessage("tool-1", "webmcp:search"),
431
+ streaming: false,
432
+ toolCall: {
433
+ id: "tool-1",
434
+ name: "webmcp:search",
435
+ status: "running",
436
+ args: { q: "shoes" },
437
+ startedAt: 3_000,
438
+ },
301
439
  });
302
- // Stale re-emit flips awaiting back to true on the wire.
303
- s.upsertMessage(awaitingMessage("tool-1", "webmcp:search"));
440
+
304
441
  const stored = s.messages.find((m) => m.toolCall?.id === "tool-1");
305
442
  expect(stored?.agentMetadata?.awaitingLocalTool).toBe(false);
443
+ expect(stored?.streaming).toBe(true);
444
+ expect(stored?.toolCall?.status).toBe("running");
445
+ expect(stored?.toolCall?.startedAt).toBe(1_000);
446
+ expect(stored?.toolCall?.durationMs).toBeUndefined();
447
+ });
448
+
449
+ it("a stale step_await re-emit does not reset completed WebMCP local-error state", () => {
450
+ const session = makeSession().session;
451
+ const s = session as unknown as {
452
+ upsertMessage: (m: AgentWidgetMessage) => void;
453
+ messages: AgentWidgetMessage[];
454
+ };
455
+ s.upsertMessage({
456
+ ...awaitingMessage("tool-1", "webmcp:search"),
457
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: false },
458
+ streaming: false,
459
+ toolCall: {
460
+ id: "tool-1",
461
+ name: "webmcp:search",
462
+ status: "complete",
463
+ args: { q: "shoes" },
464
+ result: {
465
+ isError: true,
466
+ content: [{ type: "text", text: "page tool exploded" }],
467
+ },
468
+ startedAt: 1_000,
469
+ completedAt: 1_500,
470
+ durationMs: 500,
471
+ },
472
+ });
473
+
474
+ s.upsertMessage({
475
+ ...awaitingMessage("tool-1", "webmcp:search"),
476
+ streaming: false,
477
+ toolCall: {
478
+ id: "tool-1",
479
+ name: "webmcp:search",
480
+ status: "running",
481
+ args: { q: "shoes" },
482
+ startedAt: 3_000,
483
+ },
484
+ });
485
+
486
+ const stored = s.messages.find((m) => m.toolCall?.id === "tool-1");
487
+ expect(stored?.agentMetadata?.awaitingLocalTool).toBe(false);
488
+ expect(stored?.streaming).toBe(false);
489
+ expect(stored?.toolCall?.status).toBe("complete");
490
+ expect(stored?.toolCall?.result).toEqual({
491
+ isError: true,
492
+ content: [{ type: "text", text: "page tool exploded" }],
493
+ });
494
+ expect(stored?.toolCall?.startedAt).toBe(1_000);
495
+ expect(stored?.toolCall?.completedAt).toBe(1_500);
496
+ expect(stored?.toolCall?.durationMs).toBe(500);
497
+ });
498
+
499
+ it("a new execution reusing a completed tool id is not treated as stale", () => {
500
+ const session = makeSession().session;
501
+ const s = session as unknown as {
502
+ upsertMessage: (m: AgentWidgetMessage) => void;
503
+ messages: AgentWidgetMessage[];
504
+ };
505
+ s.upsertMessage({
506
+ ...awaitingMessage("tool-1", "webmcp:search"),
507
+ id: "tool-tool-1",
508
+ agentMetadata: { executionId: "exec-1", awaitingLocalTool: false },
509
+ streaming: false,
510
+ toolCall: {
511
+ id: "tool-1",
512
+ name: "webmcp:search",
513
+ status: "complete",
514
+ args: { q: "shoes" },
515
+ result: {
516
+ isError: true,
517
+ content: [{ type: "text", text: "page tool exploded" }],
518
+ },
519
+ startedAt: 1_000,
520
+ completedAt: 1_500,
521
+ durationMs: 500,
522
+ },
523
+ });
524
+
525
+ s.upsertMessage({
526
+ ...awaitingMessage("tool-1", "webmcp:search", "exec-2"),
527
+ id: "tool-tool-1",
528
+ streaming: false,
529
+ toolCall: {
530
+ id: "tool-1",
531
+ name: "webmcp:search",
532
+ status: "running",
533
+ args: { q: "boots" },
534
+ startedAt: 3_000,
535
+ },
536
+ });
537
+
538
+ const stored = s.messages.find((m) => m.id === "tool-tool-1");
539
+ expect(stored?.agentMetadata?.executionId).toBe("exec-2");
540
+ expect(stored?.agentMetadata?.awaitingLocalTool).toBe(true);
541
+ expect(stored?.toolCall?.status).toBe("running");
542
+ expect(stored?.toolCall?.args).toEqual({ q: "boots" });
543
+ expect(stored?.toolCall?.startedAt).toBe(3_000);
544
+ expect(stored?.toolCall?.result).toBeUndefined();
545
+ expect(stored?.toolCall?.completedAt).toBeUndefined();
546
+ expect(stored?.toolCall?.durationMs).toBeUndefined();
306
547
  });
307
548
 
308
549
  it("an error event does not clear streaming while a webmcp resolve is in flight", () => {
@@ -576,6 +817,10 @@ describe("AgentWidgetSession — WebMCP parallel batched resume (core#3878)", ()
576
817
  vi.clearAllMocks();
577
818
  });
578
819
 
820
+ afterEach(() => {
821
+ vi.useRealTimers();
822
+ });
823
+
579
824
  // A `step_await(local_tool_required)` message as client.ts emits it for a
580
825
  // PARALLEL local-tool call: the per-call `toolCallId` is both the toolCall.id
581
826
  // AND `agentMetadata.webMcpToolCallId`. Two of these for one executionId share
@@ -705,6 +950,81 @@ describe("AgentWidgetSession — WebMCP parallel batched resume (core#3878)", ()
705
950
  expect(Object.keys(toolOutputs).sort()).toEqual(["toolu_A", "toolu_B"]);
706
951
  });
707
952
 
953
+ it("records elapsed time for each resolved tool in a batched WebMCP resume", async () => {
954
+ vi.useFakeTimers();
955
+ vi.setSystemTime(1_000);
956
+
957
+ let releaseA: (r: WebMcpToolResult) => void = () => undefined;
958
+ let releaseB: (r: WebMcpToolResult) => void = () => undefined;
959
+ const pA = new Promise<WebMcpToolResult>((r) => (releaseA = r));
960
+ const pB = new Promise<WebMcpToolResult>((r) => (releaseB = r));
961
+ const { session, resumeSpy } = makeSession();
962
+ const client = (session as unknown as { client: Record<string, unknown> })
963
+ .client;
964
+ (client.executeWebMcpToolCall as ReturnType<typeof vi.fn>).mockImplementation(
965
+ (_name: string, args: { sku: string }) =>
966
+ args.sku === "SHOE-001" ? pA : pB,
967
+ );
968
+
969
+ feed(session, parallelAwait("toolu_A", "SHOE-001"));
970
+ feed(session, parallelAwait("toolu_B", "SHOE-007"));
971
+ endStream(session);
972
+ await flushMicrotasks();
973
+
974
+ vi.setSystemTime(1_800);
975
+ releaseA({ content: [{ type: "text", text: "a" }] });
976
+ await flushMicrotasks();
977
+
978
+ let messages = (session as unknown as { messages: AgentWidgetMessage[] })
979
+ .messages;
980
+ const toolA = messages.find((m) => m.toolCall?.id === "toolu_A");
981
+ const toolBWhileRunning = messages.find((m) => m.toolCall?.id === "toolu_B");
982
+ expect(toolA?.toolCall?.status).toBe("running");
983
+ expect(toolA?.toolCall?.durationMs).toBeUndefined();
984
+ expect(toolBWhileRunning?.toolCall?.status).toBe("running");
985
+ expect(resumeSpy).not.toHaveBeenCalled();
986
+
987
+ vi.setSystemTime(2_600);
988
+ releaseB({ content: [{ type: "text", text: "b" }] });
989
+ await flushMicrotasks();
990
+
991
+ messages = (session as unknown as { messages: AgentWidgetMessage[] })
992
+ .messages;
993
+ const completedToolA = messages.find((m) => m.toolCall?.id === "toolu_A");
994
+ const toolB = messages.find((m) => m.toolCall?.id === "toolu_B");
995
+ expect(completedToolA?.toolCall?.status).toBe("complete");
996
+ expect(completedToolA?.toolCall?.durationMs).toBe(800);
997
+ expect(toolB?.toolCall?.status).toBe("complete");
998
+ expect(toolB?.toolCall?.durationMs).toBe(1_600);
999
+ expect(resumeSpy).toHaveBeenCalledTimes(1);
1000
+ });
1001
+
1002
+ it("does not mark batched WebMCP bubbles complete when the shared /resume fails", async () => {
1003
+ const { session, client, resumeSpy } = makeSession();
1004
+ (client.resumeFlow as ReturnType<typeof vi.fn>).mockImplementationOnce(
1005
+ async () => {
1006
+ throw new Error("resume failed");
1007
+ },
1008
+ );
1009
+
1010
+ feed(session, parallelAwait("toolu_A", "SHOE-001"));
1011
+ feed(session, parallelAwait("toolu_B", "SHOE-007"));
1012
+ endStream(session);
1013
+ await flushMicrotasks();
1014
+
1015
+ const messages = (session as unknown as { messages: AgentWidgetMessage[] })
1016
+ .messages;
1017
+ const toolA = messages.find((m) => m.toolCall?.id === "toolu_A");
1018
+ const toolB = messages.find((m) => m.toolCall?.id === "toolu_B");
1019
+ expect(resumeSpy).toHaveBeenCalledTimes(1);
1020
+ expect(toolA?.toolCall?.status).toBe("running");
1021
+ expect(toolA?.toolCall?.result).toBeUndefined();
1022
+ expect(toolA?.toolCall?.durationMs).toBeUndefined();
1023
+ expect(toolB?.toolCall?.status).toBe("running");
1024
+ expect(toolB?.toolCall?.result).toBeUndefined();
1025
+ expect(toolB?.toolCall?.durationMs).toBeUndefined();
1026
+ });
1027
+
708
1028
  it("dedupes a duplicate parallel await within the same batch", async () => {
709
1029
  const { session, executeSpy, resumeSpy } = makeSession();
710
1030
  feed(session, parallelAwait("toolu_A", "SHOE-001"));
@@ -49,6 +49,16 @@ describe("theme editor preview demo data", () => {
49
49
  expect(reasoningMessage.reasoning?.status).toBe("streaming");
50
50
  });
51
51
 
52
+ it("creates a pending approval preview entry for theming the approval bubble", () => {
53
+ const message = createPreviewTranscriptEntry("approval-request", 3);
54
+
55
+ expect(message.variant).toBe("approval");
56
+ expect(message.approval?.status).toBe("pending");
57
+ expect(message.approval?.toolName).toBe("add_to_cart");
58
+ // Includes parameters so the params block is exercised when theming.
59
+ expect(message.approval?.parameters).toBeTruthy();
60
+ });
61
+
52
62
  it("appends public preview transcript entries to an existing conversation", () => {
53
63
  const messages = appendPreviewTranscriptEntry([], "tool-running");
54
64
  const updated = appendPreviewTranscriptEntry(messages, "reasoning-complete");
@@ -206,7 +206,8 @@ export type PreviewTranscriptEntryPreset =
206
206
  | 'reasoning-streaming'
207
207
  | 'reasoning-complete'
208
208
  | 'tool-running'
209
- | 'tool-complete';
209
+ | 'tool-complete'
210
+ | 'approval-request';
210
211
 
211
212
  const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, string> = {
212
213
  'user-message': 'User message',
@@ -218,6 +219,7 @@ const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, str
218
219
  'reasoning-complete': 'Reasoning (complete)',
219
220
  'tool-running': 'Tool call (running)',
220
221
  'tool-complete': 'Tool call (complete)',
222
+ 'approval-request': 'Approval request',
221
223
  };
222
224
 
223
225
  export function getPreviewTranscriptPresetLabel(preset: PreviewTranscriptEntryPreset): string {
@@ -325,6 +327,32 @@ export function createPreviewTranscriptEntry(
325
327
  durationMs: 1200,
326
328
  },
327
329
  };
330
+ case 'approval-request': {
331
+ // Use the `approval-<approvalId>` id convention so the optimistic status
332
+ // flip in `session.resolveApproval` updates this bubble in place when the
333
+ // Approve/Deny buttons are clicked.
334
+ const approvalId = `preview-approval-${suffix}`;
335
+ return {
336
+ id: `approval-${approvalId}`,
337
+ role: 'assistant',
338
+ content: '',
339
+ createdAt,
340
+ streaming: false,
341
+ variant: 'approval',
342
+ approval: {
343
+ id: approvalId,
344
+ status: 'pending',
345
+ agentId: 'preview-agent',
346
+ executionId: `preview-exec-${suffix}`,
347
+ toolName: 'add_to_cart',
348
+ description:
349
+ 'Add the selected item to the shopping cart. Approve to let the assistant continue.',
350
+ parameters: {
351
+ items: [{ productEntityId: 129, quantity: 1 }],
352
+ },
353
+ },
354
+ };
355
+ }
328
356
  case 'tool-complete':
329
357
  return {
330
358
  id: `preview-seq-tool-complete-${suffix}`,
@@ -27,6 +27,23 @@ describe("theme editor scroll-to-bottom controls", () => {
27
27
  expect(fieldPaths).toContain("theme.components.scrollToBottom.iconSize");
28
28
  });
29
29
 
30
+ it("exposes a shadow control for every themeable component", () => {
31
+ const fieldPaths = COMPONENTS_SECTIONS.flatMap((section) => section.fields.map((field) => field.path));
32
+
33
+ // Pre-existing shadow controls.
34
+ expect(fieldPaths).toContain("theme.components.launcher.shadow");
35
+ expect(fieldPaths).toContain("theme.components.panel.shadow");
36
+ expect(fieldPaths).toContain("theme.components.scrollToBottom.shadow");
37
+ // Newly added component shadow controls.
38
+ expect(fieldPaths).toContain("theme.components.message.user.shadow");
39
+ expect(fieldPaths).toContain("theme.components.message.assistant.shadow");
40
+ expect(fieldPaths).toContain("theme.components.toolBubble.shadow");
41
+ expect(fieldPaths).toContain("theme.components.reasoningBubble.shadow");
42
+ expect(fieldPaths).toContain("theme.components.approval.requested.shadow");
43
+ expect(fieldPaths).toContain("theme.components.introCard.shadow");
44
+ expect(fieldPaths).toContain("theme.components.composer.shadow");
45
+ });
46
+
30
47
  it("adds a scroll-to-bottom interface role mapping", () => {
31
48
  const role = ALL_ROLES.find((entry) => entry.roleId === "role-scroll-to-bottom");
32
49
 
@@ -455,6 +455,36 @@ const scrollToBottomSectionDef: SectionDef = {
455
455
  ],
456
456
  };
457
457
 
458
+ // Reusable shadow-scale options matching the launcher / panel / scroll-to-bottom selects.
459
+ const SHADOW_SCALE_OPTIONS: { value: string; label: string }[] = [
460
+ { value: 'palette.shadows.none', label: 'None' },
461
+ { value: 'palette.shadows.sm', label: 'Small' },
462
+ { value: 'palette.shadows.md', label: 'Medium' },
463
+ { value: 'palette.shadows.lg', label: 'Large' },
464
+ { value: 'palette.shadows.xl', label: 'Extra Large' },
465
+ ];
466
+
467
+ // The approval and intro-card defaults are a bespoke "card" shadow rather than a
468
+ // palette step, so expose it as a leading "Default" option users can keep.
469
+ const CARD_SHADOW = '0 5px 15px rgba(15, 23, 42, 0.08)';
470
+ const CARD_SHADOW_OPTIONS = [{ value: CARD_SHADOW, label: 'Default' }, ...SHADOW_SCALE_OPTIONS];
471
+
472
+ const componentShadowsSectionDef: SectionDef = {
473
+ id: 'comp-shadows',
474
+ title: 'Component Shadows',
475
+ description: 'Box-shadow for chat bubbles and surfaces.',
476
+ collapsed: true,
477
+ fields: [
478
+ { id: 'shadow-msg-user', label: 'User Message', type: 'select', path: 'theme.components.message.user.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
479
+ { id: 'shadow-msg-assistant', label: 'Assistant Message', type: 'select', path: 'theme.components.message.assistant.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
480
+ { id: 'shadow-tool-bubble', label: 'Tool Call Bubble', type: 'select', path: 'theme.components.toolBubble.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
481
+ { id: 'shadow-reasoning-bubble', label: 'Reasoning Bubble', type: 'select', path: 'theme.components.reasoningBubble.shadow', defaultValue: 'palette.shadows.sm', options: SHADOW_SCALE_OPTIONS },
482
+ { id: 'shadow-approval', label: 'Approval Bubble', type: 'select', path: 'theme.components.approval.requested.shadow', defaultValue: CARD_SHADOW, options: CARD_SHADOW_OPTIONS },
483
+ { id: 'shadow-intro-card', label: 'Intro Card', type: 'select', path: 'theme.components.introCard.shadow', defaultValue: CARD_SHADOW, options: CARD_SHADOW_OPTIONS },
484
+ { id: 'shadow-composer', label: 'Composer', type: 'select', path: 'theme.components.composer.shadow', defaultValue: 'palette.shadows.none', options: SHADOW_SCALE_OPTIONS },
485
+ ],
486
+ };
487
+
458
488
  /** Shared shape sections (not scoped to light/dark) */
459
489
  export const COMPONENT_SHAPE_SECTIONS: SectionDef[] = [
460
490
  panelLayoutSectionDef,
@@ -462,6 +492,7 @@ export const COMPONENT_SHAPE_SECTIONS: SectionDef[] = [
462
492
  messageShapeSectionDef,
463
493
  inputShapeSectionDef,
464
494
  buttonShapeSectionDef,
495
+ componentShadowsSectionDef,
465
496
  ];
466
497
 
467
498
  /** Component color sections (can be scoped for light/dark) */
@@ -136,7 +136,7 @@ export const THEME_TOKEN_DOCS = {
136
136
  voice:
137
137
  'recording (indicator, background, border), processing (icon, background), speaking (icon).',
138
138
  approval:
139
- 'requested (background, border, text), approve (background, foreground), deny (background, foreground).',
139
+ 'requested (background, border, text, shadow), approve (background, foreground), deny (background, foreground).',
140
140
  attachment: 'image (background, border).',
141
141
  scrollToBottom:
142
142
  'Floating scroll-to-bottom affordance shared by transcript and event stream: background, foreground, border, size, borderRadius, shadow, padding, gap, fontSize, iconSize.',
@@ -330,6 +330,8 @@ export interface ApprovalTokens {
330
330
  background: TokenReference<'color'>;
331
331
  border: TokenReference<'color'>;
332
332
  text: TokenReference<'color'>;
333
+ /** Box-shadow for the approval bubble (token ref or raw CSS, e.g. `none`). */
334
+ shadow: string;
333
335
  };
334
336
  approve: ComponentTokenSet;
335
337
  deny: ComponentTokenSet;
package/src/types.ts CHANGED
@@ -255,6 +255,12 @@ export type WebMcpConfirmInfo = {
255
255
  toolName: string;
256
256
  args: unknown;
257
257
  description?: string;
258
+ /**
259
+ * Display title the tool declared via the WebMCP spec's
260
+ * `ToolDescriptor.title` (e.g. `"Add to Cart"`). Absent when the tool
261
+ * didn't declare one.
262
+ */
263
+ title?: string;
258
264
  annotations?: {
259
265
  readOnlyHint?: boolean;
260
266
  untrustedContentHint?: boolean;
@@ -1816,6 +1822,23 @@ export interface VoiceProvider {
1816
1822
  stopPlayback?(): void;
1817
1823
  }
1818
1824
 
1825
+ /**
1826
+ * Extra context for an approval decision, surfaced to `onDecision` and to the
1827
+ * `approve`/`deny` callbacks passed to the `renderApproval` plugin hook.
1828
+ */
1829
+ export type AgentWidgetApprovalDecisionOptions = {
1830
+ /**
1831
+ * The user chose a "remember this" affordance (e.g. an "Always allow"
1832
+ * button) rather than a one-time decision. The widget resolves the *current*
1833
+ * approval identically whether or not this is set — an approval bubble is a
1834
+ * single binary gate (`approved`/`denied`). Persisting a don't-ask-again
1835
+ * policy for *future* approvals (auto-resolving them, or not surfacing them)
1836
+ * is the integrator's responsibility, typically inside `onDecision`.
1837
+ * Defaults to absent/`false`.
1838
+ */
1839
+ remember?: boolean;
1840
+ };
1841
+
1819
1842
  /**
1820
1843
  * Configuration for tool approval bubbles.
1821
1844
  * Controls styling, labels, and behavior of the approval UI.
@@ -1825,6 +1848,8 @@ export type AgentWidgetApprovalConfig = {
1825
1848
  backgroundColor?: string;
1826
1849
  /** Border color of the approval bubble */
1827
1850
  borderColor?: string;
1851
+ /** Box-shadow for the approval bubble; pass `"none"` to remove it. Overrides the default `persona-shadow-sm`. */
1852
+ shadow?: string;
1828
1853
  /** Color for the title text */
1829
1854
  titleColor?: string;
1830
1855
  /** Color for the description text */
@@ -1847,19 +1872,51 @@ export type AgentWidgetApprovalConfig = {
1847
1872
  approveLabel?: string;
1848
1873
  /** Label for the deny button */
1849
1874
  denyLabel?: string;
1875
+ /**
1876
+ * How the technical details (the tool's agent-facing description and the
1877
+ * raw parameters JSON) are presented:
1878
+ * - `"collapsed"` (default) — hidden behind a "Show details" toggle
1879
+ * - `"expanded"` — visible, with the toggle available to hide them
1880
+ * - `"hidden"` — never rendered
1881
+ */
1882
+ detailsDisplay?: "collapsed" | "expanded" | "hidden";
1883
+ /** Label for the toggle that reveals the technical details */
1884
+ showDetailsLabel?: string;
1885
+ /** Label for the toggle that hides the technical details */
1886
+ hideDetailsLabel?: string;
1887
+ /**
1888
+ * Build the user-facing summary line for an approval request. Overrides the
1889
+ * default "The assistant wants to use “Tool name”." copy. Return a falsy
1890
+ * value to fall back to the default for that approval. `displayTitle` is
1891
+ * the display name the tool declared via the WebMCP spec's
1892
+ * `ToolDescriptor.title`, when one exists.
1893
+ */
1894
+ formatDescription?: (approval: {
1895
+ toolName: string;
1896
+ toolType?: string;
1897
+ description: string;
1898
+ parameters?: unknown;
1899
+ displayTitle?: string;
1900
+ }) => string | undefined;
1850
1901
  /**
1851
1902
  * Custom handler for approval decisions.
1852
1903
  * Return void to let the SDK auto-resolve via the API,
1853
1904
  * or return a Response/ReadableStream for custom handling.
1905
+ *
1906
+ * `options.remember` is `true` when the decision came from a "remember this"
1907
+ * affordance (e.g. an "Always allow" button in a custom `renderApproval`
1908
+ * plugin). Use it to persist a don't-ask-again policy for future approvals;
1909
+ * the current approval resolves the same way regardless.
1854
1910
  */
1855
1911
  onDecision?: (
1856
1912
  data: { approvalId: string; executionId: string; agentId: string; toolName: string },
1857
- decision: 'approved' | 'denied'
1913
+ decision: 'approved' | 'denied',
1914
+ options?: AgentWidgetApprovalDecisionOptions
1858
1915
  ) => Promise<Response | ReadableStream<Uint8Array> | void>;
1859
1916
  };
1860
1917
 
1861
1918
  export type AgentWidgetToolCallConfig = {
1862
- /** Box-shadow for tool-call bubbles; overrides `theme.toolBubbleShadow` when set. */
1919
+ /** Box-shadow for tool-call bubbles; pass `"none"` to remove it. Overrides the `components.toolBubble.shadow` token / `--persona-tool-bubble-shadow`. */
1863
1920
  shadow?: string;
1864
1921
  /** Background color of the tool call bubble container. */
1865
1922
  backgroundColor?: string;