Files
nisse/nisse-dev/tests/test_views.py
T
dongarandClaude Opus 5.5 37965aefca
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m17s
Fix the run-breaking tool-result order; e2e test runs the slice (M2.1, PR D)
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
2026-10-03 15:11:06 +00:00

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"