From 132b8c6eb64c2f38a687e9d5d23813dc87ad2558 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:00:18 -0400 Subject: [PATCH 1/4] Consume typed input delivery rows and declared native journal records --- src/toad/widgets/input_delivery.py | 16 ++++++--------- tests/native/test_native_journey.py | 6 +++--- tests/support/journals.py | 31 +++++++++++++++++------------ 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/toad/widgets/input_delivery.py b/src/toad/widgets/input_delivery.py index 6c6d3a101..af91ef671 100644 --- a/src/toad/widgets/input_delivery.py +++ b/src/toad/widgets/input_delivery.py @@ -13,6 +13,7 @@ from textual.widgets import Button, Static from textual._activation import PrimaryActivation +from agent_comms.input_attempt import HistoricalInput, UnresolvedInput from agent_comms.ui_model.delivery import InputDelivery from toad.project_link import AcpLogLink from toad.widgets.selection import ChangingText, InlineSelection @@ -117,14 +118,14 @@ def __init__( self, *, log_path: Path | None, - load_history: Callable[[], Awaitable[tuple[dict, ...]]], + load_history: Callable[[], Awaitable[tuple[HistoricalInput, ...]]], dismiss_history: Callable[[], Awaitable[None]], ): super().__init__() self.log_path = log_path self._load_history = load_history self._dismiss_history = dismiss_history - self._historical_inputs: tuple[dict, ...] | None = None + self._historical_inputs: tuple[HistoricalInput, ...] | None = None self._busy = False def compose(self) -> ComposeResult: @@ -167,13 +168,8 @@ def compose(self) -> ComposeResult: yield Button("Close (Esc)", id="delivery-close") @staticmethod - def _records(inputs: tuple[dict, ...]) -> str: - return "\n\n".join( - f"Sequence: {row['sequence'] if row['sequence'] is not None else 'direct input'}" - f" ยท Target: {row['target']}\nInput: {row['inputId']}\n{row['text']}" - + ("\nNotice cleared." if row.get("noticeDismissed") else "") - for row in inputs - ) + def _records(inputs: tuple[UnresolvedInput, ...]) -> str: + return "\n\n".join(row.record_text() for row in inputs) def watch_delivery(self, old: InputDelivery, new: InputDelivery) -> None: if new.history_changed(old): @@ -231,7 +227,7 @@ async def clear_history(self, event: DetailAction.Activated) -> None: async def update_history( self, event: DetailAction.Activated, - operation: Callable[[], Awaitable[tuple[dict, ...] | None]], + operation: Callable[[], Awaitable[tuple[HistoricalInput, ...] | None]], ) -> None: event.stop() if self._busy: diff --git a/tests/native/test_native_journey.py b/tests/native/test_native_journey.py index 993ba87f1..a5f3a3675 100644 --- a/tests/native/test_native_journey.py +++ b/tests/native/test_native_journey.py @@ -5,7 +5,6 @@ from agent_comms.session_file import RegistrySessionLocation import asyncio -import json from dataclasses import replace from pathlib import Path @@ -13,6 +12,7 @@ from agent_comms.acp_extension import QueuePromptRequest from agent_comms.cli_commands import ForkCliCommand, RestartCliCommand, StartCliCommand from agent_comms.input_disposition import InputDispositions +from agent_comms.native_entries import NativeEntry from agent_comms.pi_vocabulary import OffThinkingLevel from agent_comms.threads import Thread @@ -119,10 +119,10 @@ async def test_input_queued_behind_a_running_turn_is_consumed_and_saved(workspac assert not any(isinstance(child, AgentResponse) for child in view.contents.children) assert view.transcript.displayed_cursor is not None - native_rows = [json.loads(line) for line in + native_rows = [NativeEntry.read(line.encode()) for line in Path(comms.registry.require("beta").runtime.path).read_text().splitlines()] disposition = InputDispositions(comms.root / InputDispositions.filename).read().rows["acp:" + queued] - assert any(row.get("message", {}).get("inputId") == disposition.native_id for row in native_rows) + assert any(row.input_id == disposition.native_id for row in native_rows) assert not disposition.unresolved # A second ACP attachment to the same owner shows the same history and asks the model nothing. diff --git a/tests/support/journals.py b/tests/support/journals.py index f61c936f0..8138af1d6 100644 --- a/tests/support/journals.py +++ b/tests/support/journals.py @@ -13,31 +13,36 @@ from pathlib import Path from uuid import uuid4 +from agent_comms.field_codec import RecordedTimestamp +from agent_comms.native_entries import MessageEntry, NativeEntry, RootEntry, SessionEntry +from agent_comms.pi_payloads import ( + AssistantMessage, PiMessage, PiOmitted, ReportedText, TextContent, UnknownMessage, UserMessage, +) -def message_entry(role: str, text: str, *, id: str, parent: str | None = None) -> dict: - """One ``message`` line: `role` is ``assistant`` or ``user``.""" - return {"type": "message", "id": id, "parentId": parent, "timestamp": "2026-10-18T00:00:00+00:00", - "message": {"role": role, "content": [{"type": "text", "text": text}]}} + +def message_entry(message: PiMessage, *, id: str, parent: str | None = None) -> dict: + """One journal message, encoded by its declared Core record.""" + return MessageEntry( + id=id, parent_id=RootEntry.from_input(parent), + timestamp=RecordedTimestamp.written(datetime(2026, 10, 18, tzinfo=UTC)), + message=message, + ).to_wire() def assistant_entry(text: str, *, id: str, parent: str | None = None) -> dict: - return message_entry("assistant", text, id=id, parent=parent) + return message_entry(AssistantMessage(content=(TextContent(text),)), id=id, parent=parent) def user_entry(text: str, *, id: str, parent: str | None = None, input_id: str | None = None) -> dict: """A user line; `input_id` is the identity Core gave the input when it sent it to the agent.""" - entry = message_entry("user", text, id=id, parent=parent) - if input_id is not None: - entry["message"]["inputId"] = input_id - return entry + return message_entry(UserMessage( + content=(TextContent(text),), + input_id=PiOmitted() if input_id is None else ReportedText(input_id), + ), id=id, parent=parent) def write_journal(path: Path, *entries: dict) -> Path: """Write `entries` as the lines of a session journal and return its path.""" - from agent_comms.field_codec import RecordedTimestamp - from agent_comms.native_entries import MessageEntry, NativeEntry, SessionEntry - from agent_comms.pi_payloads import UnknownMessage - for entry in entries: decoded = NativeEntry.read(json.dumps(entry).encode()) if isinstance(decoded, MessageEntry) and isinstance(decoded.message, UnknownMessage): From 5b357238bb21006b118331a05bf5eb0e366a3514 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:11:47 -0400 Subject: [PATCH 2/4] Advance Core lock to main with typed delivery records --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index a85978428..f600ad0ab 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agent-comms" version = "0.1.0" -source = { git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main#83bf48ffe47efaa5baf4fc8ef9511f9c8647d065" } +source = { git = "https://github.com/OpenHCSDev/agent-comms.git?branch=main#34a2a9e3d900147f30d197e267848374d0ed5f1a" } dependencies = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, From 110c8af24afd4da60dedb3b9b871eced9fffdc09 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:19:20 -0400 Subject: [PATCH 3/4] Restore Pi journal fixtures outside typed delivery changes --- tests/native/test_native_journey.py | 6 +++--- tests/support/journals.py | 31 ++++++++++++----------------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/tests/native/test_native_journey.py b/tests/native/test_native_journey.py index a5f3a3675..993ba87f1 100644 --- a/tests/native/test_native_journey.py +++ b/tests/native/test_native_journey.py @@ -5,6 +5,7 @@ from agent_comms.session_file import RegistrySessionLocation import asyncio +import json from dataclasses import replace from pathlib import Path @@ -12,7 +13,6 @@ from agent_comms.acp_extension import QueuePromptRequest from agent_comms.cli_commands import ForkCliCommand, RestartCliCommand, StartCliCommand from agent_comms.input_disposition import InputDispositions -from agent_comms.native_entries import NativeEntry from agent_comms.pi_vocabulary import OffThinkingLevel from agent_comms.threads import Thread @@ -119,10 +119,10 @@ async def test_input_queued_behind_a_running_turn_is_consumed_and_saved(workspac assert not any(isinstance(child, AgentResponse) for child in view.contents.children) assert view.transcript.displayed_cursor is not None - native_rows = [NativeEntry.read(line.encode()) for line in + native_rows = [json.loads(line) for line in Path(comms.registry.require("beta").runtime.path).read_text().splitlines()] disposition = InputDispositions(comms.root / InputDispositions.filename).read().rows["acp:" + queued] - assert any(row.input_id == disposition.native_id for row in native_rows) + assert any(row.get("message", {}).get("inputId") == disposition.native_id for row in native_rows) assert not disposition.unresolved # A second ACP attachment to the same owner shows the same history and asks the model nothing. diff --git a/tests/support/journals.py b/tests/support/journals.py index 8138af1d6..f61c936f0 100644 --- a/tests/support/journals.py +++ b/tests/support/journals.py @@ -13,36 +13,31 @@ from pathlib import Path from uuid import uuid4 -from agent_comms.field_codec import RecordedTimestamp -from agent_comms.native_entries import MessageEntry, NativeEntry, RootEntry, SessionEntry -from agent_comms.pi_payloads import ( - AssistantMessage, PiMessage, PiOmitted, ReportedText, TextContent, UnknownMessage, UserMessage, -) - -def message_entry(message: PiMessage, *, id: str, parent: str | None = None) -> dict: - """One journal message, encoded by its declared Core record.""" - return MessageEntry( - id=id, parent_id=RootEntry.from_input(parent), - timestamp=RecordedTimestamp.written(datetime(2026, 10, 18, tzinfo=UTC)), - message=message, - ).to_wire() +def message_entry(role: str, text: str, *, id: str, parent: str | None = None) -> dict: + """One ``message`` line: `role` is ``assistant`` or ``user``.""" + return {"type": "message", "id": id, "parentId": parent, "timestamp": "2026-10-18T00:00:00+00:00", + "message": {"role": role, "content": [{"type": "text", "text": text}]}} def assistant_entry(text: str, *, id: str, parent: str | None = None) -> dict: - return message_entry(AssistantMessage(content=(TextContent(text),)), id=id, parent=parent) + return message_entry("assistant", text, id=id, parent=parent) def user_entry(text: str, *, id: str, parent: str | None = None, input_id: str | None = None) -> dict: """A user line; `input_id` is the identity Core gave the input when it sent it to the agent.""" - return message_entry(UserMessage( - content=(TextContent(text),), - input_id=PiOmitted() if input_id is None else ReportedText(input_id), - ), id=id, parent=parent) + entry = message_entry("user", text, id=id, parent=parent) + if input_id is not None: + entry["message"]["inputId"] = input_id + return entry def write_journal(path: Path, *entries: dict) -> Path: """Write `entries` as the lines of a session journal and return its path.""" + from agent_comms.field_codec import RecordedTimestamp + from agent_comms.native_entries import MessageEntry, NativeEntry, SessionEntry + from agent_comms.pi_payloads import UnknownMessage + for entry in entries: decoded = NativeEntry.read(json.dumps(entry).encode()) if isinstance(decoded, MessageEntry) and isinstance(decoded.message, UnknownMessage): From 58a11db0335c4896ab93d1d953089538ac36e69e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:25:55 -0400 Subject: [PATCH 4/4] Declare hidden and shown delivery history states --- src/toad/widgets/input_delivery.py | 44 ++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/toad/widgets/input_delivery.py b/src/toad/widgets/input_delivery.py index af91ef671..836f2b79f 100644 --- a/src/toad/widgets/input_delivery.py +++ b/src/toad/widgets/input_delivery.py @@ -1,6 +1,8 @@ """Server-owned current delivery state and dismissible historical notices.""" +from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable +from dataclasses import dataclass from pathlib import Path from typing import ClassVar @@ -95,6 +97,25 @@ def on_mount(self) -> None: self._refresh_summary() +class HistoryView(ABC): + inputs: tuple[HistoricalInput, ...] = () + + @property + @abstractmethod + def load_offered(self) -> bool: ... + + +@dataclass(frozen=True) +class HiddenHistory(HistoryView): + load_offered: ClassVar[bool] = True + + +@dataclass(frozen=True) +class ShownHistory(HistoryView): + inputs: tuple[HistoricalInput, ...] + load_offered: ClassVar[bool] = False + + class InputDeliveryDetails(ModalScreen[None]): BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("escape", "close", "Close")] DEFAULT_CSS = """ @@ -125,7 +146,7 @@ def __init__( self.log_path = log_path self._load_history = load_history self._dismiss_history = dismiss_history - self._historical_inputs: tuple[HistoricalInput, ...] | None = None + self._history: HistoryView = HiddenHistory() self._busy = False def compose(self) -> ComposeResult: @@ -173,7 +194,7 @@ def _records(inputs: tuple[UnresolvedInput, ...]) -> str: def watch_delivery(self, old: InputDelivery, new: InputDelivery) -> None: if new.history_changed(old): - self._historical_inputs = None + self._history = HiddenHistory() self._refresh_records() def watch_overview_text(self, value: str) -> None: @@ -204,30 +225,37 @@ def _refresh_records(self) -> None: count or dismissed ) load = self.query_one("#delivery-load-history", DetailAction) - load.display = bool(count or dismissed) and self._historical_inputs is None + load.display = bool(count or dismissed) and self._history.load_offered load.disabled = self._busy clear = self.query_one("#delivery-clear-history", DetailAction) clear.display = bool(count) clear.disabled = self._busy self.query_one("#delivery-historical-records", Static).update( - self._records(self._historical_inputs or ()) + self._records(self._history.inputs) ) def on_mount(self) -> None: self._refresh_records() self.watch_error() + async def _shown(self) -> HistoryView: + return ShownHistory(await self._load_history()) + + async def _cleared(self) -> HistoryView: + await self._dismiss_history() + return HiddenHistory() + @on(DetailAction.Activated, "#delivery-load-history") async def load_history(self, event: DetailAction.Activated) -> None: - await self.update_history(event, self._load_history) + await self.update_history(event, self._shown) @on(DetailAction.Activated, "#delivery-clear-history") async def clear_history(self, event: DetailAction.Activated) -> None: - await self.update_history(event, self._dismiss_history) + await self.update_history(event, self._cleared) async def update_history( self, event: DetailAction.Activated, - operation: Callable[[], Awaitable[tuple[HistoricalInput, ...] | None]], + operation: Callable[[], Awaitable[HistoryView]], ) -> None: event.stop() if self._busy: @@ -236,7 +264,7 @@ async def update_history( self.error = "" self._refresh_records() try: - self._historical_inputs = await operation() + self._history = await operation() finally: self._busy = False self._refresh_records()