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

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

View file

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

View file

@ -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...)
}

View file

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

View file

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

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