Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

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"))