From 46b4e6cb6468a8b57ed107cc0d4acd67434bbe8d Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Wed, 2 Sep 2026 11:27:50 +0000 Subject: [PATCH] fix(tui): run environment and model checks on the no-target start screen The interactive start screen skipped validate_environment() entirely, and a bare prompt sent verify=false so the model preflight never ran. Both kinds of setup launch now verify the model before leaving the start screen, environment validation runs for every mode, and quitting setup without a scan still shows the update notice. --- strix/interface/main.py | 15 +- strix/interface/tui/backend/controller.py | 43 +++-- strix/interface/tui/internal/app/setup.go | 21 +- .../tui/internal/app/setup_prompt_test.go | 28 +-- strix/interface/tui/runtime.py | 64 +++++-- tests/test_go_tui_runtime.py | 179 +++++++++++++----- tests/test_tui_backend_controller.py | 135 +++++++++---- 7 files changed, 331 insertions(+), 154 deletions(-) diff --git a/strix/interface/main.py b/strix/interface/main.py index 107f1bdc..297c4838 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -391,13 +391,10 @@ def _print_model_connection_error(exc: BaseException, model_name: str) -> None: def _bootstrap_scan(args: argparse.Namespace) -> None: """Warm up the model and prepare the run for a non-interactive scan. - Interactive launches only validate the environment here; the model - preflight and run preparation happen inside the TUI so the interface - paints immediately instead of waiting on a model round trip. + Interactive launches skip this: the model preflight and run preparation + happen inside the TUI so the interface paints immediately instead of + waiting on a model round trip. """ - validate_environment() - if not args.non_interactive: - return try: asyncio.run(warm_up_llm(show_model_warning=True)) except ModelConnectionError as exc: @@ -467,10 +464,9 @@ def main() -> None: check_docker_installed() pull_docker_image() + validate_environment() - # In setup mode the TUI collects the target, then runs prepare_run(), - # warm-up, and telemetry itself once the user starts the scan. - if not args.needs_setup: + if args.non_interactive: _bootstrap_scan(args) from strix.report.state import get_global_report_state @@ -511,6 +507,7 @@ def main() -> None: if not args.run_name: # Setup mode where the user quit before starting a scan: nothing ran. + notify_update(Console()) return results_path = run_dir_for(args.run_name) diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py index da9ee34f..6f3b3fb3 100644 --- a/strix/interface/tui/backend/controller.py +++ b/strix/interface/tui/backend/controller.py @@ -36,7 +36,8 @@ if TYPE_CHECKING: _STOPPABLE_AGENT_STATUSES = frozenset({"running", "waiting", "budget_paused"}) ChangeCallback = Callable[[], None] -StartCallback = Callable[[bool], Awaitable[None]] +StartCallback = Callable[[], Awaitable[None]] +VerifyCallback = Callable[[], Awaitable[None]] QuitCallback = Callable[[], Awaitable[None]] @@ -51,6 +52,7 @@ class TuiController: coordinator: Any = None, report_state: ReportState | None = None, on_start: StartCallback | None = None, + on_verify: VerifyCallback | None = None, on_quit: QuitCallback | None = None, on_change: ChangeCallback | None = None, ) -> None: @@ -99,7 +101,6 @@ class TuiController: # A target-less launch enters the live view and asks there before # anything is prepared; this holds the directory awaiting that answer. self.pending_workspace_mount: str | None = None - self._pending_verify = True self.messages: list[dict[str, str]] = [] self._next_message_id = 1 self.error: str | None = None @@ -112,6 +113,7 @@ class TuiController: self.viewer_url: str | None = None self._viewer_httpd: Any = None self._on_start = on_start + self._on_verify = on_verify self._on_quit = on_quit self._on_change = on_change @@ -328,12 +330,6 @@ class TuiController: async def _start(self, payload: dict[str, Any]) -> dict[str, Any]: if self.scan_started or self._start_in_progress: raise RuntimeError("Scan is already starting or running") - # A bare prompt launches optimistically, like a coding agent: it skips - # the network model preflight and surfaces any model error live. A named - # target keeps the preflight so a real scan does not commit blind. - verify = payload.get("verify", True) - if not isinstance(verify, bool): - raise TypeError("verify must be a boolean") # Launching with no target mounts the working directory, so it requires # the user's explicit confirmation rather than happening silently. mount_working_dir = payload.get("mount_working_dir", False) @@ -344,27 +340,44 @@ class TuiController: raise ValueError("No model configured. Set STRIX_LLM first.") if self._on_start is None: raise RuntimeError("Scan start is unavailable") + if not self.targets and not mount_working_dir: + raise ValueError("No target set. Add a target first.") + # The model check runs while still on the start screen, for a bare + # prompt as much as for a named target, so a failure lands in the setup + # log where the user can fix it and retry rather than in a dead run. + await self._verify_model() if not self.targets: - if not mount_working_dir: - raise ValueError("No target set. Add a target first.") # Mounting the working directory needs the user's confirmation, and # that is asked in the live view. Enter it now and prepare nothing # until the answer arrives, so declining leaves no run behind. self.pending_workspace_mount = str(Path.cwd()) - self._pending_verify = verify self.setup_mode = False self.scan_started = True self.scan_state = "preparing" return {"started": True} - await self._begin_scan(verify) + await self._begin_scan() return {"started": True} - async def _begin_scan(self, verify: bool) -> None: + async def _verify_model(self) -> None: + if self._on_verify is None: + return + self._start_in_progress = True + try: + await self._on_verify() + finally: + self._start_in_progress = False + + async def _begin_scan(self) -> None: if self._on_start is None: raise RuntimeError("Scan start is unavailable") self._start_in_progress = True try: - await self._on_start(verify) + await self._on_start() + except Exception as exc: + if not self.setup_mode: + # The live view is already up, so the failure has to show there. + self.fail_preparation(str(exc)) + raise finally: self._start_in_progress = False self.setup_mode = False @@ -384,7 +397,7 @@ class TuiController: # the whole of the input either way; the working directory is only an # extra the agent may look at, so the run goes ahead without one. self.workspace_mount = mount if approved else None - await self._begin_scan(self._pending_verify) + await self._begin_scan() return {"approved": approved} async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]: diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go index a7525fbf..7a02cf34 100644 --- a/strix/interface/tui/internal/app/setup.go +++ b/strix/interface/tui/internal/app/setup.go @@ -45,23 +45,20 @@ func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) { if len(fields) > targets { commands = append(commands, send(m.client, "setup.set_instruction", map[string]any{"instruction": value})) } - // With a target, verify the model connection before the scan commits to it. - // A bare prompt launches optimistically, like a coding agent, and mounts the - // working directory - the backend asks about that from the live view, so the - // prompt is held here in case it is declined. - verify := targets > 0 || len(m.snapshot.Targets) > 0 - payload := map[string]any{"verify": verify} - if verify { - m.setupMsg("Verifying model connection...", render.Col(amber)) - } else { + // The backend verifies the model connection before either kind of launch + // and reports on it through the setup log. A bare prompt mounts the working + // directory - the backend asks about that from the live view, so the prompt + // is held here in case it is declined. + payload := map[string]any{} + if targets == 0 && len(m.snapshot.Targets) == 0 { m.pendingPrompt = value payload["mount_working_dir"] = true } commands = append(commands, send(m.client, "setup.start", payload)) // Ordered, not batched: setup.start leaves setup mode, so it must be the - // last command to reach the backend. Batched sends race, and once the - // preflight is skipped setup.start wins, making the target and instruction - // commands land after the guard closes and fail with a red error. + // last command to reach the backend. Batched sends race, and if setup.start + // wins the target and instruction commands land after the guard closes and + // fail with a red error. return *m, tea.Sequence(commands...) } diff --git a/strix/interface/tui/internal/app/setup_prompt_test.go b/strix/interface/tui/internal/app/setup_prompt_test.go index 63a0170f..7ac2ed82 100644 --- a/strix/interface/tui/internal/app/setup_prompt_test.go +++ b/strix/interface/tui/internal/app/setup_prompt_test.go @@ -94,25 +94,6 @@ func commandTypes(envelopes []protocol.Envelope) []string { return types } -// startVerify returns the verify flag on the setup.start command, and whether -// a setup.start command was present at all. -func startVerify(t *testing.T, envelopes []protocol.Envelope) (verify, found bool) { - t.Helper() - for _, envelope := range envelopes { - if envelope.Type != "setup.start" { - continue - } - var payload struct { - Verify bool `json:"verify"` - } - if err := json.Unmarshal(envelope.Payload, &payload); err != nil { - t.Fatal(err) - } - return payload.Verify, true - } - return false, false -} - func contains(values []string, want string) bool { for _, value := range values { if value == want { @@ -160,10 +141,6 @@ func TestSetupPromptWithoutTargetLaunchesAndRequestsMount(t *testing.T) { if mount, found := startPayloadFlag(t, envelopes, "mount_working_dir"); !found || !mount { t.Fatalf("mount was not requested: mount_working_dir=%v found=%v", mount, found) } - // A bare prompt launches optimistically: no model preflight. - if verify, found := startVerify(t, envelopes); !found || verify { - t.Fatalf("bare prompt should launch with verify=false, got verify=%v found=%v", verify, found) - } // setup.start leaves setup mode, so it must be the last command sent. if start, instr := firstIndex(types, "setup.start"), lastIndex(types, "setup.set_instruction"); start < instr { t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types) @@ -273,9 +250,8 @@ func TestSetupPromptWithTargetLaunches(t *testing.T) { t.Fatalf("missing %s in %v", want, types) } } - // A named target keeps the upfront model check. - if verify, found := startVerify(t, envelopes); !found || !verify { - t.Fatalf("targeted prompt should launch with verify=true, got verify=%v found=%v", verify, found) + if _, found := startPayloadFlag(t, envelopes, "mount_working_dir"); found { + t.Fatalf("a targeted prompt must not ask to mount the working directory: %v", types) } // The target and instruction must reach the backend before setup.start // closes the setup guard. diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index 8e8ebb89..728e7023 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -63,11 +63,14 @@ class GoTuiRuntime: self.scan_error: BaseException | None = None self._last_sync_fingerprint = "" self._error_noted_agents: set[str] = set() + self.model_verified = False + self._setup_preflight: asyncio.Task[None] | None = None self.controller = TuiController( args, live_view=self.live_view, coordinator=self.coordinator, on_start=self.start_from_setup, + on_verify=self.ensure_model_verified, on_quit=self.quit, ) self.server = TuiBackendServer(self.controller) @@ -107,7 +110,51 @@ class GoTuiRuntime: ) self.controller.notify_changed() - async def start_from_setup(self, verify: bool = True) -> None: + async def check_setup_model(self) -> None: + """Verify the model route as soon as the start screen is up. + + The same round trip a direct launch makes in prepare_and_start, run in + the background so the screen paints first and the outcome lands in the + setup log before the user has finished typing. + """ + if not (load_settings().llm.model or "").strip(): + return + try: + await self._preflight_model() + except Exception as exc: + logger.exception("Go TUI setup model preflight failed") + self.controller.add_message(f"Model connection failed: {exc}", "error") + return + self.controller.add_message("Model connection verified") + + async def ensure_model_verified(self) -> None: + """Hold a setup launch until the model has answered once.""" + preflight = self._setup_preflight + if preflight is not None and not preflight.done(): + await asyncio.shield(preflight) + if self.model_verified: + return + try: + await self._preflight_model() + except Exception as exc: + logger.exception("Go TUI setup model preflight failed") + raise RuntimeError(f"Model connection failed: {exc}") from exc + + async def _preflight_model(self) -> None: + model = (load_settings().llm.model or "").strip() + self.controller.add_message("Verifying model connection...") + await preflight_model_connection(model) + self.model_verified = True + + def _start_preparation(self) -> asyncio.Task[None]: + """Kick off the work that runs behind the freshly painted TUI.""" + if self.controller.setup_mode: + self._setup_preflight = asyncio.create_task(self.check_setup_model()) + return self._setup_preflight + self.controller.begin_preparation() + return asyncio.create_task(self.prepare_and_start()) + + async def start_from_setup(self) -> None: candidate = deepcopy(self.args) candidate.scan_mode = self.controller.scan_mode candidate.instruction = self.controller.instruction @@ -124,16 +171,7 @@ class GoTuiRuntime: if isinstance(target, dict) and target.get("original") ] targets_changed = self.controller.targets != existing_targets - model = (load_settings().llm.model or "").strip() - # A bare prompt launches optimistically: it skips the network preflight - # and lets any model error surface once the agent starts, like a coding - # agent. A named target keeps the upfront check. - if verify: - try: - await preflight_model_connection(model) - except Exception as exc: - logger.exception("Go TUI setup model preflight failed") - raise RuntimeError(f"Model connection failed: {exc}") from exc + persist_current() # A confirmed target-less launch mounts the working directory for the # agent to work in, without making it a scan target. candidate.workspace_mount = self.controller.workspace_mount @@ -376,9 +414,7 @@ class GoTuiRuntime: ) process, backend_socket = await launch_tui_process(command, env, cwd) await self.server.start(backend_socket) - if not self.controller.setup_mode: - self.controller.begin_preparation() - prepare_task = asyncio.create_task(self.prepare_and_start()) + prepare_task = self._start_preparation() sync_task = asyncio.create_task(self.sync_state()) return_code = await wait_process(process) check_return_code(return_code) diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py index a1cb6369..05f81ece 100644 --- a/tests/test_go_tui_runtime.py +++ b/tests/test_go_tui_runtime.py @@ -343,15 +343,19 @@ async def test_setup_preflights_model_before_starting( assert candidate.scope_mode == "diff" assert candidate.diff_base == "origin/main" + monkeypatch.setattr(go_tui, "persist_current", lambda: calls.append("persist")) monkeypatch.setattr(go_tui, "build_targets_info", build) monkeypatch.setattr(go_tui, "prepare_run", prepare) monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + # The controller runs these two in turn for every setup launch. + await runtime.ensure_model_verified() await runtime.start_from_setup() - assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"] + # The same steps, in the same order, as a direct launch's prepare_and_start. + assert calls == ["preflight", "persist", "targets", "prepare", "telemetry", "state", "scan"] assert runtime.args.scan_mode == "quick" assert runtime.args.instruction == "" assert runtime.args.max_budget_usd == 8.5 @@ -360,35 +364,138 @@ async def test_setup_preflights_model_before_starting( assert runtime.args.diff_base == "origin/main" +def _setup_model( + monkeypatch: pytest.MonkeyPatch, model: str | None = "openrouter/test-model" +) -> None: + monkeypatch.setattr( + go_tui, + "load_settings", + lambda: SimpleNamespace(llm=SimpleNamespace(model=model)), + ) + + +def _setup_messages(runtime: GoTuiRuntime) -> list[tuple[str, str]]: + return [(message["level"], message["text"]) for message in runtime.controller.messages] + + @pytest.mark.asyncio -async def test_optimistic_setup_skips_model_preflight( +async def test_setup_model_check_reports_success_in_the_setup_log( monkeypatch: pytest.MonkeyPatch, ) -> None: runtime = GoTuiRuntime(args()) - runtime.controller.targets = [str(Path.cwd())] + calls: list[str] = [] + + async def preflight(model: str) -> None: + calls.append(model) + + _setup_model(monkeypatch) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + + await runtime.check_setup_model() + + assert calls == ["openrouter/test-model"] + assert runtime.model_verified is True + assert _setup_messages(runtime) == [ + ("info", "Verifying model connection..."), + ("info", "Model connection verified"), + ] + + +@pytest.mark.asyncio +async def test_setup_model_check_reports_failure_without_leaving_setup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + + async def preflight(_model: str) -> None: + raise TimeoutError("connection timed out") + + _setup_model(monkeypatch) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + + await runtime.check_setup_model() + + assert runtime.model_verified is False + assert runtime.controller.setup_mode is True + assert runtime.controller.scan_state == "setup" + assert _setup_messages(runtime)[-1] == ( + "error", + "Model connection failed: connection timed out", + ) + + +@pytest.mark.asyncio +async def test_setup_model_check_waits_for_a_configured_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + + _setup_model(monkeypatch, model=None) + monkeypatch.setattr( + go_tui, + "preflight_model_connection", + lambda _model: pytest.fail("nothing to check without a model"), + ) + + await runtime.check_setup_model() + + assert runtime.model_verified is False + assert runtime.controller.messages == [] + + +@pytest.mark.asyncio +async def test_ensure_model_verified_reuses_the_startup_check( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + release = asyncio.Event() calls: list[str] = [] async def preflight(_model: str) -> None: calls.append("preflight") + await release.wait() - monkeypatch.setattr( - go_tui, - "load_settings", - lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), - ) + _setup_model(monkeypatch) monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) - monkeypatch.setattr(go_tui, "build_targets_info", lambda _args, **_kw: calls.append("targets")) - monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare")) - monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) - monkeypatch.setattr(runtime, "init_run_state", lambda: calls.append("state")) - monkeypatch.setattr(runtime, "start_scan", lambda: calls.append("scan")) + runtime._setup_preflight = asyncio.create_task(runtime.check_setup_model()) + await asyncio.sleep(0) - await runtime.start_from_setup(verify=False) + # A launch that arrives mid-check waits for it rather than racing a second + # round trip. + ensure = asyncio.create_task(runtime.ensure_model_verified()) + await asyncio.sleep(0) + assert not ensure.done() + release.set() + await ensure - # No preflight: the scan launches straight through and any model error - # surfaces once the agent runs. - assert "preflight" not in calls - assert calls == ["targets", "prepare", "telemetry", "state", "scan"] + assert calls == ["preflight"] + assert runtime.model_verified is True + + +@pytest.mark.asyncio +async def test_ensure_model_verified_retries_after_a_failed_startup_check( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = GoTuiRuntime(args()) + outcomes = iter([TimeoutError("connection timed out"), None]) + calls: list[str] = [] + + async def preflight(_model: str) -> None: + calls.append("preflight") + outcome = next(outcomes) + if outcome is not None: + raise outcome + + _setup_model(monkeypatch) + monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + + await runtime.check_setup_model() + assert runtime.model_verified is False + + await runtime.ensure_model_verified() + + assert calls == ["preflight", "preflight"] + assert runtime.model_verified is True @pytest.mark.asyncio @@ -400,15 +507,8 @@ async def test_confirmed_target_less_launch_mounts_workspace_without_targets( runtime.controller.workspace_mount = str(Path.home()) prepared: list[argparse.Namespace] = [] - async def preflight(_model: str) -> None: - return None - - monkeypatch.setattr( - go_tui, - "load_settings", - lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), - ) - monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + _setup_model(monkeypatch) + monkeypatch.setattr(go_tui, "persist_current", lambda: None) monkeypatch.setattr( go_tui, "build_targets_info", @@ -419,7 +519,7 @@ async def test_confirmed_target_less_launch_mounts_workspace_without_targets( monkeypatch.setattr(runtime, "init_run_state", lambda: None) monkeypatch.setattr(runtime, "start_scan", lambda: None) - await runtime.start_from_setup(verify=False) + await runtime.start_from_setup() assert prepared[0].workspace_mount == str(Path.home()) assert prepared[0].targets_info == [] @@ -442,15 +542,8 @@ async def test_setup_preserves_prepared_cli_targets( runtime = GoTuiRuntime(runtime_args) calls: list[str] = [] - async def preflight(_model: str) -> None: - calls.append("preflight") - - monkeypatch.setattr( - go_tui, - "load_settings", - lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), - ) - monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + _setup_model(monkeypatch) + monkeypatch.setattr(go_tui, "persist_current", lambda: calls.append("persist")) monkeypatch.setattr( go_tui, "build_targets_info", @@ -465,7 +558,7 @@ async def test_setup_preserves_prepared_cli_targets( assert runtime.controller.targets == ["https://example.com"] assert runtime.args.targets_info[0]["type"] == "web" - assert calls == ["preflight", "prepare", "telemetry", "state", "scan"] + assert calls == ["persist", "prepare", "telemetry", "state", "scan"] @pytest.mark.asyncio @@ -798,19 +891,17 @@ async def test_setup_preflight_failure_does_not_start_scan( nonlocal started started = True - monkeypatch.setattr( - go_tui, - "load_settings", - lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")), - ) + _setup_model(monkeypatch) monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) + monkeypatch.setattr(go_tui, "persist_current", mark_started) monkeypatch.setattr(go_tui, "build_targets_info", mark_started) monkeypatch.setattr(runtime, "init_run_state", mark_started) monkeypatch.setattr(runtime, "start_scan", mark_started) with pytest.raises(RuntimeError, match="Model connection failed: 401 Unauthorized"): - await runtime.start_from_setup() + await runtime.ensure_model_verified() + assert runtime.model_verified is False assert started is False assert runtime.scan_task is None diff --git a/tests/test_tui_backend_controller.py b/tests/test_tui_backend_controller.py index 078a0324..3a28d020 100644 --- a/tests/test_tui_backend_controller.py +++ b/tests/test_tui_backend_controller.py @@ -155,7 +155,7 @@ def test_setup_restores_prepared_cli_targets() -> None: async def test_start_validates_model_before_callback() -> None: started = False - async def start(_verify: bool = True) -> None: + async def start() -> None: nonlocal started started = True @@ -170,7 +170,7 @@ async def test_start_validates_model_before_callback() -> None: async def test_start_launches_with_a_configured_model() -> None: started = False - async def start(_verify: bool = True) -> None: + async def start() -> None: nonlocal started started = True @@ -189,7 +189,7 @@ async def test_start_launches_with_a_configured_model() -> None: async def test_start_without_target_requires_mount_consent() -> None: started = False - async def start(_verify: bool = True) -> None: + async def start() -> None: nonlocal started started = True @@ -200,7 +200,7 @@ async def test_start_without_target_requires_mount_consent() -> None: # Mounting the working directory is never silent. with pytest.raises(ValueError, match="No target set"): - await controller.handle("setup.start", {"verify": False}) + await controller.handle("setup.start", {}) assert started is False assert controller.targets == [] assert controller.workspace_mount is None @@ -211,7 +211,7 @@ async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> N """Nothing is prepared until the live-view confirmation is answered.""" started = False - async def start(_verify: bool = True) -> None: + async def start() -> None: nonlocal started started = True @@ -220,7 +220,7 @@ async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> N loader._cached = None controller = TuiController(args(), on_start=start) - result = await controller.handle("setup.start", {"verify": False, "mount_working_dir": True}) + result = await controller.handle("setup.start", {"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 @@ -236,26 +236,23 @@ async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> N @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 + async def start() -> None: + nonlocal started 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}) + await controller.handle("setup.start", {"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 + # Mounted as a workspace: the scan genuinely has no target, so the + # instruction is the only source of truth. assert controller.workspace_mount == str(Path.cwd()) assert controller.targets == [] assert controller.scan_state == "running" @@ -264,22 +261,23 @@ async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None: @pytest.mark.asyncio async def test_declining_the_mount_runs_without_one() -> None: - started: list[bool] = [] + started = 0 - async def start(verify: bool = True) -> None: - started.append(verify) + async def start() -> None: + nonlocal started + started += 1 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}) + await controller.handle("setup.start", {"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 started == 1 assert controller.workspace_mount is None assert controller.pending_workspace_mount is None assert controller.setup_mode is False @@ -289,21 +287,22 @@ async def test_declining_the_mount_runs_without_one() -> None: @pytest.mark.asyncio async def test_approving_the_mount_runs_with_it() -> None: - started: list[bool] = [] + started = 0 - async def start(verify: bool = True) -> None: - started.append(verify) + async def start() -> None: + nonlocal started + started += 1 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}) + await controller.handle("setup.start", {"mount_working_dir": True}) result = await controller.handle("setup.confirm_mount", {"approved": True}) assert result == {"approved": True} - assert started == [False] + assert started == 1 assert controller.workspace_mount == str(Path.cwd()) assert controller.scan_state == "running" @@ -352,23 +351,91 @@ async def test_user_message_updates_live_agent_projection_immediately() -> None: @pytest.mark.asyncio -async def test_start_forwards_verify_flag_by_default() -> None: - seen_verify: bool | None = None +async def test_start_verifies_the_model_before_a_targeted_launch() -> None: + order: list[str] = [] - async def start(verify: bool = True) -> None: - nonlocal seen_verify - seen_verify = verify + async def verify() -> None: + order.append("verify") + + async def start() -> None: + order.append("start") + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start, on_verify=verify) + await controller.handle("setup.add_target", {"target": "https://example.com"}) + + await controller.handle("setup.start", {}) + + assert order == ["verify", "start"] + + +@pytest.mark.asyncio +async def test_start_verifies_the_model_before_a_bare_prompt_leaves_setup() -> None: + """A bare prompt gets the same model check as a named target, while the + setup log is still on screen to show the outcome.""" + verified = 0 + + async def verify() -> None: + nonlocal verified + verified += 1 + + async def start() -> None: + return None + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start, on_verify=verify) + + await controller.handle("setup.start", {"mount_working_dir": True}) + + assert verified == 1 + assert controller.setup_mode is False + assert controller.pending_workspace_mount == str(Path.cwd()) + + +@pytest.mark.asyncio +async def test_failed_model_check_keeps_the_start_screen() -> None: + async def verify() -> None: + raise RuntimeError("Model connection failed: timed out") + + async def start() -> None: + pytest.fail("the scan must not start when the model check fails") + + os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4" + os.environ["ANTHROPIC_API_KEY"] = "test-key" + loader._cached = None + controller = TuiController(args(), on_start=start, on_verify=verify) + + with pytest.raises(RuntimeError, match="Model connection failed"): + await controller.handle("setup.start", {"mount_working_dir": True}) + + # Still on the start screen, so the error lands in the setup log and the + # user can retry; no run was prepared behind a stuck live view. + assert controller.setup_mode is True + assert controller.scan_started is False + assert controller.scan_state == "setup" + assert controller.pending_workspace_mount is None + + +@pytest.mark.asyncio +async def test_confirmed_mount_launch_failure_is_reported_in_the_live_view() -> None: + async def start() -> None: + raise ValueError("Scan preparation failed") 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"}) + await controller.handle("setup.start", {"mount_working_dir": True}) - # A named target keeps the upfront model check. - await controller.handle("setup.start", {}) + with pytest.raises(ValueError, match="Scan preparation failed"): + await controller.handle("setup.confirm_mount", {"approved": True}) - assert seen_verify is True + assert controller.scan_state == "failed" + assert controller.error == "Scan preparation failed" @pytest.mark.asyncio @@ -376,7 +443,7 @@ async def test_start_rejects_concurrent_and_repeated_submissions() -> None: entered = asyncio.Event() release = asyncio.Event() - async def start(_verify: bool = True) -> None: + async def start() -> None: entered.set() await release.wait()