strix/tests/test_scan_fixes.py

106 lines
3.9 KiB
Python

"""Exercise live-scan worktree isolation with native tools and scripted inference."""
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from agents import RunConfig
from agents.sandbox import SandboxRunConfig
from strix.core.agents import AgentCoordinator
from strix.core.hooks import ReportUsageHooks
from strix.fix import FindingContext
from strix.fix import runtime as fix_runtime
from strix.fix.scan import ScanFixes
from strix.fix.session import WorktreeSession
from tests.test_fix_completion import ScriptedModel, finish, patch, suite_commands
from tests.test_fix_reliability import LocalSandbox, existing_suite
from tests.test_fix_runtime import _git, _request, _workspace
def setup(tmp_path):
source, _ = _workspace(tmp_path)
commit = existing_suite(source)
parent = LocalSandbox(tmp_path / "sandbox")
coordinator = AgentCoordinator()
fixes = ScanFixes(
session=parent,
coordinator=coordinator,
parent_id="root",
scan_id="scan",
state_dir=tmp_path / "state",
local_sources=[{"source_path": str(source)}],
hooks=ReportUsageHooks(model="test", max_turns=1000),
)
fixes.base = str(tmp_path / "sandbox" / "fixes")
candidate = _request(commit).candidate
candidate.finding = FindingContext(validation_status="confirmed", title="Unsafe result")
report = {
"id": "finding",
"validation_status": "confirmed",
"fix_candidate": candidate.model_dump(mode="json"),
}
return fixes, report, source, parent
@pytest.mark.asyncio
async def test_parallel_fixes_use_worktrees_without_modifying_scan_source(tmp_path, monkeypatch):
fixes, report, source, parent = setup(tmp_path)
models = {}
def config(env):
model = models.setdefault(
env.execution_id, ScriptedModel([*patch(), *suite_commands(), finish("done")])
)
return RunConfig(
model=model, sandbox=SandboxRunConfig(session=env.session), tracing_disabled=True
)
monkeypatch.setattr(fix_runtime, "_run_config", config)
fixes.notify(report)
fixes.notify({**report, "id": "another-finding"})
await fixes.wait()
assert len(models) == 2
assert all(record["status"] == "done" for record in fixes.records.values()), fixes.records
assert all(Path(record["artifact"]).exists() for record in fixes.records.values())
assert _git(source, "status", "--porcelain") == ""
assert "unsafe" in (source / "app.py").read_text()
assert parent.state.manifest.root == str(tmp_path / "sandbox")
assert not list((tmp_path / "sandbox/fixes/worktrees").glob("*/app.py"))
assert not list((tmp_path / "state/fixes").glob("*/source"))
@pytest.mark.asyncio
async def test_unconfirmed_duplicate_and_exhausted_findings_do_not_start_agent(tmp_path):
fixes, report, _, _ = setup(tmp_path)
fixes._run = AsyncMock()
fixes.notify({**report, "validation_status": "unconfirmed"})
await asyncio.sleep(0)
fixes._run.assert_not_called()
fixes.notify(report)
fixes.notify(report)
await fixes.wait()
assert fixes._run.await_count == 1
fixes.closed = False
fixes.records["finding"]["turns"] = 300
fixes.records["finding"]["status"] = "running"
fixes.tasks.clear()
fixes.notify(report)
await fixes.wait()
assert fixes._run.await_count == 1
@pytest.mark.asyncio
async def test_worktree_process_cleanup_never_terminates_parent_sessions(tmp_path):
parent = LocalSandbox(tmp_path)
parent.exec = AsyncMock()
parent.pty_terminate_all = AsyncMock()
child = WorktreeSession(parent, str(tmp_path / "fix"), "fix-one")
await child.pty_terminate_all()
parent.pty_terminate_all.assert_not_called()
assert parent.exec.call_args.args[3] == "fix-one"
with pytest.raises(ValueError, match="another agent"):
await child.pty_write_stdin(session_id=123, chars="kill")