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()