Compare commits

..
Author SHA1 Message Date
bearsyankees a7c85ac9ba fix viewer tool call collisions across agents 2026-07-27 18:38:04 -04:00
5 changed files with 84 additions and 39 deletions
+1 -2
View File
@@ -15,7 +15,6 @@ from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
from pygments.token import _TokenType
from textual.timer import Timer
from rich.align import Align
@@ -353,7 +352,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
if not token_value:
continue
color = None
tt: _TokenType | None = token_type
tt = token_type
while tt:
if tt in colors:
color = colors[tt]
+7 -5
View File
@@ -20,7 +20,7 @@ class TuiLiveView:
self.events: list[dict[str, Any]] = []
self._next_event_id = 1
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
def hydrate_from_run_dir(self, run_dir: Path) -> None:
state_dir = runtime_state_dir(run_dir)
@@ -223,7 +223,8 @@ class TuiLiveView:
timestamp: str | None = None,
) -> None:
call_id = call["call_id"]
existing = self._tool_event_by_call_id.get(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"],
@@ -233,7 +234,7 @@ class TuiLiveView:
}
if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
self._tool_event_by_call_id[call_id] = event
self._tool_event_by_agent_and_call_id[event_key] = event
else:
existing["data"].update(tool_data)
self._bump_event(existing, timestamp=timestamp)
@@ -249,7 +250,8 @@ class TuiLiveView:
timestamp: str | None = None,
) -> None:
call_id = output["call_id"]
event = self._tool_event_by_call_id.get(call_id)
event_key = (agent_id, call_id)
event = self._tool_event_by_agent_and_call_id.get(event_key)
if event is None:
event = self._append_event(
agent_id,
@@ -263,7 +265,7 @@ class TuiLiveView:
},
timestamp=timestamp,
)
self._tool_event_by_call_id[call_id] = event
self._tool_event_by_agent_and_call_id[event_key] = event
result = _parse_json_value(output["output"])
event["data"]["result"] = result
+6 -11
View File
@@ -1,7 +1,7 @@
"""Update notifications and self-update for the strix CLI.
Follows the pattern used by tools like gh, uv, and pip: a background,
rate-limited (once per hour) check against the release source, a cached
rate-limited (once per 24h) check against the release source, a cached
result in ``~/.strix``, a non-intrusive notice with the upgrade command
for the detected install method, and a ``strix --update`` self-update
path for the standalone binary install.
@@ -37,9 +37,8 @@ logger = logging.getLogger(__name__)
GITHUB_REPO = "usestrix/strix"
PYPI_PACKAGE = "strix-agent"
CHECK_INTERVAL_SECONDS = 60 * 60
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
REQUEST_TIMEOUT_SECONDS = 5
PROMPT_JOIN_TIMEOUT_SECONDS = 3.0
_CACHE_PATH = Path.home() / ".strix" / "update-check.json"
@@ -176,7 +175,7 @@ def _refresh_cache() -> None:
def start_background_check() -> None:
"""Refresh the cached latest-version info in a daemon thread (at most once per hour)."""
"""Refresh the cached latest-version info in a daemon thread (at most once per 24h)."""
global _background_thread # noqa: PLW0603
if _is_disabled():
return
@@ -188,16 +187,12 @@ def start_background_check() -> None:
_background_thread.start()
def get_available_update(
*,
respect_skip: bool = True,
join_timeout: float = 0.2,
) -> str | None:
def get_available_update(*, respect_skip: bool = True) -> str | None:
"""Return the newer version from the cache, or None if up to date / unknown."""
if _is_disabled():
return None
if _background_thread is not None:
_background_thread.join(timeout=join_timeout)
_background_thread.join(timeout=0.2)
cache = _read_cache()
latest = cache.get("latest_version")
current = get_version()
@@ -244,7 +239,7 @@ def prompt_update_if_available(console: Console) -> bool:
Returns True if strix was updated (caller should re-exec / exit).
"""
latest = get_available_update(join_timeout=PROMPT_JOIN_TIMEOUT_SECONDS)
latest = get_available_update()
if not latest or not sys.stdin.isatty() or not sys.stdout.isatty():
return False
console.print()
-21
View File
@@ -132,27 +132,6 @@ def test_write_cache_preserves_existing_fields() -> None:
assert cache == {"latest_version": "1.2.3", "checked_at": 123.0, "skipped_version": "9.9.9"}
def test_prompt_join_waits_for_fresh_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
update_check._CACHE_PATH.write_text(
json.dumps({"latest_version": "1.0.0", "checked_at": time.time() - 2 * 60 * 60})
)
monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0")
def slow_fetch() -> str:
time.sleep(0.5)
return "9.9.9"
monkeypatch.setattr(update_check, "_fetch_latest_version", slow_fetch)
update_check.start_background_check()
assert update_check.get_available_update(join_timeout=0.0) is None
assert (
update_check.get_available_update(
join_timeout=update_check.PROMPT_JOIN_TIMEOUT_SECONDS,
)
== "9.9.9"
)
def test_get_upgrade_command_all_methods() -> None:
assert update_check.get_upgrade_command("binary") == "strix --update"
assert update_check.get_upgrade_command("pipx") == "pipx upgrade strix-agent"
+70
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import sqlite3
import urllib.error
import urllib.request
from typing import TYPE_CHECKING
@@ -86,6 +87,75 @@ def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
assert state["events"] == []
def test_build_run_state_keeps_same_call_id_separate_per_agent(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "tools", status="completed", end_time=None)
agents_db = run_dir / ".state" / "agents.db"
rows = [
(
"root",
{
"type": "function_call",
"call_id": "exec_command_0",
"name": "exec_command",
"arguments": json.dumps({"cmd": "echo root"}),
},
),
(
"root",
{
"type": "function_call_output",
"call_id": "exec_command_0",
"output": json.dumps({"success": True, "output": "root"}),
},
),
(
"child",
{
"type": "function_call",
"call_id": "exec_command_0",
"name": "exec_command",
"arguments": json.dumps({"cmd": "echo child"}),
},
),
(
"child",
{
"type": "function_call_output",
"call_id": "exec_command_0",
"output": json.dumps({"success": True, "output": "child"}),
},
),
]
with sqlite3.connect(agents_db) as conn:
conn.execute(
"""
create table agent_messages (
id integer primary key,
session_id text not null,
message_data text not null,
created_at text not null
)
"""
)
conn.executemany(
"""
insert into agent_messages (session_id, message_data, created_at)
values (?, ?, '2026-01-01T00:00:00+00:00')
""",
[(agent_id, json.dumps(message)) for agent_id, message in rows],
)
state = build_run_state(run_dir)
tools = [event for event in state["events"] if event["type"] == "tool"]
assert len(tools) == 2
by_agent = {event["agent_id"]: event for event in tools}
assert by_agent["root"]["data"]["args"] == {"cmd": "echo root"}
assert by_agent["root"]["data"]["result"]["output"] == "root"
assert by_agent["child"]["data"]["args"] == {"cmd": "echo child"}
assert by_agent["child"]["data"]["result"]["output"] == "child"
def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]:
headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server