From 27fb5676740fdad7fdece0e56365a92210c7fa0f Mon Sep 17 00:00:00 2001 From: Roshan Aryal Date: Tue, 11 Aug 2026 17:15:04 +1200 Subject: [PATCH] fix: replayed tool_call_item must not revert completed/failed status to running TuiLiveView._record_tool_call_data() always set status="running" and blindly merged it into an existing event via existing["data"].update(...). If the same tool_call_item was processed twice (duplicate stream event, hydration replay) after _record_tool_output_data() had already marked the event completed/failed, the status flipped back to "running" while the result stayed populated - an inconsistent state in the timeline. Only set status="running" on a brand-new event, or when updating an existing one that hasn't reached a terminal state yet. Also adds the missing regression test for the sibling bug in the same issue (events colliding across agents sharing a call_id), which was already fixed by fade370 (#917) but never got a test. Fixes #660 --- strix/interface/tui/live_view.py | 24 +++++--- tests/test_tui_live_view_tool_events.py | 75 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 tests/test_tui_live_view_tool_events.py diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py index 24e676fe..228ec650 100644 --- a/strix/interface/tui/live_view.py +++ b/strix/interface/tui/live_view.py @@ -312,18 +312,26 @@ class TuiLiveView: call_id = call["call_id"] event_key = (agent_id, call_id) existing = self._tool_event_by_agent_and_call_id.get(event_key) - tool_data = { - "tool_name": call["tool_name"], - "args": call["args"], - "status": "running", - "agent_id": agent_id, - "call_id": call_id, - } if existing is None: + tool_data = { + "tool_name": call["tool_name"], + "args": call["args"], + "status": "running", + "agent_id": agent_id, + "call_id": call_id, + } event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp) self._tool_event_by_agent_and_call_id[event_key] = event else: - existing["data"].update(tool_data) + # A replayed or duplicated tool_call_item (duplicate stream event, + # hydration replay) must not resurrect a tool that has already + # reached a terminal state back to "running" - its result would + # then sit next to a status that says the call is still in + # flight. + update = {"tool_name": call["tool_name"], "args": call["args"]} + if existing["data"].get("status") not in {"completed", "failed"}: + update["status"] = "running" + existing["data"].update(update) self._bump_event(existing, timestamp=timestamp) def _record_tool_output(self, agent_id: str, item: Any) -> None: diff --git a/tests/test_tui_live_view_tool_events.py b/tests/test_tui_live_view_tool_events.py new file mode 100644 index 00000000..da67a688 --- /dev/null +++ b/tests/test_tui_live_view_tool_events.py @@ -0,0 +1,75 @@ +"""Tool call/output events must stay isolated per agent and never regress +from a terminal status back to "running" on replay. + +Covers usestrix/strix#660: two bugs in TuiLiveView's tool-event bookkeeping. +Bug 1 (events keyed only by call_id, colliding across agents) was already +fixed by fade370 ("fix viewer tool call collisions across agents", #917) - +this file adds the regression test that fix never got. Bug 2 (a replayed +tool_call_item resetting a completed/failed event back to "running") was +still present and is fixed here. +""" + +from __future__ import annotations + +from strix.interface.tui.live_view import TuiLiveView + + +def _call(call_id: str, tool_name: str = "shell") -> dict[str, object]: + return {"call_id": call_id, "tool_name": tool_name, "args": {}} + + +def _output(call_id: str, output: object, tool_name: str = "shell") -> dict[str, object]: + return {"call_id": call_id, "tool_name": tool_name, "output": output} + + +def test_tool_events_do_not_collide_across_agents_sharing_a_call_id() -> None: + view = TuiLiveView() + + view._record_tool_call_data("agent-A", _call("shared-id", tool_name="nmap")) + view._record_tool_call_data("agent-B", _call("shared-id", tool_name="curl")) + + a_events = view.events_for_agent("agent-A") + b_events = view.events_for_agent("agent-B") + + assert len(a_events) == 1 + assert len(b_events) == 1 + assert a_events[0]["data"]["tool_name"] == "nmap" + assert b_events[0]["data"]["tool_name"] == "curl" + + +def test_replayed_tool_call_does_not_revert_completed_status_to_running() -> None: + view = TuiLiveView() + + view._record_tool_call_data("agent-A", _call("id-1")) + view._record_tool_output_data("agent-A", _output("id-1", {"success": True})) + + event = view.events_for_agent("agent-A")[0] + assert event["data"]["status"] == "completed" + + # Duplicate stream event / hydration replay of the same call. + view._record_tool_call_data("agent-A", _call("id-1")) + + replayed = view.events_for_agent("agent-A")[0] + assert replayed["data"]["status"] == "completed" + assert replayed["data"]["result"] == {"success": True} + + +def test_replayed_tool_call_does_not_revert_failed_status_to_running() -> None: + view = TuiLiveView() + + view._record_tool_call_data("agent-A", _call("id-1")) + view._record_tool_output_data("agent-A", _output("id-1", {"success": False, "error": "x"})) + + assert view.events_for_agent("agent-A")[0]["data"]["status"] == "failed" + + view._record_tool_call_data("agent-A", _call("id-1")) + + assert view.events_for_agent("agent-A")[0]["data"]["status"] == "failed" + + +def test_first_tool_call_is_still_recorded_as_running() -> None: + view = TuiLiveView() + + view._record_tool_call_data("agent-A", _call("id-1")) + + assert view.events_for_agent("agent-A")[0]["data"]["status"] == "running"