Skip to content
Merged

Devel #236

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions tests/test_web_concurrent_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
17 changes: 11 additions & 6 deletions web/vite-frontend/src/components/mountOrbitalAgentIndicator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
}
5 changes: 2 additions & 3 deletions web/vite-frontend/src/features/chat/messageStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 20 additions & 6 deletions web/vite-frontend/src/features/graphs/AgentGraphView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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);
};
}
Expand All @@ -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) {
Expand Down
Loading