fix(cli): verify the model before the TUI opens on a direct launch

This commit is contained in:
Ahmed Allam 2026-10-04 02:14:47 +00:00 • committed by Ahmed Allam
parent d218c29ed1
commit 5325009f65
5 changed files with 72 additions and 72 deletions

View file

@ -392,11 +392,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.
"""Warm up the model and prepare the run before the interface starts.
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.
Start-screen launches skip this: they verify the model and prepare the
run once the user has entered a target.
"""
set_scan_phase("preflight")
try:
@ -483,7 +482,7 @@ def main() -> None:
# Everything below imports the scan engine; do not race the warm-up thread.
wait_for_import_warmup()
if args.non_interactive:
if args.non_interactive or not args.needs_setup:
_bootstrap_scan(args)
from strix.report.state import get_global_report_state

View file

@ -152,11 +152,6 @@ class TuiController:
]
self.notify_changed()
def begin_preparation(self) -> None:
"""Mark a directly-launched run as preparing behind the live TUI."""
self.scan_state = "preparing"
self.notify_changed()
def fail_preparation(self, detail: str) -> None:
self.scan_state = "failed"
self.error = detail

View file

@ -127,8 +127,8 @@ class GoTuiRuntime:
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
The same round trip main() makes before a direct launch, 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():
@ -162,13 +162,14 @@ class GoTuiRuntime:
await preflight_model_connection(model)
self.model_verified = True
def _start_preparation(self) -> asyncio.Task[None]:
def _start_preparation(self) -> asyncio.Task[None] | 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())
self.init_run_state()
self.start_scan()
return None
async def start_from_setup(self) -> None:
candidate = deepcopy(self.args)
@ -208,34 +209,6 @@ class GoTuiRuntime:
self.init_run_state()
self.start_scan()
async def prepare_and_start(self) -> None:
"""Prepare a directly-launched scan once the TUI is on screen.
The model round trip and run preparation run here rather than before
launch so the interface appears immediately.
"""
model = (load_settings().llm.model or "").strip()
set_scan_phase("preflight")
try:
await preflight_model_connection(model)
except Exception as exc:
logger.exception("Go TUI scan preparation failed")
report_error("model_connection_failed", exc)
self.controller.fail_preparation(str(exc))
return
try:
persist_current()
prepare_run(self.args)
telemetry_start(self.args)
except Exception as exc:
logger.exception("Go TUI scan preparation failed")
report_error("scan_preparation_failed", exc)
self.controller.fail_preparation(str(exc))
return
self.controller.scan_state = "running"
self.init_run_state()
self.start_scan()
def start_scan(self) -> None:
if self.scan_task is None:
self.scan_task = asyncio.create_task(self._run_scan())

View file

@ -231,9 +231,9 @@ async def test_runtime_does_not_initialize_or_scan_before_ready(
monkeypatch.setattr(go_tui, "launch_tui_process", launch)
monkeypatch.setattr(go_tui, "wait_process", wait_process)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "persist_current", lambda: None)
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(go_tui, "persist_current", lambda: calls.append("persist"))
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", init_state)
monkeypatch.setattr(runtime, "start_scan", start_scan)
@ -258,7 +258,7 @@ async def test_runtime_does_not_initialize_or_scan_before_ready(
},
)
await asyncio.wait_for(run_task, timeout=2)
assert calls == ["preflight", "state", "scan"]
assert calls == ["state", "scan"]
finally:
child.close()
if not run_task.done():
@ -355,7 +355,7 @@ async def test_setup_preflights_model_before_starting(
await runtime.ensure_model_verified()
await runtime.start_from_setup()
# The same steps, in the same order, as a direct launch's prepare_and_start.
# The same steps, in the same order, as main() takes before a direct launch.
assert calls == ["preflight", "persist", "targets", "prepare", "telemetry", "state", "scan"]
assert runtime.args.scan_mode == "quick"
assert runtime.args.instruction == ""
@ -1009,28 +1009,7 @@ def _direct_launch_args() -> argparse.Namespace:
@pytest.mark.asyncio
async def test_prepare_and_start_reports_ordinary_connection_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(_direct_launch_args())
started: list[str] = []
async def preflight(_model: str) -> None:
raise TimeoutError("connection timed out")
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan"))
await runtime.prepare_and_start()
assert started == []
assert runtime.controller.setup_mode is False
assert runtime.controller.scan_state == "failed"
assert "connection timed out" in (runtime.controller.error or "")
@pytest.mark.asyncio
async def test_prepare_and_start_runs_the_scan_after_preparation(
async def test_direct_launch_starts_the_prepared_scan_without_a_model_round_trip(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(_direct_launch_args())
@ -1046,9 +1025,10 @@ async def test_prepare_and_start_runs_the_scan_after_preparation(
monkeypatch.setattr(runtime, "init_run_state", lambda: order.append("state"))
monkeypatch.setattr(runtime, "start_scan", lambda: order.append("scan"))
await runtime.prepare_and_start()
assert runtime._start_preparation() is None
assert order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"]
assert order == ["state", "scan"]
assert runtime.controller.setup_mode is False
assert runtime.controller.scan_state == "running"

53
tests/test_main_launch.py Normal file
View file

@ -0,0 +1,53 @@
from __future__ import annotations
import argparse
import importlib
import sys
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import pytest
cli_main: Any = importlib.import_module("strix.interface.main")
def _launch(monkeypatch: pytest.MonkeyPatch, *, needs_setup: bool) -> list[str]:
calls: list[str] = []
args = argparse.Namespace(
non_interactive=False,
needs_setup=needs_setup,
run_name=None,
fail_on=None,
)
async def run_tui(_args: argparse.Namespace) -> None:
calls.append("tui")
monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"])
monkeypatch.setattr(cli_main, "configure_dependency_logging", lambda: None)
monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None)
monkeypatch.setattr(cli_main, "parse_arguments", lambda: args)
monkeypatch.setattr(cli_main, "start_background_check", lambda: None)
monkeypatch.setattr(cli_main, "prompt_update_if_available", lambda _console: False)
monkeypatch.setattr(cli_main, "check_docker_installed", lambda: None)
monkeypatch.setattr(cli_main, "pull_docker_image", lambda: None)
monkeypatch.setattr(cli_main, "validate_environment", lambda: None)
monkeypatch.setattr(cli_main, "wait_for_import_warmup", lambda: None)
monkeypatch.setattr(cli_main, "_bootstrap_scan", lambda _args: calls.append("bootstrap"))
monkeypatch.setattr(cli_main, "run_tui", run_tui)
monkeypatch.setattr(cli_main, "notify_update", lambda _console: None)
cli_main.main()
return calls
def test_direct_launch_verifies_the_model_before_the_tui(monkeypatch: pytest.MonkeyPatch) -> None:
assert _launch(monkeypatch, needs_setup=False) == ["bootstrap", "tui"]
def test_start_screen_launch_defers_the_model_check_to_the_tui(
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert _launch(monkeypatch, needs_setup=True) == ["tui"]