diff --git a/tests/test_web_concurrent_sessions.py b/tests/test_web_concurrent_sessions.py index 16aa14b..0b3cac9 100644 --- a/tests/test_web_concurrent_sessions.py +++ b/tests/test_web_concurrent_sessions.py @@ -259,6 +259,26 @@ def test_plain_text_blocks_keep_history_order_and_stable_identity() -> None: assert '${item.timelineId || "text:legacy"}:content' in main +def test_agent_graph_stops_animation_and_uses_one_update_transport() -> None: + graph = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "features" / "graphs" / "AgentGraphView.js").read_text(encoding="utf-8") + start_polling = graph[graph.index(" startPolling(sessionId)"):graph.index(" async _poll(sessionId)")] + + assert "this.stopPolling();" in start_polling + assert "if (this._eventStream !== eventStream) return;" in start_polling + assert "eventStream.close();" in start_polling + assert start_polling.index("eventStream.close();") < start_polling.index("setInterval(") + assert "this._hasRunningNodes = false;" in start_polling + assert "cancelAnimationFrame(this._animationFrame);" in start_polling + + +def test_orbital_indicator_skips_duplicate_state_renders() -> None: + indicator = (Path(__file__).parents[1] / "web" / "vite-frontend" / "src" / "components" / "mountOrbitalAgentIndicator.js").read_text(encoding="utf-8") + + assert "let renderedState = null;" in indicator + assert "if (state === renderedState) return;" in indicator + assert "renderedState = state;" in indicator + + def test_step_cancellation_identifies_the_active_session_owner() -> None: content = _main_js() request_step_cancellation = content[ diff --git a/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js index 5531c2a..6bd579a 100644 --- a/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js +++ b/web/vite-frontend/src/components/mountOrbitalAgentIndicator.js @@ -7,12 +7,17 @@ import OrbitalAgentIndicator from "./OrbitalAgentIndicator.jsx"; export function mountOrbitalAgentIndicator(target) { if (!target) return null; const root = createRoot(target); - const render = (state = "idle") => root.render(React.createElement(OrbitalAgentIndicator, { - state, - size: 18, - color: "var(--accent)", - title: `MatCreator is ${state}`, - })); + let renderedState = null; + const render = (state = "idle") => { + if (state === renderedState) return; + renderedState = state; + root.render(React.createElement(OrbitalAgentIndicator, { + state, + size: 18, + color: "var(--accent)", + title: `MatCreator is ${state}`, + })); + }; render(); return { render, unmount: () => root.unmount() }; } diff --git a/web/vite-frontend/src/features/chat/messageStream.js b/web/vite-frontend/src/features/chat/messageStream.js index 38f8cae..ddf87b9 100644 --- a/web/vite-frontend/src/features/chat/messageStream.js +++ b/web/vite-frontend/src/features/chat/messageStream.js @@ -75,7 +75,7 @@ export function createMessageStreamController(deps) { renderStopStatus(request); request.controller.abort(); updateAgentRunningStatus("working"); - pollCancellationConfirmed(request.sessionId, request.owner); + void pollCancellationConfirmed(request); } async function send(message) { @@ -160,8 +160,7 @@ export function createMessageStreamController(deps) { && response.response?.status === "ok") executionApprovedThisTurn = true; } else if (part.text) { updateAgentRunningStatus("thinking"); - accumulatedText = mergeReplayedText(accumulatedText, part.text); - upsertTimelineText(timeline, compactRepeatedPrefixSnapshots(accumulatedText)); + upsertTimelineText(timeline, part.text); if (!summaryTriggered && !state.summaryGeneratedFor.has(request.sessionId) && !state.sessionSummaries[request.sessionId]) { summaryTriggered = true; generateSessionSummary(request.sessionId, request.owner); diff --git a/web/vite-frontend/src/features/graphs/AgentGraphView.js b/web/vite-frontend/src/features/graphs/AgentGraphView.js index 21830ba..fce6ffc 100644 --- a/web/vite-frontend/src/features/graphs/AgentGraphView.js +++ b/web/vite-frontend/src/features/graphs/AgentGraphView.js @@ -914,11 +914,12 @@ export class AgentGraphView { } startPolling(sessionId) { + this.stopPolling(); this._currentSessionId = sessionId; - this._poll(sessionId); - this._eventStream?.close(); - this._eventStream = new EventSource(`/api/agent-graph/${encodeURIComponent(sessionId)}/events`); - this._eventStream.onmessage = (event) => { + void this._poll(sessionId); + const eventStream = new EventSource(`/api/agent-graph/${encodeURIComponent(sessionId)}/events`); + this._eventStream = eventStream; + eventStream.onmessage = (event) => { try { if (sessionId !== this._currentSessionId) return; this.update(JSON.parse(event.data)); @@ -927,8 +928,13 @@ export class AgentGraphView { } }; // Keep a low-frequency fallback for deployments running an older web - // backend that does not yet expose the graph event endpoint. - this._eventStream.onerror = () => { + // backend that does not yet expose the graph event endpoint. Close the + // EventSource before starting it so browser reconnects cannot deliver the + // same snapshots alongside the fallback poller. + eventStream.onerror = () => { + if (this._eventStream !== eventStream) return; + eventStream.close(); + this._eventStream = null; if (!this._pollInterval) this._pollInterval = setInterval(() => this._poll(sessionId), 2000); }; } @@ -940,6 +946,14 @@ export class AgentGraphView { clearInterval(this._pollInterval); this._pollInterval = null; } + // Polling owns the running-node animation lifecycle. A cancellation can + // stop updates while the last received snapshot still says "running"; + // without clearing that stale flag the canvas redraw loop never ends. + this._hasRunningNodes = false; + this._activeEdges = []; + if (this._animationFrame !== null) cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + this._network?.redraw(); } async _poll(sessionId) {