@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.
- package/dist/components/ui/button.d.ts +1 -1
- package/dist/editor/ai/AgentTerminal.d.ts +5 -1
- package/dist/editor/ai/AgentTerminal.js +740 -600
- package/dist/editor/ai/AgentTerminal.js.map +1 -1
- package/dist/editor/ai/AgentTerminalStatusBar.d.ts +7 -1
- package/dist/editor/ai/AgentTerminalStatusBar.js +8 -2
- package/dist/editor/ai/AgentTerminalStatusBar.js.map +1 -1
- package/dist/editor/ai/AiResponseMessage.js +1 -13
- package/dist/editor/ai/AiResponseMessage.js.map +1 -1
- package/dist/editor/ai/HeartbeatDiagnosticsPanel.d.ts +12 -0
- package/dist/editor/ai/HeartbeatDiagnosticsPanel.js +310 -0
- package/dist/editor/ai/HeartbeatDiagnosticsPanel.js.map +1 -0
- package/dist/editor/ai/ToolCallDisplay.d.ts +1 -3
- package/dist/editor/ai/ToolCallDisplay.js +7 -37
- package/dist/editor/ai/ToolCallDisplay.js.map +1 -1
- package/dist/editor/ai/agentDiagnostics.d.ts +24 -51
- package/dist/editor/ai/agentDiagnostics.js +22 -199
- package/dist/editor/ai/agentDiagnostics.js.map +1 -1
- package/dist/editor/ai/agentDiagnostics.test.js +93 -322
- package/dist/editor/ai/agentDiagnostics.test.js.map +1 -1
- package/dist/editor/ai/agentRecoveryEventBus.d.ts +10 -0
- package/dist/editor/ai/agentRecoveryEventBus.js +18 -0
- package/dist/editor/ai/agentRecoveryEventBus.js.map +1 -0
- package/dist/editor/ai/agentWatchdogStatusRegistry.d.ts +17 -0
- package/dist/editor/ai/agentWatchdogStatusRegistry.js +25 -0
- package/dist/editor/ai/agentWatchdogStatusRegistry.js.map +1 -0
- package/dist/editor/client/EditorShell.js +8 -7
- package/dist/editor/client/EditorShell.js.map +1 -1
- package/dist/editor/reviews/Comment.js +9 -0
- package/dist/editor/reviews/Comment.js.map +1 -1
- package/dist/editor/services/agentService.d.ts +1 -0
- package/dist/editor/services/agentService.js.map +1 -1
- package/dist/editor/settings/SettingsHeaderActionsContext.d.ts +0 -1
- package/dist/editor/settings/SettingsHeaderActionsContext.js +0 -3
- package/dist/editor/settings/SettingsHeaderActionsContext.js.map +1 -1
- package/dist/editor/settings/SettingsView.js +1 -1
- package/dist/editor/settings/SettingsView.js.map +1 -1
- package/dist/revision.d.ts +2 -2
- package/dist/revision.js +2 -2
- package/dist/splash-screen/ParheliaAssistantChat.js +61 -16
- package/dist/splash-screen/ParheliaAssistantChat.js.map +1 -1
- package/dist/splash-screen/SplashScreenAgentContext.d.ts +28 -0
- package/dist/splash-screen/SplashScreenAgentContext.js +51 -0
- package/dist/splash-screen/SplashScreenAgentContext.js.map +1 -0
- package/dist/task-board/TaskBoardWorkspace.js +31 -1
- package/dist/task-board/TaskBoardWorkspace.js.map +1 -1
- package/dist/task-board/components/TaskBoardTitlebar.js +6 -2
- package/dist/task-board/components/TaskBoardTitlebar.js.map +1 -1
- package/dist/task-board/services/taskService.d.ts +3 -0
- package/dist/task-board/services/taskService.js +4 -0
- package/dist/task-board/services/taskService.js.map +1 -1
- package/dist/task-board/taskBoardNavStore.d.ts +1 -0
- package/dist/task-board/taskBoardNavStore.js +1 -0
- package/dist/task-board/taskBoardNavStore.js.map +1 -1
- package/dist/task-board/types.d.ts +3 -0
- package/dist/task-board/views/DependencyGraphView.js +9 -8
- package/dist/task-board/views/DependencyGraphView.js.map +1 -1
- 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,
|
|
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 {
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
//
|
|
1258
|
-
|
|
1259
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1658
|
-
//
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
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
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
|
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
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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
|
-
|
|
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
|
|
1868
|
-
//
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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
|
-
|
|
1893
|
-
|
|
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
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
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
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
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
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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,
|
|
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
|
-
|
|
2034
|
-
|
|
2027
|
+
const resultMessageId = message.data?.messageId;
|
|
2028
|
+
// Backend invariant: every tool-result envelope carries the final assistant messageId.
|
|
2035
2029
|
if (!resultMessageId) {
|
|
2036
|
-
console.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
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
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
return
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2916
|
-
//
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
5054
|
-
//
|
|
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
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
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
|
-
|
|
5061
|
-
|
|
5062
|
-
clearTimeout(replayVerifyTimeoutRef.current);
|
|
5063
|
-
replayVerifyTimeoutRef.current = null;
|
|
5288
|
+
if (isExecuting) {
|
|
5289
|
+
lastNonHeartbeatUpdateAtRef.current = Date.now();
|
|
5064
5290
|
}
|
|
5065
|
-
|
|
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
|
-
//
|
|
5068
|
-
//
|
|
5069
|
-
//
|
|
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
|
-
|
|
5072
|
-
|
|
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
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
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
|
|
5336
|
+
// Telemetry must never throw out of the watchdog path.
|
|
5093
5337
|
}
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5338
|
+
try {
|
|
5339
|
+
emitAgentRecoveryEvent({
|
|
5340
|
+
agentId: agentIdAtMount,
|
|
5341
|
+
kind,
|
|
5342
|
+
payload,
|
|
5343
|
+
timestamp: new Date().toISOString(),
|
|
5344
|
+
});
|
|
5099
5345
|
}
|
|
5100
|
-
|
|
5101
|
-
|
|
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
|
|
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
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
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
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
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]
|
|
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
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
5736
|
-
|
|
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
|
|
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
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
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,
|