diff --git a/.env.example b/.env.example index 51a4464..0384a3d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/nisse-dev/src/nisse_dev/agent.py b/nisse-dev/src/nisse_dev/agent.py new file mode 100644 index 0000000..4ffa084 --- /dev/null +++ b/nisse-dev/src/nisse_dev/agent.py @@ -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 ` 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(), + ) diff --git a/nisse-dev/src/nisse_dev/config.py b/nisse-dev/src/nisse_dev/config.py new file mode 100644 index 0000000..452af10 --- /dev/null +++ b/nisse-dev/src/nisse_dev/config.py @@ -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, + } diff --git a/nisse-dev/src/nisse_dev/control.py b/nisse-dev/src/nisse_dev/control.py index ccaa604..8ad0183 100644 --- a/nisse-dev/src/nisse_dev/control.py +++ b/nisse-dev/src/nisse_dev/control.py @@ -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) diff --git a/nisse-dev/src/nisse_dev/executor.py b/nisse-dev/src/nisse_dev/executor.py index e9bcefa..e188fa7 100644 --- a/nisse-dev/src/nisse_dev/executor.py +++ b/nisse-dev/src/nisse_dev/executor.py @@ -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 diff --git a/nisse-dev/src/nisse_dev/git.py b/nisse-dev/src/nisse_dev/git.py index b947d99..5421ab7 100644 --- a/nisse-dev/src/nisse_dev/git.py +++ b/nisse-dev/src/nisse_dev/git.py @@ -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) diff --git a/nisse-dev/src/nisse_dev/main.py b/nisse-dev/src/nisse_dev/main.py index ead36c0..da9971f 100644 --- a/nisse-dev/src/nisse_dev/main.py +++ b/nisse-dev/src/nisse_dev/main.py @@ -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()) diff --git a/nisse-dev/tests/test_agent_build.py b/nisse-dev/tests/test_agent_build.py new file mode 100644 index 0000000..9deb144 --- /dev/null +++ b/nisse-dev/tests/test_agent_build.py @@ -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() diff --git a/nisse-dev/tests/test_config.py b/nisse-dev/tests/test_config.py new file mode 100644 index 0000000..57f91e5 --- /dev/null +++ b/nisse-dev/tests/test_config.py @@ -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 diff --git a/nisse-dev/tests/test_dev_main.py b/nisse-dev/tests/test_dev_main.py index 1d4c6a9..2304939 100644 --- a/nisse-dev/tests/test_dev_main.py +++ b/nisse-dev/tests/test_dev_main.py @@ -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 diff --git a/tests/integration/test_entry_points.py b/tests/integration/test_entry_points.py index e73db77..dc6d951 100644 --- a/tests/integration/test_entry_points.py +++ b/tests/integration/test_entry_points.py @@ -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