Files
strix/tests/test_tui_backend_controller.py
b260a4ee38 fix(tui): make the mount prompt clickable, and skip the mount instead of abandoning the scan (#1015)
* make the working-directory prompt answer the mouse

Its Confirm and Cancel were drawn as buttons and did nothing when clicked: the
modal mouse handler had a case for every dialog except this one, so a click fell
through and the scan sat waiting on an answer the user believed they had given.
Only the keyboard could answer it.

The prompt is docked in a corner rather than centered, so it also needs its own
bounds; the centered ones every other dialog uses would have put the buttons in
the wrong place. Those bounds now come from the same placement cornerOverlay
draws with.

Two returns that hand back the model alongside a call that mutates it are now
sequenced explicitly. They work, but only because the compiler happens to
evaluate the call first, and one of them is what puts the prompt back in the
composer when the mount is declined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skip the mount instead of abandoning the scan

Declining the working-directory prompt threw the whole launch away and dropped
back to the start screen, which is a lot to lose for answering one question
about one directory. The two answers are now about the directory alone: mount it,
or run without it. The prompt is the whole of the input either way.

The buttons say which is which - Mount and Skip rather than Confirm and Cancel -
and the prompt says what skipping costs.

A run with neither target nor directory is a real run, so two things follow it.
It can be resumed: its instruction is what drives it, and that is in the run
record. And it tells the agent plainly that it has neither, because an agent
given no scope goes looking for the one it assumes it was meant to have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 22:34:02 +03:00

450 lines
15 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_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