"""Tests for the broken-set short-circuit added to handle outer-timeout failures. When ``snapshot_baseline`` or ``get_diagnostics_sync`` time out from the service layer (because a language server hangs during initialize, or the binary is wedged), the inner spawn task is cancelled — but the inner exception handler that adds to ``_broken`` never runs. Without the service-layer fallback added in this module, every subsequent edit re-pays the full timeout cost until the process exits. This module verifies: - ``_mark_broken_for_file`` adds the right key - ``enabled_for`` short-circuits on broken keys - a missing binary is broken-set'd after one snapshot attempt """ from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from agent.lsp.manager import LSPService from agent.lsp.workspace import clear_cache @pytest.fixture(autouse=True) def _clear_workspace_cache(): clear_cache() yield clear_cache() def _make_git_workspace(tmp_path: Path) -> Path: """Build a minimal git repo with a pyproject so pyright's root resolver fires.""" repo = tmp_path / "repo" repo.mkdir() (repo / ".git").mkdir() (repo / "pyproject.toml").write_text("[project]\nname='t'\n") return repo def test_mark_broken_for_file_adds_correct_key(tmp_path, monkeypatch): """``_mark_broken_for_file`` keys the broken-set on (server_id, per_server_root) so subsequent ``enabled_for`` calls for files in the same project skip immediately.""" repo = _make_git_workspace(tmp_path) monkeypatch.chdir(str(repo)) src = repo / "x.py" src.write_text("") svc = LSPService( enabled=True, wait_mode="document", wait_timeout=2.0, install_strategy="manual", ) try: svc._mark_broken_for_file(str(src), RuntimeError("simulated")) # The pyright server resolves to the repo root via pyproject.toml. assert ("pyright", str(repo)) in svc._broken finally: svc.shutdown() def test_enabled_for_returns_false_after_broken(tmp_path, monkeypatch): """Once a (server_id, root) pair is in the broken-set, ``enabled_for`` returns False so the file_operations layer skips the LSP path entirely.""" repo = _make_git_workspace(tmp_path) monkeypatch.chdir(str(repo)) src = repo / "x.py" src.write_text("") svc = LSPService( enabled=True, wait_mode="document", wait_timeout=2.0, install_strategy="manual", ) try: # Initially enabled. assert svc.enabled_for(str(src)) is True # Mark broken. svc._mark_broken_for_file(str(src), RuntimeError("simulated")) # Now disabled — the broken-set short-circuits. assert svc.enabled_for(str(src)) is False finally: svc.shutdown() def test_enabled_for_other_file_in_same_project_also_skipped(tmp_path, monkeypatch): """The broken key is (server_id, root), so ALL files routed through the same server in the same project are skipped — not just the one that triggered the failure.""" repo = _make_git_workspace(tmp_path) monkeypatch.chdir(str(repo)) a = repo / "a.py" a.write_text("") b = repo / "b.py" b.write_text("") svc = LSPService( enabled=True, wait_mode="document", wait_timeout=2.0, install_strategy="manual", ) try: svc._mark_broken_for_file(str(a), RuntimeError("simulated")) # Both files in the same project skip pyright now. assert svc.enabled_for(str(a)) is False assert svc.enabled_for(str(b)) is False finally: svc.shutdown() def test_unrelated_project_not_affected_by_broken(tmp_path, monkeypatch): """Marking pyright broken for project A must NOT affect project B.""" repo_a = _make_git_workspace(tmp_path) repo_b = tmp_path / "repo-b" repo_b.mkdir() (repo_b / ".git").mkdir() (repo_b / "pyproject.toml").write_text("[project]\nname='b'\n") a_src = repo_a / "x.py" a_src.write_text("") b_src = repo_b / "x.py" b_src.write_text("") monkeypatch.chdir(str(repo_a)) svc = LSPService( enabled=True, wait_mode="document", wait_timeout=2.0, install_strategy="manual", ) try: svc._mark_broken_for_file(str(a_src), RuntimeError("simulated")) # Project A skipped. assert svc.enabled_for(str(a_src)) is False # Project B still enabled — the broken key is per-project. monkeypatch.chdir(str(repo_b)) assert svc.enabled_for(str(b_src)) is True finally: svc.shutdown() def test_mark_broken_handles_missing_server_silently(tmp_path): """If the file extension doesn't match any registered server, ``_mark_broken_for_file`` no-ops — nothing to mark.""" svc = LSPService( enabled=True, wait_mode="document", wait_timeout=2.0, install_strategy="manual", ) try: # No registered server for .xyz; must not raise. svc._mark_broken_for_file(str(tmp_path / "weird.xyz"), RuntimeError("x")) assert len(svc._broken) == 0 finally: svc.shutdown() def test_mark_broken_handles_no_workspace_silently(tmp_path): """File outside any git worktree → no workspace → no key to add.""" src = tmp_path / "orphan.py" src.write_text("") svc = LSPService( enabled=True, wait_mode="document", wait_timeout=2.0, install_strategy="manual", ) try: svc._mark_broken_for_file(str(src), RuntimeError("x")) assert len(svc._broken) == 0 finally: svc.shutdown() def test_snapshot_failure_marks_broken_via_outer_timeout(tmp_path, monkeypatch): """End-to-end: ``snapshot_baseline``'s outer ``_loop.run`` timeout triggers ``_mark_broken_for_file``, so a second call to ``enabled_for`` returns False.""" repo = _make_git_workspace(tmp_path) monkeypatch.chdir(str(repo)) src = repo / "x.py" src.write_text("") svc = LSPService( enabled=True, wait_mode="document", wait_timeout=2.0, install_strategy="manual", ) try: # Force the inner snapshot coroutine to raise. async def boom(_path): raise RuntimeError("outer-timeout simulated") with patch.object(svc, "_snapshot_async", boom): assert svc.enabled_for(str(src)) is True svc.snapshot_baseline(str(src)) # After the failure, the file's pair is in the broken-set and # ``enabled_for`` skips it. assert ("pyright", str(repo)) in svc._broken assert svc.enabled_for(str(src)) is False finally: svc.shutdown()