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
300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""Per-role transcript views (architecture L6; Development Plan M1.2)."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from nisse_dev.canonical import (
|
|
AssistantText,
|
|
CanonicalEvent,
|
|
DelegationCompleted,
|
|
DelegationStarted,
|
|
Fact,
|
|
PlanSubmitted,
|
|
StoredEvent,
|
|
ToolCall,
|
|
ToolResult,
|
|
UserMessage,
|
|
)
|
|
from nisse_dev.control import MILESTONE_PLANNING, PR_OPEN, Orchestrator
|
|
from nisse_dev.executor import ExecutorOutcome
|
|
from nisse_dev.gitea import Comment, PullRequest
|
|
from nisse_dev.llm import RoleMap
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.planner import LLMPlanningAgent
|
|
from nisse_dev.publish import PublishResult
|
|
from nisse_dev.render import OpenAIChatRenderer
|
|
from nisse_dev.render_anthropic import AnthropicMessagesRenderer
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.transport import InMemoryTransport
|
|
|
|
ROLES = RoleMap(executor="x", planner="y", helper="y", expert="y")
|
|
|
|
|
|
def new_store(tmp_path: Path) -> StateStore:
|
|
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
|
|
|
|
|
def texts(events: list[StoredEvent]) -> list[str]:
|
|
return [getattr(e.event, "text", "") for e in events]
|
|
|
|
|
|
# --- the orchestrator: run 2's planner, after a milestone ----------------------
|
|
|
|
|
|
class PlanningClient:
|
|
"""Always submits a plan, recording the events of each request."""
|
|
|
|
def __init__(self) -> None:
|
|
self.requests: list[list[StoredEvent]] = []
|
|
|
|
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
|
self.requests.append(events)
|
|
n = len(self.requests)
|
|
return [
|
|
ToolCall(
|
|
call_id=f"plan-{n}",
|
|
tool="submit_plan",
|
|
arguments={"content": f"plan {n}"},
|
|
)
|
|
]
|
|
|
|
|
|
class WorkingExecutor:
|
|
"""Leaves the trail a real executor run does: text, calls, results."""
|
|
|
|
def __init__(self) -> None:
|
|
self.views: list[list[StoredEvent]] = []
|
|
|
|
def run(self, transcript: Any, milestone: int) -> ExecutorOutcome:
|
|
self.views.append(transcript.view())
|
|
transcript.append(AssistantText(text="I'll write the helper", model="x"))
|
|
call = ToolCall(
|
|
call_id=f"run-{len(self.views)}",
|
|
tool="run_command",
|
|
arguments={"command": "pytest"},
|
|
)
|
|
transcript.append(call)
|
|
transcript.append(
|
|
ToolResult(call_id=call.call_id, result="1 passed", success=True)
|
|
)
|
|
return ExecutorOutcome("completed", "added truncate() with a test")
|
|
|
|
|
|
class Publisher:
|
|
def reconcilers(self) -> dict[str, Any]:
|
|
return {}
|
|
|
|
def publish(self, **kwargs: Any) -> PublishResult:
|
|
return PublishResult(
|
|
PullRequest(number=165, state="open", head="h", url="https://g/pr/165"),
|
|
"opened PR #165",
|
|
)
|
|
|
|
def push_followup(self, **kwargs: Any) -> str | None:
|
|
return None
|
|
|
|
def comment(self, index: int, body: str) -> None:
|
|
pass
|
|
|
|
|
|
def run_one_milestone(
|
|
tmp_path: Path,
|
|
) -> tuple[Orchestrator, StateStore, InMemoryTransport, PlanningClient, Any]:
|
|
store = new_store(tmp_path)
|
|
transport = InMemoryTransport()
|
|
client = PlanningClient()
|
|
executor = WorkingExecutor()
|
|
orch = Orchestrator(
|
|
store,
|
|
transport,
|
|
LLMPlanningAgent(client, ROLES),
|
|
operators={"don"},
|
|
roles=ROLES,
|
|
executor=executor,
|
|
publisher=Publisher(),
|
|
gitea_operators={"don"},
|
|
)
|
|
orch.start()
|
|
for text in ("add a truncate helper", "use demo", "approve", "approve"):
|
|
transport.receive("don", text)
|
|
transport.receive("don", "start milestone 1")
|
|
assert store.get_state().state == PR_OPEN
|
|
return orch, store, transport, client, executor
|
|
|
|
|
|
def test_the_planner_after_a_milestone_sees_facts_not_executor_turns(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# Run 2: shown the executor's calls as its own turns, the planner took
|
|
# milestone 1's work for its own and planned around it (#78).
|
|
_, store, transport, client, _ = run_one_milestone(tmp_path)
|
|
transport.receive("don", "next milestone")
|
|
assert store.get_state().state != MILESTONE_PLANNING # it planned
|
|
|
|
events = client.requests[-1]
|
|
body = OpenAIChatRenderer().render(events, tools=None, model="y")
|
|
assistant = [m for m in body["messages"] if m["role"] == "assistant"]
|
|
called = [c["function"]["name"] for m in assistant for c in m["tool_calls"]]
|
|
assert set(called) == {"submit_plan"} # its own turns, and only those
|
|
seen = "\n".join(texts(events))
|
|
assert "I'll write the helper" not in seen
|
|
assert "[operator, Matrix, sent while idle] add a truncate helper" in seen
|
|
assert "[orchestrator] arch plan rev-1 approved by don" in seen
|
|
assert "milestone 1 finished: added truncate() with a test" in seen
|
|
assert "PR #165" in seen and "https://g/pr/165" in seen
|
|
assert "planning milestone 2" in seen
|
|
|
|
|
|
def test_the_executor_sees_the_milestone_start_and_the_comment_source(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
orch, _, _, _, executor = run_one_milestone(tmp_path)
|
|
assert "[orchestrator] milestone 1 started" in texts(executor.views[0])
|
|
|
|
orch.handle_review_comment(
|
|
Comment(id=7, author="don", body="add a docstring", created_at="t")
|
|
)
|
|
review_view = texts(executor.views[-1])
|
|
assert "[operator, comment on PR #165] add a docstring" in review_view
|
|
# Its own earlier turn is still its own, not relabelled.
|
|
own = [e for e in executor.views[-1] if isinstance(e.event, AssistantText)]
|
|
assert [e.event.text for e in own] == ["I'll write the helper"]
|
|
|
|
|
|
def test_the_outcome_is_recorded_as_a_fact_when_the_task_closes(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
_, store, transport, _, _ = run_one_milestone(tmp_path)
|
|
item = store.active_item()
|
|
transport.receive("don", "project complete")
|
|
facts = [
|
|
e.event.text
|
|
for e in store.transcript(item.id, "orchestrator").read()
|
|
if isinstance(e.event, Fact)
|
|
]
|
|
assert facts[-1] == "work item completed: don said project complete"
|
|
|
|
|
|
# --- the projection, per role ----------------------------------------------------
|
|
|
|
|
|
def delegated_milestone(store: StateStore) -> None:
|
|
"""A milestone in which the executor failed and handed off to a helper."""
|
|
store.transcript("a", "planner").append(PlanSubmitted(plan="1. fix it", model="y"))
|
|
executor = store.transcript("a", "executor")
|
|
executor.append(
|
|
ToolCall(call_id="e1", tool="run_command", arguments={"command": "make"})
|
|
)
|
|
executor.append(ToolResult(call_id="e1", result="error: missing ;", success=False))
|
|
helper = executor.as_actor("helper")
|
|
helper.append(
|
|
DelegationStarted(brief="make fails", role="helper", model="y", caps={})
|
|
)
|
|
helper.append(AssistantText(text="looking at main.c", model="y"))
|
|
helper.append(
|
|
ToolCall(call_id="h1", tool="read_file", arguments={"path": "main.c"})
|
|
)
|
|
helper.append(ToolResult(call_id="h1", result="int main()", success=True))
|
|
helper.append(
|
|
DelegationCompleted(
|
|
handback="fixed the missing semicolon",
|
|
artifacts=["main.c"],
|
|
tokens=0,
|
|
duration_s=1.0,
|
|
exit_status="completed",
|
|
)
|
|
)
|
|
|
|
|
|
def test_the_executor_sees_the_helpers_report_not_its_turns(tmp_path: Path) -> None:
|
|
store = new_store(tmp_path)
|
|
delegated_milestone(store)
|
|
view = store.transcript("a", "executor").view()
|
|
assert [type(e.event).__name__ for e in view] == [
|
|
"ToolCall",
|
|
"ToolResult",
|
|
"UserMessage",
|
|
]
|
|
assert view[-1].event.text == (
|
|
"[helper report, completed] fixed the missing semicolon"
|
|
)
|
|
|
|
|
|
def test_the_helper_sees_the_plan_and_the_executors_work_as_narration(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = new_store(tmp_path)
|
|
delegated_milestone(store)
|
|
view = store.transcript("a", "helper").view()
|
|
said = texts(view)
|
|
assert said[0] == "[approved plan] 1. fix it"
|
|
assert said[1] == "[executor] run_command(make) → failed: error: missing ;"
|
|
# Its own events are unchanged, tool calls and all.
|
|
assert any(isinstance(e.event, ToolCall) and e.event.call_id == "h1" for e in view)
|
|
assert not any(
|
|
isinstance(e.event, ToolCall) and e.event.call_id == "e1" for e in view
|
|
)
|
|
|
|
|
|
def test_the_planner_sees_no_other_roles_work(tmp_path: Path) -> None:
|
|
store = new_store(tmp_path)
|
|
delegated_milestone(store)
|
|
store.transcript("a", "orchestrator").append(Fact(text="milestone 1 finished"))
|
|
assert texts(store.transcript("a", "planner").view()) == [
|
|
"[orchestrator] milestone 1 finished"
|
|
]
|
|
|
|
|
|
def test_a_view_keeps_log_ids_so_cursors_still_work(tmp_path: Path) -> None:
|
|
store = new_store(tmp_path)
|
|
delegated_milestone(store)
|
|
raw = store.transcript("a", "executor").read()
|
|
view = store.transcript("a", "executor").view(after=raw[2].id)
|
|
assert [e.id for e in view] == [raw[-1].id] # the helper's report
|
|
|
|
|
|
# --- rendering ---------------------------------------------------------------------
|
|
|
|
|
|
def test_both_renderers_show_a_fact_as_a_labelled_user_message() -> None:
|
|
events = [StoredEvent(id=1, ts="", event=Fact(text="PR #3 opened"))]
|
|
openai = OpenAIChatRenderer().render(events, tools=None, model="m")
|
|
assert openai["messages"] == [
|
|
{"role": "user", "content": "[orchestrator] PR #3 opened"}
|
|
]
|
|
anthropic = AnthropicMessagesRenderer().render(events, tools=None, model="m")
|
|
assert anthropic["messages"] == [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "[orchestrator] PR #3 opened"}],
|
|
}
|
|
]
|
|
|
|
|
|
def test_operator_messages_carry_their_channel(tmp_path: Path) -> None:
|
|
store = new_store(tmp_path)
|
|
operator = store.transcript("a", "operator")
|
|
for source in ("transport:@don", "gitea:issue:9:comment:4", "gitea:comment:5"):
|
|
operator.append(UserMessage(text="hi", source=source))
|
|
assert texts(store.transcript("a", "planner").view()) == [
|
|
"[operator, Matrix] hi",
|
|
"[operator, comment on plan issue #9] 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"
|