Fix the run-breaking tool-result order; e2e test runs the slice (M2.1, PR D)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m17s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m17s
The first live run of the new loop's e2e test found that a fact recorded while start_milestone was in flight landed between the tool call and its result; both API formats reject that, so every later request in the run failed. Views now keep each result right after its call. Scripted test models check the APIs' ordering rules, so this can't pass unit tests again. The e2e tests no longer depend on the sandbox's history: each run asks for a uniquely named function and works against a base branch of its own, deleted afterwards. The review step asks for a change that can't already be there. The scripted operator gives up after five fruitless answers, and each run keeps its state store for inspection. The driver's prompt says review fixes stay in the current milestone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
30ab5c9b9c
commit
37965aefca
11 files changed
+203
-37
No files matched your search
@@ -70,8 +70,8 @@ After the go-ahead, work in a milestone: call start_milestone before
|
|||||||
changing anything (never create or switch branches yourself), make the
|
changing anything (never create or switch branches yourself), make the
|
||||||
change, run the project's tests, then call finish_milestone with a summary.
|
change, run the project's tests, then call finish_milestone with a summary.
|
||||||
The harness commits, pushes and opens a PR, and you wait for review. Review
|
The harness commits, pushes and opens a PR, and you wait for review. Review
|
||||||
comments arrive as messages: make the changes, run the tests, and call
|
comments arrive as messages: make the changes within the same milestone (don't
|
||||||
finish_milestone again to push the fix. When the PR has merged, call
|
start another), run the tests, and call finish_milestone again to push the fix. When the PR has merged, call
|
||||||
close_item with what was done, what was verified, and what is left open. If
|
close_item with what was done, what was verified, and what is left open. If
|
||||||
the PR is closed without merging, ask the operator what next.
|
the PR is closed without merging, ask the operator what next.
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
|||||||
shown = _SHOWN.get(viewer)
|
shown = _SHOWN.get(viewer)
|
||||||
if shown is None:
|
if shown is None:
|
||||||
# A role with no view defined (the expert is stateless, the
|
# A role with no view defined (the expert is stateless, the
|
||||||
# classifier sees only an action) reads the log as it is.
|
# classifier sees only an action) reads the log as it is, with
|
||||||
return list(events)
|
# results kept after their calls.
|
||||||
|
return _results_follow_calls(list(events))
|
||||||
results = {
|
results = {
|
||||||
e.event.call_id: e.event for e in events if isinstance(e.event, ToolResult)
|
e.event.call_id: e.event for e in events if isinstance(e.event, ToolResult)
|
||||||
}
|
}
|
||||||
@@ -128,4 +129,30 @@ def project(events: list[StoredEvent], viewer: str) -> list[StoredEvent]:
|
|||||||
out.append(_said(stored, actor, line))
|
out.append(_said(stored, actor, line))
|
||||||
elif isinstance(event, AssistantText) and event.text.strip():
|
elif isinstance(event, AssistantText) and event.text.strip():
|
||||||
out.append(_said(stored, actor, event.text.strip()))
|
out.append(_said(stored, actor, event.text.strip()))
|
||||||
return out
|
return _results_follow_calls(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _results_follow_calls(events: list[StoredEvent]) -> list[StoredEvent]:
|
||||||
|
"""Keep each tool result right after its call, as both API formats
|
||||||
|
require: anything recorded while a call was in flight (a fact the
|
||||||
|
harness wrote while handling it, say) moves to just after the results.
|
||||||
|
A call with no result yet keeps its place; the renderer answers it."""
|
||||||
|
out: list[StoredEvent] = []
|
||||||
|
pending: set[str] = set()
|
||||||
|
held: list[StoredEvent] = []
|
||||||
|
for stored in events:
|
||||||
|
event = stored.event
|
||||||
|
if isinstance(event, ToolCall):
|
||||||
|
pending.add(event.call_id)
|
||||||
|
out.append(stored)
|
||||||
|
elif isinstance(event, ToolResult) and event.call_id in pending:
|
||||||
|
pending.discard(event.call_id)
|
||||||
|
out.append(stored)
|
||||||
|
if not pending:
|
||||||
|
out.extend(held)
|
||||||
|
held = []
|
||||||
|
elif pending:
|
||||||
|
held.append(stored)
|
||||||
|
else:
|
||||||
|
out.append(stored)
|
||||||
|
return out + held
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""What a real model API insists on, for scripted test models.
|
||||||
|
|
||||||
|
Both formats the agent sends require a tool call's result to come right
|
||||||
|
after it: in Anthropic's, the next message is the user's and starts with a
|
||||||
|
tool_result for every tool_use, before any text; in OpenAI's, the assistant
|
||||||
|
message with tool_calls is followed directly by a tool message for each. A
|
||||||
|
scripted model that doesn't check this lets a broken transcript pass the
|
||||||
|
unit tests and be rejected live; this makes it fail here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nisse_dev.render import OpenAIChatRenderer
|
||||||
|
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
||||||
|
|
||||||
|
|
||||||
|
def assert_renderable(events: list[Any]) -> None:
|
||||||
|
_anthropic(AnthropicMessagesRenderer().render(list(events), None, "m")["messages"])
|
||||||
|
_openai(OpenAIChatRenderer().render(list(events), None, "m")["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic(messages: list[dict[str, Any]]) -> None:
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
if message["role"] != "assistant":
|
||||||
|
continue
|
||||||
|
used = [b["id"] for b in message["content"] if b.get("type") == "tool_use"]
|
||||||
|
if not used:
|
||||||
|
continue
|
||||||
|
following = messages[index + 1] if index + 1 < len(messages) else None
|
||||||
|
assert following is not None and following["role"] == "user", (
|
||||||
|
f"anthropic messages.{index}: tool_use with no user message after it"
|
||||||
|
)
|
||||||
|
blocks = following["content"]
|
||||||
|
leading = []
|
||||||
|
for block in blocks:
|
||||||
|
if block.get("type") != "tool_result":
|
||||||
|
break
|
||||||
|
leading.append(block["tool_use_id"])
|
||||||
|
assert set(used) <= set(leading), (
|
||||||
|
f"anthropic messages.{index}: tool_use ids {sorted(set(used) - set(leading))} "
|
||||||
|
"lack a tool_result at the start of the next message"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _openai(messages: list[dict[str, Any]]) -> None:
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
calls = [c["id"] for c in message.get("tool_calls") or []]
|
||||||
|
if not calls:
|
||||||
|
continue
|
||||||
|
answered = []
|
||||||
|
for after in messages[index + 1 :]:
|
||||||
|
if after["role"] != "tool":
|
||||||
|
break
|
||||||
|
answered.append(after["tool_call_id"])
|
||||||
|
assert set(calls) <= set(answered), (
|
||||||
|
f"openai messages.{index}: tool_calls {sorted(set(calls) - set(answered))} "
|
||||||
|
"are not answered by the tool messages right after them"
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from model_contract import assert_renderable
|
||||||
|
|
||||||
from nisse_dev.cancel import Cancel
|
from nisse_dev.cancel import Cancel
|
||||||
from nisse_dev.canonical import AssistantText, CanonicalEvent, Fact, ToolCall
|
from nisse_dev.canonical import AssistantText, CanonicalEvent, Fact, ToolCall
|
||||||
@@ -34,6 +35,7 @@ class ScriptedModel:
|
|||||||
self.seen: list[list[Any]] = []
|
self.seen: list[list[Any]] = []
|
||||||
|
|
||||||
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
||||||
|
assert_renderable(events) # as the real API would
|
||||||
self.offered.append({t["function"]["name"] for t in tools or []})
|
self.offered.append({t["function"]["name"] for t in tools or []})
|
||||||
self.seen.append(events)
|
self.seen.append(events)
|
||||||
assert self.turns, "the model was called more often than scripted"
|
assert self.turns, "the model was called more often than scripted"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from model_contract import assert_renderable
|
||||||
|
|
||||||
from nisse_dev.cancel import Cancel
|
from nisse_dev.cancel import Cancel
|
||||||
from nisse_dev.canonical import ToolCall
|
from nisse_dev.canonical import ToolCall
|
||||||
@@ -42,6 +43,7 @@ class ScriptedModel:
|
|||||||
self.turns = list(turns)
|
self.turns = list(turns)
|
||||||
|
|
||||||
def complete(self, events, tools, model, system=None) -> list[Any]:
|
def complete(self, events, tools, model, system=None) -> list[Any]:
|
||||||
|
assert_renderable(events) # as the real API would
|
||||||
assert self.turns, "the model was called more often than scripted"
|
assert self.turns, "the model was called more often than scripted"
|
||||||
return self.turns.pop(0)
|
return self.turns.pop(0)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from model_contract import assert_renderable
|
||||||
from test_loop_milestones import DON, Forge, call
|
from test_loop_milestones import DON, Forge, call
|
||||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ class PolicyModel:
|
|||||||
self.n = 0
|
self.n = 0
|
||||||
|
|
||||||
def complete(self, events, tools, model, system=None) -> list[Any]:
|
def complete(self, events, tools, model, system=None) -> list[Any]:
|
||||||
|
assert_renderable(events) # as the real API would
|
||||||
self.n += 1
|
self.n += 1
|
||||||
offered = {t["function"]["name"] for t in tools}
|
offered = {t["function"]["name"] for t in tools}
|
||||||
facts = [getattr(e.event, "text", "") for e in events]
|
facts = [getattr(e.event, "text", "") for e in events]
|
||||||
|
|||||||
@@ -282,3 +282,18 @@ def test_operator_messages_carry_their_channel(tmp_path: Path) -> None:
|
|||||||
"[operator, comment on plan issue #9] hi",
|
"[operator, comment on plan issue #9] hi",
|
||||||
"[operator, Gitea comment] hi",
|
"[operator, Gitea comment] hi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_something_written_mid_call_moves_after_the_result(tmp_path: Path) -> None:
|
||||||
|
# Live, M2.1: start_milestone's fact landed between the call and its
|
||||||
|
# result, and the model API rejected every later request in the run.
|
||||||
|
store = new_store(tmp_path)
|
||||||
|
driver = store.transcript("a", "driver")
|
||||||
|
driver.append(ToolCall(call_id="c1", tool="start_milestone", arguments={}))
|
||||||
|
store.transcript("a", "orchestrator").append(Fact(text="milestone 1 started"))
|
||||||
|
driver.append(ToolResult(call_id="c1", result="started", success=True))
|
||||||
|
kinds = [type(e.event).__name__ for e in driver.view()]
|
||||||
|
assert kinds == ["ToolCall", "ToolResult", "UserMessage"]
|
||||||
|
body = AnthropicMessagesRenderer().render(driver.view(), tools=None, model="m")
|
||||||
|
result_first = body["messages"][1]["content"][0]
|
||||||
|
assert result_first["type"] == "tool_result"
|
||||||
@@ -17,4 +17,6 @@ else
|
|||||||
export NISSE_E2E_BUILD_HOST=${NISSE_E2E_BUILD_HOST:-nisse-dev.garrett-hall.com}
|
export NISSE_E2E_BUILD_HOST=${NISSE_E2E_BUILD_HOST:-nisse-dev.garrett-hall.com}
|
||||||
export NISSE_E2E_BUILD_KEY=${NISSE_E2E_BUILD_KEY:-/root/nisse-e2e/ssh/id_ed25519}
|
export NISSE_E2E_BUILD_KEY=${NISSE_E2E_BUILD_KEY:-/root/nisse-e2e/ssh/id_ed25519}
|
||||||
fi
|
fi
|
||||||
|
# Each run's state store is kept here, for nisse_dev.dump after a failure.
|
||||||
|
export NISSE_E2E_RUNS=${NISSE_E2E_RUNS:-/root/nisse-e2e/test-runs}
|
||||||
exec uv run pytest -m e2e -rs tests/integration/test_end_to_end.py tests/integration/test_end_to_end_loop.py "$@"
|
exec uv run pytest -m e2e -rs tests/integration/test_end_to_end.py tests/integration/test_end_to_end_loop.py "$@"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Shared by the end-to-end tests: each run gets work that can't already be
|
||||||
|
done, and a base branch of its own, so runs never depend on one another or
|
||||||
|
on what earlier runs merged."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from nisse_dev.gitea import GiteaClient, GiteaError
|
||||||
|
|
||||||
|
|
||||||
|
def task(run: str) -> str:
|
||||||
|
"""A request no earlier run can have fulfilled."""
|
||||||
|
return (
|
||||||
|
f"Add a function vowels_{run}(text) to the sandbox package that returns "
|
||||||
|
"how many of a, e, i, o, u (either case) the text contains, with tests."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def operator_gitea(url: str) -> GiteaClient:
|
||||||
|
token = os.environ.get("NISSE_OPERATOR_TOKEN", "")
|
||||||
|
assert token.strip(), (
|
||||||
|
"NISSE_OPERATOR_TOKEN is not set: the test operator's Gitea token"
|
||||||
|
)
|
||||||
|
return GiteaClient(url, token)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def base_branch(operator: GiteaClient, repo: str, run: str) -> Iterator[str]:
|
||||||
|
"""A branch off main for this run's PRs to target and merge into, so
|
||||||
|
nothing accumulates in main; deleted afterwards."""
|
||||||
|
name = f"e2e-base-{run}"
|
||||||
|
operator._request(
|
||||||
|
"POST",
|
||||||
|
f"/repos/{repo}/branches",
|
||||||
|
json={"new_branch_name": name, "old_branch_name": "main"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield name
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(GiteaError):
|
||||||
|
operator._request("DELETE", f"/repos/{repo}/branches/{name}")
|
||||||
@@ -25,6 +25,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from e2e_support import base_branch, operator_gitea
|
||||||
|
from e2e_support import task as make_task
|
||||||
|
|
||||||
from nisse_dev.agent import Agent, build_agent
|
from nisse_dev.agent import Agent, build_agent
|
||||||
from nisse_dev.canonical import UserMessage
|
from nisse_dev.canonical import UserMessage
|
||||||
@@ -43,10 +45,6 @@ pytestmark = [pytest.mark.integration, pytest.mark.e2e]
|
|||||||
|
|
||||||
OPERATOR = "@dongar:dongar.net" # an allowlisted operator in nisse-test.yaml
|
OPERATOR = "@dongar:dongar.net" # an allowlisted operator in nisse-test.yaml
|
||||||
SETTLE_S = 1200 # a milestone, with real models, inside one message
|
SETTLE_S = 1200 # a milestone, with real models, inside one message
|
||||||
TASK = (
|
|
||||||
"Add a function `count_vowels(text)` to the sandbox package that returns "
|
|
||||||
"how many of a, e, i, o, u (either case) the text contains, with tests."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_host(ssh_host: SSHHost) -> BuildHost:
|
def build_host(ssh_host: SSHHost) -> BuildHost:
|
||||||
@@ -70,10 +68,14 @@ def agent(
|
|||||||
ssh_host: SSHHost,
|
ssh_host: SSHHost,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> Iterator[tuple[Agent, InMemoryTransport]]:
|
) -> Iterator[tuple[Agent, InMemoryTransport, str]]:
|
||||||
run = uuid.uuid4().hex[:8]
|
run = uuid.uuid4().hex[:8]
|
||||||
|
stack = contextlib.ExitStack()
|
||||||
|
operator = operator_gitea(live_config.gitea_url)
|
||||||
|
base = stack.enter_context(base_branch(operator, live_config.gitea_repo, run))
|
||||||
config = replace(
|
config = replace(
|
||||||
live_config,
|
live_config,
|
||||||
|
base_branch=base,
|
||||||
state_db=tmp_path / "state.db",
|
state_db=tmp_path / "state.db",
|
||||||
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
|
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
|
||||||
build_host=build_host(ssh_host),
|
build_host=build_host(ssh_host),
|
||||||
@@ -84,7 +86,7 @@ def agent(
|
|||||||
try:
|
try:
|
||||||
agent = build_agent(config)
|
agent = build_agent(config)
|
||||||
agent.start()
|
agent.start()
|
||||||
yield agent, transport
|
yield agent, transport, run
|
||||||
finally:
|
finally:
|
||||||
if agent is not None:
|
if agent is not None:
|
||||||
agent.stop()
|
agent.stop()
|
||||||
@@ -97,6 +99,7 @@ def agent(
|
|||||||
state_dir=tmp_path / "ssh",
|
state_dir=tmp_path / "ssh",
|
||||||
)
|
)
|
||||||
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
|
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
|
||||||
|
stack.close() # the run's base branch
|
||||||
|
|
||||||
|
|
||||||
class Operator:
|
class Operator:
|
||||||
@@ -148,14 +151,16 @@ def is_artifact(path: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_a_milestone_end_to_end(agent: tuple[Agent, InMemoryTransport]) -> None:
|
def test_a_milestone_end_to_end(
|
||||||
running, transport = agent
|
agent: tuple[Agent, InMemoryTransport, str],
|
||||||
|
) -> None:
|
||||||
|
running, transport, run = agent
|
||||||
operator = Operator(running, transport)
|
operator = Operator(running, transport)
|
||||||
gitea, repo = running.gitea, running.config.gitea_repo
|
gitea, repo = running.gitea, running.config.gitea_repo
|
||||||
pr: dict[str, Any] = {}
|
pr: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
# The task first, before any project: it must be kept, not lost.
|
# The task first, before any project: it must be kept, not lost.
|
||||||
operator.say(TASK)
|
operator.say(make_task(run))
|
||||||
operator.say(f"use {running.config.project}")
|
operator.say(f"use {running.config.project}")
|
||||||
state = operator.drive_to_pr()
|
state = operator.drive_to_pr()
|
||||||
pr = state.data["pr"]
|
pr = state.data["pr"]
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from e2e_support import base_branch, operator_gitea, task
|
||||||
|
|
||||||
from nisse_dev.config import AgentConfig
|
from nisse_dev.config import AgentConfig
|
||||||
from nisse_dev.execution import SSHHost
|
from nisse_dev.execution import SSHHost
|
||||||
from nisse_dev.gitea import GiteaClient, GiteaError
|
from nisse_dev.gitea import GiteaError
|
||||||
from nisse_dev.loop.build import RunningLoop, build_loop_agent
|
from nisse_dev.loop.build import RunningLoop, build_loop_agent
|
||||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, WAITING, loop_of
|
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, WAITING, loop_of
|
||||||
from nisse_dev.transport import InMemoryTransport
|
from nisse_dev.transport import InMemoryTransport
|
||||||
@@ -30,19 +31,8 @@ from nisse_dev.transport import InMemoryTransport
|
|||||||
pytestmark = [pytest.mark.integration, pytest.mark.e2e]
|
pytestmark = [pytest.mark.integration, pytest.mark.e2e]
|
||||||
|
|
||||||
OPERATOR = "@dongar:dongar.net" # an allowlisted Matrix operator in nisse-test.yaml
|
OPERATOR = "@dongar:dongar.net" # an allowlisted Matrix operator in nisse-test.yaml
|
||||||
TASK = (
|
|
||||||
"Add a function count_vowels(text) to the sandbox package that returns how "
|
|
||||||
"many of a, e, i, o, u (either case) the text contains, with tests."
|
|
||||||
)
|
|
||||||
DEADLINE_S = 1200
|
DEADLINE_S = 1200
|
||||||
|
MAX_ANSWERS = 5 # per wait for something else, before giving up
|
||||||
|
|
||||||
def operator_gitea(config: AgentConfig) -> GiteaClient:
|
|
||||||
token = os.environ.get("NISSE_OPERATOR_TOKEN", "")
|
|
||||||
assert token.strip(), (
|
|
||||||
"NISSE_OPERATOR_TOKEN is not set: the test operator's Gitea token"
|
|
||||||
)
|
|
||||||
return GiteaClient(config.gitea_url, token)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -51,14 +41,23 @@ def running(
|
|||||||
ssh_host: SSHHost,
|
ssh_host: SSHHost,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> Iterator[tuple[RunningLoop, InMemoryTransport]]:
|
) -> Iterator[tuple[RunningLoop, InMemoryTransport, str]]:
|
||||||
from test_end_to_end import build_host
|
from test_end_to_end import build_host
|
||||||
|
|
||||||
run = uuid.uuid4().hex[:8]
|
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)
|
||||||
|
base = stack.enter_context(base_branch(operator, live_config.gitea_repo, run))
|
||||||
config = replace(
|
config = replace(
|
||||||
live_config,
|
live_config,
|
||||||
loop="new",
|
loop="new",
|
||||||
state_db=tmp_path / "state.db",
|
base_branch=base,
|
||||||
|
state_db=keep / "state.db",
|
||||||
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
|
workspace=Path(f"/tmp/nisse-e2e-{run}/sandbox"),
|
||||||
build_host=build_host(ssh_host),
|
build_host=build_host(ssh_host),
|
||||||
)
|
)
|
||||||
@@ -70,7 +69,7 @@ def running(
|
|||||||
# The test drives intake and PR watching itself, not on a timer.
|
# The test drives intake and PR watching itself, not on a timer.
|
||||||
agent.loop._interval = 3600
|
agent.loop._interval = 3600
|
||||||
agent.start()
|
agent.start()
|
||||||
yield agent, transport
|
yield agent, transport, run
|
||||||
finally:
|
finally:
|
||||||
if agent is not None:
|
if agent is not None:
|
||||||
agent.stop()
|
agent.stop()
|
||||||
@@ -82,6 +81,7 @@ def running(
|
|||||||
state_dir=tmp_path / "ssh",
|
state_dir=tmp_path / "ssh",
|
||||||
)
|
)
|
||||||
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
|
host.execute(f"rm -rf /tmp/nisse-e2e-{run}")
|
||||||
|
stack.close() # the run's base branch
|
||||||
|
|
||||||
|
|
||||||
def is_artifact(path: str) -> bool:
|
def is_artifact(path: str) -> bool:
|
||||||
@@ -116,6 +116,7 @@ class Operator:
|
|||||||
def drive_until(self, wait: str) -> Any:
|
def drive_until(self, wait: str) -> Any:
|
||||||
"""Answer whatever the agent asks until it waits for ``wait``."""
|
"""Answer whatever the agent asks until it waits for ``wait``."""
|
||||||
deadline = time.monotonic() + DEADLINE_S
|
deadline = time.monotonic() + DEADLINE_S
|
||||||
|
answers = 0
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
item = self.item()
|
item = self.item()
|
||||||
assert item is not None, "no work item"
|
assert item is not None, "no work item"
|
||||||
@@ -127,6 +128,9 @@ class Operator:
|
|||||||
if item.position == WAITING and loop.wait == GO_AHEAD:
|
if item.position == WAITING and loop.wait == GO_AHEAD:
|
||||||
self.say("go")
|
self.say("go")
|
||||||
elif item.position == WAITING and loop.wait == ANSWER:
|
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.")
|
self.say("Use your best judgement and proceed.")
|
||||||
else:
|
else:
|
||||||
self.settle()
|
self.settle()
|
||||||
@@ -136,18 +140,18 @@ class Operator:
|
|||||||
return self.transport.outbound[-1].text if self.transport.outbound else ""
|
return self.transport.outbound[-1].text if self.transport.outbound else ""
|
||||||
|
|
||||||
|
|
||||||
def test_the_slice_end_to_end(running: tuple[RunningLoop, InMemoryTransport]) -> None:
|
def test_the_slice_end_to_end(
|
||||||
agent, transport = running
|
running: tuple[RunningLoop, InMemoryTransport, str],
|
||||||
|
) -> None:
|
||||||
|
agent, transport, run = running
|
||||||
operator = Operator(agent, transport)
|
operator = Operator(agent, transport)
|
||||||
gitea, repo = agent.gitea, agent.config.gitea_repo
|
gitea, repo = agent.gitea, agent.config.gitea_repo
|
||||||
as_operator = operator_gitea(agent.config)
|
as_operator = operator_gitea(agent.config.gitea_url)
|
||||||
issue = None
|
issue = None
|
||||||
pr = 0
|
pr = 0
|
||||||
try:
|
try:
|
||||||
# --- the operator files an issue and assigns it to the agent (D1) ---
|
# --- the operator files an issue and assigns it to the agent (D1) ---
|
||||||
issue = as_operator.create_issue(
|
issue = as_operator.create_issue(repo, f"vowels_{run}", task(run))
|
||||||
repo, f"count_vowels ({uuid.uuid4().hex[:6]})", TASK
|
|
||||||
)
|
|
||||||
as_operator._request(
|
as_operator._request(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
f"/repos/{repo}/issues/{issue}",
|
f"/repos/{repo}/issues/{issue}",
|
||||||
@@ -167,14 +171,18 @@ def test_the_slice_end_to_end(running: tuple[RunningLoop, InMemoryTransport]) ->
|
|||||||
assert f"Closes #{issue}" in body
|
assert f"Closes #{issue}" in body
|
||||||
|
|
||||||
# --- a review comment is answered with a fix (D5) ---
|
# --- 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"]
|
before = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["sha"]
|
||||||
as_operator.post_comment(
|
as_operator.post_comment(
|
||||||
repo, pr, "Please add a one-line docstring to count_vowels."
|
repo, pr, f"Please add the comment line `# {marker}` above vowels_{run}."
|
||||||
)
|
)
|
||||||
operator.tick()
|
operator.tick()
|
||||||
operator.drive_until(MERGE)
|
operator.drive_until(MERGE)
|
||||||
after = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["sha"]
|
after = gitea._request("GET", f"/repos/{repo}/pulls/{pr}")["head"]["sha"]
|
||||||
assert after != before, "no fix was pushed for the review comment"
|
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 ---
|
# --- the operator approves and merges; the agent closes the item ---
|
||||||
with contextlib.suppress(GiteaError): # approval may not be required
|
with contextlib.suppress(GiteaError): # approval may not be required
|
||||||
@@ -188,8 +196,11 @@ def test_the_slice_end_to_end(running: tuple[RunningLoop, InMemoryTransport]) ->
|
|||||||
)
|
)
|
||||||
operator.tick()
|
operator.tick()
|
||||||
deadline = time.monotonic() + DEADLINE_S
|
deadline = time.monotonic() + DEADLINE_S
|
||||||
|
nudges = 0
|
||||||
while operator.item().status != "closed" and time.monotonic() < deadline:
|
while operator.item().status != "closed" and time.monotonic() < deadline:
|
||||||
if loop_of(operator.item()).wait == ANSWER:
|
if loop_of(operator.item()).wait == ANSWER:
|
||||||
|
nudges += 1
|
||||||
|
assert nudges <= MAX_ANSWERS, f"never closed: {operator.last()}"
|
||||||
operator.say("It's merged; please close the item.")
|
operator.say("It's merged; please close the item.")
|
||||||
operator.settle()
|
operator.settle()
|
||||||
assert operator.item().outcome == "completed"
|
assert operator.item().outcome == "completed"
|
||||||
|
|||||||
Reference in new issue
Block a user