CI / checks (push) Skipped
CI / checks (pull_request) Successful in 47s
Route selection sniffed model names: anything starting "claude-" or "anthropic/" took the vendor-native passthrough, everything else took the normalized route. That works only while an alias names its vendor. A semantic role alias like "remote-architecture" is meant to point at Anthropic today and somewhere else tomorrow, and the name says nothing either way -- so it would have taken the normalized route regardless of what was behind it, silently losing the fidelity A13 wants for delegations. LiteLLM already knows the answer. /model/info reports each alias's backing model, provider, and declared tool support, so the agent asks at startup and routes on the provider it discovers. That keeps vendor knowledge where A5 already put it -- in the proxy -- rather than duplicating it as name patterns here. There is deliberately no name-based fallback: PASSTHROUGH_PREFIXES and has_native_adapter are gone, and select_client now requires a catalog. A second heuristic could only ever disagree with the proxy. An unknown model raises instead of guessing a route. Misconfiguration is therefore loud and early. build_agent validates every role against the catalog before it clones anything, naming all the bad ones at once. Discovery is per key -- /model/info returns only what the presented token may use -- so the same check catches a role pointed at a real model this key cannot reach. An unreachable proxy is reported separately, since an outage and a typo call for different responses from the operator. Two things now get recorded rather than inferred: the resolved model is pinned alongside the role mapping at task start, for the reason A11 already pins roles, and DelegationStarted carries backing_model so A16 eval history is not averaged across whatever an alias happened to point at. A model LiteLLM declares tool-less warns at startup rather than refusing; A15 expects weak local models to be accommodated, and they are observed driving tool loops anyway. Verified live: all four aliases the production key can see route correctly through discovery, and each chosen route works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
9.9 KiB
Python
290 lines
9.9 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 sqlite3
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from test_config import COMPLETE
|
|
|
|
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.git import GitRepo
|
|
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,
|
|
},
|
|
}
|
|
for alias in ("claude-opus-5", "claude-haiku-4-5")
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
def config_for(tmp_path: Path, **overrides: str) -> AgentConfig:
|
|
env = {
|
|
**COMPLETE,
|
|
"NISSE_STATE_DB": str(tmp_path / "state.db"),
|
|
"NISSE_WORKSPACE": str(tmp_path / "work"),
|
|
**overrides,
|
|
}
|
|
return AgentConfig.from_env(env)
|
|
|
|
|
|
@pytest.fixture
|
|
def origin(tmp_path: Path) -> Path:
|
|
"""A bare repo standing in for the project on the forge."""
|
|
remote = tmp_path / "origin.git"
|
|
GitRepo(tmp_path)._run("init", "-q", "--bare", "-b", "main", str(remote))
|
|
seed = GitRepo(tmp_path / "seed")
|
|
seed.init()
|
|
seed.configure_identity("seed", "seed@test")
|
|
(seed.root / "README.md").write_text("project\n")
|
|
seed.commit_all("seed")
|
|
seed.push(str(remote), "main")
|
|
return remote
|
|
|
|
|
|
def test_workspace_is_cloned_when_absent(tmp_path: Path, origin: Path) -> None:
|
|
config = config_for(tmp_path, NISSE_GITEA_URL=f"file://{origin}")
|
|
# authenticated_url would mangle a file:// remote, so clone directly.
|
|
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
|
repo = prepare_workspace(config, "nisse-dev-testing")
|
|
|
|
assert (config.workspace / "README.md").exists()
|
|
assert repo.current_branch() == "main"
|
|
|
|
|
|
def test_workspace_is_reused_when_already_cloned(tmp_path: Path, origin: Path) -> None:
|
|
config = config_for(tmp_path)
|
|
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
|
(config.workspace / "local-work.txt").write_text("in progress")
|
|
|
|
prepare_workspace(config, "nisse-dev-testing")
|
|
|
|
# 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)
|
|
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
|
|
|
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.authenticated_url",
|
|
lambda base, repo, user, token: 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 # A12 escalation path
|
|
assert orchestrator._executor._expert is not None # A14 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
|
|
with pytest.raises(sqlite3.ProgrammingError):
|
|
agent.store.get_state() # the store really is 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:
|
|
# A13: 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, AnthropicPassthroughClient)
|
|
assert isinstance(agent.orchestrator._executor._client, 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._http
|
|
passthrough = agent.orchestrator._delegate._client._http
|
|
assert isinstance(normalized, httpx.Client)
|
|
assert normalized is not passthrough # different base URLs and headers
|
|
agent.stop()
|
|
|
|
|
|
# --- startup model discovery (A5/A11) ------------------------------------
|
|
|
|
|
|
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, NISSE_ROLE_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, NISSE_ROLE_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:
|
|
# A15 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,
|
|
},
|
|
}
|
|
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": "consultant"' 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)
|
|
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:
|
|
# A16 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()
|