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:
-12
@@ -39,18 +39,6 @@ pip-delete-this-directory.txt
|
||||
.pydevproject
|
||||
.settings/
|
||||
|
||||
# Testing
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
|
||||
# FastAPI
|
||||
.env.local
|
||||
.env.development.local
|
||||
|
||||
@@ -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:
|
||||
@echo "Available commands:"
|
||||
@@ -13,10 +13,6 @@ help:
|
||||
@echo " security - Run security checks with bandit"
|
||||
@echo " check-all - Run all code quality checks"
|
||||
@echo ""
|
||||
@echo "Testing:"
|
||||
@echo " test - Run tests with pytest"
|
||||
@echo " test-cov - Run tests with coverage reporting"
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " pre-commit - Run pre-commit hooks on all files"
|
||||
@echo " clean - Clean up cache files and artifacts"
|
||||
@@ -59,17 +55,6 @@ security:
|
||||
check-all: format lint type-check security
|
||||
@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:
|
||||
@echo "🔧 Running pre-commit hooks..."
|
||||
uv run pre-commit run --all-files
|
||||
@@ -78,13 +63,10 @@ pre-commit:
|
||||
clean:
|
||||
@echo "🧹 Cleaning up cache files..."
|
||||
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 ".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 ".coverage" -delete 2>/dev/null || true
|
||||
@echo "✅ Cleanup complete!"
|
||||
|
||||
dev: format lint type-check test
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
+1
-78
@@ -71,10 +71,6 @@ dev = [
|
||||
"pyright>=1.1.401",
|
||||
"pylint>=3.3.7",
|
||||
"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",
|
||||
"black>=25.1.0",
|
||||
"isort>=6.0.1",
|
||||
@@ -131,7 +127,6 @@ module = [
|
||||
"textual.*",
|
||||
"pyte.*",
|
||||
"libtmux.*",
|
||||
"pytest.*",
|
||||
"cvss.*",
|
||||
"opentelemetry.*",
|
||||
"scrubadub.*",
|
||||
@@ -140,23 +135,6 @@ module = [
|
||||
]
|
||||
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)
|
||||
# ============================================================================
|
||||
@@ -167,7 +145,6 @@ line-length = 100
|
||||
extend-exclude = [
|
||||
".git",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"__pycache__",
|
||||
"build",
|
||||
@@ -203,7 +180,6 @@ select = [
|
||||
"PIE", # flake8-pie
|
||||
"T20", # flake8-print
|
||||
"PYI", # flake8-pyi
|
||||
"PT", # flake8-pytest-style
|
||||
"Q", # flake8-quotes
|
||||
"RSE", # flake8-raise
|
||||
"RET", # flake8-return
|
||||
@@ -246,17 +222,6 @@ ignore = [
|
||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||
"strix/tools/finish/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" = [
|
||||
"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_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)
|
||||
# ============================================================================
|
||||
|
||||
[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
|
||||
severity = "medium"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Strix Test Suite
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for strix.agents module."""
|
||||
@@ -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 +0,0 @@
|
||||
"""Tests for strix.config module."""
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
"""Pytest configuration and shared fixtures for Strix tests."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for strix.interface module."""
|
||||
@@ -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 +0,0 @@
|
||||
"""Tests for strix.llm module."""
|
||||
@@ -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([]) == []
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"}]
|
||||
@@ -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 +0,0 @@
|
||||
"""Tests for strix.runtime module."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
# Tests for skill-related runtime behavior.
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for strix.telemetry module."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
"""Tests for strix.tools module."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "croniter"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "ipython"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "polars"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -5471,10 +5314,6 @@ dev = [
|
||||
{ name = "pyinstaller", marker = "python_full_version < '3.15'" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ 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 = "pylint", specifier = ">=3.3.7" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user