@parhelia/core 0.1.12777 → 0.1.12780

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 (49) hide show
  1. package/dist/editor/ai/AgentTerminal.js +626 -357
  2. package/dist/editor/ai/AgentTerminal.js.map +1 -1
  3. package/dist/editor/ai/AgentTerminalStatusBar.d.ts +7 -1
  4. package/dist/editor/ai/AgentTerminalStatusBar.js +8 -2
  5. package/dist/editor/ai/AgentTerminalStatusBar.js.map +1 -1
  6. package/dist/editor/ai/AiResponseMessage.js +1 -13
  7. package/dist/editor/ai/AiResponseMessage.js.map +1 -1
  8. package/dist/editor/ai/HeartbeatDiagnosticsPanel.d.ts +12 -0
  9. package/dist/editor/ai/HeartbeatDiagnosticsPanel.js +310 -0
  10. package/dist/editor/ai/HeartbeatDiagnosticsPanel.js.map +1 -0
  11. package/dist/editor/ai/ToolCallDisplay.d.ts +1 -3
  12. package/dist/editor/ai/ToolCallDisplay.js +7 -37
  13. package/dist/editor/ai/ToolCallDisplay.js.map +1 -1
  14. package/dist/editor/ai/agentRecoveryEventBus.d.ts +10 -0
  15. package/dist/editor/ai/agentRecoveryEventBus.js +18 -0
  16. package/dist/editor/ai/agentRecoveryEventBus.js.map +1 -0
  17. package/dist/editor/ai/agentWatchdogStatusRegistry.d.ts +17 -0
  18. package/dist/editor/ai/agentWatchdogStatusRegistry.js +25 -0
  19. package/dist/editor/ai/agentWatchdogStatusRegistry.js.map +1 -0
  20. package/dist/editor/reviews/Comment.js +9 -0
  21. package/dist/editor/reviews/Comment.js.map +1 -1
  22. package/dist/editor/services/agentService.d.ts +1 -0
  23. package/dist/editor/services/agentService.js.map +1 -1
  24. package/dist/editor/settings/SettingsHeaderActionsContext.d.ts +0 -1
  25. package/dist/editor/settings/SettingsHeaderActionsContext.js +0 -3
  26. package/dist/editor/settings/SettingsHeaderActionsContext.js.map +1 -1
  27. package/dist/editor/settings/SettingsView.js +1 -1
  28. package/dist/editor/settings/SettingsView.js.map +1 -1
  29. package/dist/revision.d.ts +2 -2
  30. package/dist/revision.js +2 -2
  31. package/dist/splash-screen/ParheliaAssistantChat.js +33 -9
  32. package/dist/splash-screen/ParheliaAssistantChat.js.map +1 -1
  33. package/dist/splash-screen/SplashScreenAgentContext.d.ts +2 -0
  34. package/dist/splash-screen/SplashScreenAgentContext.js +12 -1
  35. package/dist/splash-screen/SplashScreenAgentContext.js.map +1 -1
  36. package/dist/task-board/TaskBoardWorkspace.js +31 -1
  37. package/dist/task-board/TaskBoardWorkspace.js.map +1 -1
  38. package/dist/task-board/components/TaskBoardTitlebar.js +6 -2
  39. package/dist/task-board/components/TaskBoardTitlebar.js.map +1 -1
  40. package/dist/task-board/services/taskService.d.ts +3 -0
  41. package/dist/task-board/services/taskService.js +4 -0
  42. package/dist/task-board/services/taskService.js.map +1 -1
  43. package/dist/task-board/taskBoardNavStore.d.ts +1 -0
  44. package/dist/task-board/taskBoardNavStore.js +1 -0
  45. package/dist/task-board/taskBoardNavStore.js.map +1 -1
  46. package/dist/task-board/types.d.ts +3 -0
  47. package/dist/task-board/views/DependencyGraphView.js +9 -8
  48. package/dist/task-board/views/DependencyGraphView.js.map +1 -1
  49. package/package.json +1 -1
@@ -40,7 +40,9 @@ import { cn } from "../../lib/utils";
40
40
  import { sanitizeSvg } from "../../lib/sanitize";
41
41
  import { Select } from "../../components/ui/select";
42
42
  import { AgentTerminalStatusBar } from "./AgentTerminalStatusBar";
43
- import { isStreamStalled, shouldReloadAfterDiagnostics, } from "./agentDiagnostics";
43
+ import { isStreamStalled, shouldReloadAfterDiagnostics, STREAM_STALLED_AFTER_MS, } from "./agentDiagnostics";
44
+ import { emitAgentRecoveryEvent } from "./agentRecoveryEventBus";
45
+ import { updateAgentWatchdogStatus } from "./agentWatchdogStatusRegistry";
44
46
  import { forceEditorSocketReconnect } from "../client/hooks/useEditorWebSocket";
45
47
  import { SimpleTabs } from "../ui/SimpleTabs";
46
48
  import { Splitter } from "../ui/Splitter";
@@ -384,11 +386,13 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
384
386
  pendingDialogReplayTimeoutsRef.current.clear();
385
387
  };
386
388
  }, []);
387
- // Collect all pending tool calls for batch approval functionality
389
+ // Collect all pending tool calls for batch approval functionality.
390
+ // Returns a stable reference when the content is equivalent so consumers
391
+ // (memoized children) don't re-render on every streaming token.
392
+ const allPendingApprovalsRef = useRef([]);
388
393
  const allPendingApprovals = useMemo(() => {
389
394
  const pending = [];
390
395
  const seenToolCallIds = new Set();
391
- // Iterate messages in reverse to get the latest version of each tool call
392
396
  for (let i = messages.length - 1; i >= 0; i--) {
393
397
  const msg = messages[i];
394
398
  if (!msg)
@@ -416,9 +420,26 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
416
420
  }
417
421
  }
418
422
  }
419
- // Reverse back to maintain some chronological order if needed,
420
- // though for batch approval it doesn't matter much.
421
- return pending.reverse();
423
+ pending.reverse();
424
+ const prev = allPendingApprovalsRef.current;
425
+ if (prev.length === pending.length) {
426
+ let same = true;
427
+ for (let i = 0; i < pending.length; i++) {
428
+ const a = prev[i];
429
+ const b = pending[i];
430
+ if (a?.toolCallId !== b?.toolCallId ||
431
+ a?.messageId !== b?.messageId ||
432
+ a?.dbMessageId !== b?.dbMessageId ||
433
+ a?.riskLevel !== b?.riskLevel) {
434
+ same = false;
435
+ break;
436
+ }
437
+ }
438
+ if (same)
439
+ return prev;
440
+ }
441
+ allPendingApprovalsRef.current = pending;
442
+ return pending;
422
443
  }, [messages]);
423
444
  const hasPendingApprovals = useCallback(() => {
424
445
  return allPendingApprovals.length > 0;
@@ -1247,6 +1268,23 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1247
1268
  // WebSocket subscription state for agent streaming
1248
1269
  const seenMessageIdsRef = useRef(new Set());
1249
1270
  const lastSeqRef = useRef(0);
1271
+ // Run id (Guid string, lowercased) of the run we're currently displaying.
1272
+ // Set on accepted agent:run:start, used to drop stale delta/status from a
1273
+ // previous run that arrives after a new one has begun.
1274
+ const currentRunIdRef = useRef(null);
1275
+ // Run id of the most recently settled run. Used so a replayed
1276
+ // agent:run:complete / agent:run:start for an already-finished run is
1277
+ // dropped without clobbering live state.
1278
+ const lastSettledRunIdRef = useRef(null);
1279
+ // True once we've warned that the server is emitting lifecycle/delta/status
1280
+ // payloads without a runId (un-upgraded server during rollout). One-shot
1281
+ // to keep the console quiet.
1282
+ const warnedMissingRunIdRef = useRef(false);
1283
+ // Per-message ids the server has resynced via agent:run:resync. Subsequent
1284
+ // legacy agent:run:delta with seq=0 (the replay-marker seq used by older
1285
+ // replay paths) for the same messageId is dropped, so we don't double-
1286
+ // append content the resync envelope already wrote in full.
1287
+ const lastResyncedMessageIdsRef = useRef(new Set());
1250
1288
  const subscribedAgentIdRef = useRef(null);
1251
1289
  const reconcileServerStateInFlightRef = useRef(false);
1252
1290
  // Stream-stall watchdog timestamps. `lastNonHeartbeatUpdateAtRef` is set every time
@@ -1618,7 +1656,46 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1618
1656
  return updated;
1619
1657
  });
1620
1658
  }, [stripHeartbeatMessages]);
1621
- const settleCompletedRun = useCallback((finalStatus = "completed") => {
1659
+ // Normalize a server-issued runId (Guid in any case / format) into a
1660
+ // stable lowercase string for comparison. Returns null when the value is
1661
+ // missing or a zero-Guid (an old server that does not populate RunId).
1662
+ const normalizeRunId = useCallback((value) => {
1663
+ if (typeof value !== "string")
1664
+ return null;
1665
+ const trimmed = value.trim().toLowerCase();
1666
+ if (!trimmed)
1667
+ return null;
1668
+ // Accept the canonical 8-4-4-4-12 hex form. Zero-Guid is treated as
1669
+ // "no runId" because that's how older code paths surface "unknown".
1670
+ if (trimmed === "00000000-0000-0000-0000-000000000000")
1671
+ return null;
1672
+ return trimmed;
1673
+ }, []);
1674
+ const noteMissingRunId = useCallback((source) => {
1675
+ if (warnedMissingRunIdRef.current)
1676
+ return;
1677
+ warnedMissingRunIdRef.current = true;
1678
+ console.warn("[AgentTerminal] received lifecycle/delta payload without runId; " +
1679
+ "falling back to legacy run scoping. Server may need upgrade. source=" +
1680
+ source);
1681
+ }, []);
1682
+ const settleCompletedRun = useCallback((finalStatus = "completed", runId) => {
1683
+ // Run-scoped idempotency. If we've already settled this run, drop the
1684
+ // duplicate (e.g. lifecycle replay or the legacy status === "completed"
1685
+ // fallback firing after agent:run:complete already ran).
1686
+ const normalized = normalizeRunId(runId);
1687
+ if (normalized !== null) {
1688
+ if (lastSettledRunIdRef.current === normalized) {
1689
+ return;
1690
+ }
1691
+ // Settle for a stale run (the live run already advanced past this
1692
+ // one) — drop it instead of clobbering current run state.
1693
+ if (currentRunIdRef.current !== null &&
1694
+ currentRunIdRef.current !== normalized) {
1695
+ return;
1696
+ }
1697
+ lastSettledRunIdRef.current = normalized;
1698
+ }
1622
1699
  clearStopGuard();
1623
1700
  setError(null);
1624
1701
  // Keep lastSeqRef populated so the diagnostic popover can show the
@@ -1650,41 +1727,35 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1650
1727
  setIsSubmitting(false);
1651
1728
  shouldCreateNewMessage.current = false;
1652
1729
  setIsAgentThinking(false);
1653
- }, [clearHeartbeatMessages]);
1730
+ }, [clearHeartbeatMessages, normalizeRunId]);
1731
+ // With backend invariant "every envelope carries the final assistant messageId"
1732
+ // (see AgentOrchestrator), the resolver collapses to a plain find-by-id with
1733
+ // optional create-if-missing. No more synthetic/DB reconciliation.
1734
+ const resolveAssistantMessage = useCallback((prev, dbMessageId, options) => {
1735
+ const dbIndex = prev.findIndex((m) => m.id === dbMessageId);
1736
+ if (dbIndex !== -1) {
1737
+ return { messages: prev, targetIndex: dbIndex };
1738
+ }
1739
+ if (options.createIfMissing) {
1740
+ const created = createNewStreamMessage(dbMessageId, options.agentData);
1741
+ const messages = [...prev, created];
1742
+ return { messages, targetIndex: messages.length - 1 };
1743
+ }
1744
+ return { messages: prev, targetIndex: -1 };
1745
+ }, [createNewStreamMessage]);
1654
1746
  const handleContentChunk = useCallback((message, agentData) => {
1655
- // Get messageId from data, or generate one from agent ID (for backward compatibility)
1656
- // If no messageId is provided, we'll use the last assistant message or create a new one
1657
- let messageId = message.data?.messageId;
1658
- if (!messageId && agentData?.id) {
1659
- console.warn("[AgentTerminal] Content chunk missing messageId; falling back to local resolution", {
1660
- agentId: agentData.id,
1747
+ const messageId = message.data?.messageId;
1748
+ // Backend invariant: every assistant content chunk carries a final messageId.
1749
+ // If we ever see one without, drop it loudly — synthesizing a client-side id would
1750
+ // corrupt the conversation (the resync buffer is also keyed by messageId, so the
1751
+ // delta cannot be recovered either way; better to surface the bug than paper over it).
1752
+ if (!messageId) {
1753
+ console.error("[AgentTerminal] Backend invariant violation: content chunk arrived without messageId. Dropping.", {
1754
+ agentId: agentData?.id,
1661
1755
  isIncremental: message.data?.isIncremental,
1662
1756
  previousContentLength: message.data?.previousContentLength,
1663
1757
  totalContentLength: message.data?.totalContentLength,
1664
1758
  });
1665
- // For backward compatibility: if no messageId, find or create the current streaming message
1666
- // This handles cases where the backend doesn't send messageId
1667
- const currentMessages = messagesRef.current;
1668
- const lastStreamingMessage = [...currentMessages]
1669
- .reverse()
1670
- .find((m) => m.role === "assistant" && !m.isCompleted);
1671
- if (lastStreamingMessage) {
1672
- messageId = lastStreamingMessage.id;
1673
- }
1674
- else {
1675
- // If the agent isn't currently running (e.g., we switched tabs after the run
1676
- // completed), skip creating a new streaming message to avoid duplicates.
1677
- const currentAgentStatus = (agentData || agent)?.status;
1678
- const isAgentRunning = currentAgentStatus === "running";
1679
- if (!isAgentRunning) {
1680
- return;
1681
- }
1682
- // Create a new message ID based on timestamp when the agent is still running
1683
- messageId = crypto.randomUUID();
1684
- }
1685
- }
1686
- if (!messageId) {
1687
- console.error("Unable to determine messageId for content chunk!");
1688
1759
  return;
1689
1760
  }
1690
1761
  // Clear waiting state when first content chunk arrives
@@ -1767,7 +1838,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1767
1838
  }
1768
1839
  // Always call setMessages and handle all logic in the callback with latest messages
1769
1840
  setMessages((prev) => {
1770
- // Find existing message by messageId in the latest messages
1771
1841
  const existingMessageIndex = prev.findIndex((msg) => msg.id === messageId);
1772
1842
  if (existingMessageIndex === -1) {
1773
1843
  // Message doesn't exist - create new streaming message
@@ -1781,267 +1851,188 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1781
1851
  });
1782
1852
  }
1783
1853
  const newStreamMessage = createNewStreamMessage(messageId, agentData);
1784
- // Set the content for the new message
1785
- const updatedNewMessage = { ...newStreamMessage };
1786
- if (!message.data.isIncremental) {
1787
- updatedNewMessage.content = message.data?.deltaContent || "";
1788
- updatedNewMessage.reasoning = message.data?.reasoning || "";
1789
- }
1790
- else {
1791
- updatedNewMessage.content = message.data?.deltaContent || "";
1792
- updatedNewMessage.reasoning = message.data?.reasoning || "";
1793
- }
1854
+ const updatedNewMessage = {
1855
+ ...newStreamMessage,
1856
+ content: message.data?.deltaContent || "",
1857
+ reasoning: message.data?.reasoning || "",
1858
+ };
1794
1859
  const updated = [...prev, updatedNewMessage];
1795
1860
  messagesRef.current = updated;
1796
1861
  return updated;
1797
1862
  }
1798
- else {
1799
- // Message exists - update it
1800
- const existingMessage = prev[existingMessageIndex];
1801
- if (!existingMessage)
1802
- return prev;
1803
- // Check if existing content is already longer than what we're trying to stream
1804
- const currentContentLength = existingMessage.content?.length || 0;
1805
- const previousContentLength = message.data?.previousContentLength || 0;
1806
- const totalContentLength = message.data?.totalContentLength || 0;
1807
- if (message.data?.isIncremental &&
1808
- previousContentLength !== currentContentLength &&
1809
- (previousContentLength > 0 || currentContentLength > 0)) {
1810
- console.warn("[AgentTerminal] Content chunk length mismatch", {
1811
- messageId,
1812
- previousContentLength,
1813
- currentContentLength,
1814
- totalContentLength,
1815
- deltaLength: (message.data?.deltaContent || "").length,
1816
- });
1817
- }
1818
- if (message.data?.isIncremental &&
1819
- currentContentLength >= totalContentLength &&
1820
- totalContentLength > 0) {
1821
- return prev;
1822
- }
1823
- const updatedMessage = { ...existingMessage };
1824
- if (!message.data.isIncremental) {
1825
- updatedMessage.content = message.data?.deltaContent || "";
1826
- updatedMessage.reasoning = message.data?.reasoning || "";
1827
- }
1828
- else {
1829
- updatedMessage.content =
1830
- existingMessage.content + (message.data?.deltaContent || "");
1831
- // Reasoning is sent as full content from backend, not as a delta,
1832
- // so we replace instead of append to avoid duplication
1833
- updatedMessage.reasoning =
1834
- message.data?.reasoning || existingMessage.reasoning || "";
1835
- }
1836
- const updated = prev.map((msg, index) => index === existingMessageIndex ? updatedMessage : msg);
1837
- messagesRef.current = updated;
1838
- return updated;
1863
+ const existingMessage = prev[existingMessageIndex];
1864
+ if (!existingMessage)
1865
+ return prev;
1866
+ const currentContentLength = existingMessage.content?.length || 0;
1867
+ const previousContentLength = message.data?.previousContentLength || 0;
1868
+ const totalContentLength = message.data?.totalContentLength || 0;
1869
+ if (message.data?.isIncremental &&
1870
+ previousContentLength !== currentContentLength &&
1871
+ (previousContentLength > 0 || currentContentLength > 0)) {
1872
+ console.warn("[AgentTerminal] Content chunk length mismatch", {
1873
+ messageId,
1874
+ previousContentLength,
1875
+ currentContentLength,
1876
+ totalContentLength,
1877
+ deltaLength: (message.data?.deltaContent || "").length,
1878
+ });
1839
1879
  }
1880
+ if (message.data?.isIncremental &&
1881
+ currentContentLength >= totalContentLength &&
1882
+ totalContentLength > 0) {
1883
+ return prev;
1884
+ }
1885
+ const updatedMessage = { ...existingMessage };
1886
+ if (!message.data.isIncremental) {
1887
+ updatedMessage.content = message.data?.deltaContent || "";
1888
+ updatedMessage.reasoning = message.data?.reasoning || "";
1889
+ }
1890
+ else {
1891
+ updatedMessage.content =
1892
+ existingMessage.content + (message.data?.deltaContent || "");
1893
+ // Reasoning is sent as full content from backend, not as a delta,
1894
+ // so we replace instead of append to avoid duplication
1895
+ updatedMessage.reasoning =
1896
+ message.data?.reasoning || existingMessage.reasoning || "";
1897
+ }
1898
+ const updated = prev.map((msg, index) => index === existingMessageIndex ? updatedMessage : msg);
1899
+ messagesRef.current = updated;
1900
+ return updated;
1840
1901
  });
1841
- }, [createNewStreamMessage, agent]);
1902
+ }, [createNewStreamMessage]);
1842
1903
  const handleToolCall = useCallback((message, agentData) => {
1843
1904
  const extractedToolCall = extractToolCallFields(message.data);
1844
1905
  const toolCallId = extractedToolCall.toolCallId || crypto.randomUUID();
1845
- // Prefer provided messageId, otherwise fall back to the last streaming assistant message
1846
- let toolCallMessageId = message.data?.messageId;
1847
- if (!toolCallMessageId) {
1848
- console.warn("[AgentTerminal] Tool call missing messageId; falling back", {
1906
+ const toolCallMessageId = message.data?.messageId;
1907
+ // Backend invariant: every tool-call envelope carries the final assistant messageId.
1908
+ // Drop loudly rather than minting a client-side id (which collapses turns into one bubble).
1909
+ if (!toolCallMessageId || !message.data) {
1910
+ console.error("[AgentTerminal] Backend invariant violation: tool call arrived without messageId. Dropping.", {
1911
+ agentId: agentData?.id,
1849
1912
  toolCallId,
1850
1913
  toolName: message.data?.name || message.data?.displayName,
1851
1914
  });
1852
- const current = messagesRef.current;
1853
- const lastStreaming = [...current]
1854
- .reverse()
1855
- .find((m) => m.role === "assistant" && !m.isCompleted);
1856
- if (lastStreaming?.id) {
1857
- toolCallMessageId = lastStreaming.id;
1858
- }
1859
- else {
1860
- // Tool calls can arrive before any assistant content chunk (common for dialog tools like ask-questionnaire).
1861
- // Create a synthetic streaming message so the UI can render the tool call immediately.
1862
- toolCallMessageId = crypto.randomUUID();
1863
- }
1915
+ return;
1864
1916
  }
1865
- appendToolUiEvent("ui:tool-call-targeted", `${extractedToolCall.functionName || "unknown"} toolCallId=${toolCallId} targetMessageId=${toolCallMessageId || "none"} providedMessageId=${String(message.data?.messageId || "none")}`);
1866
- // Find or create the target message for this tool call
1867
- if (toolCallMessageId) {
1868
- const currentMessages = messagesRef.current;
1869
- const existingMessageIndex = currentMessages.findIndex((msg) => msg.id === toolCallMessageId);
1870
- if (existingMessageIndex === -1) {
1871
- // Double-check with current ref to prevent race conditions
1872
- const currentMessages = messagesRef.current;
1873
- const existsInRef = currentMessages.find((msg) => msg.id === toolCallMessageId);
1874
- if (!existsInRef) {
1875
- // Create new message for this tool call
1876
- const newStreamMessage = createNewStreamMessage(toolCallMessageId, agentData);
1877
- setMessages((prev) => {
1878
- // Final check before adding to prevent duplicates
1879
- const finalCheck = prev.find((msg) => msg.id === toolCallMessageId);
1880
- if (finalCheck) {
1881
- return prev;
1882
- }
1883
- const updated = [...prev, newStreamMessage];
1884
- messagesRef.current = updated;
1885
- return updated;
1886
- });
1917
+ appendToolUiEvent("ui:tool-call-targeted", `${extractedToolCall.functionName || "unknown"} toolCallId=${toolCallId} targetMessageId=${toolCallMessageId} providedMessageId=${String(message.data?.messageId || "none")}`);
1918
+ // Build the tool-call record once, outside the reducer.
1919
+ const toolCallError = message.data.functionError || message.data.error || "";
1920
+ const isPruned = !!message.data?.isPruned || /^PRUNED$/i.test(String(toolCallError));
1921
+ const toolCallCreatedDate = message.data.createdDate ||
1922
+ message.timestamp ||
1923
+ new Date().toISOString();
1924
+ const toolCallState = String(message.data?.state || "").toLowerCase();
1925
+ const isExplicitApprovalRequired = toolCallState === "toolapprovalsrequired" ||
1926
+ toolCallState === "waitingforapproval";
1927
+ const requiresApproval = isExplicitApprovalRequired
1928
+ ? message.data?.requiresApproval
1929
+ : undefined;
1930
+ const incomingToolCall = {
1931
+ id: toolCallId,
1932
+ messageId: toolCallMessageId,
1933
+ dbMessageId: message.data.messageId, // Database message ID for approval/rejection
1934
+ toolCallId: toolCallId,
1935
+ functionName: extractedToolCall.functionName,
1936
+ functionArguments: extractedToolCall.functionArguments,
1937
+ functionResult: message.data.functionResult || message.data.result || "",
1938
+ functionResultRichContent: message.data.richContent || undefined,
1939
+ functionError: toolCallError,
1940
+ isPruned,
1941
+ isCompleted: false,
1942
+ responseTimeMs: message.data.responseTimeMs,
1943
+ createdDate: toolCallCreatedDate,
1944
+ requiresApproval,
1945
+ };
1946
+ // Single reducer: resolver finds-or-creates the row by messageId, then we
1947
+ // upsert the tool call onto the resolved row by toolCallId.
1948
+ flushSync(() => {
1949
+ setMessages((prev) => {
1950
+ const resolution = resolveAssistantMessage(prev, toolCallMessageId, {
1951
+ createIfMissing: true,
1952
+ agentData,
1953
+ });
1954
+ if (resolution.targetIndex < 0) {
1955
+ return prev;
1887
1956
  }
1888
- }
1889
- }
1890
- // Add tool call to the message in the array
1891
- if (toolCallMessageId && message.data && toolCallId) {
1892
- const toolCallError = message.data.functionError || message.data.error || "";
1893
- const isPruned = !!message.data?.isPruned || /^PRUNED$/i.test(String(toolCallError));
1894
- const toolCallCreatedDate = message.data.createdDate ||
1895
- message.timestamp ||
1896
- new Date().toISOString();
1897
- const toolCallState = String(message.data?.state || "").toLowerCase();
1898
- const isExplicitApprovalRequired = toolCallState === "toolapprovalsrequired" ||
1899
- toolCallState === "waitingforapproval";
1900
- const requiresApproval = isExplicitApprovalRequired
1901
- ? message.data?.requiresApproval
1902
- : undefined;
1903
- const toolCall = {
1904
- id: toolCallId,
1905
- messageId: toolCallMessageId,
1906
- dbMessageId: message.data.messageId, // Database message ID for approval/rejection
1907
- toolCallId: toolCallId,
1908
- functionName: extractedToolCall.functionName,
1909
- functionArguments: extractedToolCall.functionArguments,
1910
- functionResult: message.data.functionResult || message.data.result || "",
1911
- functionResultRichContent: message.data.richContent || undefined,
1912
- functionError: toolCallError,
1913
- isPruned,
1914
- isCompleted: false,
1915
- responseTimeMs: message.data.responseTimeMs,
1916
- createdDate: toolCallCreatedDate,
1917
- requiresApproval,
1918
- };
1919
- // Check for existing tool call - search across ALL messages by toolCallId first
1920
- // This handles the case where the first message had messageId: null (used fallback UI ID)
1921
- // and the second message has the actual DB messageId
1922
- const currentMessages = messagesRef.current;
1923
- let targetMessage = currentMessages.find((msg) => msg.id === toolCallMessageId);
1924
- let existingToolCallIndex = -1;
1925
- let existingToolCalls = [];
1926
- // First, try to find by toolCallMessageId
1927
- if (targetMessage) {
1928
- existingToolCalls = targetMessage.toolCalls || [];
1929
- existingToolCallIndex = existingToolCalls.findIndex((tc) => tc.toolCallId === toolCallId);
1930
- }
1931
- // If not found, search across ALL messages for this toolCallId
1932
- // This handles the messageId mismatch between first (null) and second (DB ID) messages
1933
- if (existingToolCallIndex === -1) {
1934
- for (const msg of currentMessages) {
1935
- const tcIndex = (msg.toolCalls || []).findIndex((tc) => tc.toolCallId === toolCallId);
1936
- if (tcIndex !== -1) {
1937
- targetMessage = msg;
1938
- existingToolCalls = msg.toolCalls || [];
1939
- existingToolCallIndex = tcIndex;
1940
- break;
1941
- }
1957
+ const working = resolution.messages;
1958
+ const targetMsg = working[resolution.targetIndex];
1959
+ if (!targetMsg) {
1960
+ return prev;
1942
1961
  }
1943
- }
1944
- if (existingToolCallIndex !== -1 && targetMessage) {
1945
- // Tool call already exists - update it with any new/more complete data
1946
- const existingToolCall = existingToolCalls[existingToolCallIndex];
1947
- if (!existingToolCall) {
1948
- return; // Safety check - shouldn't happen
1962
+ const existingToolCalls = targetMsg.toolCalls || [];
1963
+ const idx = existingToolCalls.findIndex((tc) => tc.toolCallId === toolCallId);
1964
+ let nextToolCalls;
1965
+ if (idx === -1) {
1966
+ nextToolCalls = [...existingToolCalls, incomingToolCall];
1949
1967
  }
1950
- // Use the actual message ID where the tool call was found (may differ from toolCallMessageId)
1951
- const actualMessageId = targetMessage.id;
1952
- // Check if the new data has more information than what we have
1953
- const newArgs = toolCall.functionArguments;
1954
- const existingArgs = existingToolCall.functionArguments;
1955
- const newArgsText = stringifyToolField(newArgs) || "";
1956
- const existingArgsText = stringifyToolField(existingArgs) || "";
1957
- const hasMoreCompleteArgs = (newArgsText.length > existingArgsText.length &&
1958
- newArgsText !== existingArgsText) ||
1959
- (existingArgsText === "{}" && newArgsText !== "{}");
1960
- const hasNewResult = toolCall.functionResult && !existingToolCall.functionResult;
1961
- const hasNewRichContent = toolCall.functionResultRichContent &&
1962
- !existingToolCall.functionResultRichContent;
1963
- const hasNewError = toolCall.functionError && !existingToolCall.functionError;
1964
- const hasNewApprovalInfo = toolCall.requiresApproval && !existingToolCall.requiresApproval;
1965
- const hasNewDbMessageId = toolCall.dbMessageId && !existingToolCall.dbMessageId;
1966
- // Only update if there's meaningful new data
1967
- if (hasMoreCompleteArgs ||
1968
- hasNewResult ||
1969
- hasNewRichContent ||
1970
- hasNewError ||
1971
- hasNewApprovalInfo ||
1972
- hasNewDbMessageId) {
1973
- setMessages((prev) => {
1974
- const updated = prev.map((msg) => {
1975
- if (msg.id !== actualMessageId)
1976
- return msg;
1977
- const updatedToolCalls = [...(msg.toolCalls || [])];
1978
- const idx = updatedToolCalls.findIndex((tc) => tc.toolCallId === toolCallId);
1979
- if (idx !== -1 && updatedToolCalls[idx]) {
1980
- const existing = updatedToolCalls[idx];
1981
- // Merge: prefer new non-empty values, keep existing values as fallback
1982
- // Use type assertion for dbMessageId which is added dynamically
1983
- updatedToolCalls[idx] = {
1984
- ...existing,
1985
- functionArguments: hasMoreCompleteArgs
1986
- ? newArgsText
1987
- : existingArgsText || existing.functionArguments,
1988
- functionResult: toolCall.functionResult || existing.functionResult,
1989
- functionResultRichContent: toolCall.functionResultRichContent ||
1990
- existing.functionResultRichContent,
1991
- functionError: toolCall.functionError || existing.functionError,
1992
- requiresApproval: toolCall.requiresApproval || existing.requiresApproval,
1993
- };
1994
- // Copy dbMessageId if present (dynamically added field)
1995
- if (toolCall.dbMessageId || existing.dbMessageId) {
1996
- updatedToolCalls[idx].dbMessageId =
1997
- toolCall.dbMessageId || existing.dbMessageId;
1998
- }
1999
- }
2000
- return { ...msg, toolCalls: updatedToolCalls };
2001
- });
2002
- messagesRef.current = updated;
2003
- return updated;
2004
- });
1968
+ else {
1969
+ const existing = existingToolCalls[idx];
1970
+ if (!existing) {
1971
+ return prev;
1972
+ }
1973
+ const newArgsText = stringifyToolField(incomingToolCall.functionArguments) || "";
1974
+ const existingArgsText = stringifyToolField(existing.functionArguments) || "";
1975
+ const hasMoreCompleteArgs = (newArgsText.length > existingArgsText.length &&
1976
+ newArgsText !== existingArgsText) ||
1977
+ (existingArgsText === "{}" && newArgsText !== "{}");
1978
+ const hasNewResult = incomingToolCall.functionResult && !existing.functionResult;
1979
+ const hasNewRichContent = incomingToolCall.functionResultRichContent &&
1980
+ !existing.functionResultRichContent;
1981
+ const hasNewError = incomingToolCall.functionError && !existing.functionError;
1982
+ const hasNewApprovalInfo = incomingToolCall.requiresApproval && !existing.requiresApproval;
1983
+ const hasNewDbMessageId = incomingToolCall.dbMessageId && !existing.dbMessageId;
1984
+ // No new info bail without re-rendering the tool calls array.
1985
+ if (!hasMoreCompleteArgs &&
1986
+ !hasNewResult &&
1987
+ !hasNewRichContent &&
1988
+ !hasNewError &&
1989
+ !hasNewApprovalInfo &&
1990
+ !hasNewDbMessageId) {
1991
+ return prev;
1992
+ }
1993
+ const merged = {
1994
+ ...existing,
1995
+ functionArguments: hasMoreCompleteArgs
1996
+ ? newArgsText
1997
+ : existingArgsText || existing.functionArguments,
1998
+ functionResult: incomingToolCall.functionResult || existing.functionResult,
1999
+ functionResultRichContent: incomingToolCall.functionResultRichContent ||
2000
+ existing.functionResultRichContent,
2001
+ functionError: incomingToolCall.functionError || existing.functionError,
2002
+ requiresApproval: incomingToolCall.requiresApproval || existing.requiresApproval,
2003
+ };
2004
+ if (incomingToolCall.dbMessageId ||
2005
+ existing.dbMessageId) {
2006
+ merged.dbMessageId =
2007
+ incomingToolCall.dbMessageId || existing.dbMessageId;
2008
+ }
2009
+ nextToolCalls = [...existingToolCalls];
2010
+ nextToolCalls[idx] = merged;
2005
2011
  }
2006
- return; // Done updating existing tool call
2007
- }
2008
- flushSync(() => {
2009
- setMessages((prev) => {
2010
- const updated = prev.map((msg) => {
2011
- if (msg.id !== toolCallMessageId)
2012
- return msg;
2013
- const existingToolCalls = msg.toolCalls || [];
2014
- return { ...msg, toolCalls: [...existingToolCalls, toolCall] };
2015
- });
2016
- messagesRef.current = updated;
2017
- return updated;
2018
- });
2012
+ const updated = working.map((m, i) => i === resolution.targetIndex ? { ...m, toolCalls: nextToolCalls } : m);
2013
+ messagesRef.current = updated;
2014
+ return updated;
2019
2015
  });
2020
- const messageWithToolCall = messagesRef.current.find((msg) => msg.id === toolCallMessageId);
2021
- appendToolUiEvent("ui:tool-call-attached", `${extractedToolCall.functionName || "unknown"} toolCallId=${toolCallId} targetMessageId=${toolCallMessageId} messageToolCalls=${messageWithToolCall?.toolCalls?.length || 0} assistantMessages=${messagesRef.current.filter((msg) => msg.role === "assistant").length}`);
2022
- // If tool requires approval, agent is now waiting for user action - stop thinking
2023
- if (requiresApproval) {
2024
- setIsAgentThinking(false);
2025
- }
2016
+ });
2017
+ const messageWithToolCall = messagesRef.current.find((msg) => msg.id === toolCallMessageId);
2018
+ appendToolUiEvent("ui:tool-call-attached", `${extractedToolCall.functionName || "unknown"} toolCallId=${toolCallId} targetMessageId=${toolCallMessageId} messageToolCalls=${messageWithToolCall?.toolCalls?.length || 0} assistantMessages=${messagesRef.current.filter((msg) => msg.role === "assistant").length}`);
2019
+ // If tool requires approval, agent is now waiting for user action - stop thinking
2020
+ if (requiresApproval) {
2021
+ setIsAgentThinking(false);
2026
2022
  }
2027
- }, [appendToolUiEvent, createNewStreamMessage]);
2023
+ }, [appendToolUiEvent, resolveAssistantMessage]);
2028
2024
  const handleToolResult = useCallback((message, agentData) => {
2029
2025
  const extractedToolCall = extractToolCallFields(message.data);
2030
2026
  const resultToolCallId = extractedToolCall.toolCallId || crypto.randomUUID();
2031
- // Prefer provided messageId, otherwise fall back to the last streaming assistant message
2032
- let resultMessageId = message.data?.messageId;
2027
+ const resultMessageId = message.data?.messageId;
2028
+ // Backend invariant: every tool-result envelope carries the final assistant messageId.
2033
2029
  if (!resultMessageId) {
2034
- console.warn("[AgentTerminal] Tool result missing messageId; falling back", {
2030
+ console.error("[AgentTerminal] Backend invariant violation: tool result arrived without messageId. Dropping.", {
2031
+ agentId: agentData?.id,
2035
2032
  toolCallId: resultToolCallId,
2036
2033
  toolName: message.data?.functionName || message.data?.displayName,
2037
2034
  });
2038
- const current = messagesRef.current;
2039
- const lastStreaming = [...current]
2040
- .reverse()
2041
- .find((m) => m.role === "assistant" && !m.isCompleted);
2042
- if (lastStreaming?.id) {
2043
- resultMessageId = lastStreaming.id;
2044
- }
2035
+ return;
2045
2036
  }
2046
2037
  // Extract cost/token data from tool result if present
2047
2038
  const cost = message.cost;
@@ -2137,63 +2128,69 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2137
2128
  }
2138
2129
  }
2139
2130
  }
2140
- // Update tool result directly in the messages array
2141
- if (!resultMessageId) {
2142
- appendToolUiEvent("ui:tool-result-dropped", `${extractedToolCall.functionName || "unknown"} toolCallId=${resultToolCallId} reason=no-result-message-id`);
2143
- return;
2144
- }
2145
2131
  appendToolUiEvent("ui:tool-result-targeted", `${extractedToolCall.functionName || "unknown"} toolCallId=${resultToolCallId} targetMessageId=${resultMessageId}`);
2146
- // Update the message with tool result
2132
+ // Single reducer: resolver merges any synthetic row carrying this
2133
+ // toolCallId into the DB row first, then we apply the result update
2134
+ // by toolCallId on the resolved row.
2147
2135
  setMessages((prev) => {
2148
- const updated = prev.map((msg) => {
2149
- if (msg.id !== resultMessageId)
2150
- return msg;
2151
- const updatedMessage = { ...msg };
2152
- if (!updatedMessage.toolCalls) {
2153
- updatedMessage.toolCalls = [];
2154
- }
2155
- // Find and update the tool call with the result
2156
- const toolCallIndex = updatedMessage.toolCalls.findIndex((tc) => tc.toolCallId === resultToolCallId);
2157
- if (toolCallIndex >= 0) {
2158
- const existingToolCall = updatedMessage.toolCalls[toolCallIndex];
2159
- if (existingToolCall && message.data) {
2160
- const updatedToolCalls = [...updatedMessage.toolCalls];
2161
- const nextArgsText = stringifyToolField(extractedToolCall.functionArguments) || "";
2162
- const existingArgsText = stringifyToolField(existingToolCall.functionArguments) || "";
2163
- const hasMoreCompleteArgs = (nextArgsText.length > existingArgsText.length &&
2164
- nextArgsText !== existingArgsText) ||
2165
- (existingArgsText === "{}" && nextArgsText !== "{}");
2166
- const toolCall = {
2167
- id: existingToolCall.id,
2168
- messageId: existingToolCall.messageId,
2169
- toolCallId: existingToolCall.toolCallId,
2170
- functionName: existingToolCall.functionName,
2171
- functionArguments: hasMoreCompleteArgs
2172
- ? nextArgsText
2173
- : existingToolCall.functionArguments,
2174
- functionResult: message.data.functionResult || message.data.result || "",
2175
- functionResultRichContent: message.data.richContent ||
2176
- existingToolCall.functionResultRichContent,
2177
- functionError: message.data.functionError || message.data.error || "",
2178
- isCompleted: true,
2179
- responseTimeMs: message.data.responseTimeMs,
2180
- createdDate: existingToolCall.createdDate,
2181
- };
2182
- updatedToolCalls[toolCallIndex] = toolCall;
2183
- updatedMessage.toolCalls = updatedToolCalls;
2184
- }
2185
- // Check if all tool calls in message are completed
2186
- const allToolCallsCompleted = updatedMessage.toolCalls.every((tc) => tc.isCompleted);
2187
- if (allToolCallsCompleted) {
2188
- shouldCreateNewMessage.current = true;
2189
- }
2136
+ const resolution = resolveAssistantMessage(prev, resultMessageId, {
2137
+ createIfMissing: false,
2138
+ agentData,
2139
+ });
2140
+ if (resolution.targetIndex < 0) {
2141
+ // No row for this DB id — nothing to attach the result to. Drop with
2142
+ // a diagnostic. Should not happen given backend invariants.
2143
+ appendToolUiEvent("ui:tool-result-dropped", `${extractedToolCall.functionName || "unknown"} toolCallId=${resultToolCallId} reason=no-target-row`);
2144
+ return prev;
2145
+ }
2146
+ const working = resolution.messages;
2147
+ const targetMsg = working[resolution.targetIndex];
2148
+ if (!targetMsg) {
2149
+ return prev;
2150
+ }
2151
+ const existingToolCalls = targetMsg.toolCalls || [];
2152
+ const toolCallIndex = existingToolCalls.findIndex((tc) => tc.toolCallId === resultToolCallId);
2153
+ let nextToolCalls;
2154
+ if (toolCallIndex >= 0) {
2155
+ const existingToolCall = existingToolCalls[toolCallIndex];
2156
+ if (!existingToolCall || !message.data) {
2157
+ return working;
2190
2158
  }
2191
- else if (message.data && resultToolCallId && resultMessageId) {
2192
- // Create new tool call if it doesn't exist
2193
- const toolCallCreatedDate = message.data.createdDate ||
2194
- message.timestamp ||
2195
- new Date().toISOString();
2196
- const toolCall = {
2159
+ const nextArgsText = stringifyToolField(extractedToolCall.functionArguments) || "";
2160
+ const existingArgsText = stringifyToolField(existingToolCall.functionArguments) || "";
2161
+ const hasMoreCompleteArgs = (nextArgsText.length > existingArgsText.length &&
2162
+ nextArgsText !== existingArgsText) ||
2163
+ (existingArgsText === "{}" && nextArgsText !== "{}");
2164
+ const updatedToolCall = {
2165
+ id: existingToolCall.id,
2166
+ messageId: existingToolCall.messageId,
2167
+ toolCallId: existingToolCall.toolCallId,
2168
+ functionName: existingToolCall.functionName,
2169
+ functionArguments: hasMoreCompleteArgs
2170
+ ? nextArgsText
2171
+ : existingToolCall.functionArguments,
2172
+ functionResult: message.data.functionResult || message.data.result || "",
2173
+ functionResultRichContent: message.data.richContent ||
2174
+ existingToolCall.functionResultRichContent,
2175
+ functionError: message.data.functionError || message.data.error || "",
2176
+ isCompleted: true,
2177
+ responseTimeMs: message.data.responseTimeMs,
2178
+ createdDate: existingToolCall.createdDate,
2179
+ };
2180
+ nextToolCalls = [...existingToolCalls];
2181
+ nextToolCalls[toolCallIndex] = updatedToolCall;
2182
+ }
2183
+ else if (message.data && resultToolCallId) {
2184
+ // Tool call wasn't found on the resolved row — append it as a
2185
+ // completed call. This path is rare (would imply we got a result
2186
+ // without ever seeing the call), but the original code preserved
2187
+ // it as a defense.
2188
+ const toolCallCreatedDate = message.data.createdDate ||
2189
+ message.timestamp ||
2190
+ new Date().toISOString();
2191
+ nextToolCalls = [
2192
+ ...existingToolCalls,
2193
+ {
2197
2194
  id: resultToolCallId,
2198
2195
  messageId: resultMessageId,
2199
2196
  toolCallId: resultToolCallId,
@@ -2205,22 +2202,27 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2205
2202
  isCompleted: true,
2206
2203
  responseTimeMs: message.data.responseTimeMs,
2207
2204
  createdDate: toolCallCreatedDate,
2208
- };
2209
- updatedMessage.toolCalls = [...updatedMessage.toolCalls, toolCall];
2210
- }
2211
- // Updated tool calls count
2212
- return updatedMessage;
2213
- });
2205
+ },
2206
+ ];
2207
+ }
2208
+ else {
2209
+ return working;
2210
+ }
2211
+ // After upserting, check if all tool calls on this row are complete.
2212
+ if (nextToolCalls.every((tc) => tc.isCompleted)) {
2213
+ shouldCreateNewMessage.current = true;
2214
+ }
2215
+ const updated = working.map((m, i) => i === resolution.targetIndex ? { ...m, toolCalls: nextToolCalls } : m);
2214
2216
  messagesRef.current = updated;
2215
- const messageWithToolResult = updated.find((msg) => msg.id === resultMessageId);
2217
+ const messageWithToolResult = updated[resolution.targetIndex];
2216
2218
  const matchingToolCall = messageWithToolResult?.toolCalls?.find((tc) => tc.toolCallId === resultToolCallId);
2217
- appendToolUiEvent("ui:tool-result-applied", `${extractedToolCall.functionName || "unknown"} toolCallId=${resultToolCallId} targetMessageId=${resultMessageId} completed=${matchingToolCall?.isCompleted ? "yes" : "no"} messageToolCalls=${messageWithToolResult?.toolCalls?.length || 0}`);
2219
+ appendToolUiEvent("ui:tool-result-applied", `${extractedToolCall.functionName || "unknown"} toolCallId=${resultToolCallId} targetMessageId=${messageWithToolResult?.id || resultMessageId} completed=${matchingToolCall?.isCompleted ? "yes" : "no"} messageToolCalls=${messageWithToolResult?.toolCalls?.length || 0}`);
2218
2220
  return updated;
2219
2221
  });
2220
2222
  // Document-store refresh is now triggered by the backend agent:documents:changed
2221
2223
  // WebSocket message (fired from AgentDocumentRepository), bridged to the in-process
2222
2224
  // emitAgentDocumentsChanged event in EditorShell. No per-tool allow-list here.
2223
- }, [agent?.id, agentStub.id, appendToolUiEvent]);
2225
+ }, [agent?.id, agentStub.id, appendToolUiEvent, resolveAssistantMessage]);
2224
2226
  // Listen for local approval resolution to update UI.
2225
2227
  // Filters on detail.agentId so that with multi-pane (same agent in two panes,
2226
2228
  // or any visible terminal that isn't the dispatch source) only the matching
@@ -2314,8 +2316,11 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2314
2316
  window.addEventListener("agent:toolApprovalResolved", onApprovalResolved);
2315
2317
  return () => window.removeEventListener("agent:toolApprovalResolved", onApprovalResolved);
2316
2318
  }, []);
2317
- // Load agent data and messages
2318
- const loadAgent = useCallback(async () => {
2319
+ // Load agent data and messages.
2320
+ // `silent: true` skips the loading-spinner UI; used for background reconciles
2321
+ // (e.g. after a status change) where replacing the whole terminal with a
2322
+ // spinner would unmount the prompt textarea and reset its cursor.
2323
+ const loadAgent = useCallback(async ({ silent = false } = {}) => {
2319
2324
  try {
2320
2325
  // Even if agentStub.status is "new", try to load from backend first
2321
2326
  // The agent might have been persisted after sending a prompt
@@ -2460,7 +2465,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2460
2465
  return;
2461
2466
  }
2462
2467
  }
2463
- setIsLoading(true);
2468
+ if (!silent) {
2469
+ setIsLoading(true);
2470
+ }
2464
2471
  setError(null);
2465
2472
  // Fetch agent details and initial queued prompts in parallel
2466
2473
  const [agentData, prompts] = await Promise.all([
@@ -2657,7 +2664,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2657
2664
  }
2658
2665
  reconcileServerStateInFlightRef.current = true;
2659
2666
  try {
2660
- await loadAgent();
2667
+ await loadAgent({ silent: true });
2661
2668
  }
2662
2669
  finally {
2663
2670
  reconcileServerStateInFlightRef.current = false;
@@ -2840,6 +2847,48 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2840
2847
  console.log("[AgentTerminal] Ignoring agent:run:start during stop operation");
2841
2848
  return;
2842
2849
  }
2850
+ // Run-id bookkeeping runs BEFORE any other branch (paused-state
2851
+ // early-return, dedup reset). Replayed starts for paused runs still
2852
+ // need to establish currentRunIdRef so subsequent run-scoped messages
2853
+ // are accepted as live; without that, deltas for the same run would
2854
+ // be dropped as "stale".
2855
+ const startRunId = normalizeRunId(message.payload?.runId);
2856
+ if (startRunId === null) {
2857
+ noteMissingRunId("agent:run:start");
2858
+ }
2859
+ if (startRunId !== null) {
2860
+ // Replayed start for a run we've already settled — drop it.
2861
+ if (lastSettledRunIdRef.current === startRunId) {
2862
+ return;
2863
+ }
2864
+ // Duplicate start for the live run — drop without wiping live state
2865
+ // (do not reset lastSeqRef / seenMessageIdsRef on the second start).
2866
+ if (currentRunIdRef.current === startRunId) {
2867
+ return;
2868
+ }
2869
+ // Accepted: this is a fresh run. Take ownership of the run id and
2870
+ // (only here) reset run-scoped dedup state.
2871
+ currentRunIdRef.current = startRunId;
2872
+ lastSeqRef.current = 0;
2873
+ // #3: clear and reseed seenMessageIdsRef with currently rendered
2874
+ // message ids so dedup stays live for messages already on screen
2875
+ // but stale ids (from prior runs) don't accumulate forever.
2876
+ seenMessageIdsRef.current.clear();
2877
+ for (const msg of messagesRef.current) {
2878
+ if (msg.id) {
2879
+ seenMessageIdsRef.current.add(msg.id.toLowerCase());
2880
+ }
2881
+ }
2882
+ // #7: any "already resynced" markers from a prior run no longer
2883
+ // apply — clear so live deltas of the new run are processed
2884
+ // normally (they carry seq>=1 anyway, but keep the set bounded).
2885
+ lastResyncedMessageIdsRef.current.clear();
2886
+ }
2887
+ else {
2888
+ // Legacy / un-upgraded server: keep the historical reset behaviour
2889
+ // so the existing seq dedup keeps working.
2890
+ lastSeqRef.current = 0;
2891
+ }
2843
2892
  const currentStatus = agentRef.current?.status;
2844
2893
  if (currentStatus === "waitingForInput" ||
2845
2894
  currentStatus === "waitingForApproval" ||
@@ -2847,11 +2896,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2847
2896
  // Replayed start messages arrive before the buffered status payload when
2848
2897
  // reopening an already-paused agent. Preserve the attention state instead
2849
2898
  // of flashing "running" until the follow-up status update lands.
2899
+ // (currentRunIdRef is already established above so subsequent
2900
+ // delta/status events for this run are accepted.)
2850
2901
  return;
2851
2902
  }
2852
- // Reset run-scoped deduplication so new run seq values (starting at 1)
2853
- // are not discarded due to previous run's lastSeqRef
2854
- lastSeqRef.current = 0;
2855
2903
  setLastRunStatusReason(null);
2856
2904
  // Prep streaming UI state for the new run
2857
2905
  setIsConnecting(true);
@@ -2866,7 +2914,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2866
2914
  }
2867
2915
  // Handle agent:user:message
2868
2916
  if (messageType === "agent:user:message") {
2869
- const { messageId, content, timestamp, sourceAgentName, sourceAgent } = message.payload;
2917
+ const { messageId, content, timestamp, sourceAgentName, sourceAgent, clientMessageId, } = message.payload;
2870
2918
  // Track in seenMessageIds for deduplication
2871
2919
  const normalizedId = messageId.toLowerCase();
2872
2920
  if (seenMessageIdsRef.current.has(normalizedId)) {
@@ -2910,11 +2958,20 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2910
2958
  console.log("[AgentTerminal] Message already exists by ID, skipping:", messageId);
2911
2959
  return prev;
2912
2960
  }
2913
- // Look for an optimistic (temp) message with matching content to replace
2914
- // Temp messages have IDs starting with "temp-"
2915
- const tempMessageIndex = prev.findIndex((m) => m.id?.startsWith("temp-") &&
2916
- m.role === "user" &&
2917
- m.content === content);
2961
+ // Look for an optimistic (temp) message to replace.
2962
+ // Prefer matching by clientMessageId (the temp id we sent up at
2963
+ // submit time and the server echoes back). Fall back to content
2964
+ // equality for legacy paths and for messages that never went
2965
+ // through this client (spawned-agent / system-injected).
2966
+ let tempMessageIndex = -1;
2967
+ if (clientMessageId) {
2968
+ tempMessageIndex = prev.findIndex((m) => m.id === clientMessageId && m.role === "user");
2969
+ }
2970
+ if (tempMessageIndex === -1) {
2971
+ tempMessageIndex = prev.findIndex((m) => m.id?.startsWith("temp-") &&
2972
+ m.role === "user" &&
2973
+ m.content === content);
2974
+ }
2918
2975
  let updated;
2919
2976
  if (tempMessageIndex !== -1) {
2920
2977
  // Replace the optimistic message with the server version
@@ -2975,7 +3032,30 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2975
3032
  if (isStoppingRef.current) {
2976
3033
  return;
2977
3034
  }
3035
+ // Drop stale-run delta — a delta that belongs to a previous run on
3036
+ // this same agent (e.g. the previous run's final delta arriving
3037
+ // after we accepted agent:run:start for the new run).
3038
+ const deltaRunId = normalizeRunId(message.payload?.runId);
3039
+ if (deltaRunId === null) {
3040
+ noteMissingRunId("agent:run:delta");
3041
+ }
3042
+ else if (currentRunIdRef.current !== null &&
3043
+ deltaRunId !== currentRunIdRef.current) {
3044
+ appendToolUiEvent("ui:stream-runid-dropped", `agent:run:delta runId=${deltaRunId} currentRunId=${currentRunIdRef.current}`);
3045
+ return;
3046
+ }
2978
3047
  const { seq, type, data, cost } = message.payload;
3048
+ // Post-resync guard: a legacy replay delta carries seq=0. If we've
3049
+ // already received an agent:run:resync envelope for this message id,
3050
+ // the replay delta would re-append content the resync wrote in full.
3051
+ // Drop it. Live-run deltas always carry seq>=1 and are unaffected.
3052
+ if ((!seq || seq === 0) && data?.messageId) {
3053
+ const replayMessageId = String(data.messageId).toLowerCase();
3054
+ if (lastResyncedMessageIdsRef.current.has(replayMessageId)) {
3055
+ appendToolUiEvent("ui:stream-resync-skip-delta", `agent:run:delta messageId=${data.messageId} reason=already-resynced`);
3056
+ return;
3057
+ }
3058
+ }
2979
3059
  if (type === "ToolCall" || type === "toolCall") {
2980
3060
  }
2981
3061
  // Always allow ContextUpdate messages (metadata updates) regardless of agent status
@@ -3080,6 +3160,16 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3080
3160
  }
3081
3161
  // Unified: agent:run:status (state only)
3082
3162
  if (messageType === "agent:run:status") {
3163
+ // Drop stale-run status — see matching block in agent:run:delta handler.
3164
+ const statusRunId = normalizeRunId(message.payload?.runId);
3165
+ if (statusRunId === null) {
3166
+ noteMissingRunId("agent:run:status");
3167
+ }
3168
+ else if (currentRunIdRef.current !== null &&
3169
+ statusRunId !== currentRunIdRef.current) {
3170
+ appendToolUiEvent("ui:stream-runid-dropped", `agent:run:status runId=${statusRunId} currentRunId=${currentRunIdRef.current}`);
3171
+ return;
3172
+ }
3083
3173
  const { seq, data: statusData } = message.payload;
3084
3174
  // Reset on new-run detection — see matching block in agent:run:delta handler.
3085
3175
  if (seq === 1 && lastSeqRef.current > 0) {
@@ -3367,7 +3457,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3367
3457
  }
3368
3458
  // Handle "completed" state (fallback for legacy code paths that send status instead of lifecycle event)
3369
3459
  if (normalizedStatus === "completed") {
3370
- settleCompletedRun("completed");
3460
+ // Pass through the runId from the status payload so the
3461
+ // run-scoped idempotency guard in settleCompletedRun can drop
3462
+ // duplicates of agent:run:complete that already settled this run.
3463
+ settleCompletedRun("completed", message.payload?.runId);
3371
3464
  return;
3372
3465
  }
3373
3466
  // Handle "Running" state - agent is actively processing
@@ -3427,16 +3520,123 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3427
3520
  }
3428
3521
  return;
3429
3522
  }
3523
+ // Replay: agent:run:resync
3524
+ // Sent (additively, after the legacy agent:run:delta) on
3525
+ // reconnect / late-subscribe. Carries the full accumulated content for
3526
+ // one assistant message at a time. Replace (do NOT append) the message's
3527
+ // content / reasoning / toolCalls. Marks the messageId in
3528
+ // lastResyncedMessageIdsRef so any subsequent legacy seq=0 delta for
3529
+ // this same id is dropped (see agent:run:delta handler above).
3530
+ if (messageType === "agent:run:resync") {
3531
+ const resyncRunId = normalizeRunId(message.payload?.runId);
3532
+ if (resyncRunId === null) {
3533
+ noteMissingRunId("agent:run:resync");
3534
+ }
3535
+ else if (currentRunIdRef.current !== null &&
3536
+ resyncRunId !== currentRunIdRef.current) {
3537
+ appendToolUiEvent("ui:stream-runid-dropped", `agent:run:resync runId=${resyncRunId} currentRunId=${currentRunIdRef.current}`);
3538
+ return;
3539
+ }
3540
+ const resyncMessageId = message.payload?.messageId;
3541
+ if (!resyncMessageId) {
3542
+ return;
3543
+ }
3544
+ const totalContent = String(message.payload?.totalContent ?? "");
3545
+ const reasoning = String(message.payload?.reasoning ?? "");
3546
+ const seq = Number(message.payload?.seq ?? 0);
3547
+ const rawToolCalls = Array.isArray(message.payload?.toolCalls)
3548
+ ? message.payload.toolCalls
3549
+ : [];
3550
+ // Normalize each tool call snapshot. The server stores raw JObjects
3551
+ // from the live ToolCall / ToolResult emit shapes, which use slightly
3552
+ // different field names (id vs toolCallId, arguments vs functionArguments,
3553
+ // result vs functionResult, etc.). Map both into the frontend's
3554
+ // canonical toolCall shape.
3555
+ const normalizedToolCalls = rawToolCalls
3556
+ .filter((tc) => tc && typeof tc === "object")
3557
+ .map((tc) => {
3558
+ const toolCallId = tc.toolCallId || tc.id || crypto.randomUUID();
3559
+ return {
3560
+ id: toolCallId,
3561
+ messageId: resyncMessageId,
3562
+ toolCallId,
3563
+ functionName: tc.functionName || tc.name || tc.displayName || "",
3564
+ functionArguments: tc.functionArguments ?? tc.arguments ?? "",
3565
+ functionResult: tc.functionResult ?? tc.result ?? "",
3566
+ functionResultRichContent: tc.functionResultRichContent ?? tc.richContent ?? undefined,
3567
+ functionError: tc.functionError ?? tc.error ?? "",
3568
+ isCompleted: tc.isCompleted === true,
3569
+ responseTimeMs: tc.responseTimeMs,
3570
+ createdDate: tc.createdDate ||
3571
+ message.payload?.timestamp ||
3572
+ new Date().toISOString(),
3573
+ requiresApproval: tc.requiresApproval,
3574
+ ...(tc.dbMessageId
3575
+ ? { dbMessageId: tc.dbMessageId }
3576
+ : {}),
3577
+ };
3578
+ });
3579
+ setMessages((prev) => {
3580
+ const idx = prev.findIndex((m) => m.id === resyncMessageId);
3581
+ if (idx === -1) {
3582
+ // Resync arrived before any live message exists for this id —
3583
+ // create a streaming row and seed it with the full snapshot.
3584
+ const created = createNewStreamMessage(resyncMessageId, agent);
3585
+ const seeded = {
3586
+ ...created,
3587
+ content: totalContent,
3588
+ reasoning,
3589
+ toolCalls: normalizedToolCalls,
3590
+ };
3591
+ const updated = [...prev, seeded];
3592
+ messagesRef.current = updated;
3593
+ return updated;
3594
+ }
3595
+ const updated = prev.map((m, i) => i === idx
3596
+ ? {
3597
+ ...m,
3598
+ content: totalContent,
3599
+ reasoning,
3600
+ toolCalls: normalizedToolCalls,
3601
+ }
3602
+ : m);
3603
+ messagesRef.current = updated;
3604
+ return updated;
3605
+ });
3606
+ if (seq && seq > lastSeqRef.current) {
3607
+ lastSeqRef.current = seq;
3608
+ }
3609
+ lastResyncedMessageIdsRef.current.add(resyncMessageId.toLowerCase());
3610
+ return;
3611
+ }
3430
3612
  // Lifecycle: agent:run:complete
3431
3613
  if (messageType === "agent:run:complete") {
3432
3614
  lastNonHeartbeatUpdateAtRef.current = Date.now();
3615
+ const completeRunId = message.payload?.runId;
3616
+ if (normalizeRunId(completeRunId) === null) {
3617
+ noteMissingRunId("agent:run:complete");
3618
+ }
3433
3619
  const finalStatus = normalizeServerExecutionStatus(message.payload?.finalStatus);
3434
- settleCompletedRun(finalStatus === "cancelled" ? "cancelled" : "completed");
3620
+ settleCompletedRun(finalStatus === "cancelled" ? "cancelled" : "completed", completeRunId);
3435
3621
  return;
3436
3622
  }
3437
3623
  // Lifecycle: agent:run:error
3438
3624
  if (messageType === "agent:run:error") {
3439
3625
  lastNonHeartbeatUpdateAtRef.current = Date.now();
3626
+ const errorRunId = normalizeRunId(message.payload?.runId);
3627
+ if (errorRunId === null) {
3628
+ noteMissingRunId("agent:run:error");
3629
+ }
3630
+ else if (lastSettledRunIdRef.current === errorRunId ||
3631
+ (currentRunIdRef.current !== null &&
3632
+ currentRunIdRef.current !== errorRunId)) {
3633
+ // Replayed error for a settled run, or error for a stale run that
3634
+ // a fresh run has already superseded. Drop it.
3635
+ return;
3636
+ }
3637
+ if (errorRunId !== null) {
3638
+ lastSettledRunIdRef.current = errorRunId;
3639
+ }
3440
3640
  const errorMsg = toUserFacingAgentErrorMessage(message.payload?.error) ||
3441
3641
  "AI could not complete this request.";
3442
3642
  clearHeartbeatMessages();
@@ -3458,10 +3658,13 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3458
3658
  agent,
3459
3659
  appendToolUiEvent,
3460
3660
  clearHeartbeatMessages,
3661
+ createNewStreamMessage,
3461
3662
  handleContentChunk,
3462
3663
  handleHeartbeatMessage,
3463
3664
  handleToolCall,
3464
3665
  handleToolResult,
3666
+ noteMissingRunId,
3667
+ normalizeRunId,
3465
3668
  onAgentUpdate,
3466
3669
  schedulePendingDialogReplay,
3467
3670
  settleCompletedRun,
@@ -4109,6 +4312,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4109
4312
  model: requestSettings.modelId,
4110
4313
  mode: requestSettings.mode,
4111
4314
  context: canonicalizeAgentMetadata(effectiveContext), // Use fresh live context when in live mode
4315
+ // #4: correlate optimistic user bubble (tempMessageId) with server's
4316
+ // agent:user:message echo so the temp can be replaced by id.
4317
+ clientMessageId: tempMessageId,
4112
4318
  };
4113
4319
  console.log("[AgentTerminal] Calling startAgent API for agent:", agentId);
4114
4320
  // Reset the stall watchdog so its 15s window starts at submit, not at the last
@@ -4116,6 +4322,14 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4116
4322
  lastNonHeartbeatUpdateAtRef.current = Date.now();
4117
4323
  const response = await startAgent(request);
4118
4324
  console.log("[AgentTerminal] startAgent response:", response);
4325
+ // The agent has now been persisted server-side. Tell the parent so a
4326
+ // remount (e.g. splash chat after a workspace switch) sees a non-"new"
4327
+ // stub and goes through loadAgent's server-fetch path instead of the
4328
+ // local-only draft branch — otherwise the remounted terminal would
4329
+ // present an empty message list.
4330
+ if (agentStub.status === "new") {
4331
+ onAgentUpdate?.({ status: "running" });
4332
+ }
4119
4333
  const isQueuedForCapacity = response.reason === MACHINE_CAPACITY_REASON ||
4120
4334
  response.message?.toLowerCase().includes("machine slot") ||
4121
4335
  false;
@@ -4405,6 +4619,8 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4405
4619
  model: requestSettings.modelId,
4406
4620
  mode: requestSettings.mode,
4407
4621
  context: canonicalizeAgentMetadata(effectiveContext), // Use fresh live context when in live mode
4622
+ // #4: correlate optimistic quick-message bubble with server echo.
4623
+ clientMessageId: tempMessageId,
4408
4624
  };
4409
4625
  console.log("[AgentTerminal] Calling startAgent API for quick message");
4410
4626
  await startAgent(request);
@@ -5084,6 +5300,22 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5084
5300
  // update exceeds 15s. If it does, it asks the backend whether it has produced
5085
5301
  // sequence numbers we have not applied — and only if so, falls back to what a user
5086
5302
  // would do: reload the agent and reconnect the socket.
5303
+ useEffect(() => {
5304
+ if (!currentAgentId)
5305
+ return;
5306
+ updateAgentWatchdogStatus(currentAgentId, {
5307
+ isExecuting,
5308
+ isVisible: effectiveIsVisible,
5309
+ isStopping: isStoppingRef.current,
5310
+ isWaitingForCapacity: lastRunStatusReason === MACHINE_CAPACITY_REASON,
5311
+ stalledThresholdMs: STREAM_STALLED_AFTER_MS,
5312
+ });
5313
+ }, [
5314
+ currentAgentId,
5315
+ isExecuting,
5316
+ effectiveIsVisible,
5317
+ lastRunStatusReason,
5318
+ ]);
5087
5319
  useEffect(() => {
5088
5320
  if (!effectiveIsVisible || !isExecuting || !currentAgentId) {
5089
5321
  return;
@@ -5091,6 +5323,11 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5091
5323
  let disposed = false;
5092
5324
  const agentIdAtMount = currentAgentId;
5093
5325
  lastNonHeartbeatUpdateAtRef.current = Date.now();
5326
+ updateAgentWatchdogStatus(agentIdAtMount, {
5327
+ running: true,
5328
+ startedAt: Date.now(),
5329
+ stalledThresholdMs: STREAM_STALLED_AFTER_MS,
5330
+ });
5094
5331
  const logRecoveryEvent = (kind, payload) => {
5095
5332
  try {
5096
5333
  appendToolUiEvent(`recovery:${kind}`, JSON.stringify({ agentId: agentIdAtMount, ...payload }));
@@ -5098,9 +5335,30 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5098
5335
  catch {
5099
5336
  // Telemetry must never throw out of the watchdog path.
5100
5337
  }
5338
+ try {
5339
+ emitAgentRecoveryEvent({
5340
+ agentId: agentIdAtMount,
5341
+ kind,
5342
+ payload,
5343
+ timestamp: new Date().toISOString(),
5344
+ });
5345
+ }
5346
+ catch {
5347
+ // Telemetry must never throw out of the watchdog path.
5348
+ }
5101
5349
  };
5102
5350
  const sessionId = editContext?.sessionId;
5103
5351
  const tick = async () => {
5352
+ updateAgentWatchdogStatus(agentIdAtMount, {
5353
+ running: true,
5354
+ lastTickAt: Date.now(),
5355
+ isExecuting: true,
5356
+ isVisible: true,
5357
+ isStopping: isStoppingRef.current,
5358
+ isWaitingForCapacity: lastRunStatusReason === MACHINE_CAPACITY_REASON,
5359
+ lastNonHeartbeatUpdateAt: lastNonHeartbeatUpdateAtRef.current,
5360
+ stalledThresholdMs: STREAM_STALLED_AFTER_MS,
5361
+ });
5104
5362
  if (!isStreamStalled({
5105
5363
  isExecuting: true,
5106
5364
  isVisible: true,
@@ -5169,6 +5427,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5169
5427
  return () => {
5170
5428
  disposed = true;
5171
5429
  window.clearInterval(intervalId);
5430
+ updateAgentWatchdogStatus(agentIdAtMount, {
5431
+ running: false,
5432
+ stoppedAt: Date.now(),
5433
+ });
5172
5434
  };
5173
5435
  }, [
5174
5436
  currentAgentId,
@@ -5602,11 +5864,14 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5602
5864
  return groups.map((group, groupIndex) => {
5603
5865
  const isLastGroup = groupIndex === groups.length - 1;
5604
5866
  if (group.type === "user" && group.messages[0]) {
5605
- // Render user message
5606
- return (_jsx(UserMessage, { message: group.messages[0] }, groupIndex));
5867
+ const userKey = group.messages[0].id;
5868
+ if (!userKey) {
5869
+ console.warn("[AgentTerminal] user message group missing id", { groupIndex });
5870
+ }
5871
+ return (_jsx(UserMessage, { message: group.messages[0] }, userKey ?? `user-${groupIndex}`));
5607
5872
  }
5608
5873
  else if (group.type === "heartbeat" && group.messages[0]) {
5609
- return (_jsx(HeartbeatMessage, { message: group.messages[0] }, group.messages[0].id || groupIndex));
5874
+ return (_jsx(HeartbeatMessage, { message: group.messages[0] }, group.messages[0].id));
5610
5875
  }
5611
5876
  else {
5612
5877
  // Render bundled assistant messages
@@ -5624,6 +5889,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5624
5889
  }
5625
5890
  const convertedMessages = convertAgentMessagesToAiFormat(filteredMessages);
5626
5891
  const operationsForGroup = getOperationsForMessageGroup(convertedMessages, agentOperations);
5892
+ const assistantKey = filteredMessages[0]?.id;
5893
+ if (!assistantKey) {
5894
+ console.warn("[AgentTerminal] assistant message group missing id", { groupIndex });
5895
+ }
5627
5896
  return (_jsx(AiResponseMessage, { messages: convertedMessages, finished: !isLastGroup || !isExecuting, editOperations: operationsForGroup, defaultCollapseJson: defaultCollapseJson, profileSvgIcon: activeProfile?.svgIcon, agentId: agent?.id || agentStub.id, agentName: activeProfile?.agentName ||
5628
5897
  activeProfile?.displayTitle ||
5629
5898
  activeProfile?.name, allPendingApprovals: allPendingApprovals, onSwitchToAutonomous: handleSwitchToAutonomous, browserCaptureInlinePrompt: browserCaptureInlinePrompt, readOnly: readOnly, onQuickAction: (action) => {
@@ -5666,7 +5935,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5666
5935
  catch { }
5667
5936
  }
5668
5937
  sendQuickMessage(text);
5669
- } }, groupIndex));
5938
+ } }, assistantKey ?? `assistant-${groupIndex}`));
5670
5939
  }
5671
5940
  });
5672
5941
  })(), shouldShowThinkingDots && (_jsxs("div", { className: "flex gap-3 px-4 py-3", "data-testid": "agent-thinking-dots", children: [_jsx("div", { className: "shrink-0", children: activeProfile?.svgIcon ? (_jsx("div", { className: "text-gray-2 flex h-6 w-6 items-center justify-center [&>svg]:h-full [&>svg]:w-full", dangerouslySetInnerHTML: {
@@ -6046,7 +6315,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
6046
6315
  setShowCostAndAgent((prev) => !prev);
6047
6316
  }, variant: "outline", size: "sm", className: "h-5.5 w-5.5 cursor-pointer rounded-full", "aria-expanded": editContext?.isMobile
6048
6317
  ? showCostAndAgent
6049
- : undefined, "aria-label": "Toggle cost and context info", children: _jsx(DollarSign, { className: "size-3", strokeWidth: 1 }) }) }), _jsx(PopoverContent, { className: "w-64 p-0", align: "start", children: _jsx("div", { className: "max-h-56 overflow-y-auto p-2", children: _jsx(AgentTerminalStatusBar, { agent: agent, contextWindowStatus: contextWindowStatus, effectiveModelName: effectiveModelName, socketDiagnostics: editContext.socketDiagnostics, runDiagnosticsSnapshot: runDiagnosticsSnapshot, liveTotals: liveTotals, totalTokens: liveTotals
6318
+ : undefined, "aria-label": "Toggle cost and context info", children: _jsx(DollarSign, { className: "size-3", strokeWidth: 1 }) }) }), _jsx(PopoverContent, { className: "w-64 p-0", align: "start", children: _jsx("div", { className: "max-h-56 overflow-y-auto p-2", children: _jsx(AgentTerminalStatusBar, { agent: agent, contextWindowStatus: contextWindowStatus, effectiveModelName: effectiveModelName, socketDiagnostics: editContext.socketDiagnostics, addSocketMessageListener: editContext.addSocketMessageListener, runDiagnosticsSnapshot: runDiagnosticsSnapshot, liveTotals: liveTotals, totalTokens: liveTotals
6050
6319
  ? {
6051
6320
  input: liveTotals.input,
6052
6321
  output: liveTotals.output,
@@ -6086,7 +6355,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
6086
6355
  })(), (showStatusBar ?? !hideBottomControls) &&
6087
6356
  !simpleMode &&
6088
6357
  editContext &&
6089
- !editContext.isMobile && (_jsx(AgentTerminalStatusBar, { agent: agent, contextWindowStatus: contextWindowStatus, effectiveModelName: effectiveModelName, socketDiagnostics: editContext.socketDiagnostics, runDiagnosticsSnapshot: runDiagnosticsSnapshot, liveTotals: liveTotals, totalTokens: liveTotals
6358
+ !editContext.isMobile && (_jsx(AgentTerminalStatusBar, { agent: agent, contextWindowStatus: contextWindowStatus, effectiveModelName: effectiveModelName, socketDiagnostics: editContext.socketDiagnostics, addSocketMessageListener: editContext.addSocketMessageListener, runDiagnosticsSnapshot: runDiagnosticsSnapshot, liveTotals: liveTotals, totalTokens: liveTotals
6090
6359
  ? {
6091
6360
  input: liveTotals.input,
6092
6361
  output: liveTotals.output,