Files
nisse/nisse-dev/tests/test_loop_trust.py
T
dongarandClaude Opus 5.5 154a0c3fa8
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m42s
CI / e2e (pull_request) Successful in 4m0s
M7.1: extract nisse-core
What isn't the dev agent's moves to nisse-core, unchanged in behaviour,
with its tests: canonical, render, render_anthropic, llm, catalog, probe,
context, views, state, reconcile, worker, cancel, dump, transport, matrix,
execution, testing, vault, memory, oplog, envfile and gitea (whole).

- nisse_core.state.StateStore holds the tables every agent has; an
  agent's subclass adds its tables (version, migrations), roles and
  views. nisse_dev.state.StateStore adds the build VM blocks (v5).
- views.project takes what each role is shown, rather than a fixed table.
- Vault takes the agent's name (agent=), for provenance.
- A test that nisse_core never imports an agent.
- The loop, config, git, delegation, expert, tools and evals stay in
  nisse-dev until nisse-ops has a loop to share.

Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on
the agent host and Terraform; Architecture Part L names the package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-13 18:37:01 +00:00

188 lines
6.1 KiB
Python

"""The trust dial (L3, D5; M3.3): a project set to `merge: approved` has its
PRs merged by the harness once an operator approved the PR's current commit
and its checks pass. Gitea enforces the approval (protection); the agent
checks at start that it does, and otherwise leaves merging to the operator."""
import io
from typing import Any
from test_loop_milestones import (
DON,
FINISH,
RESTATE,
WORK,
Forge,
ScriptedModel,
call,
say,
)
from test_loop_milestones import item as the_item
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_core.cancel import Cancel
from nisse_core.oplog import OpLog
from nisse_core.transport import InMemoryTransport
from nisse_core.worker import InlineWorker
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import MERGE, loop_of
from nisse_dev.tools import Workspace
CLOSE = [call("close_item", summary="added and merged")]
def agent_for(
world: dict[str, Any], model: ScriptedModel, merge: str = "approved"
) -> tuple[LoopAgent, InMemoryTransport]:
transport = InMemoryTransport()
cancel = Cancel()
project = Project(
"sandbox",
"don/sandbox",
Workspace(world["host"], world["repo"].root, timeout_s=30),
world["milestones"],
merge=merge,
)
loop = LoopAgent(
world["store"],
transport,
world["forge"],
projects=[project],
account="nisse-dev",
operators={DON},
gitea_operators={"don"},
driver=Driver(model, "m", cancel=cancel), # type: ignore[arg-type]
worker=InlineWorker(),
cancel=cancel,
oplog=OpLog("nisse-dev", io.StringIO()),
)
transport.on_message(loop.on_message)
return loop, transport
def to_review(world: dict[str, Any], merge: str = "approved", *more: Any) -> Any:
"""An item whose PR is up, waiting for the merge."""
world["forge"].protect()
model = ScriptedModel(RESTATE, [*WORK, *FINISH], *more)
agent, transport = agent_for(world, model, merge)
agent.boot()
agent.intake()
say(transport, world, "go")
item = the_item(world)
assert loop_of(item).wait == MERGE, (item.position, loop_of(item), said(transport))
return agent, transport
def said(transport: InMemoryTransport) -> list[str]:
return [m.text for m in transport.outbound]
def test_an_approved_pr_is_merged_and_the_item_goes_on(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
agent, transport = to_review(world, "approved", CLOSE)
pr = loop_of(the_item(world)).pr
agent.watch()
assert forge.agent_merged == [] # not approved yet
forge.approve(pr)
agent.watch()
agent._schedule()
assert forge.agent_merged == [pr]
assert f"Merged PR #{pr} after don's approval." in said(transport)
assert the_item(world).status == "closed"
def test_the_operator_merges_by_default(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
agent, _ = to_review(world, "operator")
pr = loop_of(the_item(world)).pr
forge.approve(pr)
agent.watch()
assert forge.agent_merged == []
assert loop_of(the_item(world)).wait == MERGE
def test_an_approval_of_an_older_commit_doesnt_count(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
agent, _ = to_review(world)
pr = loop_of(the_item(world)).pr
forge.approve(pr, sha="0" * 40) # what the PR was before a fix
agent.watch()
assert forge.agent_merged == []
def test_only_an_operators_approval_counts(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
agent, _ = to_review(world)
pr = loop_of(the_item(world)).pr
forge.approve(pr, by="mallory")
agent.watch()
assert forge.agent_merged == []
def test_pending_checks_wait_and_failed_ones_are_said_once(
world: dict[str, Any],
) -> None:
forge: Forge = world["forge"]
agent, transport = to_review(world)
pr = loop_of(the_item(world)).pr
forge.approve(pr)
forge.status[forge.head_sha(pr)] = "pending"
agent.watch()
assert forge.agent_merged == []
forge.status[forge.head_sha(pr)] = "failure"
agent.watch()
agent.watch()
held = [t for t in said(transport) if "its checks failed" in t]
assert len(held) == 1 and forge.agent_merged == []
forge.status[forge.head_sha(pr)] = "success"
agent.watch()
assert forge.agent_merged == [pr]
def test_protection_is_checked_at_start(world: dict[str, Any]) -> None:
forge: Forge = world["forge"]
forge.protect()
agent, transport = agent_for(world, ScriptedModel())
agent.boot()
assert agent._merging == {"sandbox": True}
# The check's branch and PR are gone again.
assert all(p["state"] == "closed" for p in forge.prs.values())
assert forge.agent_merged == []
assert not [t for t in said(transport) if "leave merging to you" in t]
def test_unprotected_means_the_operator_merges(world: dict[str, Any]) -> None:
forge: Forge = world["forge"] # not protected: the agent could merge anything
model = ScriptedModel(RESTATE, [*WORK, *FINISH])
agent, transport = agent_for(world, model)
agent.boot()
assert agent._merging == {"sandbox": False}
assert any("I could merge my own PR" in t for t in said(transport))
agent.intake()
say(transport, world, "go")
pr = loop_of(the_item(world)).pr
forge.approve(pr)
agent.watch()
assert pr not in forge.agent_merged # left to the operator
def test_a_refused_merge_is_said_once_and_left_to_the_operator(
world: dict[str, Any],
) -> None:
from nisse_core.gitea import GiteaError
forge: Forge = world["forge"]
agent, transport = to_review(world)
pr = loop_of(the_item(world)).pr
forge.approve(pr)
def refuse(repo: str, index: int) -> None:
raise GiteaError("gitea POST merge returned 405: not mergeable")
forge.merge_pull_request = refuse # type: ignore[method-assign]
agent.watch()
agent.watch()
refused = [t for t in said(transport) if "Gitea refused my merge" in t]
assert len(refused) == 1
assert loop_of(the_item(world)).wait == MERGE