Add the composition root and a runnable entry point
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s

Nothing had ever assembled the agent. main.py was still the hello-world
placeholder from the scaffold, and no code outside the test suite
constructed an Orchestrator — which is how two fully built, fully
tested components came to be reachable from nothing: the delegation
harness, and the Gitea comment poller. Unit tests that construct a
component directly cannot catch that; only assembly can.

- config.py validates every variable before anything is built and
  names all of them at once, rather than failing at first use twenty
  minutes into a milestone on the call that mattered. Whitespace-only
  counts as missing, since a token of spaces passes a truthiness check
  and fails at the first request instead
- agent.py builds the whole graph and owns startup and shutdown order:
  reconcile before accepting commands, and on stop, stop watching,
  then stop talking, then close the store last
- The Gitea account is read via whoami rather than configured: it names
  the identity every permission is evaluated against, and proves the
  token works before anything depends on it
- Poller lifecycle is now a state-machine concern: watch the plan issue
  while a gate stands, the PR while it is open, and stop on leaving.
  The orchestrator owns when to watch; an injected factory owns how to
  build one
- The executor reports each turn, because it runs synchronously inside
  the transport callback: without it the agent is silent for a whole
  milestone and a working run looks exactly like a hung one
- main.py loads .env, builds, and shuts down cleanly on SIGINT/SIGTERM

The entry-point tests changed shape: nisse-dev no longer greets, it
refuses to start unconfigured and says everything that is missing. Both
tests now run with a deliberately empty environment and a .env path
that does not exist, because the working directory's .env holds live
credentials and a test must never be one cwd away from starting a real
agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dongarandClaude Opus 5 committed 2026-09-08 22:26:26 +00:00
1 parent 6331fcafd9
commit 2fb25b8195
11 files changed
+694 -15

No files matched your search

+10
View File
@@ -34,3 +34,13 @@ NISSE_GITEA_TEST_REPO=owner/sandbox-repo
# Gitea logins allowed to drive the agent via PR comments (A4 per-channel
# allowlist; a Matrix MXID and a Gitea login are different names).
NISSE_GITEA_OPERATORS=your-gitea-login
# --- Running the agent (the demo, not the tests) ---
# The tests use NISSE_GITEA_TEST_REPO; a running agent uses these.
NISSE_STATE_DB=~/nisse-demo-workspace/state.db
NISSE_WORKSPACE=~/nisse-demo-workspace/project
NISSE_PROJECT=demo
NISSE_GITEA_REPO=owner/demo-repo
# NISSE_BASE_BRANCH=main
# Path to this file, if not ./.env
# NISSE_ENV=~/nisse-demo-workspace/.env
+167
View File
@@ -0,0 +1,167 @@
"""Composition root: the one place that knows how the whole agent fits together.
Every component below has been unit-tested in isolation for some time; nothing
had ever assembled them. That gap hid two live bugs — the delegation harness
and the Gitea comment poller were both fully built and reachable from nothing
but their own tests — which is the argument for having a single root rather
than constructing pieces ad hoc.
"""
import threading
from collections.abc import Callable
from dataclasses import dataclass
from nisse_dev.approvals import PlanBoard
from nisse_dev.config import AgentConfig
from nisse_dev.control import Orchestrator
from nisse_dev.delegation import Delegation
from nisse_dev.executor import LLMExecutor
from nisse_dev.expert import ExpertDesk
from nisse_dev.git import GitRepo, authenticated_url
from nisse_dev.gitea import Comment, GiteaClient
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
from nisse_dev.matrix import MatrixTransport
from nisse_dev.oplog import OpLog
from nisse_dev.planner import LLMPlanningAgent
from nisse_dev.poller import CommentPoller
from nisse_dev.publish import MilestonePublisher
from nisse_dev.state import StateStore
from nisse_dev.tools import Workspace
@dataclass
class Agent:
config: AgentConfig
store: StateStore
transport: MatrixTransport
orchestrator: Orchestrator
gitea: GiteaClient
account: str
_stopped: threading.Event
def start(self) -> None:
"""Boot in the order A3 requires: reconcile against external truth
before accepting a single operator command."""
self.orchestrator.start()
self.transport.start()
state = self.store.get_state()
where = state.state if state else "IDLE"
self.transport.send_message(
f"nisse-dev up as {self.account} — project {self.config.project}, "
f"state {where}, planner {self.config.roles.planner}, "
f"executor {self.config.roles.executor}"
)
def wait(self) -> None:
self._stopped.wait()
def stop(self) -> None:
"""Shut down outermost-first: stop accepting work, then stop talking,
then close the state store last so nothing writes to a closed file."""
self.orchestrator.stop_watching()
self.transport.close()
self.gitea.close()
self.store.close()
self._stopped.set()
def prepare_workspace(config: AgentConfig, account: str) -> GitRepo:
"""Ensure the workspace is a clone of the project repo.
Cloning here rather than on `use <project>` is the single-project
shortcut: resolving a project name to a repo is issue #27's work.
"""
repo = GitRepo(config.workspace)
remote = authenticated_url(
config.gitea_url, config.gitea_repo, account, config.gitea_token
)
if not (config.workspace / ".git").exists():
config.workspace.parent.mkdir(parents=True, exist_ok=True)
GitRepo(config.workspace.parent).clone(remote, config.workspace)
repo.configure_identity(account, f"{account}@users.noreply.local")
return repo
def build_agent(
config: AgentConfig, *, notify: Callable[[str], None] | None = None
) -> Agent:
oplog = OpLog("nisse-dev")
store = StateStore(config.state_db, oplog=oplog)
gitea = GiteaClient(config.gitea_url, config.gitea_token)
# Whoami rather than another config variable: it names the identity every
# Gitea permission is evaluated against, and proves the token works before
# anything depends on it.
account = gitea.whoami()
git_repo = prepare_workspace(config, account)
workspace = Workspace(config.workspace)
normalized = LiteLLMClient(config.llm_base_url, config.llm_token)
# Delegations take the passthrough route; fidelity is cheap elsewhere (A5).
passthrough = AnthropicPassthroughClient(config.llm_base_url, config.llm_token)
transport = MatrixTransport(config.matrix)
announce = notify or transport.send_message
expert = ExpertDesk(normalized, store, config.roles)
delegation = Delegation(
passthrough,
store,
workspace,
git_repo,
model=config.roles.resolve("delegate"),
)
executor = LLMExecutor(
normalized,
store,
config.roles,
workspace,
expert=expert,
delegate=delegation,
notify=announce,
)
publisher = MilestonePublisher(
store,
gitea,
config.gitea_repo,
git_repo,
base_url=config.gitea_url,
account=account,
token=config.gitea_token,
base_branch=config.base_branch,
)
def poller_factory(on_comment: Callable[[Comment], None]) -> CommentPoller:
return CommentPoller(
gitea,
config.gitea_repo,
on_comment,
oplog=oplog,
# Its own comments are not new input; without this the agent
# answers itself.
ignore_authors={account},
)
orchestrator = Orchestrator(
store,
transport,
LLMPlanningAgent(normalized, store, config.roles),
operators=config.operators,
roles=config.roles,
executor=executor,
publisher=publisher,
gitea_operators=config.gitea_operators,
board=PlanBoard(gitea, config.gitea_repo),
delegate=delegation,
poller_factory=poller_factory,
)
return Agent(
config=config,
store=store,
transport=transport,
orchestrator=orchestrator,
gitea=gitea,
account=account,
_stopped=threading.Event(),
)
+99
View File
@@ -0,0 +1,99 @@
"""Agent configuration (spec A5: from env/.env, nothing hardcoded).
Every value is collected and validated **before** anything is built, and a
single error names every missing variable at once. The alternative — failing
at first use — surfaces a missing token twenty minutes into a milestone, on
the call that mattered.
"""
import os
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from nisse_dev.llm import RoleMap
from nisse_dev.matrix import MatrixConfig
REQUIRED = (
"NISSE_STATE_DB",
"NISSE_WORKSPACE",
"NISSE_PROJECT",
"NISSE_LLM_BASE_URL",
"NISSE_LLM_TOKEN",
"NISSE_ROLE_EXECUTOR",
"NISSE_ROLE_PLANNER",
"NISSE_ROLE_DELEGATE",
"NISSE_ROLE_CONSULTANT",
"NISSE_MATRIX_HOMESERVER",
"NISSE_MATRIX_USER_ID",
"NISSE_MATRIX_TOKEN",
"NISSE_MATRIX_ROOM_ID",
"NISSE_MATRIX_OPERATORS",
"NISSE_GITEA_URL",
"NISSE_GITEA_TOKEN",
"NISSE_GITEA_REPO",
"NISSE_GITEA_OPERATORS",
)
class ConfigError(RuntimeError):
pass
def _people(raw: str) -> frozenset[str]:
"""Operator allowlists are comma-separated; blanks are dropped so a
trailing comma cannot admit an empty identity."""
return frozenset(part.strip() for part in raw.split(",") if part.strip())
@dataclass(frozen=True)
class AgentConfig:
state_db: Path
workspace: Path
project: str
llm_base_url: str
llm_token: str
roles: RoleMap
matrix: MatrixConfig
operators: frozenset[str]
gitea_url: str
gitea_token: str
gitea_repo: str
gitea_operators: frozenset[str]
base_branch: str = "main"
@classmethod
def from_env(cls, env: Mapping[str, str] = os.environ) -> "AgentConfig":
# Whitespace-only counts as missing: a token of spaces would pass a
# truthiness check and fail at the first request instead.
missing = [name for name in REQUIRED if not (env.get(name) or "").strip()]
if missing:
raise ConfigError(
"cannot start: these are unset or empty — " + ", ".join(missing)
)
return cls(
state_db=Path(env["NISSE_STATE_DB"]).expanduser(),
workspace=Path(env["NISSE_WORKSPACE"]).expanduser(),
project=env["NISSE_PROJECT"],
llm_base_url=env["NISSE_LLM_BASE_URL"],
llm_token=env["NISSE_LLM_TOKEN"],
roles=RoleMap.from_env(env),
matrix=MatrixConfig.from_env(env),
operators=_people(env["NISSE_MATRIX_OPERATORS"]),
gitea_url=env["NISSE_GITEA_URL"],
gitea_token=env["NISSE_GITEA_TOKEN"],
gitea_repo=env["NISSE_GITEA_REPO"],
gitea_operators=_people(env["NISSE_GITEA_OPERATORS"]),
base_branch=env.get("NISSE_BASE_BRANCH", "main"),
)
def redacted(self) -> dict[str, str]:
"""What the agent may safely announce about how it is configured."""
return {
"project": self.project,
"workspace": str(self.workspace),
"repo": self.gitea_repo,
"planner": self.roles.planner,
"executor": self.roles.executor,
"delegate": self.roles.delegate,
}
+34
View File
@@ -164,6 +164,7 @@ class Orchestrator:
gitea_operators: Iterable[str] = (),
board: "PlanBoard | None" = None,
delegate: Any = None,
poller_factory: Any = None,
) -> None:
self._store = store
self._transport = transport
@@ -178,6 +179,11 @@ class Orchestrator:
self._gitea_operators = set(gitea_operators)
self._board = board
self._delegate = delegate
# Builds a poller bound to a comment handler; the orchestrator
# owns *when* to watch, which is a state-machine concern, while
# the factory owns how to build one.
self._poller_factory = poller_factory
self._pollers: dict[str, Any] = {}
# Transport and the comment poller deliver on separate threads;
# state transitions must not interleave.
self._lock = threading.RLock()
@@ -465,6 +471,9 @@ class Orchestrator:
# durability of the record, not the ability to approve.
return
board_state["comment_id"] = post.comment_id
# Approvals can arrive on the issue as well as over transport (A4),
# so watch it for as long as the gate stands.
self._watch("plan", board_state["issue"], self.handle_plan_comment)
def handle_plan_comment(self, comment: Any) -> None:
"""A comment on the project issue may satisfy an open plan gate (A4).
@@ -586,6 +595,8 @@ class Orchestrator:
"url": result.pull_request.url,
"milestone": milestone,
}
self._unwatch("plan") # the gate is behind us; the PR is the thread now
self._watch("review", result.pull_request.number, self.handle_review_comment)
self._transition(PR_OPEN, cause=f"milestone {milestone} published")
self._transport.send_message(
f"{result.note} — review it, then say 'next milestone' or "
@@ -594,12 +605,14 @@ class Orchestrator:
def _handle_pr_open(self, text: str) -> None:
if text == "next milestone":
self._unwatch("review")
self._data["milestone"] = int(self._data.get("milestone", 1)) + 1
self._data["gate"] = None
self._data["pr"] = None
self._transition(MILESTONE_PLANNING, cause="operator: next milestone")
self._run_planning_turn(f"milestone-{self._data['milestone']}")
elif text == "project complete":
self.stop_watching()
self._data = {**_fresh_data()}
self._project = None
self._store.oplog.task_id = None
@@ -614,6 +627,27 @@ class Orchestrator:
"'project complete'"
)
def _watch(self, kind: str, index: int, handler: Any) -> None:
"""Start watching one Gitea thread, replacing any previous watch of
the same kind. Priming first, so attaching does not replay comments
already addressed."""
if self._poller_factory is None:
return
self._unwatch(kind)
poller = self._poller_factory(handler)
poller.prime(index)
poller.start(index)
self._pollers[kind] = poller
def _unwatch(self, kind: str) -> None:
poller = self._pollers.pop(kind, None)
if poller is not None:
poller.stop()
def stop_watching(self) -> None:
for kind in list(self._pollers):
self._unwatch(kind)
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
self._data["pending"] = pending
self._transition(AWAIT_INPUT, cause=cause)
+19 -1
View File
@@ -10,6 +10,7 @@ or finished-enough. Until delegation exists (step 7), an escalation gates to
the human with the failures attached rather than engaging a frontier model.
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Literal
@@ -60,6 +61,7 @@ class LLMExecutor:
failure_limit: int = DEFAULT_FAILURE_LIMIT,
expert: ExpertDesk | None = None,
delegate: Delegation | None = None,
notify: Callable[[str], None] | None = None,
) -> None:
self._client = client
self._store = store
@@ -69,6 +71,21 @@ class LLMExecutor:
self._failure_limit = failure_limit
self._expert = expert
self._delegate = delegate
# The loop runs synchronously inside the transport callback, so
# without this the agent is silent for the whole milestone and a
# working run looks exactly like a hung one.
self._notify = notify
def _report(self, turn: int, calls: list[ToolCall]) -> None:
if self._notify is None:
return
summary = ", ".join(
f"{c.tool}({c.arguments.get('path') or c.arguments.get('command', '')[:40]})"
if c.arguments
else c.tool
for c in calls
)
self._notify(f"turn {turn + 1}: {summary}")
def _escalate(
self, milestone: int, failures: int, last_failure: str
@@ -133,7 +150,7 @@ class LLMExecutor:
did_work = False
challenged = False
for _turn in range(self._max_turns):
for turn in range(self._max_turns):
try:
produced = self._client.complete(
self._store.read_events(),
@@ -166,6 +183,7 @@ class LLMExecutor:
"executor claimed the milestone was done without making any change",
)
self._report(turn, calls)
for call in calls:
if call.tool in MUTATING_TOOLS:
did_work = True
+4
View File
@@ -59,6 +59,10 @@ class GitRepo:
self.root.mkdir(parents=True, exist_ok=True)
self._run("init", "-q", "-b", default_branch)
def clone(self, remote_url: str, dest: Path, *, token: str | None = None) -> None:
self.root.mkdir(parents=True, exist_ok=True)
self._run("clone", "-q", remote_url, str(dest), secret=token)
def configure_identity(self, name: str, email: str) -> None:
self._run("config", "user.name", name)
self._run("config", "user.email", email)
+40 -5
View File
@@ -1,11 +1,46 @@
"""Placeholder entry point for the dev agent."""
"""Entry point for the dev agent.
from nisse_core.greeting import greeting
Loads configuration, builds the agent, and stays up until told to stop.
Shutdown is deliberate rather than abrupt: A2's durability guarantees assume
the process may die at any moment, but a clean stop still beats relying on
them.
"""
import os
import signal
import sys
from pathlib import Path
from types import FrameType
from nisse_dev.agent import build_agent
from nisse_dev.config import AgentConfig, ConfigError
from nisse_dev.envfile import load_env_file
def main() -> None:
print(greeting("nisse-dev"))
def main() -> int:
load_env_file(Path(os.environ.get("NISSE_ENV", ".env")).expanduser())
try:
config = AgentConfig.from_env()
except ConfigError as exc:
# Fail here rather than twenty minutes into a milestone on the one
# call that needed the missing value.
print(f"nisse-dev: {exc}", file=sys.stderr)
return 2
agent = build_agent(config)
def shutdown(_signum: int, _frame: FrameType | None) -> None:
print("nisse-dev: stopping", file=sys.stderr)
agent.stop()
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
agent.start()
print(f"nisse-dev: ready as {agent.account} on {config.project}", file=sys.stderr)
agent.wait()
return 0
if __name__ == "__main__":
main()
raise SystemExit(main())
+179
View File
@@ -0,0 +1,179 @@
"""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, prepare_workspace
from nisse_dev.config import AgentConfig
from nisse_dev.git import GitRepo
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.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()
+91
View File
@@ -0,0 +1,91 @@
from pathlib import Path
import pytest
from nisse_dev.config import AgentConfig, ConfigError
COMPLETE = {
"NISSE_STATE_DB": "~/demo/state.db",
"NISSE_WORKSPACE": "~/demo/work",
"NISSE_PROJECT": "greet",
"NISSE_LLM_BASE_URL": "https://litellm.test",
"NISSE_LLM_TOKEN": "sk-x",
"NISSE_ROLE_EXECUTOR": "claude-haiku-4-5",
"NISSE_ROLE_PLANNER": "claude-opus-5",
"NISSE_ROLE_DELEGATE": "claude-opus-5",
"NISSE_ROLE_CONSULTANT": "claude-haiku-4-5",
"NISSE_MATRIX_HOMESERVER": "https://matrix.test",
"NISSE_MATRIX_USER_ID": "@nisse-dev-testing:test",
"NISSE_MATRIX_TOKEN": "syt_x",
"NISSE_MATRIX_ROOM_ID": "!room:test",
"NISSE_MATRIX_OPERATORS": "@don:test",
"NISSE_GITEA_URL": "https://gitea.test",
"NISSE_GITEA_TOKEN": "gt",
"NISSE_GITEA_REPO": "don/demo",
"NISSE_GITEA_OPERATORS": "don",
}
def test_complete_environment_builds() -> None:
config = AgentConfig.from_env(COMPLETE)
assert config.project == "greet"
assert config.roles.planner == "claude-opus-5"
assert config.matrix.room_id == "!room:test"
assert config.gitea_repo == "don/demo"
assert config.base_branch == "main"
def test_paths_expand_the_home_directory() -> None:
config = AgentConfig.from_env(COMPLETE)
assert config.workspace.is_absolute()
assert "~" not in str(config.state_db)
def test_every_missing_variable_is_reported_at_once() -> None:
# Failing at first use would surface a missing token twenty minutes into
# a milestone, on the call that mattered.
partial = {k: v for k, v in COMPLETE.items() if k != "NISSE_MATRIX_TOKEN"}
partial.pop("NISSE_GITEA_REPO")
with pytest.raises(ConfigError) as excinfo:
AgentConfig.from_env(partial)
message = str(excinfo.value)
assert "NISSE_MATRIX_TOKEN" in message
assert "NISSE_GITEA_REPO" in message
def test_empty_is_treated_as_missing() -> None:
with pytest.raises(ConfigError, match="NISSE_GITEA_TOKEN"):
AgentConfig.from_env({**COMPLETE, "NISSE_GITEA_TOKEN": " "})
def test_operator_lists_are_split_and_trimmed() -> None:
config = AgentConfig.from_env(
{
**COMPLETE,
"NISSE_MATRIX_OPERATORS": " @don:test , @other:test ,",
"NISSE_GITEA_OPERATORS": "don,",
}
)
assert config.operators == {"@don:test", "@other:test"}
# A trailing comma must not admit an empty identity into an allowlist.
assert "" not in config.operators
assert config.gitea_operators == {"don"}
def test_base_branch_is_overridable() -> None:
config = AgentConfig.from_env({**COMPLETE, "NISSE_BASE_BRANCH": "trunk"})
assert config.base_branch == "trunk"
def test_redacted_summary_carries_no_secrets() -> None:
summary = AgentConfig.from_env(COMPLETE).redacted()
flat = " ".join(summary.values())
for secret in ("sk-x", "syt_x", "gt"):
assert secret not in flat
assert summary["project"] == "greet"
def test_state_db_and_workspace_are_separate(tmp_path: Path) -> None:
# A2: build artefacts must not share a volume with agent state.
config = AgentConfig.from_env(COMPLETE)
assert config.state_db.parent != config.workspace
+21 -5
View File
@@ -1,10 +1,26 @@
from pathlib import Path
import pytest
from nisse_dev.config import REQUIRED
from nisse_dev.main import main
def test_main_prints_greeting(capsys: pytest.CaptureFixture[str]) -> None:
main()
assert (
capsys.readouterr().out == "Hello from nisse-dev, the house spirit is awake.\n"
)
def test_main_refuses_to_start_without_configuration(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
# Clear the real variables rather than swapping os.environ: from_env binds
# the environment object as a default argument, so a replacement would be
# ignored. And point NISSE_ENV at a file that does not exist, because the
# working directory's .env holds live credentials.
for name in REQUIRED:
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("NISSE_ENV", str(tmp_path / "absent.env"))
assert main() == 2
err = capsys.readouterr().err
assert "cannot start" in err
assert "NISSE_LLM_TOKEN" in err
+30 -4
View File
@@ -1,5 +1,6 @@
"""Integration tests: run the installed console scripts as real subprocesses."""
import os
import subprocess
import pytest
@@ -7,10 +8,35 @@ import pytest
pytestmark = pytest.mark.integration
@pytest.mark.parametrize("agent", ["nisse-dev", "nisse-ops"])
def test_entry_point_runs_and_greets(agent: str) -> None:
def test_ops_entry_point_runs_and_greets() -> None:
result = subprocess.run(
[agent], capture_output=True, text=True, timeout=30, check=False
["nisse-ops"], capture_output=True, text=True, timeout=30, check=False
)
assert result.returncode == 0
assert result.stdout == f"Hello from {agent}, the house spirit is awake.\n"
assert result.stdout == "Hello from nisse-ops, the house spirit is awake.\n"
def test_dev_entry_point_refuses_to_start_unconfigured() -> None:
"""Missing configuration must fail at startup, naming everything absent.
Run with a deliberately empty environment and a .env path that does not
exist: the real .env in the working directory holds live credentials, and
a test must never be one cwd away from starting a real agent.
"""
result = subprocess.run(
["nisse-dev"],
capture_output=True,
text=True,
timeout=30,
check=False,
env={
"PATH": os.environ["PATH"],
"HOME": os.environ["HOME"],
"NISSE_ENV": "/nonexistent/.env",
},
)
assert result.returncode == 2
assert "cannot start" in result.stderr
# All of them at once, not the first one it happened to reach.
for expected in ("NISSE_STATE_DB", "NISSE_MATRIX_TOKEN", "NISSE_GITEA_REPO"):
assert expected in result.stderr