@runtypelabs/persona 3.31.0 → 3.33.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 (51) hide show
  1. package/README.md +2 -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-quh7NmYD.d.cts → types-CthJFfNx.d.cts} +7 -0
  5. package/dist/animations/{types-quh7NmYD.d.ts → types-CthJFfNx.d.ts} +7 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +4 -4
  9. package/dist/codegen.js +3 -3
  10. package/dist/index.cjs +43 -43
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +288 -8
  13. package/dist/index.d.ts +288 -8
  14. package/dist/index.global.js +76 -79
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +43 -43
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/smart-dom-reader.d.cts +78 -2
  20. package/dist/smart-dom-reader.d.ts +78 -2
  21. package/dist/theme-editor.cjs +30 -30
  22. package/dist/theme-editor.d.cts +92 -5
  23. package/dist/theme-editor.d.ts +92 -5
  24. package/dist/theme-editor.js +30 -30
  25. package/dist/webmcp-polyfill.js +4 -0
  26. package/package.json +4 -3
  27. package/src/ask-user-question-tool.test.ts +148 -0
  28. package/src/ask-user-question-tool.ts +138 -0
  29. package/src/client.ts +43 -9
  30. package/src/components/messages.ts +10 -0
  31. package/src/components/suggestions.ts +49 -6
  32. package/src/index-core.ts +15 -0
  33. package/src/index-global.ts +39 -0
  34. package/src/runtime/host-layout.test.ts +158 -3
  35. package/src/runtime/host-layout.ts +95 -1
  36. package/src/session.ts +188 -32
  37. package/src/session.webmcp.test.ts +48 -0
  38. package/src/suggest-replies-tool.test.ts +445 -0
  39. package/src/suggest-replies-tool.ts +152 -0
  40. package/src/theme-editor/index.ts +2 -0
  41. package/src/theme-editor/webmcp/index.ts +2 -0
  42. package/src/theme-editor/webmcp/types.ts +16 -3
  43. package/src/types.ts +79 -2
  44. package/src/ui.suggest-replies.test.ts +237 -0
  45. package/src/ui.ts +57 -13
  46. package/src/utils/dock.test.ts +23 -1
  47. package/src/utils/dock.ts +2 -0
  48. package/src/version.ts +5 -2
  49. package/src/webmcp-bridge.test.ts +60 -0
  50. package/src/webmcp-bridge.ts +34 -1
  51. package/src/webmcp-polyfill.ts +16 -0
package/src/session.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { AgentWidgetClient, type SSEEventCallback } from "./client";
2
2
  import { isWebMcpToolName } from "./webmcp-bridge";
3
+ import {
4
+ SUGGEST_REPLIES_TOOL_NAME,
5
+ suggestRepliesToolResult,
6
+ } from "./suggest-replies-tool";
3
7
  import {
4
8
  AgentWidgetConfig,
5
9
  AgentWidgetEvent,
@@ -40,6 +44,49 @@ export type AgentWidgetSessionStatus =
40
44
  | "connected"
41
45
  | "error";
42
46
 
47
+ /**
48
+ * Config fields the `AgentWidgetClient` reads to shape the connection and each
49
+ * request. When NONE of these change, `updateConfig` can refresh the live
50
+ * client in place (preserving the WebMCP bridge and any in-flight stream/resume)
51
+ * instead of swapping in a fresh client and tearing down WebMCP state. Fields
52
+ * outside this list (theme, copy, layout, suggestionChips, iterationDisplay,
53
+ * postprocessMessage, feature display toggles, …) are display-only and safe to
54
+ * apply mid-turn — which is what a self-styling widget needs when a `webmcp:*`
55
+ * theme tool re-renders the widget while the agent's turn is still streaming.
56
+ *
57
+ * Compared by identity (`!==`): primitives by value, functions/objects by
58
+ * reference. A consumer that rebuilds these objects on every render simply
59
+ * takes the (still-correct) full-rebuild path. The default is therefore safe:
60
+ * anything not explicitly listed here can never strand a paused turn.
61
+ */
62
+ const CONNECTION_CONFIG_KEYS = [
63
+ "apiUrl",
64
+ "clientToken",
65
+ "flowId",
66
+ "agent",
67
+ "agentOptions",
68
+ "headers",
69
+ "getHeaders",
70
+ "webmcp",
71
+ "streamParser",
72
+ "parserType",
73
+ "contextProviders",
74
+ "requestMiddleware",
75
+ "customFetch",
76
+ "parseSSEEvent",
77
+ "onSessionInit",
78
+ "onSessionExpired",
79
+ "getStoredSessionId",
80
+ "setStoredSessionId",
81
+ ] as const satisfies ReadonlyArray<keyof AgentWidgetConfig>;
82
+
83
+ function connectionConfigChanged(
84
+ prev: AgentWidgetConfig,
85
+ next: AgentWidgetConfig,
86
+ ): boolean {
87
+ return CONNECTION_CONFIG_KEYS.some((key) => prev[key] !== next[key]);
88
+ }
89
+
43
90
  type SessionCallbacks = {
44
91
  onMessagesChanged: (messages: AgentWidgetMessage[]) => void;
45
92
  onStatusChanged: (status: AgentWidgetSessionStatus) => void;
@@ -91,6 +138,15 @@ const getWebMcpErrorMessage = (
91
138
  return fallback;
92
139
  };
93
140
 
141
+ /**
142
+ * Tool names whose `step_await` the widget resolves automatically (no user
143
+ * pill click): `webmcp:*` page tools and the built-in fire-and-forget
144
+ * `suggest_replies`. These share the await-batch / dedupe / resume machinery;
145
+ * `ask_user_question` is NOT one of them — it blocks on the answer sheet.
146
+ */
147
+ const isAutoResolvedLocalToolName = (name: string): boolean =>
148
+ isWebMcpToolName(name) || name === SUGGEST_REPLIES_TOOL_NAME;
149
+
94
150
  export class AgentWidgetSession {
95
151
  private client: AgentWidgetClient;
96
152
  private messages: AgentWidgetMessage[];
@@ -615,6 +671,21 @@ export class AgentWidgetSession {
615
671
  }
616
672
 
617
673
  public updateConfig(next: AgentWidgetConfig) {
674
+ const merged = { ...this.config, ...next };
675
+
676
+ // Connection/request-shaping change (apiUrl, clientToken, webmcp, headers,
677
+ // parser, agent, …) → full client rebuild. UI-only change (theme, copy,
678
+ // layout, suggestions, …) → refresh in place so the live stream, WebMCP
679
+ // bridge, and any in-flight resolve survive. The latter is what makes a
680
+ // self-styling widget work: a `webmcp:*` theme tool mutates config and
681
+ // re-renders mid-turn; recreating the client there would abort the very
682
+ // turn that's restyling the widget and strand the paused execution.
683
+ if (!connectionConfigChanged(this.config, merged)) {
684
+ this.config = merged;
685
+ this.client.updateConfig(merged);
686
+ return;
687
+ }
688
+
618
689
  // Replacing the client invalidates every in-flight WebMCP resolve, buffered
619
690
  // parallel-await batch, and pending approval bubble tied to the OLD client/
620
691
  // session. Tear them down BEFORE the swap (the new client has no session
@@ -626,7 +697,7 @@ export class AgentWidgetSession {
626
697
  this.webMcpInflightKeys.clear();
627
698
  this.webMcpResolvedKeys.clear();
628
699
  const prevSSECallback = this.client.getSSEEventCallback();
629
- this.config = { ...this.config, ...next };
700
+ this.config = merged;
630
701
  this.client = new AgentWidgetClient(this.config);
631
702
  this.wireDefaultWebMcpConfirm();
632
703
  if (prevSSECallback) {
@@ -1535,7 +1606,8 @@ export class AgentWidgetSession {
1535
1606
  }
1536
1607
 
1537
1608
  /**
1538
- * Collect a `webmcp:*` LOCAL-tool `step_await` into a per-executionId batch
1609
+ * Collect an auto-resolving LOCAL-tool `step_await` (`webmcp:*` page tools
1610
+ * and the built-in `suggest_replies`) into a per-executionId batch
1539
1611
  * and schedule a single deferred flush. Parallel calls (core#3878) emit
1540
1612
  * several `step_await`s for ONE paused execution within the same stream tick;
1541
1613
  * buffering them and flushing once lets us post ONE `/resume` keyed by the
@@ -1636,6 +1708,26 @@ export class AgentWidgetSession {
1636
1708
  return Date.now();
1637
1709
  }
1638
1710
 
1711
+ /**
1712
+ * Persisted-resolution guard for `suggest_replies`. The in-memory dedupe
1713
+ * sets (`webMcpInflightKeys` / `webMcpResolvedKeys`) are cleared by
1714
+ * hydrateMessages/clearMessages/cancel, but `suggestRepliesResolved`
1715
+ * survives on the stored message — so a stale `step_await` re-emit after a
1716
+ * hydration must not re-POST `/resume` for an already-resolved call (the
1717
+ * historical double-resume failure mode the batching work exists to avoid).
1718
+ * Checks the LIVE message first; the handleEvent snapshot is a fresh wire
1719
+ * skeleton whose metadata never carries the flag.
1720
+ */
1721
+ private isSuggestRepliesAlreadyResolved(
1722
+ toolMessage: AgentWidgetMessage,
1723
+ ): boolean {
1724
+ if (toolMessage.toolCall?.name !== SUGGEST_REPLIES_TOOL_NAME) return false;
1725
+ const stored = this.messages.find((m) => m.id === toolMessage.id);
1726
+ return (
1727
+ (stored ?? toolMessage).agentMetadata?.suggestRepliesResolved === true
1728
+ );
1729
+ }
1730
+
1639
1731
  private markWebMcpToolRunning(
1640
1732
  toolMessage: AgentWidgetMessage,
1641
1733
  ): number {
@@ -1666,6 +1758,9 @@ export class AgentWidgetSession {
1666
1758
  result: unknown,
1667
1759
  startedAt: number,
1668
1760
  completedAt = Date.now(),
1761
+ extraMetadata?: Partial<
1762
+ NonNullable<AgentWidgetMessage["agentMetadata"]>
1763
+ >,
1669
1764
  ): void {
1670
1765
  // A teardown such as clearMessages()/hydrateMessages()/new send can remove
1671
1766
  // the bubble while an aborted WebMCP promise is settling. Never resurrect a
@@ -1677,6 +1772,7 @@ export class AgentWidgetSession {
1677
1772
  agentMetadata: {
1678
1773
  ...toolMessage.agentMetadata,
1679
1774
  awaitingLocalTool: false,
1775
+ ...extraMetadata,
1680
1776
  },
1681
1777
  toolCall: toolMessage.toolCall
1682
1778
  ? {
@@ -1738,7 +1834,8 @@ export class AgentWidgetSession {
1738
1834
  const dedupeKey = `${executionId}:${callId}`;
1739
1835
  if (
1740
1836
  this.webMcpInflightKeys.has(dedupeKey) ||
1741
- this.webMcpResolvedKeys.has(dedupeKey)
1837
+ this.webMcpResolvedKeys.has(dedupeKey) ||
1838
+ this.isSuggestRepliesAlreadyResolved(toolMessage)
1742
1839
  ) {
1743
1840
  return null;
1744
1841
  }
@@ -1750,15 +1847,29 @@ export class AgentWidgetSession {
1750
1847
  // only means the server paused for a local tool; it is not completion.
1751
1848
  const startedAt = this.markWebMcpToolRunning(toolMessage);
1752
1849
 
1753
- const controller = new AbortController();
1754
- this.webMcpResolveControllers.add(controller);
1755
- controllers.push(controller);
1756
-
1757
1850
  // Per-call id wins for resume keying; fall back to the wire tool name
1758
1851
  // for legacy servers that don't emit `webMcpToolCallId`.
1759
1852
  const resumeKey =
1760
1853
  toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
1761
1854
 
1855
+ // Built-in fire-and-forget tool: no bridge, no confirm gate, no
1856
+ // browser-side execution — the chips render from the message list and
1857
+ // the canned output joins the batch's single /resume.
1858
+ if (wireToolName === SUGGEST_REPLIES_TOOL_NAME) {
1859
+ return {
1860
+ dedupeKey,
1861
+ resumeKey,
1862
+ output: suggestRepliesToolResult(),
1863
+ toolMessage,
1864
+ startedAt,
1865
+ completedAt: Date.now(),
1866
+ };
1867
+ }
1868
+
1869
+ const controller = new AbortController();
1870
+ this.webMcpResolveControllers.add(controller);
1871
+ controllers.push(controller);
1872
+
1762
1873
  const execPromise = this.client.executeWebMcpToolCall(
1763
1874
  wireToolName,
1764
1875
  toolMessage.toolCall?.args,
@@ -1853,6 +1964,9 @@ export class AgentWidgetSession {
1853
1964
  r.output,
1854
1965
  r.startedAt,
1855
1966
  r.completedAt,
1967
+ r.toolMessage.toolCall?.name === SUGGEST_REPLIES_TOOL_NAME
1968
+ ? { suggestRepliesResolved: true }
1969
+ : undefined,
1856
1970
  );
1857
1971
  }
1858
1972
  if (response.body) {
@@ -1892,12 +2006,16 @@ export class AgentWidgetSession {
1892
2006
  }
1893
2007
 
1894
2008
  /**
1895
- * Resolve a paused `webmcp:*` LOCAL tool call by executing it against the
1896
- * host page's tool registry and posting the result to `/resume`.
2009
+ * Resolve a paused auto-resolving LOCAL tool call and post the result to
2010
+ * `/resume`: `webmcp:*` calls execute against the host page's tool
2011
+ * registry; the built-in `suggest_replies` skips execution entirely and
2012
+ * resumes with a canned "shown" result (the chips render from the message
2013
+ * list, not from this resolve).
1897
2014
  *
1898
2015
  * Triggered automatically from `handleEvent` when a `step_await`-derived
1899
- * message arrives with a `webmcp:` prefix — the user does not click a
1900
- * pill; the bridge's confirm-bubble gate is the only interactive surface.
2016
+ * message arrives for such a tool — the user does not click a pill; the
2017
+ * bridge's confirm-bubble gate (WebMCP only) is the only interactive
2018
+ * surface.
1901
2019
  *
1902
2020
  * Idempotent on the message's `toolCall.id`: re-emits of the same step_await
1903
2021
  * (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
@@ -1918,8 +2036,9 @@ export class AgentWidgetSession {
1918
2036
  // via onError so an operator can react. This is a server-side
1919
2037
  // wire-shape bug — Persona can't recover it from the client.
1920
2038
  // - no wireToolName: defensive guard — handleEvent only calls us
1921
- // when tc.name is a `webmcp:` prefix, so this path indicates a
1922
- // direct caller misuse. Silent return.
2039
+ // for an auto-resolving local tool name (`webmcp:*` or
2040
+ // `suggest_replies`), so this path indicates a direct caller
2041
+ // misuse. Silent return.
1923
2042
  // - no toolCallId: dedupe key falls apart, but the server can still
1924
2043
  // advance if we post an isError for the wireToolName. Do that
1925
2044
  // and bail before the dedupe path.
@@ -1966,11 +2085,14 @@ export class AgentWidgetSession {
1966
2085
  }
1967
2086
 
1968
2087
  // Dedupe key scoped by executionId — see `webMcpInflightKeys` doc comment
1969
- // for the failure-recovery + cross-dispatch rationale.
2088
+ // for the failure-recovery + cross-dispatch rationale. The persisted
2089
+ // `suggestRepliesResolved` guard backs the in-memory sets across
2090
+ // hydrations.
1970
2091
  const dedupeKey = `${executionId}:${toolCallId}`;
1971
2092
  if (
1972
2093
  this.webMcpInflightKeys.has(dedupeKey) ||
1973
- this.webMcpResolvedKeys.has(dedupeKey)
2094
+ this.webMcpResolvedKeys.has(dedupeKey) ||
2095
+ this.isSuggestRepliesAlreadyResolved(toolMessage)
1974
2096
  ) {
1975
2097
  return;
1976
2098
  }
@@ -1999,21 +2121,27 @@ export class AgentWidgetSession {
1999
2121
  const { signal } = resolveController;
2000
2122
  this.setStreaming(true);
2001
2123
 
2124
+ // Built-in fire-and-forget tool: no bridge, no confirm gate, no
2125
+ // browser-side execution — the chips render from the message list and the
2126
+ // canned output resumes the execution immediately. Branch BEFORE any
2127
+ // bridge access so the missing-bridge error path can never fire for it.
2128
+ const isSuggestReplies = wireToolName === SUGGEST_REPLIES_TOOL_NAME;
2129
+
2002
2130
  const args = toolMessage.toolCall?.args;
2003
2131
  // Thread the signal INTO the bridge — short-circuits the confirm bubble
2004
2132
  // and the execute() race on cancel(), so a late confirm-approval after
2005
2133
  // cancel() cannot fire a host-page side effect with no matching /resume.
2006
- const execPromise = this.client.executeWebMcpToolCall(
2007
- wireToolName,
2008
- args,
2009
- signal,
2010
- );
2134
+ const execPromise = isSuggestReplies
2135
+ ? null
2136
+ : this.client.executeWebMcpToolCall(wireToolName, args, signal);
2011
2137
 
2012
2138
  let phase: "execute" | "resume" = "execute";
2013
2139
  let completedAt = startedAt;
2014
2140
  try {
2015
2141
  let resumeOutput: unknown;
2016
- if (!execPromise) {
2142
+ if (isSuggestReplies) {
2143
+ resumeOutput = suggestRepliesToolResult();
2144
+ } else if (!execPromise) {
2017
2145
  // Client has no bridge (config.webmcp.enabled !== true). Resume with
2018
2146
  // an error so the dispatch can advance instead of hanging.
2019
2147
  resumeOutput = {
@@ -2062,6 +2190,7 @@ export class AgentWidgetSession {
2062
2190
  resumeOutput,
2063
2191
  startedAt,
2064
2192
  completedAt,
2193
+ isSuggestReplies ? { suggestRepliesResolved: true } : undefined,
2065
2194
  );
2066
2195
  },
2067
2196
  signal,
@@ -2380,10 +2509,13 @@ export class AgentWidgetSession {
2380
2509
  if (event.type === "message") {
2381
2510
  this.upsertMessage(event.message);
2382
2511
 
2383
- // WebMCP auto-resolve: when a step_await emits a tool-variant message
2384
- // for a `webmcp:*` tool, drive the bridge to execute it and post the
2385
- // result to /resume. Unlike ask_user_question, no user pill click is
2386
- // required the bridge's confirm bubble is the only interactive surface.
2512
+ // Local-tool auto-resolve: when a step_await emits a tool-variant
2513
+ // message for a `webmcp:*` tool or the built-in fire-and-forget
2514
+ // `suggest_replies` resolve it and post the result to /resume.
2515
+ // Unlike ask_user_question, no user pill click is required; for WebMCP
2516
+ // the bridge's confirm bubble is the only interactive surface, and
2517
+ // suggest_replies resumes with a canned "shown" result while the chips
2518
+ // render above the composer.
2387
2519
  //
2388
2520
  // Defer via `queueMicrotask` so handleEvent returns FIRST. The current
2389
2521
  // SSE consumer is still mid-loop; once we return, the dispatch's
@@ -2397,11 +2529,19 @@ export class AgentWidgetSession {
2397
2529
  // if the bridge is non-operational. Otherwise the dispatch stays paused
2398
2530
  // indefinitely — `resolveWebMcpToolCall` translates the missing-bridge
2399
2531
  // case into an isError result that resumes the flow cleanly.
2532
+ // `suggest_replies`, by contrast, is gated on its feature flag: when
2533
+ // `features.suggestReplies.enabled === false` the widget neither
2534
+ // renders chips nor resumes — the same parked-execution posture as a
2535
+ // server-declared ask_user_question with its sheet disabled.
2400
2536
  const tc = event.message.toolCall;
2537
+ const autoResolvable =
2538
+ !!tc?.name &&
2539
+ (isWebMcpToolName(tc.name) ||
2540
+ (tc.name === SUGGEST_REPLIES_TOOL_NAME &&
2541
+ this.config.features?.suggestReplies?.enabled !== false));
2401
2542
  if (
2402
2543
  event.message.agentMetadata?.awaitingLocalTool === true &&
2403
- tc?.name &&
2404
- isWebMcpToolName(tc.name)
2544
+ autoResolvable
2405
2545
  ) {
2406
2546
  // Collect the await into its executionId's batch instead of resolving
2407
2547
  // it on the spot. Parallel same-tool calls (core#3878) arrive as
@@ -2659,6 +2799,21 @@ export class AgentWidgetSession {
2659
2799
  awaitingLocalTool: false,
2660
2800
  };
2661
2801
  }
2802
+ // suggest_replies equivalent: preserve the persisted fire-and-forget
2803
+ // resolution across re-emissions. It is the only dedupe signal that
2804
+ // survives a hydration (the in-memory key sets are cleared), so a
2805
+ // stale step_await re-emit must not wipe it before
2806
+ // `isSuggestRepliesAlreadyResolved` checks it in the resolve path.
2807
+ if (
2808
+ existing.agentMetadata?.suggestRepliesResolved === true &&
2809
+ withSequence.agentMetadata
2810
+ ) {
2811
+ merged.agentMetadata = {
2812
+ ...(merged.agentMetadata ?? withSequence.agentMetadata),
2813
+ suggestRepliesResolved: true,
2814
+ awaitingLocalTool: false,
2815
+ };
2816
+ }
2662
2817
  // Approval equivalent: `agent_approval_complete` carries only the
2663
2818
  // resolution (approvalId, decision, resolvedBy) — the runtime does not
2664
2819
  // re-send toolName/description/toolType/reason/parameters, so client.ts
@@ -2686,9 +2841,10 @@ export class AgentWidgetSession {
2686
2841
  parameters: incoming.parameters ?? prior.parameters,
2687
2842
  };
2688
2843
  }
2689
- // WebMCP equivalent: once a `webmcp:*` tool has started resolving
2690
- // (inflight) or resolved, a duplicate `step_await` re-emit must not
2691
- // flip `awaitingLocalTool` back to true and resurrect the "waiting on
2844
+ // Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
2845
+ // `suggest_replies`): once such a tool has started resolving (inflight)
2846
+ // or resolved, a duplicate `step_await` re-emit must not flip
2847
+ // `awaitingLocalTool` back to true and resurrect the "waiting on
2692
2848
  // local tool" UI. It also must not overwrite an existing running or
2693
2849
  // completed toolCall with the fresh running skeleton emitted by client.ts
2694
2850
  // for every step_await. resolveWebMcpToolCall's dedupe path returns
@@ -2699,7 +2855,7 @@ export class AgentWidgetSession {
2699
2855
  const reTcId = withSequence.toolCall?.id;
2700
2856
  if (
2701
2857
  reTcName &&
2702
- isWebMcpToolName(reTcName) &&
2858
+ isAutoResolvedLocalToolName(reTcName) &&
2703
2859
  reExecId &&
2704
2860
  reTcId &&
2705
2861
  withSequence.agentMetadata?.awaitingLocalTool === true
@@ -2712,7 +2868,7 @@ export class AgentWidgetSession {
2712
2868
  existing.agentMetadata?.executionId === reExecId &&
2713
2869
  existing.toolCall?.id === reTcId &&
2714
2870
  existingToolName !== undefined &&
2715
- isWebMcpToolName(existingToolName) &&
2871
+ isAutoResolvedLocalToolName(existingToolName) &&
2716
2872
  existing.toolCall?.status === "complete";
2717
2873
  if (isInflight || isResolved || hasCompletedTool) {
2718
2874
  merged.agentMetadata = {
@@ -1075,6 +1075,54 @@ describe("AgentWidgetSession — WebMCP parallel batched resume (core#3878)", ()
1075
1075
  expect(s.webMcpApprovalResolvers.size).toBe(0);
1076
1076
  });
1077
1077
 
1078
+ it("a UI-only updateConfig preserves the client, bridge, and in-flight WebMCP state", async () => {
1079
+ // Self-styling widget: a `webmcp:*` theme tool mutates config and re-renders
1080
+ // mid-turn. updateConfig must NOT swap the client when only display fields
1081
+ // (theme/copy/…) change — doing so would abort the very turn that's
1082
+ // restyling the widget and strand the paused execution. (Inverse of the
1083
+ // teardown test above.)
1084
+ const { session } = makeSession();
1085
+ const s = session as unknown as {
1086
+ client: unknown;
1087
+ config: { copy?: { welcomeTitle?: string } };
1088
+ webMcpAwaitBatches: Map<string, unknown>;
1089
+ webMcpApprovalResolvers: Map<string, unknown>;
1090
+ };
1091
+ const clientBefore = s.client;
1092
+ feed(session, parallelAwait("toolu_A", "SHOE-001"));
1093
+ feed(session, parallelAwait("toolu_B", "SHOE-007"));
1094
+ session.requestWebMcpApproval({
1095
+ toolName: "add_to_cart",
1096
+ args: { sku: "SHOE-001" },
1097
+ } as WebMcpConfirmInfo);
1098
+ expect(s.webMcpAwaitBatches.size).toBe(1);
1099
+ expect(s.webMcpApprovalResolvers.size).toBe(1);
1100
+
1101
+ // No apiUrl / webmcp in the patch → connection unchanged → in-place refresh.
1102
+ session.updateConfig({ copy: { welcomeTitle: "Recolored" } });
1103
+
1104
+ expect(s.client).toBe(clientBefore); // same client instance, not swapped
1105
+ expect(s.config.copy?.welcomeTitle).toBe("Recolored"); // display change applied
1106
+ expect(s.webMcpAwaitBatches.size).toBe(1); // batch survives
1107
+ expect(s.webMcpApprovalResolvers.size).toBe(1); // approval still pending
1108
+ });
1109
+
1110
+ it("a connection change (apiUrl) still swaps the client and tears down WebMCP state", () => {
1111
+ const { session } = makeSession();
1112
+ const s = session as unknown as {
1113
+ client: unknown;
1114
+ webMcpAwaitBatches: Map<string, unknown>;
1115
+ };
1116
+ const clientBefore = s.client;
1117
+ feed(session, parallelAwait("toolu_A", "SHOE-001"));
1118
+ expect(s.webMcpAwaitBatches.size).toBe(1);
1119
+
1120
+ session.updateConfig({ apiUrl: "http://other" });
1121
+
1122
+ expect(s.client).not.toBe(clientBefore); // fresh client
1123
+ expect(s.webMcpAwaitBatches.size).toBe(0); // batch torn down
1124
+ });
1125
+
1078
1126
  it("a teardown before the stream-end flush strands the batch", async () => {
1079
1127
  const { session, executeSpy, resumeSpy } = makeSession();
1080
1128
  feed(session, parallelAwait("toolu_A", "SHOE-001"));