@parhelia/core 0.1.12776 → 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 (58) hide show
  1. package/dist/components/ui/button.d.ts +1 -1
  2. package/dist/editor/ai/AgentTerminal.d.ts +5 -1
  3. package/dist/editor/ai/AgentTerminal.js +740 -600
  4. package/dist/editor/ai/AgentTerminal.js.map +1 -1
  5. package/dist/editor/ai/AgentTerminalStatusBar.d.ts +7 -1
  6. package/dist/editor/ai/AgentTerminalStatusBar.js +8 -2
  7. package/dist/editor/ai/AgentTerminalStatusBar.js.map +1 -1
  8. package/dist/editor/ai/AiResponseMessage.js +1 -13
  9. package/dist/editor/ai/AiResponseMessage.js.map +1 -1
  10. package/dist/editor/ai/HeartbeatDiagnosticsPanel.d.ts +12 -0
  11. package/dist/editor/ai/HeartbeatDiagnosticsPanel.js +310 -0
  12. package/dist/editor/ai/HeartbeatDiagnosticsPanel.js.map +1 -0
  13. package/dist/editor/ai/ToolCallDisplay.d.ts +1 -3
  14. package/dist/editor/ai/ToolCallDisplay.js +7 -37
  15. package/dist/editor/ai/ToolCallDisplay.js.map +1 -1
  16. package/dist/editor/ai/agentDiagnostics.d.ts +24 -51
  17. package/dist/editor/ai/agentDiagnostics.js +22 -199
  18. package/dist/editor/ai/agentDiagnostics.js.map +1 -1
  19. package/dist/editor/ai/agentDiagnostics.test.js +93 -322
  20. package/dist/editor/ai/agentDiagnostics.test.js.map +1 -1
  21. package/dist/editor/ai/agentRecoveryEventBus.d.ts +10 -0
  22. package/dist/editor/ai/agentRecoveryEventBus.js +18 -0
  23. package/dist/editor/ai/agentRecoveryEventBus.js.map +1 -0
  24. package/dist/editor/ai/agentWatchdogStatusRegistry.d.ts +17 -0
  25. package/dist/editor/ai/agentWatchdogStatusRegistry.js +25 -0
  26. package/dist/editor/ai/agentWatchdogStatusRegistry.js.map +1 -0
  27. package/dist/editor/client/EditorShell.js +8 -7
  28. package/dist/editor/client/EditorShell.js.map +1 -1
  29. package/dist/editor/reviews/Comment.js +9 -0
  30. package/dist/editor/reviews/Comment.js.map +1 -1
  31. package/dist/editor/services/agentService.d.ts +1 -0
  32. package/dist/editor/services/agentService.js.map +1 -1
  33. package/dist/editor/settings/SettingsHeaderActionsContext.d.ts +0 -1
  34. package/dist/editor/settings/SettingsHeaderActionsContext.js +0 -3
  35. package/dist/editor/settings/SettingsHeaderActionsContext.js.map +1 -1
  36. package/dist/editor/settings/SettingsView.js +1 -1
  37. package/dist/editor/settings/SettingsView.js.map +1 -1
  38. package/dist/revision.d.ts +2 -2
  39. package/dist/revision.js +2 -2
  40. package/dist/splash-screen/ParheliaAssistantChat.js +61 -16
  41. package/dist/splash-screen/ParheliaAssistantChat.js.map +1 -1
  42. package/dist/splash-screen/SplashScreenAgentContext.d.ts +28 -0
  43. package/dist/splash-screen/SplashScreenAgentContext.js +51 -0
  44. package/dist/splash-screen/SplashScreenAgentContext.js.map +1 -0
  45. package/dist/task-board/TaskBoardWorkspace.js +31 -1
  46. package/dist/task-board/TaskBoardWorkspace.js.map +1 -1
  47. package/dist/task-board/components/TaskBoardTitlebar.js +6 -2
  48. package/dist/task-board/components/TaskBoardTitlebar.js.map +1 -1
  49. package/dist/task-board/services/taskService.d.ts +3 -0
  50. package/dist/task-board/services/taskService.js +4 -0
  51. package/dist/task-board/services/taskService.js.map +1 -1
  52. package/dist/task-board/taskBoardNavStore.d.ts +1 -0
  53. package/dist/task-board/taskBoardNavStore.js +1 -0
  54. package/dist/task-board/taskBoardNavStore.js.map +1 -1
  55. package/dist/task-board/types.d.ts +3 -0
  56. package/dist/task-board/views/DependencyGraphView.js +9 -8
  57. package/dist/task-board/views/DependencyGraphView.js.map +1 -1
  58. package/package.json +1 -1
@@ -21,7 +21,7 @@ import { QueuedPromptsPanel } from "./QueuedPromptsPanel";
21
21
  import { AgentCapacityBanner, AgentCostLimitBanner, AgentErrorBanner, } from "./AgentBanners";
22
22
  import { InitialThinkingSplash } from "./InitialThinkingSplash";
23
23
  import { AgentInlineDialogContent } from "./AgentInlineDialogContent";
24
- import { AGENT_HISTORY_LIMIT, MACHINE_CAPACITY_REASON, buildPlaceholderAgentDetails, formatAllowanceLabel, formatAllowanceSource, getAgentRunMessageAgentId, getAgentRunMessageDetail, getAgentRunMessageSeq, isInactiveServerExecutionStatus, isAgentErrorStatusValue, isHeartbeatRunEventMessage, mergeAgentOperationHistory, normalizeDialogAgentId, normalizeProfileAllowanceOperations, normalizeServerExecutionStatus, } from "./agentMessageHelpers";
24
+ import { AGENT_HISTORY_LIMIT, MACHINE_CAPACITY_REASON, buildPlaceholderAgentDetails, formatAllowanceLabel, formatAllowanceSource, getAgentRunMessageAgentId, getAgentRunMessageDetail, getAgentRunMessageSeq, isAgentErrorStatusValue, isHeartbeatRunEventMessage, mergeAgentOperationHistory, normalizeDialogAgentId, normalizeProfileAllowanceOperations, normalizeServerExecutionStatus, } from "./agentMessageHelpers";
25
25
  import { AgentDocumentList, } from "./AgentDocumentList";
26
26
  import { AgentEditOperationsPanel } from "./EditOperationsPanel";
27
27
  import { SpawnedAgentsPanel } from "./SpawnedAgentsPanel";
@@ -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 { checkReplayVerified, createRecoveryStateSlice, decideRecoveryAction, interpretAgentRunDiagnostics, reconcilePendingDialogTracker, } 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";
@@ -80,7 +82,7 @@ function hasStaleRunningLoadSuppression(agentId) {
80
82
  // interface AgentTerminalProps {
81
83
  // agentStub: Agent;
82
84
  // }
83
- export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadProfiles, isActive, isVisible, isFocused, compact = false, displayMode = "full", showSummaryInput = false, hideContext = false, hideBottomControls = false, hideGreeting = false, defaultCollapseJson = false, simpleMode = false, className, initialPrompt, onAgentUpdate, onMessage, onInteractionSubmitted, onQuestionnaireOpenChange, questionnaireFooterActions, hideSummaryMessages = false, summaryPlaceholderActions, summaryPlaceholderMessage, hideSummaryWaitingPlaceholder = false, }) {
85
+ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadProfiles, isActive, isVisible, isFocused, compact = false, displayMode = "full", showSummaryInput = false, hideContext = false, hideBottomControls = false, showStatusBar, showAgentConfigControls, hideGreeting = false, defaultCollapseJson = false, simpleMode = false, className, initialPrompt, onAgentUpdate, onMessage, onInteractionSubmitted, onQuestionnaireOpenChange, questionnaireFooterActions, hideSummaryMessages = false, summaryPlaceholderActions, summaryPlaceholderMessage, hideSummaryWaitingPlaceholder = false, }) {
84
86
  // Derived from props. `isVisible` controls "is this terminal mounted and
85
87
  // streaming" (subscriptions, polling). `isFocused` controls "does this
86
88
  // terminal own keyboard focus and dialogs". Both fall back to legacy
@@ -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,18 +1268,33 @@ 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
- // Recovery ladder state. The graduated replay reconnect → stale path lives in the
1253
- // diagnostics polling effect below. State is keyed by agentId so switching between
1254
- // agents in the same terminal doesn't cross-contaminate cooldowns.
1255
- const recoveryStateByAgentRef = useRef(new Map());
1256
- const replayVerifyTimeoutRef = useRef(null);
1257
- // Mirror refs so the one-shot verify timer (which fires outside the polling effect's
1258
- // closure) can read the latest snapshot/diagnostics without re-running the effect.
1259
- const runDiagnosticsSnapshotRef = useRef(null);
1260
- const lastDiagnosticsResponseRef = useRef(null);
1261
- const [showStaleAgentBanner, setShowStaleAgentBanner] = useState(false);
1290
+ // Stream-stall watchdog timestamps. `lastNonHeartbeatUpdateAtRef` is set every time
1291
+ // something non-heartbeat advances the run (prompt submit, isExecuting transition,
1292
+ // status/delta/complete/error event, socket reconnect, or a stall reload).
1293
+ // `lastStallReloadAtRef` enforces a minimum gap between automatic reloads so a
1294
+ // backend that keeps advancing while our socket keeps bouncing can't trigger reloads
1295
+ // every 15 seconds.
1296
+ const lastNonHeartbeatUpdateAtRef = useRef(0);
1297
+ const lastStallReloadAtRef = useRef(0);
1262
1298
  const toolCallFirstSeenAtRef = useRef({});
1263
1299
  const pendingToolCompletionTimersRef = useRef({});
1264
1300
  // Cache mode/model/profile changes made while the agent is still "new" (not yet persisted)
@@ -1620,7 +1656,46 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1620
1656
  return updated;
1621
1657
  });
1622
1658
  }, [stripHeartbeatMessages]);
1623
- 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
+ }
1624
1699
  clearStopGuard();
1625
1700
  setError(null);
1626
1701
  // Keep lastSeqRef populated so the diagnostic popover can show the
@@ -1652,41 +1727,35 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1652
1727
  setIsSubmitting(false);
1653
1728
  shouldCreateNewMessage.current = false;
1654
1729
  setIsAgentThinking(false);
1655
- }, [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]);
1656
1746
  const handleContentChunk = useCallback((message, agentData) => {
1657
- // Get messageId from data, or generate one from agent ID (for backward compatibility)
1658
- // If no messageId is provided, we'll use the last assistant message or create a new one
1659
- let messageId = message.data?.messageId;
1660
- if (!messageId && agentData?.id) {
1661
- console.warn("[AgentTerminal] Content chunk missing messageId; falling back to local resolution", {
1662
- 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,
1663
1755
  isIncremental: message.data?.isIncremental,
1664
1756
  previousContentLength: message.data?.previousContentLength,
1665
1757
  totalContentLength: message.data?.totalContentLength,
1666
1758
  });
1667
- // For backward compatibility: if no messageId, find or create the current streaming message
1668
- // This handles cases where the backend doesn't send messageId
1669
- const currentMessages = messagesRef.current;
1670
- const lastStreamingMessage = [...currentMessages]
1671
- .reverse()
1672
- .find((m) => m.role === "assistant" && !m.isCompleted);
1673
- if (lastStreamingMessage) {
1674
- messageId = lastStreamingMessage.id;
1675
- }
1676
- else {
1677
- // If the agent isn't currently running (e.g., we switched tabs after the run
1678
- // completed), skip creating a new streaming message to avoid duplicates.
1679
- const currentAgentStatus = (agentData || agent)?.status;
1680
- const isAgentRunning = currentAgentStatus === "running";
1681
- if (!isAgentRunning) {
1682
- return;
1683
- }
1684
- // Create a new message ID based on timestamp when the agent is still running
1685
- messageId = crypto.randomUUID();
1686
- }
1687
- }
1688
- if (!messageId) {
1689
- console.error("Unable to determine messageId for content chunk!");
1690
1759
  return;
1691
1760
  }
1692
1761
  // Clear waiting state when first content chunk arrives
@@ -1769,7 +1838,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1769
1838
  }
1770
1839
  // Always call setMessages and handle all logic in the callback with latest messages
1771
1840
  setMessages((prev) => {
1772
- // Find existing message by messageId in the latest messages
1773
1841
  const existingMessageIndex = prev.findIndex((msg) => msg.id === messageId);
1774
1842
  if (existingMessageIndex === -1) {
1775
1843
  // Message doesn't exist - create new streaming message
@@ -1783,267 +1851,188 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
1783
1851
  });
1784
1852
  }
1785
1853
  const newStreamMessage = createNewStreamMessage(messageId, agentData);
1786
- // Set the content for the new message
1787
- const updatedNewMessage = { ...newStreamMessage };
1788
- if (!message.data.isIncremental) {
1789
- updatedNewMessage.content = message.data?.deltaContent || "";
1790
- updatedNewMessage.reasoning = message.data?.reasoning || "";
1791
- }
1792
- else {
1793
- updatedNewMessage.content = message.data?.deltaContent || "";
1794
- updatedNewMessage.reasoning = message.data?.reasoning || "";
1795
- }
1854
+ const updatedNewMessage = {
1855
+ ...newStreamMessage,
1856
+ content: message.data?.deltaContent || "",
1857
+ reasoning: message.data?.reasoning || "",
1858
+ };
1796
1859
  const updated = [...prev, updatedNewMessage];
1797
1860
  messagesRef.current = updated;
1798
1861
  return updated;
1799
1862
  }
1800
- else {
1801
- // Message exists - update it
1802
- const existingMessage = prev[existingMessageIndex];
1803
- if (!existingMessage)
1804
- return prev;
1805
- // Check if existing content is already longer than what we're trying to stream
1806
- const currentContentLength = existingMessage.content?.length || 0;
1807
- const previousContentLength = message.data?.previousContentLength || 0;
1808
- const totalContentLength = message.data?.totalContentLength || 0;
1809
- if (message.data?.isIncremental &&
1810
- previousContentLength !== currentContentLength &&
1811
- (previousContentLength > 0 || currentContentLength > 0)) {
1812
- console.warn("[AgentTerminal] Content chunk length mismatch", {
1813
- messageId,
1814
- previousContentLength,
1815
- currentContentLength,
1816
- totalContentLength,
1817
- deltaLength: (message.data?.deltaContent || "").length,
1818
- });
1819
- }
1820
- if (message.data?.isIncremental &&
1821
- currentContentLength >= totalContentLength &&
1822
- totalContentLength > 0) {
1823
- return prev;
1824
- }
1825
- const updatedMessage = { ...existingMessage };
1826
- if (!message.data.isIncremental) {
1827
- updatedMessage.content = message.data?.deltaContent || "";
1828
- updatedMessage.reasoning = message.data?.reasoning || "";
1829
- }
1830
- else {
1831
- updatedMessage.content =
1832
- existingMessage.content + (message.data?.deltaContent || "");
1833
- // Reasoning is sent as full content from backend, not as a delta,
1834
- // so we replace instead of append to avoid duplication
1835
- updatedMessage.reasoning =
1836
- message.data?.reasoning || existingMessage.reasoning || "";
1837
- }
1838
- const updated = prev.map((msg, index) => index === existingMessageIndex ? updatedMessage : msg);
1839
- messagesRef.current = updated;
1840
- 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
+ });
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 || "";
1841
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;
1842
1901
  });
1843
- }, [createNewStreamMessage, agent]);
1902
+ }, [createNewStreamMessage]);
1844
1903
  const handleToolCall = useCallback((message, agentData) => {
1845
1904
  const extractedToolCall = extractToolCallFields(message.data);
1846
1905
  const toolCallId = extractedToolCall.toolCallId || crypto.randomUUID();
1847
- // Prefer provided messageId, otherwise fall back to the last streaming assistant message
1848
- let toolCallMessageId = message.data?.messageId;
1849
- if (!toolCallMessageId) {
1850
- 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,
1851
1912
  toolCallId,
1852
1913
  toolName: message.data?.name || message.data?.displayName,
1853
1914
  });
1854
- const current = messagesRef.current;
1855
- const lastStreaming = [...current]
1856
- .reverse()
1857
- .find((m) => m.role === "assistant" && !m.isCompleted);
1858
- if (lastStreaming?.id) {
1859
- toolCallMessageId = lastStreaming.id;
1860
- }
1861
- else {
1862
- // Tool calls can arrive before any assistant content chunk (common for dialog tools like ask-questionnaire).
1863
- // Create a synthetic streaming message so the UI can render the tool call immediately.
1864
- toolCallMessageId = crypto.randomUUID();
1865
- }
1915
+ return;
1866
1916
  }
1867
- appendToolUiEvent("ui:tool-call-targeted", `${extractedToolCall.functionName || "unknown"} toolCallId=${toolCallId} targetMessageId=${toolCallMessageId || "none"} providedMessageId=${String(message.data?.messageId || "none")}`);
1868
- // Find or create the target message for this tool call
1869
- if (toolCallMessageId) {
1870
- const currentMessages = messagesRef.current;
1871
- const existingMessageIndex = currentMessages.findIndex((msg) => msg.id === toolCallMessageId);
1872
- if (existingMessageIndex === -1) {
1873
- // Double-check with current ref to prevent race conditions
1874
- const currentMessages = messagesRef.current;
1875
- const existsInRef = currentMessages.find((msg) => msg.id === toolCallMessageId);
1876
- if (!existsInRef) {
1877
- // Create new message for this tool call
1878
- const newStreamMessage = createNewStreamMessage(toolCallMessageId, agentData);
1879
- setMessages((prev) => {
1880
- // Final check before adding to prevent duplicates
1881
- const finalCheck = prev.find((msg) => msg.id === toolCallMessageId);
1882
- if (finalCheck) {
1883
- return prev;
1884
- }
1885
- const updated = [...prev, newStreamMessage];
1886
- messagesRef.current = updated;
1887
- return updated;
1888
- });
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;
1889
1956
  }
1890
- }
1891
- }
1892
- // Add tool call to the message in the array
1893
- if (toolCallMessageId && message.data && toolCallId) {
1894
- const toolCallError = message.data.functionError || message.data.error || "";
1895
- const isPruned = !!message.data?.isPruned || /^PRUNED$/i.test(String(toolCallError));
1896
- const toolCallCreatedDate = message.data.createdDate ||
1897
- message.timestamp ||
1898
- new Date().toISOString();
1899
- const toolCallState = String(message.data?.state || "").toLowerCase();
1900
- const isExplicitApprovalRequired = toolCallState === "toolapprovalsrequired" ||
1901
- toolCallState === "waitingforapproval";
1902
- const requiresApproval = isExplicitApprovalRequired
1903
- ? message.data?.requiresApproval
1904
- : undefined;
1905
- const toolCall = {
1906
- id: toolCallId,
1907
- messageId: toolCallMessageId,
1908
- dbMessageId: message.data.messageId, // Database message ID for approval/rejection
1909
- toolCallId: toolCallId,
1910
- functionName: extractedToolCall.functionName,
1911
- functionArguments: extractedToolCall.functionArguments,
1912
- functionResult: message.data.functionResult || message.data.result || "",
1913
- functionResultRichContent: message.data.richContent || undefined,
1914
- functionError: toolCallError,
1915
- isPruned,
1916
- isCompleted: false,
1917
- responseTimeMs: message.data.responseTimeMs,
1918
- createdDate: toolCallCreatedDate,
1919
- requiresApproval,
1920
- };
1921
- // Check for existing tool call - search across ALL messages by toolCallId first
1922
- // This handles the case where the first message had messageId: null (used fallback UI ID)
1923
- // and the second message has the actual DB messageId
1924
- const currentMessages = messagesRef.current;
1925
- let targetMessage = currentMessages.find((msg) => msg.id === toolCallMessageId);
1926
- let existingToolCallIndex = -1;
1927
- let existingToolCalls = [];
1928
- // First, try to find by toolCallMessageId
1929
- if (targetMessage) {
1930
- existingToolCalls = targetMessage.toolCalls || [];
1931
- existingToolCallIndex = existingToolCalls.findIndex((tc) => tc.toolCallId === toolCallId);
1932
- }
1933
- // If not found, search across ALL messages for this toolCallId
1934
- // This handles the messageId mismatch between first (null) and second (DB ID) messages
1935
- if (existingToolCallIndex === -1) {
1936
- for (const msg of currentMessages) {
1937
- const tcIndex = (msg.toolCalls || []).findIndex((tc) => tc.toolCallId === toolCallId);
1938
- if (tcIndex !== -1) {
1939
- targetMessage = msg;
1940
- existingToolCalls = msg.toolCalls || [];
1941
- existingToolCallIndex = tcIndex;
1942
- break;
1943
- }
1957
+ const working = resolution.messages;
1958
+ const targetMsg = working[resolution.targetIndex];
1959
+ if (!targetMsg) {
1960
+ return prev;
1944
1961
  }
1945
- }
1946
- if (existingToolCallIndex !== -1 && targetMessage) {
1947
- // Tool call already exists - update it with any new/more complete data
1948
- const existingToolCall = existingToolCalls[existingToolCallIndex];
1949
- if (!existingToolCall) {
1950
- 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];
1951
1967
  }
1952
- // Use the actual message ID where the tool call was found (may differ from toolCallMessageId)
1953
- const actualMessageId = targetMessage.id;
1954
- // Check if the new data has more information than what we have
1955
- const newArgs = toolCall.functionArguments;
1956
- const existingArgs = existingToolCall.functionArguments;
1957
- const newArgsText = stringifyToolField(newArgs) || "";
1958
- const existingArgsText = stringifyToolField(existingArgs) || "";
1959
- const hasMoreCompleteArgs = (newArgsText.length > existingArgsText.length &&
1960
- newArgsText !== existingArgsText) ||
1961
- (existingArgsText === "{}" && newArgsText !== "{}");
1962
- const hasNewResult = toolCall.functionResult && !existingToolCall.functionResult;
1963
- const hasNewRichContent = toolCall.functionResultRichContent &&
1964
- !existingToolCall.functionResultRichContent;
1965
- const hasNewError = toolCall.functionError && !existingToolCall.functionError;
1966
- const hasNewApprovalInfo = toolCall.requiresApproval && !existingToolCall.requiresApproval;
1967
- const hasNewDbMessageId = toolCall.dbMessageId && !existingToolCall.dbMessageId;
1968
- // Only update if there's meaningful new data
1969
- if (hasMoreCompleteArgs ||
1970
- hasNewResult ||
1971
- hasNewRichContent ||
1972
- hasNewError ||
1973
- hasNewApprovalInfo ||
1974
- hasNewDbMessageId) {
1975
- setMessages((prev) => {
1976
- const updated = prev.map((msg) => {
1977
- if (msg.id !== actualMessageId)
1978
- return msg;
1979
- const updatedToolCalls = [...(msg.toolCalls || [])];
1980
- const idx = updatedToolCalls.findIndex((tc) => tc.toolCallId === toolCallId);
1981
- if (idx !== -1 && updatedToolCalls[idx]) {
1982
- const existing = updatedToolCalls[idx];
1983
- // Merge: prefer new non-empty values, keep existing values as fallback
1984
- // Use type assertion for dbMessageId which is added dynamically
1985
- updatedToolCalls[idx] = {
1986
- ...existing,
1987
- functionArguments: hasMoreCompleteArgs
1988
- ? newArgsText
1989
- : existingArgsText || existing.functionArguments,
1990
- functionResult: toolCall.functionResult || existing.functionResult,
1991
- functionResultRichContent: toolCall.functionResultRichContent ||
1992
- existing.functionResultRichContent,
1993
- functionError: toolCall.functionError || existing.functionError,
1994
- requiresApproval: toolCall.requiresApproval || existing.requiresApproval,
1995
- };
1996
- // Copy dbMessageId if present (dynamically added field)
1997
- if (toolCall.dbMessageId || existing.dbMessageId) {
1998
- updatedToolCalls[idx].dbMessageId =
1999
- toolCall.dbMessageId || existing.dbMessageId;
2000
- }
2001
- }
2002
- return { ...msg, toolCalls: updatedToolCalls };
2003
- });
2004
- messagesRef.current = updated;
2005
- return updated;
2006
- });
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;
2007
2011
  }
2008
- return; // Done updating existing tool call
2009
- }
2010
- flushSync(() => {
2011
- setMessages((prev) => {
2012
- const updated = prev.map((msg) => {
2013
- if (msg.id !== toolCallMessageId)
2014
- return msg;
2015
- const existingToolCalls = msg.toolCalls || [];
2016
- return { ...msg, toolCalls: [...existingToolCalls, toolCall] };
2017
- });
2018
- messagesRef.current = updated;
2019
- return updated;
2020
- });
2012
+ const updated = working.map((m, i) => i === resolution.targetIndex ? { ...m, toolCalls: nextToolCalls } : m);
2013
+ messagesRef.current = updated;
2014
+ return updated;
2021
2015
  });
2022
- const messageWithToolCall = messagesRef.current.find((msg) => msg.id === toolCallMessageId);
2023
- 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}`);
2024
- // If tool requires approval, agent is now waiting for user action - stop thinking
2025
- if (requiresApproval) {
2026
- setIsAgentThinking(false);
2027
- }
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);
2028
2022
  }
2029
- }, [appendToolUiEvent, createNewStreamMessage]);
2023
+ }, [appendToolUiEvent, resolveAssistantMessage]);
2030
2024
  const handleToolResult = useCallback((message, agentData) => {
2031
2025
  const extractedToolCall = extractToolCallFields(message.data);
2032
2026
  const resultToolCallId = extractedToolCall.toolCallId || crypto.randomUUID();
2033
- // Prefer provided messageId, otherwise fall back to the last streaming assistant message
2034
- let resultMessageId = message.data?.messageId;
2027
+ const resultMessageId = message.data?.messageId;
2028
+ // Backend invariant: every tool-result envelope carries the final assistant messageId.
2035
2029
  if (!resultMessageId) {
2036
- 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,
2037
2032
  toolCallId: resultToolCallId,
2038
2033
  toolName: message.data?.functionName || message.data?.displayName,
2039
2034
  });
2040
- const current = messagesRef.current;
2041
- const lastStreaming = [...current]
2042
- .reverse()
2043
- .find((m) => m.role === "assistant" && !m.isCompleted);
2044
- if (lastStreaming?.id) {
2045
- resultMessageId = lastStreaming.id;
2046
- }
2035
+ return;
2047
2036
  }
2048
2037
  // Extract cost/token data from tool result if present
2049
2038
  const cost = message.cost;
@@ -2139,63 +2128,69 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2139
2128
  }
2140
2129
  }
2141
2130
  }
2142
- // Update tool result directly in the messages array
2143
- if (!resultMessageId) {
2144
- appendToolUiEvent("ui:tool-result-dropped", `${extractedToolCall.functionName || "unknown"} toolCallId=${resultToolCallId} reason=no-result-message-id`);
2145
- return;
2146
- }
2147
2131
  appendToolUiEvent("ui:tool-result-targeted", `${extractedToolCall.functionName || "unknown"} toolCallId=${resultToolCallId} targetMessageId=${resultMessageId}`);
2148
- // 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.
2149
2135
  setMessages((prev) => {
2150
- const updated = prev.map((msg) => {
2151
- if (msg.id !== resultMessageId)
2152
- return msg;
2153
- const updatedMessage = { ...msg };
2154
- if (!updatedMessage.toolCalls) {
2155
- updatedMessage.toolCalls = [];
2156
- }
2157
- // Find and update the tool call with the result
2158
- const toolCallIndex = updatedMessage.toolCalls.findIndex((tc) => tc.toolCallId === resultToolCallId);
2159
- if (toolCallIndex >= 0) {
2160
- const existingToolCall = updatedMessage.toolCalls[toolCallIndex];
2161
- if (existingToolCall && message.data) {
2162
- const updatedToolCalls = [...updatedMessage.toolCalls];
2163
- const nextArgsText = stringifyToolField(extractedToolCall.functionArguments) || "";
2164
- const existingArgsText = stringifyToolField(existingToolCall.functionArguments) || "";
2165
- const hasMoreCompleteArgs = (nextArgsText.length > existingArgsText.length &&
2166
- nextArgsText !== existingArgsText) ||
2167
- (existingArgsText === "{}" && nextArgsText !== "{}");
2168
- const toolCall = {
2169
- id: existingToolCall.id,
2170
- messageId: existingToolCall.messageId,
2171
- toolCallId: existingToolCall.toolCallId,
2172
- functionName: existingToolCall.functionName,
2173
- functionArguments: hasMoreCompleteArgs
2174
- ? nextArgsText
2175
- : existingToolCall.functionArguments,
2176
- functionResult: message.data.functionResult || message.data.result || "",
2177
- functionResultRichContent: message.data.richContent ||
2178
- existingToolCall.functionResultRichContent,
2179
- functionError: message.data.functionError || message.data.error || "",
2180
- isCompleted: true,
2181
- responseTimeMs: message.data.responseTimeMs,
2182
- createdDate: existingToolCall.createdDate,
2183
- };
2184
- updatedToolCalls[toolCallIndex] = toolCall;
2185
- updatedMessage.toolCalls = updatedToolCalls;
2186
- }
2187
- // Check if all tool calls in message are completed
2188
- const allToolCallsCompleted = updatedMessage.toolCalls.every((tc) => tc.isCompleted);
2189
- if (allToolCallsCompleted) {
2190
- shouldCreateNewMessage.current = true;
2191
- }
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;
2192
2158
  }
2193
- else if (message.data && resultToolCallId && resultMessageId) {
2194
- // Create new tool call if it doesn't exist
2195
- const toolCallCreatedDate = message.data.createdDate ||
2196
- message.timestamp ||
2197
- new Date().toISOString();
2198
- 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
+ {
2199
2194
  id: resultToolCallId,
2200
2195
  messageId: resultMessageId,
2201
2196
  toolCallId: resultToolCallId,
@@ -2207,22 +2202,27 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2207
2202
  isCompleted: true,
2208
2203
  responseTimeMs: message.data.responseTimeMs,
2209
2204
  createdDate: toolCallCreatedDate,
2210
- };
2211
- updatedMessage.toolCalls = [...updatedMessage.toolCalls, toolCall];
2212
- }
2213
- // Updated tool calls count
2214
- return updatedMessage;
2215
- });
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);
2216
2216
  messagesRef.current = updated;
2217
- const messageWithToolResult = updated.find((msg) => msg.id === resultMessageId);
2217
+ const messageWithToolResult = updated[resolution.targetIndex];
2218
2218
  const matchingToolCall = messageWithToolResult?.toolCalls?.find((tc) => tc.toolCallId === resultToolCallId);
2219
- 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}`);
2220
2220
  return updated;
2221
2221
  });
2222
2222
  // Document-store refresh is now triggered by the backend agent:documents:changed
2223
2223
  // WebSocket message (fired from AgentDocumentRepository), bridged to the in-process
2224
2224
  // emitAgentDocumentsChanged event in EditorShell. No per-tool allow-list here.
2225
- }, [agent?.id, agentStub.id, appendToolUiEvent]);
2225
+ }, [agent?.id, agentStub.id, appendToolUiEvent, resolveAssistantMessage]);
2226
2226
  // Listen for local approval resolution to update UI.
2227
2227
  // Filters on detail.agentId so that with multi-pane (same agent in two panes,
2228
2228
  // or any visible terminal that isn't the dispatch source) only the matching
@@ -2316,8 +2316,11 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2316
2316
  window.addEventListener("agent:toolApprovalResolved", onApprovalResolved);
2317
2317
  return () => window.removeEventListener("agent:toolApprovalResolved", onApprovalResolved);
2318
2318
  }, []);
2319
- // Load agent data and messages
2320
- 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 } = {}) => {
2321
2324
  try {
2322
2325
  // Even if agentStub.status is "new", try to load from backend first
2323
2326
  // The agent might have been persisted after sending a prompt
@@ -2462,7 +2465,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2462
2465
  return;
2463
2466
  }
2464
2467
  }
2465
- setIsLoading(true);
2468
+ if (!silent) {
2469
+ setIsLoading(true);
2470
+ }
2466
2471
  setError(null);
2467
2472
  // Fetch agent details and initial queued prompts in parallel
2468
2473
  const [agentData, prompts] = await Promise.all([
@@ -2659,7 +2664,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2659
2664
  }
2660
2665
  reconcileServerStateInFlightRef.current = true;
2661
2666
  try {
2662
- await loadAgent();
2667
+ await loadAgent({ silent: true });
2663
2668
  }
2664
2669
  finally {
2665
2670
  reconcileServerStateInFlightRef.current = false;
@@ -2842,6 +2847,48 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2842
2847
  console.log("[AgentTerminal] Ignoring agent:run:start during stop operation");
2843
2848
  return;
2844
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
+ }
2845
2892
  const currentStatus = agentRef.current?.status;
2846
2893
  if (currentStatus === "waitingForInput" ||
2847
2894
  currentStatus === "waitingForApproval" ||
@@ -2849,11 +2896,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2849
2896
  // Replayed start messages arrive before the buffered status payload when
2850
2897
  // reopening an already-paused agent. Preserve the attention state instead
2851
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.)
2852
2901
  return;
2853
2902
  }
2854
- // Reset run-scoped deduplication so new run seq values (starting at 1)
2855
- // are not discarded due to previous run's lastSeqRef
2856
- lastSeqRef.current = 0;
2857
2903
  setLastRunStatusReason(null);
2858
2904
  // Prep streaming UI state for the new run
2859
2905
  setIsConnecting(true);
@@ -2868,7 +2914,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2868
2914
  }
2869
2915
  // Handle agent:user:message
2870
2916
  if (messageType === "agent:user:message") {
2871
- const { messageId, content, timestamp, sourceAgentName, sourceAgent } = message.payload;
2917
+ const { messageId, content, timestamp, sourceAgentName, sourceAgent, clientMessageId, } = message.payload;
2872
2918
  // Track in seenMessageIds for deduplication
2873
2919
  const normalizedId = messageId.toLowerCase();
2874
2920
  if (seenMessageIdsRef.current.has(normalizedId)) {
@@ -2912,11 +2958,20 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2912
2958
  console.log("[AgentTerminal] Message already exists by ID, skipping:", messageId);
2913
2959
  return prev;
2914
2960
  }
2915
- // Look for an optimistic (temp) message with matching content to replace
2916
- // Temp messages have IDs starting with "temp-"
2917
- const tempMessageIndex = prev.findIndex((m) => m.id?.startsWith("temp-") &&
2918
- m.role === "user" &&
2919
- 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
+ }
2920
2975
  let updated;
2921
2976
  if (tempMessageIndex !== -1) {
2922
2977
  // Replace the optimistic message with the server version
@@ -2977,7 +3032,30 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
2977
3032
  if (isStoppingRef.current) {
2978
3033
  return;
2979
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
+ }
2980
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
+ }
2981
3059
  if (type === "ToolCall" || type === "toolCall") {
2982
3060
  }
2983
3061
  // Always allow ContextUpdate messages (metadata updates) regardless of agent status
@@ -3019,6 +3097,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3019
3097
  if (seq) {
3020
3098
  lastSeqRef.current = seq;
3021
3099
  }
3100
+ if (!isHeartbeat) {
3101
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
3102
+ }
3022
3103
  // Route based on delta type
3023
3104
  const agentStreamMessage = {
3024
3105
  type,
@@ -3079,6 +3160,16 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3079
3160
  }
3080
3161
  // Unified: agent:run:status (state only)
3081
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
+ }
3082
3173
  const { seq, data: statusData } = message.payload;
3083
3174
  // Reset on new-run detection — see matching block in agent:run:delta handler.
3084
3175
  if (seq === 1 && lastSeqRef.current > 0) {
@@ -3092,6 +3183,9 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3092
3183
  if (seq) {
3093
3184
  lastSeqRef.current = seq;
3094
3185
  }
3186
+ // Status events never carry a "Heartbeat" payload — heartbeats are deltas. Any
3187
+ // status update is meaningful progress for the watchdog.
3188
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
3095
3189
  // Route based on statusData.state
3096
3190
  try {
3097
3191
  // Normalize various status shapes and handle Cancelled uniformly
@@ -3363,7 +3457,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3363
3457
  }
3364
3458
  // Handle "completed" state (fallback for legacy code paths that send status instead of lifecycle event)
3365
3459
  if (normalizedStatus === "completed") {
3366
- 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);
3367
3464
  return;
3368
3465
  }
3369
3466
  // Handle "Running" state - agent is actively processing
@@ -3423,14 +3520,123 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3423
3520
  }
3424
3521
  return;
3425
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
+ }
3426
3612
  // Lifecycle: agent:run:complete
3427
3613
  if (messageType === "agent:run:complete") {
3614
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
3615
+ const completeRunId = message.payload?.runId;
3616
+ if (normalizeRunId(completeRunId) === null) {
3617
+ noteMissingRunId("agent:run:complete");
3618
+ }
3428
3619
  const finalStatus = normalizeServerExecutionStatus(message.payload?.finalStatus);
3429
- settleCompletedRun(finalStatus === "cancelled" ? "cancelled" : "completed");
3620
+ settleCompletedRun(finalStatus === "cancelled" ? "cancelled" : "completed", completeRunId);
3430
3621
  return;
3431
3622
  }
3432
3623
  // Lifecycle: agent:run:error
3433
3624
  if (messageType === "agent:run:error") {
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
+ }
3434
3640
  const errorMsg = toUserFacingAgentErrorMessage(message.payload?.error) ||
3435
3641
  "AI could not complete this request.";
3436
3642
  clearHeartbeatMessages();
@@ -3452,10 +3658,13 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
3452
3658
  agent,
3453
3659
  appendToolUiEvent,
3454
3660
  clearHeartbeatMessages,
3661
+ createNewStreamMessage,
3455
3662
  handleContentChunk,
3456
3663
  handleHeartbeatMessage,
3457
3664
  handleToolCall,
3458
3665
  handleToolResult,
3666
+ noteMissingRunId,
3667
+ normalizeRunId,
3459
3668
  onAgentUpdate,
3460
3669
  schedulePendingDialogReplay,
3461
3670
  settleCompletedRun,
@@ -4103,10 +4312,24 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4103
4312
  model: requestSettings.modelId,
4104
4313
  mode: requestSettings.mode,
4105
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,
4106
4318
  };
4107
4319
  console.log("[AgentTerminal] Calling startAgent API for agent:", agentId);
4320
+ // Reset the stall watchdog so its 15s window starts at submit, not at the last
4321
+ // delta of the previous run (which could be hours ago for a reused chat).
4322
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
4108
4323
  const response = await startAgent(request);
4109
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
+ }
4110
4333
  const isQueuedForCapacity = response.reason === MACHINE_CAPACITY_REASON ||
4111
4334
  response.message?.toLowerCase().includes("machine slot") ||
4112
4335
  false;
@@ -4396,6 +4619,8 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4396
4619
  model: requestSettings.modelId,
4397
4620
  mode: requestSettings.mode,
4398
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,
4399
4624
  };
4400
4625
  console.log("[AgentTerminal] Calling startAgent API for quick message");
4401
4626
  await startAgent(request);
@@ -4999,7 +5224,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
4999
5224
  const lastEvent = recentAgentRunEvents[recentAgentRunEvents.length - 1];
5000
5225
  const { receivedSeqs, missingSeqs, totalCount } = currentRunDiagnostics;
5001
5226
  const observedMaxSeq = receivedSeqs[receivedSeqs.length - 1] ?? 0;
5002
- const inlineCallback = activeInlineDialogRef.current?.request.callbackId?.trim() || null;
5003
5227
  return {
5004
5228
  agentId: currentAgentId,
5005
5229
  isSubmitting,
@@ -5026,8 +5250,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5026
5250
  totalToolCallCount,
5027
5251
  incompleteToolCallCount,
5028
5252
  recentToolUiEvents,
5029
- activeInlineDialogCallbackId: inlineCallback,
5030
- pendingDialogReplayCallbackIds: Array.from(pendingDialogReplayCallbackIdsRef.current),
5031
5253
  };
5032
5254
  }, [
5033
5255
  assistantGroupCount,
@@ -5048,239 +5270,176 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5048
5270
  recentToolUiEvents,
5049
5271
  totalToolCallCount,
5050
5272
  agent?.statusMessage,
5051
- activeInlineDialog,
5052
5273
  ]);
5053
- // Mirror the latest snapshot/diagnostics so the one-shot replay verify timer can read
5054
- // them after the polling effect's closure has gone stale.
5274
+ // Bump the watchdog timestamp when isExecuting goes false→true. Covers resumed and
5275
+ // foreign starts (where another browser kicks off a run we're observing) on top of
5276
+ // the explicit submit-time bump in handleSubmit.
5277
+ const previousIsExecutingRef = useRef(false);
5055
5278
  useEffect(() => {
5056
- runDiagnosticsSnapshotRef.current = runDiagnosticsSnapshot;
5057
- }, [runDiagnosticsSnapshot]);
5058
- // Reset all per-agent recovery state when the active agent changes.
5279
+ if (isExecuting && !previousIsExecutingRef.current) {
5280
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5281
+ }
5282
+ previousIsExecutingRef.current = isExecuting;
5283
+ }, [isExecuting]);
5284
+ // When the editor socket reconnects, the subscription registry resubscribes for us.
5285
+ // Give it a fresh window before the watchdog judges silence — otherwise a slow
5286
+ // resubscribe handshake would look like a stall.
5059
5287
  useEffect(() => {
5060
- recoveryStateByAgentRef.current.clear();
5061
- if (replayVerifyTimeoutRef.current) {
5062
- clearTimeout(replayVerifyTimeoutRef.current);
5063
- replayVerifyTimeoutRef.current = null;
5288
+ if (isExecuting) {
5289
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5064
5290
  }
5065
- setShowStaleAgentBanner(false);
5291
+ }, [editContext?.socketConnectionVersion, isExecuting]);
5292
+ // Reset watchdog cooldown timestamps when the active agent changes — otherwise a
5293
+ // recent reload on agent A would suppress the first reload for agent B.
5294
+ useEffect(() => {
5295
+ lastNonHeartbeatUpdateAtRef.current = 0;
5296
+ lastStallReloadAtRef.current = 0;
5066
5297
  }, [currentAgentId]);
5067
- // When the editor socket reconnects (socketConnectionVersion bump) clear the stale
5068
- // banner the registry has just re-issued subscriptions, so any "stuck" state should
5069
- // resolve on the next poll. If it doesn't, the banner will reappear.
5298
+ // Stream-stall watchdog. While the run is active and the terminal is visible, the
5299
+ // watchdog ticks every 5s and checks whether the gap since the last non-heartbeat
5300
+ // update exceeds 15s. If it does, it asks the backend whether it has produced
5301
+ // sequence numbers we have not applied — and only if so, falls back to what a user
5302
+ // would do: reload the agent and reconnect the socket.
5070
5303
  useEffect(() => {
5071
- setShowStaleAgentBanner(false);
5072
- }, [editContext?.socketConnectionVersion]);
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
+ ]);
5073
5319
  useEffect(() => {
5074
5320
  if (!effectiveIsVisible || !isExecuting || !currentAgentId) {
5075
5321
  return;
5076
5322
  }
5077
5323
  let disposed = false;
5078
5324
  const agentIdAtMount = currentAgentId;
5079
- const getRecoveryState = () => {
5080
- let state = recoveryStateByAgentRef.current.get(agentIdAtMount);
5081
- if (!state) {
5082
- state = createRecoveryStateSlice();
5083
- recoveryStateByAgentRef.current.set(agentIdAtMount, state);
5084
- }
5085
- return state;
5086
- };
5325
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5326
+ updateAgentWatchdogStatus(agentIdAtMount, {
5327
+ running: true,
5328
+ startedAt: Date.now(),
5329
+ stalledThresholdMs: STREAM_STALLED_AFTER_MS,
5330
+ });
5087
5331
  const logRecoveryEvent = (kind, payload) => {
5088
5332
  try {
5089
5333
  appendToolUiEvent(`recovery:${kind}`, JSON.stringify({ agentId: agentIdAtMount, ...payload }));
5090
5334
  }
5091
5335
  catch {
5092
- // Telemetry must never throw out of the polling path.
5336
+ // Telemetry must never throw out of the watchdog path.
5093
5337
  }
5094
- };
5095
- const clearVerifyTimer = () => {
5096
- if (replayVerifyTimeoutRef.current) {
5097
- clearTimeout(replayVerifyTimeoutRef.current);
5098
- replayVerifyTimeoutRef.current = null;
5338
+ try {
5339
+ emitAgentRecoveryEvent({
5340
+ agentId: agentIdAtMount,
5341
+ kind,
5342
+ payload,
5343
+ timestamp: new Date().toISOString(),
5344
+ });
5099
5345
  }
5100
- };
5101
- const tryReconnect = (state, reason) => {
5102
- const now = Date.now();
5103
- if (state.lastReconnectAt > 0 &&
5104
- now - state.lastReconnectAt < 60_000) {
5105
- logRecoveryEvent("reconnect-cooldown", { reason });
5106
- return false;
5346
+ catch {
5347
+ // Telemetry must never throw out of the watchdog path.
5107
5348
  }
5108
- state.lastReconnectAt = now;
5109
- const closed = forceEditorSocketReconnect(reason);
5110
- logRecoveryEvent("reconnect-started", { reason, closed });
5111
- return true;
5112
5349
  };
5113
- const checkServerCompletion = async () => {
5350
+ const sessionId = editContext?.sessionId;
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
+ });
5362
+ if (!isStreamStalled({
5363
+ isExecuting: true,
5364
+ isVisible: true,
5365
+ isStopping: isStoppingRef.current,
5366
+ isWaitingForCapacity: lastRunStatusReason === MACHINE_CAPACITY_REASON,
5367
+ lastUpdateAt: lastNonHeartbeatUpdateAtRef.current,
5368
+ now: Date.now(),
5369
+ })) {
5370
+ return;
5371
+ }
5372
+ logRecoveryEvent("stall-check", {
5373
+ silentMs: Date.now() - lastNonHeartbeatUpdateAtRef.current,
5374
+ localSeq: lastSeqRef.current,
5375
+ });
5376
+ let diagnostics;
5114
5377
  try {
5115
- const diagnostics = await getAgentDiagnostics(agentIdAtMount, editContext?.sessionId);
5116
- if (disposed) {
5117
- return;
5118
- }
5119
- lastDiagnosticsResponseRef.current = diagnostics;
5120
- const serverStatus = diagnostics.execution?.status;
5121
- if (isInactiveServerExecutionStatus(serverStatus)) {
5122
- setShowStaleAgentBanner(false);
5123
- const lastRunRequestedAt = Date.parse(diagnostics.lastRunRequest?.requestedAt || "");
5124
- const cancelRequestedAt = Date.parse(diagnostics.lastCancellation?.requestedAt || "");
5125
- const cancellationAppliesToCurrentRun = Number.isFinite(cancelRequestedAt) &&
5126
- (!Number.isFinite(lastRunRequestedAt) ||
5127
- cancelRequestedAt >= lastRunRequestedAt);
5128
- settleCompletedRun(normalizeServerExecutionStatus(serverStatus) === "cancelled" ||
5129
- cancellationAppliesToCurrentRun
5130
- ? "cancelled"
5131
- : "completed");
5132
- return;
5133
- }
5134
- // Backstop: if the backend reports an error but we never received the
5135
- // agent:run:error lifecycle event (e.g. it was missed or the broadcast
5136
- // path failed to fire), surface the error locally instead of waiting
5137
- // for the user to reload.
5138
- if (normalizeServerExecutionStatus(serverStatus) === "error") {
5139
- const rawError = diagnostics.execution?.error ?? null;
5140
- const errorMsg = toUserFacingAgentErrorMessage(rawError) ||
5141
- rawError ||
5142
- "AI could not complete this request.";
5143
- clearHeartbeatMessages();
5144
- setLastRunStatusReason(null);
5145
- setError(errorMsg);
5146
- setAgent((prev) => prev ? { ...prev, status: "error", statusMessage: errorMsg } : prev);
5147
- setIsWaitingForResponse(false);
5148
- isWaitingRef.current = false;
5149
- setIsConnecting(false);
5150
- setIsAgentThinking(false);
5151
- setShowStaleAgentBanner(false);
5152
- return;
5153
- }
5154
- const snapshot = runDiagnosticsSnapshot;
5155
- if (!editContext?.socketDiagnostics) {
5156
- // No edit context to read socket diagnostics from — recovery decisions need it,
5157
- // and without it we'd misclassify the stream. Wait for the next tick.
5158
- return;
5159
- }
5160
- const summary = interpretAgentRunDiagnostics({
5161
- socketDiagnostics: editContext.socketDiagnostics,
5162
- localSnapshot: snapshot,
5163
- serverDiagnostics: diagnostics,
5164
- });
5165
- const state = getRecoveryState();
5166
- const now = Date.now();
5167
- reconcilePendingDialogTracker(state, diagnostics.pendingDialogs, now);
5168
- // --- Verify-window resolution. Structured to avoid the "expired branch
5169
- // unreachable" pitfall: first check whether a verify is in flight, then branch on
5170
- // whether it's still inside the window or just expired.
5171
- if (state.lastReplayVerifyDeadlineAt > 0) {
5172
- if (now < state.lastReplayVerifyDeadlineAt) {
5173
- // Still inside the 4s window — leave verification to the one-shot timer.
5174
- return;
5175
- }
5176
- const verified = checkReplayVerified(state, snapshot, diagnostics);
5177
- state.lastReplayVerifyDeadlineAt = 0;
5178
- state.lastReplayVerifySnapshot = null;
5179
- logRecoveryEvent(verified ? "replay-succeeded" : "replay-failed", { kind: "poll", summaryCode: summary.code });
5180
- if (verified) {
5181
- setShowStaleAgentBanner(false);
5182
- return;
5183
- }
5184
- // Fall through — decideRecoveryAction will likely return "reconnect" now.
5185
- }
5186
- const decision = decideRecoveryAction({
5187
- summary,
5188
- localSnapshot: snapshot,
5189
- serverDiagnostics: diagnostics,
5190
- recoveryState: state,
5191
- now,
5192
- });
5193
- state.lastDecidedAction = decision.action;
5194
- logRecoveryEvent("decided", {
5195
- action: decision.action,
5196
- summaryCode: summary.code,
5197
- reasonCode: decision.reasonCode,
5198
- replayKey: decision.replayKey,
5378
+ diagnostics = await getAgentDiagnostics(agentIdAtMount, sessionId);
5379
+ }
5380
+ catch (error) {
5381
+ console.warn("[AgentTerminal] Stall watchdog failed to fetch diagnostics", error);
5382
+ logRecoveryEvent("diagnostics-failed", {
5383
+ message: error instanceof Error ? error.message : String(error),
5199
5384
  });
5200
- switch (decision.action) {
5201
- case "replay": {
5202
- const sent = requestAgentSubscriptionReplay(agentIdAtMount);
5203
- if (!sent) {
5204
- tryReconnect(state, "agent-recovery-replay-no-socket");
5205
- break;
5206
- }
5207
- const serverLastSeq = diagnostics.currentSession?.lastDelivery?.lastSeq ??
5208
- diagnostics.transport?.lastSeq ??
5209
- null;
5210
- const replayCallbackId = decision.replayKey?.split("|").pop() ?? null;
5211
- state.lastReplayKey = decision.replayKey;
5212
- state.lastReplayAt = now;
5213
- state.lastReplayVerifyDeadlineAt = now + 4_000;
5214
- state.lastReplayVerifySnapshot = {
5215
- localSeq: snapshot.lastSeq,
5216
- serverLastSeq,
5217
- pendingDialogCallbackId: replayCallbackId === "none" ? null : replayCallbackId,
5218
- };
5219
- clearVerifyTimer();
5220
- replayVerifyTimeoutRef.current = setTimeout(() => {
5221
- replayVerifyTimeoutRef.current = null;
5222
- if (disposed)
5223
- return;
5224
- const latestSnapshot = runDiagnosticsSnapshotRef.current ?? snapshot;
5225
- const latestDiagnostics = lastDiagnosticsResponseRef.current;
5226
- const verified = checkReplayVerified(state, latestSnapshot, latestDiagnostics);
5227
- state.lastReplayVerifyDeadlineAt = 0;
5228
- state.lastReplayVerifySnapshot = null;
5229
- logRecoveryEvent(verified ? "replay-succeeded" : "replay-failed", { kind: "one-shot" });
5230
- if (verified) {
5231
- setShowStaleAgentBanner(false);
5232
- }
5233
- // Do not auto-escalate from here; the next poll will see the failed verify
5234
- // and ask decideRecoveryAction for the next step (typically reconnect).
5235
- }, 4_000);
5236
- logRecoveryEvent("replay-started", {
5237
- replayKey: decision.replayKey,
5238
- summaryCode: summary.code,
5239
- serverLastSeq,
5240
- localSeq: snapshot.lastSeq,
5241
- });
5242
- break;
5243
- }
5244
- case "reconnect": {
5245
- tryReconnect(state, "agent-recovery-escalation");
5246
- break;
5247
- }
5248
- case "stale": {
5249
- setShowStaleAgentBanner(true);
5250
- logRecoveryEvent("stale", {
5251
- summaryCode: summary.code,
5252
- reasonCode: decision.reasonCode,
5253
- });
5254
- break;
5255
- }
5256
- case "none":
5257
- break;
5258
- }
5385
+ // Reset so we don't refire diagnostics every 5s while the call keeps failing.
5386
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5387
+ return;
5388
+ }
5389
+ if (disposed || agentIdAtMount !== currentAgentId) {
5390
+ return;
5391
+ }
5392
+ const backendSeq = diagnostics.currentSession?.lastDelivery?.lastSeq ??
5393
+ diagnostics.transport?.lastSeq ??
5394
+ null;
5395
+ const localSeq = lastSeqRef.current;
5396
+ if (!shouldReloadAfterDiagnostics({ localSeq, backendSeq })) {
5397
+ // Backend is at or behind us — silence is something else (idle, server lag).
5398
+ // Suppress the watchdog for another full window instead of refiring every 5s.
5399
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5400
+ return;
5401
+ }
5402
+ const sinceLastReload = Date.now() - lastStallReloadAtRef.current;
5403
+ if (lastStallReloadAtRef.current > 0 && sinceLastReload < 30_000) {
5404
+ logRecoveryEvent("reload-cooldown", { sinceLastReload });
5405
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5406
+ return;
5407
+ }
5408
+ lastStallReloadAtRef.current = Date.now();
5409
+ logRecoveryEvent("reload-fired", { backendSeq, localSeq });
5410
+ lastSeqRef.current = 0;
5411
+ seenMessageIdsRef.current.clear();
5412
+ try {
5413
+ await loadAgent();
5259
5414
  }
5260
5415
  catch (error) {
5261
- console.warn("[AgentTerminal] Failed to reconcile agent run status", error);
5416
+ console.warn("[AgentTerminal] Stall reload loadAgent() failed", error);
5417
+ }
5418
+ if (disposed || agentIdAtMount !== currentAgentId) {
5419
+ return;
5262
5420
  }
5421
+ forceEditorSocketReconnect("agent-stream-stalled");
5422
+ lastNonHeartbeatUpdateAtRef.current = Date.now();
5263
5423
  };
5264
- // Avoid racing a freshly submitted run before the backend has registered it.
5265
- const timeoutId = window.setTimeout(checkServerCompletion, 10_000);
5266
- const intervalId = window.setInterval(checkServerCompletion, 15_000);
5424
+ const intervalId = window.setInterval(() => {
5425
+ void tick();
5426
+ }, 5_000);
5267
5427
  return () => {
5268
5428
  disposed = true;
5269
- window.clearTimeout(timeoutId);
5270
5429
  window.clearInterval(intervalId);
5271
- clearVerifyTimer();
5430
+ updateAgentWatchdogStatus(agentIdAtMount, {
5431
+ running: false,
5432
+ stoppedAt: Date.now(),
5433
+ });
5272
5434
  };
5273
5435
  }, [
5274
5436
  currentAgentId,
5275
5437
  editContext?.sessionId,
5276
- editContext?.socketConnectionVersion,
5277
- editContext?.socketDiagnostics,
5278
5438
  effectiveIsVisible,
5279
5439
  isExecuting,
5440
+ lastRunStatusReason,
5280
5441
  appendToolUiEvent,
5281
- clearHeartbeatMessages,
5282
- runDiagnosticsSnapshot,
5283
- settleCompletedRun,
5442
+ loadAgent,
5284
5443
  ]);
5285
5444
  const showInitialThinkingSplash = messages.length === 0 &&
5286
5445
  !error &&
@@ -5439,33 +5598,6 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5439
5598
  "Waiting for capacity. The agent will start automatically when a slot becomes available.";
5440
5599
  return _jsx(AgentCapacityBanner, { message: message });
5441
5600
  };
5442
- const renderStaleAgentBanner = () => {
5443
- if (!showStaleAgentBanner || !currentAgentId)
5444
- return null;
5445
- const handleRefreshStream = () => {
5446
- const state = recoveryStateByAgentRef.current.get(currentAgentId);
5447
- if (state) {
5448
- state.lastReplayKey = null;
5449
- state.lastReplayAt = 0;
5450
- }
5451
- requestAgentSubscriptionReplay(currentAgentId);
5452
- void loadAgent();
5453
- setShowStaleAgentBanner(false);
5454
- };
5455
- const handleReconnect = () => {
5456
- const state = recoveryStateByAgentRef.current.get(currentAgentId);
5457
- if (state) {
5458
- state.lastReconnectAt = 0;
5459
- }
5460
- forceEditorSocketReconnect("user-stale-agent-action");
5461
- setShowStaleAgentBanner(false);
5462
- };
5463
- const handleCancel = () => {
5464
- void handleStop();
5465
- setShowStaleAgentBanner(false);
5466
- };
5467
- return (_jsxs("div", { role: "status", className: "m-3 rounded border border-amber-300 bg-amber-50 p-3 text-[12px] text-amber-900", children: [_jsx("p", { className: "font-medium", children: "Agent run looks stuck" }), _jsx("p", { className: "mt-1 text-[11px] text-amber-800", children: "The backend reports this run is still active, but recent recovery attempts did not resolve the stream. Pick an action below \u2014 automatic retries are paused until then." }), _jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [_jsx(Button, { size: "sm", variant: "outline", onClick: handleRefreshStream, children: "Refresh stream" }), _jsx(Button, { size: "sm", variant: "outline", onClick: handleReconnect, children: "Reconnect socket" }), _jsx(Button, { size: "sm", variant: "outline", onClick: handleCancel, children: "Cancel agent" })] })] }));
5468
- };
5469
5601
  const renderBrowserClaimBanner = (variant = "inline") => {
5470
5602
  if (!agent?.id || !editContext?.sessionId)
5471
5603
  return null;
@@ -5589,7 +5721,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5589
5721
  ? getOperationsForMessageGroup(summaryMessages, agentOperations)
5590
5722
  : [];
5591
5723
  return (_jsxs("div", { className: `flex h-full min-h-0 flex-col ${className || ""}`, children: [fixedBrowserClaimBanner, readOnlyAccessNotice, error &&
5592
- !isAgentErrorStatusValue((agent || agentStub)?.status) && (_jsx("div", { className: "m-4 rounded-lg border-l-4 border-red-500 bg-red-50 p-3 select-text", children: _jsxs("div", { className: "flex items-start", children: [_jsx(AlertCircle, { className: "mt-0.5 h-5 w-5 text-red-400", strokeWidth: 1 }), _jsxs("div", { className: "ml-3", children: [_jsx("p", { className: "text-[11px] font-medium text-red-800", children: "Error" }), _jsx("p", { className: "mt-1 text-[11px] text-red-700", children: error })] })] }) })), renderCapacityBanner(), renderErrorBanner(), renderStaleAgentBanner(), _jsxs("div", { ref: messagesContainerRef, className: "flex-1 overflow-y-auto", onScroll: handleScroll, children: [showInitialThinkingSplash && (_jsx(InitialThinkingSplash, { svgIcon: activeProfile?.svgIcon })), inlineBrowserClaimBanner, inlineDialog ? (inlineDialog) : latestSummaryAssistantGroup ? (_jsx("div", { className: "space-y-0 divide-y divide-gray-100 select-text", children: _jsx(AiResponseMessage, { messages: summaryMessages, finished: !latestSummaryAssistantGroup.isLastGroup || !isExecuting, editOperations: summaryOperations, defaultCollapseJson: defaultCollapseJson, profileSvgIcon: activeProfile?.svgIcon, agentId: agent?.id || agentStub.id, agentName: activeProfile?.agentName ||
5724
+ !isAgentErrorStatusValue((agent || agentStub)?.status) && (_jsx("div", { className: "m-4 rounded-lg border-l-4 border-red-500 bg-red-50 p-3 select-text", children: _jsxs("div", { className: "flex items-start", children: [_jsx(AlertCircle, { className: "mt-0.5 h-5 w-5 text-red-400", strokeWidth: 1 }), _jsxs("div", { className: "ml-3", children: [_jsx("p", { className: "text-[11px] font-medium text-red-800", children: "Error" }), _jsx("p", { className: "mt-1 text-[11px] text-red-700", children: error })] })] }) })), renderCapacityBanner(), renderErrorBanner(), _jsxs("div", { ref: messagesContainerRef, className: "flex-1 overflow-y-auto", onScroll: handleScroll, children: [showInitialThinkingSplash && (_jsx(InitialThinkingSplash, { svgIcon: activeProfile?.svgIcon })), inlineBrowserClaimBanner, inlineDialog ? (inlineDialog) : latestSummaryAssistantGroup ? (_jsx("div", { className: "space-y-0 divide-y divide-gray-100 select-text", children: _jsx(AiResponseMessage, { messages: summaryMessages, finished: !latestSummaryAssistantGroup.isLastGroup || !isExecuting, editOperations: summaryOperations, defaultCollapseJson: defaultCollapseJson, profileSvgIcon: activeProfile?.svgIcon, agentId: agent?.id || agentStub.id, agentName: activeProfile?.agentName ||
5593
5725
  activeProfile?.displayTitle ||
5594
5726
  activeProfile?.name, allPendingApprovals: allPendingApprovals, onSwitchToAutonomous: handleSwitchToAutonomous, browserCaptureInlinePrompt: browserCaptureInlinePrompt, readOnly: readOnly, onQuickAction: (action) => {
5595
5727
  const text = (action.prompt ||
@@ -5732,11 +5864,14 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5732
5864
  return groups.map((group, groupIndex) => {
5733
5865
  const isLastGroup = groupIndex === groups.length - 1;
5734
5866
  if (group.type === "user" && group.messages[0]) {
5735
- // Render user message
5736
- 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}`));
5737
5872
  }
5738
5873
  else if (group.type === "heartbeat" && group.messages[0]) {
5739
- return (_jsx(HeartbeatMessage, { message: group.messages[0] }, group.messages[0].id || groupIndex));
5874
+ return (_jsx(HeartbeatMessage, { message: group.messages[0] }, group.messages[0].id));
5740
5875
  }
5741
5876
  else {
5742
5877
  // Render bundled assistant messages
@@ -5754,6 +5889,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5754
5889
  }
5755
5890
  const convertedMessages = convertAgentMessagesToAiFormat(filteredMessages);
5756
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
+ }
5757
5896
  return (_jsx(AiResponseMessage, { messages: convertedMessages, finished: !isLastGroup || !isExecuting, editOperations: operationsForGroup, defaultCollapseJson: defaultCollapseJson, profileSvgIcon: activeProfile?.svgIcon, agentId: agent?.id || agentStub.id, agentName: activeProfile?.agentName ||
5758
5897
  activeProfile?.displayTitle ||
5759
5898
  activeProfile?.name, allPendingApprovals: allPendingApprovals, onSwitchToAutonomous: handleSwitchToAutonomous, browserCaptureInlinePrompt: browserCaptureInlinePrompt, readOnly: readOnly, onQuickAction: (action) => {
@@ -5796,7 +5935,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5796
5935
  catch { }
5797
5936
  }
5798
5937
  sendQuickMessage(text);
5799
- } }, groupIndex));
5938
+ } }, assistantKey ?? `assistant-${groupIndex}`));
5800
5939
  }
5801
5940
  });
5802
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: {
@@ -5951,9 +6090,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
5951
6090
  const placeholderShowsOwnButtons = hideBottomControls && isInPlaceholderMode;
5952
6091
  if (placeholderShowsOwnButtons)
5953
6092
  return null;
5954
- return (_jsxs("div", { className: cn("mt-2 flex items-stretch gap-2", hideBottomControls || simpleMode || isInPlaceholderMode
5955
- ? "justify-end"
5956
- : "justify-between"), children: [!hideBottomControls && !simpleMode && !isInPlaceholderMode && (_jsxs("div", { className: "flex flex-wrap items-center justify-start gap-2", children: [readOnly && (_jsx("span", { className: "shrink-0 rounded bg-gray-100 px-1 py-0.5 text-[10px] text-gray-600", title: "You have View-only access to this agent.", "data-testid": "agent-read-only-badge", children: "Read-only" })), _jsx(Select, { "data-testid": "agent-mode-selector", size: "xs", maxWidth: 240, disabled: readOnly, className: cn("h-5 w-auto min-w-[95px] rounded border px-1.5 text-[11px] font-normal", mode === "read-only"
6093
+ const showConfigControls = (showAgentConfigControls ?? !hideBottomControls) &&
6094
+ !simpleMode &&
6095
+ !isInPlaceholderMode;
6096
+ return (_jsxs("div", { className: cn("mt-2 flex items-stretch gap-2", showConfigControls ? "justify-between" : "justify-end"), children: [showConfigControls && (_jsxs("div", { className: "flex flex-wrap items-center justify-start gap-2", children: [readOnly && (_jsx("span", { className: "shrink-0 rounded bg-gray-100 px-1 py-0.5 text-[10px] text-gray-600", title: "You have View-only access to this agent.", "data-testid": "agent-read-only-badge", children: "Read-only" })), _jsx(Select, { "data-testid": "agent-mode-selector", size: "xs", maxWidth: 240, disabled: readOnly, className: cn("h-5 w-auto min-w-[95px] rounded border px-1.5 text-[11px] font-normal", mode === "read-only"
5957
6097
  ? "border-green-300 bg-green-50! text-green-700 hover:bg-green-100!"
5958
6098
  : mode === "supervised"
5959
6099
  ? "border-amber-300 bg-amber-50! text-amber-700 hover:bg-amber-100!"
@@ -6175,7 +6315,7 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
6175
6315
  setShowCostAndAgent((prev) => !prev);
6176
6316
  }, variant: "outline", size: "sm", className: "h-5.5 w-5.5 cursor-pointer rounded-full", "aria-expanded": editContext?.isMobile
6177
6317
  ? showCostAndAgent
6178
- : 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
6179
6319
  ? {
6180
6320
  input: liveTotals.input,
6181
6321
  output: liveTotals.output,
@@ -6212,10 +6352,10 @@ export function AgentTerminal({ agentStub, initialMetadata, profiles, onReloadPr
6212
6352
  : allPendingApprovals.length > 0
6213
6353
  ? "Approve or reject pending tool calls first"
6214
6354
  : "Send", "aria-label": isExecuting ? "Stop" : "Send", "data-testid": "agent-send-stop-button", "data-executing": isExecuting ? "true" : "false", children: isExecuting ? (_jsx(Square, { className: "size-3", strokeWidth: 1 })) : (_jsx(Send, { className: "size-3", strokeWidth: 1 })) })] })] }));
6215
- })(), !hideBottomControls &&
6355
+ })(), (showStatusBar ?? !hideBottomControls) &&
6216
6356
  !simpleMode &&
6217
6357
  editContext &&
6218
- !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
6219
6359
  ? {
6220
6360
  input: liveTotals.input,
6221
6361
  output: liveTotals.output,