chore: nuke tests/ and the entire test toolchain

The test suite was carrying migration scars and a long tail of
low-density assertions over SDK-derived behavior. Drop it wholesale.

- Delete ``tests/`` (42 files, ~4900 LoC).
- Drop ``pytest`` / ``pytest-asyncio`` / ``pytest-cov`` /
  ``pytest-mock`` from the dev dependency group; ``uv sync``
  uninstalls the matching wheels.
- Strip the pytest + coverage config blocks, the
  ``flake8-pytest-style`` ruff selector, the ``tests/**`` per-file
  ignores, the ``[tool.mypy.overrides] tests.*`` block, and the
  ``"tests"`` entry from bandit's ``exclude_dirs``.
- Drop the ``test`` / ``test-cov`` Makefile targets; ``dev`` no
  longer depends on tests.
- Strip the ``# Testing`` block from ``.gitignore`` (``.coverage``,
  ``.pytest_cache/``, ``htmlcov/``, ``coverage.xml``, ``nosetests.xml``,
  ``.tox/``, ``.hypothesis/``).

ruff (27) and mypy (82) baselines unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xallam
2026-04-25 13:01:20 -07:00
parent 49c38de3b2
commit a6d578c4a8
46 changed files with 3 additions and 5186 deletions
-12
View File
@@ -39,18 +39,6 @@ pip-delete-this-directory.txt
.pydevproject .pydevproject
.settings/ .settings/
# Testing
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
htmlcov/
# FastAPI # FastAPI
.env.local .env.local
.env.development.local .env.development.local
+2 -20
View File
@@ -1,4 +1,4 @@
.PHONY: help install dev-install format lint type-check test test-cov clean pre-commit setup-dev .PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
help: help:
@echo "Available commands:" @echo "Available commands:"
@@ -13,10 +13,6 @@ help:
@echo " security - Run security checks with bandit" @echo " security - Run security checks with bandit"
@echo " check-all - Run all code quality checks" @echo " check-all - Run all code quality checks"
@echo "" @echo ""
@echo "Testing:"
@echo " test - Run tests with pytest"
@echo " test-cov - Run tests with coverage reporting"
@echo ""
@echo "Development:" @echo "Development:"
@echo " pre-commit - Run pre-commit hooks on all files" @echo " pre-commit - Run pre-commit hooks on all files"
@echo " clean - Clean up cache files and artifacts" @echo " clean - Clean up cache files and artifacts"
@@ -59,17 +55,6 @@ security:
check-all: format lint type-check security check-all: format lint type-check security
@echo "✅ All code quality checks passed!" @echo "✅ All code quality checks passed!"
test:
@echo "🧪 Running tests..."
uv run pytest -v
@echo "✅ Tests complete!"
test-cov:
@echo "🧪 Running tests with coverage..."
uv run pytest -v --cov=strix --cov-report=term-missing --cov-report=html
@echo "✅ Tests with coverage complete!"
@echo "📊 Coverage report generated in htmlcov/"
pre-commit: pre-commit:
@echo "🔧 Running pre-commit hooks..." @echo "🔧 Running pre-commit hooks..."
uv run pre-commit run --all-files uv run pre-commit run --all-files
@@ -78,13 +63,10 @@ pre-commit:
clean: clean:
@echo "🧹 Cleaning up cache files..." @echo "🧹 Cleaning up cache files..."
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "htmlcov" -exec rm -rf {} + 2>/dev/null || true
find . -name "*.pyc" -delete 2>/dev/null || true find . -name "*.pyc" -delete 2>/dev/null || true
find . -name ".coverage" -delete 2>/dev/null || true
@echo "✅ Cleanup complete!" @echo "✅ Cleanup complete!"
dev: format lint type-check test dev: format lint type-check
@echo "✅ Development cycle complete!" @echo "✅ Development cycle complete!"
+1 -78
View File
@@ -71,10 +71,6 @@ dev = [
"pyright>=1.1.401", "pyright>=1.1.401",
"pylint>=3.3.7", "pylint>=3.3.7",
"bandit>=1.8.3", "bandit>=1.8.3",
"pytest>=8.4.0",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1",
"pytest-mock>=3.14.1",
"pre-commit>=4.2.0", "pre-commit>=4.2.0",
"black>=25.1.0", "black>=25.1.0",
"isort>=6.0.1", "isort>=6.0.1",
@@ -131,7 +127,6 @@ module = [
"textual.*", "textual.*",
"pyte.*", "pyte.*",
"libtmux.*", "libtmux.*",
"pytest.*",
"cvss.*", "cvss.*",
"opentelemetry.*", "opentelemetry.*",
"scrubadub.*", "scrubadub.*",
@@ -140,23 +135,6 @@ module = [
] ]
ignore_missing_imports = true ignore_missing_imports = true
# Relax strict rules for test files (pytest decorators are not fully typed)
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_decorators = false
disallow_untyped_defs = false
# Test fixtures often build raw dicts that match SDK TypedDict unions
# structurally; type:ignore-per-line would be very noisy.
disable_error_code = [
"typeddict-item", # TypedDict key access on union variants
"typeddict-unknown-key",
"type-arg", # generic params on dict/MagicMock in test helpers
"no-untyped-call", # SDK has untyped __init__ in some places
"no-any-return", # test helpers often return Any from typed call_args
"arg-type", # fakes pass mocks where SDK wants strict types
"attr-defined", # fake objects monkey-patch attrs
]
# ============================================================================ # ============================================================================
# Ruff Configuration (Fast Python Linter & Formatter) # Ruff Configuration (Fast Python Linter & Formatter)
# ============================================================================ # ============================================================================
@@ -167,7 +145,6 @@ line-length = 100
extend-exclude = [ extend-exclude = [
".git", ".git",
".mypy_cache", ".mypy_cache",
".pytest_cache",
".ruff_cache", ".ruff_cache",
"__pycache__", "__pycache__",
"build", "build",
@@ -203,7 +180,6 @@ select = [
"PIE", # flake8-pie "PIE", # flake8-pie
"T20", # flake8-print "T20", # flake8-print
"PYI", # flake8-pyi "PYI", # flake8-pyi
"PT", # flake8-pytest-style
"Q", # flake8-quotes "Q", # flake8-quotes
"RSE", # flake8-raise "RSE", # flake8-raise
"RET", # flake8-return "RET", # flake8-return
@@ -246,17 +222,6 @@ ignore = [
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"] "strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
"strix/tools/finish/tool.py" = ["PLC0415", "TC002"] "strix/tools/finish/tool.py" = ["PLC0415", "TC002"]
"strix/tools/reporting/tool.py" = ["PLC0415", "TC002"] "strix/tools/reporting/tool.py" = ["PLC0415", "TC002"]
"tests/**/*.py" = [
"S105", # Possible hardcoded password (string literal)
"S106", # Possible hardcoded password (function call)
"S108", # Possible insecure usage of temporary file/directory
"ARG001", # Unused function argument
"ARG002", # Unused method argument (test helpers / fakes mirror SDK signatures)
"PLR2004", # Magic value used in comparison
"PT018", # Multi-part assertions are a pytest style preference
"TC002", # Type-only third-party import (tests import for runtime instantiation)
"TC003", # Type-only stdlib import
]
"strix/tools/**/*.py" = [ "strix/tools/**/*.py" = [
"ARG001", # Unused function argument (tools may have unused args for interface consistency) "ARG001", # Unused function argument (tools may have unused args for interface consistency)
] ]
@@ -409,53 +374,11 @@ ensure_newline_before_comments = true
known_first_party = ["strix"] known_first_party = ["strix"]
known_third_party = ["fastapi", "pydantic", "litellm"] known_third_party = ["fastapi", "pydantic", "litellm"]
# ============================================================================
# Pytest Configuration
# ============================================================================
[tool.pytest.ini_options]
minversion = "6.0"
addopts = [
"--strict-markers",
"--strict-config",
"--cov=strix",
"--cov-report=term-missing",
"--cov-report=html",
"--cov-report=xml",
]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]
python_classes = ["Test*"]
asyncio_mode = "auto"
[tool.coverage.run]
source = ["strix"]
omit = [
"*/tests/*",
"*/migrations/*",
"*/__pycache__/*"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]
# ============================================================================ # ============================================================================
# Bandit Configuration (Security Linting) # Bandit Configuration (Security Linting)
# ============================================================================ # ============================================================================
[tool.bandit] [tool.bandit]
exclude_dirs = ["tests", "docs", "build", "dist"] exclude_dirs = ["docs", "build", "dist"]
skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks skips = ["B101", "B601", "B404", "B603", "B607"] # Skip assert, shell injection, subprocess import and partial path checks
severity = "medium" severity = "medium"
-1
View File
@@ -1 +0,0 @@
# Strix Test Suite
-1
View File
@@ -1 +0,0 @@
"""Tests for strix.agents module."""
-200
View File
@@ -1,200 +0,0 @@
"""Phase 5 tests for the SDK agent factory + prompt renderer.
These two modules are the keystone wiring between Phases 2-4 and an
actual ``Runner.run`` invocation. The tests verify:
- The prompt renderer reuses the existing Jinja template (parity with
legacy LLM._load_system_prompt) and degrades gracefully when the
template isn't available.
- ``build_strix_agent(is_root=True)`` carries ``finish_scan`` and
stops on it; child agents carry ``agent_finish`` and stop on it.
- ``make_child_factory`` snapshots scan-level config into a closure
so each spawned child inherits the right scan_mode / is_whitebox /
prompt context without create_agent having to re-derive it.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
from agents import Agent
from agents.tool import FunctionTool
from strix.agents.factory import build_strix_agent, make_child_factory
from strix.agents.prompt import _resolve_skills, render_system_prompt
# --- prompt renderer ----------------------------------------------------
def test_resolve_skills_deduplicates_and_orders() -> None:
out = _resolve_skills(
requested=["recon", "xss", "recon"],
scan_mode="deep",
is_whitebox=False,
)
assert out == ["recon", "xss", "scan_modes/deep"]
def test_resolve_skills_adds_whitebox_pair() -> None:
out = _resolve_skills(requested=None, scan_mode="fast", is_whitebox=True)
# The whitebox pair sits at the tail; scan_modes goes in the middle
# because callers can append more skills after it via the requested arg.
assert out == [
"scan_modes/fast",
"coordination/source_aware_whitebox",
"custom/source_aware_sast",
]
def test_render_system_prompt_returns_string() -> None:
"""Smoke: the StrixAgent template is on disk and renders to non-empty."""
out = render_system_prompt(skills=[], scan_mode="deep")
assert isinstance(out, str)
# The first line of the template starts with 'You are Strix'.
assert out.startswith("You are Strix")
def test_render_system_prompt_swallows_template_errors() -> None:
"""If the template path can't be resolved, return an empty string
(not raise) — agent construction must never blow up on prompt load."""
with patch(
"strix.agents.prompt.get_strix_resource_path",
side_effect=RuntimeError("missing"),
):
out = render_system_prompt(skills=[])
assert out == ""
# --- factory: shape + tools --------------------------------------------
def test_root_agent_carries_finish_scan_and_stops_there() -> None:
agent = build_strix_agent(name="strix", is_root=True)
assert isinstance(agent, Agent)
tool_names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
assert "finish_scan" in tool_names
assert "agent_finish" not in tool_names
behavior = agent.tool_use_behavior
# StopAtTools is a TypedDict at runtime → behavior is a dict.
assert isinstance(behavior, dict)
assert behavior["stop_at_tool_names"] == ["finish_scan"]
def test_child_agent_carries_agent_finish_and_stops_there() -> None:
agent = build_strix_agent(name="recon-bot", is_root=False)
tool_names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
assert "agent_finish" in tool_names
assert "finish_scan" not in tool_names
behavior = agent.tool_use_behavior
assert isinstance(behavior, dict)
assert behavior["stop_at_tool_names"] == ["agent_finish"]
def test_root_and_child_share_base_tool_set() -> None:
"""The base tool set (think/todo/notes/file_edit/web_search/etc) is
identical between root and child — only the terminator differs."""
root = build_strix_agent(is_root=True)
child = build_strix_agent(is_root=False)
root_names = {t.name for t in root.tools if isinstance(t, FunctionTool)}
child_names = {t.name for t in child.tools if isinstance(t, FunctionTool)}
# Drop the terminators and compare.
assert root_names - {"finish_scan"} == child_names - {"agent_finish"}
def test_agent_includes_graph_and_sandbox_tools() -> None:
"""The graph + sandbox tool families are required for parity with
legacy. Spot-check the ones most likely to be forgotten in a refactor."""
agent = build_strix_agent(is_root=True)
names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
expected = {
"think",
"create_todo",
"create_note",
"web_search",
"str_replace_editor",
"create_vulnerability_report",
"browser_action",
"terminal_execute",
"python_action",
"view_agent_graph",
"agent_status",
"send_message_to_agent",
"wait_for_message",
"create_agent",
}
missing = expected - names
assert not missing, f"missing tools: {missing}"
def test_agent_does_not_include_caido_tools() -> None:
"""Caido tools come from CaidoCapability.tools(); the agent doesn't
declare them directly to avoid double-registration when the SDK
runtime merges capability tools."""
agent = build_strix_agent(is_root=True)
names = {t.name for t in agent.tools if isinstance(t, FunctionTool)}
caido = {
"list_requests",
"view_request",
"send_request",
"repeat_request",
"scope_rules",
"list_sitemap",
"view_sitemap_entry",
}
overlap = names & caido
assert overlap == set(), f"unexpected Caido tools in agent.tools: {overlap}"
def test_agent_uses_run_config_model() -> None:
"""``model=None`` so the RunConfig drives the model alias through
MultiProvider rather than an SDK default like gpt-4.1."""
agent = build_strix_agent(is_root=True)
assert agent.model is None
def test_agent_instructions_contain_rendered_prompt() -> None:
"""The factory must wire the rendered prompt into ``instructions``."""
agent = build_strix_agent(is_root=True, scan_mode="deep")
assert isinstance(agent.instructions, str)
assert agent.instructions.startswith("You are Strix")
# --- child factory ------------------------------------------------------
def test_make_child_factory_returns_callable_that_builds_child() -> None:
factory = make_child_factory(scan_mode="deep", is_whitebox=False)
assert callable(factory)
child = factory(name="sub-1", skills=["recon"])
assert isinstance(child, Agent)
assert child.name == "sub-1"
behavior = child.tool_use_behavior
assert isinstance(behavior, dict)
assert behavior["stop_at_tool_names"] == ["agent_finish"]
def test_make_child_factory_passes_scan_level_config() -> None:
"""Verify scan_mode + is_whitebox flow into the rendered prompt
via the closure rather than the create_agent call site."""
captured: dict[str, Any] = {}
def fake_render(**kwargs: Any) -> str:
captured.update(kwargs)
return "stub-prompt"
factory = make_child_factory(
scan_mode="fast",
is_whitebox=True,
interactive=True,
system_prompt_context={"scope_source": "test"},
)
with patch("strix.agents.factory.render_system_prompt", side_effect=fake_render):
factory(name="child", skills=["xss"])
assert captured["scan_mode"] == "fast"
assert captured["is_whitebox"] is True
assert captured["interactive"] is True
assert captured["system_prompt_context"] == {"scope_source": "test"}
assert captured["skills"] == ["xss"]
-1
View File
@@ -1 +0,0 @@
"""Tests for strix.config module."""
-31
View File
@@ -1,31 +0,0 @@
import json
import os
from strix.config.config import Config, apply_saved_config
def test_apply_config_override_clears_default_only_vars(monkeypatch, tmp_path) -> None:
from strix.interface.main import apply_config_override
default_cfg = tmp_path / "cli-config.json"
default_cfg.write_text(
json.dumps({"env": {"LLM_API_BASE": "https://default.api", "STRIX_LLM": "default-model"}}),
encoding="utf-8",
)
custom_cfg = tmp_path / "custom.json"
custom_cfg.write_text(json.dumps({"env": {"STRIX_LLM": "custom-model"}}), encoding="utf-8")
monkeypatch.setattr(Config, "_config_file_override", None)
monkeypatch.setattr(Config, "_applied_from_default", {})
monkeypatch.setattr(Config, "config_dir", classmethod(lambda cls: tmp_path))
for var_name in Config._llm_env_vars():
monkeypatch.delenv(var_name, raising=False)
apply_saved_config()
assert os.environ.get("LLM_API_BASE") == "https://default.api"
apply_config_override(str(custom_cfg))
assert os.environ.get("STRIX_LLM") == "custom-model"
assert "LLM_API_BASE" not in os.environ
-55
View File
@@ -1,55 +0,0 @@
import json
from strix.config.config import Config
def test_traceloop_vars_are_tracked() -> None:
tracked = Config.tracked_vars()
assert "STRIX_OTEL_TELEMETRY" in tracked
assert "STRIX_POSTHOG_TELEMETRY" in tracked
assert "TRACELOOP_BASE_URL" in tracked
assert "TRACELOOP_API_KEY" in tracked
assert "TRACELOOP_HEADERS" in tracked
def test_apply_saved_uses_saved_traceloop_vars(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "cli-config.json"
config_path.write_text(
json.dumps(
{
"env": {
"TRACELOOP_BASE_URL": "https://otel.example.com",
"TRACELOOP_API_KEY": "api-key",
"TRACELOOP_HEADERS": "x-test=value",
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(Config, "_config_file_override", config_path)
monkeypatch.delenv("TRACELOOP_BASE_URL", raising=False)
monkeypatch.delenv("TRACELOOP_API_KEY", raising=False)
monkeypatch.delenv("TRACELOOP_HEADERS", raising=False)
applied = Config.apply_saved()
assert applied["TRACELOOP_BASE_URL"] == "https://otel.example.com"
assert applied["TRACELOOP_API_KEY"] == "api-key"
assert applied["TRACELOOP_HEADERS"] == "x-test=value"
def test_apply_saved_respects_existing_env_traceloop_vars(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "cli-config.json"
config_path.write_text(
json.dumps({"env": {"TRACELOOP_BASE_URL": "https://otel.example.com"}}),
encoding="utf-8",
)
monkeypatch.setattr(Config, "_config_file_override", config_path)
monkeypatch.setenv("TRACELOOP_BASE_URL", "https://env.example.com")
applied = Config.apply_saved(force=False)
assert "TRACELOOP_BASE_URL" not in applied
-1
View File
@@ -1 +0,0 @@
"""Pytest configuration and shared fixtures for Strix tests."""
-1
View File
@@ -1 +0,0 @@
"""Tests for strix.interface module."""
-153
View File
@@ -1,153 +0,0 @@
import importlib.util
from pathlib import Path
import pytest
def _load_utils_module():
module_path = Path(__file__).resolve().parents[2] / "strix" / "interface" / "utils.py"
spec = importlib.util.spec_from_file_location("strix_interface_utils_test", module_path)
if spec is None or spec.loader is None:
raise RuntimeError("Failed to load strix.interface.utils for tests")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
utils = _load_utils_module()
def test_parse_name_status_uses_rename_destination_path() -> None:
raw = (
b"R100\x00old/path.py\x00new/path.py\x00"
b"R75\x00legacy/module.py\x00modern/module.py\x00"
b"M\x00src/app.py\x00"
b"A\x00src/new_file.py\x00"
b"D\x00src/deleted.py\x00"
)
entries = utils._parse_name_status_z(raw)
classified = utils._classify_diff_entries(entries)
assert "new/path.py" in classified["analyzable_files"]
assert "old/path.py" not in classified["analyzable_files"]
assert "modern/module.py" in classified["analyzable_files"]
assert classified["renamed_files"][0]["old_path"] == "old/path.py"
assert classified["renamed_files"][0]["new_path"] == "new/path.py"
assert "src/deleted.py" in classified["deleted_files"]
assert "src/deleted.py" not in classified["analyzable_files"]
def test_build_diff_scope_instruction_includes_added_modified_and_deleted_guidance() -> None:
scope = utils.RepoDiffScope(
source_path="/tmp/repo",
workspace_subdir="repo",
base_ref="refs/remotes/origin/main",
merge_base="abc123",
added_files=["src/added.py"],
modified_files=["src/changed.py"],
renamed_files=[{"old_path": "src/old.py", "new_path": "src/new.py", "similarity": 90}],
deleted_files=["src/deleted.py"],
analyzable_files=["src/added.py", "src/changed.py", "src/new.py"],
)
instruction = utils.build_diff_scope_instruction([scope])
assert "For Added files, review the entire file content." in instruction
assert "For Modified files, focus primarily on the changed areas." in instruction
assert "Note: These files were deleted" in instruction
assert "src/deleted.py" in instruction
assert "src/old.py -> src/new.py" in instruction
def test_resolve_base_ref_prefers_github_base_ref(monkeypatch) -> None:
calls: list[str] = []
def fake_ref_exists(_repo_path: Path, ref: str) -> bool:
calls.append(ref)
return ref == "refs/remotes/origin/release-2026"
monkeypatch.setattr(utils, "_git_ref_exists", fake_ref_exists)
monkeypatch.setattr(utils, "_extract_github_base_sha", lambda _env: None)
monkeypatch.setattr(utils, "_resolve_origin_head_ref", lambda _repo_path: None)
base_ref = utils._resolve_base_ref(
Path("/tmp/repo"),
diff_base=None,
env={"GITHUB_BASE_REF": "release-2026"},
)
assert base_ref == "refs/remotes/origin/release-2026"
assert calls[0] == "refs/remotes/origin/release-2026"
def test_resolve_base_ref_falls_back_to_remote_main(monkeypatch) -> None:
calls: list[str] = []
def fake_ref_exists(_repo_path: Path, ref: str) -> bool:
calls.append(ref)
return ref == "refs/remotes/origin/main"
monkeypatch.setattr(utils, "_git_ref_exists", fake_ref_exists)
monkeypatch.setattr(utils, "_extract_github_base_sha", lambda _env: None)
monkeypatch.setattr(utils, "_resolve_origin_head_ref", lambda _repo_path: None)
base_ref = utils._resolve_base_ref(Path("/tmp/repo"), diff_base=None, env={})
assert base_ref == "refs/remotes/origin/main"
assert "refs/remotes/origin/main" in calls
assert "origin/main" not in calls
def test_resolve_diff_scope_context_auto_degrades_when_repo_scope_resolution_fails(
monkeypatch,
) -> None:
source = {"source_path": "/tmp/repo", "workspace_subdir": "repo"}
monkeypatch.setattr(utils, "_should_activate_auto_scope", lambda *_args, **_kwargs: True)
monkeypatch.setattr(utils, "_is_git_repo", lambda _repo_path: True)
monkeypatch.setattr(
utils,
"_resolve_repo_diff_scope",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("shallow history")),
)
result = utils.resolve_diff_scope_context(
local_sources=[source],
scope_mode="auto",
diff_base=None,
non_interactive=True,
env={},
)
assert result.active is False
assert result.mode == "auto"
assert result.metadata["active"] is False
assert result.metadata["mode"] == "auto"
assert "skipped_diff_scope_sources" in result.metadata
assert result.metadata["skipped_diff_scope_sources"] == [
"/tmp/repo (diff-scope skipped: shallow history)"
]
def test_resolve_diff_scope_context_diff_mode_still_raises_on_repo_scope_resolution_failure(
monkeypatch,
) -> None:
source = {"source_path": "/tmp/repo", "workspace_subdir": "repo"}
monkeypatch.setattr(utils, "_is_git_repo", lambda _repo_path: True)
monkeypatch.setattr(
utils,
"_resolve_repo_diff_scope",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("shallow history")),
)
with pytest.raises(ValueError, match="shallow history"):
utils.resolve_diff_scope_context(
local_sources=[source],
scope_mode="diff",
diff_base=None,
non_interactive=True,
env={},
)
-1
View File
@@ -1 +0,0 @@
"""Tests for strix.llm module."""
-74
View File
@@ -1,74 +0,0 @@
"""Smoke tests for AnthropicCachingLitellmModel."""
from __future__ import annotations
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
def _make(model: str) -> AnthropicCachingLitellmModel:
# ``LitellmModel.__init__`` only validates that model is a string; we
# don't need a real API key for in-memory ``_patch`` testing.
return AnthropicCachingLitellmModel(model=model, api_key="test-key")
def test_is_anthropic_detects_anthropic_prefix() -> None:
m = _make("anthropic/claude-3-5-sonnet")
assert m._is_anthropic() is True
def test_is_anthropic_detects_claude_substring() -> None:
m = _make("openrouter/anthropic-claude-haiku")
assert m._is_anthropic() is True
def test_is_anthropic_false_for_openai() -> None:
m = _make("openai/gpt-4o")
assert m._is_anthropic() is False
def test_is_anthropic_false_for_gemini() -> None:
m = _make("gemini/gemini-1.5-pro")
assert m._is_anthropic() is False
def test_patch_anthropic_adds_cache_control_to_system() -> None:
m = _make("anthropic/claude-3-5-sonnet")
items: list = [
{"role": "system", "content": "You are a helpful agent."},
{"role": "user", "content": "hi"},
]
out = m._patch(items)
assert out[0]["role"] == "system"
content = out[0]["content"]
assert isinstance(content, list)
assert content[0]["type"] == "text"
assert content[0]["text"] == "You are a helpful agent."
assert content[0]["cache_control"] == {"type": "ephemeral"}
# Second item passes through unchanged.
assert out[1] == {"role": "user", "content": "hi"}
def test_patch_non_anthropic_passes_through() -> None:
m = _make("openai/gpt-4o")
items: list = [
{"role": "system", "content": "You are a helpful agent."},
{"role": "user", "content": "hi"},
]
assert m._patch(items) is items # exact same list reference, no copy
def test_patch_skips_non_string_system_content() -> None:
"""If system content is already structured (e.g., previously patched),
don't re-wrap — pass through unchanged."""
m = _make("anthropic/claude-3-5-sonnet")
items: list = [
{"role": "system", "content": [{"type": "text", "text": "x"}]},
{"role": "user", "content": "hi"},
]
out = m._patch(items)
assert out[0]["content"] == [{"type": "text", "text": "x"}]
def test_patch_handles_empty_list() -> None:
m = _make("anthropic/claude-3-5-sonnet")
assert m._patch([]) == []
-42
View File
@@ -1,42 +0,0 @@
"""Smoke tests for the multi-provider setup."""
from __future__ import annotations
import pytest
from agents.exceptions import UserError
from strix.llm.anthropic_cache_wrapper import AnthropicCachingLitellmModel
from strix.llm.multi_provider_setup import (
_AnthropicCachingProvider,
build_multi_provider,
)
def test_anthropic_provider_wraps_in_caching_model() -> None:
provider = _AnthropicCachingProvider()
model = provider.get_model("claude-sonnet-4-6")
assert isinstance(model, AnthropicCachingLitellmModel)
# The provider re-prefixes with ``anthropic/`` so litellm routes correctly.
assert model.model == "anthropic/claude-sonnet-4-6"
assert model._is_anthropic() is True
def test_anthropic_provider_preserves_existing_anthropic_prefix() -> None:
"""If the alias already carries ``anthropic/``, don't double-prefix."""
provider = _AnthropicCachingProvider()
model = provider.get_model("anthropic/claude-3-5-sonnet-20241022")
assert model.model == "anthropic/claude-3-5-sonnet-20241022"
def test_anthropic_provider_empty_name_raises() -> None:
provider = _AnthropicCachingProvider()
with pytest.raises(UserError, match="non-empty"):
provider.get_model(None)
def test_build_multi_provider_routes_anthropic_through_caching_wrapper() -> None:
"""The configured MultiProvider should hit our caching wrapper for the
``anthropic/`` prefix."""
mp = build_multi_provider()
model = mp.get_model("anthropic/claude-sonnet-4-6")
assert isinstance(model, AnthropicCachingLitellmModel)
View File
-195
View File
@@ -1,195 +0,0 @@
"""Phase 0 smoke tests for AgentMessageBus."""
from __future__ import annotations
import asyncio
import pytest
from strix.orchestration.bus import AgentMessageBus
@pytest.fixture
def bus() -> AgentMessageBus:
return AgentMessageBus()
@pytest.mark.asyncio
async def test_register_records_agent(bus: AgentMessageBus) -> None:
await bus.register("a1", "alpha", parent_id=None)
assert bus.statuses["a1"] == "running"
assert bus.parent_of["a1"] is None
assert bus.names["a1"] == "alpha"
assert bus.inboxes["a1"] == []
assert bus.stats_live["a1"]["calls"] == 0
@pytest.mark.asyncio
async def test_send_and_drain_fifo(bus: AgentMessageBus) -> None:
await bus.register("a1", "alpha", parent_id=None)
await bus.send("a1", {"from": "b", "content": "first"})
await bus.send("a1", {"from": "c", "content": "second"})
drained = await bus.drain("a1")
assert [m["content"] for m in drained] == ["first", "second"]
assert await bus.drain("a1") == []
@pytest.mark.asyncio
async def test_send_to_unknown_agent_is_dropped(bus: AgentMessageBus) -> None:
await bus.send("ghost", {"from": "user", "content": "x"})
assert "ghost" not in bus.inboxes
@pytest.mark.asyncio
async def test_finalize_clears_inbox_parent_name(bus: AgentMessageBus) -> None:
"""C13 (AUDIT_R3): finalize cleans up routing state to avoid orphan messages."""
await bus.register("a1", "alpha", parent_id=None)
await bus.register("a2", "beta", parent_id="a1")
await bus.send("a1", {"from": "a2", "content": "hi"})
await bus.finalize("a1", "completed")
# Inbox / parent / name removed so siblings can't accidentally re-fill.
assert "a1" not in bus.inboxes
assert "a1" not in bus.parent_of
assert "a1" not in bus.names
# Status remains for diagnostics.
assert bus.statuses["a1"] == "completed"
# Messages sent to a finalized agent are dropped silently.
await bus.send("a1", {"from": "a2", "content": "ignored"})
assert "a1" not in bus.inboxes
@pytest.mark.asyncio
async def test_record_usage_aggregates(bus: AgentMessageBus) -> None:
await bus.register("a1", "alpha", parent_id=None)
class _Details:
cached_tokens = 10
class _Usage:
input_tokens = 100
output_tokens = 50
input_tokens_details = _Details()
await bus.record_usage("a1", _Usage())
await bus.record_usage("a1", _Usage())
stats = bus.stats_live["a1"]
assert stats["in"] == 200
assert stats["out"] == 100
assert stats["cached"] == 20
assert stats["calls"] == 2
@pytest.mark.asyncio
async def test_record_usage_handles_none(bus: AgentMessageBus) -> None:
await bus.register("a1", "alpha", parent_id=None)
await bus.record_usage("a1", None)
assert bus.stats_live["a1"]["calls"] == 0
@pytest.mark.asyncio
async def test_total_stats_snapshot(bus: AgentMessageBus) -> None:
"""C12 (AUDIT_R2): total_stats acquires the lock for a consistent snapshot."""
await bus.register("a1", "alpha", parent_id=None)
await bus.register("a2", "beta", parent_id="a1")
class _Details:
cached_tokens = 5
class _Usage:
input_tokens = 10
output_tokens = 20
input_tokens_details = _Details()
await bus.record_usage("a1", _Usage())
await bus.record_usage("a2", _Usage())
await bus.finalize("a2", "completed")
totals = await bus.total_stats()
assert totals["in"] == 20
assert totals["out"] == 40
assert totals["cached"] == 10
assert totals["calls"] == 2
@pytest.mark.asyncio
async def test_concurrent_send_drain_no_lost_messages() -> None:
bus = AgentMessageBus()
await bus.register("a1", "alpha", parent_id=None)
async def producer(start: int, count: int) -> None:
for i in range(count):
await bus.send("a1", {"from": "p", "content": str(start + i)})
# 50 producers x 20 messages = 1000 messages; drain in 1 reader.
producers = [asyncio.create_task(producer(i * 20, 20)) for i in range(50)]
await asyncio.gather(*producers)
drained = await bus.drain("a1")
assert len(drained) == 1000
@pytest.mark.asyncio
async def test_cancel_descendants_cancels_whole_tree() -> None:
"""C9 (AUDIT_R2): cancel_descendants cancels every transitive child."""
bus = AgentMessageBus()
await bus.register("root", "root", parent_id=None)
await bus.register("child1", "c1", parent_id="root")
await bus.register("grandchild1", "g1", parent_id="child1")
await bus.register("child2", "c2", parent_id="root")
pending = asyncio.get_event_loop().create_future()
async def fake_run() -> None:
await pending # block until cancelled
for aid in ("root", "child1", "grandchild1", "child2"):
bus.tasks[aid] = asyncio.create_task(fake_run())
await bus.cancel_descendants("root")
for aid in ("root", "child1", "grandchild1", "child2"):
assert bus.tasks[aid].cancelled() or bus.tasks[aid].done()
@pytest.mark.asyncio
async def test_cancel_descendants_triggers_leaves_before_root() -> None:
"""C9: explicit ordering check — leaves' .cancel() called before root's."""
bus = AgentMessageBus()
await bus.register("root", "root", parent_id=None)
await bus.register("child1", "c1", parent_id="root")
await bus.register("grandchild1", "g1", parent_id="child1")
cancel_call_order: list[str] = []
pending = asyncio.get_event_loop().create_future()
class _RecordingTask:
"""Wrap a real Task; record the moment .cancel() is invoked."""
def __init__(self, name: str, task: asyncio.Task) -> None:
self._name = name
self._task = task
def done(self) -> bool:
return self._task.done()
def cancelled(self) -> bool:
return self._task.cancelled()
def cancel(self, *args: object, **kwargs: object) -> bool:
cancel_call_order.append(self._name)
return self._task.cancel()
def __await__(self):
return self._task.__await__()
async def fake_run() -> None:
await pending
for aid in ("root", "child1", "grandchild1"):
real = asyncio.create_task(fake_run())
bus.tasks[aid] = _RecordingTask(aid, real) # type: ignore[assignment]
await bus.cancel_descendants("root")
# grandchild and child must have .cancel() called before root.
assert cancel_call_order.index("grandchild1") < cancel_call_order.index("root")
assert cancel_call_order.index("child1") < cancel_call_order.index("root")
-95
View File
@@ -1,95 +0,0 @@
"""Phase 0 smoke tests for inject_messages_filter."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import pytest
from agents.run_config import CallModelData, ModelInputData
from strix.orchestration.bus import AgentMessageBus
from strix.orchestration.filter import inject_messages_filter
@dataclass
class _FakeAgent:
name: str = "agent"
def _call_data(
context: Any,
items: list[Any],
instructions: str | None = "system",
) -> CallModelData[Any]:
return CallModelData(
model_data=ModelInputData(input=items, instructions=instructions),
agent=_FakeAgent(),
context=context,
)
@pytest.mark.asyncio
async def test_empty_inbox_passes_through() -> None:
bus = AgentMessageBus()
await bus.register("a1", "alpha", parent_id=None)
data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "x"}])
out = await inject_messages_filter(data)
assert out.input == [{"role": "user", "content": "x"}]
assert out.instructions == "system"
@pytest.mark.asyncio
async def test_pending_messages_appended_in_order() -> None:
bus = AgentMessageBus()
await bus.register("a1", "alpha", parent_id=None)
await bus.send("a1", {"from": "b", "content": "hello", "type": "info", "priority": "normal"})
await bus.send("a1", {"from": "c", "content": "second", "type": "info", "priority": "high"})
data = _call_data({"bus": bus, "agent_id": "a1"}, [{"role": "user", "content": "task"}])
out = await inject_messages_filter(data)
assert len(out.input) == 3
assert out.input[0] == {"role": "user", "content": "task"}
assert "Message from agent b" in out.input[1]["content"]
assert "hello" in out.input[1]["content"]
assert "second" in out.input[2]["content"]
assert "priority=high" in out.input[2]["content"]
@pytest.mark.asyncio
async def test_user_sender_skips_envelope() -> None:
bus = AgentMessageBus()
await bus.register("a1", "alpha", parent_id=None)
await bus.send("a1", {"from": "user", "content": "follow-up question"})
data = _call_data({"bus": bus, "agent_id": "a1"}, [])
out = await inject_messages_filter(data)
assert out.input == [{"role": "user", "content": "follow-up question"}]
@pytest.mark.asyncio
async def test_no_bus_in_context_passes_through() -> None:
data = _call_data({"agent_id": "a1"}, [{"role": "user", "content": "x"}])
out = await inject_messages_filter(data)
assert out.input == [{"role": "user", "content": "x"}]
@pytest.mark.asyncio
async def test_filter_exception_returns_unmodified() -> None:
"""C14 (AUDIT_R3): filter exception is caught; original data returned."""
class _BrokenBus:
async def drain(self, _: str) -> list[dict[str, Any]]:
raise RuntimeError("simulated bug")
data = _call_data(
{"bus": _BrokenBus(), "agent_id": "a1"},
[{"role": "user", "content": "still works"}],
)
out = await inject_messages_filter(data)
assert out.input == [{"role": "user", "content": "still works"}]
-174
View File
@@ -1,174 +0,0 @@
"""Phase 0 smoke tests for StrixOrchestrationHooks."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from strix.orchestration.bus import AgentMessageBus
from strix.orchestration.hooks import StrixOrchestrationHooks
@dataclass
class _Ctx:
"""Minimal stand-in for RunContextWrapper / AgentHookContext.
Only ``.context`` is exercised by the hooks under test; SDK's real wrappers
expose much more, but the hooks treat ``.context`` as the dict we put in.
"""
context: dict[str, Any] = field(default_factory=dict)
@dataclass
class _Tool:
name: str
class _FakeTracer:
def __init__(self) -> None:
self.starts: list[tuple[str, str]] = []
self.ends: list[tuple[str, str, Any]] = []
def log_tool_start(self, agent_id: str, tool_name: str) -> None:
self.starts.append((agent_id, tool_name))
def log_tool_end(self, agent_id: str, tool_name: str, result: Any) -> None:
self.ends.append((agent_id, tool_name, result))
@pytest.mark.asyncio
async def test_on_llm_start_injects_85_percent_warning() -> None:
hooks = StrixOrchestrationHooks()
items: list[Any] = []
ctx = _Ctx(context={"max_turns": 100, "turn_count": 85})
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
assert len(items) == 1
assert "85%" in items[0]["content"]
@pytest.mark.asyncio
async def test_on_llm_start_injects_n_minus_3_warning() -> None:
hooks = StrixOrchestrationHooks()
items: list[Any] = []
ctx = _Ctx(context={"max_turns": 100, "turn_count": 97})
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
assert len(items) == 1
assert "3 iterations left" in items[0]["content"]
@pytest.mark.asyncio
async def test_on_llm_start_no_warning_at_other_turns() -> None:
hooks = StrixOrchestrationHooks()
items: list[Any] = []
ctx = _Ctx(context={"max_turns": 100, "turn_count": 50})
await hooks.on_llm_start(ctx, agent=None, system_prompt="x", input_items=items)
assert items == []
@pytest.mark.asyncio
async def test_on_llm_end_records_usage_and_increments_turn() -> None:
hooks = StrixOrchestrationHooks()
bus = AgentMessageBus()
await bus.register("a1", "alpha", parent_id=None)
ctx = _Ctx(context={"bus": bus, "agent_id": "a1", "turn_count": 0})
class _Details:
cached_tokens = 5
class _Usage:
input_tokens = 10
output_tokens = 20
input_tokens_details = _Details()
class _Resp:
usage = _Usage()
await hooks.on_llm_end(ctx, agent=None, response=_Resp())
assert ctx.context["turn_count"] == 1
assert bus.stats_live["a1"]["in"] == 10
@pytest.mark.asyncio
async def test_on_agent_end_detects_crash() -> None:
"""on_agent_end without agent_finish_called posts crash message to parent."""
hooks = StrixOrchestrationHooks()
bus = AgentMessageBus()
await bus.register("root", "root", parent_id=None)
await bus.register("child", "specialist", parent_id="root")
ctx = _Ctx(context={"bus": bus, "agent_id": "child"})
await hooks.on_agent_end(ctx, agent=None, output=None) # crashed (output=None)
drained = await bus.drain("root")
assert len(drained) == 1
assert "[Agent crash]" in drained[0]["content"]
assert "child" in drained[0]["content"]
assert drained[0]["type"] == "crash"
assert bus.statuses["child"] == "crashed"
@pytest.mark.asyncio
async def test_on_agent_end_no_crash_when_finish_called() -> None:
hooks = StrixOrchestrationHooks()
bus = AgentMessageBus()
await bus.register("root", "root", parent_id=None)
await bus.register("child", "specialist", parent_id="root")
ctx = _Ctx(
context={
"bus": bus,
"agent_id": "child",
"agent_finish_called": True,
}
)
await hooks.on_agent_end(ctx, agent=None, output="done")
assert await bus.drain("root") == []
assert bus.statuses["child"] == "completed"
@pytest.mark.asyncio
async def test_on_tool_end_marks_finish_called() -> None:
"""When agent_finish or finish_scan returns, mark context flag for crash detection."""
hooks = StrixOrchestrationHooks()
ctx = _Ctx(context={"agent_id": "a1"})
await hooks.on_tool_end(ctx, agent=None, tool=_Tool("agent_finish"), result="ok")
assert ctx.context["agent_finish_called"] is True
@pytest.mark.asyncio
async def test_on_tool_end_other_tool_does_not_set_flag() -> None:
hooks = StrixOrchestrationHooks()
ctx = _Ctx(context={"agent_id": "a1"})
await hooks.on_tool_end(ctx, agent=None, tool=_Tool("terminal_execute"), result="x")
assert ctx.context.get("agent_finish_called") is None
@pytest.mark.asyncio
async def test_on_tool_start_logs_to_tracer() -> None:
hooks = StrixOrchestrationHooks()
tracer = _FakeTracer()
ctx = _Ctx(context={"tracer": tracer, "agent_id": "a1"})
await hooks.on_tool_start(ctx, agent=None, tool=_Tool("browser_action"))
assert tracer.starts == [("a1", "browser_action")]
@pytest.mark.asyncio
async def test_hook_exception_does_not_propagate() -> None:
"""C15 (AUDIT_R3): a bug in the hook body must never tear down the run."""
hooks = StrixOrchestrationHooks()
class _BrokenBus:
async def record_usage(self, *_: Any, **__: Any) -> None:
raise RuntimeError("simulated")
ctx = _Ctx(context={"bus": _BrokenBus(), "agent_id": "a1"})
class _Resp:
usage = None
# Should not raise.
await hooks.on_llm_end(ctx, agent=None, response=_Resp())
-1
View File
@@ -1 +0,0 @@
"""Tests for strix.runtime module."""
-103
View File
@@ -1,103 +0,0 @@
"""Phase 0 smoke tests for StrixDockerSandboxClient.
These tests do NOT require Docker. They mock the Docker SDK client (passed
to the constructor) and verify our subclass injects the right kwargs into
``containers.create``. Live container tests are part of Phase 0 manual
smoke (TESTING_STRATEGY.md §9).
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock
import docker.errors # type: ignore[import-untyped,unused-ignore]
import pytest
from strix.runtime.strix_docker_client import StrixDockerSandboxClient
@pytest.fixture
def fake_docker() -> MagicMock:
"""Stand-in for the Docker SDK client passed to the subclass."""
fake = MagicMock()
fake.images.get.return_value = MagicMock() # image_exists -> True
fake.images.pull = MagicMock()
fake.containers.create.return_value = MagicMock(name="container")
return fake
def _create_kwargs(fake: MagicMock) -> dict[str, Any]:
result: dict[str, Any] = fake.containers.create.call_args.kwargs
return result
def test_subclass_injects_net_admin_and_net_raw(fake_docker: MagicMock) -> None:
client = StrixDockerSandboxClient(docker_client=fake_docker)
asyncio.run(
client._create_container("strix-image:latest", manifest=None, exposed_ports=()),
)
kwargs = _create_kwargs(fake_docker)
assert "NET_ADMIN" in kwargs["cap_add"]
assert "NET_RAW" in kwargs["cap_add"]
def test_subclass_injects_host_gateway(fake_docker: MagicMock) -> None:
client = StrixDockerSandboxClient(docker_client=fake_docker)
asyncio.run(
client._create_container("strix-image:latest", manifest=None, exposed_ports=()),
)
kwargs = _create_kwargs(fake_docker)
assert kwargs["extra_hosts"]["host.docker.internal"] == "host-gateway"
def test_subclass_preserves_image_and_command(fake_docker: MagicMock) -> None:
client = StrixDockerSandboxClient(docker_client=fake_docker)
asyncio.run(
client._create_container("custom:tag", manifest=None, exposed_ports=()),
)
kwargs = _create_kwargs(fake_docker)
assert kwargs["image"] == "custom:tag"
assert kwargs["entrypoint"] == ["tail"]
assert kwargs["command"] == ["-f", "/dev/null"]
assert kwargs["detach"] is True
def test_subclass_emits_ports_dict_for_exposed_ports(fake_docker: MagicMock) -> None:
client = StrixDockerSandboxClient(docker_client=fake_docker)
asyncio.run(
client._create_container(
"strix-image:latest",
manifest=None,
exposed_ports=(48081, 48080),
),
)
kwargs = _create_kwargs(fake_docker)
assert "48081/tcp" in kwargs["ports"]
assert "48080/tcp" in kwargs["ports"]
def test_caps_appended_not_duplicated(fake_docker: MagicMock) -> None:
"""Idempotent injection: calling twice doesn't add duplicate caps."""
client = StrixDockerSandboxClient(docker_client=fake_docker)
asyncio.run(
client._create_container("img:latest", manifest=None, exposed_ports=()),
)
kwargs = _create_kwargs(fake_docker)
assert kwargs["cap_add"].count("NET_ADMIN") == 1
assert kwargs["cap_add"].count("NET_RAW") == 1
def test_pulls_image_when_missing(fake_docker: MagicMock) -> None:
"""If image_exists returns False on first check, pull is invoked."""
# First call raises ImageNotFound, second succeeds.
fake_docker.images.get.side_effect = [
docker.errors.ImageNotFound("not found"),
MagicMock(),
]
client = StrixDockerSandboxClient(docker_client=fake_docker)
asyncio.run(
client._create_container("registry.io/strix:tag", manifest=None, exposed_ports=()),
)
fake_docker.images.pull.assert_called_once()
View File
-156
View File
@@ -1,156 +0,0 @@
"""Phase 4 tests for CaidoCapability.
The capability has three observable behaviors that need parity with the
PLAYBOOK contract:
1. ``process_manifest`` injects http_proxy / https_proxy / ALL_PROXY env
vars into the manifest's ``Environment.value`` dict.
2. ``tools()`` returns the seven Caido SDK function tools we wrapped in
Phase 2.5 — same instances, in the documented order.
3. ``bind`` schedules an aggregated healthcheck task; the orchestration
hook later awaits it on first agent start.
The healthcheck task itself is exercised by the healthcheck unit tests;
here we only verify the wiring (task created, name set, points at the
right ports).
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from agents.sandbox.entries import LocalDir
from agents.sandbox.manifest import Environment, Manifest
from strix.sandbox.caido_capability import CaidoCapability
def test_capability_type_and_default_state() -> None:
cap = CaidoCapability()
assert cap.type == "caido"
assert cap._healthcheck_task is None
assert cap._tool_server_host_port is None
assert cap._caido_host_port is None
def test_process_manifest_injects_proxy_env_vars(tmp_path: object) -> None:
"""Existing env vars must be preserved; proxy keys are added."""
cap = CaidoCapability()
manifest = Manifest(
environment=Environment(
value={"PYTHONUNBUFFERED": "1", "TOOL_SERVER_TOKEN": "abc"},
),
)
out = cap.process_manifest(manifest)
env = out.environment.value
# Pre-existing entries preserved.
assert env["PYTHONUNBUFFERED"] == "1"
assert env["TOOL_SERVER_TOKEN"] == "abc"
# Proxy entries injected, all pointing at the in-container Caido port.
assert env["http_proxy"] == "http://127.0.0.1:48080"
assert env["https_proxy"] == "http://127.0.0.1:48080"
assert env["ALL_PROXY"] == "http://127.0.0.1:48080"
def test_process_manifest_handles_missing_environment() -> None:
"""A manifest without env entries should still get the proxy block."""
cap = CaidoCapability()
# ``LocalDir`` requires a real path on disk; use a temp one to satisfy
# the validator without actually mounting anything.
manifest = Manifest(entries={"src": LocalDir(src="/tmp")})
out = cap.process_manifest(manifest)
env = out.environment.value
assert env["http_proxy"] == "http://127.0.0.1:48080"
def test_tools_returns_seven_caido_tools_in_order() -> None:
cap = CaidoCapability()
names = [t.name for t in cap.tools()]
assert names == [
"list_requests",
"view_request",
"send_request",
"repeat_request",
"scope_rules",
"list_sitemap",
"view_sitemap_entry",
]
def test_tools_returns_a_fresh_list_per_call() -> None:
"""SDK convention — caller may mutate the returned list."""
cap = CaidoCapability()
a = cap.tools()
b = cap.tools()
assert a == b
assert a is not b
@pytest.mark.asyncio
async def test_instructions_mentions_caido_and_tools() -> None:
cap = CaidoCapability()
out = await cap.instructions(Manifest())
assert out is not None
assert "<caido_proxy>" in out
# Every tool name appears verbatim so the model knows what's available.
for name in (
"list_requests",
"view_request",
"send_request",
"repeat_request",
"scope_rules",
"list_sitemap",
"view_sitemap_entry",
):
assert name in out
def test_configure_host_ports_stores_both() -> None:
cap = CaidoCapability()
cap.configure_host_ports(tool_server_host_port=12345, caido_host_port=12346)
assert cap._tool_server_host_port == 12345
assert cap._caido_host_port == 12346
@pytest.mark.asyncio
async def test_bind_without_configured_ports_skips_healthcheck() -> None:
"""If the session manager forgets to configure ports, bind shouldn't
schedule a probe against ``None`` — it should warn and no-op.
"""
cap = CaidoCapability()
fake_session = MagicMock()
cap.bind(fake_session)
assert cap._healthcheck_task is None
assert cap.session is fake_session
@pytest.mark.asyncio
async def test_bind_schedules_healthcheck_task_when_ports_configured() -> None:
"""The hook chain (StrixOrchestrationHooks.on_agent_start) awaits this
task — it must exist as an asyncio.Task with a useful name.
"""
cap = CaidoCapability()
cap.configure_host_ports(tool_server_host_port=54321, caido_host_port=54322)
fake_session = MagicMock()
# Patch the actual probes so we don't try to connect for real.
async def _fake_probe(*args: object, **kwargs: object) -> None:
return None
with (
patch(
"strix.sandbox.caido_capability.wait_for_http_ready",
side_effect=_fake_probe,
),
patch(
"strix.sandbox.caido_capability.wait_for_tcp_ready",
side_effect=_fake_probe,
),
):
cap.bind(fake_session)
assert cap._healthcheck_task is not None
assert isinstance(cap._healthcheck_task, asyncio.Task)
assert "caido-healthcheck-54321" in cap._healthcheck_task.get_name()
await cap._healthcheck_task # must complete without error
-171
View File
@@ -1,171 +0,0 @@
"""Phase 4 tests for the sandbox port readiness probes.
The two helpers (``wait_for_http_ready`` and ``wait_for_tcp_ready``)
gate session bring-up, so a regression here would mean every fresh
scan hits a connection-refused on its first tool call. Tests cover:
- Happy path returns when the probe succeeds.
- Polling continues across transient failures.
- Timeout raises ``SandboxNotReadyError`` with a useful last-error.
- Real ``asyncio.open_connection`` against a local listener verifies
the TCP probe end-to-end (no mocking — the helper is small enough
that a real socket is the cheaper test).
"""
from __future__ import annotations
import asyncio
import contextlib
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from strix.sandbox.healthcheck import (
SandboxNotReadyError,
wait_for_http_ready,
wait_for_tcp_ready,
)
# --- HTTP probe ----------------------------------------------------------
@pytest.mark.asyncio
async def test_wait_for_http_ready_returns_immediately_on_2xx() -> None:
response = MagicMock(spec=httpx.Response)
response.status_code = 200
client = AsyncMock()
client.get = AsyncMock(return_value=response)
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client):
await wait_for_http_ready("http://localhost:9999/health", timeout=1)
assert client.get.await_count == 1
@pytest.mark.asyncio
async def test_wait_for_http_ready_polls_through_connect_errors() -> None:
"""Two connect errors followed by a 200 — the helper should keep going."""
response_ok = MagicMock(spec=httpx.Response)
response_ok.status_code = 200
side_effects: list[Any] = [
httpx.ConnectError("conn refused"),
httpx.ConnectError("conn refused"),
response_ok,
]
client = AsyncMock()
client.get = AsyncMock(side_effect=side_effects)
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client):
await wait_for_http_ready(
"http://localhost:9999/health",
timeout=5,
poll_interval=0.01,
)
assert client.get.await_count == 3
@pytest.mark.asyncio
async def test_wait_for_http_ready_raises_after_timeout() -> None:
client = AsyncMock()
client.get = AsyncMock(side_effect=httpx.ConnectError("nope"))
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
with (
patch(
"strix.sandbox.healthcheck.httpx.AsyncClient",
return_value=client,
),
pytest.raises(SandboxNotReadyError) as exc_info,
):
await wait_for_http_ready(
"http://localhost:9999/health",
timeout=0.3,
poll_interval=0.05,
)
err = str(exc_info.value)
assert "http://localhost:9999/health" in err
assert "ConnectError" in err
@pytest.mark.asyncio
async def test_wait_for_http_ready_treats_5xx_as_not_ready() -> None:
response_500 = MagicMock(spec=httpx.Response)
response_500.status_code = 500
response_ok = MagicMock(spec=httpx.Response)
response_ok.status_code = 200
client = AsyncMock()
client.get = AsyncMock(side_effect=[response_500, response_ok])
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
with patch("strix.sandbox.healthcheck.httpx.AsyncClient", return_value=client):
await wait_for_http_ready(
"http://localhost:9999/health",
timeout=2,
poll_interval=0.01,
)
assert client.get.await_count == 2
# --- TCP probe -----------------------------------------------------------
@pytest.mark.asyncio
async def test_wait_for_tcp_ready_against_real_listener() -> None:
"""Spin up a local TCP echo server and verify the probe connects."""
async def _server_handler(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
# Drain any bytes the test sends, then close.
await reader.read(0)
writer.close()
with contextlib.suppress(OSError):
await writer.wait_closed()
server = await asyncio.start_server(_server_handler, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
try:
await wait_for_tcp_ready("127.0.0.1", port, timeout=2, poll_interval=0.05)
finally:
server.close()
await server.wait_closed()
@pytest.mark.asyncio
async def test_wait_for_tcp_ready_raises_when_port_closed() -> None:
async def _no_handler(
_reader: asyncio.StreamReader,
_writer: asyncio.StreamWriter,
) -> None:
return
# Bind and immediately close to claim a definitely-unused port number.
server = await asyncio.start_server(_no_handler, "127.0.0.1", 0)
closed_port = server.sockets[0].getsockname()[1]
server.close()
await server.wait_closed()
with pytest.raises(SandboxNotReadyError) as exc_info:
await wait_for_tcp_ready(
"127.0.0.1",
closed_port,
timeout=0.3,
poll_interval=0.05,
)
err = str(exc_info.value)
assert f"127.0.0.1:{closed_port}" in err
-206
View File
@@ -1,206 +0,0 @@
"""Phase 4 tests for the per-scan sandbox session manager.
We don't spin up real Docker here — the ``StrixDockerSandboxClient`` is
patched and we assert on the manifest / options / bundle shape. Goals:
- Cache hit: a second ``create_or_reuse(scan_id, ...)`` returns the same
bundle without calling client.create twice.
- Manifest carries the right env vars (TOOL_SERVER_TOKEN, container ports,
STRIX_SANDBOX_EXECUTION_TIMEOUT, PYTHONUNBUFFERED).
- The Docker client options request both container ports be exposed.
- Capability is configured with the resolved host ports *before* bind,
so its healthcheck task probes the right ones.
- Bundle is cached and surfaces in ``cached_scan_ids``.
- ``cleanup`` cancels the healthcheck task and calls ``client.delete``;
errors during delete are swallowed.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from strix.sandbox import session_manager
from strix.sandbox.caido_capability import CaidoCapability
@pytest.fixture(autouse=True)
def _isolate_cache() -> Iterator[None]:
session_manager._reset_cache_for_tests()
yield
session_manager._reset_cache_for_tests()
def _noop_bind(_self: Any, _session: Any) -> None:
"""Stand-in for CaidoCapability.bind that skips the healthcheck task."""
def _make_endpoint(port: int) -> Any:
ep = MagicMock()
ep.port = port
ep.host = "127.0.0.1"
ep.tls = False
return ep
def _make_client_and_session(
*,
tool_port: int = 12001,
caido_port: int = 12002,
) -> tuple[Any, Any]:
"""Build a fake DockerSandboxClient and session pair."""
session = MagicMock()
session._resolve_exposed_port = AsyncMock(
side_effect=lambda port: _make_endpoint(
tool_port if port == 48081 else caido_port,
),
)
client = MagicMock()
client.create = AsyncMock(return_value=session)
client.delete = AsyncMock()
return client, session
@pytest.mark.asyncio
async def test_create_or_reuse_creates_new_session(tmp_path: Any) -> None:
client, session = _make_client_and_session()
# Patch the capability's bind to a no-op so we don't spin up the
# healthcheck task in unit tests.
with (
patch(
"strix.sandbox.session_manager.StrixDockerSandboxClient",
return_value=client,
),
patch.object(CaidoCapability, "bind", _noop_bind),
):
bundle = await session_manager.create_or_reuse(
"scan-1",
image="strix-sandbox:test",
sources_path=tmp_path,
)
# Bundle shape.
assert bundle["client"] is client
assert bundle["session"] is session
assert bundle["tool_server_host_port"] == 12001
assert bundle["caido_host_port"] == 12002
assert isinstance(bundle["bearer"], str) and len(bundle["bearer"]) >= 32
assert isinstance(bundle["capability"], CaidoCapability)
# Capability got the resolved host ports BEFORE bind would have run.
assert bundle["capability"]._tool_server_host_port == 12001
assert bundle["capability"]._caido_host_port == 12002
# client.create called exactly once with manifest + exposed ports.
assert client.create.await_count == 1
options = client.create.await_args.kwargs["options"]
assert options.image == "strix-sandbox:test"
assert set(options.exposed_ports) == {48080, 48081}
manifest = client.create.await_args.kwargs["manifest"]
env = manifest.environment.value
assert env["TOOL_SERVER_TOKEN"] == bundle["bearer"]
assert env["TOOL_SERVER_PORT"] == "48081"
assert env["CAIDO_PORT"] == "48080"
assert env["STRIX_SANDBOX_EXECUTION_TIMEOUT"] == "120"
assert env["PYTHONUNBUFFERED"] == "1"
assert env["HOST_GATEWAY"] == "host.docker.internal"
@pytest.mark.asyncio
async def test_create_or_reuse_returns_cached_bundle(tmp_path: Any) -> None:
client, _ = _make_client_and_session()
with (
patch(
"strix.sandbox.session_manager.StrixDockerSandboxClient",
return_value=client,
),
patch.object(CaidoCapability, "bind", _noop_bind),
):
first = await session_manager.create_or_reuse(
"scan-X",
image="i",
sources_path=tmp_path,
)
second = await session_manager.create_or_reuse(
"scan-X",
image="i",
sources_path=tmp_path,
)
assert first is second
assert client.create.await_count == 1
assert "scan-X" in session_manager.cached_scan_ids()
@pytest.mark.asyncio
async def test_create_or_reuse_passes_custom_execution_timeout(tmp_path: Any) -> None:
client, _ = _make_client_and_session()
with (
patch(
"strix.sandbox.session_manager.StrixDockerSandboxClient",
return_value=client,
),
patch.object(CaidoCapability, "bind", _noop_bind),
):
await session_manager.create_or_reuse(
"scan-2",
image="i",
sources_path=tmp_path,
execution_timeout=300,
)
manifest = client.create.await_args.kwargs["manifest"]
assert manifest.environment.value["STRIX_SANDBOX_EXECUTION_TIMEOUT"] == "300"
@pytest.mark.asyncio
async def test_cleanup_calls_delete_and_drops_cache(tmp_path: Any) -> None:
client, session = _make_client_and_session()
with (
patch(
"strix.sandbox.session_manager.StrixDockerSandboxClient",
return_value=client,
),
patch.object(CaidoCapability, "bind", _noop_bind),
):
await session_manager.create_or_reuse(
"scan-3",
image="i",
sources_path=tmp_path,
)
assert "scan-3" in session_manager.cached_scan_ids()
await session_manager.cleanup("scan-3")
client.delete.assert_awaited_once_with(session)
assert "scan-3" not in session_manager.cached_scan_ids()
@pytest.mark.asyncio
async def test_cleanup_swallows_delete_errors(tmp_path: Any) -> None:
"""A flaky Docker daemon shouldn't prevent cache eviction."""
client, _ = _make_client_and_session()
client.delete = AsyncMock(side_effect=RuntimeError("docker daemon went away"))
with (
patch(
"strix.sandbox.session_manager.StrixDockerSandboxClient",
return_value=client,
),
patch.object(CaidoCapability, "bind", _noop_bind),
):
await session_manager.create_or_reuse(
"scan-4",
image="i",
sources_path=tmp_path,
)
await session_manager.cleanup("scan-4") # must not raise
assert "scan-4" not in session_manager.cached_scan_ids()
@pytest.mark.asyncio
async def test_cleanup_unknown_scan_is_noop() -> None:
"""No cached entry → cleanup is a quiet no-op."""
await session_manager.cleanup("never-existed") # must not raise
-1
View File
@@ -1 +0,0 @@
# Tests for skill-related runtime behavior.
View File
-1
View File
@@ -1 +0,0 @@
"""Tests for strix.telemetry module."""
-28
View File
@@ -1,28 +0,0 @@
from strix.telemetry.flags import is_otel_enabled, is_posthog_enabled
def test_flags_fallback_to_strix_telemetry(monkeypatch) -> None:
monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False)
monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False)
monkeypatch.setenv("STRIX_TELEMETRY", "0")
assert is_otel_enabled() is False
assert is_posthog_enabled() is False
def test_otel_flag_overrides_global_telemetry(monkeypatch) -> None:
monkeypatch.setenv("STRIX_TELEMETRY", "0")
monkeypatch.setenv("STRIX_OTEL_TELEMETRY", "1")
monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False)
assert is_otel_enabled() is True
assert is_posthog_enabled() is False
def test_posthog_flag_overrides_global_telemetry(monkeypatch) -> None:
monkeypatch.setenv("STRIX_TELEMETRY", "0")
monkeypatch.setenv("STRIX_POSTHOG_TELEMETRY", "1")
monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False)
assert is_otel_enabled() is False
assert is_posthog_enabled() is True
-201
View File
@@ -1,201 +0,0 @@
"""Phase 1 smoke tests for StrixTracingProcessor."""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from strix.telemetry.strix_processor import StrixTracingProcessor
@dataclass
class _FakeSpanData:
"""Minimal stand-in for an SDK SpanData class."""
name: str = "FunctionSpanData" # used by class .__name__
payload: dict[str, Any] = field(default_factory=dict)
def export(self) -> dict[str, Any]:
return dict(self.payload)
# Concrete SpanData subclasses so the processor's ``_span_kind`` heuristic
# (drop ``SpanData`` suffix, lowercase) produces stable event_types.
class FunctionSpanData(_FakeSpanData):
pass
class GenerationSpanData(_FakeSpanData):
pass
class AgentSpanData(_FakeSpanData):
pass
@dataclass
class _FakeSpan:
span_id: str
trace_id: str
span_data: _FakeSpanData
@dataclass
class _FakeTrace:
trace_id: str
name: str = "test-workflow"
metadata: dict[str, Any] = field(default_factory=dict)
def export(self) -> dict[str, Any]:
return {"name": self.name, "metadata": self.metadata}
def _read_events(path: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
@pytest.fixture
def run_dir(tmp_path: Path) -> Path:
return tmp_path / "strix_runs" / "test-run"
def test_constructor_creates_run_dir(run_dir: Path) -> None:
StrixTracingProcessor(run_dir=run_dir)
assert run_dir.exists()
def test_on_trace_start_writes_run_started(run_dir: Path) -> None:
p = StrixTracingProcessor(run_dir=run_dir)
p.on_trace_start(_FakeTrace(trace_id="t-1", metadata={"scan_id": "abc"}))
events = _read_events(p.events_path)
assert events == [
{
"event_type": "run.started",
"trace_id": "t-1",
"metadata": {"name": "test-workflow", "metadata": {"scan_id": "abc"}},
}
]
def test_on_trace_end_writes_run_completed(run_dir: Path) -> None:
p = StrixTracingProcessor(run_dir=run_dir)
p.on_trace_end(_FakeTrace(trace_id="t-1"))
events = _read_events(p.events_path)
assert events == [{"event_type": "run.completed", "trace_id": "t-1"}]
def test_span_start_and_end_emit_typed_events(run_dir: Path) -> None:
"""``GenerationSpanData`` → ``generation.started`` / ``generation.completed``."""
p = StrixTracingProcessor(run_dir=run_dir)
span = _FakeSpan(
span_id="s-1",
trace_id="t-1",
span_data=GenerationSpanData(payload={"model": "gpt-foo"}),
)
p.on_span_start(span)
p.on_span_end(span)
events = _read_events(p.events_path)
assert [e["event_type"] for e in events] == [
"generation.started",
"generation.completed",
]
assert events[0]["span_id"] == "s-1"
assert events[0]["data"] == {"model": "gpt-foo"}
def test_concurrent_writes_yield_valid_jsonl(run_dir: Path) -> None:
"""C7 (AUDIT_R2): per-path lock prevents JSONL corruption under contention."""
p = StrixTracingProcessor(run_dir=run_dir)
def writer(idx: int) -> None:
for i in range(50):
p._emit({"event_type": "synthetic", "writer": idx, "i": i})
threads = [threading.Thread(target=writer, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# 10 threads x 50 events = 500 lines; all valid JSON.
lines = p.events_path.read_text().splitlines()
assert len(lines) == 500
for line in lines:
json.loads(line) # raises on corrupt line
def test_emit_swallows_oserror_and_logs(
run_dir: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""C16 (AUDIT_R3): a write failure must NOT propagate."""
p = StrixTracingProcessor(run_dir=run_dir)
# Make events_path point to a directory so open(..., "a") raises.
p.events_path = run_dir
with caplog.at_level("ERROR", logger="strix.telemetry.strix_processor"):
p._emit({"event_type": "boom"})
assert any("Failed to append" in rec.message for rec in caplog.records)
def test_span_export_failure_does_not_propagate(run_dir: Path) -> None:
"""If span_data.export raises, we still emit an event with data=None."""
p = StrixTracingProcessor(run_dir=run_dir)
class _BoomSpanData:
def export(self) -> dict[str, Any]:
raise RuntimeError("nope")
# Reuse the lowercase rule: class name has no "SpanData" suffix → "boomspandata"
# would not be ideal; use a properly-named subclass.
class FunctionSpanDataBroken(_BoomSpanData):
pass
span = _FakeSpan(span_id="s-1", trace_id="t-1", span_data=FunctionSpanDataBroken())
p.on_span_end(span)
events = _read_events(p.events_path)
assert len(events) == 1
assert events[0]["data"] is None
def test_pii_scrubbed_via_sanitizer(run_dir: Path) -> None:
"""Sanitizer is invoked on every emit before write."""
seen: list[Any] = []
class _StubSanitizer:
def sanitize(self, data: Any, key_hint: str | None = None) -> Any:
seen.append(data)
# Replace any "secret" string with [REDACTED].
if isinstance(data, dict):
clean = {k: "[REDACTED]" if k == "api_key" else v for k, v in data.items()}
if "metadata" in clean and isinstance(clean["metadata"], dict):
md = dict(clean["metadata"])
md.pop("api_key", None)
clean["metadata"] = md
return clean
return data
p = StrixTracingProcessor(run_dir=run_dir, sanitizer=_StubSanitizer())
p._emit({"event_type": "test", "api_key": "sk-very-secret"})
events = _read_events(p.events_path)
assert events[0]["api_key"] == "[REDACTED]"
assert seen and seen[0]["api_key"] == "sk-very-secret"
def test_force_flush_and_shutdown_are_noops(run_dir: Path) -> None:
p = StrixTracingProcessor(run_dir=run_dir)
# Should not raise.
p.force_flush()
p.shutdown()
-428
View File
@@ -1,428 +0,0 @@
import json
import sys
import types
from pathlib import Path
from typing import Any, ClassVar
import pytest
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExportResult
from strix.telemetry import tracer as tracer_module
from strix.telemetry import utils as telemetry_utils
from strix.telemetry.tracer import Tracer, set_global_tracer
def _load_events(events_path: Path) -> list[dict[str, Any]]:
lines = events_path.read_text(encoding="utf-8").splitlines()
return [json.loads(line) for line in lines if line]
@pytest.fixture(autouse=True)
def _reset_tracer_globals(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(tracer_module, "_global_tracer", None)
monkeypatch.setattr(tracer_module, "_OTEL_BOOTSTRAPPED", False)
monkeypatch.setattr(tracer_module, "_OTEL_REMOTE_ENABLED", False)
telemetry_utils.reset_events_write_locks()
monkeypatch.delenv("STRIX_TELEMETRY", raising=False)
monkeypatch.delenv("STRIX_OTEL_TELEMETRY", raising=False)
monkeypatch.delenv("STRIX_POSTHOG_TELEMETRY", raising=False)
monkeypatch.delenv("TRACELOOP_BASE_URL", raising=False)
monkeypatch.delenv("TRACELOOP_API_KEY", raising=False)
monkeypatch.delenv("TRACELOOP_HEADERS", raising=False)
def test_tracer_local_mode_writes_jsonl_with_correlation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer("local-observability")
set_global_tracer(tracer)
tracer.set_scan_config({"targets": ["https://example.com"], "user_instructions": "focus auth"})
tracer.log_chat_message("starting scan", "user", "agent-1")
tracer.log_chat_message("scanning login form", "assistant", "agent-1")
events_path = tmp_path / "strix_runs" / "local-observability" / "events.jsonl"
assert events_path.exists()
events = _load_events(events_path)
assert any(event["event_type"] == "chat.message" for event in events)
assert any(event["event_type"] == "run.configured" for event in events)
for event in events:
assert event["run_id"] == "local-observability"
assert event["trace_id"]
assert event["span_id"]
def test_tracer_redacts_sensitive_payloads(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer("redaction-run")
set_global_tracer(tracer)
tracer.log_chat_message(
"request failed with token sk-secret-token-value",
"assistant",
"agent-1",
metadata={
"api_key": "sk-secret-token-value",
"authorization": "Bearer super-secret-token",
},
)
events_path = tmp_path / "strix_runs" / "redaction-run" / "events.jsonl"
events = _load_events(events_path)
serialized = json.dumps(events)
assert "sk-secret-token-value" not in serialized
assert "super-secret-token" not in serialized
assert "[REDACTED]" in serialized
def test_tracer_remote_mode_configures_traceloop_export(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
class FakeTraceloop:
init_calls: ClassVar[list[dict[str, Any]]] = []
@staticmethod
def init(**kwargs: Any) -> None:
FakeTraceloop.init_calls.append(kwargs)
@staticmethod
def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004
return None
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
monkeypatch.setenv("TRACELOOP_BASE_URL", "https://otel.example.com")
monkeypatch.setenv("TRACELOOP_API_KEY", "test-api-key")
monkeypatch.setenv("TRACELOOP_HEADERS", '{"x-custom":"header"}')
tracer = Tracer("remote-observability")
set_global_tracer(tracer)
tracer.log_chat_message("hello", "user", "agent-1")
assert tracer._remote_export_enabled is True
assert FakeTraceloop.init_calls
init_kwargs = FakeTraceloop.init_calls[-1]
assert init_kwargs["api_endpoint"] == "https://otel.example.com"
assert init_kwargs["api_key"] == "test-api-key"
assert init_kwargs["headers"] == {"x-custom": "header"}
assert isinstance(init_kwargs["processor"], SimpleSpanProcessor)
assert "strix.run_id" not in init_kwargs["resource_attributes"]
assert "strix.run_name" not in init_kwargs["resource_attributes"]
events_path = tmp_path / "strix_runs" / "remote-observability" / "events.jsonl"
events = _load_events(events_path)
run_started = next(event for event in events if event["event_type"] == "run.started")
assert run_started["payload"]["remote_export_enabled"] is True
def test_tracer_local_mode_avoids_traceloop_remote_endpoint(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
class FakeTraceloop:
init_calls: ClassVar[list[dict[str, Any]]] = []
@staticmethod
def init(**kwargs: Any) -> None:
FakeTraceloop.init_calls.append(kwargs)
@staticmethod
def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004
return None
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
tracer = Tracer("local-traceloop")
set_global_tracer(tracer)
tracer.log_chat_message("hello", "user", "agent-1")
assert FakeTraceloop.init_calls
init_kwargs = FakeTraceloop.init_calls[-1]
assert "api_endpoint" not in init_kwargs
assert "api_key" not in init_kwargs
assert "headers" not in init_kwargs
assert isinstance(init_kwargs["processor"], SimpleSpanProcessor)
assert tracer._remote_export_enabled is False
def test_otlp_fallback_includes_auth_and_custom_headers(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(tracer_module, "Traceloop", None)
monkeypatch.setenv("TRACELOOP_BASE_URL", "https://otel.example.com")
monkeypatch.setenv("TRACELOOP_API_KEY", "test-api-key")
monkeypatch.setenv("TRACELOOP_HEADERS", '{"x-custom":"header"}')
captured: dict[str, Any] = {}
class FakeOTLPSpanExporter:
def __init__(self, endpoint: str, headers: dict[str, str] | None = None, **kwargs: Any):
captured["endpoint"] = endpoint
captured["headers"] = headers or {}
captured["kwargs"] = kwargs
def export(self, spans: Any) -> SpanExportResult:
return SpanExportResult.SUCCESS
def shutdown(self) -> None:
return None
def force_flush(self, timeout_millis: int = 30_000) -> bool:
return True
fake_module = types.ModuleType("opentelemetry.exporter.otlp.proto.http.trace_exporter")
fake_module.OTLPSpanExporter = FakeOTLPSpanExporter
monkeypatch.setitem(
sys.modules,
"opentelemetry.exporter.otlp.proto.http.trace_exporter",
fake_module,
)
tracer = Tracer("otlp-fallback")
set_global_tracer(tracer)
assert tracer._remote_export_enabled is True
assert captured["endpoint"] == "https://otel.example.com/v1/traces"
assert captured["headers"]["Authorization"] == "Bearer test-api-key"
assert captured["headers"]["x-custom"] == "header"
def test_traceloop_init_failure_does_not_mark_bootstrapped_on_provider_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
class FakeTraceloop:
@staticmethod
def init(**kwargs: Any) -> None: # noqa: ARG004
raise RuntimeError("traceloop init failed")
@staticmethod
def set_association_properties(properties: dict[str, Any]) -> None: # noqa: ARG004
return None
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
def _raise_provider_error(provider: Any) -> None:
raise RuntimeError("provider setup failed")
monkeypatch.setattr(tracer_module.trace, "set_tracer_provider", _raise_provider_error)
tracer = Tracer("bootstrap-failure")
set_global_tracer(tracer)
assert tracer_module._OTEL_BOOTSTRAPPED is False
assert tracer._remote_export_enabled is False
def test_run_completed_event_emitted_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer("single-complete")
set_global_tracer(tracer)
tracer.save_run_data(mark_complete=True)
tracer.save_run_data(mark_complete=True)
events_path = tmp_path / "strix_runs" / "single-complete" / "events.jsonl"
events = _load_events(events_path)
run_completed = [event for event in events if event["event_type"] == "run.completed"]
assert len(run_completed) == 1
def test_events_with_agent_id_include_agent_name(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer("agent-name-enrichment")
set_global_tracer(tracer)
# _enrich_actor pulls names from the tracer's agents dict; populate it
# the same way the orchestration path will once wired (placeholder
# until live wiring lands).
tracer.agents["agent-1"] = {"name": "Root Agent"}
tracer.log_chat_message("hello", "assistant", "agent-1")
events_path = tmp_path / "strix_runs" / "agent-name-enrichment" / "events.jsonl"
events = _load_events(events_path)
chat_event = next(event for event in events if event["event_type"] == "chat.message")
assert chat_event["actor"]["agent_id"] == "agent-1"
assert chat_event["actor"]["agent_name"] == "Root Agent"
def test_get_total_llm_stats_aggregates_recordings(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer("cost-rollup")
set_global_tracer(tracer)
tracer.record_llm_usage(
input_tokens=1_000,
output_tokens=250,
cached_tokens=100,
cost=0.12345,
requests=2,
)
tracer.record_llm_usage(
input_tokens=2_000,
output_tokens=500,
cached_tokens=400,
cost=0.54321,
requests=3,
)
stats = tracer.get_total_llm_stats()
assert stats["total"] == {
"input_tokens": 3_000,
"output_tokens": 750,
"cached_tokens": 500,
"cost": 0.6667,
"requests": 5,
}
assert stats["total_tokens"] == 3_750
def test_run_metadata_is_only_on_run_lifecycle_events(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer("metadata-scope")
set_global_tracer(tracer)
tracer.log_chat_message("hello", "assistant", "agent-1")
tracer.save_run_data(mark_complete=True)
events_path = tmp_path / "strix_runs" / "metadata-scope" / "events.jsonl"
events = _load_events(events_path)
run_started = next(event for event in events if event["event_type"] == "run.started")
run_completed = next(event for event in events if event["event_type"] == "run.completed")
chat_event = next(event for event in events if event["event_type"] == "chat.message")
assert "run_metadata" in run_started
assert "run_metadata" in run_completed
assert "run_metadata" not in chat_event
def test_set_run_name_resets_cached_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer()
set_global_tracer(tracer)
old_events_path = tracer.events_file_path
tracer.set_run_name("renamed-run")
tracer.log_chat_message("hello", "assistant", "agent-1")
new_events_path = tracer.events_file_path
assert new_events_path != old_events_path
assert new_events_path == tmp_path / "strix_runs" / "renamed-run" / "events.jsonl"
events = _load_events(new_events_path)
assert any(event["event_type"] == "run.started" for event in events)
assert any(event["event_type"] == "chat.message" for event in events)
def test_set_run_name_resets_run_completed_flag(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
tracer = Tracer()
set_global_tracer(tracer)
tracer.save_run_data(mark_complete=True)
tracer.set_run_name("renamed-complete")
tracer.save_run_data(mark_complete=True)
events_path = tmp_path / "strix_runs" / "renamed-complete" / "events.jsonl"
events = _load_events(events_path)
run_completed = [event for event in events if event["event_type"] == "run.completed"]
assert any(event["event_type"] == "run.started" for event in events)
assert len(run_completed) == 1
def test_set_run_name_updates_traceloop_association_properties(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
class FakeTraceloop:
associations: ClassVar[list[dict[str, Any]]] = []
@staticmethod
def init(**kwargs: Any) -> None: # noqa: ARG004
return None
@staticmethod
def set_association_properties(properties: dict[str, Any]) -> None:
FakeTraceloop.associations.append(properties)
monkeypatch.setattr(tracer_module, "Traceloop", FakeTraceloop)
tracer = Tracer()
set_global_tracer(tracer)
tracer.set_run_name("renamed-run")
assert FakeTraceloop.associations
assert FakeTraceloop.associations[-1]["run_id"] == "renamed-run"
assert FakeTraceloop.associations[-1]["run_name"] == "renamed-run"
def test_events_write_locks_are_scoped_by_events_file(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("STRIX_TELEMETRY", "0")
tracer_one = Tracer("lock-run-a")
tracer_two = Tracer("lock-run-b")
lock_a_from_one = tracer_one._get_events_write_lock(tracer_one.events_file_path)
lock_a_from_two = tracer_two._get_events_write_lock(tracer_one.events_file_path)
lock_b = tracer_two._get_events_write_lock(tracer_two.events_file_path)
assert lock_a_from_one is lock_a_from_two
assert lock_a_from_one is not lock_b
def test_tracer_skips_jsonl_when_telemetry_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("STRIX_TELEMETRY", "0")
tracer = Tracer("telemetry-disabled")
set_global_tracer(tracer)
tracer.log_chat_message("hello", "assistant", "agent-1")
tracer.save_run_data(mark_complete=True)
events_path = tmp_path / "strix_runs" / "telemetry-disabled" / "events.jsonl"
assert not events_path.exists()
def test_tracer_otel_flag_overrides_global_telemetry(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("STRIX_TELEMETRY", "0")
monkeypatch.setenv("STRIX_OTEL_TELEMETRY", "1")
tracer = Tracer("otel-enabled")
set_global_tracer(tracer)
tracer.log_chat_message("hello", "assistant", "agent-1")
tracer.save_run_data(mark_complete=True)
events_path = tmp_path / "strix_runs" / "otel-enabled" / "events.jsonl"
assert events_path.exists()
-39
View File
@@ -1,39 +0,0 @@
from strix.telemetry.utils import prune_otel_span_attributes
def test_prune_otel_span_attributes_drops_high_volume_prompt_content() -> None:
attributes = {
"gen_ai.operation.name": "openai.chat",
"gen_ai.request.model": "gpt-5.2",
"gen_ai.prompt.0.role": "system",
"gen_ai.prompt.0.content": "a" * 20_000,
"gen_ai.completion.0.content": "b" * 10_000,
"llm.input_messages.0.content": "c" * 5_000,
"llm.output_messages.0.content": "d" * 5_000,
"llm.input": "x" * 3_000,
"llm.output": "y" * 3_000,
}
pruned = prune_otel_span_attributes(attributes)
assert "gen_ai.prompt.0.content" not in pruned
assert "gen_ai.completion.0.content" not in pruned
assert "llm.input_messages.0.content" not in pruned
assert "llm.output_messages.0.content" not in pruned
assert "llm.input" not in pruned
assert "llm.output" not in pruned
assert pruned["gen_ai.operation.name"] == "openai.chat"
assert pruned["gen_ai.prompt.0.role"] == "system"
assert pruned["strix.filtered_attributes_count"] == 6
def test_prune_otel_span_attributes_keeps_metadata_when_nothing_is_dropped() -> None:
attributes = {
"gen_ai.operation.name": "openai.chat",
"gen_ai.request.model": "gpt-5.2",
"gen_ai.prompt.0.role": "user",
}
pruned = prune_otel_span_attributes(attributes)
assert pruned == attributes
-319
View File
@@ -1,319 +0,0 @@
"""Phase 5 tests for the top-level SDK scan entry point.
We never spin up a real Docker container or hit a real LLM here. The
tests patch ``session_manager.create_or_reuse``, ``Runner.run``, and
the agent factory so we can verify the wiring shape:
- The bus is registered with a root agent before Runner.run.
- The context dict carries every field downstream code (tools, hooks,
filter) reads.
- The session manager's bundle flows through to the context (host
ports, bearer, sandbox session/client).
- ``cleanup_on_exit=True`` always cleans up, even when Runner.run
raises.
- ``cleanup_on_exit=False`` preserves the cached session.
- Cancellation propagates: if Runner.run raises, descendants are
cancelled before re-raising.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from strix.entry import _build_root_task, _build_scope_context, run_strix_scan
from strix.orchestration.bus import AgentMessageBus
# --- helpers ------------------------------------------------------------
def _bundle_for_test() -> dict[str, Any]:
return {
"client": MagicMock(name="docker_client"),
"session": MagicMock(name="sandbox_session"),
"capability": MagicMock(),
"tool_server_host_port": 12001,
"caido_host_port": 12002,
"bearer": "test-bearer-token-1234567890",
}
def _scan_config(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"targets": [
{
"type": "web_application",
"details": {"target_url": "https://example.com"},
},
],
"user_instructions": "find xss",
"scan_mode": "deep",
"is_whitebox": False,
}
base.update(overrides)
return base
# --- task / scope builders ---------------------------------------------
def test_build_root_task_groups_targets_and_appends_instructions() -> None:
config = _scan_config(
targets=[
{
"type": "repository",
"details": {
"target_repo": "https://github.com/x/y",
"cloned_repo_path": "/tmp/y",
"workspace_subdir": "y",
},
},
{
"type": "ip_address",
"details": {"target_ip": "10.0.0.1"},
},
],
user_instructions="report only critical issues",
)
task = _build_root_task(config)
assert "Repositories:" in task
assert "https://github.com/x/y (available at: /workspace/y)" in task
assert "IP Addresses:" in task
assert "10.0.0.1" in task
assert "Special instructions: report only critical issues" in task
def test_build_root_task_renders_diff_scope_block() -> None:
config = _scan_config(
diff_scope={
"active": True,
"repos": [
{
"workspace_subdir": "service-x",
"analyzable_files_count": 7,
"deleted_files_count": 2,
},
],
},
)
task = _build_root_task(config)
assert "Scope Constraints:" in task
assert "service-x: 7 changed file(s)" in task
assert "service-x: 2 deleted file(s)" in task
def test_build_scope_context_marks_authorization_source() -> None:
config = _scan_config(
targets=[
{
"type": "web_application",
"details": {"target_url": "https://target.test"},
},
],
)
ctx = _build_scope_context(config)
assert ctx["scope_source"] == "system_scan_config"
assert ctx["authorization_source"] == "strix_platform_verified_targets"
assert ctx["user_instructions_do_not_expand_scope"] is True
assert ctx["authorized_targets"] == [
{"type": "web_application", "value": "https://target.test", "workspace_path": ""},
]
# --- run_strix_scan wiring ---------------------------------------------
@pytest.mark.asyncio
async def test_run_strix_scan_wires_context_and_calls_runner(tmp_path: Path) -> None:
"""End-to-end (mocked) — assert every downstream consumer of context
sees the bundle's bearer + host ports."""
bundle = _bundle_for_test()
captured_context: dict[str, Any] = {}
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
captured_context.update(kwargs.get("context", {}))
return MagicMock(name="run_result")
with (
patch(
"strix.entry.session_manager.create_or_reuse",
new=AsyncMock(return_value=bundle),
) as create_mock,
patch(
"strix.entry.session_manager.cleanup",
new=AsyncMock(),
) as cleanup_mock,
patch("strix.entry.Runner.run", side_effect=fake_runner_run) as runner_mock,
# Stub the factory to avoid rendering the 158k-char prompt for
# every test (it's covered by sdk_prompt tests).
patch(
"strix.entry.build_strix_agent",
return_value=MagicMock(name="root_agent"),
) as factory_mock,
):
await run_strix_scan(
scan_config=_scan_config(),
scan_id="scan-test",
image="strix-sandbox:test",
sources_path=tmp_path,
)
# Session manager calls.
create_mock.assert_awaited_once()
create_args = create_mock.await_args
assert create_args is not None
assert create_args.args == ("scan-test",)
assert create_args.kwargs["image"] == "strix-sandbox:test"
assert create_args.kwargs["sources_path"] == tmp_path
cleanup_mock.assert_awaited_once_with("scan-test")
# Factory called with is_root=True.
factory_mock.assert_called_once()
assert factory_mock.call_args.kwargs["is_root"] is True
# Runner.run called once with the root agent.
assert runner_mock.call_count == 1
# Context shape passed into Runner.run.
assert captured_context["sandbox_session"] is bundle["session"]
assert captured_context["sandbox_client"] is bundle["client"]
assert captured_context["sandbox_token"] == bundle["bearer"]
assert captured_context["tool_server_host_port"] == bundle["tool_server_host_port"]
assert captured_context["caido_host_port"] == bundle["caido_host_port"]
# Bus is registered and root agent_id is populated.
bus = captured_context["bus"]
assert isinstance(bus, AgentMessageBus)
assert captured_context["agent_id"] in bus.statuses
assert bus.parent_of[captured_context["agent_id"]] is None
# Child factory wired through so create_agent works.
assert callable(captured_context["agent_factory"])
@pytest.mark.asyncio
async def test_run_strix_scan_cleans_up_on_runner_failure(tmp_path: Path) -> None:
"""If Runner.run raises, cleanup must still fire (the finally branch)."""
bundle = _bundle_for_test()
with (
patch(
"strix.entry.session_manager.create_or_reuse",
new=AsyncMock(return_value=bundle),
),
patch(
"strix.entry.session_manager.cleanup",
new=AsyncMock(),
) as cleanup_mock,
patch(
"strix.entry.Runner.run",
side_effect=RuntimeError("simulated LLM blow-up"),
),
patch("strix.entry.build_strix_agent", return_value=MagicMock()),
pytest.raises(RuntimeError, match="simulated LLM"),
):
await run_strix_scan(
scan_config=_scan_config(),
scan_id="scan-fail",
image="i",
sources_path=tmp_path,
)
cleanup_mock.assert_awaited_once_with("scan-fail")
@pytest.mark.asyncio
async def test_run_strix_scan_skips_cleanup_when_disabled(tmp_path: Path) -> None:
bundle = _bundle_for_test()
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
return MagicMock()
with (
patch(
"strix.entry.session_manager.create_or_reuse",
new=AsyncMock(return_value=bundle),
),
patch(
"strix.entry.session_manager.cleanup",
new=AsyncMock(),
) as cleanup_mock,
patch("strix.entry.Runner.run", side_effect=fake_runner_run),
patch("strix.entry.build_strix_agent", return_value=MagicMock()),
):
await run_strix_scan(
scan_config=_scan_config(),
scan_id="scan-keep",
image="i",
sources_path=tmp_path,
cleanup_on_exit=False,
)
cleanup_mock.assert_not_called()
@pytest.mark.asyncio
async def test_run_strix_scan_auto_generates_scan_id(tmp_path: Path) -> None:
"""A caller without a stable id should still get a valid scan_id
flowing into create_or_reuse."""
bundle = _bundle_for_test()
captured_scan_id: list[str] = []
async def fake_create(scan_id: str, **_kwargs: Any) -> Any:
captured_scan_id.append(scan_id)
return bundle
with (
patch(
"strix.entry.session_manager.create_or_reuse",
new=AsyncMock(side_effect=fake_create),
),
patch("strix.entry.session_manager.cleanup", new=AsyncMock()),
patch("strix.entry.Runner.run", new=AsyncMock(return_value=MagicMock())),
patch("strix.entry.build_strix_agent", return_value=MagicMock()),
):
await run_strix_scan(
scan_config=_scan_config(),
image="i",
sources_path=tmp_path,
)
assert len(captured_scan_id) == 1
assert captured_scan_id[0].startswith("scan-")
assert len(captured_scan_id[0]) > len("scan-")
@pytest.mark.asyncio
async def test_run_strix_scan_passes_scan_level_config_into_factory(
tmp_path: Path,
) -> None:
"""scan_mode / is_whitebox flow from scan_config into both the
root factory call and the child factory closure."""
bundle = _bundle_for_test()
factory_calls: list[dict[str, Any]] = []
def fake_factory(**kwargs: Any) -> Any:
factory_calls.append(kwargs)
return MagicMock()
with (
patch(
"strix.entry.session_manager.create_or_reuse",
new=AsyncMock(return_value=bundle),
),
patch("strix.entry.session_manager.cleanup", new=AsyncMock()),
patch("strix.entry.Runner.run", new=AsyncMock(return_value=MagicMock())),
patch("strix.entry.build_strix_agent", side_effect=fake_factory),
):
await run_strix_scan(
scan_config=_scan_config(scan_mode="fast", is_whitebox=True),
scan_id="s",
image="i",
sources_path=tmp_path,
)
assert factory_calls[0]["scan_mode"] == "fast"
assert factory_calls[0]["is_whitebox"] is True
assert factory_calls[0]["is_root"] is True
-173
View File
@@ -1,173 +0,0 @@
"""Smoke tests for make_run_config / make_agent_context."""
from __future__ import annotations
from agents import RunConfig
from agents.model_settings import ModelSettings
from agents.retry import ModelRetryBackoffSettings
from strix.orchestration.bus import AgentMessageBus
from strix.run_config_factory import make_agent_context, make_run_config
def test_make_run_config_returns_run_config() -> None:
cfg = make_run_config(sandbox_session=None)
assert isinstance(cfg, RunConfig)
def test_default_parallel_tool_calls_is_false() -> None:
"""Default is sequential — the tool server serializes one task per agent."""
cfg = make_run_config(sandbox_session=None)
assert cfg.model_settings is not None
assert cfg.model_settings.parallel_tool_calls is False
def test_default_tool_choice_is_required() -> None:
cfg = make_run_config(sandbox_session=None)
assert cfg.model_settings is not None
assert cfg.model_settings.tool_choice == "required"
def test_call_model_input_filter_is_wired() -> None:
cfg = make_run_config(sandbox_session=None)
assert cfg.call_model_input_filter is not None
# Wired to inject_messages_filter (validated by name to keep import light).
assert cfg.call_model_input_filter.__name__ == "inject_messages_filter"
def test_retry_settings_have_max_retries_5() -> None:
cfg = make_run_config(sandbox_session=None)
assert cfg.model_settings is not None
retry = cfg.model_settings.retry
assert retry is not None
assert retry.max_retries == 5
def test_retry_backoff_uses_strix_defaults() -> None:
"""min(90, 2*2^n) with initial 2s, max 90s, x2."""
cfg = make_run_config(sandbox_session=None)
assert cfg.model_settings is not None
retry = cfg.model_settings.retry
assert retry is not None
backoff = retry.backoff
assert isinstance(backoff, ModelRetryBackoffSettings)
assert backoff.initial_delay == 2.0
assert backoff.max_delay == 90.0
assert backoff.multiplier == 2.0
def test_retry_policy_is_set() -> None:
"""Retry policy is wired (auth/validation 4xx excluded by construction)."""
cfg = make_run_config(sandbox_session=None)
assert cfg.model_settings is not None
retry = cfg.model_settings.retry
assert retry is not None
assert retry.policy is not None
def test_trace_include_sensitive_data_is_false() -> None:
cfg = make_run_config(sandbox_session=None)
assert cfg.trace_include_sensitive_data is False
def test_model_settings_override_merges() -> None:
"""Per-call override path."""
override = ModelSettings(tool_choice="auto", parallel_tool_calls=True)
cfg = make_run_config(sandbox_session=None, model_settings_override=override)
assert cfg.model_settings is not None
assert cfg.model_settings.tool_choice == "auto"
assert cfg.model_settings.parallel_tool_calls is True
# Retry settings (not in override) preserved from base.
assert cfg.model_settings.retry is not None
assert cfg.model_settings.retry.max_retries == 5
def test_reasoning_effort_propagates() -> None:
cfg = make_run_config(sandbox_session=None, reasoning_effort="high")
assert cfg.model_settings is not None
assert cfg.model_settings.reasoning is not None
assert cfg.model_settings.reasoning.effort == "high"
def test_max_turns_default_is_300() -> None:
"""Default max_turns=300 in make_agent_context.
``max_turns`` itself is passed to ``Runner.run``; the context copy is
consumed by the budget-warning hook.
"""
bus = AgentMessageBus()
ctx = make_agent_context(
bus=bus,
sandbox_session=None,
sandbox_token=None,
tool_server_host_port=None,
caido_host_port=None,
agent_id="root",
parent_id=None,
tracer=None,
)
assert ctx["max_turns"] == 300
def test_make_agent_context_full_shape() -> None:
"""The context dict carries every field tools/hooks reach for."""
bus = AgentMessageBus()
ctx = make_agent_context(
bus=bus,
sandbox_session=None,
sandbox_token="bearer-xyz",
tool_server_host_port=48081,
caido_host_port=48080,
agent_id="agent-1",
parent_id=None,
tracer="not-a-real-tracer",
is_whitebox=True,
diff_scope={"changed_files": ["src/app.py"]},
run_id="strix_runs/abc_def",
)
assert ctx["bus"] is bus
assert ctx["agent_id"] == "agent-1"
assert ctx["parent_id"] is None
assert ctx["agent_finish_called"] is False
assert ctx["turn_count"] == 0
assert ctx["is_whitebox"] is True
assert ctx["diff_scope"] == {"changed_files": ["src/app.py"]}
assert ctx["run_id"] == "strix_runs/abc_def"
assert ctx["sandbox_token"] == "bearer-xyz"
assert ctx["tool_server_host_port"] == 48081
assert ctx["caido_host_port"] == 48080
def test_make_agent_context_is_whitebox_defaults_false() -> None:
bus = AgentMessageBus()
ctx = make_agent_context(
bus=bus,
sandbox_session=None,
sandbox_token=None,
tool_server_host_port=None,
caido_host_port=None,
agent_id="r",
parent_id=None,
tracer=None,
)
assert ctx["is_whitebox"] is False
assert ctx["diff_scope"] is None
def test_sandbox_config_omitted_when_no_session() -> None:
cfg = make_run_config(sandbox_session=None)
assert cfg.sandbox is None
def test_model_default_is_strix_claude() -> None:
cfg = make_run_config(sandbox_session=None)
assert cfg.model == "anthropic/claude-sonnet-4-6"
def test_multi_provider_is_built() -> None:
"""Verify the factory wires our custom MultiProvider, not the SDK default."""
cfg = make_run_config(sandbox_session=None)
# MultiProvider is opaque, but our build_multi_provider returns
# an instance with our prefix routes installed.
assert cfg.model_provider is not None
-1
View File
@@ -1 +0,0 @@
"""Tests for strix.tools module."""
-34
View File
@@ -1,34 +0,0 @@
"""Fixtures for strix.tools tests."""
from collections.abc import Callable
from typing import Any
import pytest
@pytest.fixture
def sample_function_with_types() -> Callable[..., None]:
"""Create a sample function with type annotations for testing argument conversion."""
def func(
name: str,
count: int,
enabled: bool,
ratio: float,
items: list[Any],
config: dict[str, Any],
optional: str | None = None,
) -> None:
pass
return func
@pytest.fixture
def sample_function_no_annotations() -> Callable[..., None]:
"""Create a sample function without type annotations."""
def func(arg1, arg2, arg3): # type: ignore[no-untyped-def]
pass
return func
-73
View File
@@ -1,73 +0,0 @@
"""Phase 0 smoke tests for the strix_tool decorator factory.
The SDK's ``FunctionTool`` only honors ``timeout_seconds`` for ``async``
handlers (verified at ``agents.tool._validate_function_tool_timeout_config``):
sync function bodies cannot be cleanly cancelled, so the SDK refuses to
attach a timeout to them. Every Strix tool is therefore an ``async def``;
sync libraries (libtmux, IPython) get wrapped in ``asyncio.to_thread``
inside the async tool body.
"""
from __future__ import annotations
import pytest
from agents.tool import FunctionTool
from strix.tools._decorator import strix_tool
def test_strix_tool_returns_function_tool() -> None:
@strix_tool()
async def my_tool(x: int) -> str:
"""Do a thing."""
return str(x)
assert isinstance(my_tool, FunctionTool)
def test_strix_tool_default_timeout_is_120s() -> None:
@strix_tool()
async def my_tool(x: int) -> str:
"""Do a thing."""
return str(x)
assert my_tool.timeout_seconds == 120.0
def test_strix_tool_default_timeout_behavior_is_error_as_result() -> None:
@strix_tool()
async def my_tool(x: int) -> str:
"""Do a thing."""
return str(x)
assert my_tool.timeout_behavior == "error_as_result"
def test_strix_tool_timeout_override() -> None:
@strix_tool(timeout=300)
async def my_tool(x: int) -> str:
"""Do a thing."""
return str(x)
assert my_tool.timeout_seconds == 300.0
def test_strix_tool_critical_tool_can_raise() -> None:
"""C20 (AUDIT_R3): critical tools opt into raise_exception."""
@strix_tool(timeout=30, timeout_behavior="raise_exception")
async def critical_tool(x: int) -> str:
"""Do a critical thing."""
return str(x)
assert critical_tool.timeout_behavior == "raise_exception"
def test_strix_tool_sync_handlers_rejected_by_sdk() -> None:
"""SDK explicitly rejects timeout on sync handlers; documenting the constraint."""
with pytest.raises(ValueError, match="async @function_tool"):
@strix_tool()
def my_sync_tool(x: int) -> str:
"""Sync tools can't have a timeout in the SDK."""
return str(x)
-514
View File
@@ -1,514 +0,0 @@
"""Phase 3 tests for the multi-agent graph SDK tools.
Six tools: view_agent_graph, agent_status, send_message_to_agent,
wait_for_message, create_agent, agent_finish.
Strategy:
- Build a real ``AgentMessageBus`` in each test and put it under
``ctx.context['bus']`` so the tools exercise the same code path
production runs do.
- ``create_agent`` is the only tool that touches ``Runner.run`` and
``asyncio.create_task``. Its test injects a stub agent factory and
patches the SDK's ``Runner.run`` to a sentinel coroutine — we verify
the spawn shape (bus.register called, task handle stored, identity
block in initial input) without spinning up a real LLM.
- ``wait_for_message`` is exercised on both branches: a message arrives
mid-poll, and the timeout path.
"""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from agents.tool import FunctionTool
from strix.orchestration.bus import AgentMessageBus
from strix.tools.agents_graph.tools import (
agent_finish,
agent_status,
create_agent,
send_message_to_agent,
view_agent_graph,
wait_for_message,
)
@dataclass
class _Ctx:
context: dict[str, Any] = field(default_factory=dict)
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
from agents.tool_context import ToolContext
tool_ctx = ToolContext(
context=ctx.context,
usage=None,
tool_name=tool.name,
tool_call_id="test-call-id",
tool_arguments=json.dumps(kwargs),
)
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
assert isinstance(result, str)
decoded = json.loads(result)
assert isinstance(decoded, dict)
return decoded
async def _make_bus_with_agents() -> AgentMessageBus:
"""Bus prepopulated with a root + two registered children."""
bus = AgentMessageBus()
await bus.register("root-1", "root", parent_id=None)
await bus.register("child-A", "scanner", parent_id="root-1")
await bus.register("child-B", "exploiter", parent_id="root-1")
return bus
def _ctx_for(bus: AgentMessageBus, agent_id: str = "root-1") -> _Ctx:
return _Ctx(context={"bus": bus, "agent_id": agent_id, "parent_id": None})
# --- registration ---------------------------------------------------------
def test_all_graph_tools_are_function_tools() -> None:
for tool in (
view_agent_graph,
agent_status,
send_message_to_agent,
wait_for_message,
create_agent,
agent_finish,
):
assert isinstance(tool, FunctionTool)
# --- view_agent_graph -----------------------------------------------------
@pytest.mark.asyncio
async def test_view_agent_graph_renders_tree() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(view_agent_graph, _ctx_for(bus))
assert out["success"] is True
assert "root (root-1)" in out["graph_structure"]
assert "scanner (child-A)" in out["graph_structure"]
assert "exploiter (child-B)" in out["graph_structure"]
# The "you" marker should be on the calling agent's line.
you_lines = [line for line in out["graph_structure"].splitlines() if "← you" in line]
assert len(you_lines) == 1
assert "root-1" in you_lines[0]
assert out["summary"]["total"] == 3
assert out["summary"]["running"] == 3
@pytest.mark.asyncio
async def test_view_agent_graph_handles_missing_bus() -> None:
out = await _invoke(view_agent_graph, _Ctx(context={}))
assert out["success"] is False
assert "Bus" in out["error"]
# --- agent_status ---------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_status_returns_state() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(agent_status, _ctx_for(bus), agent_id="child-A")
assert out["success"] is True
assert out["agent_id"] == "child-A"
assert out["name"] == "scanner"
assert out["status"] == "running"
assert out["parent_id"] == "root-1"
assert out["pending_messages"] == 0
@pytest.mark.asyncio
async def test_agent_status_unknown_id() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(agent_status, _ctx_for(bus), agent_id="nope")
assert out["success"] is False
assert "Unknown" in out["error"]
# --- send_message_to_agent -----------------------------------------------
@pytest.mark.asyncio
async def test_send_message_queues_into_target_inbox() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(
send_message_to_agent,
_ctx_for(bus, agent_id="child-A"),
target_agent_id="child-B",
message="hello sibling",
priority="high",
)
assert out["success"] is True
assert out["delivery_status"] == "queued"
# Drain via the same API the filter uses; confirm the message landed.
msgs = await bus.drain("child-B")
assert len(msgs) == 1
assert msgs[0]["from"] == "child-A"
assert msgs[0]["content"] == "hello sibling"
assert msgs[0]["priority"] == "high"
@pytest.mark.asyncio
async def test_send_message_unknown_target() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(
send_message_to_agent,
_ctx_for(bus),
target_agent_id="ghost",
message="hi",
)
assert out["success"] is False
assert "not found" in out["error"]
@pytest.mark.asyncio
async def test_send_message_to_finalized_agent_is_rejected() -> None:
"""A finalized target should not silently swallow messages."""
bus = await _make_bus_with_agents()
await bus.finalize("child-A", "completed")
out = await _invoke(
send_message_to_agent,
_ctx_for(bus),
target_agent_id="child-A",
message="too late",
)
assert out["success"] is False
# finalize() also clears parent_of/names, so the user-visible state is
# "completed" — confirm the wrapper treats finalized agents as
# undeliverable rather than queuing into a dropped inbox.
assert "completed" in out["error"] or "not found" in out["error"]
# --- wait_for_message ----------------------------------------------------
@pytest.mark.asyncio
async def test_wait_for_message_returns_when_message_arrives() -> None:
bus = await _make_bus_with_agents()
async def deliver_after_short_pause() -> None:
await asyncio.sleep(0.1)
await bus.send("child-A", {"from": "child-B", "content": "ping", "type": "info"})
sender = asyncio.create_task(deliver_after_short_pause())
out = await _invoke(
wait_for_message,
_ctx_for(bus, agent_id="child-A"),
timeout_seconds=3,
)
await sender
assert out["success"] is True
assert out["status"] == "message_arrived"
assert out["pending_messages"] >= 1
# Status must be returned to "running" after the wait completes.
assert bus.statuses["child-A"] == "running"
@pytest.mark.asyncio
async def test_wait_for_message_times_out() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(
wait_for_message,
_ctx_for(bus, agent_id="child-A"),
timeout_seconds=1,
)
assert out["success"] is True
assert out["status"] == "timeout"
assert out["timeout_seconds"] == 1
assert bus.statuses["child-A"] == "running"
# --- create_agent --------------------------------------------------------
@pytest.mark.asyncio
async def test_create_agent_requires_factory_in_context() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(
create_agent,
_ctx_for(bus),
name="recon-bot",
task="enumerate hosts",
)
assert out["success"] is False
assert "agent_factory" in out["error"]
@pytest.mark.asyncio
async def test_create_agent_spawns_and_registers_child() -> None:
"""Verify the spawn shape without running a real LLM."""
bus = await _make_bus_with_agents()
factory_calls: list[dict[str, Any]] = []
def fake_factory(*, name: str, skills: list[str]) -> Any:
factory_calls.append({"name": name, "skills": list(skills)})
# The Runner.run patch below ignores this object; any sentinel
# works.
return object()
runner_calls: list[dict[str, Any]] = []
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
# Capture the input + max_turns so the test can assert the
# identity block + delegation envelope are present.
runner_calls.append({"args": args, "kwargs": kwargs})
await asyncio.sleep(0) # cooperate so create_task can return
return None
ctx = _Ctx(
context={
"bus": bus,
"agent_id": "root-1",
"parent_id": None,
"agent_factory": fake_factory,
"sandbox_session": None,
"sandbox_client": None,
"sandbox_token": "token",
"tool_server_host_port": 12345,
"caido_host_port": None,
"tracer": None,
"model": "anthropic/claude-sonnet-4-6",
"model_settings": None,
"max_turns": 300,
"is_whitebox": False,
}
)
with patch(
"strix.tools.agents_graph.tools.Runner.run",
side_effect=fake_runner_run,
):
out = await _invoke(
create_agent,
ctx,
name="recon-bot",
task="enumerate hosts",
inherit_context=False,
skills=["recon"],
)
# The spawned task must be allowed to run so Runner.run side-effect
# records the call.
new_id = out["agent_id"]
await asyncio.gather(*(t for t in bus.tasks.values()), return_exceptions=True)
assert out["success"] is True
assert factory_calls == [{"name": "recon-bot", "skills": ["recon"]}]
assert len(runner_calls) == 1
# Bus state: child registered, task stored.
assert new_id in bus.statuses
assert bus.parent_of[new_id] == "root-1"
assert bus.names[new_id] == "recon-bot"
assert new_id in bus.tasks
# Initial input shape: identity preamble + task message at the end.
initial_input = runner_calls[0]["kwargs"]["input"]
assert any(
isinstance(item, dict) and "You are agent recon-bot" in item.get("content", "")
for item in initial_input
)
assert initial_input[-1]["content"] == "enumerate hosts"
@pytest.mark.asyncio
async def test_create_agent_inherits_parent_history() -> None:
bus = await _make_bus_with_agents()
def fake_factory(*, name: str, skills: list[str]) -> Any:
return object()
runner_calls: list[dict[str, Any]] = []
async def fake_runner_run(*args: Any, **kwargs: Any) -> Any:
runner_calls.append(kwargs)
return None
parent_history = [
{"role": "user", "content": "scope: example.com"},
{"role": "assistant", "content": "I'll start with subdomain enum."},
]
ctx = _Ctx(
context={
"bus": bus,
"agent_id": "root-1",
"parent_id": None,
"agent_factory": fake_factory,
"parent_input_items": parent_history,
"sandbox_session": None,
"sandbox_client": None,
"sandbox_token": "token",
"tool_server_host_port": 12345,
"caido_host_port": None,
"tracer": None,
"model": "anthropic/claude-sonnet-4-6",
"model_settings": None,
"max_turns": 300,
}
)
with patch(
"strix.tools.agents_graph.tools.Runner.run",
side_effect=fake_runner_run,
):
await _invoke(
create_agent,
ctx,
name="child",
task="do thing",
inherit_context=True,
)
await asyncio.gather(*(t for t in bus.tasks.values()), return_exceptions=True)
initial_input = runner_calls[0]["input"]
contents = [item.get("content", "") for item in initial_input]
assert any("Inherited context from parent" in c for c in contents)
assert any("End of inherited context" in c for c in contents)
# Parent's exact items should be in between.
assert any(c == "scope: example.com" for c in contents)
# --- agent_finish --------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_finish_rejects_root() -> None:
bus = await _make_bus_with_agents()
out = await _invoke(
agent_finish,
_ctx_for(bus, agent_id="root-1"),
result_summary="done",
)
assert out["success"] is False
assert "agent_finish is for subagents" in out["error"]
@pytest.mark.asyncio
async def test_agent_finish_posts_report_to_parent_inbox() -> None:
bus = await _make_bus_with_agents()
ctx = _Ctx(
context={
"bus": bus,
"agent_id": "child-A",
"parent_id": "root-1",
"agent_finish_called": False,
}
)
out = await _invoke(
agent_finish,
ctx,
result_summary="found 3 issues",
findings=["xss in /search", "open redirect", "stored xss"],
final_recommendations=["sanitize search input"],
success=True,
)
assert out["success"] is True
assert out["agent_completed"] is True
assert out["parent_notified"] is True
# Side effects: agent_finish_called flipped (so on_agent_end records
# "completed", not "crashed"), and the parent's inbox got the report.
assert ctx.context["agent_finish_called"] is True
parent_msgs = await bus.drain("root-1")
assert len(parent_msgs) == 1
msg = parent_msgs[0]
assert msg["type"] == "completion"
assert msg["from"] == "child-A"
payload = json.loads(msg["content"])
assert payload["kind"] == "agent_completion_report"
assert payload["summary"] == "found 3 issues"
assert "xss in /search" in payload["findings"]
assert "sanitize search input" in payload["recommendations"]
@pytest.mark.asyncio
async def test_agent_finish_skips_parent_when_report_to_parent_false() -> None:
bus = await _make_bus_with_agents()
ctx = _Ctx(
context={
"bus": bus,
"agent_id": "child-A",
"parent_id": "root-1",
"agent_finish_called": False,
}
)
out = await _invoke(
agent_finish,
ctx,
result_summary="silent done",
report_to_parent=False,
)
assert out["success"] is True
assert out["parent_notified"] is False
assert ctx.context["agent_finish_called"] is True
parent_msgs = await bus.drain("root-1")
assert parent_msgs == []
# --- bus integration sanity ---------------------------------------------
@pytest.mark.asyncio
async def test_create_agent_spawn_is_cancelable_via_bus() -> None:
"""Verify bus.cancel_descendants reaches a child task we just spawned."""
bus = await _make_bus_with_agents()
def fake_factory(*, name: str, skills: list[str]) -> Any:
return object()
# Long-lived child that yields control so we can cancel it before it
# finishes naturally.
async def slow_runner_run(*args: Any, **kwargs: Any) -> Any:
await asyncio.sleep(60)
return None
ctx = _Ctx(
context={
"bus": bus,
"agent_id": "root-1",
"parent_id": None,
"agent_factory": fake_factory,
"sandbox_session": None,
"sandbox_client": None,
"sandbox_token": "t",
"tool_server_host_port": 12345,
"caido_host_port": None,
"tracer": None,
"model": "anthropic/claude-sonnet-4-6",
"model_settings": None,
"max_turns": 300,
}
)
runner_mock = AsyncMock(side_effect=slow_runner_run)
with patch(
"strix.tools.agents_graph.tools.Runner.run",
new=runner_mock,
):
out = await _invoke(
create_agent,
ctx,
name="long-running",
task="do thing",
inherit_context=False,
)
child_id = out["agent_id"]
# Let the task actually start.
await asyncio.sleep(0.05)
await bus.cancel_descendants("root-1")
# The cancel should have propagated; the task is done (cancelled).
assert bus.tasks[child_id].done()
-250
View File
@@ -1,250 +0,0 @@
"""Phase 2.3 smoke tests for the simplest SDK-wrapped local tools.
Validates the wrapping pattern (legacy implementation in, JSON string out)
on three tool families: think (trivial), todo (in-memory + agent_state
adapter), notes (in-memory + JSONL persistence).
If this slice works end-to-end the same pattern carries the rest of the
local tools (reporting, web_search, file_edit, finish_scan, load_skill).
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from agents.tool import FunctionTool
from strix.tools.notes import tools as _notes_impl
from strix.tools.notes.tools import (
create_note,
delete_note,
get_note,
list_notes,
update_note,
)
from strix.tools.thinking.tool import think
from strix.tools.todo.tools import (
create_todo,
delete_todo,
list_todos,
mark_todo_done,
mark_todo_pending,
update_todo,
)
@dataclass
class _Ctx:
"""Stand-in for ``RunContextWrapper``."""
context: dict[str, Any] = field(default_factory=dict)
def _ctx_for(agent_id: str = "test-agent") -> _Ctx:
return _Ctx(context={"agent_id": agent_id})
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
"""Invoke a function tool the way the SDK would and JSON-decode the result."""
from agents.tool_context import ToolContext
tool_ctx = ToolContext(
context=ctx.context,
usage=None,
tool_name=tool.name,
tool_call_id="test-call-id",
tool_arguments=json.dumps(kwargs),
)
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
assert isinstance(result, str)
decoded = json.loads(result)
assert isinstance(decoded, dict)
return decoded
# --- think ----------------------------------------------------------------
def test_think_is_a_function_tool() -> None:
assert isinstance(think, FunctionTool)
assert think.name == "think"
@pytest.mark.asyncio
async def test_think_records_thought() -> None:
ctx = _ctx_for()
thought = "planning my next move"
out = await _invoke(think, ctx, thought=thought)
assert out["success"] is True
assert f"{len(thought)} characters" in out["message"]
@pytest.mark.asyncio
async def test_think_rejects_empty() -> None:
ctx = _ctx_for()
out = await _invoke(think, ctx, thought=" ")
assert out["success"] is False
# --- todo -----------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_todo_storage() -> None:
"""Each test starts with an empty todo store so tests don't bleed."""
from strix.tools.todo import tools as todo_module
todo_module._todos_storage.clear()
def test_todo_tools_are_function_tools() -> None:
for tool in (
create_todo,
list_todos,
update_todo,
mark_todo_done,
mark_todo_pending,
delete_todo,
):
assert isinstance(tool, FunctionTool)
@pytest.mark.asyncio
async def test_todo_lifecycle() -> None:
ctx = _ctx_for("agent-A")
# Create
created = await _invoke(create_todo, ctx, title="audit endpoint", priority="high")
assert created["success"] is True
assert created["count"] == 1
todo_id = created["created"][0]["todo_id"]
# List
listed = await _invoke(list_todos, ctx)
assert listed["success"] is True
assert any(t["todo_id"] == todo_id for t in listed["todos"])
# Update
updated = await _invoke(update_todo, ctx, todo_id=todo_id, status="in_progress")
assert updated["success"] is True
# Mark done
done = await _invoke(mark_todo_done, ctx, todo_id=todo_id)
assert done["success"] is True
# Reset to pending
pending = await _invoke(mark_todo_pending, ctx, todo_id=todo_id)
assert pending["success"] is True
# Delete
deleted = await _invoke(delete_todo, ctx, todo_id=todo_id)
assert deleted["success"] is True
@pytest.mark.asyncio
async def test_todos_are_per_agent_isolated() -> None:
"""Two agents should have independent todo stores."""
ctx_a = _ctx_for("agent-A")
ctx_b = _ctx_for("agent-B")
await _invoke(create_todo, ctx_a, title="A's task")
await _invoke(create_todo, ctx_b, title="B's task")
list_a = await _invoke(list_todos, ctx_a)
list_b = await _invoke(list_todos, ctx_b)
titles_a = [t["title"] for t in list_a["todos"]]
titles_b = [t["title"] for t in list_b["todos"]]
assert titles_a == ["A's task"]
assert titles_b == ["B's task"]
@pytest.mark.asyncio
async def test_create_todo_bulk_via_json_string() -> None:
ctx = _ctx_for()
out = await _invoke(
create_todo,
ctx,
todos=json.dumps(
[
{"title": "t1", "priority": "high"},
{"title": "t2", "priority": "low"},
],
),
)
assert out["success"] is True
assert out["count"] == 2
# --- notes ----------------------------------------------------------------
@pytest.fixture
def notes_run_dir(tmp_path: Path) -> Iterator[Path]:
"""Point the legacy notes module at a fresh run dir per test."""
run_dir = tmp_path / "strix_runs" / "test"
run_dir.mkdir(parents=True)
_notes_impl._notes_storage.clear()
_notes_impl._loaded_notes_run_dir = None
with patch.object(_notes_impl, "_get_run_dir", return_value=run_dir):
yield run_dir
def test_notes_tools_are_function_tools() -> None:
for tool in (create_note, list_notes, get_note, update_note, delete_note):
assert isinstance(tool, FunctionTool)
@pytest.mark.asyncio
async def test_note_lifecycle(notes_run_dir: Path) -> None:
ctx = _ctx_for()
created = await _invoke(
create_note,
ctx,
title="SQLi at /login",
content="Form param `email` reflects into the WHERE clause.",
category="findings",
tags=["sqli", "auth"],
)
assert created["success"] is True
note_id = created["note_id"]
listed = await _invoke(list_notes, ctx, category="findings")
assert listed["success"] is True
assert listed["total_count"] == 1
fetched = await _invoke(get_note, ctx, note_id=note_id)
assert fetched["success"] is True
assert "WHERE clause" in fetched["note"]["content"]
updated = await _invoke(
update_note,
ctx,
note_id=note_id,
content="Confirmed boolean-blind SQLi.",
)
assert updated["success"] is True
deleted = await _invoke(delete_note, ctx, note_id=note_id)
assert deleted["success"] is True
@pytest.mark.asyncio
async def test_notes_jsonl_appended(notes_run_dir: Path) -> None:
"""Verify side effect: notes.jsonl receives one event per op."""
ctx = _ctx_for()
await _invoke(create_note, ctx, title="t", content="c", category="general")
jsonl = notes_run_dir / "notes" / "notes.jsonl"
assert jsonl.exists()
events = [json.loads(line) for line in jsonl.read_text().splitlines() if line]
assert events[0]["op"] == "create"
assert events[0]["note"]["title"] == "t"
@@ -1,79 +0,0 @@
"""C6 regression test — concurrent notes JSONL writes must produce valid JSONL.
This test would fail before the C6 fix (AUDIT_R2 §1.1, applied in Phase 2.2):
the legacy ``_append_note_event`` opened the file and called ``f.write``
without holding ``_notes_lock``, so two threads writing simultaneously
could interleave bytes mid-line and corrupt the JSONL.
"""
from __future__ import annotations
import json
import threading
from collections.abc import Iterator
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from strix.tools.notes.tools import _append_note_event
@pytest.fixture
def notes_path(tmp_path: Path) -> Iterator[Path]:
"""Point ``_get_notes_jsonl_path`` at a tmp file for the test."""
target = tmp_path / "notes" / "notes.jsonl"
target.parent.mkdir(parents=True, exist_ok=True)
with patch(
"strix.tools.notes.tools._get_notes_jsonl_path",
return_value=target,
):
yield target
def test_concurrent_note_writes_yield_valid_jsonl(notes_path: Path) -> None:
"""C6 fix: 50 threads x 20 events = 1000 lines, all valid JSON.
Without the lock, byte-level interleaving on the file produces
fragments like ``{"timesta{"timestamp"...`` that fail json.loads.
"""
def writer(thread_idx: int) -> None:
for i in range(20):
note: dict[str, Any] = {
"title": f"thread-{thread_idx}-note-{i}",
"content": "x" * 200, # non-trivial body to widen the race
"category": "general",
}
_append_note_event(
op="create",
note_id=f"t{thread_idx}-i{i}",
note=note,
)
threads = [threading.Thread(target=writer, args=(t,)) for t in range(50)]
for t in threads:
t.start()
for t in threads:
t.join()
lines = notes_path.read_text(encoding="utf-8").splitlines()
assert len(lines) == 1000, f"expected 1000 lines, got {len(lines)}"
for line in lines:
# raises if the line is malformed JSON
event = json.loads(line)
assert event["op"] == "create"
assert "note_id" in event
def test_single_writer_still_works(notes_path: Path) -> None:
"""Sanity: serial writes still produce a valid JSONL log."""
_append_note_event("create", "n1", {"title": "first"})
_append_note_event("update", "n1", {"title": "first updated"})
_append_note_event("delete", "n1")
events = [json.loads(line) for line in notes_path.read_text().splitlines()]
assert [e["op"] for e in events] == ["create", "update", "delete"]
assert all(e["note_id"] == "n1" for e in events)
-187
View File
@@ -1,187 +0,0 @@
from pathlib import Path
import pytest
from strix.telemetry.tracer import Tracer, get_global_tracer, set_global_tracer
from strix.tools.notes import tools as notes_actions
def _reset_notes_state() -> None:
notes_actions._notes_storage.clear()
notes_actions._loaded_notes_run_dir = None
def test_wiki_notes_are_persisted_and_removed(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_reset_notes_state()
previous_tracer = get_global_tracer()
tracer = Tracer("wiki-test-run")
set_global_tracer(tracer)
try:
created = notes_actions._create_note_impl(
title="Repo Map",
content="## Architecture\n- monolith",
category="wiki",
tags=["source-map"],
)
assert created["success"] is True
note_id = created["note_id"]
assert isinstance(note_id, str)
note = notes_actions._notes_storage[note_id]
wiki_filename = note.get("wiki_filename")
assert isinstance(wiki_filename, str)
wiki_path = tmp_path / "strix_runs" / "wiki-test-run" / "wiki" / wiki_filename
assert wiki_path.exists()
assert "## Architecture" in wiki_path.read_text(encoding="utf-8")
updated = notes_actions._update_note_impl(
note_id=note_id,
content="## Architecture\n- service-oriented",
)
assert updated["success"] is True
assert "service-oriented" in wiki_path.read_text(encoding="utf-8")
deleted = notes_actions._delete_note_impl(note_id=note_id)
assert deleted["success"] is True
assert wiki_path.exists() is False
finally:
_reset_notes_state()
set_global_tracer(previous_tracer)
def test_notes_jsonl_replay_survives_memory_reset(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_reset_notes_state()
previous_tracer = get_global_tracer()
tracer = Tracer("notes-replay-run")
set_global_tracer(tracer)
try:
created = notes_actions._create_note_impl(
title="Auth findings",
content="initial finding",
category="findings",
tags=["auth"],
)
assert created["success"] is True
note_id = created["note_id"]
assert isinstance(note_id, str)
notes_path = tmp_path / "strix_runs" / "notes-replay-run" / "notes" / "notes.jsonl"
assert notes_path.exists() is True
_reset_notes_state()
listed = notes_actions._list_notes_impl(category="findings")
assert listed["success"] is True
assert listed["total_count"] == 1
assert listed["notes"][0]["note_id"] == note_id
assert "content" not in listed["notes"][0]
assert "content_preview" in listed["notes"][0]
updated = notes_actions._update_note_impl(note_id=note_id, content="updated finding")
assert updated["success"] is True
_reset_notes_state()
listed_after_update = notes_actions._list_notes_impl(search="updated finding")
assert listed_after_update["success"] is True
assert listed_after_update["total_count"] == 1
assert listed_after_update["notes"][0]["note_id"] == note_id
assert listed_after_update["notes"][0]["content_preview"] == "updated finding"
listed_with_content = notes_actions._list_notes_impl(
category="findings",
include_content=True,
)
assert listed_with_content["success"] is True
assert listed_with_content["total_count"] == 1
assert listed_with_content["notes"][0]["content"] == "updated finding"
deleted = notes_actions._delete_note_impl(note_id=note_id)
assert deleted["success"] is True
_reset_notes_state()
listed_after_delete = notes_actions._list_notes_impl(category="findings")
assert listed_after_delete["success"] is True
assert listed_after_delete["total_count"] == 0
finally:
_reset_notes_state()
set_global_tracer(previous_tracer)
def test_get_note_returns_full_note(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
_reset_notes_state()
previous_tracer = get_global_tracer()
tracer = Tracer("get-note-run")
set_global_tracer(tracer)
try:
created = notes_actions._create_note_impl(
title="Repo wiki",
content="entrypoints and sinks",
category="wiki",
tags=["repo:appsmith"],
)
assert created["success"] is True
note_id = created["note_id"]
assert isinstance(note_id, str)
result = notes_actions._get_note_impl(note_id=note_id)
assert result["success"] is True
assert result["note"]["note_id"] == note_id
assert result["note"]["content"] == "entrypoints and sinks"
finally:
_reset_notes_state()
set_global_tracer(previous_tracer)
def test_list_and_get_note_handle_wiki_repersist_oserror_gracefully(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
_reset_notes_state()
previous_tracer = get_global_tracer()
tracer = Tracer("wiki-repersist-oserror-run")
set_global_tracer(tracer)
try:
created = notes_actions._create_note_impl(
title="Repo wiki",
content="initial wiki content",
category="wiki",
tags=["repo:demo"],
)
assert created["success"] is True
note_id = created["note_id"]
assert isinstance(note_id, str)
_reset_notes_state()
def _raise_oserror(*_args: object, **_kwargs: object) -> None:
raise OSError("disk full")
monkeypatch.setattr(notes_actions, "_persist_wiki_note", _raise_oserror)
listed = notes_actions._list_notes_impl(category="wiki")
assert listed["success"] is True
assert listed["total_count"] == 1
assert listed["notes"][0]["note_id"] == note_id
fetched = notes_actions._get_note_impl(note_id=note_id)
assert fetched["success"] is True
assert fetched["note"]["note_id"] == note_id
assert fetched["note"]["content"] == "initial wiki content"
finally:
_reset_notes_state()
set_global_tracer(previous_tracer)
-305
View File
@@ -1,305 +0,0 @@
"""Smoke tests for the remaining local SDK tool wrappers.
Covers: web_search, file_edit (str_replace_editor + list_files +
search_files), reporting (create_vulnerability_report), finish_scan.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from unittest.mock import patch
import pytest
from agents.tool import FunctionTool
from strix.tools.file_edit.tools import (
list_files,
search_files,
str_replace_editor,
)
from strix.tools.finish.tool import finish_scan
from strix.tools.reporting.tool import create_vulnerability_report
from strix.tools.web_search.tool import web_search
@dataclass
class _Ctx:
context: dict[str, Any] = field(default_factory=dict)
def _ctx_for(agent_id: str = "test-agent") -> _Ctx:
return _Ctx(
context={
"agent_id": agent_id,
"tool_server_host_port": 12345,
"sandbox_token": "test-token",
},
)
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
from agents.tool_context import ToolContext
tool_ctx = ToolContext(
context=ctx.context,
usage=None,
tool_name=tool.name,
tool_call_id="test-call-id",
tool_arguments=json.dumps(kwargs),
)
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
assert isinstance(result, str)
decoded = json.loads(result)
assert isinstance(decoded, dict)
return decoded
def test_all_remaining_tools_are_function_tools() -> None:
for tool in (
web_search,
str_replace_editor,
list_files,
search_files,
create_vulnerability_report,
finish_scan,
):
assert isinstance(tool, FunctionTool)
# --- web_search -----------------------------------------------------------
@pytest.mark.asyncio
async def test_web_search_no_api_key_returns_structured_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The legacy function returns a structured error when the env var is
missing — verify the wrapper passes that through verbatim."""
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
out = await _invoke(web_search, _ctx_for(), query="anything")
assert out["success"] is False
assert "PERPLEXITY_API_KEY" in out["message"]
@pytest.mark.asyncio
async def test_web_search_delegates_to_perplexity(monkeypatch: pytest.MonkeyPatch) -> None:
"""The wrapper invokes the Perplexity HTTP path on a thread."""
monkeypatch.setenv("PERPLEXITY_API_KEY", "fake-key")
fake_result = {
"success": True,
"query": "xss techniques",
"content": "Reflected XSS payload examples...",
"message": "Web search completed successfully",
}
with patch(
"strix.tools.web_search.tool._do_search",
return_value=fake_result,
) as do_search:
out = await _invoke(web_search, _ctx_for(), query="xss techniques")
assert out == fake_result
do_search.assert_called_once_with("xss techniques")
# --- file_edit (sandbox-bound) -------------------------------------------
@pytest.mark.asyncio
async def test_str_replace_editor_routes_to_sandbox() -> None:
"""file_edit tools must POST to the in-sandbox tool server, not run locally."""
fake_response = {"result": {"content": "file viewed"}}
with patch(
"strix.tools.file_edit.tools.post_to_sandbox",
return_value=fake_response,
) as dispatch:
out = await _invoke(
str_replace_editor,
_ctx_for(),
command="view",
path="src/foo.py",
)
assert out == fake_response
assert dispatch.call_count == 1
# post_to_sandbox is called positionally as (ctx, tool_name, kwargs).
args, _ = dispatch.call_args
assert args[1] == "str_replace_editor"
assert args[2]["command"] == "view"
assert args[2]["path"] == "src/foo.py"
# All optional file-edit params are forwarded as None (parity with legacy schema).
assert args[2]["file_text"] is None
assert args[2]["old_str"] is None
@pytest.mark.asyncio
async def test_list_files_routes_to_sandbox() -> None:
fake_response = {"result": {"files": ["a.py"], "directories": []}}
with patch(
"strix.tools.file_edit.tools.post_to_sandbox",
return_value=fake_response,
) as dispatch:
out = await _invoke(list_files, _ctx_for(), path="src", recursive=True)
assert out == fake_response
args, _ = dispatch.call_args
assert args[1] == "list_files"
assert args[2] == {"path": "src", "recursive": True}
@pytest.mark.asyncio
async def test_search_files_routes_to_sandbox() -> None:
fake_response = {"result": {"output": "src/foo.py:1:match"}}
with patch(
"strix.tools.file_edit.tools.post_to_sandbox",
return_value=fake_response,
) as dispatch:
out = await _invoke(
search_files,
_ctx_for(),
path="src",
regex="TODO",
file_pattern="*.py",
)
assert out == fake_response
args, _ = dispatch.call_args
assert args[1] == "search_files"
assert args[2] == {"path": "src", "regex": "TODO", "file_pattern": "*.py"}
# --- reporting -----------------------------------------------------------
@pytest.mark.asyncio
async def test_create_vulnerability_report_validates_required_fields() -> None:
"""Empty required fields should be rejected by the validator."""
out = await _invoke(
create_vulnerability_report,
_ctx_for(),
title="", # empty -> validation error
description="d",
impact="i",
target="t",
technical_analysis="ta",
poc_description="pd",
poc_script_code="curl ...",
remediation_steps="rs",
cvss_breakdown={"attack_vector": "N"},
)
assert out["success"] is False
assert "errors" in out
assert any("Title" in e for e in out["errors"])
@pytest.mark.asyncio
async def test_create_vulnerability_report_delegates_to_impl() -> None:
"""Verify the wrapper threads its kwargs through to the implementation."""
fake_result = {
"success": True,
"message": "Vulnerability report 'X' created successfully",
"report_id": "abc123",
"severity": "high",
"cvss_score": 7.5,
}
with patch(
"strix.tools.reporting.tool._do_create",
return_value=fake_result,
) as do_create:
out = await _invoke(
create_vulnerability_report,
_ctx_for(),
title="t",
description="d",
impact="i",
target="tg",
technical_analysis="ta",
poc_description="pd",
poc_script_code="pc",
remediation_steps="rs",
cvss_breakdown={"attack_vector": "N"},
cve="CVE-2024-12345",
)
assert out == fake_result
kwargs = do_create.call_args.kwargs
assert kwargs["title"] == "t"
assert kwargs["cve"] == "CVE-2024-12345"
# Optional params we didn't pass should still be forwarded as None.
assert kwargs["endpoint"] is None
assert kwargs["method"] is None
# --- finish_scan ---------------------------------------------------------
@pytest.mark.asyncio
async def test_finish_scan_validates_empty_fields() -> None:
"""Legacy validation: every section must be non-empty."""
out = await _invoke(
finish_scan,
_ctx_for(),
executive_summary="",
methodology="m",
technical_analysis="ta",
recommendations="r",
)
assert out["success"] is False
assert any("Executive summary" in e for e in out["errors"])
@pytest.mark.asyncio
async def test_finish_scan_rejects_subagent() -> None:
"""A subagent (parent_id is set) must not be able to finish the scan."""
ctx = _Ctx(
context={
"agent_id": "child-1",
"parent_id": "root-1",
"tool_server_host_port": 12345,
"sandbox_token": "test-token",
},
)
out = await _invoke(
finish_scan,
ctx,
executive_summary="es",
methodology="m",
technical_analysis="ta",
recommendations="r",
)
assert out["success"] is False
assert out["error"] == "finish_scan_wrong_agent"
@pytest.mark.asyncio
async def test_finish_scan_persists_via_tracer() -> None:
"""When a global tracer exists, finish_scan should write the four sections."""
from unittest.mock import MagicMock
fake_tracer = MagicMock()
fake_tracer.vulnerability_reports = [{}, {}, {}]
with patch(
"strix.telemetry.tracer.get_global_tracer",
return_value=fake_tracer,
):
out = await _invoke(
finish_scan,
_ctx_for("root-agent"),
executive_summary="es",
methodology="m",
technical_analysis="ta",
recommendations="r",
)
assert out["success"] is True
assert out["vulnerabilities_found"] == 3
fake_tracer.update_scan_final_fields.assert_called_once_with(
executive_summary="es",
methodology="m",
technical_analysis="ta",
recommendations="r",
)
-206
View File
@@ -1,206 +0,0 @@
"""Phase 2.1 smoke tests for the sandbox dispatch helper."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import httpx
import pytest
from strix.tools._sandbox_dispatch import post_to_sandbox
@dataclass
class _Ctx:
"""Stand-in for ``RunContextWrapper``. Only ``.context`` is touched."""
context: dict[str, Any] = field(default_factory=dict)
def _ok_ctx(**overrides: Any) -> _Ctx:
base: dict[str, Any] = {
"tool_server_host_port": 48081,
"sandbox_token": "test-bearer",
"agent_id": "agent-1",
}
base.update(overrides)
return _Ctx(context=base)
@pytest.mark.asyncio
async def test_missing_context_returns_error() -> None:
"""If ``ctx.context`` isn't a dict, return error — never raise."""
ctx = _Ctx(context="not a dict")
result = await post_to_sandbox(ctx, "browser_action", {"action": "launch"})
assert "error" in result
assert "context" in result["error"].lower()
@pytest.mark.asyncio
async def test_missing_port_or_token_returns_error() -> None:
ctx = _Ctx(context={"sandbox_token": "tok"}) # no port
result = await post_to_sandbox(ctx, "x", {})
assert "error" in result
assert "tool server port" in result["error"].lower()
@pytest.mark.asyncio
async def test_successful_response_returned_as_dict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
async def fake_post(
self: httpx.AsyncClient,
url: str,
*,
json: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
return httpx.Response(
status_code=200,
json={"result": "ok"},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "terminal_execute", {"command": "ls"})
assert result == {"result": "ok"}
assert captured["url"] == "http://127.0.0.1:48081/execute"
assert captured["json"] == {
"agent_id": "agent-1",
"tool_name": "terminal_execute",
"kwargs": {"command": "ls"},
}
assert captured["headers"]["Authorization"] == "Bearer test-bearer"
@pytest.mark.asyncio
async def test_401_returns_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=401,
text="forbidden",
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "authorization" in result["error"].lower()
@pytest.mark.asyncio
async def test_5xx_returns_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=503,
text="server fell over",
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
assert "error" in result
assert "503" in result["error"]
@pytest.mark.asyncio
async def test_timeout_returns_timeout_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
raise httpx.ReadTimeout("read timeout")
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "python_action", {})
assert "error" in result
assert "timed out" in result["error"].lower()
@pytest.mark.asyncio
async def test_connection_error_returns_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
raise httpx.ConnectError("refused")
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "connection" in result["error"].lower()
@pytest.mark.asyncio
async def test_response_too_large_capped(monkeypatch: pytest.MonkeyPatch) -> None:
"""C18 (AUDIT_R3): response > 50MB returns error, doesn't OOM."""
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
# Construct a response with a >50MB body. Use a string trick —
# httpx.Response stores .content directly; we make it look huge.
big = b"x" * (51 * 1024 * 1024)
return httpx.Response(
status_code=200,
content=big,
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
assert "error" in result
assert "too large" in result["error"].lower()
@pytest.mark.asyncio
async def test_non_json_response_returns_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=200,
text="hello not json",
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "non-json" in result["error"].lower()
@pytest.mark.asyncio
async def test_non_object_json_returns_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=200,
json=["a", "list", "not", "an", "object"],
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "non-object" in result["error"].lower()
-348
View File
@@ -1,348 +0,0 @@
"""Smoke tests for the sandbox-bound SDK tool wrappers.
Covers: browser_action, terminal_execute, python_action, file_edit
helpers, and the seven Caido proxy tools.
These wrappers are pure pass-throughs to ``post_to_sandbox`` — there's
no per-tool logic to assert, so the tests focus on:
- ``FunctionTool`` registration succeeds (which proves the SDK could
derive a JSON schema from the type hints — a non-trivial check given
Literal types, ``dict[str, str]``, and strict-mode opt-outs).
- The dispatch payload to ``post_to_sandbox`` carries the right
``kwargs`` shape so the in-container tool server can dispatch to the
matching action.
- The ``send_request`` / ``repeat_request`` tools opt out of strict
schema mode (their ``headers`` / ``modifications`` dicts are
free-form and would otherwise fail registration).
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from unittest.mock import patch
import pytest
from agents.tool import FunctionTool
from strix.tools.browser.tool import browser_action
from strix.tools.proxy.tools import (
list_requests,
list_sitemap,
repeat_request,
scope_rules,
send_request,
view_request,
view_sitemap_entry,
)
from strix.tools.python.tool import python_action
from strix.tools.terminal.tool import terminal_execute
_ALL_SANDBOX_TOOLS = (
browser_action,
terminal_execute,
python_action,
list_requests,
view_request,
send_request,
repeat_request,
scope_rules,
list_sitemap,
view_sitemap_entry,
)
@dataclass
class _Ctx:
context: dict[str, Any] = field(default_factory=dict)
def _ctx_for(agent_id: str = "test-agent") -> _Ctx:
return _Ctx(
context={
"agent_id": agent_id,
"tool_server_host_port": 12345,
"sandbox_token": "test-token",
},
)
async def _invoke(tool: FunctionTool, ctx: _Ctx, **kwargs: Any) -> dict[str, Any]:
from agents.tool_context import ToolContext
tool_ctx = ToolContext(
context=ctx.context,
usage=None,
tool_name=tool.name,
tool_call_id="test-call-id",
tool_arguments=json.dumps(kwargs),
)
result = await tool.on_invoke_tool(tool_ctx, json.dumps(kwargs))
assert isinstance(result, str)
decoded = json.loads(result)
assert isinstance(decoded, dict)
return decoded
def test_all_sandbox_tools_register() -> None:
for tool in _ALL_SANDBOX_TOOLS:
assert isinstance(tool, FunctionTool)
def test_send_and_repeat_request_opt_out_of_strict_mode() -> None:
"""The two free-form-dict tools must turn off strict JSON schema."""
assert send_request.strict_json_schema is False
assert repeat_request.strict_json_schema is False
# All other tools should keep strict mode on (the SDK default).
for tool in (
browser_action,
terminal_execute,
python_action,
list_requests,
view_request,
scope_rules,
list_sitemap,
view_sitemap_entry,
):
assert tool.strict_json_schema is True, tool.name
# --- browser ---------------------------------------------------------------
@pytest.mark.asyncio
async def test_browser_action_dispatches_full_payload() -> None:
"""All optional browser_action params must forward as None when unset
(the in-container handler distinguishes ``None`` from missing)."""
fake = {"result": {"screenshot": "data:image/png;base64,..."}}
with patch(
"strix.tools.browser.tool.post_to_sandbox",
return_value=fake,
) as dispatch:
out = await _invoke(
browser_action,
_ctx_for(),
action="goto",
url="https://example.com",
)
assert out == fake
args, _ = dispatch.call_args
assert args[1] == "browser_action"
payload = args[2]
assert payload["action"] == "goto"
assert payload["url"] == "https://example.com"
# All optional params are present in the payload (as None / defaults)
# so the legacy in-container handler sees the full kwarg surface.
for key in (
"coordinate",
"text",
"tab_id",
"js_code",
"duration",
"key",
"file_path",
):
assert key in payload, key
assert payload[key] is None
assert payload["clear"] is False
# --- terminal --------------------------------------------------------------
@pytest.mark.asyncio
async def test_terminal_execute_dispatches() -> None:
fake = {"result": {"content": "hello\n", "exit_code": 0}}
with patch(
"strix.tools.terminal.tool.post_to_sandbox",
return_value=fake,
) as dispatch:
out = await _invoke(
terminal_execute,
_ctx_for(),
command="echo hello",
terminal_id="term-1",
)
assert out == fake
args, _ = dispatch.call_args
assert args[1] == "terminal_execute"
assert args[2]["command"] == "echo hello"
assert args[2]["terminal_id"] == "term-1"
assert args[2]["is_input"] is False
assert args[2]["no_enter"] is False
assert args[2]["timeout"] is None
# --- python ---------------------------------------------------------------
@pytest.mark.asyncio
async def test_python_action_dispatches() -> None:
fake = {"result": {"stdout": "42\n", "is_running": False}}
with patch(
"strix.tools.python.tool.post_to_sandbox",
return_value=fake,
) as dispatch:
out = await _invoke(
python_action,
_ctx_for(),
action="execute",
code="print(6*7)",
session_id="sess-1",
)
assert out == fake
args, _ = dispatch.call_args
assert args[1] == "python_action"
assert args[2] == {
"action": "execute",
"code": "print(6*7)",
"timeout": 30,
"session_id": "sess-1",
}
# --- proxy / Caido --------------------------------------------------------
@pytest.mark.asyncio
async def test_list_requests_forwards_full_query() -> None:
fake: dict[str, Any] = {"result": {"requests": []}}
with patch(
"strix.tools.proxy.tools.post_to_sandbox",
return_value=fake,
) as dispatch:
await _invoke(
list_requests,
_ctx_for(),
httpql_filter="resp.code:eq:500",
page_size=20,
sort_by="response_time",
sort_order="asc",
)
args, _ = dispatch.call_args
assert args[1] == "list_requests"
assert args[2]["httpql_filter"] == "resp.code:eq:500"
assert args[2]["page_size"] == 20
assert args[2]["sort_by"] == "response_time"
assert args[2]["sort_order"] == "asc"
# Defaults preserved.
assert args[2]["start_page"] == 1
assert args[2]["end_page"] == 1
@pytest.mark.asyncio
async def test_view_request_dispatches() -> None:
fake = {"result": {"raw": "GET / HTTP/1.1..."}}
with patch(
"strix.tools.proxy.tools.post_to_sandbox",
return_value=fake,
) as dispatch:
await _invoke(
view_request,
_ctx_for(),
request_id="req-9",
part="response",
search_pattern="Set-Cookie",
)
args, _ = dispatch.call_args
assert args[1] == "view_request"
assert args[2]["request_id"] == "req-9"
assert args[2]["part"] == "response"
assert args[2]["search_pattern"] == "Set-Cookie"
@pytest.mark.asyncio
async def test_send_request_normalizes_missing_headers() -> None:
"""Legacy schema treats omitted ``headers`` as ``{}``; the wrapper must too."""
fake = {"result": {"status": 200}}
with patch(
"strix.tools.proxy.tools.post_to_sandbox",
return_value=fake,
) as dispatch:
await _invoke(
send_request,
_ctx_for(),
method="POST",
url="https://api.example.com/login",
body='{"u":"x"}',
)
args, _ = dispatch.call_args
assert args[1] == "send_request"
assert args[2]["headers"] == {} # not None
assert args[2]["method"] == "POST"
assert args[2]["body"] == '{"u":"x"}'
@pytest.mark.asyncio
async def test_repeat_request_normalizes_missing_modifications() -> None:
fake = {"result": {"status": 200}}
with patch(
"strix.tools.proxy.tools.post_to_sandbox",
return_value=fake,
) as dispatch:
await _invoke(repeat_request, _ctx_for(), request_id="req-1")
args, _ = dispatch.call_args
assert args[1] == "repeat_request"
assert args[2]["modifications"] == {}
@pytest.mark.asyncio
async def test_scope_rules_dispatches() -> None:
fake = {"result": {"scope_id": "s-1"}}
with patch(
"strix.tools.proxy.tools.post_to_sandbox",
return_value=fake,
) as dispatch:
await _invoke(
scope_rules,
_ctx_for(),
action="create",
scope_name="prod",
allowlist=["*.example.com"],
)
args, _ = dispatch.call_args
assert args[1] == "scope_rules"
assert args[2]["action"] == "create"
assert args[2]["scope_name"] == "prod"
assert args[2]["allowlist"] == ["*.example.com"]
assert args[2]["denylist"] is None
@pytest.mark.asyncio
async def test_list_sitemap_defaults() -> None:
fake: dict[str, Any] = {"result": {"entries": []}}
with patch(
"strix.tools.proxy.tools.post_to_sandbox",
return_value=fake,
) as dispatch:
await _invoke(list_sitemap, _ctx_for())
args, _ = dispatch.call_args
assert args[1] == "list_sitemap"
assert args[2]["depth"] == "DIRECT"
assert args[2]["page"] == 1
@pytest.mark.asyncio
async def test_view_sitemap_entry_dispatches() -> None:
fake = {"result": {"entry_id": "e-1"}}
with patch(
"strix.tools.proxy.tools.post_to_sandbox",
return_value=fake,
) as dispatch:
await _invoke(view_sitemap_entry, _ctx_for(), entry_id="e-1")
args, _ = dispatch.call_args
assert args[1] == "view_sitemap_entry"
assert args[2] == {"entry_id": "e-1"}
@@ -1,62 +0,0 @@
import importlib
import sys
from types import ModuleType
from typing import Any
from strix.config import Config
from strix.tools.registry import clear_registry
def _empty_config_load(_cls: type[Config]) -> dict[str, dict[str, str]]:
return {"env": {}}
def _reload_tools_module() -> ModuleType:
clear_registry()
for name in list(sys.modules):
if name == "strix.tools" or name.startswith("strix.tools."):
sys.modules.pop(name, None)
return importlib.import_module("strix.tools")
def test_non_sandbox_skips_browser_and_web_search_when_disabled(
monkeypatch: Any,
) -> None:
"""Browser registration is gated on STRIX_DISABLE_BROWSER and
web_search on PERPLEXITY_API_KEY; both should stay out of the
in-container ``register_tool`` registry when their gates are off.
Agents_graph is no longer in this registry — those tools are SDK
function tools (host-side only), not in-container tools.
"""
monkeypatch.setenv("STRIX_SANDBOX_MODE", "false")
monkeypatch.setenv("STRIX_DISABLE_BROWSER", "true")
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
monkeypatch.setattr(Config, "load", classmethod(_empty_config_load))
tools = _reload_tools_module()
names = set(tools.get_tool_names())
assert "browser_action" not in names
assert "web_search" not in names
def test_sandbox_registers_sandbox_tools_but_not_non_sandbox_tools(
monkeypatch: Any,
) -> None:
monkeypatch.setenv("STRIX_SANDBOX_MODE", "true")
monkeypatch.setenv("STRIX_DISABLE_BROWSER", "true")
monkeypatch.delenv("PERPLEXITY_API_KEY", raising=False)
monkeypatch.setattr(Config, "load", classmethod(_empty_config_load))
tools = _reload_tools_module()
names = set(tools.get_tool_names())
assert "terminal_execute" in names
assert "python_action" in names
assert "list_requests" in names
assert "create_agent" not in names
assert "finish_scan" not in names
assert "browser_action" not in names
assert "web_search" not in names
Generated
-165
View File
@@ -731,90 +731,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
] ]
[[package]]
name = "coverage"
version = "7.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[[package]] [[package]]
name = "croniter" name = "croniter"
version = "6.2.2" version = "6.2.2"
@@ -1816,15 +1732,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "ipython" name = "ipython"
version = "9.11.0" version = "9.11.0"
@@ -3917,15 +3824,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "polars" name = "polars"
version = "1.39.3" version = "1.39.3"
@@ -4468,61 +4366,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" }, { url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ 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" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -5471,10 +5314,6 @@ dev = [
{ name = "pyinstaller", marker = "python_full_version < '3.15'" }, { name = "pyinstaller", marker = "python_full_version < '3.15'" },
{ name = "pylint" }, { name = "pylint" },
{ name = "pyright" }, { name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-mock" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -5515,10 +5354,6 @@ dev = [
{ name = "pyinstaller", marker = "python_full_version >= '3.12' and python_full_version < '3.15'", specifier = ">=6.17.0" }, { name = "pyinstaller", marker = "python_full_version >= '3.12' and python_full_version < '3.15'", specifier = ">=6.17.0" },
{ name = "pylint", specifier = ">=3.3.7" }, { name = "pylint", specifier = ">=3.3.7" },
{ name = "pyright", specifier = ">=1.1.401" }, { name = "pyright", specifier = ">=1.1.401" },
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-mock", specifier = ">=3.14.1" },
{ name = "ruff", specifier = ">=0.11.13" }, { name = "ruff", specifier = ">=0.11.13" },
] ]