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.
This commit is contained in:
Ahmed Allam
2026-09-02 11:27:50 +00:00
committed by Ahmed Allam
parent b5c3807fef
commit 46b4e6cb64
7 changed files with 331 additions and 154 deletions

View File

@@ -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

View File

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