{"repo":"NousResearch/hermes-agent","public_id":"22401","display_label":"pytest-split dependency and uv venv test script fixes","summary":"Multiple issues and fix attempts address run_tests.sh failing due to undeclared pytest-split and incompatibility with uv virtual environments. Related fixes also handle unsetting HERMES_CRON_SESSION in the test script.","subsystem":"other","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22767},"resolution":{"state":"resolved","by":"pr-22767"},"freshness":{"state":"verified","last_verified_at":"2026-05-21T11:10:04+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.25},"fix_edges":[{"issue_number":22400,"pr_number":22767,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22401,"pr_number":22767,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":22401,"pr_number":29016,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":22401,"title":"scripts/run_tests.sh requires undeclared pytest-split and fails in uv venvs without pip","url":null,"state":"closed"},{"kind":"pr","number":22440,"title":"fix(tests): declare pytest-split in dev extra; make run_tests.sh uv-aware","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"48be2e0e4dbc4489f418e8d58794790c9c830390","number":0},{"kind":"pr","value":"#29016","number":29016},{"kind":"pr","value":"#22401","number":22401}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22476,"title":"fix(scripts): make run_tests.sh pytest-split bootstrap uv-aware","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22440","number":22440},{"kind":"pr","value":"#22401","number":22401},{"kind":"pr","value":"#22440","number":22440}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":22503,"title":"fix(scripts): support uv venv for pytest-split + unset HERMES_CRON_SESSION","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22767","number":22767}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":22504,"title":"fix(deps): declare pytest-split in dev extras","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f00dc6d7a3a1d1a1cc5e98507d2efb201990f517","number":0},{"kind":"pr","value":"#22767","number":22767},{"kind":"pr","value":"#22401","number":22401}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22730,"title":"fix(scripts): declare pytest-split in dev deps and unset HERMES_CRON_SESSION","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22767","number":22767},{"kind":"pr","value":"#22767","number":22767},{"kind":"pr","value":"#22401","number":22401}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22767,"title":"fix(tests): harden run_tests.sh (uv-aware bootstrap + scrub HERMES_CRON_SESSION)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["pyproject.toml","scripts/run_tests.sh"],"fix_hunks":[{"file":"pyproject.toml","hunks":["@@ -54,7 +54,7 @@ dependencies = [\n modal = [\"modal>=1.0.0,<2\"]\n daytona = [\"daytona>=0.148.0,<1\"]\n vercel = [\"vercel>=0.5.7,<0.6.0\"]\n-dev = [\"debugpy>=1.8.0,<2\", \"pytest>=9.0.2,<10\", \"pytest-asyncio>=1.3.0,<2\", \"pytest-xdist>=3.0,<4\", \"mcp>=1.2.0,<2\", \"ty>=0.0.1a29,<0.0.22\", \"ruff\"]\n+dev = [\"debugpy>=1.8.0,<2\", \"pytest>=9.0.2,<10\", \"pytest-asyncio>=1.3.0,<2\", \"pytest-xdist>=3.0,<4\", \"pytest-split>=0.9,<1\", \"mcp>=1.2.0,<2\", \"ty>=0.0.1a29,<0.0.22\", \"ruff\"]\n messaging = [\"python-telegram-bot[webhooks]>=22.6,<23\", \"discord.py[voice]>=2.7.1,<3\", \"aiohttp>=3.13.3,<4\", \"slack-bolt>=1.18.0,<2\", \"slack-sdk>=3.27.0,<4\", \"qrcode>=7.0,<8\"]\n cron = []  # croniter is now a core dependency; this extra kept for back-compat\n slack = [\"slack-bolt>=1.18.0,<2\", \"slack-sdk>=3.27.0,<4\"]"]},{"file":"scripts/run_tests.sh","hunks":["@@ -44,7 +44,15 @@ PYTHON=\"$VENV/bin/python\"\n # ── Ensure pytest-split is installed (required for shard-equivalent runs) ──\n if ! \"$PYTHON\" -c \"import pytest_split\" 2>/dev/null; then\n   echo \"→ installing pytest-split into $VENV\"\n-  \"$PYTHON\" -m pip install --quiet \"pytest-split>=0.9,<1\"\n+  if command -v uv >/dev/null 2>&1; then\n+    uv pip install --python \"$PYTHON\" --quiet \"pytest-split>=0.9,<1\"\n+  elif \"$PYTHON\" -m pip --version >/dev/null 2>&1; then\n+    \"$PYTHON\" -m pip install --quiet \"pytest-split>=0.9,<1\"\n+  else\n+    echo \"error: neither uv nor pip is available in $VENV — pytest-split is missing\" >&2\n+    echo \"  fix: run  uv pip install -e \\\".[dev]\\\"  from $REPO_ROOT\" >&2\n+    exit 1\n+  fi\n fi\n \n # ── Hermetic environment ────────────────────────────────────────────────────","@@ -67,6 +75,7 @@ unset HERMES_YOLO_MODE HERMES_INTERACTIVE HERMES_QUIET HERMES_TOOL_PROGRESS \\\n       HERMES_TOOL_PROGRESS_MODE HERMES_MAX_ITERATIONS HERMES_SESSION_PLATFORM \\\n       HERMES_SESSION_CHAT_ID HERMES_SESSION_CHAT_NAME HERMES_SESSION_THREAD_ID \\\n       HERMES_SESSION_SOURCE HERMES_SESSION_KEY HERMES_GATEWAY_SESSION \\\n+      HERMES_CRON_SESSION \\\n       HERMES_PLATFORM HERMES_INFERENCE_PROVIDER HERMES_MANAGED HERMES_DEV \\\n       HERMES_CONTAINER HERMES_EPHEMERAL_SYSTEM_PROMPT HERMES_TIMEZONE \\\n       HERMES_REDACT_SECRETS HERMES_BACKGROUND_NOTIFICATIONS HERMES_EXEC_ASK \\\n"]}]},{"kind":"pr","number":29016,"title":"test: use subprocesses for each test file","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".github/workflows/tests.yml",".gitignore","AGENTS.md","hermes_cli/main.py","hermes_cli/profiles.py","pyproject.toml","scripts/run_tests.sh","scripts/run_tests_parallel.py","tests/agent/test_auxiliary_client.py","tests/agent/test_skill_commands.py","tests/conftest.py","tests/gateway/conftest.py","tests/gateway/test_dm_topics.py","tests/gateway/test_google_chat.py","tests/hermes_cli/test_pty_bridge.py","tests/plugins/test_achievements_plugin.py","tests/plugins/web/test_web_search_provider_plugins.py","tests/test_run_tests_parallel.py","tests/tools/conftest.py","tests/tools/test_approval_plugin_hooks.py","tests/tools/test_browser_supervisor.py","tests/tools/test_discord_tool.py","tests/tools/test_homeassistant_tool.py","tests/tools/test_kanban_tools.py","tests/tools/test_send_message_tool.py","tests/tools/test_terminal_tool_requirements.py","tests/tools/test_video_generation_tool_surface_matrix.py","tests/tools/test_web_providers.py","tests/tools/test_web_providers_brave_free.py","tests/tools/test_web_providers_ddgs.py","tests/tools/test_web_providers_searxng.py","tests/tools/test_web_tools_tavily.py","tests/tools/test_website_policy.py","tests/tools/test_write_deny.py"],"fix_hunks":[{"file":".github/workflows/tests.yml","hunks":["@@ -23,13 +23,24 @@ concurrency:\n jobs:\n   test:\n     runs-on: ubuntu-latest\n-    timeout-minutes: 30\n+    timeout-minutes: 60\n     steps:\n       - name: Checkout code\n         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2\n \n-      - name: Install system dependencies\n-        run: sudo apt-get update && sudo apt-get install -y ripgrep\n+      - name: Install ripgrep (prebuilt binary)\n+        run: |\n+          set -euo pipefail\n+          RG_VERSION=15.1.0\n+          RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599\n+          RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz\n+          curl -sSfL -o \"$RG_TARBALL\" \\\n+            \"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}\"\n+          echo \"${RG_SHA256}  ${RG_TARBALL}\" | sha256sum -c -\n+          tar -xzf \"$RG_TARBALL\"\n+          sudo mv \"ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl/rg\" /usr/local/bin/rg\n+          rm -rf \"$RG_TARBALL\" \"ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl\"\n+          rg --version\n \n       - name: Install uv\n         uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86  # v5","@@ -44,9 +55,26 @@ jobs:\n           uv pip install -e \".[all,dev]\"\n \n       - name: Run tests\n+        # Per-file isolation via scripts/run_tests_parallel.py: discovers\n+        # every test_*.py file under tests/ (excluding integration/ + e2e/),\n+        # then runs `python -m pytest <file>` in a freshly-spawned subprocess\n+        # with bounded parallelism. No xdist, no shared workers, no\n+        # module-level state leakage between files.\n+        #\n+        # Why per-file (not per-test): per-test spawn cost (~250ms × 17k\n+        # tests = 70min CPU minimum) blew the wall-clock budget. Per-file\n+        # spawn (~250ms × ~850 files = ~3.5min) fits while still giving\n+        # every file a fresh interpreter — the only isolation boundary\n+        # that matters in practice (cross-file leakage was the original\n+        # flake source; intra-file is the test author's responsibility).\n+        #\n+        # Why drop xdist entirely: xdist's persistent workers accumulate\n+        # state across files, which is exactly the leakage we wanted to\n+        # fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does\n+        # the job with cleaner semantics.\n         run: |\n           source .venv/bin/activate\n-          python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto --timeout=30 --timeout-method=signal\n+          python scripts/run_tests_parallel.py\n         env:\n           # Ensure tests don't accidentally call real APIs\n           OPENROUTER_API_KEY: \"\"","@@ -60,8 +88,19 @@ jobs:\n       - name: Checkout code\n         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2\n \n-      - name: Install system dependencies\n-        run: sudo apt-get update && sudo apt-get install -y ripgrep\n+      - name: Install ripgrep (prebuilt binary)\n+        run: |\n+          set -euo pipefail\n+          RG_VERSION=15.1.0\n+          RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599\n+          RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz\n+          curl -sSfL -o \"$RG_TARBALL\" \\\n+            \"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}\"\n+          echo \"${RG_SHA256}  ${RG_TARBALL}\" | sha256sum -c -\n+          tar -xzf \"$RG_TARBALL\"\n+          sudo mv \"ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl/rg\" /usr/local/bin/rg\n+          rm -rf \"$RG_TARBALL\" \"ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl\"\n+          rg --version\n \n       - name: Install uv\n         uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86  # v5"]},{"file":".gitignore","hunks":["@@ -18,6 +18,7 @@ __pycache__/web_tools.cpython-310.pyc\n logs/\n data/\n .pytest_cache/\n+.pytest-cache/\n tmp/\n temp_vision_images/\n hermes-*/*"]},{"file":"AGENTS.md","hunks":["@@ -1013,17 +1013,39 @@ def profile_env(tmp_path, monkeypatch):\n \n **ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces\n hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,\n-4 xdist workers matching GHA ubuntu-latest). Direct `pytest` on a 16+ core\n-developer machine with API keys set diverges from CI in ways that have caused\n-multiple \"works locally, fails in CI\" incidents (and the reverse).\n+`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`\n+on a 16+ core developer machine with API keys set diverges from CI in ways\n+that have caused multiple \"works locally, fails in CI\" incidents (and the reverse).\n \n ```bash\n scripts/run_tests.sh                                  # full suite, CI-parity\n scripts/run_tests.sh tests/gateway/                   # one directory\n scripts/run_tests.sh tests/agent/test_foo.py::test_x  # one test\n scripts/run_tests.sh -v --tb=long                     # pass-through pytest flags\n+scripts/run_tests.sh --no-isolate tests/foo/          # disable subprocess isolation (faster, for debugging)\n ```\n \n+### Subprocess-per-test isolation\n+\n+Every test runs in a freshly-spawned Python subprocess via the in-tree plugin\n+at `tests/_isolate_plugin.py`. This means module-level dicts/sets and\n+ContextVars from one test cannot leak into the next — the historic\n+`_reset_module_state` autouse fixture is gone.\n+\n+Implementation notes:\n+\n+- The plugin uses `multiprocessing.get_context(\"spawn\")`, which works on\n+  Linux, macOS, and Windows alike (POSIX `fork` is not used).\n+- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist\n+  parallelism amortizes this across cores; on a 20-core box the full suite\n+  finishes in roughly the same wall time as before, but flake-free.\n+- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.\n+  Hangs are killed and surfaced as a failure report.\n+- Pass `--no-isolate` to disable isolation — useful when debugging a single\n+  test interactively, or when you specifically want to verify state leakage.\n+- The plugin disables itself in child processes (sentinel envvar\n+  `HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.\n+\n ### Why the wrapper (and why the old \"just call pytest\" doesn't work)\n \n Five real sources of local-vs-CI drift the script closes:","@@ -1034,7 +1056,7 @@ Five real sources of local-vs-CI drift the script closes:\n | HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |\n | Timezone | Local TZ (PDT etc.) | UTC |\n | Locale | Whatever is set | C.UTF-8 |\n-| xdist workers | `-n auto` = all cores (20+ on a workstation) | `-n 4` matching CI |\n+| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |\n \n `tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest\n invocation (including IDE integrations) gets hermetic behavior — but the wrapper","@@ -1042,15 +1064,21 @@ is belt-and-suspenders.\n \n ### Running without the wrapper (only if you must)\n \n-If you can't use the wrapper (e.g. on Windows or inside an IDE that shells\n-pytest directly), at minimum activate the venv and pass `-n 4`:\n+If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),\n+at minimum activate the venv. The isolation plugin loads automatically from\n+`addopts` in `pyproject.toml`, so you get the same per-test process isolation\n+either way.\n \n ```bash\n source .venv/bin/activate   # or: source venv/bin/activate\n-python -m pytest tests/ -q -n 4\n+python -m pytest tests/ -q\n ```\n \n-Worker count above 4 will surface test-ordering flakes that CI never sees.\n+If you need to bypass isolation for fast feedback while debugging:\n+\n+```bash\n+python -m pytest tests/agent/test_foo.py -q --no-isolate\n+```\n \n Always run the full suite before pushing changes.\n "]},{"file":"hermes_cli/main.py","hunks":["@@ -6086,24 +6086,36 @@ def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]\n     them after a successful ``git pull`` so we can auto-roll-back instead of\n     leaving the user with a bricked install.\n \n+    The compiled ``.pyc`` is written to a temp directory rather than the\n+    source tree's ``__pycache__/`` so we don't race with concurrent test\n+    workers that walk the same dir, and so we don't leave a stale pyc\n+    behind in production if the next interpreter run picks a different\n+    Python version. The pyc is discarded on function return either way —\n+    we only care about the compile-or-not signal.\n+\n     Returns ``(ok, failing_path, error_message)``. ``ok=True`` means every\n     file parsed cleanly.\n     \"\"\"\n     import py_compile\n+    import tempfile\n \n     root = Path(root)\n-    for relpath in _UPDATE_CRITICAL_FILES:\n-        path = root / relpath\n-        if not path.exists():\n-            # Missing file is suspicious but not necessarily fatal — a future\n-            # refactor may legitimately remove one of these. Skip and move on.\n-            continue\n-        try:\n-            py_compile.compile(str(path), doraise=True)\n-        except py_compile.PyCompileError as exc:\n-            return False, str(path), str(exc)\n-        except OSError as exc:\n-            return False, str(path), f\"could not read: {exc}\"\n+    with tempfile.TemporaryDirectory(prefix=\"hermes-syntax-check-\") as tmpdir:\n+        for relpath in _UPDATE_CRITICAL_FILES:\n+            path = root / relpath\n+            if not path.exists():\n+                # Missing file is suspicious but not necessarily fatal — a future\n+                # refactor may legitimately remove one of these. Skip and move on.\n+                continue\n+            # Mirror the relative path under the tmpdir so two different\n+            # files with the same basename don't collide on the cfile name.\n+            cfile = Path(tmpdir) / (relpath.replace(\"/\", \"__\") + \"c\")\n+            try:\n+                py_compile.compile(str(path), cfile=str(cfile), doraise=True)\n+            except py_compile.PyCompileError as exc:\n+                return False, str(path), str(exc)\n+            except OSError as exc:\n+                return False, str(path), f\"could not read: {exc}\"\n     return True, None, None\n \n "]},{"file":"hermes_cli/profiles.py","hunks":["@@ -902,7 +902,49 @@ def delete_profile(name: str, yes: bool = False) -> Path:\n \n     # 4. Remove profile directory\n     try:\n-        shutil.rmtree(profile_dir)\n+        def _make_writable(func, path, exc):\n+            \"\"\"onexc/onerror handler: add +w on PermissionError so rmtree can proceed.\n+\n+            Handles two cases on NixOS (and other systems with read-only\n+            copies from immutable stores):\n+            1. The path itself isn't writable (e.g. a file with mode 0444)\n+            2. The *parent* directory isn't writable (e.g. mode 0555)\n+\n+            Compatible with both the ``onexc`` API (3.12+, receives an\n+            exception instance) and the ``onerror`` API (3.11-, receives\n+            ``sys.exc_info()`` tuple).\n+            \"\"\"\n+            import stat as _stat\n+            import sys as _sys\n+\n+            # Normalise the two callback signatures:\n+            #   onexc(func, path, exc_instance)   — 3.12+\n+            #   onerror(func, path, exc_info_tuple) — 3.11\n+            if isinstance(exc, tuple):\n+                exc = exc[1]  # exc_info → actual exception object\n+\n+            if isinstance(exc, PermissionError):\n+                # Make the path writable\n+                try:\n+                    os.chmod(path, os.stat(path).st_mode | _stat.S_IWUSR)\n+                except OSError:\n+                    pass\n+                # Also make the parent writable (needed for unlink/rmdir)\n+                parent = os.path.dirname(path)\n+                if parent:\n+                    try:\n+                        os.chmod(parent, os.stat(parent).st_mode | _stat.S_IWUSR)\n+                    except OSError:\n+                        pass\n+                func(path)\n+            else:\n+                raise\n+\n+        # ``onexc`` was added in 3.12; fall back to ``onerror`` on 3.11.\n+        try:\n+            shutil.rmtree(profile_dir, onexc=_make_writable)\n+        except TypeError:\n+            shutil.rmtree(profile_dir, onerror=_make_writable)\n         print(f\"✓ Removed {profile_dir}\")\n     except Exception as e:\n         print(f\"⚠ Could not remove {profile_dir}: {e}\")"]},{"file":"pyproject.toml","hunks":["@@ -84,7 +84,7 @@ modal = [\"modal==1.3.4\"]\n daytona = [\"daytona==0.155.0\"]\n vercel = [\"vercel==0.5.7\"]\n hindsight = [\"hindsight-client==0.6.1\"]\n-dev = [\"debugpy==1.8.20\", \"pytest==9.0.2\", \"pytest-asyncio==1.3.0\", \"pytest-xdist==3.8.0\", \"pytest-split==0.11.0\", \"pytest-timeout==2.4.0\", \"mcp==1.26.0\", \"ty==0.0.21\", \"ruff==0.15.10\"]\n+dev = [\"debugpy==1.8.20\", \"pytest==9.0.2\", \"pytest-asyncio==1.3.0\", \"pytest-timeout==2.4.0\", \"mcp==1.26.0\", \"ty==0.0.21\", \"ruff==0.15.10\"]\n messaging = [\"python-telegram-bot[webhooks]==22.6\", \"discord.py[voice]==2.7.1\", \"aiohttp==3.13.3\", \"brotlicffi==1.2.0.1\", \"slack-bolt==1.27.0\", \"slack-sdk==3.40.1\", \"qrcode==7.4.2\"]\n cron = []  # croniter is now a core dependency; this extra kept for back-compat\n slack = [\"slack-bolt==1.27.0\", \"slack-sdk==3.40.1\", \"aiohttp==3.13.3\"]","@@ -232,16 +232,12 @@ markers = [\n     \"integration: marks tests requiring external services (API keys, Modal, etc.)\",\n     \"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances\",\n ]\n-# pytest-timeout: per-test 60s hard cap with thread method.\n-# Discovered May 2026: the suite reliably hangs at ~96% on full runs even\n-# though every individual test completes in <30s. Root cause is leaked\n-# threads / atexit handlers accumulating across thousands of tests until\n-# something deadlocks at session teardown. Adding pytest-timeout (with\n-# thread method, which forces an interrupt into the test thread) breaks\n-# the deadlock — the suite then completes cleanly. The 60s cap is large\n-# enough that no legitimate test trips it; if a test exceeds it that's a\n-# real bug worth surfacing as a Timeout failure.\n-addopts = \"-m 'not integration' -n auto --timeout=30 --timeout-method=signal\"\n+# pytest-timeout: per-test 30s hard cap with signal method.\n+# This is the fallback inside each per-file pytest subprocess (see\n+# scripts/run_tests_parallel.py). Per-file isolation gives every test\n+# file a fresh Python interpreter; pytest-timeout catches Python-level\n+# hangs within a file.\n+addopts = \"-m 'not integration' --timeout=30 --timeout-method=signal\"\n \n [tool.ty.environment]\n python-version = \"3.13\""]},{"file":"scripts/run_tests.sh","hunks":["@@ -3,29 +3,36 @@\n # `pytest` directly to guarantee your local run matches CI behavior.\n #\n # What this script enforces:\n-#   * -n 4 xdist workers (CI has 4 cores; -n auto diverges locally)\n+#   * Per-file isolation via scripts/run_tests_parallel.py — each test\n+#     file runs in its own freshly-spawned `python -m pytest <file>`\n+#     subprocess. No xdist, no shared workers, no module-level leakage\n+#     between files.\n #   * TZ=UTC, LANG=C.UTF-8, PYTHONHASHSEED=0 (deterministic)\n-#   * Credential env vars blanked (conftest.py also does this, but this\n-#     is belt-and-suspenders for anyone running `pytest` outside of\n-#     our conftest path — e.g. calling pytest on a single file)\n-#   * Proper venv activation\n+#   * Env vars blanked (conftest.py also does this, but this\n+#     is belt-and-suspenders for anyone running pytest outside our\n+#     conftest path — e.g. on a single file)\n+#   * Proper venv activation (probes .venv, venv, then ~/.hermes/...)\n #\n # Usage:\n-#   scripts/run_tests.sh                     # full suite\n-#   scripts/run_tests.sh tests/agent/        # one directory\n-#   scripts/run_tests.sh tests/agent/test_foo.py::TestClass::test_method\n-#   scripts/run_tests.sh --tb=long -v        # pass-through pytest args\n+#   scripts/run_tests.sh                            # full suite\n+#   scripts/run_tests.sh -j 4                       # cap parallelism\n+#   scripts/run_tests.sh tests/agent/               # discover only here\n+#   scripts/run_tests.sh tests/agent/ tests/acp/    # multiple roots\n+#   scripts/run_tests.sh tests/foo.py               # single file\n+#   scripts/run_tests.sh tests/foo.py -- --tb=long  # path + pytest args\n+#   scripts/run_tests.sh -- -v --tb=long            # pytest args only\n+#\n+# Everything after a literal '--' is passed through to each per-file\n+# pytest invocation. Positional path arguments before '--' override\n+# the default discovery root (tests/).\n \n set -euo pipefail\n \n # ── Locate repo root ────────────────────────────────────────────────────────\n-# Works whether this is the main checkout or a worktree.\n SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n REPO_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\n \n # ── Activate venv ───────────────────────────────────────────────────────────\n-# Prefer a .venv in the current tree, fall back to the main checkout's venv\n-# (useful for worktrees where we don't always duplicate the venv).\n VENV=\"\"\n for candidate in \"$REPO_ROOT/.venv\" \"$REPO_ROOT/venv\" \"$HOME/.hermes/hermes-agent/venv\"; do\n   if [ -f \"$candidate/bin/activate\" ]; then","@@ -41,94 +48,31 @@ fi\n \n PYTHON=\"$VENV/bin/python\"\n \n-# ── Ensure pytest-split is installed (required for shard-equivalent runs) ──\n-if ! \"$PYTHON\" -c \"import pytest_split\" 2>/dev/null; then\n-  echo \"→ installing pytest-split into $VENV\"\n-  if command -v uv >/dev/null 2>&1; then\n-    uv pip install --python \"$PYTHON\" --quiet \"pytest-split>=0.9,<1\"\n-  elif \"$PYTHON\" -m pip --version >/dev/null 2>&1; then\n-    \"$PYTHON\" -m pip install --quiet \"pytest-split>=0.9,<1\"\n-  else\n-    echo \"error: neither uv nor pip is available in $VENV — pytest-split is missing\" >&2\n-    echo \"  fix: run  uv pip install -e \\\".[dev]\\\"  from $REPO_ROOT\" >&2\n-    exit 1\n-  fi\n-fi\n-\n-# ── Hermetic environment ────────────────────────────────────────────────────\n-# Mirror what CI does in .github/workflows/tests.yml + what conftest.py does.\n-# Unset every credential-shaped var currently in the environment.\n-while IFS='=' read -r name _; do\n-  case \"$name\" in\n-    *_API_KEY|*_TOKEN|*_SECRET|*_PASSWORD|*_CREDENTIALS|*_ACCESS_KEY| \\\n-    *_SECRET_ACCESS_KEY|*_PRIVATE_KEY|*_OAUTH_TOKEN|*_WEBHOOK_SECRET| \\\n-    *_ENCRYPT_KEY|*_APP_SECRET|*_CLIENT_SECRET|*_CORP_SECRET|*_AES_KEY| \\\n-    AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|FAL_KEY| \\\n-    GH_TOKEN|GITHUB_TOKEN)\n-      unset \"$name\"\n-      ;;\n-  esac\n-done < <(env)\n-\n-# Unset HERMES_* behavioral vars too.\n-unset HERMES_YOLO_MODE HERMES_INTERACTIVE HERMES_QUIET HERMES_TOOL_PROGRESS \\\n-      HERMES_TOOL_PROGRESS_MODE HERMES_MAX_ITERATIONS HERMES_SESSION_PLATFORM \\\n-      HERMES_SESSION_CHAT_ID HERMES_SESSION_CHAT_NAME HERMES_SESSION_THREAD_ID \\\n-      HERMES_SESSION_SOURCE HERMES_SESSION_KEY HERMES_GATEWAY_SESSION \\\n-      HERMES_CRON_SESSION \\\n-      HERMES_PLATFORM HERMES_INFERENCE_PROVIDER HERMES_MANAGED HERMES_DEV \\\n-      HERMES_CONTAINER HERMES_EPHEMERAL_SYSTEM_PROMPT HERMES_TIMEZONE \\\n-      HERMES_REDACT_SECRETS HERMES_BACKGROUND_NOTIFICATIONS HERMES_EXEC_ASK \\\n-      HERMES_HOME_MODE 2>/dev/null || true\n \n-# Pin deterministic runtime.\n-export TZ=UTC\n-export LANG=C.UTF-8\n-export LC_ALL=C.UTF-8\n-export PYTHONHASHSEED=0\n-\n-# ── Live-gateway test guard (developer machines) ────────────────────────────\n-# If a system-wide hermes pytest_live_guard plugin is installed at\n-# $HOME/.hermes/pytest_live_guard.py, force-load it here so every test run\n-# from this script gets the protection regardless of which worktree is\n-# checked out (in-tree tests/conftest.py guard may be missing on stale\n-# branches). Harmless on CI / fresh machines that don't have the file.\n+# ── Live-gateway plugin (computed before we drop env) ───────────────────────\n+EXTRA_PYTHONPATH=\"\"\n+EXTRA_PYTEST_PLUGINS=\"\"\n if [ -f \"$HOME/.hermes/pytest_live_guard.py\" ]; then\n-  case \":${PYTHONPATH:-}:\" in\n-    *\":$HOME/.hermes:\"*) ;;\n-    *) export PYTHONPATH=\"${PYTHONPATH:+$PYTHONPATH:}$HOME/.hermes\" ;;\n-  esac\n-  if [[ \",${PYTEST_PLUGINS:-},\" != *,pytest_live_guard,* ]]; then\n-    export PYTEST_PLUGINS=\"${PYTEST_PLUGINS:+$PYTEST_PLUGINS,}pytest_live_guard\"\n-  fi\n+  EXTRA_PYTHONPATH=\"$HOME/.hermes\"\n+  EXTRA_PYTEST_PLUGINS=\"pytest_live_guard\"\n fi\n \n-# ── Worker count ────────────────────────────────────────────────────────────\n-# CI uses `-n auto` on ubuntu-latest which gives 4 workers. A 20-core\n-# workstation with `-n auto` gets 20 workers and exposes test-ordering\n-# flakes that CI will never see. Pin to 4 so local matches CI.\n-WORKERS=\"${HERMES_TEST_WORKERS:-4}\"\n \n-# ── Run pytest ──────────────────────────────────────────────────────────────\n-cd \"$REPO_ROOT\"\n+# ── Run in hermetic env ──────────────────────────────────────────────────────\n+# env -i: start with empty environment, opt-in only what we need.\n+# No credential var can leak — you'd have to explicitly add it here.\n+echo \"▶ running per-file parallel test suite via run_tests_parallel.py\"\n+echo \"  (TZ=UTC LANG=C.UTF-8 PYTHONHASHSEED=0; clean env)\"\n \n-# If the first argument starts with `-` treat all args as pytest flags;\n-# otherwise treat them as test paths.\n-ARGS=(\"$@\")\n-\n-echo \"▶ running pytest with $WORKERS workers, hermetic env, in $REPO_ROOT\"\n-echo \"  (TZ=UTC LANG=C.UTF-8 PYTHONHASHSEED=0; all credential env vars unset)\"\n+cd \"$REPO_ROOT\"\n \n-# -o \"addopts=\" clears pyproject.toml's `-n auto` so our -n wins.\n-# We re-add --timeout/--timeout-method here because pyproject.toml's\n-# addopts is wiped above. The 60s cap is essential: see pyproject.toml\n-# for why (suite deadlocks at session teardown without it).\n-exec \"$PYTHON\" -m pytest \\\n-  -o \"addopts=\" \\\n-  -n \"$WORKERS\" \\\n-  --timeout=30 \\\n-  --timeout-method=signal \\\n-  --ignore=tests/integration \\\n-  --ignore=tests/e2e \\\n-  -m \"not integration\" \\\n-  \"${ARGS[@]}\"\n+exec env -i \\\n+  PATH=\"$PATH\" \\\n+  HOME=\"$HOME\" \\\n+  TZ=UTC \\\n+  LANG=C.UTF-8 \\\n+  LC_ALL=C.UTF-8 \\\n+  PYTHONHASHSEED=0 \\\n+  ${EXTRA_PYTHONPATH:+PYTHONPATH=\"$EXTRA_PYTHONPATH\"} \\\n+  ${EXTRA_PYTEST_PLUGINS:+PYTEST_PLUGINS=\"$EXTRA_PYTEST_PLUGINS\"} \\\n+  \"$PYTHON\" \"$SCRIPT_DIR/run_tests_parallel.py\" \"$@\""]},{"file":"scripts/run_tests_parallel.py","hunks":["@@ -0,0 +1,650 @@\n+#!/usr/bin/env python3\n+\"\"\"Per-file parallel test runner.\n+\n+The minimum-viable replacement for pytest-xdist + a subprocess-isolation\n+plugin. Discovers test files under ``tests/`` (excluding integration/e2e\n+unless explicitly requested), then runs one ``python -m pytest <file>``\n+subprocess per file, with bounded parallelism (default: ``os.cpu_count()``).\n+\n+Why per-file rather than per-test?\n+    Per-test spawn overhead (~250ms × 17k tests = 70min CPU minimum)\n+    swamped the actual work. Per-file spawn (~250ms × ~850 files = ~3.5min)\n+    fits in the budget while still giving every file a fresh Python\n+    interpreter — the only isolation boundary that actually matters\n+    (cross-file module-level state leakage was the original flake source;\n+    intra-file state is the test author's responsibility).\n+\n+Why drop xdist entirely?\n+    xdist's persistent workers accumulate state across files, which is\n+    exactly the leakage we wanted to fix. xdist also adds complexity\n+    (loadfile vs loadscope, --max-worker-restart, internal control plane)\n+    that we don't need when the unit of work is \"run pytest on one file\".\n+    A subprocess.Popen pool gated by a semaphore is ~60 lines and does\n+    the job.\n+\n+Usage:\n+    python scripts/run_tests_parallel.py [pytest_args...]\n+\n+    Common pytest args pass through (e.g. ``-v``, ``-x``, ``--tb=long``,\n+    ``-k 'pattern'``, ``--lf``).\n+\n+Environment:\n+    HERMES_TEST_WORKERS  Override worker count (default: os.cpu_count())\n+    HERMES_TEST_PATHS    Override discovery roots (colon-sep, default: 'tests')\n+\n+Exit code: 0 if every file's pytest exited 0; 1 otherwise.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import argparse\n+import os\n+import subprocess\n+import sys\n+import threading\n+import time\n+from concurrent.futures import ThreadPoolExecutor, Future\n+from pathlib import Path\n+from typing import Dict, List, Tuple\n+\n+\n+# Default test discovery roots.\n+_DEFAULT_ROOTS = [\"tests\"]\n+\n+# Directories to skip during discovery — the e2e + integration suites\n+# require real services and are run separately. Match exactly the\n+# ``--ignore=`` flags the previous CI command used.\n+_SKIP_PARTS = {\"integration\", \"e2e\"}\n+\n+# Per-file wall-clock cap. Generous default — pytest-timeout still\n+# enforces per-test caps inside each subprocess; this is just an outer\n+# safety net so a single hung file can't stall the whole suite. Override\n+# via --file-timeout or HERMES_TEST_FILE_TIMEOUT.\n+_DEFAULT_FILE_TIMEOUT_SECONDS = 600.0  # 10 minutes\n+\n+\n+def _count_tests(\n+    files: List[Path], repo_root: Path, pytest_passthrough: List[str]\n+) -> dict[Path, int]:\n+    \"\"\"Run ``pytest --co -q`` once to count individual tests per file.\n+\n+    Returns a mapping ``{file_path: test_count}``. Files with zero\n+    collected tests are omitted from the dict (not an error — e.g. the\n+    file only defines fixtures / conftest helpers).\n+\n+    This is a single subprocess call (~2-5s for ~1k files) that gives\n+    us the total test count for the discovery announcement and\n+    per-file counts for the progress lines.\n+\n+    ``--ignore`` flags for directories in ``_SKIP_PARTS`` are added\n+    automatically so that pytest's own collection machinery (conftest\n+    walking, directory traversal) doesn't pull in tests we intend to\n+    skip — matching what the per-file runs will actually execute.\n+    \"\"\"\n+    # Build --ignore flags for skipped dirs so the --co collection\n+    # mirrors what we'll actually run (not what pytest might find via\n+    # conftest walking or directory traversal).\n+    ignore_args: List[str] = []\n+    for root in [repo_root / p for p in _DEFAULT_ROOTS]:\n+        for part in _SKIP_PARTS:\n+            d = root / part\n+            if d.is_dir():\n+                ignore_args.extend([\"--ignore\", str(d)])\n+\n+    cmd = [\n+        sys.executable, \"-m\", \"pytest\",\n+        \"--co\", \"-q\",\n+        *ignore_args,\n+        *[str(f) for f in files],\n+        *pytest_passthrough,\n+    ]\n+    try:\n+        result = subprocess.run(\n+            cmd,\n+            cwd=repo_root,\n+            capture_output=True,\n+            text=True,\n+            timeout=120,\n+        )\n+    except (subprocess.TimeoutExpired, OSError):\n+        return {}\n+\n+    counts: dict[Path, int] = {}\n+    for line in result.stdout.splitlines():\n+        # Lines look like: tests/acp/test_auth.py::TestClass::test_name\n+        if \"::\" not in line:\n+            continue\n+        file_part = line.split(\"::\", 1)[0]\n+        key = repo_root / file_part\n+        counts[key] = counts.get(key, 0) + 1\n+\n+    return counts\n+\n+\n+def _discover_files(roots: List[Path]) -> List[Path]:\n+    \"\"\"Return every ``test_*.py`` under the given roots (sorted).\n+\n+    Roots may be directories (recursed for ``test_*.py``) or explicit\n+    ``.py`` files (included as-is, even if they don't match the\n+    ``test_*`` prefix — caller knows what they want).\n+\n+    Exclude any file whose path contains a component in ``_SKIP_PARTS``,\n+    UNLESS the user explicitly named it as a root (in which case the\n+    user's intent overrides the skip filter).\n+    \"\"\"\n+    seen: set[Path] = set()\n+    out: List[Path] = []\n+    for root in roots:\n+        if not root.exists():\n+            continue\n+        if root.is_file():\n+            # Explicit file: include it as-is, skip the _SKIP_PARTS filter\n+            # since the user named it directly.\n+            real = root.resolve()\n+            if real not in seen:\n+                seen.add(real)\n+                out.append(root)\n+            continue\n+        for path in root.rglob(\"test_*.py\"):\n+            if any(part in _SKIP_PARTS for part in path.parts):\n+                continue\n+            real = path.resolve()\n+            if real in seen:\n+                continue\n+            seen.add(real)\n+            out.append(path)\n+    return sorted(out)\n+\n+\n+def _kill_tree(proc: \"subprocess.Popen\", pgid: int | None = None) -> None:\n+    \"\"\"Kill the pytest subprocess and every descendant it spawned.\n+\n+    A test run can spin up uvicorn servers, async runtimes, or other\n+    long-running grandchildren that survive the pytest subprocess exit\n+    if we don't kill the whole tree. ``subprocess.Popen.kill()`` only\n+    targets the immediate child; grandchildren reparent to PID 1\n+    (Linux) / get adopted by services.exe (Windows) and leak.\n+\n+    POSIX: the caller must pass ``pgid`` — the process group id captured\n+    immediately after Popen (via ``os.getpgid(proc.pid)``). We can't\n+    look it up here in the happy path because by the time we get\n+    called the leader process has already been reaped and its pid is\n+    gone from the kernel's process table, even though descendants in\n+    the group are still alive. SIGKILL'ing the captured pgid takes out\n+    everything in that group atomically.\n+\n+    Windows: ``taskkill /F /T /PID`` walks the recorded ppid chain and\n+    terminates the whole tree, even when the root has already exited.\n+\n+    Why not psutil: psutil walks the parent-child tree, but in the\n+    happy path the root has already been reaped so ``psutil.Process(pid)``\n+    can't find it; grandchildren reparented to PID 1 are also\n+    unreachable by tree walk at that point. The platform-native\n+    primitives (process groups / taskkill) handle both cases correctly\n+    without an extra abstraction layer.\n+    \"\"\"\n+    if proc.pid is None:\n+        return\n+\n+    if sys.platform == \"win32\":\n+        try:\n+            \n+            subprocess.run(\n+                [\"taskkill\", \"/F\", \"/T\", \"/PID\", str(proc.pid)],\n+                stdout=subprocess.DEVNULL,\n+                stderr=subprocess.DEVNULL,\n+                timeout=10,\n+            )  # windows-footgun: ok\n+        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):\n+            pass\n+    else:\n+        # POSIX: kill the captured pgid. Local-import signal so the\n+        # SIGKILL attribute is never referenced on Windows.\n+        if pgid is not None:\n+            try:\n+                import signal as _signal\n+                os.killpg(pgid, _signal.SIGKILL)  # windows-footgun: ok\n+            except (ProcessLookupError, PermissionError, OSError):\n+                pass\n+\n+    # Belt-and-suspenders: ensure subprocess.communicate() sees the exit.\n+    try:\n+        proc.kill()\n+    except (ProcessLookupError, OSError):\n+        pass\n+\n+\n+def _run_one_file(\n+    file: Path,\n+    pytest_args: List[str],\n+    repo_root: Path,\n+    file_timeout: float,\n+) -> Tuple[Path, int, str, dict[str, int]]:\n+    \"\"\"Run ``python -m pytest <file> <pytest_args>`` in a fresh subprocess.\n+\n+    Returns (file, returncode, captured_combined_output, summary_counts).\n+\n+    ``summary_counts`` is the result of ``_parse_pytest_summary(output)`` —\n+\n+    pytest exit codes (https://docs.pytest.org/en/stable/reference/exit-codes.html):\n+        0 = all tests passed\n+        1 = some tests failed\n+        2 = test execution interrupted\n+        3 = internal error\n+        4 = pytest CLI usage error\n+        5 = no tests collected\n+\n+    We treat exit 5 as a pass: it just means every test in the file was\n+    skipped or filtered by a marker (e.g. ``-m 'not integration'`` skips\n+    files where every test is marked integration). That's intentional and\n+    not a failure mode.\n+\n+    On per-file timeout (``file_timeout`` seconds) or any other exception\n+    during ``communicate()``, we kill the whole process group / process\n+    tree so grandchildren (uvicorn servers, async runtimes, etc.) do not\n+    orphan onto PID 1. The pytest-timeout plugin enforces per-test\n+    timeouts inside the subprocess; this outer timeout exists only to\n+    bound a pathologically slow or hung file as a whole.\n+    \"\"\"\n+    cmd = [sys.executable, \"-m\", \"pytest\", str(file), *pytest_args]\n+    proc = subprocess.Popen(\n+        cmd,\n+        cwd=repo_root,\n+        stdout=subprocess.PIPE,\n+        stderr=subprocess.STDOUT,\n+        text=True,\n+        # POSIX: place the child at the head of its own process group so\n+        # _kill_tree can SIGKILL the group atomically.\n+        # Windows: this maps to CREATE_NEW_PROCESS_GROUP in CPython 3.12+;\n+        # _kill_tree handles the Windows path via taskkill /F /T.\n+        start_new_session=True,\n+    )\n+\n+    # Capture the pgid NOW, before the leader can exit and be reaped.\n+    # Once the leader is reaped, os.getpgid(proc.pid) raises\n+    # ProcessLookupError even though grandchildren in that group are\n+    # still alive — defeating the whole cleanup. None on Windows where\n+    # the pgid concept doesn't apply (taskkill walks ppid chain instead).\n+    pgid: int | None = None\n+    if sys.platform != \"win32\":\n+        try:\n+            pgid = os.getpgid(proc.pid)\n+        except (ProcessLookupError, PermissionError):\n+            # Astonishingly fast child? Already dead. _kill_tree's\n+            # fallback will handle this case as a no-op.\n+            pgid = None\n+\n+    try:\n+        output, _ = proc.communicate(timeout=file_timeout)\n+        rc = proc.returncode\n+    except subprocess.TimeoutExpired:\n+        _kill_tree(proc, pgid=pgid)\n+        # Drain whatever the child wrote before we killed it so we have\n+        # something to surface in the failure dump.\n+        try:\n+            output, _ = proc.communicate(timeout=10)\n+        except subprocess.TimeoutExpired:\n+            output = \"(file timeout exceeded; output unavailable)\"\n+        rc = 124  # de facto convention for \"killed by timeout\".\n+        output = (\n+            f\"(per-file timeout: {file_timeout:.0f}s exceeded; \"\n+            f\"process tree SIGKILL'd)\\n{output}\"\n+        )\n+    except BaseException:\n+        # KeyboardInterrupt / runner crash — make sure no zombie\n+        # grandchildren outlive us.\n+        _kill_tree(proc, pgid=pgid)\n+        raise\n+    else:\n+        # Happy path: pytest exited on its own. The child process already\n+        # cleaned up its grandchildren if it's well-behaved, but\n+        # well-behaved is not universal — kill the group anyway. Already-\n+        # dead processes are a no-op.\n+        _kill_tree(proc, pgid=pgid)\n+\n+    if rc == 5:\n+        # No tests collected — every test in the file was filtered out.\n+        # Treat as a pass; surface info in a slightly distinct status\n+        # so the operator can spot it.\n+        rc = 0\n+    summary = _parse_pytest_summary(output)\n+    return file, rc, output, summary\n+\n+\n+def _parse_pytest_summary(output: str) -> dict[str, int]:\n+    \"\"\"Extract per-file test pass/fail/skip counts from pytest output.\n+\n+    pytest prints a summary line like ``12 passed, 3 skipped, 1 failed in 2.1s``\n+    as the last non-empty line before the short test summary.  We scrape that\n+    line for the individual counts so the progress display can show test-level\n+    granularity instead of just file-level pass/fail.\n+\n+    Returns a dict with keys ``passed``, ``failed``, ``skipped``, ``errors``,\n+    ``xfailed``, ``xpassed`` (only keys found in the output are present).\n+    \"\"\"\n+    import re\n+\n+    result: dict[str, int] = {}\n+    # Walk backwards from the end — the summary line is always near the tail.\n+    for line in reversed(output.splitlines()):\n+        line = line.strip()\n+        if not line:\n+            continue\n+        # Match \"N passed\", \"N failed\", \"N skipped\", \"N errors\", \"N xfailed\", \"N xpassed\"\n+        for m in re.finditer(r\"(\\d+)\\s+(passed|failed|skipped|errors|xfailed|xpassed)\", line):\n+            result[m.group(2)] = int(m.group(1))\n+        # Also match \"N error\" (singular — pytest uses this sometimes).\n+        for m in re.finditer(r\"(\\d+)\\s+error\\b\", line):\n+            result.setdefault(\"errors\", result.get(\"errors\", 0) + int(m.group(1)))\n+        if result:\n+            # Found the counts line — done.\n+            break\n+        # Stop at the short test summary header (if any) — everything above\n+        # that is individual failure details, not the counts line.\n+        if line.startswith(\"FAILED\") or line.startswith(\"SHORT TEST SUMMARY\"):\n+            break\n+    return result\n+\n+\n+def _format_file(file: Path, repo_root: Path) -> str:\n+    \"\"\"Render a test-file path for display: strip the repo-root prefix\n+    when possible so output reads ``tests/acp/test_auth.py`` instead of\n+    ``/home/runner/work/hermes-agent/hermes-agent/tests/acp/test_auth.py``.\n+\n+    Falls back to the absolute path for anything outside the repo root.\n+    \"\"\"\n+    try:\n+        return str(file.resolve().relative_to(repo_root.resolve()))\n+    except ValueError:\n+        return str(file)\n+\n+\n+def _print_progress(\n+    tests_done: int,\n+    total_tests: int,\n+    file: Path,\n+    rc: int,\n+    dur: float,\n+    repo_root: Path,\n+    tests_passed: int,\n+    tests_failed: int,\n+    test_counts: dict[Path, int],\n+    file_summary: dict[str, int] | None = None,\n+) -> None:\n+    \"\"\"Single-line live progress.\n+\n+    When ``file_summary`` is provided (parsed from pytest output), the\n+    per-file parenthetical shows individual test pass/fail counts instead\n+    of just the total test count.\n+    \"\"\"\n+    status = \"✓\" if rc == 0 else \"✗\"\n+    pct = (tests_done / total_tests * 100) if total_tests else 0\n+    # Digit width for left-side counter padding (derived from total file count).\n+    fw = len(str(tests_passed + tests_failed))\n+    # Build per-file test count string.\n+    if file_summary:\n+        parts = []\n+        p = file_summary.get(\"passed\", 0)\n+        f = file_summary.get(\"failed\", 0)\n+        s = file_summary.get(\"skipped\", 0)\n+        e = file_summary.get(\"errors\", 0)\n+        if p:\n+            parts.append(f\"{p}✓\")\n+        if f:\n+            parts.append(f\"{f}✗\")\n+        if s:\n+            parts.append(f\"{s}s\")\n+        if e:\n+            parts.append(f\"{e}e\")\n+        # xfailed/xpassed are rare; include if present.\n+        xf = file_summary.get(\"xfailed\", 0)\n+        xp = file_summary.get(\"xpassed\", 0)\n+        if xf:\n+            parts.append(f\"{xf}xf\")\n+        if xp:\n+            parts.append(f\"{xp}xp\")\n+        test_str = \" \".join(parts) + \", \" if parts else \"\"\n+    else:\n+        n_tests = test_counts.get(file, 0)\n+        test_str = f\"{n_tests} tests, \" if n_tests else \"\"\n+    msg = (\n+        f\"[{pct:5.1f}% | {tests_done:>5}/{total_tests}\"\n+        f\" | ✓{tests_passed:>{fw}} | ✗{tests_failed:>{fw}}] \"\n+        f\"{status} {_format_file(file, repo_root)} ({test_str}{dur:.1f}s)\"\n+    )\n+    # Truncate to terminal width if available (no clobbering ANSI lines).\n+    try:\n+        cols = os.get_terminal_size().columns\n+        if len(msg) > cols:\n+            msg = msg[: cols - 1] + \"…\"\n+    except OSError:\n+        pass\n+    print(msg, flush=True)\n+\n+\n+def _print_inline_failure(\n+    file: Path, output: str, repo_root: Path, pytest_passthrough: List[str]\n+) -> None:\n+    \"\"\"Print a compact failure summary immediately when a file fails.\n+\n+    Shows the tail of the pytest output (the failure section with stack\n+    traces) and a ready-to-run repro command, so the developer doesn't\n+    have to wait for the full run to finish before seeing what broke.\n+    \"\"\"\n+    rel = _format_file(file, repo_root)\n+    # Build a repro command the developer can copy-paste.\n+    passthrough_str = \" \".join(pytest_passthrough) if pytest_passthrough else \"\"\n+    repro = f\"python -m pytest {rel}\"\n+    if passthrough_str:\n+        repro += f\" {passthrough_str}\"\n+\n+    # Grab just the failure lines (last ~30 lines of pytest output —\n+    # typically the FAILED summary + short test info).\n+    lines = output.rstrip().splitlines()\n+    tail = \"\\n\".join(lines[-30:])\n+\n+    print(flush=True)\n+    print(f\"  ╔╍ Failed: {rel} ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍\", flush=True)\n+    for line in tail.splitlines():\n+        print(f\"  ║ {line}\", flush=True)\n+    print(f\"  ║\", flush=True)\n+    print(f\"  ║  Repro: {repro}\", flush=True)\n+    print(f\"  ╚╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍\", flush=True)\n+    print(flush=True)\n+\n+\n+def main() -> int:\n+    parser = argparse.ArgumentParser(\n+        description=__doc__,\n+        formatter_class=argparse.RawDescriptionHelpFormatter,\n+    )\n+    parser.add_argument(\n+        \"-j\",\n+        \"--jobs\",\n+        type=int,\n+        default=int(os.environ.get(\"HERMES_TEST_WORKERS\") or (os.cpu_count() or 4) * 2),\n+        help=\"Parallel worker count (default: $HERMES_TEST_WORKERS or cpu_count*2)\",\n+    )\n+    parser.add_argument(\n+        \"--paths\",\n+        default=os.environ.get(\"HERMES_TEST_PATHS\", \":\".join(_DEFAULT_ROOTS)),\n+        help=\"Colon-separated discovery roots (default: 'tests')\",\n+    )\n+    parser.add_argument(\n+        \"--include-integration\",\n+        action=\"store_true\",\n+        help=\"Don't skip integration/ e2e/ during discovery\",\n+    )\n+    parser.add_argument(\n+        \"--file-timeout\",\n+        type=float,\n+        default=float(\n+            os.environ.get(\"HERMES_TEST_FILE_TIMEOUT\", _DEFAULT_FILE_TIMEOUT_SECONDS)\n+        ),\n+        help=(\n+            \"Per-file wall-clock cap in seconds. On timeout, the pytest \"\n+            \"subprocess and its full process tree are SIGKILL'd. \"\n+            \"Default: 600 (10 min), env: HERMES_TEST_FILE_TIMEOUT.\"\n+        ),\n+    )\n+    parser.add_argument(\n+        \"paths_positional\",\n+        nargs=\"*\",\n+        metavar=\"PATH\",\n+        help=(\n+            \"Restrict discovery to these paths (directories or .py files). \"\n+            \"Mutually exclusive with --paths. Anything after a literal '--' \"\n+            \"separator is passed through to each per-file pytest invocation.\"\n+        ),\n+    )\n+    # Manually split argv on '--' so positional paths and pytest passthrough\n+    # args don't fight over each other. argparse's nargs=\"*\" positional is\n+    # greedy and will swallow everything after '--' including the pytest\n+    # flags, defeating the convention.\n+    argv = sys.argv[1:]\n+    if \"--\" in argv:\n+        sep = argv.index(\"--\")\n+        our_args, pytest_passthrough = argv[:sep], argv[sep + 1 :]\n+    else:\n+        our_args, pytest_passthrough = argv, []\n+    args = parser.parse_args(our_args)\n+\n+    repo_root = Path(__file__).resolve().parent.parent\n+\n+    # Resolve discovery roots: positional path args override --paths if any\n+    # were supplied, otherwise --paths (which itself defaults to 'tests').\n+    if args.paths_positional:\n+        # Positionals can be directories OR explicit .py files. Either is\n+        # fine — _discover_files handles both via rglob('test_*.py') for\n+        # dirs and direct inclusion for files.\n+        roots = [repo_root / p for p in args.paths_positional]\n+    else:\n+        roots = [repo_root / p for p in args.paths.split(\":\") if p]\n+\n+    if args.include_integration:\n+        # Caller takes responsibility — typically used via explicit -k filter.\n+        global _SKIP_PARTS  # noqa: PLW0603 — config knob\n+        _SKIP_PARTS = set()\n+\n+    files = _discover_files(roots)\n+    if not files:\n+        print(f\"No test files discovered under {[str(r) for r in roots]}\", file=sys.stderr)\n+        return 1\n+\n+    # Count individual tests per file via a single pytest --co pass.\n+    test_counts = _count_tests(files, repo_root, pytest_passthrough)\n+    total_tests = sum(test_counts.values())\n+\n+    print(\n+        f\"Discovered {len(files)} test files ({total_tests} tests) under \"\n+        f\"{[str(r.relative_to(repo_root)) if r.is_relative_to(repo_root) else str(r) for r in roots]}; \"\n+        f\"running with -j {args.jobs}\",\n+        flush=True,\n+    )\n+\n+    # Capture and print on completion (out-of-order is fine — keeps the\n+    # terminal clean rather than interleaving N parallel pytest outputs).\n+    failures: List[Tuple[Path, str, Dict[str, int]]] = []\n+    started = time.monotonic()\n+    files_done = 0\n+    tests_done = 0\n+    pass_count = 0\n+    fail_count = 0\n+    tests_passed = 0\n+    tests_failed = 0\n+    lock = threading.Lock()\n+\n+    def _on_done(file: Path, started_at: float, fut: \"Future[Tuple[Path, int, str, dict[str, int]]]\") -> None:\n+        nonlocal files_done, tests_done, pass_count, fail_count, tests_passed, tests_failed\n+        n_tests = test_counts.get(file, 0)\n+        try:\n+            fpath, rc, output, summary = fut.result()\n+        except Exception as exc:  # noqa: BLE001 — must always advance counter\n+            with lock:\n+                files_done += 1\n+                tests_done += n_tests\n+                fail_count += 1\n+                failures.append((file, f\"runner crashed: {exc!r}\", {}))\n+                _print_progress(\n+                    tests_done, total_tests, file, 1,\n+                    time.monotonic() - started_at,\n+                    repo_root, tests_passed, tests_failed,\n+                    test_counts,\n+                )\n+            return\n+        with lock:\n+            files_done += 1\n+            tests_done += n_tests\n+            # Accumulate test-level counts from parsed summary.\n+            tests_passed += summary.get(\"passed\", 0)\n+            tests_failed += summary.get(\"failed\", 0)\n+            if rc == 0:\n+                pass_count += 1\n+            else:\n+                fail_count += 1\n+                failures.append((fpath, output, summary))\n+            _print_progress(\n+                tests_done, total_tests, fpath, rc,\n+                time.monotonic() - started_at,\n+                repo_root, tests_passed, tests_failed,\n+                test_counts,\n+                file_summary=summary,\n+            )\n+            if rc != 0:\n+                _print_inline_failure(fpath, output, repo_root, pytest_passthrough)\n+\n+    with ThreadPoolExecutor(max_workers=args.jobs) as pool:\n+        futures: List[Future] = []\n+        for file in files:\n+            t0 = time.monotonic()\n+            fut = pool.submit(\n+                _run_one_file, file, pytest_passthrough, repo_root, args.file_timeout\n+            )\n+            fut.add_done_callback(lambda f, file=file, t0=t0: _on_done(file, t0, f))\n+            futures.append(fut)\n+        # Block until everything's done. ThreadPoolExecutor.__exit__ waits\n+        # for all submitted work, but doing it explicitly here makes the\n+        # control flow obvious.\n+        for fut in futures:\n+            fut.result() if fut.exception() is None else None\n+\n+    elapsed = time.monotonic() - started\n+    print()\n+    pct = (tests_done / total_tests * 100) if total_tests else 0\n+    print(f\"=== Summary: {len(files)} files, {tests_passed} tests passed, {tests_failed} failed ({pct:.0f}% complete) in {elapsed:.1f}s ({args.jobs} workers) ===\")\n+\n+    if failures:\n+        print()\n+        print(\"=== Failure output ===\")\n+        for file, output, _summary in failures:\n+            print()\n+            print(f\"--- {_format_file(file, repo_root)} ---\")\n+            print(output.rstrip())\n+        print()\n+        # Split: files with actual test failures vs non-zero exit for other reasons\n+        test_fail_files = [(f, s) for f, _o, s in failures if s.get(\"failed\", 0) > 0]\n+        all_passed_but_nonzero = [(f, s) for f, _o, s in failures\n+                                  if s.get(\"failed\", 0) == 0 and s.get(\"passed\", 0) > 0]\n+        no_tests_ran = [(f, s) for f, _o, s in failures\n+                        if s.get(\"failed\", 0) == 0 and s.get(\"passed\", 0) == 0]\n+        if test_fail_files:\n+            total_tf = sum(s.get(\"failed\", 0) for _, s in test_fail_files)\n+            print(f\"=== {len(test_fail_files)} file{'s' if len(test_fail_files) != 1 else ''} with test failures ({total_tf} test{'s' if total_tf != 1 else ''} failed) ===\")\n+            for file, s in test_fail_files:\n+                nf = s.get(\"failed\", 0)\n+                print(f\"  {_format_file(file, repo_root)}  ({nf} test{'s' if nf != 1 else ''} failed)\")\n+        if all_passed_but_nonzero:\n+            print(f\"=== {len(all_passed_but_nonzero)} file{'s' if len(all_passed_but_nonzero) != 1 else ''} where all tests passed but pytest exited non-zero (warnings-as-errors, hook failures, etc.) ===\")\n+            for file, s in all_passed_but_nonzero:\n+                print(f\"  {_format_file(file, repo_root)}  ({s.get('passed', 0)} passed)\")\n+        if no_tests_ran:\n+            print(f\"=== {len(no_tests_ran)} file{'s' if len(no_tests_ran) != 1 else ''} where no tests ran (collection/import error, timeout before collection, etc.) ===\")\n+            for file, s in no_tests_ran:\n+                print(f\"  {_format_file(file, repo_root)}\")\n+        return 1\n+\n+    return 0\n+\n+\n+if __name__ == \"__main__\":\n+    sys.exit(main())"]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -40,6 +40,16 @@ def _clean_env(monkeypatch):\n         \"ANTHROPIC_API_KEY\", \"ANTHROPIC_TOKEN\", \"CLAUDE_CODE_OAUTH_TOKEN\",\n     ):\n         monkeypatch.delenv(key, raising=False)\n+    # Module-level unhealthy cache (10-min TTL) leaks between tests;\n+    # earlier tests that call _mark_provider_unhealthy() poison the\n+    # cache for later ones, causing _resolve_auto to skip providers\n+    # that the test patched to return valid clients.\n+    import agent.auxiliary_client as _aux_mod\n+    _aux_mod._aux_unhealthy_until.clear()\n+    _aux_mod._aux_unhealthy_logged_at.clear()\n+    yield\n+    _aux_mod._aux_unhealthy_until.clear()\n+    _aux_mod._aux_unhealthy_logged_at.clear()\n \n \n @pytest.fixture","@@ -461,6 +471,17 @@ def test_expired_codex_openrouter_wins(self, tmp_path, monkeypatch):\n         import base64\n         import time as _time\n \n+        # Belt-and-suspenders: _try_openrouter marks openrouter unhealthy\n+        # when OPENROUTER_API_KEY is absent (which the preceding test in\n+        # this class exercises).  The file-level _clean_env autouse fixture\n+        # clears the cache, but fixture ordering with the conftest\n+        # _hermetic_environment autouse can leave a narrow window where\n+        # the mark reappears.  Explicitly clear here so this test is\n+        # independent of run order.\n+        import agent.auxiliary_client as _aux_mod\n+        _aux_mod._aux_unhealthy_until.clear()\n+        _aux_mod._aux_unhealthy_logged_at.clear()\n+\n         header = base64.urlsafe_b64encode(b'{\"alg\":\"RS256\",\"typ\":\"JWT\"}').rstrip(b\"=\").decode()\n         payload_data = json.dumps({\"exp\": int(_time.time()) - 3600}).encode()\n         payload = base64.urlsafe_b64encode(payload_data).rstrip(b\"=\").decode()","@@ -1047,6 +1068,20 @@ def test_picks_up_patched_functions(self):\n class TestTryPaymentFallback:\n     \"\"\"_try_payment_fallback skips the failed provider and tries alternatives.\"\"\"\n \n+    @pytest.fixture(autouse=True)\n+    def _clear_unhealthy_cache(self):\n+        \"\"\"Earlier tests in this file call _mark_provider_unhealthy() which\n+        pollutes the module-level ``_aux_unhealthy_until`` dict (10-min TTL).\n+        Without this cleanup the fallback chain skips providers we've patched\n+        to return valid clients — the patched function is never called.\n+        \"\"\"\n+        from agent.auxiliary_client import _aux_unhealthy_until, _aux_unhealthy_logged_at\n+        _aux_unhealthy_until.clear()\n+        _aux_unhealthy_logged_at.clear()\n+        yield\n+        _aux_unhealthy_until.clear()\n+        _aux_unhealthy_logged_at.clear()\n+\n     def test_skips_failed_provider(self):\n         mock_client = MagicMock()\n         with patch(\"agent.auxiliary_client._try_openrouter\", return_value=(None, None)), \\"]},{"file":"tests/agent/test_skill_commands.py","hunks":["@@ -556,10 +556,11 @@ def fail_if_called(var_name, prompt, metadata=None):\n             raising=False,\n         )\n \n-        with patch.dict(\n-            os.environ, {\"HERMES_SESSION_PLATFORM\": \"telegram\"}, clear=False\n-        ):\n-            with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", tmp_path):\n+            from gateway.session_context import clear_session_vars, set_session_vars\n+\n+            tokens = [redacted:secret-assignment]\"telegram\")\n+            try:\n                 _make_skill(\n                     tmp_path,\n                     \"test-skill\",","@@ -571,6 +572,8 @@ def fail_if_called(var_name, prompt, metadata=None):\n                 )\n                 scan_skill_commands()\n                 msg = build_skill_invocation_message(\"/test-skill\", \"do stuff\")\n+            finally:\n+                clear_session_vars(tokens)\n \n         assert msg is not None\n         assert \"local cli\" in msg.lower()"]},{"file":"tests/conftest.py","hunks":["@@ -20,12 +20,9 @@\n \"\"\"\n \n import asyncio\n-import logging\n import os\n import re\n-import signal\n import sys\n-import tempfile\n from pathlib import Path\n from unittest.mock import patch\n ","@@ -37,6 +34,22 @@\n     sys.path.insert(0, str(PROJECT_ROOT))\n \n \n+# ── Per-file process isolation ──────────────────────────────────────────────\n+# Tests run via ``scripts/run_tests_parallel.py``, which spawns a fresh\n+# ``python -m pytest <file>`` subprocess per test file. Cross-file state\n+# leakage (module-level dicts, ContextVars, caches) is impossible: each\n+# file gets a clean Python interpreter. Intra-file ordering is the test\n+# author's responsibility — if test A in foo.py mutates state that test B\n+# in foo.py reads, that's a real bug to fix in the file (it would also\n+# bite anyone running ``pytest tests/foo.py`` directly).\n+#\n+# This replaces the historic _reset_module_state autouse fixture (manual\n+# state clearing) and the brief experiment with subprocess-per-test\n+# isolation (too slow at ~17k tests).\n+#\n+# See ``scripts/run_tests_parallel.py`` for the runner.\n+\n+\n # ── Credential env-var filter ──────────────────────────────────────────────\n #\n # Any env var in the current process matching ONE of these patterns is","@@ -279,7 +292,7 @@ def _looks_like_credential(name: str) -> bool:\n     \"WECOM_HOME_CHANNEL_NAME\",\n     # Platform gating — set by load_gateway_config() as a side effect when\n     # a config.yaml is present, so individual test bodies that call the\n-    # loader leak these values into later tests on the same xdist worker.\n+    # loader leak these values into later tests in the same process.\n     # Force-clear on every test setup so the leak can't happen.\n     \"SLACK_REQUIRE_MENTION\",\n     \"SLACK_STRICT_MENTION\",","@@ -368,144 +381,21 @@ def _isolate_hermes_home(_hermetic_environment):\n     return None\n \n \n-# ── Module-level state reset ───────────────────────────────────────────────\n+# ── Module-level state reset — replaced by per-file process isolation ──────\n #\n-# Python modules are singletons per process, and pytest-xdist workers are\n-# long-lived. Module-level dicts/sets (tool registries, approval state,\n-# interrupt flags) and ContextVars persist across tests in the same worker,\n-# causing tests that pass alone to fail when run with siblings.\n+# Each test FILE runs in a freshly-spawned ``python -m pytest <file>``\n+# subprocess via ``scripts/run_tests_parallel.py``, so module-level dicts /\n+# sets / ContextVars from tests in one file cannot leak into tests in\n+# another file. No manual per-module clearing needed.\n #\n-# Each entry in this fixture clears state that belongs to a specific module.\n-# New state buckets go here too — this is the single gate that prevents\n-# \"works alone, flakes in CI\" bugs from state leakage.\n+# Within a single file, ordering is the author's responsibility. If your\n+# tests in the same file share mutable state, either reset it explicitly\n+# in a fixture or split them across files.\n #\n-# The skill `test-suite-cascade-diagnosis` documents the concrete patterns\n-# this closes; the running example was `test_command_guards` failing 12/15\n-# CI runs because ``tools.approval._session_approved`` carried approvals\n-# from one test's session into another's.\n-\n[redacted:email](autouse=True)\n-def _reset_module_state():\n-    \"\"\"Clear module-level mutable state and ContextVars between tests.\n-\n-    Keeps state from leaking across tests on the same xdist worker. Modules\n-    that don't exist yet (test collection before production import) are\n-    skipped silently — production import later creates fresh empty state.\n-    \"\"\"\n-    # --- logging — quiet/one-shot paths mutate process-global logger state ---\n-    logging.disable(logging.NOTSET)\n-    for _logger_name in (\"tools\", \"run_agent\", \"trajectory_compressor\", \"cron\", \"hermes_cli\"):\n-        _logger = logging.getLogger(_logger_name)\n-        _logger.disabled = False\n-        _logger.setLevel(logging.NOTSET)\n-        _logger.propagate = True\n-\n-    # --- tools.approval — the single biggest source of cross-test pollution ---\n-    try:\n-        from tools import approval as _approval_mod\n-        _approval_mod._session_approved.clear()\n-        _approval_mod._session_yolo.clear()\n-        _approval_mod._permanent_approved.clear()\n-        _approval_mod._pending.clear()\n-        _approval_mod._gateway_queues.clear()\n-        _approval_mod._gateway_notify_cbs.clear()\n-        # ContextVar: reset to empty string so get_current_session_key()\n-        # falls through to the env var / default path, matching a fresh\n-        # process.\n-        _approval_mod._approval_session_key.set(\"\")\n-    except Exception:\n-        pass\n-\n-    # --- tools.interrupt — per-thread interrupt flag set ---\n-    try:\n-        from tools import interrupt as _interrupt_mod\n-        with _interrupt_mod._lock:\n-            _interrupt_mod._interrupted_threads.clear()\n-    except Exception:\n-        pass\n-\n-    # --- gateway.session_context — 9 ContextVars that represent\n-    #     the active gateway session. If set in one test and not reset,\n-    #     the next test's get_session_env() reads stale values.\n-    try:\n-        from gateway import session_context as _sc_mod\n-        for _cv in (\n-            _sc_mod._SESSION_PLATFORM,\n-            _sc_mod._SESSION_CHAT_ID,\n-            _sc_mod._SESSION_CHAT_NAME,\n-            _sc_mod._SESSION_THREAD_ID,\n-            _sc_mod._SESSION_USER_ID,\n-            _sc_mod._SESSION_USER_NAME,\n-            _sc_mod._SESSION_KEY,\n-            _sc_mod._CRON_AUTO_DELIVER_PLATFORM,\n-            _sc_mod._CRON_AUTO_DELIVER_CHAT_ID,\n-            _sc_mod._CRON_AUTO_DELIVER_THREAD_ID,\n-        ):\n-            _cv.set(_sc_mod._UNSET)\n-    except Exception:\n-        pass\n-\n-    # --- tools.env_passthrough — ContextVar<set[str]> with no default ---\n-    # LookupError is normal if the test never set it. Setting it to an\n-    # empty set unconditionally normalizes the starting state.\n-    try:\n-        from tools import env_passthrough as _envp_mod\n-        _envp_mod._allowed_env_vars_var.set(set())\n-    except Exception:\n-        pass\n-\n-    # --- tools.terminal_tool — active environment/cwd cache ---\n-    # File tools prefer a live terminal cwd when one is cached for the task.\n-    # Clear terminal environments between tests so a prior terminal call can't\n-    # override TERMINAL_CWD in path-resolution tests.\n-    try:\n-        from tools import terminal_tool as _term_mod\n-        _envs_to_cleanup = []\n-        with _term_mod._env_lock:\n-            _envs_to_cleanup = list(_term_mod._active_environments.values())\n-            _term_mod._active_environments.clear()\n-            _term_mod._last_activity.clear()\n-            _term_mod._creation_locks.clear()\n-        for _env in _envs_to_cleanup:\n-            try:\n-                _env.cleanup()\n-            except Exception:\n-                pass\n-    except Exception:\n-        pass\n-\n-    # --- tools.credential_files — ContextVar<dict> ---\n-    try:\n-        from tools import credential_files as _credf_mod\n-        _credf_mod._registered_files_var.set({})\n-    except Exception:\n-        pass\n-\n-    # --- agent.auxiliary_client — runtime main provider/model override and\n-    #     payment-error health cache. Both are process-global in production;\n-    #     reset them per test so one worker's fallback/402 test does not make\n-    #     later auxiliary-client tests skip otherwise-available providers.\n-    try:\n-        from agent import auxiliary_client as _aux_mod\n-        _aux_mod.clear_runtime_main()\n-        _aux_mod._reset_aux_unhealthy_cache()\n-    except Exception:\n-        pass\n-\n-    # --- tools.file_tools — per-task read history + file-ops cache ---\n-    # _read_tracker accumulates per-task_id read history for loop detection,\n-    # capped by _READ_HISTORY_CAP. If entries from a prior test persist, the\n-    # cap is hit faster than expected and capacity-related tests flake.\n-    try:\n-        from tools import file_tools as _ft_mod\n-        with _ft_mod._read_tracker_lock:\n-            _ft_mod._read_tracker.clear()\n-        with _ft_mod._file_ops_lock:\n-            _ft_mod._file_ops_cache.clear()\n-    except Exception:\n-        pass\n-\n-    yield\n+# The skill ``test-suite-cascade-diagnosis`` documents the cascade patterns\n+# this replaces; the running example was ``test_command_guards`` failing\n+# 12/15 CI runs because ``tools.approval._session_approved`` carried\n+# approvals from one test's session into another's.\n \n \n @pytest.fixture()","@@ -532,13 +422,12 @@ def mock_config():\n     }\n \n \n-# ── Global test timeout ─────────────────────────────────────────────────────\n-# Kill any individual test that takes longer than 30 seconds.\n-# Prevents hanging tests (subprocess spawns, blocking I/O) from stalling the\n-# entire test suite.\n+# ── Per-test timeout — handled by the isolation plugin ─────────────────────\n+#\n+# The subprocess-per-test plugin enforces the configured ``isolate_timeout``\n+# ini key by terminating the child if it overruns. The old SIGALRM-based\n+# fixture (POSIX-only, didn't work on Windows) is gone.\n \n-def _timeout_handler(signum, frame):\n-    raise TimeoutError(\"Test exceeded 30 second timeout\")\n \n @pytest.fixture(autouse=True)\n def _ensure_current_event_loop(request):","@@ -584,45 +473,6 @@ def _ensure_current_event_loop(request):\n                 asyncio.set_event_loop(None)\n \n \n[redacted:email](autouse=True)\n-def _enforce_test_timeout():\n-    \"\"\"Kill any individual test that takes longer than 30 seconds.\n-    SIGALRM is Unix-only; skip on Windows.\"\"\"\n-    if sys.platform == \"win32\":\n-        yield\n-        return\n-    old = signal.signal(signal.SIGALRM, _timeout_handler)\n-    signal.alarm(30)\n-    yield\n-    signal.alarm(0)\n-    signal.signal(signal.SIGALRM, old)\n-\n-\n[redacted:email](autouse=True)\n-def _reset_tool_registry_caches():\n-    \"\"\"Clear tool-registry-level caches between tests.\n-\n-    The production registry caches ``check_fn()`` results for 30 s\n-    (see tools/registry.py) and :func:`get_tool_definitions` memoizes\n-    its result (see model_tools.py). Both are keyed on state that tests\n-    routinely mutate (env vars, registry._generation, config.yaml mtime)\n-    — but a stale result from test A can still be served to test B\n-    because 30 s covers the entire suite, and xdist worker reuse means\n-    one test's cache lands in another's process. Clearing before every\n-    test keeps hermetic behavior.\n-    \"\"\"\n-    try:\n-        from tools.registry import invalidate_check_fn_cache\n-        invalidate_check_fn_cache()\n-    except ImportError:\n-        pass\n-    try:\n-        from model_tools import _clear_tool_defs_cache\n-        _clear_tool_defs_cache()\n-    except ImportError:\n-        pass\n-\n-\n # ── Live-system guard ──────────────────────────────────────────────────────\n #\n # Several test files exercise the gateway-restart / kill code paths"]},{"file":"tests/gateway/conftest.py","hunks":["@@ -313,19 +313,30 @@ def _scan_for_plugin_adapter_antipattern(source: str) -> list[str]:\n     return offenses\n \n \n-def pytest_configure(config):\n-    \"\"\"Reject plugin-adapter tests that use the sys.path anti-pattern.\n+def _fingerprint_gateway_tests() -> str:\n+    \"\"\"Return a short fingerprint that changes when any gateway test file changes.\n \n-    Runs once per pytest session on the controller, BEFORE any xdist\n-    worker is spawned. If any file under ``tests/gateway/`` matches the\n-    anti-pattern, we fail the whole session with a clear message —\n-    before a polluted ``sys.path`` can cascade across workers.\n+    Uses (mtime, size) pairs instead of content hashing — fast to compute\n+    (stat-only, no reads) and sufficient for cache invalidation across\n+    per-file subprocess runs.\n     \"\"\"\n-    # Only run on the xdist controller (or in non-xdist runs). Skip on\n-    # worker subprocesses so we don't scan the filesystem N times.\n-    if hasattr(config, \"workerinput\"):\n-        return\n+    import hashlib\n \n+    h = hashlib.sha256()\n+    for path in sorted(_GATEWAY_DIR.rglob(\"test_*.py\")):\n+        try:\n+            st = path.stat()\n+            h.update(f\"{path.name}:{st.st_mtime_ns}:{st.st_size}\".encode())\n+        except OSError:\n+            h.update(f\"{path.name}:missing\".encode())\n+    return h.hexdigest()[:16]\n+\n+\n+def _run_adapter_antipattern_scan() -> list[str]:\n+    \"\"\"Scan gateway test files for the plugin-adapter anti-pattern.\n+\n+    Returns a list of violation strings (empty if clean).\n+    \"\"\"\n     violations: list[str] = []\n     for path in _GATEWAY_DIR.rglob(\"test_*.py\"):\n         if path.name in {\"_plugin_adapter_loader.py\", \"conftest.py\"}:","@@ -334,20 +345,108 @@ def pytest_configure(config):\n             source = path.read_text(encoding=\"utf-8\")\n         except OSError:\n             continue\n+        # Fast string pre-filter: skip files that can't possibly violate.\n+        # A violating file MUST contain both (a) an adapter/plugins/platforms\n+        # reference AND (b) either sys.path manipulation or a bare adapter import.\n         if \"adapter\" not in source and \"plugins/platforms\" not in source:\n             continue\n+        if not (\n+            \"sys.path\" in source\n+            or \"import adapter\" in source\n+            or \"from adapter import\" in source\n+        ):\n+            continue\n         offenses = _scan_for_plugin_adapter_antipattern(source)\n         if offenses:\n             violations.append(\n                 f\"  {path.relative_to(_GATEWAY_DIR.parent.parent)}:\\n    \"\n                 + \"\\n    \".join(offenses)\n             )\n+    return violations\n \n-    if violations:\n-        raise pytest.UsageError(\n-            \"Plugin-adapter-import anti-pattern detected in gateway tests:\\n\"\n-            + \"\\n\".join(violations)\n-            + \"\\n\\n\"\n-            + _GUARD_HINT\n-        )\n+\n+def pytest_configure(config):\n+    \"\"\"Reject plugin-adapter tests that use the sys.path anti-pattern.\n+\n+    Runs once per pytest session on the controller, BEFORE any xdist\n+    worker is spawned. If any file under ``tests/gateway/`` matches the\n+    anti-pattern, we fail the whole session with a clear message —\n+    before a polluted ``sys.path`` can cascade across workers.\n+\n+    **Performance**: in the per-file subprocess isolation model (no xdist),\n+    every subprocess is a \"controller\" — so the naive scan would run 257\n+    times, each costing ~1s of AST walking.  We avoid this with two\n+    strategies:\n+\n+    1. **Tight string pre-filter**: a file can only violate if it contains\n+       *both* an adapter/plugins/platforms reference *and* a sys.path\n+       manipulation or bare ``import adapter``.  This drops ~95% of files\n+       from needing AST parsing.\n+    2. **File-locked cache**: the scan result is cached in\n+       ``.pytest-cache/gw-adapter-guard-<fingerprint>`` keyed on a\n+       fingerprint of the gateway test file mtimes/sizes.  Concurrent\n+       subprocesses acquire a lock; only the first performs the scan;\n+       the rest wait and read the cached result.\n+    \"\"\"\n+    # Only run on the xdist controller (or in non-xdist runs). Skip on\n+    # worker subprocesses so we don't scan the filesystem N times.\n+    if hasattr(config, \"workerinput\"):\n+        return\n+\n+    fp = _fingerprint_gateway_tests()\n+    cache_dir = Path.cwd() / \".pytest-cache\"\n+    cache_file = cache_dir / f\"gw-adapter-guard-{fp}\"\n+    lock_file = cache_dir / f\".gw-adapter-guard-{fp}.lock\"\n+\n+    cache_dir.mkdir(parents=True, exist_ok=True)\n+\n+    # Evict stale cache entries from previous fingerprints (best-effort).\n+    try:\n+        for old in cache_dir.glob(\"gw-adapter-guard-*\"):\n+            if old.name != f\"gw-adapter-guard-{fp}\":\n+                old.unlink(missing_ok=True)\n+        for old in cache_dir.glob(\".gw-adapter-guard-*.lock\"):\n+            if old.name != f\".gw-adapter-guard-{fp}.lock\":\n+                old.unlink(missing_ok=True)\n+    except OSError:\n+        pass  # Non-critical; old files are harmless.\n+\n+    # Use filelock to ensure only one process scans at a time.\n+    # Concurrent subprocesses all hit pytest_configure simultaneously;\n+    # without a lock they'd all find no cache and all run the scan.\n+    try:\n+        from filelock import FileLock\n+        lock = FileLock(str(lock_file), timeout=120)\n+    except ImportError:\n+        # Fallback: no locking (still correct, just slower under contention).\n+        import contextlib\n+\n+        class _NoLock:\n+            def __enter__(self):\n+                return self\n+            def __exit__(self, *a):\n+                pass\n+        lock = _NoLock()\n+\n+    with lock:\n+        if cache_file.exists():\n+            cached = cache_file.read_text(encoding=\"utf-8\")\n+            if cached == \"clean\":\n+                return\n+            raise pytest.UsageError(cached)\n+\n+        # Slow path: this process is the first to acquire the lock.\n+        violations = _run_adapter_antipattern_scan()\n+\n+        if violations:\n+            msg = (\n+                \"Plugin-adapter-import anti-pattern detected in gateway tests:\\n\"\n+                + \"\\n\".join(violations)\n+                + \"\\n\\n\"\n+                + _GUARD_HINT\n+            )\n+            cache_file.write_text(msg, encoding=\"utf-8\")\n+            raise pytest.UsageError(msg)\n+        else:\n+            cache_file.write_text(\"clean\", encoding=\"utf-8\")\n "]},{"file":"tests/gateway/test_dm_topics.py","hunks":["@@ -22,19 +22,26 @@\n \n \n def _ensure_telegram_mock():\n-    if \"telegram\" in sys.modules and hasattr(sys.modules[\"telegram\"], \"__file__\"):\n-        return\n-\n     telegram_mod = MagicMock()\n     telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)\n-    telegram_mod.constants.ParseMode.MARKDOWN_V2 = \"MarkdownV2\"\n-    telegram_mod.constants.ChatType.GROUP = \"group\"\n-    telegram_mod.constants.ChatType.SUPERGROUP = \"supergroup\"\n-    telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n-    telegram_mod.constants.ChatType.PRIVATE = \"private\"\n-\n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n-        sys.modules.setdefault(name, telegram_mod)\n+\n+    # Register telegram.constants as a separate module mock so that\n+    # ``from telegram.constants import ChatType`` resolves to our mock\n+    # with string-valued members (not auto-generated MagicMocks).\n+    constants_mod = MagicMock()\n+    constants_mod.ParseMode.MARKDOWN_V2 = \"MarkdownV2\"\n+    constants_mod.ChatType.GROUP = \"group\"\n+    constants_mod.ChatType.SUPERGROUP = \"supergroup\"\n+    constants_mod.ChatType.CHANNEL = \"channel\"\n+    constants_mod.ChatType.PRIVATE = \"private\"\n+\n+    sys.modules[\"telegram\"] = telegram_mod\n+    sys.modules[\"telegram.ext\"] = telegram_mod.ext\n+    sys.modules[\"telegram.constants\"] = constants_mod\n+    sys.modules[\"telegram.request\"] = telegram_mod.request\n+\n+    # Force reimport so the adapter picks up the mock ChatType.\n+    sys.modules.pop(\"gateway.platforms.telegram\", None)\n \n \n _ensure_telegram_mock()"]},{"file":"tests/gateway/test_google_chat.py","hunks":["@@ -22,6 +22,11 @@\n \n from gateway.config import Platform, PlatformConfig, load_gateway_config\n \n+# Platform uses _missing_() for dynamic members, so \"google_chat\" is\n+# resolvable via Platform(\"google_chat\") even without a static\n+# GOOGLE_CHAT attribute on the enum class.\n+_GC = Platform(\"google_chat\")\n+\n \n # ---------------------------------------------------------------------------\n # Mock the google-* packages if they are not installed","@@ -229,7 +234,7 @@ def _make_chat_envelope(text=\"hello\", sender_email=\"[redacted:email]\", sender_type=\n \n class TestPlatformRegistration:\n     def test_enum_value(self):\n-        assert Platform.GOOGLE_CHAT.value == \"google_chat\"\n+        assert _GC.value == \"google_chat\"\n \n     def test_requirements_check_returns_true_when_available(self):\n         # The shim flag is True in this test module.","@@ -266,14 +271,14 @@ def test_missing_subscription_does_not_enable(self, monkeypatch):\n         monkeypatch.setenv(\"GOOGLE_CHAT_PROJECT_ID\", \"p\")\n         # No subscription.\n         cfg = load_gateway_config()\n-        assert Platform.GOOGLE_CHAT not in cfg.platforms\n+        assert _GC not in cfg.platforms\n \n     def test_missing_project_does_not_enable(self, monkeypatch):\n         self._clean_env(monkeypatch)\n         monkeypatch.setenv(\"GOOGLE_CHAT_SUBSCRIPTION_NAME\",\n                            \"projects/p/subscriptions/s\")\n         cfg = load_gateway_config()\n-        assert Platform.GOOGLE_CHAT not in cfg.platforms\n+        assert _GC not in cfg.platforms\n \n \n ","@@ -2583,7 +2588,7 @@ def test_allowlist_matches_when_user_id_is_email(self, monkeypatch):\n         runner.pairing_store.is_approved = MagicMock(return_value=False)\n \n         source = SessionSource(\n-            platform=Platform.GOOGLE_CHAT,\n+            platform=_GC,\n             chat_id=\"spaces/S\",\n             chat_type=\"dm\",\n             user_id=\"[redacted:email]\",       # post-swap: email is canonical","@@ -2604,7 +2609,7 @@ def test_allowlist_denies_wrong_email(self, monkeypatch):\n         runner.pairing_store.is_approved = MagicMock(return_value=False)\n \n         source = SessionSource(\n-            platform=Platform.GOOGLE_CHAT,\n+            platform=_GC,\n             chat_id=\"spaces/S\",\n             chat_type=\"dm\",\n             user_id=\"[redacted:email]\",","@@ -2630,7 +2635,7 @@ def test_allowlist_falls_back_to_resource_name_when_no_email(\n         runner.pairing_store.is_approved = MagicMock(return_value=False)\n \n         source = SessionSource(\n-            platform=Platform.GOOGLE_CHAT,\n+            platform=_GC,\n             chat_id=\"spaces/S\",\n             chat_type=\"dm\",\n             user_id=\"users/77777\",  # no email available — resource name wins"]},{"file":"tests/hermes_cli/test_pty_bridge.py","hunks":["@@ -7,6 +7,7 @@\n from __future__ import annotations\n \n import os\n+import shutil\n import sys\n import time\n ","@@ -66,7 +67,7 @@ def test_reads_child_stdout(self):\n     def test_write_sends_to_child_stdin(self):\n         # `cat` with no args echoes stdin back to stdout.  We write a line,\n         # read it back, then signal EOF to let cat exit cleanly.\n-        bridge = PtyBridge.spawn([\"/bin/cat\"])\n+        bridge = PtyBridge.spawn([shutil.which(\"cat\") or \"cat\"])\n         try:\n             bridge.write(b\"hello-pty\\n\")\n             output = _read_until(bridge, b\"hello-pty\")"]},{"file":"tests/plugins/test_achievements_plugin.py","hunks":["@@ -62,8 +62,9 @@ def plugin_api(tmp_path, monkeypatch):\n class _FakeSessionDB:\n     \"\"\"Stand-in for hermes_state.SessionDB that records scan calls.\"\"\"\n \n-    def __init__(self, session_count: int):\n+    def __init__(self, session_count: int, scan_delay: float = 0):\n         self.session_count = session_count\n+        self.scan_delay = scan_delay\n         self.last_limit: Optional[int] = None\n         self.last_include_children: Optional[bool] = None\n         self.list_calls = 0","@@ -78,6 +79,8 @@ def list_sessions_rich(\n         include_children: bool = False,\n         project_compression_tips: bool = True,\n     ) -> List[Dict[str, Any]]:\n+        if self.scan_delay:\n+            time.sleep(self.scan_delay)\n         self.last_limit = limit\n         self.last_include_children = include_children\n         self.list_calls += 1","@@ -225,10 +228,8 @@ def test_evaluate_all_stale_cache_serves_stale_and_refreshes_in_background(plugi\n     the stale data immediately and kicks a background refresh. Users don't\n     stare at a loading spinner every time TTL expires.\n     \"\"\"\n-    fake_db = _FakeSessionDB(session_count=10)\n+    fake_db = _FakeSessionDB(session_count=10, scan_delay=2.0)\n     _install_fake_session_db(plugin_api, fake_db)\n-\n-    # Seed a stale snapshot on disk.\n     stale_generated_at = int(time.time()) - plugin_api.SNAPSHOT_TTL_SECONDS - 60\n     stale_payload = {\n         \"achievements\": [],"]},{"file":"tests/plugins/web/test_web_search_provider_plugins.py","hunks":["@@ -2,8 +2,8 @@\n \n Covers:\n \n-- All seven bundled plugins (brave-free, ddgs, searxng, exa, parallel,\n-  tavily, firecrawl) instantiate and self-report the expected\n+- All eight bundled plugins (brave-free, ddgs, searxng, exa, parallel,\n+  tavily, firecrawl, xai) instantiate and self-report the expected\n   capabilities + ABC-derived defaults.\n - Each plugin's ``is_available()`` correctly reflects env-var presence.\n - The web_search_registry resolves an active provider in the documented","@@ -47,6 +47,7 @@ def _clear_web_env(monkeypatch: pytest.MonkeyPatch) -> None:\n         \"FIRECRAWL_GATEWAY_URL\",\n         \"TOOL_GATEWAY_DOMAIN\",\n         \"TOOL_GATEWAY_USER_TOKEN\",\n+        \"XAI_API_KEY\",\n     ):\n         monkeypatch.delenv(k, raising=False)\n ","@@ -70,7 +71,7 @@ def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None:\n \n \n class TestBundledPluginsRegister:\n-    \"\"\"All seven bundled web plugins discover and register correctly.\"\"\"\n+    \"\"\"All eight bundled web plugins discover and register correctly.\"\"\"\n \n     def test_all_seven_plugins_present_in_registry(self) -> None:\n         _ensure_plugins_loaded()","@@ -85,6 +86,7 @@ def test_all_seven_plugins_present_in_registry(self) -> None:\n             \"parallel\",\n             \"searxng\",\n             \"tavily\",\n+            \"xai\",\n         ]\n \n     @pytest.mark.parametrize(","@@ -100,6 +102,8 @@ def test_all_seven_plugins_present_in_registry(self) -> None:\n             # disabled in the migration (fell through to a legacy inline\n             # path); the follow-up commit enabled it natively.\n             (\"firecrawl\", True, True, True),\n+            # xai: search-only via Grok's agentic web_search tool.\n+            (\"xai\", True, False, False),\n         ],\n     )\n     def test_capability_flags_match_spec(","@@ -120,7 +124,7 @@ def test_capability_flags_match_spec(\n \n     @pytest.mark.parametrize(\n         \"plugin_name\",\n-        [\"brave-free\", \"ddgs\", \"searxng\", \"exa\", \"parallel\", \"tavily\", \"firecrawl\"],\n+        [\"brave-free\", \"ddgs\", \"searxng\", \"exa\", \"parallel\", \"tavily\", \"firecrawl\", \"xai\"],\n     )\n     def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None:\n         _ensure_plugins_loaded()","@@ -133,7 +137,7 @@ def test_each_plugin_has_name_and_display_name(self, plugin_name: str) -> None:\n \n     @pytest.mark.parametrize(\n         \"plugin_name\",\n-        [\"brave-free\", \"ddgs\", \"searxng\", \"exa\", \"parallel\", \"tavily\", \"firecrawl\"],\n+        [\"brave-free\", \"ddgs\", \"searxng\", \"exa\", \"parallel\", \"tavily\", \"firecrawl\", \"xai\"],\n     )\n     def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None:\n         \"\"\"``get_setup_schema()`` returns a dict the picker can consume.\"\"\"","@@ -239,6 +243,17 @@ def test_ddgs_always_available_when_package_importable(self) -> None:\n         # Truthy or falsy, just must not raise.\n         _ = bool(p.is_available())\n \n+    def test_xai_requires_api_key_or_oauth(self, monkeypatch: pytest.MonkeyPatch) -> None:\n+        \"\"\"xAI needs XAI_API_KEY or OAuth tokens in auth.json.\"\"\"\n+        _ensure_plugins_loaded()\n+        from agent.web_search_registry import get_provider\n+\n+        p = get_provider(\"xai\")\n+        assert p is not None\n+        assert p.is_available() is False  # no XAI_API_KEY, no auth.json\n+        monkeypatch.setenv(\"XAI_API_KEY\", \"real\")\n+        assert p.is_available() is True\n+\n \n # ---------------------------------------------------------------------------\n # Registry resolution semantics (Option B — conservative smart fallback)","@@ -455,7 +470,7 @@ def test_tavily_crawl_returns_error_dict_when_unconfigured(self) -> None:\n         if result[\"results\"]:\n             assert \"error\" in result[\"results\"][0]\n \n-    def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self) -> None:\n+    def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self):\n         \"\"\"firecrawl crawl is async (wraps SDK in to_thread); error must be\n         surfaced via the per-page result shape, not raised.\"\"\"\n         _ensure_plugins_loaded()","@@ -473,3 +488,15 @@ def test_firecrawl_crawl_returns_error_dict_when_unconfigured(self) -> None:\n         assert len(result[\"results\"]) >= 1\n         assert \"error\" in result[\"results\"][0]\n         assert result[\"results\"][0][\"url\"] == \"https://example.com\"\n+\n+    def test_xai_search_returns_error_dict_when_unconfigured(self) -> None:\n+        \"\"\"xAI returns a typed error dict (no XAI_API_KEY).\"\"\"\n+        _ensure_plugins_loaded()\n+        from agent.web_search_registry import get_provider\n+\n+        p = get_provider(\"xai\")\n+        assert p is not None\n+        result = p.search(\"test\", limit=5)\n+        assert isinstance(result, dict)\n+        assert result.get(\"success\") is False\n+        assert \"error\" in result"]},{"file":"tests/test_run_tests_parallel.py","hunks":["@@ -0,0 +1,187 @@\n+\"\"\"Verify scripts/run_tests_parallel.py kills test-spawned grandchildren.\n+\n+Setup\n+-----\n+A test in this file spawns a long-lived Python grandchild that writes\n+its PID + a nonce to a tempfile, then exits without cleaning up.\n+With the old ``subprocess.run`` runner, that grandchild would orphan\n+and outlive the test (and the whole runner). With the current Popen +\n+``start_new_session`` + ``_kill_tree`` runner, the grandchild gets\n+SIGKILL'd via process-group kill when its file's pytest exits.\n+\n+The leaker test always passes — its only job is to spawn a grandchild\n+and walk away. The verifier runs the runner over the leaker file in a\n+subprocess, then waits for the grandchild PID to disappear from the\n+kernel's process table.\n+\n+POSIX-only: Windows has its own grandchild lifecycle (no shared session,\n+``taskkill /F /T`` semantics). Marked accordingly.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import os\n+import subprocess\n+import sys\n+import textwrap\n+import time\n+from pathlib import Path\n+\n+import pytest\n+\n+\n+# Both tests share the same handoff file: the leaker writes here, the\n+# verifier reads here. We park it in $TMPDIR with a unique-per-run name\n+# so concurrent invocations of the suite don't clobber each other.\n+_HANDOFF_DIR = Path(os.environ.get(\"TMPDIR\", \"/tmp\")) / \"hermes-isolation-probe\"\n+_HANDOFF_DIR.mkdir(exist_ok=True)\n+\n+\n+def _handoff_path_for(nonce: str) -> Path:\n+    return _HANDOFF_DIR / f\"grandchild-{nonce}.json\"\n+\n+\n+def _pid_alive(pid: int) -> bool:\n+    \"\"\"POSIX: send signal 0 to probe whether ``pid`` is still alive.\n+\n+    ``os.kill(pid, 0)`` raises ``ProcessLookupError`` if the process is\n+    gone, ``PermissionError`` if it exists but we can't signal it\n+    (someone else's pid). We treat PermissionError as \"alive\" because\n+    the process exists and that's all we need to know.\n+    \"\"\"\n+    if sys.platform == \"win32\":  # pragma: no cover — POSIX-only test\n+        # On Windows we'd use OpenProcess + GetExitCodeProcess; this\n+        # test is skipped on Windows so the path is unreachable.\n+        raise RuntimeError(\"_pid_alive POSIX-only\")\n+    try:\n+        os.kill(pid, 0)\n+    except ProcessLookupError:\n+        return False\n+    except PermissionError:\n+        return True\n+    return True\n+\n+\n[redacted:email](sys.platform == \"win32\", reason=\"POSIX-only probe\")\n[redacted:email]_system_guard_bypass\n+def test_grandchild_leak_is_killed_by_runner(tmp_path: Path) -> None:\n+    \"\"\"Run the parallel runner over a probe file and verify cleanup.\n+\n+    1. Materialize a probe file that spawns a long-lived grandchild and\n+       writes its PID to disk before exiting.\n+    2. Invoke ``scripts/run_tests_parallel.py`` against the probe file.\n+    3. Wait for the grandchild PID to vanish (poll for ~5s).\n+    4. Assert the runner exited cleanly AND the grandchild is dead.\n+    \"\"\"\n+    repo_root = Path(__file__).resolve().parent.parent\n+    runner = repo_root / \"scripts\" / \"run_tests_parallel.py\"\n+    assert runner.exists(), f\"runner missing at {runner}\"\n+\n+    # Probe lives in a temp dir, NOT under tests/, so the regular suite\n+    # never picks it up — only our explicit invocation does.\n+    probe_dir = tmp_path / \"probe\"\n+    probe_dir.mkdir()\n+    probe = probe_dir / \"test_probe_leaker.py\"\n+    nonce = f\"{os.getpid()}-{int(time.time() * 1000)}\"\n+    handoff = _handoff_path_for(nonce)\n+    if handoff.exists():\n+        handoff.unlink()\n+\n+    probe_src = textwrap.dedent(f\"\"\"\n+        import json, os, subprocess, sys, time\n+        from pathlib import Path\n+\n+        HANDOFF = Path({str(handoff)!r})\n+\n+        def test_spawns_grandchild_and_walks_away():\n+            # Long-lived grandchild: detached, ignores SIGTERM (we want\n+            # SIGKILL or process-group kill to be the only thing that\n+            # works, simulating a misbehaving server).\n+            child = subprocess.Popen(\n+                [\n+                    sys.executable, \"-c\",\n+                    \"import os, signal, sys, time; \"\n+                    \"signal.signal(signal.SIGTERM, signal.SIG_IGN); \"\n+                    \"sys.stdout.write(f'gc-pgid={{os.getpgid(0)}} gc-pid={{os.getpid()}}\\\\\\\\n'); \"\n+                    \"sys.stdout.flush(); \"\n+                    \"time.sleep(600)\",\n+                ],\n+                stdout=subprocess.PIPE,\n+                stderr=subprocess.STDOUT,\n+                # IMPORTANT: do NOT pass start_new_session here. We want\n+                # the grandchild to inherit the pytest subprocess's\n+                # process group, so when the runner kills the group the\n+                # grandchild dies too.\n+            )\n+            # Read the first line so we can record gc's pgid in the\n+            # handoff, then walk away — don't close the pipe (would\n+            # signal EOF and let the child see SIGPIPE on next write).\n+            first_line = child.stdout.readline().decode().strip()\n+            HANDOFF.write_text(json.dumps({{\n+                \"pid\": child.pid,\n+                \"diag\": first_line,\n+                \"test_pid\": os.getpid(),\n+                \"test_pgid\": os.getpgid(0),\n+            }}))\n+            assert child.pid > 0\n+    \"\"\").strip()\n+    probe.write_text(probe_src + \"\\n\")\n+\n+    # Run the parallel runner against just the probe file. The runner\n+    # discovers under ``tests/`` by default, so we override via --paths.\n+    proc = subprocess.run(\n+        [\n+            sys.executable,\n+            str(runner),\n+            \"--paths\",\n+            str(probe_dir),\n+            \"-j\",\n+            \"1\",\n+            # Tight per-file timeout: the probe finishes in <1s, no\n+            # need for 10min.\n+            \"--file-timeout\",\n+            \"30\",\n+        ],\n+        cwd=repo_root,\n+        stdout=subprocess.PIPE,\n+        stderr=subprocess.STDOUT,\n+        text=True,\n+        timeout=60,\n+    )\n+\n+    assert handoff.exists(), (\n+        f\"probe never wrote handoff file; runner output:\\n{proc.stdout}\"\n+    )\n+    handoff_data = json.loads(handoff.read_text())\n+    grandchild_pid = handoff_data[\"pid\"]\n+    diag = handoff_data.get(\"diag\", \"(no diag)\")\n+    test_pid = handoff_data.get(\"test_pid\")\n+    test_pgid = handoff_data.get(\"test_pgid\")\n+    handoff.unlink()\n+\n+    # The runner must have exited cleanly (probe test passes).\n+    assert proc.returncode == 0, (\n+        f\"runner exited {proc.returncode}; output:\\n{proc.stdout}\"\n+    )\n+\n+    # The grandchild must be gone. Poll for a bit because process-group\n+    # SIGKILL + reaping isn't synchronous; on a loaded box it can take\n+    # a beat.\n+    deadline = time.monotonic() + 5.0\n+    while time.monotonic() < deadline:\n+        if not _pid_alive(grandchild_pid):\n+            break\n+        time.sleep(0.05)\n+    else:\n+        # Test cleanup: kill the leaked grandchild ourselves so a\n+        # FAILED assertion doesn't leave a sleep(600) running.\n+        try:\n+            os.kill(grandchild_pid, 9)\n+        except ProcessLookupError:\n+            pass\n+        pytest.fail(\n+            f\"grandchild PID {grandchild_pid} survived runner exit; \"\n+            f\"diag={diag!r} test_pid={test_pid} test_pgid={test_pgid}; \"\n+            f\"runner output:\\n{proc.stdout}\"\n+        )"]},{"file":"tests/tools/conftest.py","hunks":["@@ -0,0 +1,50 @@\n+\"\"\"Shared fixtures for tests/tools/ web-provider tests.\n+\n+Per-file subprocess isolation means each test file gets a fresh interpreter,\n+so module-level state (like the web-search-provider registry) is empty when\n+a file starts.  The ``web_registry_populated`` fixture registers all bundled\n+providers before each test and resets the registry afterwards — tests that\n+depend on the registry being populated should use it explicitly or via\n+``@pytest.mark.usefixtures(\"web_registry_populated\")``.\n+\"\"\"\n+\n+import pytest\n+\n+\n+def register_all_web_providers():\n+    \"\"\"Register all bundled web-search providers into the global registry.\n+\n+    This is the single source of truth for the provider list used by\n+    test classes that need the registry populated for dispatch checks.\n+    \"\"\"\n+    from agent.web_search_registry import register_provider, _reset_for_tests\n+    from plugins.web.brave_free.provider import BraveFreeWebSearchProvider\n+    from plugins.web.ddgs.provider import DDGSWebSearchProvider\n+    from plugins.web.exa.provider import ExaWebSearchProvider\n+    from plugins.web.firecrawl.provider import FirecrawlWebSearchProvider\n+    from plugins.web.parallel.provider import ParallelWebSearchProvider\n+    from plugins.web.searxng.provider import SearXNGWebSearchProvider\n+    from plugins.web.tavily.provider import TavilyWebSearchProvider\n+    from plugins.web.xai.provider import XAIWebSearchProvider\n+\n+    _reset_for_tests()\n+    for cls in (\n+        BraveFreeWebSearchProvider,\n+        DDGSWebSearchProvider,\n+        ExaWebSearchProvider,\n+        FirecrawlWebSearchProvider,\n+        ParallelWebSearchProvider,\n+        SearXNGWebSearchProvider,\n+        TavilyWebSearchProvider,\n+        XAIWebSearchProvider,\n+    ):\n+        register_provider(cls())\n+\n+\n[redacted:email]\n+def web_registry_populated():\n+    \"\"\"Populate the web-search-provider registry for one test, then reset.\"\"\"\n+    register_all_web_providers()\n+    yield\n+    from agent.web_search_registry import _reset_for_tests\n+    _reset_for_tests()"]},{"file":"tests/tools/test_approval_plugin_hooks.py","hunks":["@@ -22,18 +22,28 @@\n \n \n @pytest.fixture\n-def isolated_session(monkeypatch):\n-    \"\"\"Give each test a fresh session_key and clean approval-state.\"\"\"\n+def isolated_session(monkeypatch, tmp_path):\n+    \"\"\"Give each test a fresh session_key, clean approval-state, and isolated\n+    HERMES_HOME so the real user's command_allowlist doesn't leak in.\"\"\"\n+    import tools.approval as _am\n+\n     session_key = \"test:session:approval_hooks\"\n     token = [redacted:secret-assignment]\n     monkeypatch.setenv(\"HERMES_SESSION_KEY\", session_key)\n     # Make sure we don't skip guards via yolo / approvals.mode=off\n     monkeypatch.delenv(\"HERMES_YOLO_MODE\", raising=False)\n+    # Isolate from the real user's permanent allowlist + session state\n+    _saved_permanent = _am._permanent_approved.copy()\n+    _saved_session = {k: v.copy() for k, v in _am._session_approved.items()}\n+    _am._permanent_approved.clear()\n+    _am._session_approved.clear()\n     try:\n         yield session_key\n     finally:\n+        _am._permanent_approved.update(_saved_permanent)\n+        _am._session_approved.update(_saved_session)\n         try:\n-            approval_module._approval_session_key.reset(token)\n+            _am._approval_session_key.reset(token)\n         except Exception:\n             pass\n         clear_session(session_key)"]},{"file":"tests/tools/test_browser_supervisor.py","hunks":["@@ -41,7 +41,7 @@ def _find_chrome() -> str:\n \n \n @pytest.fixture\n-def chrome_cdp(worker_id):\n+def chrome_cdp(request):\n     \"\"\"Start a headless Chrome with --remote-debugging-port, yield its WS URL.\n \n     Uses a unique port per xdist worker to avoid cross-worker collisions.","@@ -51,6 +51,9 @@ def chrome_cdp(worker_id):\n     import socket\n \n     # xdist worker_id is \"master\" in single-process mode or \"gw0\"..\"gwN\" otherwise.\n+    # Under subprocess-per-file isolation there's no xdist, so we fall back\n+    # to \"master\" via the session-scoped fixture below.\n+    worker_id = request.getfixturevalue(\"worker_id\") if \"worker_id\" in request.fixturenames else \"master\"\n     if worker_id == \"master\":\n         port_offset = 0\n     else:"]},{"file":"tests/tools/test_discord_tool.py","hunks":["@@ -1089,9 +1089,17 @@ def test_non_403_errors_are_not_enriched(self, mock_req, monkeypatch):\n class TestModelToolsIntegration:\n     def setup_method(self):\n         _reset_capability_cache()\n+        from model_tools import _clear_tool_defs_cache\n+        from tools.registry import invalidate_check_fn_cache\n+        _clear_tool_defs_cache()\n+        invalidate_check_fn_cache()\n \n     def teardown_method(self):\n         _reset_capability_cache()\n+        from model_tools import _clear_tool_defs_cache\n+        from tools.registry import invalidate_check_fn_cache\n+        _clear_tool_defs_cache()\n+        invalidate_check_fn_cache()\n \n     @patch(\"tools.discord_tool._discord_request\")\n     def test_discord_admin_schema_rebuilt_by_get_tool_definitions("]},{"file":"tests/tools/test_homeassistant_tool.py","hunks":["@@ -501,16 +501,18 @@ def test_tools_in_homeassistant_toolset(self):\n \n     def test_check_fn_gates_availability(self, monkeypatch):\n         \"\"\"Registry should exclude HA tools when HASS_TOKEN is not set.\"\"\"\n-        from tools.registry import registry\n+        from tools.registry import invalidate_check_fn_cache, registry\n \n         monkeypatch.delenv(\"HASS_TOKEN\", raising=False)\n+        invalidate_check_fn_cache()\n         defs = registry.get_definitions({\"ha_list_entities\", \"ha_get_state\", \"ha_call_service\"})\n         assert len(defs) == 0\n \n     def test_check_fn_includes_when_token_set(self, monkeypatch):\n         \"\"\"Registry should include HA tools when HASS_TOKEN is set.\"\"\"\n-        from tools.registry import registry\n+        from tools.registry import invalidate_check_fn_cache, registry\n \n         monkeypatch.setenv(\"HASS_TOKEN\", \"test-token\")\n+        invalidate_check_fn_cache()\n         defs = registry.get_definitions({\"ha_list_entities\", \"ha_get_state\", \"ha_call_service\"})\n         assert len(defs) == 3"]},{"file":"tests/tools/test_kanban_tools.py","hunks":["@@ -1093,6 +1093,11 @@ def test_kanban_guidance_not_in_normal_prompt(monkeypatch, tmp_path):\n     from pathlib import Path as _P\n     monkeypatch.setattr(_P, \"home\", lambda: tmp_path)\n \n+    from tools.registry import invalidate_check_fn_cache\n+    from model_tools import _clear_tool_defs_cache\n+    invalidate_check_fn_cache()\n+    _clear_tool_defs_cache()\n+\n     from run_agent import AIAgent\n     a = AIAgent(\n         api_key=\"test\",","@@ -1116,6 +1121,11 @@ def test_kanban_guidance_in_worker_prompt(monkeypatch, tmp_path):\n     from pathlib import Path as _P\n     monkeypatch.setattr(_P, \"home\", lambda: tmp_path)\n \n+    from tools.registry import invalidate_check_fn_cache\n+    from model_tools import _clear_tool_defs_cache\n+    invalidate_check_fn_cache()\n+    _clear_tool_defs_cache()\n+\n     from run_agent import AIAgent\n     a = AIAgent(\n         api_key=\"test\","]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -10,6 +10,12 @@\n \n import pytest\n \n+# python-telegram-bot is an optional dep — skip the entire module when\n+# it isn't installed (e.g. CI bare env). Tests that patch telegram.Bot\n+# or call _send_telegram need it; tests for other platforms don't but\n+# keeping the whole file consistent is simpler.\n+_HAS_TELEGRAM = pytest.importorskip(\"telegram\", reason=\"python-telegram-bot not installed\") is not None\n+\n \n @pytest.fixture(autouse=True)\n def _reset_signal_scheduler():"]},{"file":"tests/tools/test_terminal_tool_requirements.py","hunks":["@@ -2,11 +2,26 @@\n \n import importlib\n \n+import pytest\n+\n from model_tools import get_tool_definitions\n \n terminal_tool_module = importlib.import_module(\"tools.terminal_tool\")\n \n \n[redacted:email](autouse=True)\n+def _clear_caches():\n+    \"\"\"Invalidate check_fn and tool-definitions caches before each test\n+    so that monkeypatched env vars / config take effect.\"\"\"\n+    from tools.registry import invalidate_check_fn_cache\n+    from model_tools import _clear_tool_defs_cache\n+    invalidate_check_fn_cache()\n+    _clear_tool_defs_cache()\n+    yield\n+    invalidate_check_fn_cache()\n+    _clear_tool_defs_cache()\n+\n+\n class TestTerminalRequirements:\n     def test_local_backend_requirements(self, monkeypatch):\n         monkeypatch.setattr("]},{"file":"tests/tools/test_video_generation_tool_surface_matrix.py","hunks":["@@ -95,7 +95,9 @@ def _invoke_tool(home, cfg: dict, args: dict) -> dict:\n     if hasattr(cfg_mod, \"_invalidate_load_config_cache\"):\n         cfg_mod._invalidate_load_config_cache()\n \n-    from tools.registry import registry\n+    from tools.registry import discover_builtin_tools, registry\n+    if \"video_generate\" not in registry._tools:\n+        discover_builtin_tools()\n     handler = registry._tools[\"video_generate\"].handler\n     return json.loads(handler(args))\n "]},{"file":"tests/tools/test_web_providers.py","hunks":["@@ -13,6 +13,8 @@\n \n import pytest\n \n+from tests.tools.conftest import register_all_web_providers\n+\n \n # ---------------------------------------------------------------------------\n # ABC enforcement","@@ -276,6 +278,15 @@ class TestUnconfiguredErrorEnvelopeParity:\n     ``result.get(\"error\")`` detect the failure cleanly.\n     \"\"\"\n \n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n     def _clear_web_creds(self, monkeypatch):\n         for k in (\n             \"BRAVE_SEARCH_API_KEY\","]},{"file":"tests/tools/test_web_providers_brave_free.py","hunks":["@@ -15,6 +15,10 @@\n import json\n from unittest.mock import MagicMock, patch\n \n+import pytest\n+\n+from tests.tools.conftest import register_all_web_providers\n+\n \n # ---------------------------------------------------------------------------\n # BraveFreeWebSearchProvider unit tests","@@ -239,6 +243,15 @@ def test_check_web_api_key_true_when_brave_free_configured(self, monkeypatch):\n \n \n class TestBraveFreeSearchOnlyErrors:\n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n     def test_web_extract_returns_search_only_error(self, monkeypatch):\n         import asyncio\n         from tools import web_tools","@@ -246,6 +259,7 @@ def test_web_extract_returns_search_only_error(self, monkeypatch):\n         monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"brave-free\"})\n         monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n         monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n         monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n \n         result_str = asyncio.get_event_loop().run_until_complete(","@@ -264,6 +278,8 @@ def test_web_crawl_returns_search_only_error(self, monkeypatch):\n         monkeypatch.setenv(\"BRAVE_SEARCH_API_KEY\", \"BSAkey123\")\n         monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n         monkeypatch.setattr(web_tools, \"check_firecrawl_api_key\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(web_tools, \"check_website_access\", lambda url: None)\n         monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n \n         result_str = asyncio.get_event_loop().run_until_complete("]},{"file":"tests/tools/test_web_providers_ddgs.py","hunks":["@@ -14,6 +14,10 @@\n import types\n from unittest.mock import MagicMock\n \n+import pytest\n+\n+from tests.tools.conftest import register_all_web_providers\n+\n \n def _install_fake_ddgs(monkeypatch, *, text_results=None, text_raises=None):\n     \"\"\"Install a stub ``ddgs`` module in sys.modules for the duration of a test.","@@ -210,6 +214,15 @@ def test_check_web_api_key_true_when_ddgs_configured(self, monkeypatch):\n \n \n class TestDDGSSearchOnlyErrors:\n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n     def test_web_extract_returns_search_only_error(self, monkeypatch):\n         import asyncio\n         from tools import web_tools","@@ -217,6 +230,7 @@ def test_web_extract_returns_search_only_error(self, monkeypatch):\n         monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"ddgs\"})\n         monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n         monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n         monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n \n         result_str = asyncio.get_event_loop().run_until_complete(","@@ -235,6 +249,8 @@ def test_web_crawl_returns_search_only_error(self, monkeypatch):\n         monkeypatch.setattr(web_tools, \"_ddgs_package_importable\", lambda: True)\n         monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n         monkeypatch.setattr(web_tools, \"check_firecrawl_api_key\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(web_tools, \"check_website_access\", lambda url: None)\n         monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n \n         result_str = asyncio.get_event_loop().run_until_complete("]},{"file":"tests/tools/test_web_providers_searxng.py","hunks":["@@ -17,6 +17,8 @@\n \n import pytest\n \n+from tests.tools.conftest import register_all_web_providers\n+\n \n # ---------------------------------------------------------------------------\n # SearXNGWebSearchProvider unit tests","@@ -301,6 +303,15 @@ def test_no_credentials_fails(self, monkeypatch):\n class TestSearXNGOnlyExtractCrawlErrors:\n     \"\"\"When searxng is the active backend, extract/crawl must return clear errors.\"\"\"\n \n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n     def test_web_crawl_searxng_returns_clear_error(self, monkeypatch):\n         import asyncio\n         from tools import web_tools","@@ -309,6 +320,8 @@ def test_web_crawl_searxng_returns_clear_error(self, monkeypatch):\n         monkeypatch.setenv(\"SEARXNG_URL\", \"http://localhost:8080\")\n         monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n         monkeypatch.setattr(web_tools, \"check_firecrawl_api_key\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n+        monkeypatch.setattr(web_tools, \"check_website_access\", lambda url: None)\n         monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n \n         import json","@@ -326,6 +339,7 @@ def test_web_extract_searxng_returns_clear_error(self, monkeypatch):\n         monkeypatch.setattr(web_tools, \"_load_web_config\", lambda: {\"backend\": \"searxng\"})\n         monkeypatch.setenv(\"SEARXNG_URL\", \"http://localhost:8080\")\n         monkeypatch.setattr(web_tools, \"_is_tool_gateway_ready\", lambda: False)\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n         monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False, raising=False)\n \n         import json"]},{"file":"tests/tools/test_web_tools_tavily.py","hunks":["@@ -13,6 +13,8 @@\n import pytest\n from unittest.mock import patch, MagicMock\n \n+from tests.tools.conftest import register_all_web_providers\n+\n \n # ─── _tavily_request ─────────────────────────────────────────────────────────\n ","@@ -163,6 +165,15 @@ def test_fallback_url(self):\n class TestWebSearchTavily:\n     \"\"\"Test web_search_tool dispatch to Tavily.\"\"\"\n \n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n     def test_search_dispatches_to_tavily(self):\n         mock_response = MagicMock()\n         mock_response.json.return_value = {","@@ -186,6 +197,15 @@ def test_search_dispatches_to_tavily(self):\n class TestWebExtractTavily:\n     \"\"\"Test web_extract_tool dispatch to Tavily.\"\"\"\n \n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n     def test_extract_dispatches_to_tavily(self):\n         mock_response = MagicMock()\n         mock_response.json.return_value = {","@@ -211,6 +231,15 @@ def test_extract_dispatches_to_tavily(self):\n class TestWebCrawlTavily:\n     \"\"\"Test web_crawl_tool dispatch to Tavily.\"\"\"\n \n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n     def test_crawl_dispatches_to_tavily(self):\n         mock_response = MagicMock()\n         mock_response.json.return_value = {"]},{"file":"tests/tools/test_website_policy.py","hunks":["@@ -4,6 +4,8 @@\n import pytest\n import yaml\n \n+from tests.tools.conftest import register_all_web_providers\n+\n from tools.website_policy import WebsitePolicyError, check_website_access, load_website_blocklist\n \n ","@@ -347,40 +349,191 @@ def test_browser_navigate_allows_when_shared_file_missing(monkeypatch, tmp_path)\n     assert result is None\n \n \n[redacted:email]\n-async def test_web_extract_short_circuits_blocked_url(monkeypatch):\n-    from tools import web_tools\n-    from plugins.web.firecrawl import provider as firecrawl_provider\n+class TestWebToolPolicy:\n+    \"\"\"Tests that exercise web_extract_tool / web_crawl_tool with website-policy gates.\n+\n+    These tests need the bundled web providers to be registered in the\n+    agent.web_search_registry so the tool dispatchers can find an active\n+    provider.  Without registration, the tools return an error dict that\n+    lacks a ``results`` key, causing ``KeyError``.\n+    \"\"\"\n+\n+    _register_providers = staticmethod(register_all_web_providers)\n+\n+    @pytest.fixture(autouse=True)\n+    def _populate_web_registry(self):\n+        self._register_providers()\n+        yield\n+        from agent.web_search_registry import _reset_for_tests\n+        _reset_for_tests()\n+\n+    @pytest.mark.asyncio\n+    async def test_web_extract_short_circuits_blocked_url(self, monkeypatch):\n+        from tools import web_tools\n+        from plugins.web.firecrawl import provider as firecrawl_provider\n+\n+        # Allow test URLs past SSRF check so website policy is what gets tested\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n+        # The per-URL website-policy gate moved into the firecrawl plugin's\n+        # extract() during the web-provider migration. Patch it at the new\n+        # location; the dispatcher-level gate (used by web_crawl_tool's\n+        # pre-flight) still lives on tools.web_tools.\n+        monkeypatch.setattr(\n+            firecrawl_provider,\n+            \"check_website_access\",\n+            lambda url: {\n+                \"host\": \"blocked.test\",\n+                \"rule\": \"blocked.test\",\n+                \"source\": \"config\",\n+                \"message\": \"Blocked by website policy\",\n+            },\n+        )\n+        monkeypatch.setattr(\n+            firecrawl_provider,\n+            \"_get_firecrawl_client\",\n+            lambda: pytest.fail(\"firecrawl should not run for blocked URL\"),\n+        )\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n+        # Force the firecrawl plugin to be the active extract provider.\n+        monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n+\n+        result = json.loads(await web_tools.web_extract_tool([\"https://blocked.test\"], use_llm_processing=False))\n+\n+        assert result[\"results\"][0][\"url\"] == \"https://blocked.test\"\n+        assert \"Blocked by website policy\" in result[\"results\"][0][\"error\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_web_extract_blocks_redirected_final_url(self, monkeypatch):\n+        from tools import web_tools\n+        from plugins.web.firecrawl import provider as firecrawl_provider\n+\n+        # Allow test URLs past SSRF check so website policy is what gets tested\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n+\n+        def fake_check(url):\n+            if url == \"https://allowed.test\":\n+                return None\n+            if url == \"https://blocked.test/final\":\n+                return {\n+                    \"host\": \"blocked.test\",\n+                    \"rule\": \"blocked.test\",\n+                    \"source\": \"config\",\n+                    \"message\": \"Blocked by website policy\",\n+                }\n+            pytest.fail(f\"unexpected URL checked: {url}\")\n+\n+        class FakeFirecrawlClient:\n+            def scrape(self, url, formats):\n+                return {\n+                    \"markdown\": \"secret content\",\n+                    \"metadata\": {\n+                        \"title\": \"Redirected\",\n+                        \"sourceURL\": \"https://blocked.test/final\",\n+                    },\n+                }\n \n-    # Allow test URLs past SSRF check so website policy is what gets tested\n-    monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n-    # The per-URL website-policy gate moved into the firecrawl plugin's\n-    # extract() during the web-provider migration. Patch it at the new\n-    # location; the dispatcher-level gate (used by web_crawl_tool's\n-    # pre-flight) still lives on tools.web_tools.\n-    monkeypatch.setattr(\n-        firecrawl_provider,\n-        \"check_website_access\",\n-        lambda url: {\n-            \"host\": \"blocked.test\",\n-            \"rule\": \"blocked.test\",\n-            \"source\": \"config\",\n-            \"message\": \"Blocked by website policy\",\n-        },\n-    )\n-    monkeypatch.setattr(\n-        firecrawl_provider,\n-        \"_get_firecrawl_client\",\n-        lambda: pytest.fail(\"firecrawl should not run for blocked URL\"),\n-    )\n-    monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n-    # Force the firecrawl plugin to be the active extract provider.\n-    monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n+        # After the web-provider migration, the per-URL gate + firecrawl client\n+        # live in the plugin. Patch both at the plugin location.\n+        monkeypatch.setattr(firecrawl_provider, \"check_website_access\", fake_check)\n+        monkeypatch.setattr(firecrawl_provider, \"_get_firecrawl_client\", lambda: FakeFirecrawlClient())\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n+        monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n+\n+        result = json.loads(await web_tools.web_extract_tool([\"https://allowed.test\"], use_llm_processing=False))\n+\n+        assert result[\"results\"][0][\"url\"] == \"https://blocked.test/final\"\n+        assert result[\"results\"][0][\"content\"] == \"\"\n+        assert result[\"results\"][0][\"blocked_by_policy\"][\"rule\"] == \"blocked.test\"\n+\n+    @pytest.mark.asyncio\n+    async def test_web_crawl_short_circuits_blocked_url(self, monkeypatch):\n+        from tools import web_tools\n+\n+        # web_crawl_tool checks for Firecrawl env before website policy\n+        monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n+        # Allow test URLs past SSRF check so website policy is what gets tested\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n+        # The dispatcher-level (seed-URL) policy gate still lives on web_tools.\n+        # No per-page gate runs in this test because the dispatcher returns\n+        # immediately when the seed is blocked, before delegating to the plugin.\n+        monkeypatch.setattr(\n+            web_tools,\n+            \"check_website_access\",\n+            lambda url: {\n+                \"host\": \"blocked.test\",\n+                \"rule\": \"blocked.test\",\n+                \"source\": \"config\",\n+                \"message\": \"Blocked by website policy\",\n+            },\n+        )\n+        # If the dispatcher ever reaches the firecrawl plugin's crawl(), the test\n+        # fails — pin the plugin module's client lookup so we'd notice.\n+        from plugins.web.firecrawl import provider as firecrawl_provider\n+        monkeypatch.setattr(\n+            firecrawl_provider,\n+            \"_get_firecrawl_client\",\n+            lambda: pytest.fail(\"firecrawl plugin should not run for blocked crawl URL\"),\n+        )\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n+\n+        result = json.loads(await web_tools.web_crawl_tool(\"https://blocked.test\", use_llm_processing=False))\n+\n+        assert result[\"results\"][0][\"url\"] == \"https://blocked.test\"\n+        assert result[\"results\"][0][\"blocked_by_policy\"][\"rule\"] == \"blocked.test\"\n+\n+    @pytest.mark.asyncio\n+    async def test_web_crawl_blocks_redirected_final_url(self, monkeypatch):\n+        from tools import web_tools\n+        from plugins.web.firecrawl import provider as firecrawl_provider\n+\n+        # Force the firecrawl plugin to be the active crawl provider.\n+        monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n+        # Allow test URLs past SSRF check so website policy is what gets tested\n+        monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n+\n+        def fake_check(url):\n+            # Dispatcher seed-URL gate (web_tools.check_website_access call)\n+            # and plugin per-page gate (firecrawl_provider.check_website_access\n+            # call) both flow through this single fake_check.\n+            if url == \"https://allowed.test\":\n+                return None\n+            if url == \"https://blocked.test/final\":\n+                return {\n+                    \"host\": \"blocked.test\",\n+                    \"rule\": \"blocked.test\",\n+                    \"source\": \"config\",\n+                    \"message\": \"Blocked by website policy\",\n+                }\n+            pytest.fail(f\"unexpected URL checked: {url}\")\n+\n+        class FakeCrawlClient:\n+            def crawl(self, url, **kwargs):\n+                return {\n+                    \"data\": [\n+                        {\n+                            \"markdown\": \"secret crawl content\",\n+                            \"metadata\": {\n+                                \"title\": \"Redirected crawl page\",\n+                                \"sourceURL\": \"https://blocked.test/final\",\n+                            },\n+                        }\n+                    ]\n+                }\n \n-    result = json.loads(await web_tools.web_extract_tool([\"https://blocked.test\"], use_llm_processing=False))\n+        # After PR #25182 follow-up: per-page policy gate lives in\n+        # plugins.web.firecrawl.provider.crawl(). Patch the gate + client at\n+        # the plugin location. The dispatcher-level (seed) gate also reads\n+        # web_tools.check_website_access — patch both.\n+        monkeypatch.setattr(web_tools, \"check_website_access\", fake_check)\n+        monkeypatch.setattr(firecrawl_provider, \"check_website_access\", fake_check)\n+        monkeypatch.setattr(firecrawl_provider, \"_get_firecrawl_client\", lambda: FakeCrawlClient())\n+        monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n \n-    assert result[\"results\"][0][\"url\"] == \"https://blocked.test\"\n-    assert \"Blocked by website policy\" in result[\"results\"][0][\"error\"]\n+        result = json.loads(await web_tools.web_crawl_tool(\"https://allowed.test\", use_llm_processing=False))\n+\n+        assert result[\"results\"][0][\"content\"] == \"\"\n+        assert result[\"results\"][0][\"error\"] == \"Blocked by website policy\"\n+        assert result[\"results\"][0][\"blocked_by_policy\"][\"rule\"] == \"blocked.test\"\n \n \n def test_check_website_access_fails_open_on_malformed_config(tmp_path, monkeypatch):","@@ -400,139 +553,3 @@ def test_check_website_access_fails_open_on_malformed_config(tmp_path, monkeypat\n     # With default path, errors are caught and fail open\n     result = check_website_access(\"https://example.com\")\n     assert result is None  # allowed, not crashed\n-\n-\n[redacted:email]\n-async def test_web_extract_blocks_redirected_final_url(monkeypatch):\n-    from tools import web_tools\n-    from plugins.web.firecrawl import provider as firecrawl_provider\n-\n-    # Allow test URLs past SSRF check so website policy is what gets tested\n-    monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n-\n-    def fake_check(url):\n-        if url == \"https://allowed.test\":\n-            return None\n-        if url == \"https://blocked.test/final\":\n-            return {\n-                \"host\": \"blocked.test\",\n-                \"rule\": \"blocked.test\",\n-                \"source\": \"config\",\n-                \"message\": \"Blocked by website policy\",\n-            }\n-        pytest.fail(f\"unexpected URL checked: {url}\")\n-\n-    class FakeFirecrawlClient:\n-        def scrape(self, url, formats):\n-            return {\n-                \"markdown\": \"secret content\",\n-                \"metadata\": {\n-                    \"title\": \"Redirected\",\n-                    \"sourceURL\": \"https://blocked.test/final\",\n-                },\n-            }\n-\n-    # After the web-provider migration, the per-URL gate + firecrawl client\n-    # live in the plugin. Patch both at the plugin location.\n-    monkeypatch.setattr(firecrawl_provider, \"check_website_access\", fake_check)\n-    monkeypatch.setattr(firecrawl_provider, \"_get_firecrawl_client\", lambda: FakeFirecrawlClient())\n-    monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n-    monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n-\n-    result = json.loads(await web_tools.web_extract_tool([\"https://allowed.test\"], use_llm_processing=False))\n-\n-    assert result[\"results\"][0][\"url\"] == \"https://blocked.test/final\"\n-    assert result[\"results\"][0][\"content\"] == \"\"\n-    assert result[\"results\"][0][\"blocked_by_policy\"][\"rule\"] == \"blocked.test\"\n-\n-\n[redacted:email]\n-async def test_web_crawl_short_circuits_blocked_url(monkeypatch):\n-    from tools import web_tools\n-\n-    # web_crawl_tool checks for Firecrawl env before website policy\n-    monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n-    # Allow test URLs past SSRF check so website policy is what gets tested\n-    monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n-    # The dispatcher-level (seed-URL) policy gate still lives on web_tools.\n-    # No per-page gate runs in this test because the dispatcher returns\n-    # immediately when the seed is blocked, before delegating to the plugin.\n-    monkeypatch.setattr(\n-        web_tools,\n-        \"check_website_access\",\n-        lambda url: {\n-            \"host\": \"blocked.test\",\n-            \"rule\": \"blocked.test\",\n-            \"source\": \"config\",\n-            \"message\": \"Blocked by website policy\",\n-        },\n-    )\n-    # If the dispatcher ever reaches the firecrawl plugin's crawl(), the test\n-    # fails — pin the plugin module's client lookup so we'd notice.\n-    from plugins.web.firecrawl import provider as firecrawl_provider\n-    monkeypatch.setattr(\n-        firecrawl_provider,\n-        \"_get_firecrawl_client\",\n-        lambda: pytest.fail(\"firecrawl plugin should not run for blocked crawl URL\"),\n-    )\n-    monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n-\n-    result = json.loads(await web_tools.web_crawl_tool(\"https://blocked.test\", use_llm_processing=False))\n-\n-    assert result[\"results\"][0][\"url\"] == \"https://blocked.test\"\n-    assert result[\"results\"][0][\"blocked_by_policy\"][\"rule\"] == \"blocked.test\"\n-\n-\n[redacted:email]\n-async def test_web_crawl_blocks_redirected_final_url(monkeypatch):\n-    from tools import web_tools\n-    from plugins.web.firecrawl import provider as firecrawl_provider\n-\n-    # Force the firecrawl plugin to be the active crawl provider.\n-    monkeypatch.setenv(\"FIRECRAWL_API_KEY\", \"fake-key\")\n-    # Allow test URLs past SSRF check so website policy is what gets tested\n-    monkeypatch.setattr(web_tools, \"is_safe_url\", lambda url: True)\n-\n-    def fake_check(url):\n-        # Dispatcher seed-URL gate (web_tools.check_website_access call)\n-        # and plugin per-page gate (firecrawl_provider.check_website_access\n-        # call) both flow through this single fake_check.\n-        if url == \"https://allowed.test\":\n-            return None\n-        if url == \"https://blocked.test/final\":\n-            return {\n-                \"host\": \"blocked.test\",\n-                \"rule\": \"blocked.test\",\n-                \"source\": \"config\",\n-                \"message\": \"Blocked by website policy\",\n-            }\n-        pytest.fail(f\"unexpected URL checked: {url}\")\n-\n-    class FakeCrawlClient:\n-        def crawl(self, url, **kwargs):\n-            return {\n-                \"data\": [\n-                    {\n-                        \"markdown\": \"secret crawl content\",\n-                        \"metadata\": {\n-                            \"title\": \"Redirected crawl page\",\n-                            \"sourceURL\": \"https://blocked.test/final\",\n-                        },\n-                    }\n-                ]\n-            }\n-\n-    # After PR #25182 follow-up: per-page policy gate lives in\n-    # plugins.web.firecrawl.provider.crawl(). Patch the gate + client at\n-    # the plugin location. The dispatcher-level (seed) gate also reads\n-    # web_tools.check_website_access — patch both.\n-    monkeypatch.setattr(web_tools, \"check_website_access\", fake_check)\n-    monkeypatch.setattr(firecrawl_provider, \"check_website_access\", fake_check)\n-    monkeypatch.setattr(firecrawl_provider, \"_get_firecrawl_client\", lambda: FakeCrawlClient())\n-    monkeypatch.setattr(\"tools.interrupt.is_interrupted\", lambda: False)\n-\n-    result = json.loads(await web_tools.web_crawl_tool(\"https://allowed.test\", use_llm_processing=False))\n-\n-    assert result[\"results\"][0][\"content\"] == \"\"\n-    assert result[\"results\"][0][\"error\"] == \"Blocked by website policy\"\n-    assert result[\"results\"][0][\"blocked_by_policy\"][\"rule\"] == \"blocked.test\""]},{"file":"tests/tools/test_write_deny.py","hunks":["@@ -1,8 +1,10 @@\n \"\"\"Tests for _is_write_denied() — verifies deny list blocks sensitive paths on all platforms.\"\"\"\n \n import os\n+\n import pytest\n from pathlib import Path\n+from unittest.mock import patch\n \n from tools.file_operations import _is_write_denied\n ","@@ -97,8 +99,22 @@ def test_kube_prefix(self):\n     def test_sudoers_d_prefix(self):\n         assert _is_write_denied(\"/etc/sudoers.d/custom\") is True\n \n-    def test_systemd_prefix(self):\n-        assert _is_write_denied(\"/etc/systemd/system/evil.service\") is True\n+    def test_systemd_prefix(self, tmp_path):\n+        # On NixOS, /etc/systemd is a symlink into /nix/store, so\n+        # realpath() resolves it to a store path that doesn't match\n+        # the /etc/systemd/ prefix.  Build a real directory tree so\n+        # realpath is a no-op and prefix matching works.\n+        fake_etc = tmp_path / \"etc\" / \"systemd\" / \"system\"\n+        fake_etc.mkdir(parents=True)\n+        target = str(fake_etc / \"evil.service\")\n+        # Patch the prefix builder to include our tmp_path prefix\n+        import agent.file_safety as _fs\n+        _orig = _fs.build_write_denied_prefixes\n+        _extra_prefix = str(tmp_path / \"etc\" / \"systemd\") + os.sep\n+        def _patched(home):\n+            return _orig(home) + [_extra_prefix]\n+        with patch.object(_fs, \"build_write_denied_prefixes\", _patched):\n+            assert _is_write_denied(target) is True\n \n \n class TestWriteAllowed:"]},{"file":"uv.lock","hunks":["@@ -1261,15 +1261,6 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/e2/bc/7a34e904a415040ba626948d0b0a36a08cd073f12b13342578a68331be3c/exa_py-2.10.2-py3-none-any.whl\", hash = \"sha256:ecb2a7581f4b7a8aeb6b434acce1bbc40f92ed1d4126b2aa6029913acd904a47\", size = 72248, upload-time = \"2026-03-26T20:29:37.306Z\" },\n ]\n \n-[[package]]\n-name = \"execnet\"\n-version = \"2.1.2\"\n-source = { registry = \"https://pypi.org/simple\" }\n-sdist = { url = \"https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz\", hash = \"sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd\", size = 166622, upload-time = \"2025-11-12T09:56:37.75Z\" }\n-wheels = [\n-    { url = \"https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl\", hash = \"sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec\", size = 40708, upload-time = \"2025-11-12T09:56:36.333Z\" },\n-]\n-\n [[package]]\n name = \"fal-client\"\n version = \"0.13.1\"","@@ -1635,9 +1626,7 @@ all = [\n     { name = \"ptyprocess\", marker = \"sys_platform != 'win32'\" },\n     { name = \"pytest\" },\n     { name = \"pytest-asyncio\" },\n-    { name = \"pytest-split\" },\n     { name = \"pytest-timeout\" },\n-    { name = \"pytest-xdist\" },\n     { name = \"pywinpty\", marker = \"sys_platform == 'win32'\" },\n     { name = \"ruff\" },\n     { name = \"simple-term-menu\" },","@@ -1668,9 +1657,7 @@ dev = [\n     { name = \"mcp\" },\n     { name = \"pytest\" },\n     { name = \"pytest-asyncio\" },\n-    { name = \"pytest-split\" },\n     { name = \"pytest-timeout\" },\n-    { name = \"pytest-xdist\" },\n     { name = \"ruff\" },\n     { name = \"ty\" },\n ]","@@ -1863,9 +1850,7 @@ requires-dist = [\n     { name = \"pyjwt\", extras = [\"crypto\"], specifier = \"==2.12.1\" },\n     { name = \"pytest\", marker = \"extra == 'dev'\", specifier = \"==9.0.2\" },\n     { name = \"pytest-asyncio\", marker = \"extra == 'dev'\", specifier = \"==1.3.0\" },\n-    { name = \"pytest-split\", marker = \"extra == 'dev'\", specifier = \"==0.11.0\" },\n     { name = \"pytest-timeout\", marker = \"extra == 'dev'\", specifier = \"==2.4.0\" },\n-    { name = \"pytest-xdist\", marker = \"extra == 'dev'\", specifier = \"==3.8.0\" },\n     { name = \"python-dotenv\", specifier = \"==1.2.2\" },\n     { name = \"python-telegram-bot\", extras = [\"webhooks\"], marker = \"extra == 'messaging'\", specifier = \"==22.6\" },\n     { name = \"python-telegram-bot\", extras = [\"webhooks\"], marker = \"extra == 'termux'\", specifier = \"==22.6\" },","@@ -3482,18 +3467,6 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl\", hash = \"sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5\", size = 15075, upload-time = \"2025-11-10T16:07:45.537Z\" },\n ]\n \n-[[package]]\n-name = \"pytest-split\"\n-version = \"0.11.0\"\n-source = { registry = \"https://pypi.org/simple\" }\n-dependencies = [\n-    { name = \"pytest\" },\n-]\n-sdist = { url = \"https://files.pythonhosted.org/packages/2f/16/8af4c5f2ceb3640bb1f78dfdf5c184556b10dfe9369feaaad7ff1c13f329/pytest_split-0.11.0.tar.gz\", hash = \"sha256:8ebdb29cc72cc962e8eb1ec07db1eeb98ab25e215ed8e3216f6b9fc7ce0ec2b5\", size = 13421, upload-time = \"2026-02-03T09:14:31.469Z\" }\n-wheels = [\n-    { url = \"https://files.pythonhosted.org/packages/ae/a1/d4423657caaa8be9b31e491592b49cebdcfd434d3e74512ce71f6ec39905/pytest_split-0.11.0-py3-none-any.whl\", hash = \"sha256:899d7c0f5730da91e2daf283860eb73b503259cb416851a65599368849c7f382\", size = 11911, upload-time = \"2026-02-03T09:14:33.708Z\" },\n-]\n-\n [[package]]\n name = \"pytest-timeout\"\n version = \"2.4.0\"","@@ -3506,19 +3479,6 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl\", hash = \"sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2\", size = 14382, upload-time = \"2025-05-05T19:44:33.502Z\" },\n ]\n \n-[[package]]\n-name = \"pytest-xdist\"\n-version = \"3.8.0\"\n-source = { registry = \"https://pypi.org/simple\" }\n-dependencies = [\n-    { name = \"execnet\" },\n-    { name = \"pytest\" },\n-]\n-sdist = { url = \"https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz\", hash = \"sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1\", size = 88069, upload-time = \"2025-07-01T13:30:59.346Z\" }\n-wheels = [\n-    { url = \"https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl\", hash = \"sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88\", size = 46396, upload-time = \"2025-07-01T13:30:56.632Z\" },\n-]\n-\n [[package]]\n name = \"python-dateutil\"\n version = \"2.9.0.post0\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}