mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 08:03:42 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user