- 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
164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""Conversation on the new loop (M2.7, L5, #42, #77): questions answered
|
|
without changing the item, revisions that say what changed, progress in
|
|
prose at a readable rate, raw tool activity on request, and a note for a
|
|
message that can't be read yet."""
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from test_loop import DON, RESTATED, FakeGitea, Harness, ScriptedModel, call, issue
|
|
|
|
from nisse_dev.canonical import AssistantText
|
|
from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of
|
|
|
|
|
|
class Clock:
|
|
def __init__(self) -> None:
|
|
self.now = 1000.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
def harness(tmp_path: Path, *turns: list[Any], **kw: Any) -> Harness:
|
|
return Harness(tmp_path, ScriptedModel(*turns), FakeGitea(issue()), **kw)
|
|
|
|
|
|
# --- questions never change state (#77) -------------------------------------
|
|
|
|
|
|
def test_a_question_about_a_restatement_leaves_it_up(tmp_path: Path) -> None:
|
|
h = harness(
|
|
tmp_path,
|
|
RESTATED,
|
|
[call("reply", text="Because text.py already holds the string helpers.")],
|
|
[call("ask_human", question="started; anything else?")],
|
|
)
|
|
h.agent.intake()
|
|
h.say("Why text.py?")
|
|
loop = loop_of(h.item())
|
|
assert (h.item().position, loop.wait, loop.rev) == (WAITING, GO_AHEAD, 1)
|
|
assert not loop.question and not loop.started
|
|
assert h.posted()[-1] == "Because text.py already holds the string helpers."
|
|
h.say("go") # still bound to rev-1
|
|
assert loop_of(h.item()).started
|
|
|
|
|
|
def test_a_question_back_keeps_the_agents_own_question_open(tmp_path: Path) -> None:
|
|
h = harness(
|
|
tmp_path,
|
|
[call("ask_human", question="One function or two?")],
|
|
[call("reply", text="One handles words, two would split lines too.")],
|
|
)
|
|
h.agent.intake()
|
|
h.say("What would the second one do?")
|
|
loop = loop_of(h.item())
|
|
assert (loop.wait, loop.note) == (ANSWER, "One function or two?")
|
|
|
|
|
|
def test_a_question_that_isnt_answered_still_goes_back(tmp_path: Path) -> None:
|
|
h = harness(
|
|
tmp_path,
|
|
RESTATED,
|
|
[call("restate", text="sneaky v2", changed="x")], # not offered
|
|
[AssistantText(text="hmm", model="m")],
|
|
[AssistantText(text="hmm", model="m")], # prose twice: the run fails
|
|
)
|
|
h.agent.intake()
|
|
h.say("why?")
|
|
loop = loop_of(h.item())
|
|
# restate isn't offered in a question run: no new revision.
|
|
assert (loop.wait, loop.rev) == (GO_AHEAD, 1)
|
|
assert h.posted()[-1].startswith("I couldn't answer that")
|
|
|
|
|
|
# --- explanations ------------------------------------------------------------
|
|
|
|
|
|
def test_a_revision_says_what_changed(tmp_path: Path) -> None:
|
|
h = harness(
|
|
tmp_path,
|
|
RESTATED,
|
|
[call("restate", text="count_words in words.py", changed="")],
|
|
[
|
|
call(
|
|
"restate",
|
|
text="count_words in words.py",
|
|
changed="a module of its own, as you asked",
|
|
)
|
|
],
|
|
)
|
|
h.agent.intake()
|
|
h.say("put it in its own module")
|
|
loop = loop_of(h.item())
|
|
assert loop.rev == 2
|
|
assert "Changed from rev-1: a module of its own, as you asked" in h.posted()[-1]
|
|
|
|
|
|
# --- progress and details (#42) ----------------------------------------------
|
|
|
|
|
|
def test_progress_is_posted_at_a_readable_rate(tmp_path: Path) -> None:
|
|
clock = Clock()
|
|
|
|
class Ticking(ScriptedModel):
|
|
def complete(self, events, tools, model, system=None): # type: ignore[no-untyped-def]
|
|
clock.now += 30 # each turn takes a while
|
|
return super().complete(events, tools, model, system)
|
|
|
|
h = Harness(
|
|
tmp_path,
|
|
Ticking(
|
|
[
|
|
AssistantText(text="Looking at text.py first.", model="m"),
|
|
call("list_dir", path="."),
|
|
],
|
|
[
|
|
AssistantText(text="Reading the tests too.", model="m"),
|
|
call("list_dir", path="tests"),
|
|
],
|
|
[
|
|
AssistantText(text="x" * 1000, model="m"),
|
|
call("list_dir", path="."),
|
|
],
|
|
[call("restate", text="I'll add count_words.", changed="")],
|
|
),
|
|
FakeGitea(issue()),
|
|
clock=clock,
|
|
)
|
|
h.agent.intake()
|
|
said = h.posted()
|
|
assert "Looking at text.py first." in said
|
|
assert "Reading the tests too." not in said # 30 s later: folded away
|
|
long = [p for p in said if p.startswith("xxx")]
|
|
assert long and len(long[0]) < 420 and long[0].endswith(" …") # 60 s on
|
|
|
|
|
|
def test_details_shows_the_last_tool_calls(tmp_path: Path) -> None:
|
|
h = harness(
|
|
tmp_path,
|
|
[
|
|
call("list_dir", path="src"),
|
|
call("read_file", path="text.py"),
|
|
call("restate", text="I'll add count_words.", changed=""),
|
|
],
|
|
)
|
|
h.agent.intake()
|
|
h.say("details")
|
|
shown = h.posted()[-1]
|
|
assert shown.startswith("The last tool calls on #14:")
|
|
assert "list_dir(src)" in shown and "read_file(text.py)" in shown
|
|
assert loop_of(h.item()).wait == GO_AHEAD # nothing changed
|
|
|
|
|
|
def test_a_message_mid_run_is_acknowledged(tmp_path: Path) -> None:
|
|
h = harness(tmp_path, RESTATED)
|
|
h.agent.intake()
|
|
h.agent._running = True # as if a driver run were under way
|
|
h.agent._active_thread = "elsewhere"
|
|
h.agent._active_name = "#15"
|
|
h.transport.receive(DON, "one more thing", thread=h.thread())
|
|
assert "Noted; I'm in the middle of #15" in h.posted()[-1]
|
|
h.transport.receive(DON, "and another", thread="elsewhere")
|
|
assert h.posted()[-1] == "Noted; I'll read it when this turn ends."
|