- Questions never change state (L5, #77): a message ending in "?" to a waiting project item starts a question run (reply and look-only tools, an instruction to change nothing), and the item goes back to the very wait it was in: a restatement up stays up and a go binds to it; the agent's own question stays open. - Explanations: a restatement gives its reasons and the main alternative; a revision says what changed (restate(changed), sent back without it); a milestone's summary says what was done, verified and left open. - Progress in prose (#42): the driver's and a helper's own words for a turn are posted, trimmed, at most once a minute per item; `details` shows the last tool calls; a message arriving mid-run is acknowledged at once ("Noted; ..."). - CI: the e2e job's comments and the plan no longer claim a newer push cancels the run it supersedes (Gitea here doesn't); the concurrency block goes. Tests: conversation unit tests (questions in a go-ahead and an answer wait, an unanswerable question, revisions, progress rate and trimming, details, mid-run notes), the helper's progress, the walk asking questions in every state with a questions_change_nothing property (routing questions the old way fails 32 of 48 seeds), and an e2e question while a restatement is up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
319 lines
10 KiB
Python
319 lines
10 KiB
Python
"""Threads, commands and labels in the new loop (M2.3): commands that name
|
|
their item, ambiguous ones asked about (L5); plain-language go-aheads; `bug`
|
|
grace periods, `discuss` and `design` (D1)."""
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
|
|
from test_loop import h as h # noqa: PLC0414 -- the fixture
|
|
|
|
from nisse_dev.canonical import Fact, ToolCall
|
|
from nisse_dev.llm import LLMError
|
|
from nisse_dev.loop.items import ANSWER, GO_AHEAD, READY, WAITING, loop_of
|
|
from nisse_dev.loop.reader import GoAheadReader
|
|
|
|
STARTED = [call("ask_human", question="started; anything else?")]
|
|
|
|
|
|
class Clock:
|
|
def __init__(self) -> None:
|
|
self.now = 1000.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
class Reader:
|
|
"""A scripted go-ahead reader: agrees with what it's told to."""
|
|
|
|
def __init__(self, *yes: str) -> None:
|
|
self.yes = set(yes)
|
|
self.asked: list[tuple[str, str]] = []
|
|
|
|
def agrees(self, proposal: str, reply: str, model: str = "") -> bool:
|
|
self.asked.append((proposal, reply))
|
|
return reply in self.yes
|
|
|
|
|
|
def facts(h: Harness) -> list[str]:
|
|
return [
|
|
e.event.text
|
|
for e in h.store.transcript(h.item().id, "orchestrator").read()
|
|
if isinstance(e.event, Fact)
|
|
]
|
|
|
|
|
|
def two(tmp_path: Path, *turns: list[Any]) -> Harness:
|
|
return Harness(
|
|
tmp_path,
|
|
ScriptedModel(*turns),
|
|
FakeGitea(issue(14), issue(15, title="Add count_lines")),
|
|
)
|
|
|
|
|
|
# --- commands that name their item, and ambiguous ones (L5) -------------------
|
|
|
|
|
|
def test_a_named_go_at_top_level_applies_to_its_item(tmp_path: Path) -> None:
|
|
h = two(tmp_path, RESTATED, RESTATED, STARTED)
|
|
h.agent.intake()
|
|
h.say("go #15", thread=None)
|
|
items = {loop_of(i).issue: i for i in h.store.work_items()}
|
|
assert loop_of(items[15]).started and not loop_of(items[14]).started
|
|
|
|
|
|
def test_a_named_abandon_in_another_thread_still_names_its_item(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
h = two(tmp_path, RESTATED, RESTATED)
|
|
h.agent.intake()
|
|
items = {loop_of(i).issue: i for i in h.store.work_items()}
|
|
h.transport.receive(DON, "Abandon #15.", thread=loop_of(items[14]).thread)
|
|
items = {loop_of(i).issue: i for i in h.store.work_items()}
|
|
assert items[15].status == "closed" and items[14].status == "open"
|
|
|
|
|
|
def test_an_unnamed_command_at_top_level_is_asked_about(tmp_path: Path) -> None:
|
|
h = two(tmp_path, RESTATED, RESTATED)
|
|
h.agent.intake()
|
|
h.say("abandon", thread=None)
|
|
assert all(i.status == "open" for i in h.store.work_items())
|
|
asked = h.posted()[-1]
|
|
assert "Which item?" in asked and "#14" in asked and "#15" in asked
|
|
|
|
|
|
def test_naming_an_item_that_isnt_open_says_so(h: Harness) -> None:
|
|
h.agent.intake()
|
|
h.say("go #99", thread=None)
|
|
assert "#99 isn't one of my open work items" in h.posted()[-1]
|
|
|
|
|
|
def test_stop_in_a_waiting_items_thread_is_not_an_answer(h: Harness) -> None:
|
|
h.agent.intake()
|
|
h.say("stop")
|
|
assert (h.item().position, loop_of(h.item()).wait) == (WAITING, GO_AHEAD)
|
|
assert "nothing to stop" in h.posted()[-1]
|
|
|
|
|
|
# --- plain-language go-aheads (L5, D1) ------------------------------------------
|
|
|
|
|
|
def test_a_plain_yes_is_a_go_ahead_said_so_first(tmp_path: Path) -> None:
|
|
reader = Reader("Yep, that's it")
|
|
h = Harness(
|
|
tmp_path, ScriptedModel(RESTATED, STARTED), FakeGitea(issue()), reader=reader
|
|
)
|
|
h.agent.intake()
|
|
h.say("Yep, that's it")
|
|
assert loop_of(h.item()).started
|
|
assert "Taking that as a go-ahead on rev-1" in " ".join(h.posted())
|
|
assert reader.asked == [
|
|
("I'll add count_words to text.py, with tests.", "Yep, that's it")
|
|
]
|
|
assert any(f.startswith("go-ahead on rev-1 from") for f in facts(h))
|
|
|
|
|
|
def test_anything_else_goes_to_the_driver(tmp_path: Path) -> None:
|
|
h = Harness(
|
|
tmp_path,
|
|
ScriptedModel(RESTATED, [call("restate", text="v2", changed="as you asked")]),
|
|
FakeGitea(issue()),
|
|
reader=Reader(),
|
|
)
|
|
h.agent.intake()
|
|
h.say("Mostly, but call it word_count")
|
|
loop = loop_of(h.item())
|
|
assert not loop.started and loop.rev == 2
|
|
|
|
|
|
def test_a_question_never_reaches_the_reader(tmp_path: Path) -> None:
|
|
reader = Reader("sounds good?")
|
|
h = Harness(
|
|
tmp_path,
|
|
ScriptedModel(RESTATED, [call("restate", text="v2", changed="as you asked")]),
|
|
FakeGitea(issue()),
|
|
reader=reader,
|
|
)
|
|
h.agent.intake()
|
|
h.say("sounds good?")
|
|
assert not loop_of(h.item()).started and reader.asked == []
|
|
|
|
|
|
def test_a_plain_yes_to_an_older_revision_is_not_a_go(tmp_path: Path) -> None:
|
|
reader = Reader("yes")
|
|
h = Harness(
|
|
tmp_path,
|
|
ScriptedModel(
|
|
RESTATED, [call("restate", text="v2", changed="as you asked")], STARTED
|
|
),
|
|
FakeGitea(issue()),
|
|
reader=reader,
|
|
)
|
|
h.agent.intake()
|
|
h.say("change it a bit") # rev-2 goes up
|
|
h.agent.handle(_msg("yes", h.thread()), about=1)
|
|
assert not loop_of(h.item()).started
|
|
assert [reply for _, reply in reader.asked] == ["change it a bit"]
|
|
|
|
|
|
class _Model:
|
|
def __init__(self, result: Any) -> None:
|
|
self.result = result
|
|
|
|
def complete(self, events, tools, model, system=None) -> list[Any]:
|
|
if isinstance(self.result, Exception):
|
|
raise self.result
|
|
return self.result
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("result", "agrees"),
|
|
[
|
|
([ToolCall(call_id="v", tool="verdict", arguments={"answer": "yes"})], True),
|
|
(
|
|
[ToolCall(call_id="v", tool="verdict", arguments={"answer": "unclear"})],
|
|
False,
|
|
),
|
|
([], False),
|
|
(LLMError("down"), False),
|
|
],
|
|
)
|
|
def test_the_reader_agrees_only_on_a_clear_yes(result: Any, agrees: bool) -> None:
|
|
reader = GoAheadReader(_Model(result), "m") # type: ignore[arg-type]
|
|
assert reader.agrees("add count_words", "ok") is agrees
|
|
|
|
|
|
# --- labels (D1) ----------------------------------------------------------------
|
|
|
|
|
|
def bug(tmp_path: Path, *turns: list[Any], **kw: Any) -> tuple[Harness, Clock]:
|
|
clock = Clock()
|
|
h = Harness(
|
|
tmp_path,
|
|
ScriptedModel(*turns),
|
|
FakeGitea(issue(labels=("bug",))),
|
|
clock=clock,
|
|
bug_grace_s=600,
|
|
**kw,
|
|
)
|
|
return h, clock
|
|
|
|
|
|
def test_a_bug_goes_ahead_after_the_grace_period(tmp_path: Path) -> None:
|
|
h, clock = bug(tmp_path, RESTATED, STARTED)
|
|
h.agent.intake()
|
|
assert "go ahead in 10 min unless you reply" in h.posted()[-1]
|
|
assert "going ahead in 10 min" in h.agent.status()
|
|
clock.now += 599
|
|
h.agent.intake()
|
|
assert not loop_of(h.item()).started
|
|
clock.now += 1
|
|
h.agent.intake()
|
|
assert loop_of(h.item()).started
|
|
assert "go-ahead on rev-1 from the bug grace period" in facts(h)
|
|
|
|
|
|
def test_a_reply_stops_the_bugs_countdown(tmp_path: Path) -> None:
|
|
h, clock = bug(tmp_path, RESTATED, [call("ask_human", question="which file?")])
|
|
h.agent.intake()
|
|
h.say("hmm, what about unicode")
|
|
clock.now += 3600
|
|
h.agent.intake()
|
|
loop = loop_of(h.item())
|
|
assert not loop.started and loop.grace_until == 0 and loop.wait == ANSWER
|
|
|
|
|
|
def test_a_new_revision_of_a_bug_gets_its_own_countdown(tmp_path: Path) -> None:
|
|
h, clock = bug(
|
|
tmp_path,
|
|
RESTATED,
|
|
[call("restate", text="v2", changed="as you asked")],
|
|
STARTED,
|
|
)
|
|
h.agent.intake()
|
|
clock.now += 300
|
|
h.say("use the other file")
|
|
assert loop_of(h.item()).grace_until == clock.now + 600
|
|
clock.now += 600
|
|
h.agent.intake()
|
|
assert "go-ahead on rev-2 from the bug grace period" in facts(h)
|
|
|
|
|
|
def test_the_bug_label_reaches_the_driver(tmp_path: Path) -> None:
|
|
seen: list[str] = []
|
|
|
|
class Spy(ScriptedModel):
|
|
def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def]
|
|
seen.append(system or "")
|
|
return super().complete(events, tools, model, system)
|
|
|
|
h = Harness(tmp_path, Spy(RESTATED), FakeGitea(issue(labels=("bug", "ui"))))
|
|
h.agent.intake()
|
|
assert "never fix a bug you couldn't see" in seen[0]
|
|
assert "labelled discuss" not in seen[0]
|
|
|
|
|
|
def test_discuss_talks_before_it_restates(tmp_path: Path) -> None:
|
|
offered: list[set[str]] = []
|
|
|
|
class Spy(ScriptedModel):
|
|
def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def]
|
|
offered.append({t["function"]["name"] for t in tools})
|
|
return super().complete(events, tools, model, system)
|
|
|
|
h = Harness(
|
|
tmp_path,
|
|
Spy([call("ask_human", question="what's it for?")], RESTATED),
|
|
FakeGitea(issue(labels=("discuss",))),
|
|
)
|
|
h.agent.intake()
|
|
assert "restate" not in offered[0]
|
|
assert loop_of(h.item()).wait == ANSWER
|
|
h.say("for the stats page")
|
|
assert "restate" in offered[1]
|
|
assert loop_of(h.item()).wait == GO_AHEAD
|
|
|
|
|
|
def test_an_unlabelled_issue_waits_for_go_with_no_countdown(h: Harness) -> None:
|
|
h.agent.intake()
|
|
loop = loop_of(h.item())
|
|
assert loop.wait == GO_AHEAD and loop.grace_until == 0
|
|
assert h.item().position != READY
|
|
|
|
|
|
def _msg(text: str, thread: str) -> Any:
|
|
from nisse_dev.transport import InboundMessage
|
|
|
|
return InboundMessage(DON, text, thread=thread)
|
|
|
|
|
|
def test_a_restatement_that_asks_is_sent_back(tmp_path: Path) -> None:
|
|
"""A restatement is agreed to as it stands; one ending in a question
|
|
(models add "Is this right?") never goes up."""
|
|
h = Harness(
|
|
tmp_path,
|
|
ScriptedModel(
|
|
[call("restate", text="I'll add count_words. Is this right?")],
|
|
[call("restate", text="I'll add count_words to text.py, with tests.")],
|
|
),
|
|
FakeGitea(issue()),
|
|
)
|
|
h.agent.intake()
|
|
loop = loop_of(h.item())
|
|
assert (loop.wait, loop.rev) == (GO_AHEAD, 1)
|
|
posted = [p for p in h.posted() if p.startswith("rev-")]
|
|
assert posted == [
|
|
(
|
|
"rev-1: I'll add count_words to text.py, with tests.\n\n"
|
|
"Say go to start, or reply with changes."
|
|
)
|
|
]
|
|
results = [
|
|
e.event.result
|
|
for e in h.store.transcript(h.item().id, "driver").read()
|
|
if hasattr(e.event, "result")
|
|
]
|
|
assert any("a restatement asks no questions" in r for r in results)
|