Conversations on the new loop (M2.5, PR A)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m14s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 5m14s
- A top-level message that isn't a command is a conversation: an item in the `general` scope (L1), threaded under the operator's message, with `reply`, `ask_human` and `propose_work` and no workspace or execution. `general` is reserved as a project name. - propose_work(project, title, summary) goes up as a revision for a go-ahead (or a plain yes). On go, under a file_issue intent (L8), the record issue is opened in that project, assigned to the agent and carrying the new item's ID; its item starts agreed, with the summary as its brief. Intake skips any issue carrying an ID (D1); boot adopts a filed issue or puts the proposal up again. - A conversation closes after tuning.loop.general_idle_s (default a day); a reply in its thread then starts a new conversation that continues it. Tests: conversation unit tests (threading, filing, the marker, an unknown project, changes, idle close and continuation, a plain yes, both crash cases, commands at top level); the walk with conversations, proposals and idle time, and records_have_one_item / conversations_do_no_work; an e2e conversation that becomes filed work. 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
d70d43a8cc
commit
3f54896f65
14 files changed
+811
-35
No files matched your search
@@ -0,0 +1,239 @@
|
||||
"""Conversations on the new loop (M2.5, PR A): the `general` scope, a
|
||||
thread per conversation, idle close and continuation (L1, L5), and agreed
|
||||
work filed as a record issue whose item starts as agreed (D1, L8). Real git
|
||||
on the throwaway build host; a fake Gitea."""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from test_loop_milestones import DON, Forge, ScriptedModel, call, loop_agent
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_dev.canonical import Fact, ToolResult
|
||||
from nisse_dev.gitea import Issue
|
||||
from nisse_dev.loop.agent import FILE_ISSUE
|
||||
from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, WAITING, loop_of
|
||||
|
||||
PROPOSAL = call(
|
||||
"propose_work",
|
||||
project="sandbox",
|
||||
title="Add count_lines",
|
||||
summary="Add count_lines(text) to text.py, counting newline-separated lines, "
|
||||
"with tests. First step: the function and its tests.",
|
||||
)
|
||||
|
||||
|
||||
def conversations(world: dict[str, Any]) -> list[Any]:
|
||||
return [i for i in world["store"].work_items() if i.scope == "general"]
|
||||
|
||||
|
||||
def facts(world: dict[str, Any], item: Any) -> list[str]:
|
||||
return [
|
||||
e.event.text
|
||||
for e in world["store"].transcript(item.id, "orchestrator").read()
|
||||
if isinstance(e.event, Fact)
|
||||
]
|
||||
|
||||
|
||||
def quiet(world: dict[str, Any]) -> None:
|
||||
"""No assigned issue: only conversations in these tests."""
|
||||
world["forge"].issues.clear()
|
||||
|
||||
|
||||
def test_a_top_level_message_is_a_conversation_in_its_own_thread(
|
||||
world: dict[str, Any],
|
||||
) -> None:
|
||||
quiet(world)
|
||||
model = ScriptedModel(
|
||||
[call("reply", text="A 4090 runs a 27B model at 4-bit comfortably.")],
|
||||
[call("reply", text="Yes, with a smaller context.")],
|
||||
)
|
||||
agent, transport = loop_agent(world, model)
|
||||
asked = transport.receive(DON, "Could a 4090 run a 27B model?")
|
||||
[chat] = conversations(world)
|
||||
loop = loop_of(chat)
|
||||
assert loop.thread == asked # the thread is under the operator's message
|
||||
assert (chat.position, loop.wait) == (WAITING, CHAT)
|
||||
reply = transport.outbound[-1]
|
||||
assert reply.thread == asked and "4-bit" in reply.text
|
||||
assert 'conversation "Could a 4090 run a 27B model?": talking' in agent.status()
|
||||
|
||||
transport.receive(DON, "And a 32B?", thread=asked)
|
||||
assert len(conversations(world)) == 1 # the same conversation
|
||||
assert transport.outbound[-1].text == "Yes, with a smaller context."
|
||||
|
||||
|
||||
def test_agreed_work_is_filed_and_starts_as_agreed(world: dict[str, Any]) -> None:
|
||||
quiet(world)
|
||||
forge: Forge = world["forge"]
|
||||
model = ScriptedModel(
|
||||
[PROPOSAL],
|
||||
[ # the filed item's first run: straight to work, no restatement
|
||||
call("start_milestone", name="count_lines"),
|
||||
call("write_file", path="lines.py", content="L = 1\n"),
|
||||
call("finish_milestone", summary="count_lines", final="yes"),
|
||||
],
|
||||
)
|
||||
_, transport = loop_agent(world, model)
|
||||
root = transport.receive(DON, "I'd like a line counter in the sandbox.")
|
||||
assert transport.outbound[-1].text.startswith(
|
||||
'rev-1: I\'d open an issue in don/sandbox, "Add count_lines"'
|
||||
)
|
||||
transport.receive(DON, "go", thread=root)
|
||||
|
||||
[filed] = forge.issues
|
||||
marker = re.search(r"<!-- nisse-item: ([\w-]+) -->", filed.body)
|
||||
assert (
|
||||
marker and "count newline-separated lines" not in filed.body
|
||||
) # it's the summary
|
||||
assert "counting newline-separated lines" in filed.body
|
||||
assert forge.assigned == [filed.number]
|
||||
work = world["store"].work_item(marker.group(1))
|
||||
assert work is not None and work.scope == "sandbox"
|
||||
assert loop_of(work).issue == filed.number and loop_of(work).started
|
||||
assert (work.position, loop_of(work).wait) == (WAITING, MERGE) # it worked
|
||||
[pr] = forge.prs
|
||||
assert f"Closes #{filed.number}" in forge.prs[pr]["body"]
|
||||
# The conversation says where the work went and goes on.
|
||||
[chat] = conversations(world)
|
||||
assert loop_of(chat).became == (f"don/sandbox#{filed.number}",)
|
||||
assert loop_of(chat).wait == CHAT
|
||||
assert any("filed from the conversation" in f for f in facts(world, work))
|
||||
|
||||
|
||||
def test_intake_never_takes_a_record_issue(world: dict[str, Any]) -> None:
|
||||
quiet(world)
|
||||
forge: Forge = world["forge"]
|
||||
body = "brief\n\n<!-- nisse-item: sandbox-cabc123 -->"
|
||||
forge.issues.append(Issue(30, "a record", body, "nisse-dev", ()))
|
||||
agent, _ = loop_agent(world, ScriptedModel())
|
||||
agent.intake()
|
||||
assert world["store"].work_items() == []
|
||||
|
||||
|
||||
def test_a_proposal_for_an_unknown_project_is_sent_back(world: dict[str, Any]) -> None:
|
||||
quiet(world)
|
||||
model = ScriptedModel(
|
||||
[call("propose_work", project="nope", title="t", summary="s.")],
|
||||
[call("reply", text="Which project should it go in?")],
|
||||
)
|
||||
_, transport = loop_agent(world, model)
|
||||
transport.receive(DON, "Let's build a thing")
|
||||
results = [
|
||||
e.event.result
|
||||
for e in world["store"].transcript(conversations(world)[0].id, "driver").read()
|
||||
if isinstance(e.event, ToolResult)
|
||||
]
|
||||
assert any("no project 'nope': the projects are sandbox" in r for r in results)
|
||||
assert loop_of(conversations(world)[0]).wait == CHAT
|
||||
|
||||
|
||||
def test_changes_to_a_proposal_go_back_to_the_driver(world: dict[str, Any]) -> None:
|
||||
quiet(world)
|
||||
model = ScriptedModel([PROPOSAL], [PROPOSAL])
|
||||
_, transport = loop_agent(world, model)
|
||||
root = transport.receive(DON, "a line counter please")
|
||||
transport.receive(DON, "call it line_count instead", thread=root)
|
||||
loop = loop_of(conversations(world)[0])
|
||||
assert (loop.wait, loop.rev) == (GO_AHEAD, 2)
|
||||
assert world["forge"].issues == []
|
||||
|
||||
|
||||
def test_an_idle_conversation_closes_and_a_reply_continues_it(
|
||||
world: dict[str, Any],
|
||||
) -> None:
|
||||
quiet(world)
|
||||
model = ScriptedModel(
|
||||
[call("reply", text="Sure.")], [call("reply", text="Picking up again.")]
|
||||
)
|
||||
agent, transport = loop_agent(world, model)
|
||||
clock = [1000.0]
|
||||
agent._clock = lambda: clock[0]
|
||||
agent._idle = 3600
|
||||
root = transport.receive(DON, "thinking about a backup tool")
|
||||
clock[0] += 3599
|
||||
agent.intake()
|
||||
assert conversations(world)[0].status == "open"
|
||||
clock[0] += 1
|
||||
agent.intake()
|
||||
[old] = conversations(world)
|
||||
assert (old.status, old.outcome) == ("closed", "idle")
|
||||
|
||||
transport.receive(DON, "back to that backup tool", thread=root)
|
||||
new = [c for c in conversations(world) if c.status == "open"]
|
||||
assert len(new) == 1 and loop_of(new[0]).follows == old.id
|
||||
assert loop_of(new[0]).thread == root
|
||||
assert any("this continues the conversation" in f for f in facts(world, new[0]))
|
||||
|
||||
|
||||
def test_a_plain_yes_files_the_work(world: dict[str, Any]) -> None:
|
||||
quiet(world)
|
||||
|
||||
class Reader:
|
||||
def agrees(self, proposal: str, reply: str) -> bool:
|
||||
return reply == "sounds right"
|
||||
|
||||
model = ScriptedModel([PROPOSAL], [call("ask_human", question="anything else?")])
|
||||
agent, transport = loop_agent(world, model)
|
||||
agent._reader = Reader()
|
||||
root = transport.receive(DON, "a line counter please")
|
||||
transport.receive(DON, "sounds right", thread=root)
|
||||
assert len(world["forge"].issues) == 1
|
||||
|
||||
|
||||
def test_a_filing_cut_off_by_a_crash_is_settled_at_boot(world: dict[str, Any]) -> None:
|
||||
quiet(world)
|
||||
forge: Forge = world["forge"]
|
||||
model = ScriptedModel([PROPOSAL])
|
||||
_, transport = loop_agent(world, model)
|
||||
root = transport.receive(DON, "a line counter please")
|
||||
chat = conversations(world)[0]
|
||||
store = world["store"]
|
||||
# The issue was opened, then the agent died before its item existed.
|
||||
intent = store.begin_intent(
|
||||
FILE_ISSUE,
|
||||
{"repo": "don/sandbox", "item": "sandbox-cdead01", "title": "Add count_lines"},
|
||||
cause="filing",
|
||||
work_item=chat.id,
|
||||
)
|
||||
forge.issues.append(
|
||||
Issue(
|
||||
40, "Add count_lines", "b\n\n<!-- nisse-item: sandbox-cdead01 -->", "x", ()
|
||||
)
|
||||
)
|
||||
agent2, _ = loop_agent(world, ScriptedModel([call("ask_human", question="ok?")]))
|
||||
agent2.boot()
|
||||
work = store.work_item("sandbox-cdead01")
|
||||
assert work is not None and loop_of(work).issue == 40 and loop_of(work).started
|
||||
assert not [i for i in store.pending_intents() if i.id == intent]
|
||||
assert root # the conversation itself is untouched
|
||||
|
||||
|
||||
def test_a_filing_that_never_reached_gitea_is_put_up_again(
|
||||
world: dict[str, Any],
|
||||
) -> None:
|
||||
quiet(world)
|
||||
_, transport = loop_agent(world, ScriptedModel([PROPOSAL]))
|
||||
transport.receive(DON, "a line counter please")
|
||||
chat = conversations(world)[0]
|
||||
world["store"].begin_intent(
|
||||
FILE_ISSUE,
|
||||
{"repo": "don/sandbox", "item": "sandbox-cnever1", "title": "Add count_lines"},
|
||||
cause="filing",
|
||||
work_item=chat.id,
|
||||
)
|
||||
agent2, _ = loop_agent(world, ScriptedModel())
|
||||
agent2.boot()
|
||||
loop = loop_of(world["store"].work_item(chat.id))
|
||||
assert loop.wait == GO_AHEAD and "cut off by a restart" in loop.note
|
||||
assert world["store"].work_item("sandbox-cnever1") is None
|
||||
|
||||
|
||||
def test_commands_alone_at_top_level_are_not_conversations(
|
||||
world: dict[str, Any],
|
||||
) -> None:
|
||||
quiet(world)
|
||||
_, transport = loop_agent(world, ScriptedModel())
|
||||
transport.receive(DON, "status")
|
||||
transport.receive(DON, "go")
|
||||
assert conversations(world) == []
|
||||
Reference in new issue
Block a user