"""Per-role transcript views (architecture L6; Development Plan M1.2).""" import io from collections.abc import Mapping from pathlib import Path from nisse_core.canonical import ( AssistantText, DelegationCompleted, DelegationStarted, Fact, PlanSubmitted, StoredEvent, ToolCall, ToolResult, UserMessage, ) from nisse_core.oplog import OpLog from nisse_core.render import OpenAIChatRenderer from nisse_core.render_anthropic import AnthropicMessagesRenderer from nisse_core.state import StateStore class Store(StateStore): """An agent's store: the helper is shown the driver's work as narration, the driver only the helper's report.""" SHOWN: Mapping[str, Mapping[str, str]] = { "driver": {"helper": "report"}, "helper": {"driver": "narrate"}, } def new_store(tmp_path: Path) -> StateStore: return Store(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 projection, per role ---------------------------------------------------- def delegated_milestone(store: StateStore) -> None: """A milestone in which the driver failed and handed off to a helper.""" store.transcript("a", "driver").append(PlanSubmitted(plan="1. fix it", model="y")) driver = store.transcript("a", "driver") driver.append( ToolCall(call_id="e1", tool="run_command", arguments={"command": "make"}) ) driver.append(ToolResult(call_id="e1", result="error: missing ;", success=False)) helper = driver.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_driver_sees_the_helpers_report_not_its_turns(tmp_path: Path) -> None: store = new_store(tmp_path) delegated_milestone(store) view = store.transcript("a", "driver").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_drivers_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] == "[driver] 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_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", "driver").read() view = store.transcript("a", "driver").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", "driver").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"