4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
191 lines
7.6 KiB
Python
191 lines
7.6 KiB
Python
"""Tests for the per-agent budget config loader."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
from pydantic import ValidationError
|
|
|
|
from tools.system.fleet_monitoring import config as config_mod
|
|
from tools.system.fleet_monitoring.config import (
|
|
AgentBudget,
|
|
AgentsConfig,
|
|
load_agents_config,
|
|
save_agents_config,
|
|
set_agent_budget,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
"""Autouse: redirect ``agents_config_path`` at a per-test tmp file so
|
|
tests never touch the developer's real ``~/.opensre/agents.yaml``.
|
|
Tests that need the path itself can still request it by name.
|
|
"""
|
|
target = tmp_path / "agents.yaml"
|
|
monkeypatch.setattr(config_mod, "agents_config_path", lambda: target)
|
|
return target
|
|
|
|
|
|
class TestAgentBudget:
|
|
def test_all_fields_optional(self) -> None:
|
|
assert AgentBudget().hourly_budget_usd is None
|
|
assert AgentBudget().progress_minutes is None
|
|
assert AgentBudget().error_rate_pct is None
|
|
|
|
def test_negative_budget_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
AgentBudget(hourly_budget_usd=-1.0)
|
|
|
|
def test_zero_budget_rejected(self) -> None:
|
|
# gt=0 keeps the model in lockstep with the CLI's `usd <= 0`
|
|
# check so a hand-edit can't sneak in a $0/hr ceiling.
|
|
with pytest.raises(ValidationError):
|
|
AgentBudget(hourly_budget_usd=0.0)
|
|
|
|
def test_negative_progress_minutes_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
AgentBudget(progress_minutes=-5)
|
|
|
|
def test_error_rate_above_100_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
AgentBudget(error_rate_pct=150.0)
|
|
|
|
def test_unknown_field_rejected_with_suggestion(self) -> None:
|
|
with pytest.raises(ValidationError) as exc:
|
|
AgentBudget.model_validate({"hourly_budegt_usd": 5.0})
|
|
# StrictConfigModel surfaces a "did you mean" hint
|
|
message = str(exc.value)
|
|
assert "hourly_budegt_usd" in message
|
|
assert "hourly_budget_usd" in message
|
|
|
|
|
|
class TestAgentsConfigModel:
|
|
def test_default_is_empty_dict(self) -> None:
|
|
assert AgentsConfig().agents == {}
|
|
|
|
def test_top_level_unknown_field_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
AgentsConfig.model_validate({"agents": {}, "version": 1})
|
|
|
|
|
|
class TestLoadAgentsConfig:
|
|
def test_returns_empty_when_file_missing(self, isolated_path: Path) -> None:
|
|
assert not isolated_path.exists()
|
|
config = load_agents_config()
|
|
assert config == AgentsConfig()
|
|
|
|
def test_returns_empty_when_file_is_blank(self, isolated_path: Path) -> None:
|
|
isolated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
isolated_path.write_text("", encoding="utf-8")
|
|
assert load_agents_config() == AgentsConfig()
|
|
|
|
def test_parses_existing_yaml(self, isolated_path: Path) -> None:
|
|
isolated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
isolated_path.write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"agents": {
|
|
"claude-code": {"hourly_budget_usd": 5.0, "progress_minutes": 8},
|
|
"aider": {"hourly_budget_usd": 1.4},
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
config = load_agents_config()
|
|
assert config.agents["claude-code"].hourly_budget_usd == 5.0
|
|
assert config.agents["claude-code"].progress_minutes == 8
|
|
assert config.agents["aider"].hourly_budget_usd == 1.4
|
|
assert config.agents["aider"].progress_minutes is None
|
|
|
|
def test_returns_empty_on_unparseable_yaml(self, isolated_path: Path) -> None:
|
|
isolated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# Unclosed brace: yaml.YAMLError on parse → loader falls back to empty.
|
|
isolated_path.write_text("agents: {unclosed", encoding="utf-8")
|
|
assert load_agents_config() == AgentsConfig()
|
|
|
|
def test_raises_on_strict_typing_violation(self, isolated_path: Path) -> None:
|
|
# Valid YAML, invalid schema (typo). The loader does NOT swallow
|
|
# this because surfacing the error lets the user fix the typo
|
|
# rather than silently overwriting it on the next write.
|
|
isolated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
isolated_path.write_text(
|
|
yaml.safe_dump({"agents": {"claude-code": {"hourly_budegt_usd": 5.0}}}),
|
|
encoding="utf-8",
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
load_agents_config()
|
|
|
|
|
|
class TestSaveAgentsConfig:
|
|
def test_creates_parent_directory(self, isolated_path: Path) -> None:
|
|
# tmp_path is the parent of `isolated_path`; the loader writes
|
|
# one level deeper to exercise mkdir(parents=True).
|
|
save_agents_config(AgentsConfig(agents={"aider": AgentBudget(hourly_budget_usd=2.0)}))
|
|
assert isolated_path.exists()
|
|
on_disk = yaml.safe_load(isolated_path.read_text(encoding="utf-8"))
|
|
assert on_disk == {"agents": {"aider": {"hourly_budget_usd": 2.0}}}
|
|
|
|
def test_round_trips_through_load(self) -> None:
|
|
original = AgentsConfig(
|
|
agents={
|
|
"claude-code": AgentBudget(hourly_budget_usd=5.0, progress_minutes=8),
|
|
"aider": AgentBudget(error_rate_pct=10.0),
|
|
}
|
|
)
|
|
save_agents_config(original)
|
|
assert load_agents_config() == original
|
|
|
|
|
|
class TestSetAgentBudget:
|
|
def test_writes_and_reloads(self) -> None:
|
|
set_agent_budget("claude-code", 5.0)
|
|
reloaded = load_agents_config()
|
|
assert reloaded.agents["claude-code"].hourly_budget_usd == 5.0
|
|
|
|
def test_preserves_other_agents(self) -> None:
|
|
save_agents_config(AgentsConfig(agents={"aider": AgentBudget(hourly_budget_usd=1.4)}))
|
|
set_agent_budget("claude-code", 5.0)
|
|
reloaded = load_agents_config()
|
|
assert reloaded.agents["aider"].hourly_budget_usd == 1.4
|
|
assert reloaded.agents["claude-code"].hourly_budget_usd == 5.0
|
|
|
|
def test_preserves_other_fields_on_same_agent(self) -> None:
|
|
save_agents_config(
|
|
AgentsConfig(
|
|
agents={"claude-code": AgentBudget(progress_minutes=8, error_rate_pct=2.5)}
|
|
)
|
|
)
|
|
set_agent_budget("claude-code", 5.0)
|
|
reloaded = load_agents_config().agents["claude-code"]
|
|
assert reloaded.hourly_budget_usd == 5.0
|
|
assert reloaded.progress_minutes == 8
|
|
assert reloaded.error_rate_pct == 2.5
|
|
|
|
def test_overwrites_existing_hourly_value(self) -> None:
|
|
set_agent_budget("claude-code", 3.0)
|
|
set_agent_budget("claude-code", 7.5)
|
|
assert load_agents_config().agents["claude-code"].hourly_budget_usd == 7.5
|
|
|
|
def test_strips_surrounding_whitespace_from_name(self) -> None:
|
|
set_agent_budget(" claude-code ", 5.0)
|
|
reloaded = load_agents_config()
|
|
assert "claude-code" in reloaded.agents
|
|
# The padded variant did not become a separate key.
|
|
assert " claude-code " not in reloaded.agents
|
|
|
|
def test_rejects_nan_at_api_layer(self) -> None:
|
|
# ``model_copy`` would skip validators and silently persist a
|
|
# ``nan`` that the next load can't parse. Re-validating in
|
|
# ``set_agent_budget`` blocks corruption from any caller that
|
|
# bypasses the CLI's pre-check.
|
|
with pytest.raises(ValidationError):
|
|
set_agent_budget("claude-code", float("nan"))
|
|
|
|
def test_rejects_inf_at_api_layer(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
set_agent_budget("claude-code", float("inf"))
|