feat(tui): replace Textual with a Go/Bubble Tea interface (#941)

This commit is contained in:
oyasumi
2026-08-03 19:23:07 -07:00
committed by GitHub
parent a51ca18666
commit 5bb9fe896b
128 changed files with 16229 additions and 6562 deletions
+82
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import importlib
import json
import sys
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
@@ -83,3 +84,84 @@ def test_parse_arguments_rejects_resume_with_target_list(
cli_main.parse_arguments()
assert "Cannot combine --resume with --target/--target-list" in capsys.readouterr().err
def _write_run_record(runs_dir: Path, run_name: str, record: dict[str, Any]) -> None:
"""Write a resumable run: its record plus the agent snapshot resume needs."""
run_dir = runs_dir / run_name
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
state_dir = run_dir / ".state"
state_dir.mkdir(exist_ok=True)
(state_dir / "agents.json").write_text("{}", encoding="utf-8")
def test_resume_restores_a_target_less_workspace_mount(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A run that only mounted a working directory is resumable."""
work = tmp_path / "project"
work.mkdir()
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"pentest_abcd",
{
"run_name": "pentest_abcd",
"targets_info": [],
"local_sources": [],
"workspace_mount": str(work),
"instruction": "audit the auth flow",
"scan_mode": "deep",
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"])
args = cli_main.parse_arguments()
# Still genuinely target-less, and the workspace is mounted again.
assert args.targets_info == []
assert args.workspace_mount == str(work)
assert args.local_sources == [
{"source_path": str(work), "workspace_subdir": "project", "protect_metadata": True}
]
assert args.instruction == "audit the auth flow"
def test_resume_reports_a_missing_workspace_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"pentest_abcd",
{
"run_name": "pentest_abcd",
"targets_info": [],
"local_sources": [],
"workspace_mount": str(tmp_path / "deleted"),
},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"])
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "is missing" in capsys.readouterr().err
def test_resume_still_requires_targets_or_a_workspace(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.chdir(tmp_path)
_write_run_record(
tmp_path / "strix_runs",
"pentest_abcd",
{"run_name": "pentest_abcd", "targets_info": [], "local_sources": []},
)
monkeypatch.setattr(sys, "argv", ["strix", "--resume", "pentest_abcd"])
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert "has no targets_info" in capsys.readouterr().err
+1
View File
@@ -58,6 +58,7 @@ def test_post_form_returns_parsed_body() -> None:
resp = mock.MagicMock()
resp.status_code = 200
resp.content = b'{"access_token": "tok"}'
resp.__enter__.return_value = resp
with mock.patch.object(requests, "post", return_value=resp) as post:
data = codex._post_form({"grant_type": "refresh_token"})
+5 -2
View File
@@ -226,8 +226,11 @@ def test_streamed_openrouter_costs_ignores_entries_without_cost() -> None:
def test_streamed_openrouter_costs_cleared_on_new_run() -> None:
streamed_openrouter_costs.remember("gen-stale", {"cost": 0.7})
set_global_report_state(ReportState.__new__(ReportState))
assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None
try:
set_global_report_state(ReportState.__new__(ReportState))
assert streamed_openrouter_costs.take(SimpleNamespace(id="gen-stale")) is None
finally:
set_global_report_state(None)
def test_openrouter_stream_handler_records_cost() -> None:
+907
View File
@@ -0,0 +1,907 @@
from __future__ import annotations
import argparse
import asyncio
import json
import os
import shutil
import socket
import struct
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.interface.tui import runtime as go_tui
from strix.interface.tui import sidecar
from strix.interface.tui.runtime import GoTuiRuntime
def args() -> argparse.Namespace:
return argparse.Namespace(
needs_setup=True,
targets_info=[],
instruction=None,
scan_mode="deep",
max_budget_usd=None,
max_turns=DEFAULT_MAX_TURNS,
scope_mode="auto",
diff_base=None,
local_sources=[],
diff_scope={"active": False},
user_explicit_instruction=None,
run_name="test-run",
)
def test_binary_command_prefers_packaged_sidecar(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
sidecar = tmp_path / "strix-tui"
sidecar.write_text("binary")
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar)
monkeypatch.setattr(
shutil,
"which",
lambda _name: pytest.fail("PATH lookup should not run"),
)
assert GoTuiRuntime.binary_command() == [str(sidecar)]
def test_binary_command_prefers_current_source_over_packaged_sidecar(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
source = tmp_path / "tui-src"
source.mkdir()
(source / "go.mod").write_text("module test\n")
sidecar = tmp_path / "strix-tui"
sidecar.write_text("stale")
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: sidecar)
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/go" if name == "go" else None)
assert GoTuiRuntime.binary_command() == ["go", "run", "./cmd/strix-tui"]
def test_binary_command_reports_missing_sidecar(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing")
monkeypatch.setattr(shutil, "which", lambda _name: None)
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"):
GoTuiRuntime.binary_command()
def test_binary_command_ignores_unconstrained_path_sidecar(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,
) -> None:
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing")
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: tmp_path / "tui-src")
monkeypatch.setattr(shutil, "which", lambda _name: "/untrusted/path/strix-tui")
with pytest.raises(RuntimeError, match="Bubble Tea TUI binary not found"):
GoTuiRuntime.binary_command()
def test_child_environment_excludes_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "openai-secret")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "aws-id")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret")
monkeypatch.setenv("AWS_SESSION_TOKEN", "aws-token")
monkeypatch.setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/aws-token")
monkeypatch.setenv("VERTEXAI_CREDENTIALS", '{"private_key":"secret"}')
monkeypatch.setenv("STRIX_TUI_TOKEN", "stale-transport-token")
monkeypatch.setenv("TERM", "xterm-256color")
env = sidecar.child_environment()
assert env["TERM"] == "xterm-256color"
assert "OPENAI_API_KEY" not in env
assert "AWS_ACCESS_KEY_ID" not in env
assert "AWS_SECRET_ACCESS_KEY" not in env
assert "AWS_SESSION_TOKEN" not in env
assert "AWS_WEB_IDENTITY_TOKEN_FILE" not in env
assert "VERTEXAI_CREDENTIALS" not in env
assert "STRIX_TUI_TOKEN" not in env
def test_accept_authenticated_connection() -> None:
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
address = listener.getsockname()
def connect() -> None:
with socket.create_connection(address) as connection:
connection.sendall(b"one-use-token")
thread = threading.Thread(target=connect)
thread.start()
connection = sidecar._accept_authenticated_connection(listener, "one-use-token")
connection.close()
listener.close()
thread.join()
def test_rejects_invalid_connection_token() -> None:
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
address = listener.getsockname()
def connect() -> None:
with socket.create_connection(address) as connection:
connection.sendall(b"invalidd-token")
thread = threading.Thread(target=connect)
thread.start()
with pytest.raises(PermissionError, match="authentication failed"):
sidecar._accept_authenticated_connection(listener, "expected-token")
listener.close()
thread.join()
@pytest.mark.asyncio
async def test_windows_transport_launches_without_inherited_fd() -> None:
child = """
import os
import socket
host, port = os.environ["STRIX_TUI_ADDR"].rsplit(":", 1)
with socket.create_connection((host, int(port))) as connection:
connection.sendall(os.environ["STRIX_TUI_TOKEN"].encode("ascii"))
"""
env = os.environ.copy()
env.pop("STRIX_TUI_FD", None)
process, connection = await sidecar._launch_windows_tui_process(
[sys.executable, "-c", child], env, None
)
connection.close()
assert await sidecar.wait_process(process) == 0
async def _receive_exactly(connection: socket.socket, size: int) -> bytes:
result = b""
while len(result) < size:
chunk = await asyncio.get_running_loop().sock_recv(connection, size - len(result))
if not chunk:
raise EOFError
result += chunk
return result
async def _receive_message(connection: socket.socket) -> dict[str, Any]:
size = struct.unpack(">I", await _receive_exactly(connection, 4))[0]
message: dict[str, Any] = json.loads(await _receive_exactly(connection, size))
return message
async def _send_message(connection: socket.socket, message: dict[str, Any]) -> None:
raw = json.dumps(message).encode()
await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw)
@pytest.mark.asyncio
async def test_runtime_does_not_initialize_or_scan_before_ready(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.needs_setup = False
runtime = GoTuiRuntime(runtime_args)
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
calls: list[str] = []
scan_started = asyncio.Event()
async def launch(
_command: list[str], _env: dict[str, str], _cwd: str | None
) -> tuple[SimpleNamespace, socket.socket]:
return SimpleNamespace(returncode=None), backend
async def wait_process(_process: object) -> int:
await scan_started.wait()
return 0
def init_state() -> None:
calls.append("state")
def start_scan() -> None:
calls.append("scan")
scan_started.set()
async def preflight(_model: str) -> None:
calls.append("preflight")
monkeypatch.setattr(runtime, "binary_command", lambda: ["test-sidecar"])
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(runtime, "init_run_state", init_state)
monkeypatch.setattr(runtime, "start_scan", start_scan)
run_task = asyncio.create_task(runtime.run())
try:
hello = await _receive_message(child)
assert hello["type"] == "hello"
assert calls == []
await _send_message(
child,
{
"version": 3,
"type": "ready",
"payload": {
"capabilities": [
"state-revisions",
"collection-deltas",
"structured-command-errors",
"agents-collection",
]
},
},
)
await asyncio.wait_for(run_task, timeout=2)
assert calls == ["preflight", "state", "scan"]
finally:
child.close()
if not run_task.done():
run_task.cancel()
@pytest.mark.asyncio
async def test_pre_activation_failure_propagates_to_dispatcher(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FailedRuntime:
async def run(self) -> None:
raise go_tui.GoTuiPreActivationError("protocol mismatch")
monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: FailedRuntime())
with pytest.raises(go_tui.GoTuiPreActivationError, match="protocol mismatch"):
await go_tui.run_go_tui(args())
@pytest.mark.asyncio
async def test_post_activation_failure_is_surfaced(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class ActivatedRuntime:
async def run(self) -> None:
raise RuntimeError("sidecar failed after ready")
monkeypatch.setattr(go_tui, "GoTuiRuntime", lambda _args: ActivatedRuntime())
with pytest.raises(RuntimeError, match="after ready"):
await go_tui.run_go_tui(args())
@pytest.mark.asyncio
async def test_setup_preflights_model_before_starting(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.instruction = "CLI instruction"
runtime = GoTuiRuntime(runtime_args)
assert runtime.controller.instruction == "CLI instruction"
runtime.controller.targets = ["https://example.com", "/workspace/mounted"]
runtime.controller.scan_mode = "quick"
runtime.controller.instruction = ""
runtime.controller.max_budget_usd = 8.5
runtime.controller.max_turns = 321
runtime.controller.scope_mode = "diff"
runtime.controller.diff_base = "origin/main"
calls: list[str] = []
async def preflight(model: str) -> None:
assert model == "openrouter/test-model"
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)
def build(candidate: argparse.Namespace, **_: object) -> None:
calls.append("targets")
assert candidate.target == ["https://example.com", "/workspace/mounted"]
candidate.targets_info = [
{
"type": "web",
"details": {"target_url": "https://example.com"},
"original": "https://example.com",
},
{
"type": "local_code",
"details": {"target_path": "/workspace/mounted"},
"original": "/workspace/mounted",
},
]
def prepare(candidate: argparse.Namespace) -> None:
calls.append("prepare")
assert candidate.max_budget_usd == 8.5
assert candidate.max_turns == 321
assert candidate.scope_mode == "diff"
assert candidate.diff_base == "origin/main"
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"))
await runtime.start_from_setup()
assert calls == ["preflight", "targets", "prepare", "telemetry", "state", "scan"]
assert runtime.args.scan_mode == "quick"
assert runtime.args.instruction == ""
assert runtime.args.max_budget_usd == 8.5
assert runtime.args.max_turns == 321
assert runtime.args.scope_mode == "diff"
assert runtime.args.diff_base == "origin/main"
@pytest.mark.asyncio
async def test_optimistic_setup_skips_model_preflight(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(args())
runtime.controller.targets = [str(Path.cwd())]
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)
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"))
await runtime.start_from_setup(verify=False)
# 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"]
@pytest.mark.asyncio
async def test_confirmed_target_less_launch_mounts_workspace_without_targets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The working directory reaches the run as a workspace, never as a target."""
runtime = GoTuiRuntime(args())
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)
monkeypatch.setattr(
go_tui,
"build_targets_info",
lambda _args, **_kw: pytest.fail("a target-less launch must not build targets"),
)
monkeypatch.setattr(go_tui, "prepare_run", prepared.append)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
monkeypatch.setattr(runtime, "start_scan", lambda: None)
await runtime.start_from_setup(verify=False)
assert prepared[0].workspace_mount == str(Path.home())
assert prepared[0].targets_info == []
@pytest.mark.asyncio
async def test_setup_preserves_prepared_cli_targets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.target = ["https://example.com"]
runtime_args.target_list = []
runtime_args.targets_info = [
{
"type": "web",
"details": {"url": "https://example.com"},
"original": "https://example.com",
}
]
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)
monkeypatch.setattr(
go_tui,
"build_targets_info",
lambda _args, **_kw: pytest.fail("prepared targets should not be rebuilt"),
)
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"))
await runtime.start_from_setup()
assert runtime.controller.targets == ["https://example.com"]
assert runtime.args.targets_info[0]["type"] == "web"
assert calls == ["preflight", "prepare", "telemetry", "state", "scan"]
@pytest.mark.asyncio
async def test_setup_target_change_preserves_local_targets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.target = []
runtime_args.target_list = ["targets.txt"]
runtime_args.targets_info = [
{
"type": "local_code",
"details": {"target_path": "/workspace/source"},
"original": "/workspace/source",
}
]
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets.append("https://example.com")
async def preflight(_model: str) -> None:
return None
def build(target_args: argparse.Namespace, **_: object) -> None:
assert target_args.target == ["/workspace/source", "https://example.com"]
target_args.targets_info = [
{
"type": "web",
"details": {"url": "https://example.com"},
"original": "https://example.com",
},
{
"type": "local_code",
"details": {"target_path": "/workspace/source"},
"original": "/workspace/source",
},
]
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", build)
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
monkeypatch.setattr(runtime, "start_scan", lambda: None)
await runtime.start_from_setup()
assert runtime.args.target_list == []
assert runtime.args.targets_info[0]["type"] == "web"
assert runtime.args.targets_info[1]["type"] == "local_code"
@pytest.mark.asyncio
async def test_setup_same_basename_uses_combined_workspace_names_on_retry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
existing_repo = "https://example.com/first/app.git"
added_repo = "https://example.com/second/app.git"
runtime_args = args()
runtime_args.target = []
runtime_args.target_list = ["targets.txt"]
runtime_args.targets_info = [
{
"type": "repository",
"details": {
"target_repo": existing_repo,
"workspace_subdir": "app",
"cloned_repo_path": "/clones/app",
},
"original": existing_repo,
}
]
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets.append(added_repo)
prepare_attempts = 0
started: list[str] = []
async def preflight(_model: str) -> None:
return None
def build(target_args: argparse.Namespace, **_: object) -> None:
assert target_args.target == [existing_repo, added_repo]
target_args.targets_info = [
{
"type": "repository",
"details": {
"target_repo": existing_repo,
"workspace_subdir": "app",
},
"original": existing_repo,
},
{
"type": "repository",
"details": {
"target_repo": added_repo,
"workspace_subdir": "app-2",
},
"original": added_repo,
},
]
def prepare(candidate: argparse.Namespace) -> None:
nonlocal prepare_attempts
prepare_attempts += 1
assert [target["details"]["workspace_subdir"] for target in candidate.targets_info] == [
"app",
"app-2",
]
if prepare_attempts == 1:
candidate.targets_info[0]["details"]["target_repo"] = "/mutated"
candidate.targets_info[1]["details"]["workspace_subdir"] = "mutated"
raise ValueError("retry setup")
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", build)
monkeypatch.setattr(go_tui, "prepare_run", prepare)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: started.append("state"))
monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan"))
with pytest.raises(ValueError, match="retry setup"):
await runtime.start_from_setup()
assert runtime.args.targets_info[0]["details"] == {
"target_repo": existing_repo,
"workspace_subdir": "app",
"cloned_repo_path": "/clones/app",
}
assert started == []
await runtime.start_from_setup()
assert prepare_attempts == 2
assert runtime.args.target_list == []
assert [target["details"]["workspace_subdir"] for target in runtime.args.targets_info] == [
"app",
"app-2",
]
assert runtime.args.targets_info[0]["details"]["target_repo"] == existing_repo
assert started == ["state", "scan"]
@pytest.mark.asyncio
async def test_setup_target_rebuild_restores_all_target_fields_on_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.target = None
runtime_args.target_list = ["targets.txt"]
runtime_args.targets_info = [
{
"type": "local_code",
"details": {"target_path": "/workspace/source"},
"original": "/workspace/source",
}
]
original_targets_info = json.loads(json.dumps(runtime_args.targets_info))
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets.append("https://example.com")
async def preflight(_model: str) -> None:
return None
def fail_rebuild(target_args: argparse.Namespace, **_: object) -> None:
target_args.target = ["mutated"]
target_args.target_list = ["mutated.txt"]
target_args.targets_info = [{"original": "partial"}]
raise ValueError("bad target")
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "build_targets_info", fail_rebuild)
with pytest.raises(ValueError, match="bad target"):
await runtime.start_from_setup()
assert runtime.args.target is None
assert runtime.args.target_list == ["targets.txt"]
assert runtime.args.targets_info == original_targets_info
@pytest.mark.asyncio
async def test_setup_rebuild_canonicalizes_relative_local_target(
tmp_path: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
source = tmp_path / "source"
source.mkdir()
monkeypatch.chdir(tmp_path)
runtime_args = args()
runtime_args.target = []
runtime_args.target_list = []
runtime = GoTuiRuntime(runtime_args)
runtime.controller.targets = ["source"]
prepared = False
async def preflight(_model: str) -> None:
return None
def prepare(candidate: argparse.Namespace) -> None:
nonlocal prepared
prepared = True
assert len(candidate.targets_info) == 1
assert candidate.targets_info[0]["details"]["target_path"] == str(source.resolve())
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "prepare_run", prepare)
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None)
monkeypatch.setattr(runtime, "init_run_state", lambda: None)
monkeypatch.setattr(runtime, "start_scan", lambda: None)
await runtime.start_from_setup()
assert prepared is True
assert runtime.args.targets_info[0]["original"] == str(source.resolve())
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_setup_prepare_system_exit_is_recoverable_and_transactional(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime_args = args()
runtime_args.instruction = "CLI instruction"
runtime_args.scan_mode = "deep"
runtime_args.target = ["https://example.com"]
runtime_args.target_list = []
runtime_args.targets_info = [
{
"type": "web",
"details": {"url": "https://example.com"},
"original": "https://example.com",
}
]
original_args = json.loads(json.dumps(vars(runtime_args)))
runtime = GoTuiRuntime(runtime_args)
runtime.controller.scan_mode = "quick"
runtime.controller.instruction = ""
telemetry_started = False
async def preflight(_model: str) -> None:
return None
def fail_prepare(candidate: argparse.Namespace) -> None:
assert candidate is not runtime.args
candidate.run_name = "mutated-run"
candidate.targets_info[0]["details"]["url"] = "https://mutated.example"
raise ValueError("invalid diff scope")
def telemetry(_candidate: argparse.Namespace) -> None:
nonlocal telemetry_started
telemetry_started = True
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "prepare_run", fail_prepare)
monkeypatch.setattr(go_tui, "telemetry_start", telemetry)
with pytest.raises(ValueError, match="invalid diff scope"):
await runtime.start_from_setup()
assert vars(runtime.args) == original_args
assert telemetry_started is False
assert runtime.scan_task is None
@pytest.mark.asyncio
async def test_scan_passes_max_turns_and_budget(monkeypatch: pytest.MonkeyPatch) -> None:
runtime_args = args()
runtime_args.max_turns = 37
runtime_args.max_budget_usd = 4.25
runtime = GoTuiRuntime(runtime_args)
runtime.scan_config = {"run_name": "test-run"}
captured: dict[str, Any] = {}
async def run_scan(**kwargs: Any) -> None:
captured.update(kwargs)
coordinator = kwargs["coordinator"]
await coordinator.register("root", "Root", parent_id=None)
await coordinator.set_status("root", "stopped")
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(image="test-image")),
)
monkeypatch.setattr(go_tui, "run_strix_scan", run_scan)
await runtime._run_scan()
assert captured["max_turns"] == 37
assert captured["max_budget_usd"] == 4.25
assert runtime.controller.scan_state == "stopped"
@pytest.mark.asyncio
async def test_setup_preflight_failure_does_not_start_scan(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(args())
runtime.controller.targets = ["https://example.com"]
started = False
async def preflight(_model: str) -> None:
raise ValueError("401 Unauthorized")
def mark_started(*_args: Any) -> None:
nonlocal started
started = True
monkeypatch.setattr(
go_tui,
"load_settings",
lambda: SimpleNamespace(llm=SimpleNamespace(model="openrouter/test-model")),
)
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
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()
assert started is False
assert runtime.scan_task is None
@pytest.mark.asyncio
async def test_agent_state_sync_uses_latest_graph_snapshot_shape() -> None:
runtime = GoTuiRuntime(args())
await runtime.coordinator.register("root", "Strix", parent_id=None)
await runtime.coordinator.register("child", "Recon", parent_id="root")
await runtime.coordinator.set_status("child", "failed", error="provider rejected request")
await runtime._sync_agent_state()
assert runtime.live_view.agents["root"]["name"] == "Strix"
child = runtime.live_view.agents["child"]
assert child["name"] == "Recon"
assert child["parent_id"] == "root"
assert child["status"] == "failed"
assert child["error_message"] == "provider rejected request"
@pytest.mark.asyncio
async def test_agent_state_sync_projects_completed_report() -> None:
runtime = GoTuiRuntime(args())
runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"}))
await runtime.coordinator.register("root", "Strix", parent_id=None)
await runtime.coordinator.set_status("root", "completed")
await runtime._sync_agent_state()
assert runtime.controller.scan_state == "completed"
@pytest.mark.asyncio
async def test_agent_state_sync_does_not_mask_root_failure_with_completed_report() -> None:
runtime = GoTuiRuntime(args())
runtime.report_state = cast("Any", SimpleNamespace(run_record={"status": "completed"}))
await runtime.coordinator.register("root", "Strix", parent_id=None)
await runtime.coordinator.set_status("root", "failed", error="finalization failed")
await runtime._sync_agent_state()
assert runtime.controller.scan_state == "failed"
assert runtime.controller.error == "finalization failed"
def _direct_launch_args() -> argparse.Namespace:
launch_args = args()
launch_args.needs_setup = False
return launch_args
@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(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = GoTuiRuntime(_direct_launch_args())
order: list[str] = []
async def preflight(_model: str) -> None:
order.append("preflight")
monkeypatch.setattr(go_tui, "preflight_model_connection", preflight)
monkeypatch.setattr(go_tui, "persist_current", lambda: order.append("persist"))
monkeypatch.setattr(go_tui, "prepare_run", lambda _args: order.append("prepare"))
monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: order.append("telemetry"))
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 order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"]
assert runtime.controller.scan_state == "running"
+34 -1
View File
@@ -8,7 +8,12 @@ from typing import Any
import litellm
import pytest
from strix.core.inputs import build_root_task, child_initial_input, make_model_settings
from strix.core.inputs import (
build_root_task,
build_scope_context,
child_initial_input,
make_model_settings,
)
def _child_kwargs(parent_history: list[Any]) -> dict[str, Any]:
@@ -202,6 +207,34 @@ def test_build_root_task_web_application_with_instructions() -> None:
assert "Special instructions: Focus on auth." in task
def test_build_root_task_workspace_mount_is_not_a_target() -> None:
"""A target-less run gets a working directory, not an assessment scope."""
config = {
"targets": [],
"user_instructions": "Find IDOR in the checkout flow.",
"workspace_mount": "/Users/me/code/api",
"workspace_subdir": "api",
}
task = build_root_task(config)
assert "Working Directory:" in task
assert "/workspace/api" in task
assert "No scan target was set" in task
assert "Special instructions: Find IDOR in the checkout flow." in task
# It must not be presented as an asset to test.
for label in ("Local Codebases:", "Repositories:", "URLs:", "IP Addresses:"):
assert label not in task
def test_build_scope_context_authorizes_nothing_without_targets() -> None:
"""A mounted workspace grants no authorized scope."""
scope = build_scope_context(
{"targets": [], "workspace_mount": "/Users/me/code/api", "workspace_subdir": "api"}
)
assert scope["authorized_targets"] == []
def test_build_root_task_diff_scope() -> None:
config = {
"targets": [],
+52
View File
@@ -2,11 +2,13 @@
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any
import pytest
from strix.interface.scan_setup import attach_workspace_mount
from strix.interface.utils import (
check_mountable_dir,
collect_local_sources,
@@ -14,6 +16,7 @@ from strix.interface.utils import (
infer_target_type,
read_target_list_file,
)
from strix.runtime.session_manager import build_bind_mounts
def _local_target(target_path: str) -> dict[str, Any]:
@@ -66,6 +69,55 @@ def test_check_mountable_dir_rejects_home(tmp_path: Path, monkeypatch: pytest.Mo
check_mountable_dir(home)
def test_infer_target_type_guards_sensitive_dirs_by_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home))
with pytest.raises(ValueError, match="Refusing to mount"):
infer_target_type(str(home))
def test_workspace_mount_is_mounted_without_becoming_a_target(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A workspace mount reaches the sandbox but carries no target semantics.
It is the directory the agent works in, so it is exempt from the guard that
refuses home directories for scan targets, and it never enters targets_info.
"""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: home))
args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=str(home))
attach_workspace_mount(args)
assert args.targets_info == []
assert args.local_sources == [
{
"source_path": str(home),
"workspace_subdir": args.workspace_subdir,
"protect_metadata": True,
}
]
# It is a real bind mount, so the sandbox exposes it under /workspace.
assert build_bind_mounts(args.local_sources)[0]["target"] == (
f"/workspace/{args.workspace_subdir}"
)
def test_workspace_mount_absent_leaves_local_sources_alone() -> None:
args = argparse.Namespace(targets_info=[], local_sources=[], workspace_mount=None)
attach_workspace_mount(args)
assert args.local_sources == []
def test_check_mountable_dir_rejects_system_root() -> None:
etc = Path("/etc")
if not etc.is_dir():
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def test_wheel_build_requires_go(tmp_path: Path) -> None:
uv = shutil.which("uv")
if uv is None:
pytest.skip("uv is required for the packaging smoke test")
env = os.environ.copy()
env["PATH"] = str(tmp_path / "path-without-go")
result = subprocess.run( # noqa: S603
[uv, "build", "--wheel", "--out-dir", str(tmp_path / "dist")],
cwd=PROJECT_ROOT,
env=env,
check=False,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Go 1.24 or newer is required" in result.stdout + result.stderr
-46
View File
@@ -1,46 +0,0 @@
"""Tests for the proxy tool TUI renderers."""
from __future__ import annotations
from rich.text import Text
from strix.interface.tui.renderers.proxy_renderer import ViewRequestRenderer
def _plain(static: object) -> str:
content = static.content # type: ignore[attr-defined]
return content.plain if isinstance(content, Text) else str(content)
def _render(content: str, *, has_more: bool) -> str:
tool_data = {
"status": "completed",
"result": {
"content": content,
"has_more": has_more,
"page": 1,
"total_lines": len(content.split("\n")),
},
}
return _plain(ViewRequestRenderer.render(tool_data))
_MARKER = "... more content available"
def test_more_content_hint_shown_when_over_fifteen_lines() -> None:
content = "\n".join(f"line{i}" for i in range(30))
assert _MARKER in _render(content, has_more=False)
def test_no_more_content_hint_within_fifteen_lines() -> None:
content = "\n".join(f"line{i}" for i in range(5))
assert _MARKER not in _render(content, has_more=False)
def test_more_content_hint_shown_when_has_more_flag_set() -> None:
content = "\n".join(f"line{i}" for i in range(3))
assert _MARKER in _render(content, has_more=True)
+430
View File
@@ -0,0 +1,430 @@
from __future__ import annotations
import argparse
import asyncio
import os
from pathlib import Path
import pytest
from strix.config import apply_config_override, loader
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.interface.tui.backend.controller import TuiController
def args() -> argparse.Namespace:
return argparse.Namespace(
needs_setup=True,
targets_info=[],
instruction=None,
scan_mode="deep",
max_budget_usd=None,
max_turns=DEFAULT_MAX_TURNS,
scope_mode="auto",
diff_base=None,
local_sources=[],
diff_scope={"active": False},
user_explicit_instruction=None,
run_name=None,
)
@pytest.fixture(autouse=True)
def isolated_config(tmp_path: Path) -> None:
for key in (
"STRIX_LLM",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"LLM_API_KEY",
"LLM_API_BASE",
"AZURE_API_KEY",
"AZURE_API_BASE",
"AZURE_API_VERSION",
):
os.environ.pop(key, None)
apply_config_override(tmp_path / "config.json")
@pytest.mark.asyncio
async def test_setup_state_is_serializable() -> None:
controller = TuiController(args())
await controller.handle("setup.add_target", {"target": "https://example.com"})
await controller.handle("setup.set_instruction", {"instruction": "focus on auth"})
snapshot = controller.snapshot()
assert snapshot["targets"] == ["https://example.com"]
assert snapshot["instruction"] == "focus on auth"
assert snapshot["scan_state"] == "setup"
assert snapshot["scan_mode"] == "deep"
assert snapshot["max_budget_usd"] is None
assert snapshot["max_turns"] == 500
assert snapshot["scope_mode"] == "auto"
assert snapshot["diff_base"] is None
@pytest.mark.asyncio
async def test_setup_instruction_starts_from_cli_and_can_be_cleared() -> None:
setup_args = args()
setup_args.instruction = " CLI instruction "
controller = TuiController(setup_args)
assert controller.snapshot()["instruction"] == "CLI instruction"
result = await controller.handle("setup.set_instruction", {"instruction": ""})
assert result == {"instruction": ""}
assert controller.snapshot()["instruction"] == ""
@pytest.mark.asyncio
async def test_setup_controls_reject_changes_after_start() -> None:
controller = TuiController(args())
controller.setup_mode = False
controller.scan_started = True
with pytest.raises(RuntimeError, match="can no longer be changed"):
await controller.handle("setup.add_target", {"target": "https://example.com"})
@pytest.mark.asyncio
async def test_large_target_list_reports_truncated_snapshot_count() -> None:
controller = TuiController(args())
for index in range(20):
await controller.handle("setup.add_target", {"target": f"https://target-{index}.example"})
added = await controller.handle("setup.add_target", {"target": "https://last.example"})
snapshot = controller.snapshot()
assert added == {"target": "https://last.example", "total": 21}
assert snapshot["target_count"] == 21
# The snapshot only carries a bounded prefix of the list.
assert len(snapshot["targets"]) == 16
def test_state_populates_model_warning_for_non_frontier_model() -> None:
os.environ["STRIX_LLM"] = "openai/gpt-3.5-turbo"
loader._cached = None
warning = TuiController(args()).snapshot()["model_warning"]
assert "openai/gpt-3.5-turbo" in warning
assert "not a recommended frontier model" in warning
def test_setup_restores_prepared_cli_targets() -> None:
setup_args = args()
setup_args.targets_info = [
{"type": "web", "details": {}, "original": "https://example.com"},
{"type": "local_code", "details": {}, "original": "/workspace/source"},
]
controller = TuiController(setup_args)
assert controller.snapshot()["targets"] == ["https://example.com", "/workspace/source"]
@pytest.mark.asyncio
async def test_start_validates_model_before_callback() -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
with pytest.raises(ValueError, match="No model configured"):
await controller.handle("setup.start", {})
assert started is False
@pytest.mark.asyncio
async def test_start_launches_with_a_configured_model() -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
loader._cached = None
controller = TuiController(args(), on_start=start)
await controller.handle("setup.add_target", {"target": "https://example.com"})
result = await controller.handle("setup.start", {})
assert result == {"started": True}
assert started is True
@pytest.mark.asyncio
async def test_start_without_target_requires_mount_consent() -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
# Mounting the working directory is never silent.
with pytest.raises(ValueError, match="No target set"):
await controller.handle("setup.start", {"verify": False})
assert started is False
assert controller.targets == []
assert controller.workspace_mount is None
@pytest.mark.asyncio
async def test_target_less_start_enters_live_view_and_waits_for_the_mount() -> None:
"""Nothing is prepared until the live-view confirmation is answered."""
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
os.environ["ANTHROPIC_API_KEY"] = "test-key"
loader._cached = None
controller = TuiController(args(), on_start=start)
result = await controller.handle("setup.start", {"verify": False, "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
# been prepared and nothing is mounted yet.
assert started is False
assert controller.setup_mode is False
assert controller.scan_state == "preparing"
assert controller.pending_workspace_mount == str(Path.cwd())
assert controller.workspace_mount is None
assert controller.snapshot()["pending_mount"] == str(Path.cwd())
@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
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})
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
assert controller.workspace_mount == str(Path.cwd())
assert controller.targets == []
assert controller.scan_state == "running"
assert controller.snapshot()["pending_mount"] == ""
@pytest.mark.asyncio
async def test_declining_the_mount_returns_to_the_start_screen() -> None:
started = False
async def start(_verify: bool = True) -> None:
nonlocal started
started = True
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})
result = await controller.handle("setup.confirm_mount", {"approved": False})
assert result == {"approved": False}
# Nothing was prepared, so the session goes back to the start screen and can
# be launched again.
assert started is False
assert controller.workspace_mount is None
assert controller.pending_workspace_mount is None
assert controller.setup_mode is True
assert controller.scan_started is False
assert controller.scan_state == "setup"
@pytest.mark.asyncio
async def test_confirm_mount_requires_a_pending_request() -> None:
controller = TuiController(args())
with pytest.raises(RuntimeError, match="No mount confirmation is pending"):
await controller.handle("setup.confirm_mount", {"approved": True})
def test_snapshot_exposes_working_directory() -> None:
controller = TuiController(args())
assert controller.snapshot()["working_dir"] == str(Path.cwd())
assert controller.snapshot()["pending_mount"] == ""
@pytest.mark.asyncio
async def test_start_forwards_verify_flag_by_default() -> None:
seen_verify: bool | None = None
async def start(verify: bool = True) -> None:
nonlocal seen_verify
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.add_target", {"target": "https://example.com"})
# A named target keeps the upfront model check.
await controller.handle("setup.start", {})
assert seen_verify is True
@pytest.mark.asyncio
async def test_start_rejects_concurrent_and_repeated_submissions() -> None:
entered = asyncio.Event()
release = asyncio.Event()
async def start(_verify: bool = True) -> None:
entered.set()
await release.wait()
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"})
first_start = asyncio.create_task(controller.handle("setup.start", {}))
await entered.wait()
with pytest.raises(RuntimeError, match="already starting or running"):
await controller.handle("setup.start", {})
release.set()
await first_start
with pytest.raises(RuntimeError, match="already starting or running"):
await controller.handle("setup.start", {})
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "failed", "crashed", "stopped"])
async def test_stop_rejects_terminal_agents(status: str) -> None:
class Coordinator:
def __init__(self) -> None:
self.calls: list[str] = []
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
self.calls.append(agent_id)
return True
coordinator = Coordinator()
controller = TuiController(args(), coordinator=coordinator)
controller.set_runtime(scan_loop=asyncio.get_running_loop())
controller.live_view.upsert_agent("agent-1", name="Agent", status=status)
with pytest.raises(RuntimeError, match=f"cannot be stopped while {status}"):
await controller.handle("agent.stop", {"agent_id": "agent-1"})
assert coordinator.calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["running", "waiting", "budget_paused"])
async def test_stop_allows_active_agents(status: str) -> None:
class Coordinator:
def __init__(self) -> None:
self.calls: list[str] = []
async def cancel_descendants_graceful(self, agent_id: str) -> bool:
self.calls.append(agent_id)
return True
coordinator = Coordinator()
controller = TuiController(args(), coordinator=coordinator)
controller.set_runtime(scan_loop=asyncio.get_running_loop())
controller.live_view.upsert_agent("agent-1", name="Agent", status=status)
result = await controller.handle("agent.stop", {"agent_id": "agent-1"})
assert result == {"stopped": True}
assert coordinator.calls == ["agent-1"]
@pytest.mark.asyncio
async def test_stop_handles_coordinator_rejection_after_stale_active_projection() -> None:
class Coordinator:
async def cancel_descendants_graceful(self, _agent_id: str) -> bool:
return False
controller = TuiController(args(), coordinator=Coordinator())
controller.set_runtime(scan_loop=asyncio.get_running_loop())
controller.live_view.upsert_agent("agent-1", name="Agent", status="running")
with pytest.raises(RuntimeError, match="no longer active"):
await controller.handle("agent.stop", {"agent_id": "agent-1"})
@pytest.mark.asyncio
async def test_unknown_command_is_rejected() -> None:
controller = TuiController(args())
with pytest.raises(ValueError, match="Unknown command"):
await controller.handle("nope", {})
def test_messages_are_sanitized_and_agents_are_collection_only() -> None:
controller = TuiController(args())
controller.add_message("replace\x1b]52;c;Y2xpcA==\x07 key\x85")
for index in range(40):
controller.live_view.upsert_agent(f"agent-{index}", name=f"Agent {index}")
snapshot = controller.snapshot()
assert "agents" not in snapshot
assert [message["text"] for message in snapshot["messages"]] == ["replace key"]
assert len(controller.collection("agents")) == 40
@pytest.mark.asyncio
async def test_existing_viewer_is_reopened_and_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[str] = []
class ViewerServer:
shutdown_called = False
close_called = False
def shutdown(self) -> None:
self.shutdown_called = True
def server_close(self) -> None:
self.close_called = True
controller = TuiController(args())
controller.viewer_status = "running"
controller.viewer_url = "http://127.0.0.1:1234/?token=test"
server = ViewerServer()
controller._viewer_httpd = server
monkeypatch.setattr("strix.interface.tui.backend.controller.webbrowser.open", opened.append)
result = await controller.handle("viewer.open", {})
controller.close_viewer()
assert result == {"status": "running", "url": controller.viewer_url}
assert opened == [controller.viewer_url]
assert server.shutdown_called is True
assert server.close_called is True
+560
View File
@@ -0,0 +1,560 @@
from __future__ import annotations
import argparse
import asyncio
import json
import socket
import struct
from types import SimpleNamespace
from typing import Any, cast
import pytest
from agents.tool import ToolOutputImage
from strix.config.settings import DEFAULT_MAX_TURNS
from strix.interface.tui.backend.controller import TuiController
from strix.interface.tui.backend.projection import terminal_projection
from strix.interface.tui.backend.protocol import (
MAX_COMMAND_BYTES,
PROTOCOL_CAPABILITIES,
PROTOCOL_VERSION,
ProtocolHandshakeError,
envelope,
)
from strix.interface.tui.backend.server import TuiBackendServer
from strix.interface.tui.live_view import TuiLiveView
def args() -> argparse.Namespace:
return argparse.Namespace(
needs_setup=True,
targets_info=[],
instruction=None,
scan_mode="deep",
max_budget_usd=None,
max_turns=DEFAULT_MAX_TURNS,
scope_mode="auto",
diff_base=None,
local_sources=[],
diff_scope={"active": False},
user_explicit_instruction=None,
run_name=None,
)
async def send_message(connection: socket.socket, message: dict[str, object]) -> None:
raw = json.dumps(message).encode()
await asyncio.get_running_loop().sock_sendall(connection, struct.pack(">I", len(raw)) + raw)
async def receive_exactly(connection: socket.socket, size: int) -> bytes:
chunks: list[bytes] = []
while size:
chunk = await asyncio.get_running_loop().sock_recv(connection, size)
if not chunk:
raise EOFError
chunks.append(chunk)
size -= len(chunk)
return b"".join(chunks)
async def receive_frame(connection: socket.socket) -> tuple[int, dict[str, Any]]:
size = struct.unpack(">I", await receive_exactly(connection, 4))[0]
value = json.loads(await receive_exactly(connection, size))
assert isinstance(value, dict)
return size, value
async def receive_message(connection: socket.socket) -> dict[str, Any]:
return (await receive_frame(connection))[1]
async def start_server(
server: TuiBackendServer, backend: socket.socket, child: socket.socket
) -> dict[str, Any]:
start_task = asyncio.create_task(server.start(backend))
hello = await receive_message(child)
await send_message(
child,
{
"version": PROTOCOL_VERSION,
"type": "ready",
"payload": {"capabilities": list(PROTOCOL_CAPABILITIES)},
},
)
await asyncio.wait_for(start_task, timeout=1)
return hello
async def receive_until(
connection: socket.socket,
message_type: str,
*,
request_id: str | None = None,
) -> dict[str, Any]:
for _ in range(100):
message = await asyncio.wait_for(receive_message(connection), timeout=2)
if message.get("type") != message_type:
continue
if request_id is not None and message.get("request_id") != request_id:
continue
return message
raise AssertionError(f"did not receive {message_type}")
async def receive_initial_state(connection: socket.socket) -> None:
state_received = False
complete: set[str] = set()
while not state_received or complete != {"agents", "events", "vulnerabilities"}:
message = await asyncio.wait_for(receive_message(connection), timeout=2)
if message["type"] == "state":
state_received = True
elif message["type"] == "collection_bootstrap":
payload = message["payload"]
if payload["done"]:
complete.add(payload["collection"])
@pytest.mark.asyncio
async def test_server_requires_ready_before_state_or_commands() -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
server = TuiBackendServer(TuiController(args()))
start_task = asyncio.create_task(server.start(backend))
try:
hello = await receive_message(child)
assert hello == {
"version": 3,
"type": "hello",
"payload": {"capabilities": list(PROTOCOL_CAPABILITIES)},
}
with pytest.raises(TimeoutError):
await asyncio.wait_for(receive_message(child), timeout=0.1)
assert not start_task.done()
await send_message(
child,
{
"version": 3,
"type": "ready",
"payload": {"capabilities": list(PROTOCOL_CAPABILITIES)},
},
)
await asyncio.wait_for(start_task, timeout=1)
assert server.activated is True
assert (await receive_until(child, "state"))["payload"]["revision"] == 1
finally:
child.close()
start_task.cancel()
await server.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("version", "capabilities"),
[
(2, list(PROTOCOL_CAPABILITIES)),
(3, ["state-revisions"]),
],
)
async def test_server_rejects_handshake_mismatch(version: int, capabilities: list[str]) -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
server = TuiBackendServer(TuiController(args()))
start_task = asyncio.create_task(server.start(backend))
try:
await receive_message(child)
await send_message(
child,
{"version": version, "type": "ready", "payload": {"capabilities": capabilities}},
)
with pytest.raises(ProtocolHandshakeError, match="mismatch"):
await asyncio.wait_for(start_task, timeout=1)
assert server.activated is False
finally:
child.close()
await server.close()
@pytest.mark.asyncio
async def test_server_command_round_trip_over_inherited_socket() -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
server = TuiBackendServer(TuiController(args()))
await start_server(server, backend, child)
try:
await send_message(
child,
{
"version": 3,
"type": "setup.add_target",
"request_id": "test-1",
"payload": {"target": "example.com"},
},
)
result = await receive_until(child, "command_result", request_id="test-1")
assert result["payload"]["ok"] is True
assert result["payload"]["command"] == "setup.add_target"
state = await receive_until(child, "state")
assert state["payload"]["revision"] >= 1
assert state["payload"]["state"]["targets"] == ["example.com"]
finally:
child.close()
await server.close()
def test_unicode_heavy_setup_state_stays_within_control_frame_limit() -> None:
controller = TuiController(args())
controller.instruction = "🔒" * 10_000
controller.targets = [f"https://例え.{index}/" + "" * 500 for index in range(20)]
controller.error = "" * 10_000
controller.messages = [
{"id": str(index), "text": "" * 10_000, "level": "warning"} for index in range(10)
]
controller.report_state = cast(
"Any",
SimpleNamespace(
caido_url="https://例え.example/" + "" * 10_000,
get_total_llm_usage=lambda: {f"model-{index}": "" * 10_000 for index in range(20)},
),
)
server = TuiBackendServer(controller)
snapshot = controller.snapshot()
encoded = server._encode(envelope("state", {"revision": 1, "state": snapshot}))
assert len(encoded) <= MAX_COMMAND_BYTES
assert "🔒".encode() in encoded
assert snapshot["projection_truncated"] is True
@pytest.mark.asyncio
async def test_persistence_error_does_not_kill_command_reader(
monkeypatch: pytest.MonkeyPatch,
) -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
controller = TuiController(args())
calls = 0
async def handle(command: str, payload: dict[str, Any]) -> dict[str, Any]:
nonlocal calls
calls += 1
if calls == 1:
raise OSError("disk is read-only")
return {"command": command, "payload": payload}
monkeypatch.setattr(controller, "handle", handle)
server = TuiBackendServer(controller)
await start_server(server, backend, child)
try:
for request_id in ("persist-1", "persist-2"):
await send_message(
child,
{
"version": 3,
"type": "setup.select_model",
"request_id": request_id,
"payload": {"provider": "openai", "model": "openai/gpt-5"},
},
)
result = await receive_until(child, "command_result", request_id=request_id)
if request_id == "persist-1":
assert result["payload"]["error"] == {
"code": "persistence_error",
"message": "disk is read-only",
"retryable": True,
}
else:
assert result["payload"]["ok"] is True
assert server._reader_task is not None and not server._reader_task.done()
finally:
child.close()
await server.close()
@pytest.mark.asyncio
async def test_invalid_version_error_is_correlated_and_next_command_succeeds() -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
server = TuiBackendServer(TuiController(args()))
await start_server(server, backend, child)
try:
await send_message(
child,
{
"version": 2,
"type": "setup.add_target",
"request_id": "bad-version",
"payload": {"target": "ignored.example"},
},
)
rejected = await receive_until(child, "command_result", request_id="bad-version")
assert rejected["payload"]["error"]["code"] == "invalid_request"
await send_message(
child,
{
"version": 3,
"type": "setup.add_target",
"request_id": "after-error",
"payload": {"target": "example.com"},
},
)
accepted = await receive_until(child, "command_result", request_id="after-error")
assert accepted["payload"]["ok"] is True
finally:
child.close()
await server.close()
@pytest.mark.asyncio
async def test_collection_bootstrap_is_chunked_deltas_are_incremental_and_idle_is_silent() -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
controller = TuiController(args())
report_state = SimpleNamespace(
vulnerability_reports=[],
caido_url=None,
get_total_llm_usage=dict,
)
controller.report_state = cast("Any", report_state)
content = "x" * (64 * 1024)
for index in range(80):
controller.live_view.record_user_message(f"agent-{index}", content)
server = TuiBackendServer(controller)
await start_server(server, backend, child)
try:
event_frames = 0
event_count = 0
complete: set[str] = set()
state_received = False
while not (complete == {"agents", "events", "vulnerabilities"} and state_received):
size, message = await asyncio.wait_for(receive_frame(child), timeout=5)
if message["type"] == "state":
state_received = True
if message["type"] != "collection_bootstrap":
continue
payload = message["payload"]
if payload["collection"] == "events":
event_frames += 1
event_count += len(payload["items"])
assert size <= 4 * 1024 * 1024
if payload["done"]:
complete.add(payload["collection"])
assert event_frames >= 2
assert event_count == 80
server.notify_changed()
with pytest.raises(TimeoutError):
await asyncio.wait_for(receive_message(child), timeout=0.2)
controller.live_view.record_user_message("agent-new", "delta")
controller.notify_changed()
delta = await receive_until(child, "collection_delta")
assert delta["payload"]["collection"] == "events"
assert delta["payload"]["base_revision"] == 1
assert len(delta["payload"]["operations"]) == 1
report_state.vulnerability_reports.append(
{"id": "vuln-0001", "title": "Incremental finding", "severity": "high"}
)
controller.notify_changed()
finding_delta = await receive_until(child, "collection_delta")
assert finding_delta["payload"]["collection"] == "vulnerabilities"
assert len(finding_delta["payload"]["operations"]) == 1
finally:
child.close()
await server.close()
@pytest.mark.asyncio
async def test_agents_collection_has_no_state_cap_and_sends_delete_and_resync() -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
controller = TuiController(args())
for index in range(40):
controller.live_view.upsert_agent(
f"agent-{index}",
name=f"Agent {index}",
status="running",
)
server = TuiBackendServer(controller)
await start_server(server, backend, child)
try:
agents: list[dict[str, Any]] = []
complete: set[str] = set()
state: dict[str, Any] | None = None
while state is None or complete != {"agents", "events", "vulnerabilities"}:
message = await asyncio.wait_for(receive_message(child), timeout=2)
if message["type"] == "state":
state = message["payload"]["state"]
elif message["type"] == "collection_bootstrap":
payload = message["payload"]
if payload["collection"] == "agents":
agents.extend(payload["items"])
if payload["done"]:
complete.add(payload["collection"])
assert "agents" not in state
assert len(agents) == 40
controller.live_view.agents.pop("agent-7")
controller.notify_changed()
delta = await receive_until(child, "collection_delta")
assert delta["payload"]["collection"] == "agents"
assert delta["payload"]["operations"] == [{"op": "delete", "id": "agent-7"}]
await send_message(
child,
{
"version": 3,
"type": "collection.resync",
"request_id": "resync-agents",
"payload": {"collection": "agents"},
},
)
result = await receive_until(child, "command_result", request_id="resync-agents")
assert result["payload"]["ok"] is True
bootstrap = await receive_until(child, "collection_bootstrap")
assert bootstrap["payload"]["collection"] == "agents"
assert bootstrap["payload"]["revision"] == 3
assert len(bootstrap["payload"]["items"]) == 39
finally:
child.close()
await server.close()
@pytest.mark.asyncio
async def test_bootstrap_larger_than_64_mib_has_no_total_message_ceiling(
monkeypatch: pytest.MonkeyPatch,
) -> None:
server = TuiBackendServer(TuiController(args()))
shared_projection = "x" * (1024 * 1024)
items = [{"id": f"event-{index}", "content": shared_projection} for index in range(65)]
frames: list[dict[str, Any]] = []
encoded_sizes: list[int] = []
async def capture(message: dict[str, Any]) -> None:
encoded_sizes.append(len(server._encode(message)))
frames.append(message)
monkeypatch.setattr(server, "_send", capture)
await server._send_collection_frames(
"collection_bootstrap",
{"collection": "events", "revision": 1},
"items",
items,
)
assert sum(len(item["content"]) for item in items) > 64 * 1024 * 1024
assert len(frames) > 16
assert max(encoded_sizes) <= 4 * 1024 * 1024
assert frames[0]["payload"]["cursor"] == 0
assert frames[-1]["payload"]["next_cursor"] == len(items)
assert frames[-1]["payload"]["done"] is True
@pytest.mark.asyncio
async def test_oversized_terminal_projection_is_truncated_without_mutating_history() -> None:
controller = TuiController(args())
durable = "x" * (2 * 1024 * 1024)
controller.live_view.record_user_message("agent", durable)
projected = controller.collection("events")
assert len(projected[0]["data"]["content"]) < len(durable)
assert controller.live_view.events[0]["data"]["content"] == durable
def test_terminal_projection_strips_ansi_osc_and_c1_controls() -> None:
controller = TuiController(args())
hostile = "safe\x1b[31mred\x1b[0m\x1b]52;c;Y2xpcGJvYXJk\x07\x85tail"
controller.live_view.record_user_message("agent", hostile)
projected = controller.collection_snapshot("events")[1][0]["data"]["content"]
assert projected == "saferedtail"
assert "\x1b" not in projected
hostile_mapping = {"header\x1b]52;c;Y2xpcA==\x07": "value"}
assert list(terminal_projection(hostile_mapping)) == ["header"]
assert list(TuiBackendServer._sanitize_wire_value(hostile_mapping)) == ["header"]
def test_terminal_event_history_is_bounded_without_changing_durable_sessions() -> None:
controller = TuiController(args())
for index in range(10_050):
controller.live_view.record_user_message("agent", f"message-{index}")
_cursor, projected = controller.collection_snapshot("events")
assert len(controller.live_view.events) == 10_000
assert len(projected) == 5_000
assert projected[0]["data"]["content"] == "message-5050"
assert projected[-1]["data"]["content"] == "message-10049"
@pytest.mark.asyncio
async def test_oversized_command_frame_is_rejected_before_payload_read() -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
server = TuiBackendServer(TuiController(args()))
await start_server(server, backend, child)
try:
await asyncio.get_running_loop().sock_sendall(
child, struct.pack(">I", MAX_COMMAND_BYTES + 1)
)
assert server._reader_task is not None
await asyncio.wait_for(server._reader_task, timeout=1)
assert server._socket is None
finally:
child.close()
await server.close()
@pytest.mark.asyncio
async def test_server_stops_when_peer_closes() -> None:
backend, child = socket.socketpair()
child.setblocking(False) # noqa: FBT003
server = TuiBackendServer(TuiController(args()))
await start_server(server, backend, child)
child.close()
try:
assert server._reader_task is not None
await asyncio.wait_for(server._reader_task, timeout=1)
finally:
await server.close()
def test_image_data_uri_survives_terminal_projection() -> None:
uri = "data:image/png;base64," + "A" * 100_000
assert terminal_projection(uri) == uri
assert terminal_projection({"type": "image", "image_url": uri})["image_url"] == uri
oversized = "data:image/png;base64," + "A" * (3 * 1024 * 1024)
assert terminal_projection(oversized) == "[image omitted from terminal projection]"
def test_view_image_tool_output_is_normalized_to_image_dict() -> None:
uri = "data:image/png;base64," + "B" * 4000
view = TuiLiveView()
view._record_tool_output_data(
"agent",
{
"call_id": "c1",
"tool_name": "view_image",
"output": ToolOutputImage(type="image", image_url=uri),
},
)
view._record_tool_output_data(
"agent",
{
"call_id": "c2",
"tool_name": "view_image",
"output": [{"type": "input_image", "image_url": uri}],
},
)
for event in view.events:
assert event["data"]["result"] == {"type": "image", "image_url": uri}
+49
View File
@@ -0,0 +1,49 @@
"""Guard against the Python and Go protocol constants drifting apart.
The wire protocol is declared twice — ``strix/interface/tui/backend/protocol.py``
for the backend and ``strix/interface/tui/internal/protocol/protocol.go`` for the
sidecar. This test parses the Go source shipped in the tree and checks the two
declarations agree, so a version or capability change in one language cannot
land silently without the other.
"""
from __future__ import annotations
import re
from pathlib import Path
from strix.interface.tui.backend.protocol import PROTOCOL_CAPABILITIES, PROTOCOL_VERSION
GO_PROTOCOL_SOURCE = (
Path(__file__).resolve().parents[1]
/ "strix"
/ "interface"
/ "tui"
/ "internal"
/ "protocol"
/ "protocol.go"
)
def test_go_protocol_source_is_present() -> None:
assert GO_PROTOCOL_SOURCE.is_file()
def test_protocol_version_matches_go() -> None:
source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8")
match = re.search(r"^const Version = (\d+)$", source, flags=re.MULTILINE)
assert match is not None, "const Version not found in protocol.go"
assert int(match.group(1)) == PROTOCOL_VERSION
def test_protocol_capabilities_match_go() -> None:
source = GO_PROTOCOL_SOURCE.read_text(encoding="utf-8")
match = re.search(
r"^var Capabilities = \[\]string\{\n(?P<body>(?:\t\"[^\"]+\",\n)+)\}",
source,
flags=re.MULTILINE,
)
assert match is not None, "var Capabilities not found in protocol.go"
go_capabilities = re.findall(r"\"([^\"]+)\"", match.group("body"))
assert tuple(go_capabilities) == PROTOCOL_CAPABILITIES
+281
View File
@@ -0,0 +1,281 @@
"""Resumed history must attribute only typed messages to the user.
Guidance the system feeds an agent is injected as a user turn, so replayed
history cannot tell it apart from a typed message by role alone. A live run only
shows what the user actually typed; resuming has to match that.
"""
from __future__ import annotations
import json
import sqlite3
from typing import TYPE_CHECKING, Any
import pytest
from strix.core.paths import runtime_state_dir
from strix.interface.tui.backend.live_view import TuiLiveView as GoTuiLiveView
from strix.interface.tui.live_view import TuiLiveView, _is_internal_agent_turn
if TYPE_CHECKING:
from pathlib import Path
def _write_run(run_dir: Path, items: list[dict[str, Any]], agent_id: str = "root") -> None:
"""Persist an agent snapshot plus a session history for hydration to read."""
state_dir = runtime_state_dir(run_dir)
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "agents.json").write_text(
json.dumps({"statuses": {agent_id: "running"}, "names": {agent_id: "recon"}}),
encoding="utf-8",
)
connection = sqlite3.connect(state_dir / "agents.db")
try:
connection.execute(
"create table agent_messages (id integer primary key, session_id text, "
"message_data text, created_at text)"
)
for index, item in enumerate(items, start=1):
connection.execute(
"insert into agent_messages (id, session_id, message_data, created_at) "
"values (?, ?, ?, ?)",
(index, agent_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"),
)
connection.commit()
finally:
connection.close()
def _user_messages(view: TuiLiveView) -> list[str]:
return [
str(event["data"]["content"])
for event in view.events
if event.get("type") == "chat" and event["data"].get("role") == "user"
]
def test_resume_hides_system_guidance_injected_as_user_turns(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
_write_run(
run_dir,
[
# The task the agent was launched with, not a typed message.
{"role": "user", "content": "\n\nURLs: - https://example.com"},
{"role": "assistant", "content": "starting"},
{
"role": "user",
"content": "[Message from system (system) | type=auto_resume | priority=normal]\n"
"Waiting timeout reached.",
},
{"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."},
# A stall notice reaches the parent through the coordinator, so it
# arrives wrapped rather than as a bare "[Agent stalled]".
{
"role": "user",
"content": "[Message from recon (a1) | type=stalled | priority=high]\n"
"[Agent stalled] recon (a1) kept ending turns",
},
{
"role": "user",
"content": "Your previous message ended a turn without a tool call. "
"Plain text never ends execution.",
},
{"role": "assistant", "content": "continuing"},
],
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == []
# The agent's own side of the conversation is untouched.
assert [
str(event["data"]["content"])
for event in view.events
if event.get("type") == "chat" and event["data"].get("role") == "assistant"
] == ["starting", "continuing"]
def test_resume_keeps_messages_the_user_actually_typed(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
_write_run(
run_dir,
[
{"role": "user", "content": "\n\nURLs: - https://example.com"},
{"role": "assistant", "content": "starting"},
{"role": "user", "content": "check the coupon endpoint next"},
{"role": "assistant", "content": "on it"},
{"role": "user", "content": "[NOTICE] Turn budget: 350/500 used (70%)."},
{"role": "user", "content": "stop testing the admin panel"},
],
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == [
"check the coupon endpoint next",
"stop testing the admin panel",
]
def test_resume_treats_each_agents_first_user_turn_as_its_task(tmp_path: Path) -> None:
"""Subagents get their task the same way, so it is skipped per agent."""
run_dir = tmp_path / "run"
state_dir = runtime_state_dir(run_dir)
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "agents.json").write_text(
json.dumps(
{
"statuses": {"root": "running", "child": "running"},
"names": {"root": "root", "child": "recon"},
"parent_of": {"child": "root"},
}
),
encoding="utf-8",
)
connection = sqlite3.connect(state_dir / "agents.db")
try:
connection.execute(
"create table agent_messages (id integer primary key, session_id text, "
"message_data text, created_at text)"
)
rows = [
("root", {"role": "user", "content": "\n\nURLs: - https://example.com"}),
("child", {"role": "user", "content": "Audit the login flow."}),
("child", {"role": "user", "content": "also try the password reset"}),
]
for index, (session_id, item) in enumerate(rows, start=1):
connection.execute(
"insert into agent_messages (id, session_id, message_data, created_at) "
"values (?, ?, ?, ?)",
(index, session_id, json.dumps(item), f"2026-01-01T00:00:{index:02d}+00:00"),
)
connection.commit()
finally:
connection.close()
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
# Both tasks are skipped; only the follow-up typed at the child remains.
assert _user_messages(view) == ["also try the password reset"]
def test_internal_turn_classifier_matches_every_injected_form() -> None:
for content in (
# Coordinator deliveries, which wrap the stall, terminal and budget notices.
"[Message from recon (a1) | type=information | priority=normal]\nfound it",
"[Message from recon (a1) | type=stalled | priority=high]\n[Agent stalled] recon (a1)",
"[Message from system (system) | type=budget_extended | priority=normal]\n"
"[Budget] extended",
# Budget warnings, the only notices injected without a wrapper.
"[NOTICE] Turn budget: 350/500 used (70%).",
"[URGENT] Scan cost budget: $9.50/$10.00 spent (95%).",
"[CRITICAL] Turn budget: 480/500 used (96%).",
"== Inherited context from parent (background only) ==",
"Your previous message ended a turn without a tool call.",
"Your previous response ended the autonomous Strix run without a lifecycle tool call.",
):
assert _is_internal_agent_turn(content), content
def test_internal_turn_classifier_keeps_bracketed_user_text() -> None:
"""A leading bracket is not enough: typed text often starts with one."""
for content in (
'[{"id": 1, "role": "admin"}, {"id": 2}]',
"[link](https://example.com) check this endpoint",
"[URGENT] stop testing the admin panel",
"[2026-01-01 12:00:03] ERROR auth failed - look into this",
"[note] creds are admin:hunter2",
"[Agent] can you check this?",
"[]",
"check the coupon endpoint next",
"Use creds admin:hunter2 for the login form",
"stop",
):
assert not _is_internal_agent_turn(content), content
@pytest.mark.parametrize("view_class", [TuiLiveView, GoTuiLiveView])
def test_user_instruction_opens_the_transcript_when_the_root_agent_appears(
view_class: type[TuiLiveView],
) -> None:
"""A live scan has no root agent yet, so the message waits for it.
Exercised against the projection the Go TUI actually uses as well as the
base one: that subclass overrides upsert_agent without calling back, so a
hook placed there would silently never run.
"""
view = view_class()
view.set_user_instruction("find IDOR in the checkout flow")
assert _user_messages(view) == []
view.upsert_agent("ab12", name="Strix", parent_id=None, status="running")
assert view.flush_user_instruction() is True
assert _user_messages(view) == ["find IDOR in the checkout flow"]
# Repeated agent syncs and subagents must not repeat it.
view.upsert_agent("cd34", name="recon", parent_id="ab12", status="running")
view.upsert_agent("ab12", status="running")
assert view.flush_user_instruction() is False
assert _user_messages(view) == ["find IDOR in the checkout flow"]
def test_blank_user_instruction_adds_nothing() -> None:
view = TuiLiveView()
view.set_user_instruction(" ")
view.set_user_instruction(None)
view.upsert_agent("ab12", name="Strix", parent_id=None, status="running")
assert _user_messages(view) == []
def test_replayed_run_opens_with_the_users_instruction(tmp_path: Path) -> None:
"""It comes from the run record and sorts ahead of replayed history."""
run_dir = tmp_path / "run"
_write_run(
run_dir,
[
{"role": "user", "content": "\n\nURLs: - https://example.com"},
{"role": "assistant", "content": "starting"},
{"role": "user", "content": "also check coupons"},
],
)
(run_dir / "run.json").write_text(
json.dumps(
{
"start_time": "2026-01-01T00:00:00+00:00",
# instruction carries the diff-scope preamble; only the user's own
# text belongs in the transcript.
"instruction": "[diff-scope preamble]\n\naudit the auth flow",
"user_instruction": "audit the auth flow",
}
),
encoding="utf-8",
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == ["audit the auth flow", "also check coupons"]
first = view.events[0]
assert first["data"]["content"] == "audit the auth flow"
# Stamped with the run's start, so ordering by timestamp keeps it first.
assert first["timestamp"] == "2026-01-01T00:00:00+00:00"
def test_replayed_run_without_an_instruction_is_unchanged(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
_write_run(run_dir, [{"role": "assistant", "content": "starting"}])
(run_dir / "run.json").write_text(
json.dumps({"start_time": "2026-01-01T00:00:00+00:00"}), encoding="utf-8"
)
view = TuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert _user_messages(view) == []
+53
View File
@@ -0,0 +1,53 @@
import sys
import pytest
import urllib3.response
from strix.telemetry import logging as tlog
from strix.telemetry.logging import _is_urllib3_closed_file_noise
class _Args:
def __init__(self, exc_value: BaseException | None, obj: object) -> None:
self.exc_type = type(exc_value) if exc_value is not None else None
self.exc_value = exc_value
self.exc_traceback = None
self.err_msg = None
self.object = obj
def _urllib3_response() -> urllib3.response.HTTPResponse:
return urllib3.response.HTTPResponse(body=b"")
def test_filters_urllib3_closed_file_noise() -> None:
args = _Args(ValueError("I/O operation on closed file."), _urllib3_response())
assert _is_urllib3_closed_file_noise(args) # type: ignore[arg-type]
def test_passes_through_other_unraisables() -> None:
assert not _is_urllib3_closed_file_noise(
_Args(ValueError("I/O operation on closed file."), object()) # type: ignore[arg-type]
)
assert not _is_urllib3_closed_file_noise(
_Args(RuntimeError("boom"), _urllib3_response()) # type: ignore[arg-type]
)
assert not _is_urllib3_closed_file_noise(
_Args(ValueError("something else"), _urllib3_response()) # type: ignore[arg-type]
)
def test_installed_hook_filters_and_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[object] = []
monkeypatch.setattr(sys, "unraisablehook", calls.append)
monkeypatch.setattr(tlog, "_unraisable_hook_installed", False)
tlog._silence_urllib3_finalizer_noise()
hook = sys.unraisablehook
assert hook is not calls.append
hook(_Args(ValueError("I/O operation on closed file."), _urllib3_response())) # type: ignore[arg-type]
assert calls == []
other = _Args(RuntimeError("boom"), object())
hook(other) # type: ignore[arg-type]
assert calls == [other]