@runtypelabs/persona 4.28.2 → 4.29.1

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 (145) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/wipe.d.cts +1 -1
  4. package/dist/animations/wipe.d.ts +1 -1
  5. package/dist/approval-ui.cjs +3 -3
  6. package/dist/approval-ui.d.cts +1 -1
  7. package/dist/approval-ui.d.ts +1 -1
  8. package/dist/approval-ui.js +3 -3
  9. package/dist/artifacts-ui.d.cts +2 -2
  10. package/dist/artifacts-ui.d.ts +2 -2
  11. package/dist/chunk-B1-_pp-Y.js +4 -0
  12. package/dist/{chunk-DrYAfpvk.js → chunk-BcLIgi1h.js} +1 -1
  13. package/dist/chunk-BkW8s3fj.js +2 -0
  14. package/dist/chunk-Bm2-bemv.js +1 -0
  15. package/dist/chunk-BvhTpG6k.js +1 -0
  16. package/dist/chunk-Csks3Qw1.js +1 -0
  17. package/dist/chunk-D5B629Mj.js +1 -0
  18. package/dist/chunk-DGlFKA0r.js +9 -0
  19. package/dist/chunk-DO9_WSpy.js +1 -0
  20. package/dist/chunk-DTDHeUsG.js +1 -0
  21. package/dist/chunk-DjFpGFGA.js +1 -0
  22. package/dist/{chunk-dRBlrLd7.js → chunk-DtmtfIkd.js} +1 -1
  23. package/dist/chunk-lA5CJCgK.js +1 -0
  24. package/dist/client-history.js +1 -1
  25. package/dist/client-stream.js +1 -1
  26. package/dist/codegen.cjs +1 -1
  27. package/dist/codegen.js +1 -1
  28. package/dist/{composer-input-C39npJaz.d.cts → composer-input-B0P4OtNi.d.cts} +1 -1
  29. package/dist/{composer-input-C39npJaz.d.ts → composer-input-B0P4OtNi.d.ts} +1 -1
  30. package/dist/{context-mention-manager-DLCB1l9E.d.cts → context-mention-manager-DmLI_RM6.d.cts} +1 -1
  31. package/dist/{context-mention-manager-DLCB1l9E.d.ts → context-mention-manager-DmLI_RM6.d.ts} +1 -1
  32. package/dist/context-mentions-inline.d.cts +2 -2
  33. package/dist/context-mentions-inline.d.ts +2 -2
  34. package/dist/context-mentions.d.cts +3 -3
  35. package/dist/context-mentions.d.ts +3 -3
  36. package/dist/{defaults-DiAgWHXy.d.cts → defaults-DVo1wbZs.d.cts} +1 -1
  37. package/dist/{defaults-DiAgWHXy.d.ts → defaults-DVo1wbZs.d.ts} +1 -1
  38. package/dist/event-stream-view.d.cts +1 -1
  39. package/dist/event-stream-view.d.ts +1 -1
  40. package/dist/{fallback-speech-engine-C5_PUZjH.d.cts → fallback-speech-engine-BMnlmKnY.d.cts} +1 -1
  41. package/dist/{fallback-speech-engine-C5_PUZjH.d.ts → fallback-speech-engine-BMnlmKnY.d.ts} +1 -1
  42. package/dist/forms-ui.d.cts +2 -2
  43. package/dist/forms-ui.d.ts +2 -2
  44. package/dist/history-view.d.cts +1 -1
  45. package/dist/history-view.d.ts +1 -1
  46. package/dist/index.cjs +50 -49
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.cts +7 -7
  49. package/dist/index.d.ts +7 -7
  50. package/dist/index.global.js +2 -3
  51. package/dist/index.global.js.map +1 -1
  52. package/dist/index.js +38 -37
  53. package/dist/index.js.map +1 -1
  54. package/dist/{markdown-parsers-loader-BBdyIEU7.d.cts → markdown-parsers-loader-CeovcyYf.d.cts} +1 -1
  55. package/dist/{markdown-parsers-loader-BBdyIEU7.d.ts → markdown-parsers-loader-CeovcyYf.d.ts} +1 -1
  56. package/dist/{preview-Chjb0bmI.d.cts → preview-Bkb4UrQC.d.cts} +2 -2
  57. package/dist/{preview-Chjb0bmI.d.ts → preview-Bkb4UrQC.d.ts} +2 -2
  58. package/dist/session-actions.js +1 -1
  59. package/dist/{session-a_qIp4xp.d.ts → session-iLyKipoM.d.cts} +28 -10
  60. package/dist/{session-a_qIp4xp.d.cts → session-iLyKipoM.d.ts} +28 -10
  61. package/dist/session-reconnect.cjs +1 -1
  62. package/dist/session-reconnect.d.cts +8 -2
  63. package/dist/session-reconnect.d.ts +8 -2
  64. package/dist/session-reconnect.js +1 -1
  65. package/dist/smart-dom-reader.d.cts +1 -1
  66. package/dist/smart-dom-reader.d.ts +1 -1
  67. package/dist/theme-editor-preview.cjs +61 -60
  68. package/dist/theme-editor-preview.d.cts +1 -1
  69. package/dist/theme-editor-preview.d.ts +1 -1
  70. package/dist/theme-editor-preview.js +40 -40
  71. package/dist/theme-editor.d.cts +2 -2
  72. package/dist/theme-editor.d.ts +2 -2
  73. package/dist/theme-reference.d.cts +2 -2
  74. package/dist/theme-reference.d.ts +2 -2
  75. package/dist/{types-BdO1jNRm.d.cts → types-BuDirJkQ.d.cts} +200 -23
  76. package/dist/{types-BdO1jNRm.d.ts → types-BuDirJkQ.d.ts} +200 -23
  77. package/dist/voice-client-tools.cjs +1 -0
  78. package/dist/voice-client-tools.d.cts +13 -0
  79. package/dist/voice-client-tools.d.ts +13 -0
  80. package/dist/voice-client-tools.js +1 -0
  81. package/dist/voice-runtime.cjs +3 -3
  82. package/dist/voice-runtime.d.cts +15 -5
  83. package/dist/voice-runtime.d.ts +15 -5
  84. package/dist/voice-runtime.js +5 -5
  85. package/dist/voice-worklet-player.d.cts +2 -2
  86. package/dist/voice-worklet-player.d.ts +2 -2
  87. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.ts → webmcp-runtime-entry-CXLjRt1k.d.cts} +26 -5
  88. package/dist/{webmcp-runtime-entry-B6bpVWSa.d.cts → webmcp-runtime-entry-CXLjRt1k.d.ts} +26 -5
  89. package/dist/webmcp-runtime.cjs +2 -1
  90. package/dist/webmcp-runtime.d.cts +1 -1
  91. package/dist/webmcp-runtime.d.ts +1 -1
  92. package/dist/webmcp-runtime.js +2 -1
  93. package/dist/widget.css +1 -1
  94. package/package.json +6 -1
  95. package/src/client-approve.test.ts +17 -1
  96. package/src/client-resume.ts +15 -3
  97. package/src/client.interrupt.test.ts +21 -0
  98. package/src/client.ts +61 -4
  99. package/src/client.voice-tools.test.ts +65 -0
  100. package/src/components/activity-row.ts +3 -2
  101. package/src/components/approval-actions.test.ts +8 -0
  102. package/src/components/approval-actions.ts +6 -2
  103. package/src/components/approval-bubble.test.ts +14 -0
  104. package/src/components/approval-bubble.ts +8 -1
  105. package/src/components/tool-bubble.ts +8 -5
  106. package/src/generated/runtype-openapi-contract.ts +39 -1
  107. package/src/index-core.ts +11 -0
  108. package/src/index-global.ts +6 -0
  109. package/src/resume-dedupe-local-only.test.ts +273 -0
  110. package/src/runtime/persist-state.test.ts +86 -0
  111. package/src/session-actions.ts +20 -6
  112. package/src/session-reconnect.ts +18 -1
  113. package/src/session.restored-browser-tools.test.ts +272 -0
  114. package/src/session.test.ts +26 -0
  115. package/src/session.ts +185 -31
  116. package/src/session.voice.test.ts +212 -0
  117. package/src/styles/widget.css +4 -2
  118. package/src/types.ts +178 -22
  119. package/src/ui.restored-browser-tools.test.ts +128 -0
  120. package/src/ui.ts +32 -18
  121. package/src/utils/formatting.test.ts +9 -1
  122. package/src/utils/formatting.ts +4 -0
  123. package/src/utils/local-only-message.ts +15 -0
  124. package/src/utils/restored-browser-state.ts +50 -0
  125. package/src/utils/storage.ts +12 -1
  126. package/src/utils/voice-prior-tool-results.ts +37 -0
  127. package/src/voice/runtype-voice-provider.ts +94 -7
  128. package/src/voice/session-voice-wiring.ts +27 -3
  129. package/src/voice/voice-client-tools-session.ts +116 -0
  130. package/src/voice/voice-delegation.ts +19 -3
  131. package/src/voice/voice.test.ts +396 -0
  132. package/src/voice-client-tools-bundle.test.ts +37 -0
  133. package/src/voice-client-tools-loader.ts +24 -0
  134. package/src/voice-client-tools.ts +9 -0
  135. package/src/webmcp-bridge.test.ts +152 -1
  136. package/src/webmcp-bridge.ts +5 -6
  137. package/src/webmcp-polyfill.integration.test.ts +5 -1
  138. package/src/webmcp-runtime-entry.ts +151 -26
  139. package/dist/chunk-B6zZsT6F.js +0 -1
  140. package/dist/chunk-CztSjKHK.js +0 -9
  141. package/dist/chunk-DbhxjyOB.js +0 -4
  142. package/dist/chunk-DsdqLStH.js +0 -1
  143. package/dist/chunk-GIqbL2Eg.js +0 -1
  144. package/dist/chunk-HUOUif-P.js +0 -1
  145. package/dist/chunk-UlcxdHef.js +0 -1
package/src/types.ts CHANGED
@@ -818,14 +818,18 @@ export type AgentWidgetAgentRequestPayload = {
818
818
  // WebMCP Types (page-discovered tools shipped per dispatch)
819
819
  // ============================================================================
820
820
 
821
- /**
822
- * Wire shape for a single client-discovered tool sent on `dispatch.clientTools[]`.
823
- *
824
- * Mirrors the SDK's `ClientToolDefinition` in `@runtypelabs/sdk`. Only the
825
- * JSON-serializable surface of a WebMCP tool: the `execute` function stays
826
- * client-side; the server merges these into the agent's tool catalog under
827
- * the `webmcp:` namespace.
828
- */
821
+ /** Advisory WebMCP and MCP hints. These do not grant approval or safe retry. */
822
+ export type ToolAnnotations = {
823
+ readOnlyHint?: boolean;
824
+ destructiveHint?: boolean;
825
+ consequentialHint?: boolean;
826
+ untrustedContentHint?: boolean;
827
+ idempotentHint?: boolean;
828
+ openWorldHint?: boolean;
829
+ debugging?: boolean;
830
+ };
831
+
832
+ /** Wire metadata for a client-side tool; its execute function stays in the browser. */
829
833
  export type ClientToolDefinition = {
830
834
  /** Bare tool name; the server prepends `webmcp:` on the wire. */
831
835
  name: string;
@@ -842,20 +846,16 @@ export type ClientToolDefinition = {
842
846
  /** Origin of the page that registered the tool: for server-side audit. */
843
847
  pageOrigin?: string;
844
848
  /**
845
- * WebMCP `Tool.annotations` (spec). Not used for gating server-side; the
846
- * widget reads these client-side. Forwarded so traces/dashboards can show
847
- * `readOnlyHint` / `untrustedContentHint` on tool-call records.
849
+ * WebMCP hints and MCP compatibility hints forwarded to runtime tool metadata
850
+ * and confirmation callbacks. No hint skips the default confirmation gate.
848
851
  */
849
- annotations?: {
850
- readOnlyHint?: boolean;
851
- untrustedContentHint?: boolean;
852
- };
852
+ annotations?: ToolAnnotations;
853
853
  /**
854
854
  * Hoisted copy of `annotations.untrustedContentHint`. This is the field the
855
855
  * server actually acts on (`InlineClientToolSchema`): it spotlights the
856
856
  * tool's output before the model reads it, so instructions embedded in
857
857
  * page-provided content (reviews, comments, emails) are not treated as
858
- * instructions. `annotations` itself is trace/dashboard metadata only.
858
+ * instructions. A nested true hint also opts into output protection.
859
859
  */
860
860
  untrustedContentHint?: boolean;
861
861
  };
@@ -875,16 +875,19 @@ export type WebMcpConfirmInfo = {
875
875
  * didn't declare one.
876
876
  */
877
877
  title?: string;
878
- annotations?: {
879
- readOnlyHint?: boolean;
880
- untrustedContentHint?: boolean;
881
- };
878
+ annotations?: ToolAnnotations;
882
879
  /**
883
880
  * Why the confirm was requested. Currently always `'gate'`: the default
884
881
  * confirm-by-default gate that fires before every `webmcp:*` call. Tool
885
882
  * callbacks on v6 receive an AbortSignal, not a nested confirmation API.
886
883
  */
887
884
  reason: 'gate';
885
+ /**
886
+ * Aborts when the call no longer needs an answer (its deadline passed, or it
887
+ * was cancelled). A confirm UI should close and resolve `false`; the tool
888
+ * never runs after it aborts either way.
889
+ */
890
+ signal?: AbortSignal;
888
891
  };
889
892
 
890
893
  /**
@@ -5264,6 +5267,13 @@ export type VoiceDelegationRequest = {
5264
5267
  userUtteranceIds: string[];
5265
5268
  /** The voice model's view of the conversation it delegated from. */
5266
5269
  messages: Array<{ role: string; content: unknown }>;
5270
+ /**
5271
+ * `delegation_started.input.priorToolResults` as received: page tools the
5272
+ * server already ran for this turn, each `{ toolCallId, name, arguments,
5273
+ * result }`. Unvalidated. The chat turn records them as completed calls and
5274
+ * never runs them again.
5275
+ */
5276
+ priorToolResults?: unknown;
5267
5277
  /**
5268
5278
  * Set when the server streams delegated answers (`delegation_stream`): called
5269
5279
  * with each speakable piece of the turn's answer, in order, while it renders.
@@ -5355,8 +5365,126 @@ export interface VoiceSessionBridge {
5355
5365
  * declined too, as the approval TTL would.
5356
5366
  */
5357
5367
  dropDelegation?(delegationId: string, expired?: boolean): void;
5368
+ /**
5369
+ * The page tools a voice call may offer the server's fast tier
5370
+ * (`client_tools` / `client_tool_call`). Returns `null` when WebMCP is not
5371
+ * enabled on this widget; the provider then does not declare `client_tools`.
5372
+ */
5373
+ clientTools?(): VoiceClientTools | null;
5374
+ /**
5375
+ * The server answered a delegation itself (`delegation_completed.route ===
5376
+ * 'direct'`): record the visitor's turn and the spoken answer in the chat,
5377
+ * as a delegated turn would have. `assistantText` may be empty (no readout).
5378
+ */
5379
+ recordDirectTurn?(turn: VoiceDirectTurn): void;
5380
+ }
5381
+
5382
+ /** A delegation the server answered on its fast tier, for the chat history. */
5383
+ export type VoiceDirectTurn = {
5384
+ delegationId: string;
5385
+ userText: string;
5386
+ userUtteranceIds: string[];
5387
+ assistantText: string;
5388
+ };
5389
+
5390
+ /** `client_tool_result.status` (open enum on the wire). */
5391
+ export type VoiceClientToolStatus =
5392
+ | 'completed'
5393
+ | 'failed'
5394
+ | 'denied'
5395
+ | 'stale'
5396
+ | 'timeout'
5397
+ | (string & {});
5398
+
5399
+ /** What running one `client_tool_call` produced. */
5400
+ export type VoiceClientToolOutcome = {
5401
+ status: VoiceClientToolStatus;
5402
+ /** The page tool's return value (any JSON), on `completed`. */
5403
+ result?: unknown;
5404
+ error?: { message: string };
5405
+ };
5406
+
5407
+ /** The page's tool set as of now, plus its content fingerprint. */
5408
+ export type VoiceClientToolsSnapshot = {
5409
+ /** Exactly what `/v1/client/chat` would send as `clientTools[]` (bare names). */
5410
+ tools: ClientToolDefinition[];
5411
+ fingerprint: string;
5412
+ };
5413
+
5414
+ /** WebMCP access for a voice call (see {@link VoiceSessionBridge.clientTools}). */
5415
+ export interface VoiceClientTools {
5416
+ snapshot(): Promise<VoiceClientToolsSnapshot>;
5417
+ /** Subscribe to registry changes; returns an unsubscribe. */
5418
+ onChange(listener: () => void): () => void;
5419
+ /**
5420
+ * Run a page tool. `autoConfirmReadOnly`: skip the confirm gate when the
5421
+ * tool's registry entry has `annotations.readOnlyHint === true`.
5422
+ */
5423
+ execute(
5424
+ name: string,
5425
+ args: Record<string, unknown>,
5426
+ options: { signal: AbortSignal; autoConfirmReadOnly: boolean },
5427
+ ): Promise<VoiceClientToolOutcome>;
5428
+ /**
5429
+ * Start the call's fast-tier session (loads its lazy chunk): it sends
5430
+ * `client_tools` through `channel` now and on every change, and answers
5431
+ * the `client_tool_call` frames handed to it.
5432
+ */
5433
+ open(channel: VoiceClientToolsChannel): Promise<VoiceClientToolsSession>;
5434
+ }
5435
+
5436
+ /** The call socket, as the fast-tier session sees it. */
5437
+ export type VoiceClientToolsChannel = {
5438
+ /** Send a frame; `false` when the call (socket) is gone. */
5439
+ send(frame: ClientToolsFrame | ClientToolResultFrame): boolean;
5440
+ /** The server cancelled this delegation: its tool results are not sent. */
5441
+ cancelled(delegationId: string): boolean;
5442
+ };
5443
+
5444
+ /** A call's fast-tier session (see {@link VoiceClientTools.open}). */
5445
+ export interface VoiceClientToolsSession {
5446
+ call(frame: Partial<ClientToolCallFrame>): void;
5447
+ /** Abort running tool calls (of one delegation, or all); they send nothing. */
5448
+ abort(delegationId?: string): void;
5449
+ close(): void;
5358
5450
  }
5359
5451
 
5452
+ /**
5453
+ * Client → server (`runtype-browser-v1`, `client_tools` capability): the page's
5454
+ * tool set, after `session_config` and whenever its fingerprint changes. Names
5455
+ * are the FULL names the server calls back with: `webmcp:` prefixed for
5456
+ * `origin: 'webmcp'`, bare for `sdk`.
5457
+ */
5458
+ export type ClientToolsFrame = {
5459
+ type: 'client_tools';
5460
+ /** The content fingerprint the chat path computes for the same `clientTools[]`. */
5461
+ fingerprint: string;
5462
+ tools: ClientToolDefinition[];
5463
+ };
5464
+
5465
+ /** Server → client: the fast tier picked a page tool for this delegation. */
5466
+ export type ClientToolCallFrame = {
5467
+ type: 'client_tool_call';
5468
+ delegationId: string;
5469
+ toolCallId: string;
5470
+ /** `webmcp:<name>` or bare; the client accepts both. */
5471
+ name: string;
5472
+ arguments: Record<string, unknown>;
5473
+ /** The `client_tools` fingerprint the call was chosen from. */
5474
+ fingerprint: string;
5475
+ timeoutMs: number;
5476
+ };
5477
+
5478
+ /** Client → server: the answer to one {@link ClientToolCallFrame}. */
5479
+ export type ClientToolResultFrame = {
5480
+ type: 'client_tool_result';
5481
+ delegationId: string;
5482
+ toolCallId: string;
5483
+ status: VoiceClientToolStatus;
5484
+ result?: unknown;
5485
+ error?: { message: string };
5486
+ };
5487
+
5360
5488
  /**
5361
5489
  * Extra context for an approval decision, surfaced to `onDecision` and to the
5362
5490
  * `approve`/`deny` callbacks passed to the `renderApproval` plugin hook.
@@ -6283,7 +6411,23 @@ export type ClientChatRequest = {
6283
6411
  * reads aloud: the server asks the agent for a speakable answer (the direct
6284
6412
  * answer first, in plain sentences). Not persisted into history.
6285
6413
  */
6286
- voice?: { spoken: true };
6414
+ voice?: {
6415
+ spoken: true;
6416
+ /**
6417
+ * Page tools the voice server already ran for this hand-back
6418
+ * (`delegation_started.input.priorToolResults`), sent only when it carried
6419
+ * some. The agent narrates their results instead of calling them again.
6420
+ */
6421
+ priorToolResults?: VoicePriorToolResult[];
6422
+ };
6423
+ };
6424
+
6425
+ /** A page tool call the voice server already ran, as `/v1/client/chat` takes it. */
6426
+ export type VoicePriorToolResult = {
6427
+ toolCallId: string;
6428
+ name: string;
6429
+ arguments: Record<string, unknown>;
6430
+ result: unknown;
6287
6431
  };
6288
6432
 
6289
6433
  /**
@@ -7085,7 +7229,8 @@ export type AgentWidgetPersistStateConfig = {
7085
7229
  */
7086
7230
  storage?: 'local' | 'session';
7087
7231
  /**
7088
- * Prefix for storage keys.
7232
+ * Prefix for storage keys. Also scopes the default transcript key
7233
+ * (`${keyPrefix}state` in localStorage) when no `storageAdapter` is given.
7089
7234
  * @default 'persona-'
7090
7235
  */
7091
7236
  keyPrefix?: string;
@@ -8233,6 +8378,8 @@ export type AgentWidgetReasoning = {
8233
8378
  export type AgentWidgetToolCall = {
8234
8379
  /** Optional approval state for activity-row status chrome. */
8235
8380
  approvalStatus?: "pending" | "approved" | "denied" | "timeout";
8381
+ /** Terminal browser work whose callback was lost when the chat restarted. */
8382
+ interrupted?: boolean;
8236
8383
  id: string;
8237
8384
  name?: string;
8238
8385
  status: "pending" | "running" | "complete";
@@ -8260,6 +8407,8 @@ export type AgentWidgetToolCall = {
8260
8407
  export type AgentWidgetApproval = {
8261
8408
  id: string;
8262
8409
  status: "pending" | "approved" | "denied" | "timeout";
8410
+ /** A browser callback lost on restart. The approval expires with status `timeout`. */
8411
+ interrupted?: boolean;
8263
8412
  agentId: string;
8264
8413
  executionId: string;
8265
8414
  toolName: string;
@@ -8384,6 +8533,13 @@ export type AgentWidgetMessage = {
8384
8533
  * model as conversation.
8385
8534
  */
8386
8535
  voiceCaption?: boolean;
8536
+ /**
8537
+ * Set on widget-generated failure notices (a failed dispatch, a rejected
8538
+ * approval decision, a reconnect that gave up). The bubble renders in this
8539
+ * browser only: it is never sent to the model as conversation history and
8540
+ * never written to the storage adapter.
8541
+ */
8542
+ localOnly?: boolean;
8387
8543
  /**
8388
8544
  * Raw structured payload for this message (e.g., JSON action response).
8389
8545
  * Populated automatically when structured parsers run.
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createAgentExperience } from "./ui";
4
+ import { provideApprovalUi } from "./approval-ui-loader";
5
+ import * as approvalUiEntry from "./approval-ui-entry";
6
+ import type { AgentWidgetMessage, AgentWidgetStoredState } from "./types";
7
+ import { createDemoHistoryProvider } from "./internal/demo-history-provider";
8
+
9
+ provideApprovalUi(approvalUiEntry);
10
+ let controller: ReturnType<typeof createAgentExperience> | undefined;
11
+ afterEach(() => { controller?.destroy(); document.body.replaceChildren(); localStorage.clear(); vi.restoreAllMocks(); });
12
+
13
+ const snapshot = (): AgentWidgetStoredState => ({
14
+ metadata: { custom: "retained" }, draft: { text: "My unsent proposal" },
15
+ messages: [
16
+ { id: "tool-1", role: "assistant", content: "", createdAt: "2026-01-01", sequence: 1, streaming: true, variant: "tool", agentMetadata: { executionId: "exec", awaitingLocalTool: true }, toolCall: { id: "call-1", name: "webmcp:purchase", status: "running", startedAt: 1, args: { quantity: 2 }, approvalStatus: "pending" } },
17
+ { id: "tool-2", role: "assistant", content: "", createdAt: "2026-01-01", sequence: 2, variant: "tool", toolCall: { id: "call-2", name: "webmcp:search", status: "complete", result: "Found two items", durationMs: 2000 } },
18
+ { id: "approval-webmcp-1", role: "assistant", content: "", createdAt: "2026-01-01", sequence: 3, variant: "approval", approval: { id: "webmcp-1", status: "pending", agentId: "", executionId: "", toolType: "webmcp", toolName: "webmcp:purchase", description: "Buy items", parameters: { quantity: 2 } } },
19
+ ] satisfies AgentWidgetMessage[],
20
+ });
21
+
22
+ for (const source of ["sync", "async", "initial", "hook"] as const) {
23
+ describe.each(["card", "row"] as const)(`${source} restoration (%s)`, variant => {
24
+ it("renders historical controls and activity as interrupted while preserving drafts", async () => {
25
+ const stored = snapshot();
26
+ const before = structuredClone(stored);
27
+ const mount = document.createElement("div"); document.body.appendChild(mount);
28
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
29
+ controller = createAgentExperience(mount, {
30
+ apiUrl: "/test", launcher: { enabled: false }, webmcp: { enabled: true },
31
+ ...(source === "initial" ? { initialMessages: stored.messages, persistState: false } : source === "hook" ? { onStateLoaded: () => stored, persistState: false } : {
32
+ storageAdapter: { load: () => source === "async" ? Promise.resolve(stored) : stored, save: vi.fn(), clear: vi.fn() },
33
+ }),
34
+ features: { toolCallDisplay: { variant, grouped: true, groupedMode: "collapsible" } },
35
+ toolCall: { completeTextTemplate: "Finished {toolName} ~{duration}~" },
36
+ });
37
+ await vi.waitFor(() => expect(mount.textContent).toContain("chat restarted"));
38
+ expect(mount.querySelector('[data-action="allow"], [data-action="deny"], [data-approval-buttons]')).toBeNull();
39
+ expect(mount.querySelector('[data-tool-elapsed], [data-activity-state="running"], [data-activity-state="awaiting-approval"]')).toBeNull();
40
+ expect(mount.querySelector('[data-message-id="tool-1"]')?.textContent).toContain("Interrupted");
41
+ expect(mount.querySelector('[data-message-id="tool-1"]')?.textContent).not.toContain("Finished");
42
+ if (variant === "row") {
43
+ expect(mount.querySelector('[data-persona-tool-group]')?.getAttribute("data-activity-state")).toBe("interrupted");
44
+ expect(mount.textContent).toContain("2s");
45
+ }
46
+ if (source === "sync" || source === "async") expect(controller.getComposerState().text).toBe("My unsent proposal");
47
+ expect(stored).toEqual(before);
48
+ expect(fetchSpy).not.toHaveBeenCalled();
49
+ });
50
+ });
51
+ }
52
+
53
+ it("keeps a cardless interrupted tool visible when completed rows are removed", async () => {
54
+ const stored = snapshot();
55
+ const mount = document.createElement("div"); document.body.appendChild(mount);
56
+ controller = createAgentExperience(mount, {
57
+ launcher: { enabled: false }, persistState: false,
58
+ initialMessages: [stored.messages![0]],
59
+ features: { toolCallDisplay: { variant: "row", completedVisibility: "removed" } },
60
+ toolCall: { renderCollapsedSummary: ({ createElapsedElement }) => createElapsedElement() },
61
+ });
62
+ expect(mount.querySelector('[data-message-id="tool-1"]')).not.toBeNull();
63
+ expect(mount.querySelector('[data-tool-elapsed]')).toBeNull();
64
+ expect(mount.textContent).toContain("chat restarted");
65
+ });
66
+
67
+ it("supports a custom approval renderer that only handles the original statuses", () => {
68
+ const labels = { pending: "Waiting", approved: "Allowed", denied: "Denied", timeout: "Expired" };
69
+ const mount = document.createElement("div"); document.body.appendChild(mount);
70
+ const render = vi.fn(({ message }: { message: AgentWidgetMessage }) => {
71
+ const node = document.createElement("span");
72
+ node.textContent = message.approval ? labels[message.approval.status] : "";
73
+ return node;
74
+ });
75
+ controller = createAgentExperience(mount, {
76
+ launcher: { enabled: false }, persistState: false,
77
+ initialMessages: [snapshot().messages![2]],
78
+ plugins: [{ id: "existing-approval-renderer", renderApproval: render }],
79
+ });
80
+ expect(mount.textContent).toContain("Expired");
81
+ expect(render).toHaveBeenCalled();
82
+ expect(render.mock.calls[0][0].message.approval).toMatchObject({ status: "timeout", interrupted: true });
83
+ });
84
+
85
+ it.each(["card", "row"] as const)("loading earlier messages retires browser activity in %s display", async variant => {
86
+ const stored = snapshot();
87
+ const before = structuredClone(stored);
88
+ const provider = createDemoHistoryProvider();
89
+ const summary = { id: "restored", title: "Restored", targetId: null, preview: "", createdAt: "2026-01-01", updatedAt: "2026-01-02", messageCount: 4 };
90
+ vi.spyOn(provider, "prepareOpen").mockResolvedValue({ conversationId: summary.id, conversationRevision: "r1", commit: vi.fn(), discard: vi.fn() });
91
+ vi.spyOn(provider, "getPage").mockImplementation(async (_id, options) => ({
92
+ summary, conversationRevision: "r1", nextCursor: options?.cursor ? null : "older",
93
+ messages: options?.cursor ? stored.messages! : [{ id: "newer", role: "assistant", content: "Newer reply", createdAt: "2026-01-02" }],
94
+ }));
95
+ const mount = document.createElement("div"); document.body.appendChild(mount);
96
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
97
+ controller = createAgentExperience(mount, {
98
+ apiUrl: "/test", launcher: { enabled: false }, persistState: false,
99
+ features: { history: { enabled: true, provider }, toolCallDisplay: { variant, grouped: true } },
100
+ });
101
+ await controller.openConversation(summary.id);
102
+ const earlier = mount.querySelector<HTMLButtonElement>("[data-persona-history-earlier]");
103
+ expect(earlier).not.toBeNull();
104
+ earlier!.click();
105
+ await vi.waitFor(() => expect(mount.textContent).toContain("chat restarted"));
106
+ expect(mount.querySelector('[data-action="allow"], [data-action="deny"], [data-tool-elapsed], [data-activity-state="running"], [data-activity-state="awaiting-approval"]')).toBeNull();
107
+ expect(mount.textContent).toContain("Interrupted");
108
+ expect(mount.textContent).toContain("Newer reply");
109
+ expect(stored).toEqual(before);
110
+ expect(fetchSpy).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it.each(["button", "controller"] as const)("restored server WebMCP approval uses its transport via %s", async route => {
114
+ const message = snapshot().messages![2];
115
+ message.approval!.executionId = "durable-exec";
116
+ const onDecision = vi.fn(async () => undefined);
117
+ const mount = document.createElement("div"); document.body.appendChild(mount);
118
+ controller = createAgentExperience(mount, {
119
+ launcher: { enabled: false }, persistState: false, initialMessages: [message], approval: { onDecision },
120
+ });
121
+ expect(onDecision).not.toHaveBeenCalled();
122
+ if (route === "button") mount.querySelector<HTMLButtonElement>('[data-action="allow"]')!.click();
123
+ else await controller.resolveApproval(message.approval!.id, "approved");
124
+ await vi.waitFor(() => expect(onDecision).toHaveBeenCalledTimes(1));
125
+ expect(onDecision.mock.calls[0]).toEqual([{
126
+ approvalId: "webmcp-1", executionId: "durable-exec", agentId: "", toolName: "webmcp:purchase",
127
+ }, "approved", undefined]);
128
+ });
package/src/ui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isBrowserLocalApproval } from "./utils/restored-browser-state";
1
2
  import { activityVariant, activityDuration, createActivityLifecycle, createActivityGroup } from "./components/activity-row";
2
3
  import { applyStatusIndicatorState, placeStatusIndicator } from "./utils/status-indicator";
3
4
  import { usesSessionVoice } from "./utils/voice-support";
@@ -331,7 +332,8 @@ import {
331
332
  defaultActionHandlers,
332
333
  defaultJsonActionParser
333
334
  } from "./utils/actions";
334
- import { createLocalStorageAdapter } from "./utils/storage";
335
+ import { createLocalStorageAdapter, transcriptStorageKey } from "./utils/storage";
336
+ import { isLocalOnlyMessage } from "./utils/local-only-message";
335
337
  import { createVisitorStore, type VisitorStore } from "./utils/visitor-store";
336
338
  import { loadHistoryView } from "./history-view-loader";
337
339
  import { getHistoryShellSync, loadHistoryShell, type HistoryShellModule } from "./history-shell-loader";
@@ -501,11 +503,13 @@ const ensureRecord = (value: unknown): Record<string, unknown> => {
501
503
  return { ...(value as Record<string, unknown>) };
502
504
  };
503
505
 
504
- const stripStreamingFromMessages = (messages: AgentWidgetMessage[]) =>
505
- messages.map((message) => ({
506
- ...message,
507
- streaming: false
508
- }));
506
+ const messagesForStorage = (messages: AgentWidgetMessage[]) =>
507
+ messages
508
+ .filter((message) => !isLocalOnlyMessage(message))
509
+ .map((message) => ({
510
+ ...message,
511
+ streaming: false
512
+ }));
509
513
 
510
514
  type Controller = {
511
515
  update: (config: AgentWidgetConfigPatch) => void;
@@ -1064,7 +1068,12 @@ export const createAgentExperience = (
1064
1068
  const storageAdapter: AgentWidgetStorageAdapter | null =
1065
1069
  messagePersistenceDisabled
1066
1070
  ? null
1067
- : (config.storageAdapter ?? createLocalStorageAdapter());
1071
+ : (config.storageAdapter ??
1072
+ createLocalStorageAdapter(
1073
+ transcriptStorageKey(
1074
+ typeof config.persistState === "object" ? config.persistState.keyPrefix : undefined
1075
+ )
1076
+ ));
1068
1077
  const personaOwnsClientTokenRecovery =
1069
1078
  Boolean(config.clientToken) && typeof config.reconnectStream !== "function";
1070
1079
  let persistentMetadata: Record<string, unknown> = {};
@@ -3055,8 +3064,8 @@ export const createAgentExperience = (
3055
3064
 
3056
3065
  // WebMCP gate approvals resolve a local Promise the bridge is parked on
3057
3066
  // (no server round-trip); server-driven approvals call the API. The
3058
- // `toolType` marker set in `requestWebMcpApproval` discriminates the two.
3059
- if (approvalMessage.approval.toolType === "webmcp") {
3067
+ // `toolType` marker and absent execution id identify browser-local gates.
3068
+ if (isBrowserLocalApproval(approvalMessage.approval)) {
3060
3069
  session.resolveWebMcpApproval(messageId, decision);
3061
3070
  } else {
3062
3071
  session.resolveApproval(approvalMessage.approval, decision);
@@ -5159,7 +5168,7 @@ export const createAgentExperience = (
5159
5168
 
5160
5169
  const getMessagesForPersistence = () =>
5161
5170
  session
5162
- ? stripStreamingFromMessages(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
5171
+ ? messagesForStorage(session.getMessages()).filter(msg => !(msg as any).__skipPersist)
5163
5172
  : [];
5164
5173
 
5165
5174
  let storageMutationTail: Promise<void> | null = null;
@@ -5209,7 +5218,7 @@ export const createAgentExperience = (
5209
5218
 
5210
5219
  // Allow saving even if session doesn't exist yet (for metadata during init)
5211
5220
  const messages = messagesOverride
5212
- ? stripStreamingFromMessages(messagesOverride)
5221
+ ? messagesForStorage(messagesOverride)
5213
5222
  : session
5214
5223
  ? getMessagesForPersistence()
5215
5224
  : [];
@@ -6239,7 +6248,8 @@ export const createAgentExperience = (
6239
6248
  return (
6240
6249
  dropCompletedToolCalls &&
6241
6250
  message.variant === "tool" &&
6242
- message.toolCall?.status === "complete"
6251
+ message.toolCall?.status === "complete" &&
6252
+ !message.toolCall.interrupted
6243
6253
  );
6244
6254
  };
6245
6255
 
@@ -6507,7 +6517,7 @@ export const createAgentExperience = (
6507
6517
  ?.getMessages()
6508
6518
  .find((m) => m.id === approvalMessageId);
6509
6519
  if (!live?.approval) return;
6510
- if (live.approval.toolType === "webmcp") {
6520
+ if (isBrowserLocalApproval(live.approval)) {
6511
6521
  sessionRef.current?.resolveWebMcpApproval(live.id, decision);
6512
6522
  } else {
6513
6523
  sessionRef.current?.resolveApproval(live.approval, decision, options);
@@ -6801,7 +6811,7 @@ export const createAgentExperience = (
6801
6811
  // A tool call hidden by `completedVisibility: "removed"` does not
6802
6812
  // create a visible break in the transcript, so it should not split
6803
6813
  // an otherwise contiguous group.
6804
- if (dropCompletedToolCalls && message.toolCall.status === "complete") {
6814
+ if (dropCompletedToolCalls && message.toolCall.status === "complete" && !message.toolCall.interrupted) {
6805
6815
  return;
6806
6816
  }
6807
6817
  currentGroup.push(message);
@@ -6859,10 +6869,12 @@ export const createAgentExperience = (
6859
6869
  id, name: `${group.length} tools`, status: active ? "running" : "complete",
6860
6870
  chunks: group.flatMap(item => item.toolCall?.chunks ?? []),
6861
6871
  success: !group.some(item => item.toolCall?.success === false),
6872
+ interrupted: !active && group.some(item => item.toolCall?.interrupted),
6862
6873
  } };
6863
6874
  activityIds.add(id);
6864
6875
  activityLifecycle.observe(synthetic, "tool", toolExpansionState, config.features.toolCallDisplay);
6865
- const label = `${active ? "Using" : "Used"} ${group.length} tools`;
6876
+ const label = synthetic.toolCall?.interrupted ? `${group.length} tools · Interrupted`
6877
+ : `${active ? "Using" : "Used"} ${group.length} tools`;
6866
6878
  const custom = config.toolCall?.renderGroupedSummary?.({ messages: group, toolCalls: group.map(item => item.toolCall!), defaultSummary: label, config });
6867
6879
  const row = createActivityGroup(synthetic, config, toolExpansionState.has(id), custom ?? label);
6868
6880
  row.bubble.classList.add("persona-tool-group");
@@ -6888,7 +6900,9 @@ export const createAgentExperience = (
6888
6900
  "persona-tool-group-summary persona-text-xs persona-text-persona-muted";
6889
6901
  summary.id = `tool-group-summary-${group[0].id}`;
6890
6902
 
6891
- const defaultSummary = `Called ${group.length} ${group.length === 1 ? "tool" : "tools"}`;
6903
+ const toolCount = `${group.length} ${group.length === 1 ? "tool" : "tools"}`;
6904
+ const defaultSummary = group.some(item => item.toolCall?.interrupted)
6905
+ ? `${toolCount} · Interrupted` : `Called ${toolCount}`;
6892
6906
  const renderedSummary = config.toolCall?.renderGroupedSummary?.({
6893
6907
  messages: group,
6894
6908
  toolCalls: group
@@ -13277,8 +13291,8 @@ export const createAgentExperience = (
13277
13291
  // Mirror the in-panel click handler: WebMCP gate bubbles resolve a local
13278
13292
  // Promise the bridge is parked on (no server round-trip and they carry an
13279
13293
  // empty executionId/agentId), so they must NOT hit the server approval
13280
- // API. Route by the `toolType` marker set in `requestWebMcpApproval`.
13281
- if (approvalMessage.approval.toolType === "webmcp") {
13294
+ // API. Server-backed approvals retain their execution id and use the API.
13295
+ if (isBrowserLocalApproval(approvalMessage.approval)) {
13282
13296
  session.resolveWebMcpApproval(approvalMessage.id, decision);
13283
13297
  return;
13284
13298
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed } from "./formatting";
2
+ import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed, computeToolElapsed } from "./formatting";
3
3
 
4
4
  describe("JSON Stream Parser", () => {
5
5
  it("should extract text field incrementally as JSON streams in", () => {
@@ -268,3 +268,11 @@ describe("computeReasoningElapsed", () => {
268
268
  expect(result).toBe("<0.1s");
269
269
  });
270
270
  });
271
+
272
+ it("never invents an interrupted tool's duration, including elapsed render hooks", () => {
273
+ const tool = { id: "browser", status: "complete" as const, interrupted: true };
274
+ expect(computeToolElapsed(tool)).toBe("");
275
+ expect(computeToolElapsed({ ...tool, startedAt: 1 })).toBe("");
276
+ expect(computeToolElapsed({ ...tool, durationMs: 2000 })).toBe("2s");
277
+ expect(computeToolElapsed({ ...tool, startedAt: 1000, completedAt: 4000 })).toBe("3s");
278
+ });
@@ -124,6 +124,10 @@ export const formatElapsedMs = (ms: number): string => {
124
124
  * Computes the current elapsed time string for a tool call.
125
125
  */
126
126
  export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
127
+ if (
128
+ tool.interrupted && tool.duration === undefined && tool.durationMs === undefined &&
129
+ (tool.startedAt === undefined || tool.completedAt === undefined)
130
+ ) return "";
127
131
  const durationMs =
128
132
  typeof tool.duration === "number"
129
133
  ? tool.duration
@@ -0,0 +1,15 @@
1
+ import type { AgentWidgetMessage } from "../types";
2
+
3
+ /** Id prefix of the bubble the durable reconnect loop posts when it gives up. */
4
+ export const RECONNECT_FAILED_ID_PREFIX = "reconnect-failed-";
5
+
6
+ /**
7
+ * A widget-generated notice that is shown in this browser only: never sent as
8
+ * conversation history and never written to the storage adapter. The id check
9
+ * covers reconnect-failure bubbles persisted before `localOnly` existed.
10
+ */
11
+ export const isLocalOnlyMessage = (message: AgentWidgetMessage): boolean =>
12
+ message.localOnly === true ||
13
+ (message.role === "assistant" &&
14
+ typeof message.id === "string" &&
15
+ message.id.startsWith(RECONNECT_FAILED_ID_PREFIX));
@@ -0,0 +1,50 @@
1
+ import type { AgentWidgetApproval, AgentWidgetMessage, AgentWidgetToolCall } from "../types";
2
+ import { isWebMcpToolName } from "../webmcp-bridge";
3
+
4
+ export const BROWSER_TOOL_INTERRUPTION =
5
+ "The chat restarted and this browser tool was interrupted. Request a fresh proposal to try again.";
6
+
7
+ export function isBrowserLocalApproval(approval: AgentWidgetApproval): boolean {
8
+ return approval.toolType === "webmcp" && !approval.executionId;
9
+ }
10
+
11
+ /** History cannot restore a browser callback. Server continuations stay live. */
12
+ export function restoreBrowserToolState(message: AgentWidgetMessage): AgentWidgetMessage {
13
+ const approval = message.approval;
14
+ // Server approvals can also name webmcp tools. An execution id identifies
15
+ // their durable continuation; native browser gates have no execution id.
16
+ const interruptedApproval = approval?.status === "pending" && isBrowserLocalApproval(approval);
17
+ let hasBrowserTool = false;
18
+ const settle = (tool: AgentWidgetToolCall): AgentWidgetToolCall => {
19
+ if (!tool.name || !isWebMcpToolName(tool.name)) return tool;
20
+ hasBrowserTool = true;
21
+ // Older await frames used complete for the wire step before the browser
22
+ // had executed anything. A recorded outcome, however, remains historical.
23
+ const unfinished = tool.status !== "complete" ||
24
+ (message.agentMetadata?.awaitingLocalTool === true &&
25
+ tool.result === undefined && tool.success === undefined);
26
+ if (!unfinished) return tool;
27
+ return {
28
+ ...tool,
29
+ status: "complete",
30
+ interrupted: true,
31
+ success: false,
32
+ error: BROWSER_TOOL_INTERRUPTION,
33
+ approvalStatus: tool.approvalStatus === "pending" ? "timeout" : tool.approvalStatus,
34
+ // Retain recorded durations and paired timestamps. Never fabricate an
35
+ // end timestamp or derive a duration from the time spent off the page.
36
+ startedAt: tool.completedAt === undefined ? undefined : tool.startedAt,
37
+ };
38
+ };
39
+ const toolCall = message.toolCall ? settle(message.toolCall) : undefined;
40
+ const tools = message.tools?.map(settle);
41
+ return {
42
+ ...message,
43
+ streaming: hasBrowserTool || interruptedApproval ? false : message.streaming,
44
+ ...(interruptedApproval ? { approval: { ...approval, status: "timeout" as const, interrupted: true } } : {}),
45
+ ...(toolCall ? { toolCall } : {}),
46
+ ...(tools ? { tools } : {}),
47
+ ...(hasBrowserTool && message.agentMetadata?.awaitingLocalTool
48
+ ? { agentMetadata: { ...message.agentMetadata, awaitingLocalTool: false } } : {}),
49
+ };
50
+ }