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