Files
nisse/tests/integration/test_end_to_end_loop.py
T
dongarandClaude Opus 5.5 6ae2375194
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m16s
CI / e2e (pull_request) Successful in 10m51s
M2.8: switch over to the new loop
nisse-dev now runs only the conversational loop. The gated state machine
it was built alongside is gone:

- Removed control.py, planner.py, executor.py, approvals.py, gates.py
  (command_form moves to loop/reader.py), publish.py and poller.py, the
  old assembly in agent.py (its shared helpers move to loop/build.py),
  the store's state-machine view (get_state, transition), their tests,
  the old end-to-end and live planning/milestone/gate tests, and
  specs/orchestrator.qnt with its scenarios.
- Roles are driver, helper, expert and classifier; driver is required.
  loop:, roles.planner, roles.executor and tuning.executor are refused
  with what to use instead; tuning.driver takes the turn cap and failure
  limit.
- The benchmark and the capability matrix measure the driver: the
  loop's own system prompt and tools, the task's plan as the agreed
  restatement. Task plans end with finish_milestone, so earlier runs
  show as (old); stored runs' executor tuning reads as the driver's.
- A delegation on the loop records what its helper's alias resolved to
  (L9), as the old path's did.
- The dump shows a work item's revision and what it waits for.
- Behaviour the old tests covered and the loop shares stays covered:
  driver runs that fail on a model call, stop mid-turn, reset the
  failure count; go-aheads ignoring case and punctuation; a request too
  big to fit is never sent; the assembly tests, now on build_loop_agent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 03:12:58 +00:00

680 lines
27 KiB
Python

"""The end-to-end tests on the new loop (Development Plan M2.1, M2.2): the
whole slice, from an issue assigned in Gitea to a merged PR and a closed work
item, and the queue, with an issue blocked on another until it closes; with
real models, the Gitea sandbox and a real build host.
A scripted operator plays the operator's part: in Gitea through its own test
account (NISSE_OPERATOR_TOKEN; an operator only in nisse-test.yaml), and in
the item's Matrix thread through the in-memory transport. Run with
scripts/e2e.sh after each milestone.
"""
import base64
import contextlib
import os
import re
import time
import uuid
from collections.abc import Iterator
from dataclasses import replace
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
import pytest
from e2e_support import (
build_host,
dependent_task,
e2e_roles,
fresh_repo,
operator_gitea,
task,
)
from nisse_dev.config import AgentConfig, LoopTuning
from nisse_dev.execution import SSHHost
from nisse_dev.gitea import GiteaClient, GiteaError
from nisse_dev.loop.build import RunningLoop, build_loop_agent
from nisse_dev.loop.items import (
ANSWER,
BLOCKED,
GO_AHEAD,
MERGE,
WAITING,
item_for_issue,
loop_of,
)
from nisse_dev.transport import InMemoryTransport
pytestmark = [pytest.mark.integration, pytest.mark.e2e]
OPERATOR = "@dongar:dongar.net" # an allowlisted Matrix operator in nisse-test.yaml
DEADLINE_S = 1200
MAX_ANSWERS = 5 # per wait for something else, before giving up
BUG_GRACE_S = 20
@pytest.fixture
def running(
live_config: AgentConfig,
ssh_host: SSHHost,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[tuple[RunningLoop, InMemoryTransport, str]]:
run = uuid.uuid4().hex[:8]
stack = contextlib.ExitStack()
# Kept outside pytest's temp directories, which pytest prunes, so a
# failed run's transcript can be read afterwards (nisse_dev.dump).
keep = Path(os.environ.get("NISSE_E2E_RUNS", "/tmp/nisse-e2e-runs")) / run
keep.mkdir(parents=True, exist_ok=True)
print(f"\ne2e run {run}: state store in {keep}")
operator = operator_gitea(live_config.gitea_url)
account = GiteaClient(live_config.gitea_url, live_config.gitea_token).whoami()
fresh = stack.enter_context(fresh_repo(operator, run, keep / "keys", agent=account))
[project] = live_config.projects
project = replace(
project,
repo=fresh.name,
base_branch="main",
push_key=fresh.push_key,
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
build_host=build_host(ssh_host),
)
config = replace(
live_config,
projects=(project,),
roles=e2e_roles(),
state_db=keep / "state.db",
# Short, so a bug's grace period runs out within the test.
tuning=replace(live_config.tuning, loop=LoopTuning(bug_grace_s=BUG_GRACE_S)),
)
transport = InMemoryTransport()
monkeypatch.setattr("nisse_dev.loop.build.MatrixTransport", lambda cfg: transport)
agent = None
try:
# What the agent reads when told a new project is ready: replaced by
# a test that adds one (D8).
source = [config]
agent = build_loop_agent(config, load=lambda: source[0])
agent.source = source # type: ignore[attr-defined]
# The test drives intake and PR watching itself, not on a timer.
agent.loop._interval = 3600
agent.start()
yield agent, transport, run
finally:
if agent is not None:
agent.stop()
host = SSHHost(
project.build_host.address,
key=project.build_host.key,
user=project.build_host.user,
port=project.build_host.port,
state_dir=tmp_path / "ssh",
)
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
stack.close() # the run's repository
def is_artifact(path: str) -> bool:
parts = path.split("/")
return (
"__pycache__" in parts
or ".pytest_cache" in parts
or fnmatch(parts[-1], "*.py[co]")
)
def merge(gitea: GiteaClient, repo: str, pr: int, wait_s: float = 60) -> None:
"""Approve and merge, waiting out Gitea's "Please try again later": it
answers that while it is still checking a freshly pushed PR can merge."""
with contextlib.suppress(GiteaError): # approved already
gitea._request(
"POST",
f"/repos/{repo}/pulls/{pr}/reviews",
json={"event": "APPROVED", "body": "looks good"},
)
deadline = time.monotonic() + wait_s
while True:
try:
gitea._request(
"POST", f"/repos/{repo}/pulls/{pr}/merge", json={"Do": "merge"}
)
return
except GiteaError as exc:
if "try again later" not in str(exc) or time.monotonic() > deadline:
raise
time.sleep(3)
class Operator:
def __init__(self, agent: RunningLoop, transport: InMemoryTransport) -> None:
self.agent = agent
self.transport = transport
def settle(self) -> None:
self.agent.worker.call(lambda: None, timeout=DEADLINE_S)
def tick(self) -> None:
"""Intake and PR watching, as the agent's timer would do."""
self.agent.worker.call(self.agent.loop.intake, timeout=DEADLINE_S)
project: str | None = None # whose issues ``issue`` numbers, if not the only
def item(self, issue: int = 0) -> Any:
"""The work item for ``issue``; by default the first one."""
if issue:
store, project = self.agent.store, self.project
return self.agent.worker.call(lambda: item_for_issue(store, issue, project))
items = self.agent.worker.call(self.agent.store.work_items)
return items[0] if items else None
def say(self, text: str, issue: int = 0) -> None:
thread = loop_of(self.item(issue)).thread
self.transport.receive(OPERATOR, text, thread=thread)
self.settle()
def drive_until(self, wait: str, issue: int = 0, *, go: bool = True) -> Any:
"""Answer whatever the agent asks until it waits for ``wait``.
Without ``go``, a restatement is left for its grace period."""
deadline = time.monotonic() + DEADLINE_S
answers = 0
while time.monotonic() < deadline:
item = self.item(issue)
assert item is not None, "no work item"
loop = loop_of(item)
if item.position == WAITING and loop.wait == wait:
return item
if item.status == "closed":
raise AssertionError(f"closed early: {self.last()}")
if wait == GO_AHEAD and loop.started:
raise AssertionError(f"went past the go-ahead: {self.last()}")
if item.position == WAITING and loop.wait == GO_AHEAD and not go:
assert loop.grace_until, f"no grace period: {self.last()}"
time.sleep(2)
self.tick() # what the agent's timer would do
elif item.position == WAITING and loop.wait == GO_AHEAD:
self.say("go", issue)
elif item.position == WAITING and loop.wait == ANSWER:
answers += 1
# Answering forever is how a stuck agent looks: give up.
assert answers <= MAX_ANSWERS, f"stuck asking: {self.last()}"
self.say("Use your best judgement and proceed.", issue)
else:
self.settle()
raise AssertionError(f"never waited for {wait}: {self.last()}")
def merge_and_close(self, issue: int) -> None:
"""Approve and merge the item's PR, and see the agent close the item."""
gitea, repo = self.agent.gitea, self.agent.config.projects[0].repo
as_operator = operator_gitea(self.agent.config.gitea_url)
pr = loop_of(self.item(issue)).pr
with contextlib.suppress(GiteaError): # approval may not be required
as_operator._request(
"POST",
f"/repos/{repo}/pulls/{pr}/reviews",
json={"event": "APPROVED", "body": "looks good"},
)
merge(as_operator, repo, pr)
self.tick()
deadline = time.monotonic() + DEADLINE_S
nudges = 0
while self.item(issue).status != "closed" and time.monotonic() < deadline:
if loop_of(self.item(issue)).wait == ANSWER:
nudges += 1
assert nudges <= MAX_ANSWERS, f"never closed: {self.last()}"
self.say("It's merged; please close the item.", issue)
self.settle()
assert self.item(issue).outcome == "completed"
state = gitea._request("GET", f"/repos/{repo}/issues/{issue}")["state"]
assert state == "closed"
def tidy(self, issues: list[int], prs: list[int]) -> None:
"""Leave the sandbox tidy: whatever is still open gets closed."""
gitea, repo = self.agent.gitea, self.agent.config.projects[0].repo
as_operator = operator_gitea(self.agent.config.gitea_url)
for issue in issues:
item = self.item(issue)
if item is not None and item.status == "open":
self.say("abandon", issue)
with contextlib.suppress(GiteaError):
as_operator.close_issue(repo, issue)
for pr in prs:
with contextlib.suppress(GiteaError):
head = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["ref"]
as_operator._request("DELETE", f"/repos/{repo}/branches/{head}")
def last(self) -> str:
return self.transport.outbound[-1].text if self.transport.outbound else ""
@pytest.mark.smoke
def test_the_slice_end_to_end(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
agent, transport, run = running
operator = Operator(agent, transport)
gitea, repo = agent.gitea, agent.config.projects[0].repo
as_operator = operator_gitea(agent.config.gitea_url)
issue = None
pr = 0
try:
# --- the operator files an issue and assigns it to the agent (D1) ---
issue = as_operator.create_issue(repo, f"vowels_{run}", task(run))
as_operator._request(
"PATCH",
f"/repos/{repo}/issues/{issue}",
json={"assignees": [agent.account]},
)
operator.tick()
item = operator.item()
assert item is not None and loop_of(item).issue == issue
# --- restatement, go-ahead, milestone, PR (L3, D4) ---
item = operator.drive_until(MERGE)
pr = loop_of(item).pr
files = gitea._request("GET", f"/repos/{repo}/pulls/{pr}/files")
changed = [f["filename"] for f in files]
assert changed and not [p for p in changed if is_artifact(p)], changed
body = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["body"]
assert f"Closes #{issue}" in body
# --- a review comment is answered with a fix (D5) ---
# A request that can't already be met, so a fix must follow.
marker = f"reviewed-{uuid.uuid4().hex[:8]}"
before = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["sha"]
as_operator.post_comment(
repo, pr, f"Please add the comment line `# {marker}` above vowels_{run}."
)
operator.tick()
operator.drive_until(MERGE)
after = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["sha"]
assert after != before, "no fix was pushed for the review comment"
diff = gitea._http.get(f"/repos/{repo}/pulls/{pr}.diff").text
assert marker in diff, "the pushed fix doesn't do what the comment asked"
# --- the operator approves and merges; the agent closes the item ---
operator.merge_and_close(issue)
finally:
operator.tidy([issue] if issue is not None else [], [pr] if pr else [])
def test_an_issue_blocked_on_another_waits_for_it(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.2: B depends on A in Gitea. B is blocked until A's PR merges and A
closes; then B starts from a workspace that has A's work."""
agent, transport, run = running
operator = Operator(agent, transport)
gitea, repo = agent.gitea, agent.config.projects[0].repo
as_operator = operator_gitea(agent.config.gitea_url)
owner, name = repo.split("/")
issues: list[int] = []
prs: list[int] = []
try:
a = as_operator.create_issue(repo, f"vowels_{run}", task(run))
issues.append(a)
b = as_operator.create_issue(repo, f"consonants_{run}", dependent_task(run))
issues.append(b)
as_operator._request(
"POST",
f"/repos/{repo}/issues/{b}/dependencies",
json={"owner": owner, "repo": name, "index": a},
)
for n in (b, a): # B first: the dependency, not the order, decides
as_operator._request(
"PATCH",
f"/repos/{repo}/issues/{n}",
json={"assignees": [agent.account]},
)
operator.tick()
assert operator.item(b).position == BLOCKED
assert loop_of(operator.item(b)).blocked_by == (f"{repo}#{a}",)
# --- A runs to a merged PR; B stays blocked meanwhile ---
item = operator.drive_until(MERGE, a)
prs.append(loop_of(item).pr)
assert operator.item(b).position == BLOCKED
operator.merge_and_close(a)
# --- A closed: B is unblocked and starts from A's work ---
operator.tick()
assert operator.item(b).position != BLOCKED
item = operator.drive_until(MERGE, b)
prs.append(loop_of(item).pr)
files = gitea._request("GET", f"/repos/{repo}/pulls/{prs[-1]}/files")
diff = gitea._http.get(f"/repos/{repo}/pulls/{prs[-1]}.diff").text
assert f"consonants_{run}" in diff, [f["filename"] for f in files]
# Built on A's merged work, so A's function isn't added again.
assert f"\n+def vowels_{run}" not in diff
finally:
operator.tidy(issues, prs)
def label(gitea: GiteaClient, repo: str, name: str) -> int:
"""The repository's label ``name``, made if it's missing."""
for existing in gitea._request("GET", f"/repos/{repo}/labels"):
if existing["name"] == name:
return int(existing["id"])
made = gitea._request(
"POST", f"/repos/{repo}/labels", json={"name": name, "color": "#ee0701"}
)
return int(made["id"])
def test_a_bug_goes_ahead_after_its_grace_period(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.3: a `bug` issue is reproduced and restated, and work starts when
the grace period runs out, with no go from the operator."""
agent, transport, run = running
operator = Operator(agent, transport)
repo, base = agent.config.projects[0].repo, agent.config.projects[0].base_branch
as_operator = operator_gitea(agent.config.gitea_url)
issues: list[int] = []
prs: list[int] = []
try:
# A real bug on this run's base branch, for the agent to reproduce.
code = (
f"def last_word_{run}(text):\n"
' """The last word of text, or "" if it has none."""\n'
" return text.split()[-1]\n"
)
as_operator._request(
"POST",
f"/repos/{repo}/contents/sandbox/last_word_{run}.py",
json={
"branch": base,
"content": base64.b64encode(code.encode()).decode(),
"message": f"last_word_{run}, with a bug",
},
)
data = as_operator._request(
"POST",
f"/repos/{repo}/issues",
json={
"title": f"last_word_{run} crashes on empty text",
"body": f"`last_word_{run}('')` in sandbox/last_word_{run}.py "
'raises IndexError; it should return "" as its docstring says.',
"labels": [label(as_operator, repo, "bug")],
"assignees": [agent.account],
},
)
issues.append(int(data["number"]))
operator.tick()
assert loop_of(operator.item(issues[0])).labels == ("bug",)
item = operator.drive_until(MERGE, issues[0], go=False)
prs.append(loop_of(item).pr)
orchestrator = agent.worker.call(
lambda: agent.store.transcript(item.id, "orchestrator").read()
)
said = [getattr(e.event, "text", "") for e in orchestrator]
assert any(t.endswith("from the bug grace period") for t in said), said
finally:
operator.tidy(issues, prs)
def test_a_plain_yes_is_a_go_ahead(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.3: "yep, that's right" to a restatement is read as a go-ahead,
and the agent says so before acting."""
agent, transport, run = running
operator = Operator(agent, transport)
repo = agent.config.projects[0].repo
as_operator = operator_gitea(agent.config.gitea_url)
issues: list[int] = []
prs: list[int] = []
try:
issues.append(as_operator.create_issue(repo, f"vowels_{run}", task(run)))
as_operator._request(
"PATCH",
f"/repos/{repo}/issues/{issues[0]}",
json={"assignees": [agent.account]},
)
operator.tick()
for _ in range(MAX_ANSWERS):
operator.drive_until(GO_AHEAD, issues[0])
if "?" not in operator.last():
break
# A restatement with a question in it can't be agreed to as it
# stands: answer first (the reader rightly says no to a yes).
operator.say(
f"Not yet: a new function vowels_{run}, not reusing anything "
"that exists. Put up a new restatement with that.",
issues[0],
)
operator.say("Yep, that's right, sounds good.", issues[0])
said = [m.text for m in transport.outbound]
assert any(t.startswith("Taking that as a go-ahead on rev-") for t in said)
assert loop_of(operator.item(issues[0])).started
pr = loop_of(operator.item(issues[0])).pr
if pr:
prs.append(pr)
finally:
operator.tidy(issues, prs)
def test_a_two_milestone_feature(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.4: work in two milestones. The first PR carries the build plan and
is part of the issue; the second starts from the merged first and closes
it; the item closes after both merge."""
agent, transport, run = running
operator = Operator(agent, transport)
gitea, repo = agent.gitea, agent.config.projects[0].repo
as_operator = operator_gitea(agent.config.gitea_url)
issues: list[int] = []
prs: list[int] = []
try:
body = (
f"{task(run)}\n\nThen, separately, {dependent_task(run)[0].lower()}"
f"{dependent_task(run)[1:]}\n\nDo this in two milestones, one PR each: "
f"vowels_{run} first, then consonants_{run} after the first has merged. "
"Write a short build plan for it."
)
issues.append(as_operator.create_issue(repo, f"letters_{run}", body))
as_operator._request(
"PATCH",
f"/repos/{repo}/issues/{issues[0]}",
json={"assignees": [agent.account]},
)
operator.tick()
# --- milestone 1: the plan and vowels; part of the issue ---
item = operator.drive_until(MERGE, issues[0])
first = loop_of(item).pr
prs.append(first)
pr = gitea._request("GET", f"/repos/{repo}/pulls/{first}")
assert f"Part of #{issues[0]}" in pr["body"], pr["body"]
files = [
f["filename"]
for f in gitea._request("GET", f"/repos/{repo}/pulls/{first}/files")
]
assert any(f.startswith("docs/plans/") for f in files), files
merge(as_operator, repo, first)
operator.tick()
# --- milestone 2: from the merged first; closes the issue ---
item = operator.drive_until(MERGE, issues[0])
second = loop_of(item).pr
assert second != first
prs.append(second)
pr = gitea._request("GET", f"/repos/{repo}/pulls/{second}")
assert f"Closes #{issues[0]}" in pr["body"], pr["body"]
diff = gitea._http.get(f"/repos/{repo}/pulls/{second}.diff").text
assert f"consonants_{run}" in diff
assert f"\n+def vowels_{run}" not in diff # built on the merged first
operator.merge_and_close(issues[0])
finally:
operator.tidy(issues, prs)
@pytest.mark.smoke
def test_a_conversation_becomes_filed_work(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.5: a top-level message is a conversation; the work it turns into
is proposed, agreed, filed as a record issue, and started as agreed."""
agent, transport, run = running
operator = Operator(agent, transport)
gitea, repo = agent.gitea, agent.config.projects[0].repo
issues: list[int] = []
prs: list[int] = []
def conversation() -> Any:
items = agent.worker.call(agent.store.work_items)
return next(i for i in items if i.scope == "general")
try:
root = transport.receive(
OPERATOR,
f"I've been thinking the sandbox could use this: {task(run)} "
"Could you file that as an issue and start on it?",
)
operator.settle()
deadline = time.monotonic() + DEADLINE_S
answers = 0
while loop_of(conversation()).wait != GO_AHEAD:
assert time.monotonic() < deadline, f"never proposed: {operator.last()}"
answers += 1
assert answers <= MAX_ANSWERS, f"never proposed: {operator.last()}"
transport.receive(
OPERATOR,
"Yes, propose it as an issue in the sandbox project.",
thread=root,
)
operator.settle()
assert operator.last().startswith(f"rev-{loop_of(conversation()).rev}:")
transport.receive(OPERATOR, "go", thread=root)
operator.settle()
[filed] = loop_of(conversation()).became
number = int(filed.split("#")[1])
issues.append(number)
data = gitea._request("GET", f"/repos/{repo}/issues/{number}")
assert "<!-- nisse-item: " in data["body"]
assert agent.account in [a["login"] for a in data["assignees"] or []]
item = operator.drive_until(MERGE, number)
assert loop_of(item).started # no second restatement
prs.append(loop_of(item).pr)
pr = gitea._request("GET", f"/repos/{repo}/pulls/{prs[0]}")
assert f"Closes #{number}" in pr["body"]
finally:
operator.tidy(issues, prs)
def test_a_conversation_becomes_a_new_project(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.5: a conversation proposes a new project; the operator provides
what the checklist asks for (here, a repository of its own made the way
fresh_repo makes one, and the entry the agent wrote); "done" has the
agent check it all, open the tracking issue, and start the architecture."""
import yaml
from nisse_dev.config import _parse_projects
agent, transport, run = running
operator = Operator(agent, transport)
as_operator = operator_gitea(agent.config.gitea_url)
name = f"tally-{run}"
def conversation() -> Any:
items = agent.worker.call(agent.store.work_items)
return next(i for i in items if i.scope == "general")
root = transport.receive(
OPERATOR,
f"I'd like a new project, named {name}: a tiny command-line tally "
"counter in Python 3 with pytest, no services, secrets or extra network "
"access. Please propose it as a new project.",
)
operator.settle()
for _ in range(MAX_ANSWERS):
if loop_of(conversation()).wait == GO_AHEAD:
break
transport.receive(
OPERATOR, f"Yes: propose it as a new project named {name}.", thread=root
)
operator.settle()
assert loop_of(conversation()).proposal_kind == "project", operator.last()
transport.receive(OPERATOR, "go", thread=root)
operator.settle()
checklist = operator.last()
entry = re.search(r"```yaml\n(.*?)\n```", checklist, re.DOTALL)
assert entry is not None, checklist
# --- the operator provides it all, as the checklist asks ---
keep = agent.config.state_db.parent
with fresh_repo(
as_operator, f"{run}-new", keep / "keys", agent=agent.account, seed=False
) as repo:
host = agent.config.projects[0].build_host
filled = (
entry.group(1)
.replace(f"<owner>/{name}", repo.name)
.replace("<the build VM>", host.address)
.replace("<my SSH key for it>", str(host.key))
.replace("<the deploy key's private half>", str(repo.push_key))
)
errors: list[str] = []
[project] = _parse_projects(yaml.safe_load(filled)["projects"], {}, errors)
assert errors == [], errors
# On this run's build host and in its scratch directory.
project = replace(
project, build_host=host, workspace=Path(f"/tmp/nisse-e2e-{run}/{name}")
)
agent.source[0] = replace( # type: ignore[attr-defined]
agent.config, projects=(*agent.config.projects, project)
)
transport.receive(OPERATOR, "done", thread=root)
operator.settle()
became = loop_of(conversation()).became
assert became, f"not set up: {operator.last()}"
number = int(became[0].split("#")[1])
operator.project = name
item = operator.drive_until(MERGE, number)
assert "design" in loop_of(item).labels
pr = loop_of(item).pr
files = [
f["filename"]
for f in agent.gitea._request("GET", f"/repos/{repo.name}/pulls/{pr}/files")
]
assert "ARCHITECTURE.md" in files, files
assert any(f.startswith("docs/plans/") for f in files), files
@pytest.mark.smoke
def test_a_question_leaves_the_restatement_up(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.7: a question while a restatement is up is answered, and changes
nothing: the same revision stays up, and a go binds to it."""
agent, transport, run = running
operator = Operator(agent, transport)
repo = agent.config.projects[0].repo
as_operator = operator_gitea(agent.config.gitea_url)
issues: list[int] = []
try:
issues.append(as_operator.create_issue(repo, f"vowels_{run}", task(run)))
as_operator._request(
"PATCH",
f"/repos/{repo}/issues/{issues[0]}",
json={"assignees": [agent.account]},
)
operator.tick()
item = operator.drive_until(GO_AHEAD, issues[0])
rev = loop_of(item).rev
said = len(transport.outbound)
operator.say("Why put it in that module rather than a new one?", issues[0])
answer = [m.text for m in transport.outbound[said:]]
assert answer and not answer[-1].startswith("rev-"), answer
loop = loop_of(operator.item(issues[0]))
assert (loop.wait, loop.rev) == (GO_AHEAD, rev), loop
operator.say("go", issues[0])
assert loop_of(operator.item(issues[0])).started
finally:
operator.tidy(issues, [])