Files
strix/tests/test_tui_backend_controller.py
T
yoni d4573a3197 fix(tui): show the actual subscription provider in the Go TUI
The Go TUI rewrite hardcoded "ChatGPT subscription" in the stats view and its
snapshot only carried a boolean, so a Grok run was labeled ChatGPT — the same
bug previously fixed on the Python side. Carry the provider label through the
snapshot protocol and render it, falling back to a generic "Subscription".

utils._subscription_label becomes public subscription_label since the TUI
backend now needs it too.
2026-08-13 03:26:22 +00:00

472 lines
16 KiB
Python

from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import pytest
from strix.config import apply_config_override, loader
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.interface.tui.backend.controller import TuiController
def args() -> argparse.Namespace:
return argparse.Namespace(
needs_setup=True,
targets_info=[],
instruction=None,
scan_mode="deep",
max_budget_usd=None,
max_turns=DEFAULT_MAX_TURNS,
scope_mode="auto",
diff_base=None,
local_sources=[],
diff_scope={"active": False},
user_explicit_instruction=None,
run_name=None,
)
@pytest.fixture(autouse=True)
def isolated_config(tmp_path: Path) -> None:
for key in (
"STRIX_LLM",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"LLM_API_KEY",
"LLM_API_BASE",
"AZURE_API_KEY",
"AZURE_API_BASE",
"AZURE_API_VERSION",
):
os.environ.pop(key, None)
apply_config_override(tmp_path / "config.json")
@pytest.mark.asyncio
async def test_setup_state_is_serializable() -> None:
controller = TuiController(args())
await controller.handle("setup.add_target", {"target": "https://example.com"})
await controller.handle("setup.set_instruction", {"instruction": "focus on auth"})
snapshot = controller.snapshot()
assert snapshot["targets"] == ["https://example.com"]
assert snapshot["instruction"] == "focus on auth"
assert snapshot["scan_state"] == "setup"
assert snapshot["scan_mode"] == "deep"
assert snapshot["max_budget_usd"] is None
assert snapshot["max_turns"] == 500
assert snapshot["scope_mode"] == "auto"
assert snapshot["diff_base"] is None
@pytest.mark.asyncio
async def test_setup_instruction_starts_from_cli_and_can_be_cleared() -> None:
setup_args = args()
setup_args.instruction = " CLI instruction "
controller = TuiController(setup_args)
assert controller.snapshot()["instruction"] == "CLI instruction"
result = await controller.handle("setup.set_instruction", {"instruction": ""})
assert result == {"instruction": ""}
assert controller.snapshot()["instruction"] == ""
@pytest.mark.asyncio
async def test_setup_controls_reject_changes_after_start() -> None:
controller = TuiController(args())
controller.setup_mode = False
controller.scan_started = True
with pytest.raises(RuntimeError, match="can no longer be changed"):
await controller.handle("setup.add_target", {"target": "https://example.com"})
@pytest.mark.asyncio
async def test_large_target_list_reports_truncated_snapshot_count() -> None:
controller = TuiController(args())
for index in range(20):
await controller.handle("setup.add_target", {"target": f"https://target-{index}.example"})
added = await controller.handle("setup.add_target", {"target": "https://last.example"})
snapshot = controller.snapshot()
assert added == {"target": "https://last.example", "total": 21}
assert snapshot["target_count"] == 21
# The snapshot only carries a bounded prefix of the list.
assert len(snapshot["targets"]) == 16
def test_state_populates_model_warning_for_non_frontier_model() -> None:
os.environ["STRIX_LLM"] = "openai/gpt-3.5-turbo"
loader._cached = None
warning = TuiController(args()).snapshot()["model_warning"]
assert "openai/gpt-3.5-turbo" in warning
assert "not a recommended frontier model" in warning
def test_snapshot_carries_the_subscription_provider_label() -> None:
os.environ["STRIX_LLM"] = "grok/grok-4"
loader._cached = None
snapshot = TuiController(args()).snapshot()
# The TUI renders this label, so it must name the actual provider rather
# than assuming ChatGPT.
assert snapshot["subscription"] is True
assert snapshot["subscription_label"] == "Grok subscription"
def test_snapshot_has_no_subscription_label_for_api_key_runs() -> None:
os.environ["STRIX_LLM"] = "openai/gpt-5.4"
loader._cached = None
snapshot = TuiController(args()).snapshot()
assert snapshot["subscription"] is False
assert snapshot["subscription_label"] == ""
def test_setup_restores_prepared_cli_targets() -> None:
setup_args = args()
setup_args.targets_info = [
{"type": "web", "details": {}, "original": "https://example.com"},
{"type": "local_code", "details": {}, "original": "/workspace/source"},
]
controller = TuiController(setup_args)
assert controller.snapshot()["targets"] == ["https://example.com", "/workspace/source"]
@pytest.mark.asyncio
async def test_start_validates_model_before_callback() -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
with pytest.raises(ValueError, match="No model configured"):
await controller.handle("setup.start", {})
assert started is False
@pytest.mark.asyncio
async def test_start_launches_with_a_configured_model() -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
result = await controller.handle("setup.start", {})
assert result == {"started": True}
assert started is True
@pytest.mark.asyncio
async def test_start_without_target_requires_mount_consent() -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
# Mounting the working directory is never silent.
with pytest.raises(ValueError, match="No target set"):
await controller.handle("setup.start", {"verify": False})
assert started is False
assert controller.targets == []
assert controller.workspace_mount is None
@pytest.mark.asyncio
async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> None:
"""Nothing is prepared until the live-view confirmation is answered."""
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
result = await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
assert result == {"started": True}
# The live view is up so the prompt can be shown there, but the scan has not
# been prepared and nothing is mounted yet.
assert started is False
assert controller.setup_mode is False
assert controller.scan_state == "preparing"
assert controller.pending_workspace_mount == str(Path.cwd())
assert controller.workspace_mount is None
assert controller.snapshot()["pending_mount"] == str(Path.cwd())
@pytest.mark.asyncio
async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None:
started = False
seen_verify: bool | None = None
async def start(verify: bool = True) -> None:
nonlocal started, seen_verify
started = True
seen_verify = verify
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
result = await controller.handle("setup.confirm_mount", {"approved": True})
assert result == {"approved": True}
assert started is True
# Launched optimistically, and mounted as a workspace: the scan genuinely
# has no target, so the instruction is the only source of truth.
assert seen_verify is False
assert controller.workspace_mount == str(Path.cwd())
assert controller.targets == []
assert controller.scan_state == "running"
assert controller.snapshot()["pending_mount"] == ""
@pytest.mark.asyncio
async def test_declining_the_mount_runs_without_one() -> None:
started: list[bool] = []
async def start(verify: bool = True) -> None:
started.append(verify)
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
result = await controller.handle("setup.confirm_mount", {"approved": False})
assert result == {"approved": False}
# Declining skips the directory; it does not abandon the scan.
assert started == [False]
assert controller.workspace_mount is None
assert controller.pending_workspace_mount is None
assert controller.setup_mode is False
assert controller.scan_started is True
assert controller.scan_state == "running"
@pytest.mark.asyncio
async def test_approving_the_mount_runs_with_it() -> None:
started: list[bool] = []
async def start(verify: bool = True) -> None:
started.append(verify)
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
result = await controller.handle("setup.confirm_mount", {"approved": True})
assert result == {"approved": True}
assert started == [False]
assert controller.workspace_mount == str(Path.cwd())
assert controller.scan_state == "running"
@pytest.mark.asyncio
async def test_confirm_mount_requires_a_pending_request() -> None:
controller = TuiController(args())
with pytest.raises(RuntimeError, match="No mount confirmation is pending"):
await controller.handle("setup.confirm_mount", {"approved": True})
def test_snapshot_exposes_working_directory() -> None:
controller = TuiController(args())
assert controller.snapshot()["working_dir"] == str(Path.cwd())
assert controller.snapshot()["pending_mount"] == ""
@pytest.mark.asyncio
async def test_start_forwards_verify_flag_by_default() -> None:
seen_verify: bool | None = None
async def start(verify: bool = True) -> None:
nonlocal seen_verify
seen_verify = verify
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
# A named target keeps the upfront model check.
await controller.handle("setup.start", {})
assert seen_verify is True
@pytest.mark.asyncio
async def test_start_rejects_concurrent_and_repeated_submissions() -> None:
entered = asyncio.Event()
release = asyncio.Event()
async def start(_verify: bool = True) -> None:
entered.set()
await release.wait()
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
first_start = asyncio.create_task(controller.handle("setup.start", {}))
await entered.wait()
with pytest.raises(RuntimeError, match="already starting or running"):
await controller.handle("setup.start", {})
release.set()
await first_start
with pytest.raises(RuntimeError, match="already starting or running"):
await controller.handle("setup.start", {})
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "failed", "crashed", "stopped"])
async def test_stop_rejects_terminal_agents(status: str) -> None:
class Coordinator:
def __init__(self) -> None:
self.calls: list[str] = []
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
self.calls.append(agent_id)
return True
coordinator = Coordinator()
controller = TuiController(args(), coordinator=coordinator)
controller.set_runtime(scan_loop=asyncio.get_running_loop())
controller.live_view.upsert_agent("agent-1", name="Agent", status=status)
with pytest.raises(RuntimeError, match=f"cannot be stopped while {status}"):
await controller.handle("agent.stop", {"agent_id": "agent-1"})
assert coordinator.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["running", "waiting", "budget_paused"])
async def test_stop_allows_active_agents(status: str) -> None:
class Coordinator:
def __init__(self) -> None:
self.calls: list[str] = []
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
self.calls.append(agent_id)
return True
coordinator = Coordinator()
controller = TuiController(args(), coordinator=coordinator)
controller.set_runtime(scan_loop=asyncio.get_running_loop())
controller.live_view.upsert_agent("agent-1", name="Agent", status=status)
result = await controller.handle("agent.stop", {"agent_id": "agent-1"})
assert result == {"stopped": True}
assert coordinator.calls == ["agent-1"]
@pytest.mark.asyncio
async def test_stop_handles_coordinator_rejection_after_stale_active_projection() -> None:
class Coordinator:
async def cancel_descendants_graceful(self, _agent_id: str) -> bool:
return False
controller = TuiController(args(), coordinator=Coordinator())
controller.set_runtime(scan_loop=asyncio.get_running_loop())
controller.live_view.upsert_agent("agent-1", name="Agent", status="running")
with pytest.raises(RuntimeError, match="no longer active"):
await controller.handle("agent.stop", {"agent_id": "agent-1"})
@pytest.mark.asyncio
async def test_unknown_command_is_rejected() -> None:
controller = TuiController(args())
with pytest.raises(ValueError, match="Unknown command"):
await controller.handle("nope", {})
def test_messages_are_sanitized_and_agents_are_collection_only() -> None:
controller = TuiController(args())
controller.add_message("replace\x1b]52;c;Y2xpcA==\x07 key\x85")
for index in range(40):
controller.live_view.upsert_agent(f"agent-{index}", name=f"Agent {index}")
snapshot = controller.snapshot()
assert "agents" not in snapshot
assert [message["text"] for message in snapshot["messages"]] == ["replace key"]
assert len(controller.collection("agents")) == 40
@pytest.mark.asyncio
async def test_existing_viewer_is_reopened_and_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[str] = []
class ViewerServer:
shutdown_called = False
close_called = False
def shutdown(self) -> None:
self.shutdown_called = True
def server_close(self) -> None:
self.close_called = True
controller = TuiController(args())
controller.viewer_status = "running"
controller.viewer_url = "http://127.0.0.1:1234/?token=test"
server = ViewerServer()
controller._viewer_httpd = server
monkeypatch.setattr("strix.interface.tui.backend.controller.webbrowser.open", opened.append)
result = await controller.handle("viewer.open", {})
controller.close_viewer()
assert result == {"status": "running", "url": controller.viewer_url}
assert opened == [controller.viewer_url]
assert server.shutdown_called is True
assert server.close_called is True