Files
nisse/nisse-dev/tests/test_agent_build.py
T
dongarandClaude Opus 5.5 761ebdb857
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
Run every command on the build VM over SSH (M1.3)
execution.SSHHost.execute is now the only way the agent runs anything
(L13). Commands travel as a script on SSH's stdin, so the secrets handed
to a call are never on a command line, and every value handed out is
redacted from the output. Timeouts kill the remote process group, and
an unreachable host is reported as such, not as a failed command.

Workspace and GitRepo run on the build VM. The clone uses the
repository's SSH URL, so no credential is stored in it (#89); fetches
and pushes get the push key over stdin, in a short-lived ssh-agent,
and PRs are still opened from the agent's host with its API token.
The bench runs on the build host too.

Tests reach "the build VM" through a throwaway local sshd (nisse_dev
.testing); CI installs openssh-server. The live push test now pushes
with the sandbox's deploy key (NISSE_PUSH_KEY).

Fixes #89.

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

377 lines
13 KiB
Python

"""The composition root. These tests exist because two components — the
delegation harness and the comment poller — were fully built, fully tested,
and reachable from nothing but their own tests. Unit tests that construct a
component directly cannot catch that; only assembly can."""
import subprocess
from pathlib import Path
from typing import Any
import httpx
import pytest
from test_config import SECRETS, tree
from nisse_dev.agent import build_agent, discover_models, prepare_workspace
from nisse_dev.catalog import ModelCatalog, UnknownModel
from nisse_dev.config import AgentConfig
from nisse_dev.execution import SSHHost
from nisse_dev.oplog import OpLog
# What the test roles resolve to, as LiteLLM would report them.
TEST_CATALOG = ModelCatalog.from_payload(
{
"data": [
{
"model_name": alias,
"litellm_params": {"model": f"anthropic/{alias}"},
"model_info": {
"litellm_provider": "anthropic",
"supports_function_calling": True,
"max_input_tokens": 200000,
"max_output_tokens": 64000,
},
}
for alias in ("claude-opus-5", "claude-haiku-4-5")
]
}
)
# The build VM for every test here: the session's throwaway sshd.
HOST: SSHHost
@pytest.fixture(autouse=True)
def _build_host(ssh_host: SSHHost) -> None:
global HOST
HOST = ssh_host
def config_for(tmp_path: Path, **changes: str) -> AgentConfig:
return AgentConfig.from_tree(
tree(
state_db=str(tmp_path / "state.db"),
workspace=str(tmp_path / "work"),
build_host__address=HOST.address,
build_host__user=HOST.user,
build_host__port=str(HOST.port),
build_host__key=str(HOST._key),
**changes,
),
SECRETS,
)
def git(*args: str) -> None:
subprocess.run(["git", *args], check=True, capture_output=True)
@pytest.fixture
def origin(tmp_path: Path) -> Path:
"""A bare repo standing in for the project on the forge."""
remote = tmp_path / "origin.git"
git("init", "-q", "--bare", "-b", "main", str(remote))
seed = tmp_path / "seed"
git("init", "-q", "-b", "main", str(seed))
(seed / "README.md").write_text("project\n")
git("-C", str(seed), "add", "-A")
git(
"-C",
str(seed),
"-c",
"user.name=s",
"-c",
"user.email=s@t",
"commit",
"-qm",
"s",
)
git("-C", str(seed), "push", "-q", str(remote), "HEAD:refs/heads/main")
return remote
def test_workspace_is_cloned_on_the_build_host_when_absent(
tmp_path: Path, origin: Path
) -> None:
config = config_for(tmp_path)
repo = prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
assert (config.workspace / "README.md").exists()
assert repo.current_branch() == "main"
# The clone's configuration holds the plain URL it was given (#89).
assert str(origin) in (config.workspace / ".git" / "config").read_text()
def test_workspace_is_reused_when_already_cloned(tmp_path: Path, origin: Path) -> None:
config = config_for(tmp_path)
prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
(config.workspace / "local-work.txt").write_text("in progress")
prepare_workspace(HOST, config, "nisse-dev-testing", str(origin), None)
# Re-cloning would silently destroy uncommitted work.
assert (config.workspace / "local-work.txt").exists()
class FakeTransport:
def __init__(self) -> None:
self.sent: list[str] = []
self.started = False
self.closed = False
def send_message(self, text: str) -> None:
self.sent.append(text)
def send_approval_request(self, text: str) -> None:
self.sent.append(text)
def ask_question(self, text: str) -> None:
self.sent.append(text)
def on_message(self, callback: Any) -> None:
self.callback = callback
def start(self) -> None:
self.started = True
def close(self) -> None:
self.closed = True
def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
"""Assemble the real graph with only the network boundaries faked."""
config = config_for(tmp_path)
monkeypatch.setattr(
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
)
monkeypatch.setattr(
"nisse_dev.agent.ModelCatalog.discover",
classmethod(lambda cls, *a, **kw: TEST_CATALOG),
)
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
monkeypatch.setattr(
"nisse_dev.agent.GiteaClient.ssh_url", lambda self, repo: str(origin)
)
return build_agent(config)
def test_the_whole_graph_assembles(tmp_path: Path, origin: Path, monkeypatch) -> None:
agent = build_offline(tmp_path, origin, monkeypatch)
orchestrator = agent.orchestrator
# Every component that was previously unreachable is now connected.
assert orchestrator._executor is not None
assert orchestrator._publisher is not None
assert orchestrator._board is not None
assert orchestrator._delegate is not None
assert orchestrator._poller_factory is not None
assert orchestrator._executor._delegate is not None # L9 escalation path
assert orchestrator._executor._expert is not None # L9 consult path
agent.stop()
def test_start_announces_where_it_resumed(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
agent = build_offline(tmp_path, origin, monkeypatch)
agent.start()
assert agent.transport.started
readiness = agent.transport.sent[-1]
assert "nisse-dev up as nisse-dev-testing" in readiness
assert "greet" in readiness # the project
assert "claude-opus-5" in readiness # the pinned planner
agent.stop()
def test_stop_closes_everything_and_releases_the_wait(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
agent = build_offline(tmp_path, origin, monkeypatch)
agent.start()
agent.stop()
assert agent.transport.closed
agent.wait() # returns immediately rather than hanging
assert agent.store.closed
def test_poller_factory_ignores_the_agents_own_comments(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
# Without this the agent answers its own PR replies in a loop.
agent = build_offline(tmp_path, origin, monkeypatch)
poller = agent.orchestrator._poller_factory(lambda c: None)
assert "nisse-dev-testing" in poller._ignore
agent.stop()
def test_delegation_uses_the_passthrough_route(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
# L9: delegations run vendor-native; the normalized route is for the
# places where fidelity is cheap.
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
agent = build_offline(tmp_path, origin, monkeypatch)
delegation = agent.orchestrator._delegate
assert isinstance(delegation._client._inner, AnthropicPassthroughClient)
assert isinstance(agent.orchestrator._executor._client._inner, LiteLLMClient)
agent.stop()
def test_httpx_clients_are_not_shared_between_routes(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
agent = build_offline(tmp_path, origin, monkeypatch)
normalized = agent.orchestrator._executor._client._inner._http
passthrough = agent.orchestrator._delegate._client._inner._http
assert isinstance(normalized, httpx.Client)
assert normalized is not passthrough # different base URLs and headers
agent.stop()
# --- startup model discovery (L9) ------------------------------------
def test_a_typod_role_refuses_to_boot(tmp_path: Path, monkeypatch) -> None:
# Misconfiguration should cost a second at startup, not be discovered ten
# minutes into a milestone.
config = config_for(tmp_path, roles__planner="clause-opus-5")
monkeypatch.setattr(
"nisse_dev.agent.ModelCatalog.discover",
classmethod(lambda cls, *a, **kw: TEST_CATALOG),
)
with pytest.raises(UnknownModel, match="planner='clause-opus-5'"):
discover_models(config, OpLog("test"))
def test_a_role_this_key_cannot_reach_refuses_to_boot(
tmp_path: Path, monkeypatch
) -> None:
# /model/info is scoped per key, so the same check catches a role pointed
# at a real model that this token has no access to.
config = config_for(tmp_path, roles__executor="local-fast")
monkeypatch.setattr(
"nisse_dev.agent.ModelCatalog.discover",
classmethod(lambda cls, *a, **kw: TEST_CATALOG),
)
with pytest.raises(UnknownModel, match="executor='local-fast'"):
discover_models(config, OpLog("test"))
def test_a_tool_less_model_warns_but_still_boots(tmp_path: Path, monkeypatch) -> None:
# L9 expects weak local models to be accommodated, and models LiteLLM
# declares tool-less are observed driving tool loops anyway.
import io
tool_less = ModelCatalog.from_payload(
{
"data": [
{
"model_name": alias,
"litellm_params": {"model": "openai/qwen3-8b-awq"},
"model_info": {
"litellm_provider": "openai",
"supports_function_calling": False,
"max_input_tokens": 32768,
},
}
for alias in ("claude-opus-5", "claude-haiku-4-5")
]
}
)
monkeypatch.setattr(
"nisse_dev.agent.ModelCatalog.discover",
classmethod(lambda cls, *a, **kw: tool_less),
)
stream = io.StringIO()
discover_models(config_for(tmp_path), OpLog("test", stream))
logged = stream.getvalue()
assert "model_tools_unsupported" in logged
assert '"role": "executor"' in logged
assert '"role": "expert"' not in logged # ask_expert drives no tools
def test_the_resolved_model_is_pinned_at_task_start(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
agent = build_offline(tmp_path, origin, monkeypatch)
# The store belongs to the worker thread, so the work has to run there.
agent.worker.call(lambda: agent.orchestrator._begin_project("greet"))
pinned = agent.orchestrator._data["models"]
assert pinned["planner"] == {
"alias": "claude-opus-5",
"backing": "anthropic/claude-opus-5",
"provider": "anthropic",
}
agent.stop()
def test_delegation_records_what_the_alias_resolved_to(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
# L9 eval history keyed on an alias alone would average across whatever
# that alias pointed at over time.
agent = build_offline(tmp_path, origin, monkeypatch)
assert agent.orchestrator._delegate._backing_model == "anthropic/claude-opus-5"
agent.stop()
def test_tuning_reaches_every_component(
tmp_path: Path, origin: Path, monkeypatch
) -> None:
from dataclasses import replace
from nisse_dev.config import (
DelegationTuning,
ExecutorTuning,
ExpertTuning,
ToolsTuning,
Tuning,
)
config = replace(
config_for(tmp_path),
max_output_tokens=8192,
tuning=Tuning(
executor=ExecutorTuning(max_turns=40, failure_limit=3),
expert=ExpertTuning(consult_budget=5),
delegation=DelegationTuning(
max_turns=12, max_tokens=50_000, wall_clock_s=60
),
tools=ToolsTuning(command_timeout_s=300, max_output_chars=2000),
),
)
monkeypatch.setattr(
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
)
monkeypatch.setattr(
"nisse_dev.agent.ModelCatalog.discover",
classmethod(lambda cls, *a, **kw: TEST_CATALOG),
)
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
monkeypatch.setattr(
"nisse_dev.agent.GiteaClient.ssh_url", lambda self, repo: str(origin)
)
agent = build_agent(config)
executor = agent.orchestrator._executor
delegation = agent.orchestrator._delegate
assert (executor._max_turns, executor._failure_limit) == (40, 3)
assert executor._expert._budget == 5
assert delegation._caps.turns == 12
assert delegation._caps.tokens == 50_000
assert delegation._caps.wall_clock_s == 60
assert executor._workspace._timeout_s == 300
assert executor._workspace._max_output_chars == 2000
assert executor._client._inner._max_tokens == 8192
assert delegation._client._inner._max_tokens == 8192
assert executor._client._max_output_tokens == 8192
# tool_choice is asked of exactly the models LiteLLM declares enforce it.
assert executor._client._enforces_tool_choice("claude-opus-5") is False
agent.stop()