Files
nisse/nisse-dev/tests/test_config.py
T
dongarandClaude Opus 5.5 f84bb70839
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m44s
The new loop's core: intake, restatement, go-ahead, threads (M2.1, PR B)
nisse_dev.loop runs alongside control.py, chosen with `loop: new`.
Issues assigned to the agent (by an operator, or filed by one) become work
items, each with a Matrix thread. A driver run is one model with tools
until it calls a waiting tool: restate puts rev-N up for a go-ahead, bound
to the revision on screen when the reply arrived; ask_human waits for an
answer; close_item closes the item and its issue. Before the go-ahead the
driver can look but not change or close anything. One item is active at a
time; replies in an item's thread go to it. On boot, an item cut off
mid-run waits on the operator, and waiting points are put up again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-02 23:46:42 +00:00

377 lines
13 KiB
Python

import copy
from pathlib import Path
from typing import Any
import pytest
from nisse_dev.config import AgentConfig, ConfigError
from nisse_dev.llm import DEFAULT_REQUEST_TIMEOUT_S
# A complete config as the YAML file would give it, with secrets by reference.
COMPLETE: dict[str, Any] = {
"state_db": "~/demo/state.db",
"workspace": "~/demo/work",
"project": "greet",
"llm": {"base_url": "https://litellm.test", "token": "${TEST_LLM_TOKEN}"},
"roles": {
"executor": "claude-haiku-4-5",
"planner": "claude-opus-5",
"helper": "claude-opus-5",
"expert": "claude-haiku-4-5",
},
"matrix": {
"homeserver": "https://matrix.test/",
"user_id": "@nisse-dev-testing:test",
"token": "${TEST_MATRIX_TOKEN}",
"room_id": "!room:test",
"operators": ["@don:test"],
},
"gitea": {
"url": "https://gitea.test",
"token": "${TEST_GITEA_TOKEN}",
"repo": "don/demo",
"operators": ["don"],
},
"build_host": {"address": "build.test", "key": "~/.ssh/nisse-dev"},
}
SECRETS = {
"TEST_LLM_TOKEN": "sk-x",
"TEST_MATRIX_TOKEN": "syt_x",
"TEST_GITEA_TOKEN": "gt",
}
def tree(**changes: Any) -> dict[str, Any]:
"""COMPLETE with dotted keys changed; a value of None removes the key."""
result = copy.deepcopy(COMPLETE)
for dotted, value in changes.items():
*parents, leaf = dotted.split("__")
node = result
for part in parents:
node = node.setdefault(part, {})
if value is None:
node.pop(leaf, None)
else:
node[leaf] = value
return result
def build(**changes: Any) -> AgentConfig:
return AgentConfig.from_tree(tree(**changes), SECRETS)
def test_complete_config_builds_with_secrets_by_reference() -> None:
config = build()
assert config.project == "greet"
assert config.llm_token == "sk-x"
assert config.gitea_token == "gt"
assert config.roles.planner == "claude-opus-5"
assert config.matrix.room_id == "!room:test"
assert config.matrix.homeserver == "https://matrix.test"
assert config.gitea_repo == "don/demo"
assert config.base_branch == "main"
assert config.tuning.executor.max_turns == 40 # absent section: defaults
def test_paths_expand_the_home_directory() -> None:
config = build()
assert config.workspace.is_absolute()
assert "~" not in str(config.state_db)
def test_every_missing_setting_is_reported_at_once() -> None:
# Failing at first use would surface a missing token twenty minutes into
# a milestone, on the call that mattered.
with pytest.raises(ConfigError) as excinfo:
build(matrix__token=None, gitea__repo=None)
message = str(excinfo.value)
assert "matrix.token" in message
assert "gitea.repo" in message
def test_empty_is_treated_as_missing() -> None:
with pytest.raises(ConfigError, match="gitea.repo"):
build(gitea__repo=" ")
def test_a_reference_to_an_unset_variable_is_an_error() -> None:
# Never an empty string: an empty token would pass for a value and fail
# at the first request.
env = {k: v for k, v in SECRETS.items() if k != "TEST_GITEA_TOKEN"}
with pytest.raises(ConfigError, match=r"gitea.token: references \$\{TEST_GITEA"):
AgentConfig.from_tree(tree(), env)
def test_operator_lists_drop_blanks() -> None:
config = build(matrix__operators=[" @don:test ", "@other:test", " "])
assert config.operators == {"@don:test", "@other:test"}
# A blank entry must not admit an empty identity into an allowlist.
assert "" not in config.operators
def test_an_operator_list_of_only_blanks_is_missing() -> None:
with pytest.raises(ConfigError, match="gitea.operators"):
build(gitea__operators=[" "])
def test_base_branch_is_configurable() -> None:
assert build(gitea__base_branch="trunk").base_branch == "trunk"
def test_max_output_tokens_is_configurable_and_checked() -> None:
assert build(llm__max_output_tokens=8192).max_output_tokens == 8192
with pytest.raises(ConfigError, match="llm.max_output_tokens"):
build(llm__max_output_tokens=0)
def test_request_timeout_is_configurable_and_checked() -> None:
assert build().request_timeout_s == DEFAULT_REQUEST_TIMEOUT_S
assert build(llm__request_timeout_s=900).request_timeout_s == 900.0
for bad in (0, -5, "soon"):
with pytest.raises(ConfigError, match="llm.request_timeout_s"):
build(llm__request_timeout_s=bad)
def test_the_request_timeout_reaches_both_clients() -> None:
from nisse_dev.agent import make_llm_clients
from nisse_dev.catalog import ModelCatalog
normalized, passthrough = make_llm_clients(
build(llm__request_timeout_s=42), ModelCatalog({})
)
for client in (normalized, passthrough):
assert client._inner._http.timeout.read == 42.0
def test_redacted_summary_carries_no_secrets() -> None:
summary = build().redacted()
flat = " ".join(summary.values())
for secret in SECRETS.values():
assert secret not in flat
assert summary["project"] == "greet"
def test_tuning_is_read() -> None:
config = build(
tuning={
"executor": {"max_turns": 40, "failure_limit": 3},
"expert": {"consult_budget": 5},
"delegation": {"max_turns": 20, "max_tokens": 100000, "wall_clock_s": 600},
"tools": {"command_timeout_s": 300, "max_output_chars": 2000},
}
)
tuning = config.tuning
assert (tuning.executor.max_turns, tuning.executor.failure_limit) == (40, 3)
assert tuning.expert.consult_budget == 5
assert tuning.delegation.max_tokens == 100_000
assert tuning.delegation.wall_clock_s == 600.0
assert tuning.tools.max_output_chars == 2000
def test_a_partly_given_tuning_section_keeps_the_other_defaults() -> None:
tuning = build(tuning={"executor": {"max_turns": 40}}).tuning
assert tuning.executor.failure_limit == 2
def test_every_problem_is_reported_at_once() -> None:
# A misspelt knob that was silently ignored would look exactly like a knob
# with no effect, so unknown keys are errors.
bad = tree(project=None, llm_extra=1)
bad["tuning"] = {
"executor": {"max_turn": 40, "failure_limit": 0},
"expert": {"consult_budget": True},
"bogus": {"x": 1},
}
with pytest.raises(ConfigError) as excinfo:
AgentConfig.from_tree(bad, SECRETS)
message = str(excinfo.value)
for expected in (
"project",
"llm_extra: unknown setting",
"tuning.executor.max_turn: unknown setting",
"tuning.executor.failure_limit: must be a positive number",
"tuning.expert.consult_budget: must be a positive number",
"tuning.bogus: unknown section",
):
assert expected in message
def test_a_whole_number_is_required_where_one_is_meant() -> None:
with pytest.raises(ConfigError, match="tuning.executor.max_turns"):
build(tuning={"executor": {"max_turns": 2.5}})
# --- the file --------------------------------------------------------------
FILE = """
state_db: ~/demo/state.db
workspace: ~/demo/work
project: greet
llm: {base_url: "https://litellm.test", token: "${TEST_LLM_TOKEN}"}
roles:
executor: local-fast
planner: claude-opus-5
helper: claude-opus-5
expert: claude-haiku-4-5
matrix:
homeserver: https://matrix.test
user_id: "@nisse-dev-testing:test"
token: ${TEST_MATRIX_TOKEN}
room_id: "!room:test"
operators: ["@don:test"]
gitea:
url: https://gitea.test
token: ${TEST_GITEA_TOKEN}
repo: don/demo
operators: [don]
build_host:
address: build.test
key: ~/.ssh/nisse-dev
"""
def write(directory: Path, text: str = FILE) -> Path:
path = directory / "nisse-dev.yaml"
path.write_text(text)
return path
def test_a_file_is_read(tmp_path: Path) -> None:
config = AgentConfig.from_file(write(tmp_path), SECRETS)
assert config.roles.executor == "local-fast"
assert config.config_file == tmp_path / "nisse-dev.yaml"
def test_invalid_yaml_is_reported(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="not valid YAML"):
AgentConfig.from_file(write(tmp_path, "roles: [unclosed"), SECRETS)
def test_load_uses_nisse_config(tmp_path: Path) -> None:
path = write(tmp_path)
assert AgentConfig.load({**SECRETS, "NISSE_CONFIG": str(path)}).project == "greet"
def test_load_defaults_to_the_working_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write(tmp_path)
monkeypatch.chdir(tmp_path)
assert AgentConfig.load(SECRETS).project == "greet"
def test_load_without_a_file_says_how_to_make_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ConfigError, match="nisse-dev.example.yaml"):
AgentConfig.load(SECRETS)
def test_the_example_file_is_valid() -> None:
# The example documents every knob; it must not drift out of loading.
example = Path(__file__).resolve().parents[2] / "nisse-dev.example.yaml"
env = {
"NISSE_LLM_TOKEN": "sk-x",
"NISSE_MATRIX_TOKEN": "syt_x",
"NISSE_GITEA_TOKEN": "gt",
}
config = AgentConfig.from_file(example, env)
assert config.tuning.executor.max_turns == 40
assert config.roles.executor == "qwen3.8-27b"
# --- nothing that carries secrets may sit inside the clone -----------------
def test_state_db_and_workspace_are_separate() -> None:
# L8: build artefacts must not share a volume with agent state.
config = build()
assert config.state_db.parent != config.workspace
def test_state_db_inside_the_clone_is_refused(tmp_path: Path) -> None:
# git add -A would commit it, and the push would publish the agent's
# own state alongside the project.
work = tmp_path / "project"
with pytest.raises(ConfigError, match="inside the workspace"):
build(workspace=str(work), state_db=str(work / "state.db"))
def test_env_file_inside_the_clone_is_refused(tmp_path: Path) -> None:
# The severe case: the agent would commit and push its own credentials
# as a side effect of working correctly.
work = tmp_path / "project"
with pytest.raises(ConfigError, match="NISSE_ENV"):
AgentConfig.from_tree(
tree(workspace=str(work), state_db=str(tmp_path / "state.db")),
{**SECRETS, "NISSE_ENV": str(work / ".env")},
)
def test_a_config_file_inside_the_clone_is_refused(tmp_path: Path) -> None:
work = tmp_path / "project"
work.mkdir()
text = FILE.replace("workspace: ~/demo/work", f"workspace: {work}")
with pytest.raises(ConfigError, match="NISSE_CONFIG"):
AgentConfig.from_file(write(work, text), SECRETS)
def test_sibling_paths_are_fine(tmp_path: Path) -> None:
config = AgentConfig.from_tree(
tree(workspace=str(tmp_path / "project"), state_db=str(tmp_path / "state.db")),
{**SECRETS, "NISSE_ENV": str(tmp_path / ".env")},
)
assert config.workspace.name == "project"
def test_context_tuning_is_read_and_checked() -> None:
config = build(tuning={"context": {"keep_recent_turns": 4, "safety_margin": 0.2}})
assert config.tuning.context.keep_recent_turns == 4
assert config.tuning.context.safety_margin == 0.2
assert build().tuning.context.keep_recent_turns == 6
with pytest.raises(ConfigError, match="safety_margin: must be less than 1"):
build(tuning={"context": {"safety_margin": 1}})
def test_the_build_host_is_required_and_defaults_to_root_on_22() -> None:
config = build()
assert (config.build_host.address, config.build_host.user) == ("build.test", "root")
assert config.build_host.port == 22
assert config.build_host.key.is_absolute()
with pytest.raises(ConfigError, match="build_host.address"):
build(build_host__address=None)
def test_a_bad_build_host_port_is_reported() -> None:
with pytest.raises(ConfigError, match="build_host.port"):
build(build_host__port="ssh")
def test_the_push_key_is_optional_and_a_path() -> None:
assert build().push_key is None
assert build(gitea__push_key="~/k").push_key == Path("~/k").expanduser()
def test_a_push_token_needs_its_user_and_excludes_a_key() -> None:
config = build(gitea__push_user="pusher", gitea__push_token="${TEST_GITEA_TOKEN}")
assert (config.push_user, config.push_token) == ("pusher", "gt")
with pytest.raises(ConfigError, match="set both or neither"):
build(gitea__push_user="pusher")
with pytest.raises(ConfigError, match="not both"):
build(gitea__push_user="p", gitea__push_token="t", gitea__push_key="~/k")
def test_no_credential_appears_in_the_config_repr() -> None:
config = build(gitea__push_user="pusher", gitea__push_token="${TEST_GITEA_TOKEN}")
text = repr(config)
for secret in SECRETS.values():
assert secret not in text
def test_the_loop_defaults_to_old_and_takes_new() -> None:
assert build().loop == "old"
assert build(loop="new").loop == "new"
with pytest.raises(ConfigError, match="loop: must be old or new"):
build(loop="newer")