Add the composition root and a runnable entry point #31
No files matched your search
@@ -34,3 +34,26 @@ 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.
|
||||
#
|
||||
# Layout. NISSE_WORKSPACE is the *clone the agent works in*, not the directory
|
||||
# the agent runs from — they must not be the same place:
|
||||
#
|
||||
# ~/nisse-demo-workspace/ deployment root, not a git repo
|
||||
# ├── .env this file
|
||||
# ├── state.db the A2 state store
|
||||
# └── project/ NISSE_WORKSPACE, the clone
|
||||
#
|
||||
# The agent commits its workspace with `git add -A` and pushes it, so anything
|
||||
# inside the clone reaches the project repository. A .env or state store placed
|
||||
# there would publish credentials as a side effect of the agent working
|
||||
# correctly; startup refuses to proceed if either is inside the workspace.
|
||||
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
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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)
|
||||
)
|
||||
config = 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"),
|
||||
)
|
||||
config._refuse_secrets_inside_the_clone(env)
|
||||
return config
|
||||
|
||||
def _refuse_secrets_inside_the_clone(self, env: Mapping[str, str]) -> None:
|
||||
"""The workspace is a git clone the agent commits with ``git add -A``
|
||||
and pushes. Anything inside it reaches the project repository, so a
|
||||
state store or an env file placed there would publish credentials as a
|
||||
side effect of the agent working correctly — silently, and once.
|
||||
"""
|
||||
workspace = self.workspace.expanduser().resolve()
|
||||
offenders = []
|
||||
for label, path in (
|
||||
("NISSE_STATE_DB", self.state_db),
|
||||
("NISSE_ENV", Path(env.get("NISSE_ENV", ".env"))),
|
||||
):
|
||||
resolved = path.expanduser().resolve()
|
||||
if resolved == workspace or workspace in resolved.parents:
|
||||
offenders.append(f"{label} ({resolved})")
|
||||
if offenders:
|
||||
raise ConfigError(
|
||||
"refusing to start: these sit inside the workspace clone at "
|
||||
f"{workspace} and would be committed and pushed to the project "
|
||||
"repository — " + ", ".join(offenders)
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -0,0 +1,132 @@
|
||||
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
|
||||
|
||||
|
||||
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"):
|
||||
AgentConfig.from_env(
|
||||
{
|
||||
**COMPLETE,
|
||||
"NISSE_WORKSPACE": str(work),
|
||||
"NISSE_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_env(
|
||||
{
|
||||
**COMPLETE,
|
||||
"NISSE_WORKSPACE": str(work),
|
||||
"NISSE_STATE_DB": str(tmp_path / "state.db"),
|
||||
"NISSE_ENV": str(work / ".env"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_sibling_paths_are_fine(tmp_path: Path) -> None:
|
||||
config = AgentConfig.from_env(
|
||||
{
|
||||
**COMPLETE,
|
||||
"NISSE_WORKSPACE": str(tmp_path / "project"),
|
||||
"NISSE_STATE_DB": str(tmp_path / "state.db"),
|
||||
"NISSE_ENV": str(tmp_path / ".env"),
|
||||
}
|
||||
)
|
||||
assert config.workspace.name == "project"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user