Files
nisse/nisse-dev/tests/test_loop_projects.py
T
dongarandClaude Opus 5.5 e351d0a74e
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m37s
CI / e2e (pull_request) Successful in 4m5s
nisse-core: group modules into packages by architecture section
A pure move; behaviour unchanged.

- models (L9, L7): llm, catalog, probe, render, render_anthropic, context
- log (L7, L6): canonical, views
- state (L8): store (was state), reconcile, worker, cancel, dump
- knowledge (L10, L6.4): vault, memory
- conversation (L5): transport, matrix
- execution (L13): ssh (was execution), testing
- authority (P3, P4, L4): actions
- gitea, oplog, envfile stay at the top until each gains a second module.

Every import, monkeypatch target, script and doc reference rewritten.

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

189 lines
7.0 KiB
Python

"""Several projects on the new loop (M1.4, D8): intake from each project's
repository, each item working in its own project's workspace and publishing
to its own repository, issue numbers told apart, label defaults, and parking
across projects. Real git on the throwaway build host; fake Gitea per repo."""
import io
from pathlib import Path
from typing import Any
import pytest
from test_loop_milestones import DON, Forge, ScriptedModel, call, git, make_world
from nisse_core.conversation.transport import InMemoryTransport
from nisse_core.execution.ssh import SSHHost
from nisse_core.oplog import OpLog
from nisse_core.state.cancel import Cancel
from nisse_core.state.worker import InlineWorker
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, item_for_issue, loop_of
from nisse_dev.tools import Workspace
REPOS = {"alpha": "don/alpha", "beta": "don/beta"}
class Router:
"""One Gitea for the agent, routing each call to its repository's fake."""
def __init__(self, forges: dict[str, Forge]) -> None:
self.forges = forges
def __getattr__(self, name: str) -> Any:
def call(repo: str, *args: Any, **kwargs: Any) -> Any:
return getattr(self.forges[repo], name)(repo, *args, **kwargs)
return call
@pytest.fixture
def worlds(ssh_host: SSHHost, tmp_path: Path) -> dict[str, dict[str, Any]]:
alpha = make_world(ssh_host, tmp_path / "alpha", repo_name=REPOS["alpha"])
beta = make_world(
ssh_host, tmp_path / "beta", store=alpha["store"], repo_name=REPOS["beta"]
)
return {"alpha": alpha, "beta": beta}
def agent_for(
worlds: dict[str, dict[str, Any]],
model: ScriptedModel,
labels: dict[str, tuple[str, ...]] | None = None,
) -> tuple[LoopAgent, InMemoryTransport]:
transport = InMemoryTransport()
cancel = Cancel()
router = Router({REPOS[n]: w["forge"] for n, w in worlds.items()})
projects = [
Project(
name=n,
repo=REPOS[n],
workspace=Workspace(w["host"], w["repo"].root, timeout_s=30),
milestones=w["milestones"],
labels=(labels or {}).get(n, ()),
)
for n, w in worlds.items()
]
for w in worlds.values():
w["milestones"]._gitea = router # publishing goes through the router too
agent = LoopAgent(
worlds["alpha"]["store"],
transport,
router, # type: ignore[arg-type]
projects=projects,
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(agent.on_message)
return agent, transport
def item(worlds: dict[str, Any], project: str) -> Any:
found = item_for_issue(worlds["alpha"]["store"], 14, project)
assert found is not None
return found
def say(transport: InMemoryTransport, worlds: Any, project: str, text: str) -> None:
transport.receive(DON, text, thread=loop_of(item(worlds, project)).thread)
def restate(name: str) -> list[Any]:
return [call("restate", text=f"{name}: add count_words")]
def work(path: str) -> list[Any]:
return [
call("start_milestone", name=path),
call("write_file", path=path, content=f"# {path}\n"),
call("finish_milestone", summary=path, final="yes"),
]
def test_both_projects_issues_are_taken_and_told_apart(worlds: Any) -> None:
# Both forges have an issue #14.
agent, transport = agent_for(worlds, ScriptedModel(restate("a"), restate("b")))
agent.intake()
a, b = item(worlds, "alpha"), item(worlds, "beta")
assert (a.scope, b.scope) == ("alpha", "beta")
said = [m.text for m in transport.outbound]
assert any(t.startswith("Queued don/alpha#14") for t in said)
assert any(t.startswith("Queued don/beta#14") for t in said)
status = agent.status()
assert "alpha#14 Add count_words" in status and "beta#14 Add count_words" in status
def test_each_item_works_and_publishes_in_its_own_project(worlds: Any) -> None:
model = ScriptedModel(restate("a"), restate("b"), work("a.py"), work("b.py"))
agent, transport = agent_for(worlds, model)
agent.intake()
say(transport, worlds, "alpha", "go")
say(transport, worlds, "beta", "go")
fa, fb = worlds["alpha"]["forge"], worlds["beta"]["forge"]
[pa], [pb] = fa.prs, fb.prs
assert fa.files(pa) == ["a.py"] and fb.files(pb) == ["b.py"]
assert fa.prs[pa]["head"].startswith(item(worlds, "alpha").id)
assert fb.prs[pb]["head"].startswith(item(worlds, "beta").id)
# Each workspace has only its own project's work.
assert not (worlds["alpha"]["repo"].root / "b.py").exists()
assert loop_of(item(worlds, "beta")).wait == MERGE
def test_a_bare_number_in_two_projects_is_asked_about(worlds: Any) -> None:
model = ScriptedModel(restate("a"), restate("b"), work("b.py"))
agent, transport = agent_for(worlds, model)
agent.intake()
transport.receive(DON, "go #14")
asked = transport.outbound[-1].text
assert "open in more than one project" in asked
assert "go alpha#14" in asked and "go beta#14" in asked
assert not loop_of(item(worlds, "alpha")).started
transport.receive(DON, "go beta#14")
assert loop_of(item(worlds, "beta")).started
assert not loop_of(item(worlds, "alpha")).started
def test_do_next_names_a_project(worlds: Any) -> None:
agent, transport = agent_for(worlds, ScriptedModel(restate("a"), restate("b")))
agent.intake()
transport.receive(DON, "do #14 next")
assert "do alpha#14 next or do beta#14 next" in transport.outbound[-1].text
transport.receive(DON, "do beta#14 next")
assert "beta#14 goes first" in transport.outbound[-1].text
def test_a_projects_label_defaults_steer_its_items(worlds: Any) -> None:
model = ScriptedModel(
[call("ask_human", question="what's it for?")], # alpha: discuss first
restate("b"),
)
agent, _ = agent_for(worlds, model, labels={"alpha": ("discuss",)})
agent.intake()
assert loop_of(item(worlds, "alpha")).labels == ("discuss",)
assert loop_of(item(worlds, "alpha")).wait == ANSWER
assert loop_of(item(worlds, "beta")).wait == GO_AHEAD
def test_switching_projects_parks_the_other_projects_work(worlds: Any) -> None:
model = ScriptedModel(
restate("a"),
restate("b"),
[ # alpha: half done, then a question
call("start_milestone", name="a"),
call("write_file", path="half.py", content="H = 1\n"),
call("ask_human", question="one more thing?"),
],
work("b.py"), # beta runs meanwhile
)
agent, transport = agent_for(worlds, model)
agent.intake()
say(transport, worlds, "alpha", "go")
say(transport, worlds, "beta", "go")
bare = ["--git-dir", str(worlds["alpha"]["bare"])]
parked = f"refs/heads/{item(worlds, 'alpha').id}-parked"
assert git(*bare, "show", f"{parked}^3:half.py") == "H = 1"