@runtypelabs/persona 3.28.0 → 3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.28.0",
3
+ "version": "3.29.1",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -3056,6 +3056,46 @@ describe('AgentWidgetClient — step_await parsing', () => {
3056
3056
  expect(toolMsg!.agentMetadata?.awaitingLocalTool).toBe(true);
3057
3057
  });
3058
3058
 
3059
+ it('emits a running tool message for WebMCP local_tool_required until the browser tool resolves', async () => {
3060
+ global.fetch = vi.fn().mockResolvedValue({
3061
+ ok: true,
3062
+ body: buildStepAwaitStream({
3063
+ awaitReason: 'local_tool_required',
3064
+ id: 'step-1',
3065
+ name: 'Test Step',
3066
+ stepType: 'prompt',
3067
+ index: 0,
3068
+ toolCallId: 'tc_webmcp_1',
3069
+ toolName: 'webmcp:get_product_by_url',
3070
+ executionId: 'exec_abc',
3071
+ startedAt: 1234,
3072
+ parameters: { path: '/jade/' },
3073
+ }),
3074
+ });
3075
+
3076
+ const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
3077
+ const events: AgentWidgetEvent[] = [];
3078
+ await client.dispatch(
3079
+ { messages: [{ id: 'u1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
3080
+ (e) => events.push(e)
3081
+ );
3082
+
3083
+ const toolMsg = events
3084
+ .filter((e) => e.type === 'message')
3085
+ .map((e) => (e as { message: AgentWidgetMessage }).message)
3086
+ .find((m) => m.variant === 'tool' && m.toolCall?.name === 'webmcp:get_product_by_url');
3087
+
3088
+ expect(toolMsg).toBeDefined();
3089
+ expect(toolMsg!.toolCall!.id).toBe('tc_webmcp_1');
3090
+ expect(toolMsg!.toolCall!.status).toBe('running');
3091
+ expect(toolMsg!.toolCall!.startedAt).toBe(1234);
3092
+ expect(toolMsg!.toolCall!.completedAt).toBeUndefined();
3093
+ expect(toolMsg!.toolCall!.durationMs).toBeUndefined();
3094
+ expect(toolMsg!.toolCall!.args).toEqual({ path: '/jade/' });
3095
+ expect(toolMsg!.agentMetadata?.executionId).toBe('exec_abc');
3096
+ expect(toolMsg!.agentMetadata?.awaitingLocalTool).toBe(true);
3097
+ });
3098
+
3059
3099
  it('ignores step_await events whose awaitReason is not local_tool_required', async () => {
3060
3100
  global.fetch = vi.fn().mockResolvedValue({
3061
3101
  ok: true,
package/src/client.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  ContentPart,
24
24
  WebMcpConfirmHandler
25
25
  } from "./types";
26
- import { WebMcpBridge, computeClientToolsFingerprint } from "./webmcp-bridge";
26
+ import { WebMcpBridge, computeClientToolsFingerprint, isWebMcpToolName } from "./webmcp-bridge";
27
27
  import {
28
28
  extractTextFromJson,
29
29
  createPlainTextParser,
@@ -2026,14 +2026,28 @@ export class AgentWidgetClient {
2026
2026
  const toolId =
2027
2027
  toolCallId ?? (payload.toolId as string) ?? `local-${nextSequence()}`;
2028
2028
  const toolMessage = ensureToolMessage(toolId);
2029
+ const toolName = payload.toolName as string;
2030
+ const webMcpTool = isWebMcpToolName(toolName);
2029
2031
  const tool = toolMessage.toolCall ?? { id: toolId, status: "pending" as const };
2030
- tool.name = payload.toolName as string;
2032
+ tool.name = toolName;
2031
2033
  tool.args = payload.parameters;
2032
- tool.status = "complete";
2034
+ // WebMCP tools are executed asynchronously by the browser AFTER this
2035
+ // `step_await` arrives. Keep them running until session.ts resolves
2036
+ // the page tool and records its actual elapsed time. Other local
2037
+ // tools (for example ask_user_question) keep the existing complete
2038
+ // state because they are waiting for a user interaction, not an
2039
+ // automatic page-tool execution.
2040
+ tool.status = webMcpTool ? "running" : "complete";
2033
2041
  tool.chunks = tool.chunks ?? [];
2034
2042
  tool.startedAt =
2035
2043
  tool.startedAt ?? resolveTimestamp(payload.startedAt ?? payload.timestamp);
2036
- tool.completedAt = tool.completedAt ?? tool.startedAt;
2044
+ if (webMcpTool) {
2045
+ tool.completedAt = undefined;
2046
+ tool.duration = undefined;
2047
+ tool.durationMs = undefined;
2048
+ } else {
2049
+ tool.completedAt = tool.completedAt ?? tool.startedAt;
2050
+ }
2037
2051
  toolMessage.toolCall = tool;
2038
2052
  toolMessage.streaming = false;
2039
2053
  toolMessage.agentMetadata = {
@@ -36,4 +36,47 @@ describe("codegen subpath", () => {
36
36
  expect(code).toContain(`@runtypelabs/persona@${VERSION}/dist/install.global.js`);
37
37
  expect(code).not.toContain("@latest");
38
38
  });
39
+
40
+ describe("mount target option", () => {
41
+ it("defaults to body when no target is given", () => {
42
+ expect(fromSubpath(config, "react-component")).toContain("target: 'body'");
43
+ expect(fromSubpath(config, "script-manual")).toContain("target: 'body'");
44
+ // installer omits the target key entirely (installer falls back to body)
45
+ expect(fromSubpath(config, "script-installer")).not.toContain('"target"');
46
+ });
47
+
48
+ it("emits the selector as the initAgentWidget target for code formats", () => {
49
+ const opts = { target: "#chat" };
50
+ expect(fromSubpath(config, "esm", opts)).toContain("target: '#chat'");
51
+ expect(fromSubpath(config, "react-component", opts)).toContain("target: '#chat'");
52
+ expect(fromSubpath(config, "script-manual", opts)).toContain("target: '#chat'");
53
+ });
54
+
55
+ it("serializes the selector as a top-level installer option (sibling of config)", () => {
56
+ // install.ts reads the mount point from the TOP LEVEL of data-config, not
57
+ // from inside the widget config. Parse the emitted data-config and assert
58
+ // the structure so a regression to the nested form is caught.
59
+ const code = fromSubpath(config, "script-installer", { target: "#chat" });
60
+ const json = code.match(/data-config='([^']*)'/)?.[1];
61
+ expect(json).toBeTruthy();
62
+ const parsed = JSON.parse(json!);
63
+ expect(parsed.target).toBe("#chat");
64
+ expect(parsed.config).toBeTruthy();
65
+ // target must NOT be buried inside the widget config (where it's ignored)
66
+ expect(parsed.config.target).toBeUndefined();
67
+ });
68
+
69
+ it("keeps target as a top-level sibling alongside windowKey", () => {
70
+ const code = fromSubpath(config, "script-installer", { target: "#chat", windowKey: "myWidget" });
71
+ const parsed = JSON.parse(code.match(/data-config='([^']*)'/)![1]);
72
+ expect(parsed.target).toBe("#chat");
73
+ expect(parsed.windowKey).toBe("myWidget");
74
+ expect(parsed.config).toBeTruthy();
75
+ });
76
+
77
+ it("escapes single quotes in the selector so it can't break the literal", () => {
78
+ const code = fromSubpath(config, "react-component", { target: "[data-x='y']" });
79
+ expect(code).toContain("target: '[data-x=\\'y\\']'");
80
+ });
81
+ });
39
82
  });
@@ -206,6 +206,7 @@ export type RuntypeAgentSSEEvent = {
206
206
  iteration: number;
207
207
  reflection?: string;
208
208
  seq: number;
209
+ timestamp?: string;
209
210
  type: "agent_reflection";
210
211
  } | {
211
212
  activatedCapabilities: Array<string>;
@@ -213,6 +214,7 @@ export type RuntypeAgentSSEEvent = {
213
214
  iteration: number;
214
215
  seq: number;
215
216
  skill: string;
217
+ timestamp?: string;
216
218
  toolCallId: string;
217
219
  type: "agent_skill_loaded";
218
220
  } | ({
@@ -222,6 +224,7 @@ export type RuntypeAgentSSEEvent = {
222
224
  proposalId?: string;
223
225
  seq: number;
224
226
  skill: string;
227
+ timestamp?: string;
225
228
  toolCallId: string;
226
229
  type: "agent_skill_proposed";
227
230
  }) | ({
@@ -255,6 +258,7 @@ export type RuntypeAgentSSEEvent = {
255
258
  iteration?: number;
256
259
  recoverable: boolean;
257
260
  seq: number;
261
+ timestamp?: string;
258
262
  type: "agent_error";
259
263
  } | {
260
264
  executionId: string;
@@ -289,6 +293,7 @@ export type RuntypeFlowSSEEvent = {
289
293
  failedSteps?: number;
290
294
  finalOutput?: string;
291
295
  flowId?: string;
296
+ flowName?: string;
292
297
  output?: unknown;
293
298
  seq?: number;
294
299
  source?: string;
@@ -340,6 +345,7 @@ export type RuntypeFlowSSEEvent = {
340
345
  id?: string;
341
346
  index?: number;
342
347
  name?: string;
348
+ outputVariable?: string;
343
349
  seq?: number;
344
350
  startedAt: string;
345
351
  stepId?: string;
@@ -751,6 +757,7 @@ export type RuntypeDispatchSSEEvent = {
751
757
  iteration: number;
752
758
  reflection?: string;
753
759
  seq: number;
760
+ timestamp?: string;
754
761
  type: "agent_reflection";
755
762
  } | {
756
763
  activatedCapabilities: Array<string>;
@@ -758,6 +765,7 @@ export type RuntypeDispatchSSEEvent = {
758
765
  iteration: number;
759
766
  seq: number;
760
767
  skill: string;
768
+ timestamp?: string;
761
769
  toolCallId: string;
762
770
  type: "agent_skill_loaded";
763
771
  } | ({
@@ -767,6 +775,7 @@ export type RuntypeDispatchSSEEvent = {
767
775
  proposalId?: string;
768
776
  seq: number;
769
777
  skill: string;
778
+ timestamp?: string;
770
779
  toolCallId: string;
771
780
  type: "agent_skill_proposed";
772
781
  }) | ({
@@ -800,6 +809,7 @@ export type RuntypeDispatchSSEEvent = {
800
809
  iteration?: number;
801
810
  recoverable: boolean;
802
811
  seq: number;
812
+ timestamp?: string;
803
813
  type: "agent_error";
804
814
  } | {
805
815
  executionId: string;
@@ -832,6 +842,7 @@ export type RuntypeDispatchSSEEvent = {
832
842
  failedSteps?: number;
833
843
  finalOutput?: string;
834
844
  flowId?: string;
845
+ flowName?: string;
835
846
  output?: unknown;
836
847
  seq?: number;
837
848
  source?: string;
@@ -883,6 +894,7 @@ export type RuntypeDispatchSSEEvent = {
883
894
  id?: string;
884
895
  index?: number;
885
896
  name?: string;
897
+ outputVariable?: string;
886
898
  seq?: number;
887
899
  startedAt: string;
888
900
  stepId?: string;
package/src/session.ts CHANGED
@@ -76,6 +76,20 @@ function buildDispatchErrorContent(
76
76
  return err.message ? `${base}\n\n_Details: ${err.message}_` : base;
77
77
  }
78
78
 
79
+ const buildWebMcpErrorResult = (message: string) => ({
80
+ isError: true,
81
+ content: [{ type: "text" as const, text: message }],
82
+ });
83
+
84
+ const getWebMcpErrorMessage = (
85
+ error: unknown,
86
+ fallback = "WebMCP tool execution failed.",
87
+ ): string => {
88
+ if (error instanceof Error && error.message) return error.message;
89
+ if (typeof error === "string" && error) return error;
90
+ return fallback;
91
+ };
92
+
79
93
  export class AgentWidgetSession {
80
94
  private client: AgentWidgetClient;
81
95
  private messages: AgentWidgetMessage[];
@@ -1595,6 +1609,78 @@ export class AgentWidgetSession {
1595
1609
  }
1596
1610
  }
1597
1611
 
1612
+ private resolveWebMcpToolStartedAt(
1613
+ toolMessage: AgentWidgetMessage,
1614
+ ): number {
1615
+ const stored = this.messages.find((m) => m.id === toolMessage.id);
1616
+ const candidates = [
1617
+ stored?.toolCall?.startedAt,
1618
+ toolMessage.toolCall?.startedAt,
1619
+ ];
1620
+ for (const candidate of candidates) {
1621
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
1622
+ return candidate;
1623
+ }
1624
+ }
1625
+ return Date.now();
1626
+ }
1627
+
1628
+ private markWebMcpToolRunning(
1629
+ toolMessage: AgentWidgetMessage,
1630
+ ): number {
1631
+ const startedAt = this.resolveWebMcpToolStartedAt(toolMessage);
1632
+ this.upsertMessage({
1633
+ ...toolMessage,
1634
+ streaming: true,
1635
+ agentMetadata: {
1636
+ ...toolMessage.agentMetadata,
1637
+ awaitingLocalTool: false,
1638
+ },
1639
+ toolCall: toolMessage.toolCall
1640
+ ? {
1641
+ ...toolMessage.toolCall,
1642
+ status: "running",
1643
+ startedAt,
1644
+ completedAt: undefined,
1645
+ duration: undefined,
1646
+ durationMs: undefined,
1647
+ }
1648
+ : toolMessage.toolCall,
1649
+ });
1650
+ return startedAt;
1651
+ }
1652
+
1653
+ private markWebMcpToolComplete(
1654
+ toolMessage: AgentWidgetMessage,
1655
+ result: unknown,
1656
+ startedAt: number,
1657
+ completedAt = Date.now(),
1658
+ ): void {
1659
+ // A teardown such as clearMessages()/hydrateMessages()/new send can remove
1660
+ // the bubble while an aborted WebMCP promise is settling. Never resurrect a
1661
+ // cleared message just to mark the old resolve complete.
1662
+ if (!this.messages.some((message) => message.id === toolMessage.id)) return;
1663
+ this.upsertMessage({
1664
+ ...toolMessage,
1665
+ streaming: false,
1666
+ agentMetadata: {
1667
+ ...toolMessage.agentMetadata,
1668
+ awaitingLocalTool: false,
1669
+ },
1670
+ toolCall: toolMessage.toolCall
1671
+ ? {
1672
+ ...toolMessage.toolCall,
1673
+ status: "complete",
1674
+ result,
1675
+ startedAt,
1676
+ completedAt,
1677
+ duration: undefined,
1678
+ durationMs: Math.max(0, completedAt - startedAt),
1679
+ }
1680
+ : toolMessage.toolCall,
1681
+ });
1682
+ }
1683
+
1598
1684
  /**
1599
1685
  * Resolve TWO OR MORE parallel local-tool awaits sharing one paused
1600
1686
  * executionId with a SINGLE `/resume` (core#3878). Each call is executed
@@ -1613,6 +1699,14 @@ export class AgentWidgetSession {
1613
1699
  executionId: string,
1614
1700
  snapshots: AgentWidgetMessage[],
1615
1701
  ): Promise<void> {
1702
+ type ExecutedWebMcpTool = {
1703
+ dedupeKey: string;
1704
+ resumeKey: string;
1705
+ output: unknown;
1706
+ toolMessage: AgentWidgetMessage;
1707
+ startedAt: number;
1708
+ completedAt: number;
1709
+ };
1616
1710
  const claimedKeys: string[] = [];
1617
1711
  const controllers: AbortController[] = [];
1618
1712
  // Dedicated controller for the shared resume fetch so cancel() can abort it
@@ -1640,14 +1734,10 @@ export class AgentWidgetSession {
1640
1734
  this.webMcpInflightKeys.add(dedupeKey);
1641
1735
  claimedKeys.push(dedupeKey);
1642
1736
 
1643
- // Clear the awaiting flag so the local-tool UI doesn't linger.
1644
- this.upsertMessage({
1645
- ...toolMessage,
1646
- agentMetadata: {
1647
- ...toolMessage.agentMetadata,
1648
- awaitingLocalTool: false,
1649
- },
1650
- });
1737
+ // Clear the awaiting flag and keep the tool bubble running while the
1738
+ // browser-side WebMCP promise is in flight. The initial `step_await`
1739
+ // only means the server paused for a local tool; it is not completion.
1740
+ const startedAt = this.markWebMcpToolRunning(toolMessage);
1651
1741
 
1652
1742
  const controller = new AbortController();
1653
1743
  this.webMcpResolveControllers.add(controller);
@@ -1686,24 +1776,43 @@ export class AgentWidgetSession {
1686
1776
  error instanceof Error ? error : new Error(String(error)),
1687
1777
  );
1688
1778
  }
1779
+ this.markWebMcpToolComplete(
1780
+ toolMessage,
1781
+ buildWebMcpErrorResult(
1782
+ isAbortError
1783
+ ? "Aborted by cancel()"
1784
+ : getWebMcpErrorMessage(error),
1785
+ ),
1786
+ startedAt,
1787
+ );
1689
1788
  // Release the dedupe claim so a re-emit can retry this call.
1690
1789
  this.webMcpInflightKeys.delete(dedupeKey);
1691
1790
  return null;
1692
1791
  }
1693
1792
  }
1694
1793
  if (controller.signal.aborted) {
1794
+ this.markWebMcpToolComplete(
1795
+ toolMessage,
1796
+ buildWebMcpErrorResult("Aborted by cancel()"),
1797
+ startedAt,
1798
+ );
1695
1799
  this.webMcpInflightKeys.delete(dedupeKey);
1696
1800
  return null;
1697
1801
  }
1698
- return { dedupeKey, resumeKey, output };
1802
+ return {
1803
+ dedupeKey,
1804
+ resumeKey,
1805
+ output,
1806
+ toolMessage,
1807
+ startedAt,
1808
+ completedAt: Date.now(),
1809
+ };
1699
1810
  }),
1700
1811
  );
1701
1812
 
1813
+ let ready: ExecutedWebMcpTool[] = [];
1702
1814
  try {
1703
- const ready = executed.filter(
1704
- (r): r is { dedupeKey: string; resumeKey: string; output: unknown } =>
1705
- r !== null,
1706
- );
1815
+ ready = executed.filter((r): r is ExecutedWebMcpTool => r !== null);
1707
1816
  // Everything deduped/aborted/failed — nothing to post.
1708
1817
  if (ready.length === 0) return;
1709
1818
 
@@ -1723,9 +1832,17 @@ export class AgentWidgetSession {
1723
1832
  throw new Error(errorData?.error ?? `Resume failed: ${response.status}`);
1724
1833
  }
1725
1834
  // Server accepted the batch — mark every included call resolved so stale
1726
- // re-emits don't re-execute the page tool.
1835
+ // re-emits don't re-execute the page tool, then complete each bubble.
1836
+ // Do this only after /resume HTTP success; if /resume fails, the server
1837
+ // may still be paused and the retry path must not show a final result.
1727
1838
  for (const r of ready) {
1728
1839
  this.webMcpResolvedKeys.add(r.dedupeKey);
1840
+ this.markWebMcpToolComplete(
1841
+ r.toolMessage,
1842
+ r.output,
1843
+ r.startedAt,
1844
+ r.completedAt,
1845
+ );
1729
1846
  }
1730
1847
  if (response.body) {
1731
1848
  await this.connectStream(response.body, { allowReentry: true });
@@ -1740,6 +1857,14 @@ export class AgentWidgetSession {
1740
1857
  this.callbacks.onError?.(
1741
1858
  error instanceof Error ? error : new Error(String(error)),
1742
1859
  );
1860
+ } else {
1861
+ for (const r of ready) {
1862
+ this.markWebMcpToolComplete(
1863
+ r.toolMessage,
1864
+ buildWebMcpErrorResult("Aborted by cancel()"),
1865
+ r.startedAt,
1866
+ );
1867
+ }
1743
1868
  }
1744
1869
  } finally {
1745
1870
  for (const key of claimedKeys) {
@@ -1842,14 +1967,10 @@ export class AgentWidgetSession {
1842
1967
 
1843
1968
  // Mark resolved on the message so the UI's local-tool sheet (if any
1844
1969
  // generic one ever lands) does not show — this is a fully-automatic
1845
- // tool from the user's perspective, modulo the confirm bubble.
1846
- this.upsertMessage({
1847
- ...toolMessage,
1848
- agentMetadata: {
1849
- ...toolMessage.agentMetadata,
1850
- awaitingLocalTool: false,
1851
- },
1852
- });
1970
+ // tool from the user's perspective, modulo the confirm bubble. Keep the
1971
+ // tool bubble running until the browser-side promise resolves; the
1972
+ // initial step_await was only the server pause, not tool completion.
1973
+ const startedAt = this.markWebMcpToolRunning(toolMessage);
1853
1974
 
1854
1975
  // Per-resolve AbortController, NOT the shared `this.abortController`.
1855
1976
  // A single turn can produce multiple `webmcp:*` step_await messages —
@@ -1877,6 +1998,8 @@ export class AgentWidgetSession {
1877
1998
  signal,
1878
1999
  );
1879
2000
 
2001
+ let phase: "execute" | "resume" = "execute";
2002
+ let completedAt = startedAt;
1880
2003
  try {
1881
2004
  let resumeOutput: unknown;
1882
2005
  if (!execPromise) {
@@ -1891,6 +2014,7 @@ export class AgentWidgetSession {
1891
2014
  } else {
1892
2015
  resumeOutput = await execPromise;
1893
2016
  }
2017
+ completedAt = Date.now();
1894
2018
  // If cancel() fired during execute, the bridge returned an aborted
1895
2019
  // result — don't post it. The server's SSE has been torn down; a
1896
2020
  // /resume now would just produce an orphan dispatch on the server.
@@ -1898,6 +2022,11 @@ export class AgentWidgetSession {
1898
2022
  // the resolve set) so we don't clobber a sibling resolve or a live
1899
2023
  // dispatch's controller here.
1900
2024
  if (signal.aborted) {
2025
+ this.markWebMcpToolComplete(
2026
+ toolMessage,
2027
+ buildWebMcpErrorResult("Aborted by cancel()"),
2028
+ startedAt,
2029
+ );
1901
2030
  return;
1902
2031
  }
1903
2032
  // Mark resolved as soon as the HTTP /resume returns OK — not after the
@@ -1913,9 +2042,16 @@ export class AgentWidgetSession {
1913
2042
  // call (only same-tool PARALLEL calls could collide on the name).
1914
2043
  const resumeKey =
1915
2044
  toolMessage.agentMetadata?.webMcpToolCallId ?? wireToolName;
2045
+ phase = "resume";
1916
2046
  await this.resumeWithToolOutput(executionId, resumeKey, resumeOutput, {
1917
2047
  onHttpOk: () => {
1918
2048
  this.webMcpResolvedKeys.add(dedupeKey);
2049
+ this.markWebMcpToolComplete(
2050
+ toolMessage,
2051
+ resumeOutput,
2052
+ startedAt,
2053
+ completedAt,
2054
+ );
1919
2055
  },
1920
2056
  signal,
1921
2057
  });
@@ -1928,6 +2064,17 @@ export class AgentWidgetSession {
1928
2064
  // Streaming/teardown handled by the shared `finally` (gated on the
1929
2065
  // resolve set) — do NOT null the shared `this.abortController` here; it
1930
2066
  // may belong to a live dispatch or sibling resolve, not to us.
2067
+ if (phase === "execute" || isAbortError || signal.aborted) {
2068
+ this.markWebMcpToolComplete(
2069
+ toolMessage,
2070
+ buildWebMcpErrorResult(
2071
+ isAbortError || signal.aborted
2072
+ ? "Aborted by cancel()"
2073
+ : getWebMcpErrorMessage(error),
2074
+ ),
2075
+ startedAt,
2076
+ );
2077
+ }
1931
2078
  if (!isAbortError) {
1932
2079
  // The bridge normalizes tool errors into result objects, so reaching
1933
2080
  // here means a network failure during `/resume` itself, OR a stream
@@ -2504,9 +2651,11 @@ export class AgentWidgetSession {
2504
2651
  // WebMCP equivalent: once a `webmcp:*` tool has started resolving
2505
2652
  // (inflight) or resolved, a duplicate `step_await` re-emit must not
2506
2653
  // flip `awaitingLocalTool` back to true and resurrect the "waiting on
2507
- // local tool" UI. resolveWebMcpToolCall's dedupe path returns without
2508
- // re-touching the message, so correct the merge here (also avoids a
2509
- // one-frame flash before that microtask runs).
2654
+ // local tool" UI. It also must not overwrite an existing running or
2655
+ // completed toolCall with the fresh running skeleton emitted by client.ts
2656
+ // for every step_await. resolveWebMcpToolCall's dedupe path returns
2657
+ // without re-touching the message, so correct the merge here (also avoids
2658
+ // a one-frame flash before that microtask runs).
2510
2659
  const reTcName = withSequence.toolCall?.name;
2511
2660
  const reExecId = withSequence.agentMetadata?.executionId;
2512
2661
  const reTcId = withSequence.toolCall?.id;
@@ -2518,14 +2667,26 @@ export class AgentWidgetSession {
2518
2667
  withSequence.agentMetadata?.awaitingLocalTool === true
2519
2668
  ) {
2520
2669
  const reKey = `${reExecId}:${reTcId}`;
2521
- if (
2522
- this.webMcpInflightKeys.has(reKey) ||
2523
- this.webMcpResolvedKeys.has(reKey)
2524
- ) {
2670
+ const isInflight = this.webMcpInflightKeys.has(reKey);
2671
+ const isResolved = this.webMcpResolvedKeys.has(reKey);
2672
+ const existingToolName = existing.toolCall?.name;
2673
+ const hasCompletedTool =
2674
+ existing.agentMetadata?.executionId === reExecId &&
2675
+ existing.toolCall?.id === reTcId &&
2676
+ existingToolName !== undefined &&
2677
+ isWebMcpToolName(existingToolName) &&
2678
+ existing.toolCall?.status === "complete";
2679
+ if (isInflight || isResolved || hasCompletedTool) {
2525
2680
  merged.agentMetadata = {
2526
2681
  ...(merged.agentMetadata ?? {}),
2527
2682
  awaitingLocalTool: false,
2528
2683
  };
2684
+ // Preserve the in-flight/completed tool state. For in-flight calls,
2685
+ // this keeps the original `startedAt`; for completed calls, it keeps
2686
+ // the measured duration/result even when the call completed with a
2687
+ // local/browser error and therefore was not promoted to resolved.
2688
+ merged.toolCall = existing.toolCall;
2689
+ merged.streaming = existing.streaming;
2529
2690
  }
2530
2691
  }
2531
2692
  return merged;