diff --git a/docs/agents/TESTS.md b/docs/agents/TESTS.md index 83dea21d2..79d1cb756 100644 --- a/docs/agents/TESTS.md +++ b/docs/agents/TESTS.md @@ -210,7 +210,7 @@ The starting point was `main` on 2026-10-11 against the published runtime: 363 i | `channel_any_mode_ui_pilot` | 1 | rewritten | `tests/textual/test_channel_any_mode.py` | The menu item now carries Core's one label, "Toggle member activity"; the old two labels were Toad's own. | | `channel_display_basis_pilot` | 1 | rewritten | `tests/app/test_channel_display_basis.py` | Painted rows are asked as screen text plus Core seen_sequences; has_older/painted_keys became 'channel 9 not shown'. | | `channel_mentions_pilot` | 1 | rewritten | `tests/app/test_channel_mentions.py` | Asserted before the mention candidates had loaded; waits for the list. | -| `channel_participant_publication_pilot` | 1 | rewritten | `tests/textual/test_channel_participants.py` | (ChannelParticipants widget, sys.monitoring count of prepare_participants on worker vs UI thread, kept as counts). Dropped: the ThreadView.presentation counter (no longer called by the participants path) and the loop-gap measurement (never asserted). | +| `channel_participant_publication_pilot` | 1 | superseded | `tests/textual/test_conversation_value.py` | ChannelParticipants and its preparation method were deleted. The current ChannelActivity receives actual member changes through the shared sidebar observer; the conversation journey checks that new members arrive. | | `channel_scope_expansion_pilot` | 1 | rewritten | `tests/app/test_channel_scope_expansion.py`, `tests/textual/test_channel_scope_rows.py` | App test waits on screen text with no explicit refresh; textual test keeps the explicit refresh and row/has_older checks. Both pass; the old AssertionError did not reproduce (old file used runtime_fixture refresh_comms). | | `channel_visibility_observation_pilot` | 1 | rewritten | `tests/textual/test_hidden_channel_refresh.py` | (hidden view's refresh callback, reader pending set). The unused checkpoint_checks helper in the old file was dead code and is not carried over. | | `checkpoint_source_pivot_pilot` | 1 | rewritten | `tests/textual/test_checkpoint_first_source.py` | Expected the saved answer as a response widget inside the history; history is drawn as lines, so the test now requires no live response widget left and the answer drawn exactly once. The covered live response is retired again since the answers-drawn-twice fix (agent_response.py). | @@ -293,7 +293,7 @@ The starting point was `main` on 2026-10-11 against the published runtime: 363 i | `offscreen_tool_hydration_pilot` | 1 | rewritten | `tests/textual/test_offscreen_tool_diff.py` | kept the not-yet-mounted-then-hydrated-on-scroll assertions. Dropped the TOAD_BENCH_EAGER_TOOLS environment switch variant (a benchmark mode that patched _visible_in_window) and the printed millisecond figures. | | `owner_navigation_pilot` | 1 | open defect | `tests/app/test_channel_tab_sharing.py` | A channel has one tab for all sessions (deliberate). What the tab does for a second session is open defect shared-channel-tab. | | `owner_poll_latency_pilot` | 1 | deleted | — | Only printed timings. | -| `owner_reader_reuse_pilot` | 1 | rewritten | `tests/app/test_owner_reader_reuse.py` | (workspace fixture, one test also starts the app). Three tests: shared reader and root change, replaced owner receives no request, bound reader is the app's own service. Uses support.agents.detached and support.coordination.registered_coordination_fact. | +| `owner_reader_reuse_pilot` | 1 | rewritten | `tests/app/test_owner_reader_reuse.py` | Two real-app checks: agents share the application's existing service and another explicit root is refused. Deleted the obsolete per-agent reader reconstruction and root-rebinding expectations. | | `path_filter_pilot` | 1 | rewritten | `tests/model/test_path_filter.py`, `tests/textual/test_path_filter_tree.py` | PathFilter assertions go to model; tree root change and hidden-session deferred reload go to Textual. Hidden session publishes on the MainScreen captured as app.selected_session (WorkspaceScreen has no publish_core); return is ui.select_tab. | | `pending_thread_open_pilot` | 1 | rewritten (needs native package) | `tests/native/test_thread_open_during_slow_load.py`, `tests/support/peers/delayed_load.py`, `tests/native/test_native_journey.py::test_a_second_thread_opened_from_the_app_answers_its_first_message` | The scripted peer (was acp_navigation_server.py) claims a Core thread but publishes no input queue; | | `permission_pending_mount_pilot` | 1 | rewritten | `tests/textual/test_permission_requests.py`, `tests/app/test_permission_across_tabs.py` | The hand-rebinding cases are replaced by app tests through a real scripted agent and real tab switches; all pass. | @@ -327,7 +327,7 @@ The starting point was `main` on 2026-10-11 against the published runtime: 363 i | `stream_style_work_pilot` | 1 | rewritten | `tests/textual/test_old_output_is_not_restyled.py` | kept zero-restyle counts for append and sidebar toggle; dropped printed millisecond figures. | | `t8_mounted_pilot` | 1 | rewritten | `tests/textual/test_command_pane_and_resume_modal.py`, `tests/model/test_saved_sessions_of_this_machine_read_back_typed.py` | Command pane environment and resume modal to Textual (bare App now ThemedApp, saved AgentDefinition has a name). The read-only check of this machine's ~/.local/state/toad/toad.db is its own model test and skips when absent; | | `tab_order_pilot` | 1 | rewritten | `tests/app/test_tab_order.py` | Selected sessions through Textual's switch_mode; asks the harness now. | -| `terminal_crash_pilot` | 1 | rewritten (needs a terminal emulator) | `tests/terminal/test_terminal_crash.py`, `tests/support/terminal_crash_app.py` | Real PTY via support.terminal; script app moved into support; asserts exit 1, one 0600 Terminal_crash file naming fail_after_paint, no locals, error echoed on the terminal. | +| `terminal_crash_pilot` | 1 | rewritten (needs a terminal emulator) | `tests/terminal/test_terminal_crash.py`, `tests/support/terminal_crash_app.py` | Real PTY: a mounted handler failure is displayed, then a channel opens, accepts a draft and quits cleanly. Fatal startup/teardown and recoverable mounted errors have different contracts. | | `test_agent_configuration` | 1 | rewritten | `tests/model/test_agent_configuration.py` | Model layer; the fake that collected post_message is a detached Agent whose events stream is subscribed (ConfigurationChanged from the agent is the old 'message.agent is agent'). | | `test_fixture_journal_retention` | 13 | deleted | — | 13 tests of a cold-storage utility for record files that nothing used. | | `test_shared_acp_transport` | 2 | rewritten | `tests/model/test_sessions_share_one_stdio_process.py`, `tests/textual/test_workspace_tabs_share_one_agent_connection.py`, `tests/support/shared_stdio.py` | Split into the Agent-objects test (model) and the two-tab app test (textual). claim_thread lost start_at_latest (inbox now starts at creation), dropped. The app test needs AGENT_COMMS_AGENT_MODELS=test/one via monkeypatch for the model catalogue. | diff --git a/src/toad/acp/agent.py b/src/toad/acp/agent.py index 1d34dedca..472264fbb 100644 --- a/src/toad/acp/agent.py +++ b/src/toad/acp/agent.py @@ -274,11 +274,11 @@ async def update_project(self, path: str) -> str: raise ValueError("Project changes require an agent-comms thread.") authority = ClientSessionRequest(self, self.session_id) coordination = self.coordination - async with self.controller.transcripts.bind(coordination.wire_root) as comms: - authority.require() - result = await asyncio.to_thread( - comms.threads.set_project, coordination.thread.name, path, - ) + comms = await self.controller.transcripts.service(coordination.wire_root) + authority.require() + result = await asyncio.to_thread( + comms.threads.set_project, coordination.thread.name, path, + ) authority.require() self.coordination = replace(self.coordination, worktree=result.current) self.project_root_path = Path(result.current) diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index 5dadb7adc..7e3f30acf 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -210,9 +210,9 @@ async def _submit(self, prompt, command, authority, queue_scope, project): coordination = self.coordination if coordination is not None: self.require_owner(coordination, authority) - async with self.transcripts.bind(coordination.wire_root) as comms: - origin = await asyncio.to_thread( - self.agent.queue_attachment.capture_human_input, comms, queue_scope) + comms = await self.transcripts.service(coordination.wire_root) + origin = await asyncio.to_thread( + self.agent.queue_attachment.capture_human_input, comms, queue_scope) self.require_owner(coordination, authority) command = replace(command, origin=origin) content = await asyncio.to_thread(build_prompt, project, prompt) @@ -319,13 +319,13 @@ async def request_owner(self, method: str, **params): coordination = self.coordination authority = ClientSessionRequest(self.agent, self.agent.session_id) self.require_owner(coordination, authority) - async with self.transcripts.bind(coordination.wire_root) as comms: - def resolve(): - owner = comms.registry.require(coordination.thread.name) - if owner.incarnation != coordination.thread: - raise ValueError('The owner incarnation changed while preparing the request.') - return RuntimeProxy(OwnerRequestContext(comms), owner.name, socket_path(comms.root, owner.require_process().pid)) - proxy = await asyncio.to_thread(resolve) + comms = await self.transcripts.service(coordination.wire_root) + def resolve(): + owner = comms.registry.require(coordination.thread.name) + if owner.incarnation != coordination.thread: + raise ValueError('The owner incarnation changed while preparing the request.') + return RuntimeProxy(OwnerRequestContext(comms), owner.name, socket_path(comms.root, owner.require_process().pid)) + proxy = await asyncio.to_thread(resolve) try: self.require_owner(coordination, authority) result = await proxy.request(method, **params) diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 4e893cbe5..f64209475 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -1,12 +1,14 @@ """ACP session handshake, durable metadata and reconnection.""" from __future__ import annotations + +from agent_comms.errors import RouteChangedError from agent_comms.declared_family import DeclaredFamily from agent_comms.child_process import join_retirement import asyncio import os from dataclasses import dataclass, replace from collections.abc import Callable -from agent_comms.route_selection import RouteChanged, RouteSelection +from agent_comms.route_selection import RouteSelection from agent_comms.thread_presentation import SessionDisconnected import toad from toad import constants @@ -340,7 +342,7 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: try: await asyncio.to_thread( RouteSelection.capture, self.agent.controller.transcripts.access.launch_root.path) - except RouteChanged as error: + except RouteChangedError as error: self.startup_failed(f"Reconnect blocked before restart: {error}") return await self.agent.stop() @@ -506,12 +508,12 @@ async def rename_coordination(self, display_name: str) -> None: authority = ClientSessionRequest(self.agent, self.agent.session_id) owner_pid = self.agent.coordination.owner_pid - async with self.agent.controller.transcripts.bind(wire_root) as comms: - authority.require() - result = await asyncio.to_thread( - comms.threads.rename_managed_thread, - thread, display_name, owner_pid=owner_pid, - ) + comms = await self.agent.controller.transcripts.service(wire_root) + authority.require() + result = await asyncio.to_thread( + comms.threads.rename_managed_thread, + thread, display_name, owner_pid=owner_pid, + ) authority.require() self.agent.coordination = replace( self.agent.coordination, diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 71b946336..f679fbc66 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -2,6 +2,8 @@ from __future__ import annotations +from agent_comms.errors import RouteChangedError + from dataclasses import dataclass from typing import Any @@ -121,12 +123,11 @@ def input_started(self, update: InputStartedUpdate) -> None: @handles(CoordinationChangedUpdate) def coordination_changed(self, update: CoordinationChangedUpdate) -> None: from pathlib import Path - from agent_comms.route_selection import RouteChanged agent = self.agent root = agent.controller.transcripts.access.launch_root.path if Path(update.wire_root).resolve() != root.resolve(): - raise RouteChanged("ACP owner reported a different coordination root") + raise RouteChangedError("ACP owner reported a different coordination root") agent.coordination = update agent.project_root_path = Path(update.worktree) agent.context_measurement = ContextMeasurement.saved(update.context_usage) diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index 79a32dc35..824e8943e 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -13,7 +13,6 @@ from __future__ import annotations import asyncio -from contextlib import asynccontextmanager from agent_comms.route_selection import RouteSelection from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate @@ -41,12 +40,8 @@ async def thread_presentation(self, root: str, thread: str): """Read one thread presentation through this reader's bound service.""" from toad.owner_preparation import read_thread_presentation - async with self.bind(root) as reader: - return await asyncio.to_thread(read_thread_presentation, reader, thread) - - @asynccontextmanager - async def bind(self, root: str): - yield await self.service(root) + reader = await self.service(root) + return await asyncio.to_thread(read_thread_presentation, reader, thread) async def publication(self, update: TranscriptSnapshotUpdate) -> TranscriptSnapshotUpdate: """The published page while its witness is current, else the current page.""" diff --git a/src/toad/app.py b/src/toad/app.py index 1aa531abe..8ed7b87c2 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -202,11 +202,13 @@ async def _close_all(self) -> None: # View release can fail. The application still owns its observation # pipe and process pools; an Unmount event may never follow that error. async with AsyncResourceRelease() as release: - release.push_async_callback(self.render_processes.aclose) - release.push_async_callback(self.navigation_reader.aclose) release.callback(self.terminal_attention.close) release.callback(self.colours.stop) release.push_async_callback(self.coordination_access.close) + # Accepted preparation survives its UI waiter. Keep the observer + # available until those workers and their read replies finish. + release.push_async_callback(self.render_processes.aclose) + release.push_async_callback(self.navigation_reader.aclose) release.push_async_callback(super()._close_all) release.push_async_callback(self.coordination_access.stop_observing) release.push_async_callback(self.thread_actions.close) diff --git a/src/toad/app_node.py b/src/toad/app_node.py new file mode 100644 index 000000000..841b521de --- /dev/null +++ b/src/toad/app_node.py @@ -0,0 +1,16 @@ +"""Native nodes bound to Toad's application contract.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual.dom import DOMNode + +if TYPE_CHECKING: + from toad.app import ToadApp + + +class ToadNode(DOMNode): + """Keep the application refinement on one shared native parent.""" + + app: ToadApp diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 389acd951..99b46cf52 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -11,6 +11,8 @@ from __future__ import annotations +from agent_comms.errors import RouteChangedError + import asyncio import threading from dataclasses import dataclass, field @@ -22,13 +24,14 @@ from weakref import WeakKeyDictionary, WeakSet from agent_comms.coordination_errors import StaleRevision +from agent_comms.child_process import join_retirement from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.presentation import MessageNotification, NoRevision, ReadState from agent_comms.revision_views import ExternalTranscriptsView, StatusView -from agent_comms.route_selection import RouteChanged, RouteSelection, run_selected_write +from agent_comms.route_selection import RouteSelection, run_selected_write from agent_comms.ui_model.observation import ( ObserveNotifications, NotificationsObserved, NotificationsUnreadable, ReadNotifications, - ObservationProcess, ObserveSidebar, ObserveThreads, ObserveViews, RevisionObserved, + ObservationProcess, ObserverDied, ObserveSidebar, ObserveThreads, ObserveViews, RevisionObserved, SidebarObserved, SidebarPreparing, SidebarUnreadable, ViewsRetired, StopObserving, StopSidebar, ThreadsObserved, ) @@ -36,7 +39,7 @@ from agent_comms.ui_model.status import OpenThreadStatus from toad.core.events import CoreEventStream, CoordinationObserved, ExternalTranscriptChanged, OpenTabsChanged, LinkContextChanged, ObservedServiceChanged, ThreadRowsChanged from toad.core_event_carrier import ThreadCallbackSubscription -from toad.resource_release import ResourceRelease +from toad.resource_release import AsyncResourceRelease, ResourceRelease from toad.preferences import SETTINGS from agent_comms.display_references import PendingReferences, UnavailableReferences @@ -107,7 +110,7 @@ def attach(self, access, previous): waiters, access.sidebar_waiters = access.sidebar_waiters, [] for _, waiter in waiters: if not waiter.done(): - waiter.set_exception(RouteChanged("Comms service changed before the sidebar was read")) + waiter.set_exception(RouteChangedError("Comms service changed before the sidebar was read")) access.revision = NoRevision() access.sidebar_interest = access.sidebar_current = None access.thread_status.clear() @@ -203,7 +206,6 @@ def activate(self, access): self.loop.add_reader(self.process.fileno(), self.receive, access) access.update_sidebar_interest() self.thread_interest(access) - self.updates.callback(self.answer_reads) self.updates.enter_context(access.app.events.subscribe(OpenTabsChanged, self.views_changed)) self.subscriptions.enter_context(self.updates) self.views_changed() @@ -215,6 +217,7 @@ def answer_reads(self): def stop_updates(self) -> AnsweringReads: super().stop_updates() + self.answer_reads() return AnsweringReads( self.selection, self.service, self.sidebar, self.process, self.loop, access=self.access, subscriptions=self.subscriptions, updates=self.updates) @@ -232,8 +235,9 @@ def receive(self, access): try: for result in self.process.results(): access.dispatch_sync(result, self) - except (OSError, ValueError, RuntimeError) as error: + except ObserverDied as error: self.loop.remove_reader(self.process.fileno()) + self.failed(error, self.selection) access.app._handle_exception(error) def views_changed(self, event=None, subscription=None): @@ -253,7 +257,7 @@ def references_for(self, root): async def current_references(self, access, root): try: await access.current_sidebar() - except RouteChanged: + except RouteChangedError: return await access.observation.current_references(access, root) return access.reference_context(root) @@ -331,7 +335,7 @@ def open_current(self) -> Observing | FailedObservation: def require(self, selected: RouteSelection) -> Observing: """The service for ``selected``; a new selection opens it and starts its observation.""" if RouteSelection.capture() != selected: - raise RouteChanged("Comms route changed before the operation") + raise RouteChangedError("Comms route changed before the operation") with self.opening: return self.observation.require(self, selected) @@ -343,7 +347,7 @@ def open_selected(self, selected: RouteSelection) -> Observing: acquisition.enter_context(selected.route.hold_client_route()) service = wire(selected.route) if service.root.resolve() != selected.root or RouteSelection.capture() != selected: - raise RouteChanged("Comms route changed while opening the service") + raise RouteChangedError("Comms route changed while opening the service") observed = Observing( selected, service, SidebarModel(self.app.call_next), ObservationProcess(service.route), self.app.running_loop, access=self) @@ -547,11 +551,16 @@ async def stop_observing(self) -> None: async def close(self) -> None: """Retire the observation once the views it served have closed.""" - await self.stop_observing() - self.observation.subscriptions.close() - self.observation = NotObserving(access=self) - await asyncio.gather(*self.retiring, return_exceptions=True) - self.cleanup.close() + async with AsyncResourceRelease() as release: + release.callback(self.cleanup.close) + try: + with ResourceRelease() as observation: + observation.callback(lambda: self.observation.subscriptions.close()) + await self.stop_observing() + finally: + self.observation = NotObserving(access=self) + for task in tuple(self.retiring): + release.push_async_callback(join_retirement, task) async def route_changed(self) -> bool: previous = self.observation diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index f101d3408..3d1ff299f 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -1,22 +1,34 @@ -"""One declared conversation value owns its destination and history behavior.""" +"""Conversation values own stable destinations; snapshots own their current names.""" from __future__ import annotations + import asyncio from abc import abstractmethod -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar -from agent_comms.historical_views import ChannelDisplayHistory, ConversationHistoryView, DMDisplayHistory -from agent_comms.declared_family import DeclaredFamily + +from agent_comms.channel_targets import ( + ChannelTarget, + IncarnationThreadTarget, + NamedTarget, + ThreadTarget, +) +from agent_comms.channels import AllChannel from agent_comms.comms import Comms +from agent_comms.declared_family import DeclaredFamily +from agent_comms.display_references import CurrentDisplayReferences +from agent_comms.historical_views import ( + ChannelDisplayHistory, + ConversationHistoryView, + DMDisplayHistory, +) from agent_comms.message_page import MessagePage, PageBounds +from agent_comms.thread_identity import ThreadIncarnation from textual.app import ComposeResult -from toad.widgets.input_delivery import InputDeliveryBar -from toad.widgets.native_history import NativeHistory -from toad.widgets.session_details import ConversationSessionDetails + +from toad.status_thread import NoStatusThread, StatusThread from toad.widgets.channel_activity import ChannelActivity -from toad.widgets.observed_thread_activity import NoStatusThread, StatusThread -from agent_comms.channel_targets import ChannelTarget, NamedTarget, ThreadTarget -from agent_comms.channels import AllChannel +from toad.widgets.session_details import ConversationSessionDetails from toad.widgets.sidebar_tree import UnpaintedChannel if TYPE_CHECKING: @@ -25,9 +37,22 @@ @dataclass(frozen=True) class ConversationKind(DeclaredFamily, affix="Conversation"): - target: NamedTarget history_view: ClassVar[type[ConversationHistoryView]] + @property + @abstractmethod + def initial_label(self) -> str: ... + + @property + @abstractmethod + def input_scope(self) -> str: ... + + @abstractmethod + def destination(self, references: CurrentDisplayReferences) -> NamedTarget: ... + + @abstractmethod + def retained_target(self) -> NamedTarget: ... + def threads_needed(self, snapshot, me): return () @@ -38,21 +63,20 @@ def channels_needed(self, root): def view_identity(cls, key): return key - @classmethod @abstractmethod - def page(cls, source: WireSource, bounds: PageBounds) -> MessagePage: ... + def page(self, source: WireSource, bounds: PageBounds) -> MessagePage: ... + @classmethod @abstractmethod - def resolve(self, comms: Comms, me: str) -> ResolvedConversation: ... + def resolve(cls, comms: Comms, me: str, name: str) -> ResolvedConversation: ... - def label(self) -> str: - return self.target.name + def label(self, references: CurrentDisplayReferences) -> str: + return self.destination(references).name def status_thread(self, root, access) -> StatusThread: return NoStatusThread() - def historical_sessions(self, create_view): - """The UI owns construction; the conversation selects its initial identity.""" + def historical_sessions(self, create_view, references): return create_view() @classmethod @@ -67,13 +91,12 @@ async def toggle_dm(cls, screen) -> None: screen.app.session_navigation.direct_messages() def unread(self, observation): - return observation.channel_unread(self.target.name) - - def placeholder(self): - return f"Message {self.target.name}" + return observation.channel_unread( + self.destination(observation.require_snapshot().references).name + ) def send_target(self) -> NamedTarget: - return self.target + return self.retained_target() def read_only(self, comms): return False @@ -86,27 +109,25 @@ async def update_mentions(cls, view, comms) -> None: ... async def status_text(self, comms, *, read_only: bool) -> str: ... def activity_members(self, model): - return (self.target.name,) + return (self.destination(model.observation.require_snapshot().references).name,) def active_members(self, model): - return tuple(model.threads.rows[name] for name in self.activity_members(model) - if model.threads.rows[name].active) - - @classmethod - def activity_details(cls, target: ThreadTarget): - return ConversationSessionDetails(target, history=NativeHistory(), delivery=InputDeliveryBar()) + return tuple( + model.threads.rows[name] + for name in self.activity_members(model) + if model.threads.rows[name].active + ) @classmethod - def compose_activity(cls, view) -> ComposeResult: - view.thread_activity = cls.activity_details(view.conversation.target) - yield view.thread_activity + @abstractmethod + def compose_activity(cls, view) -> ComposeResult: ... @abstractmethod def prompt(self): ... @classmethod def seen_page(cls, history, seen): - """Acknowledge only visible fragments using their original page's proof.""" + """Acknowledge visible fragments using their original page's proof.""" if not seen: return None page = seen[0].evidence @@ -125,8 +146,24 @@ class ChannelConversation(ConversationKind): target: ChannelTarget history_view = ChannelDisplayHistory + @property + def initial_label(self) -> str: + return self.target.name + + @property + def input_scope(self) -> str: + return self.target.name + + def destination(self, references: CurrentDisplayReferences) -> ChannelTarget: + return self.target + + def retained_target(self) -> ChannelTarget: + return self.target + def activity_members(self, model): - return model.channels.rows.get(self.target.name, UnpaintedChannel(self.target.name)).members + return model.channels.rows.get( + self.target.name, UnpaintedChannel(self.target.name) + ).members def channels_needed(self, root): return ((root, self.target.name),) @@ -134,18 +171,21 @@ def channels_needed(self, root): @classmethod def view_identity(cls, key): from toad.session_tracker import ChannelViewAddress + return ChannelViewAddress(key.root, key.conversation) - @classmethod - def page(cls, source: WireSource, bounds: PageBounds) -> MessagePage: + def page(self, source: WireSource, bounds: PageBounds) -> MessagePage: return source.comms.views.channel_display_page( - source.conversation.target.name, viewer=source.viewer, bounds=bounds) + self.target.name, + viewer=source.viewer, bounds=bounds, + ) - def resolve(self, comms: Comms, me: str) -> ResolvedConversation: + @classmethod + def resolve(cls, comms: Comms, me: str, name: str) -> ResolvedConversation: snapshot = comms.registry.snapshot() - resolved = self.target.resolve(snapshot, comms.bus.require_channel) + resolved = ChannelTarget(name).resolve(snapshot, comms.bus.require_channel) return ResolvedConversation( - snapshot.canonical_name(me), replace(self, target=replace(self.target, name=resolved.channel.name)), + snapshot.canonical_name(me), cls(ChannelTarget(resolved.channel.name)) ) @classmethod @@ -155,10 +195,14 @@ def compose_activity(cls, view) -> ComposeResult: async def status_text(self, comms, *, read_only: bool) -> str: return "Read-only saved view" if read_only else "" + @property + def initial_placeholder(self) -> str: + return f"Message {self.target.name}" + def prompt(self): from toad.widgets.channel_prompt import ChannelPrompt - return ChannelPrompt(placeholder=self.placeholder()) + return ChannelPrompt(placeholder=self.initial_placeholder) def read_only(self, comms): return comms.bus.require_channel(self.target.name).read_only @@ -167,8 +211,6 @@ def read_only(self, comms): async def update_mentions(cls, view, comms): from toad.widgets.channel_prompt import ChannelPrompt - # Mention completion consumes the observer's membership snapshot, - # not another registry/catalog read after every channel page. history = view.message_history source = history.source_snapshot() conversation = view.conversation @@ -178,59 +220,91 @@ async def update_mentions(cls, view, comms): observed = access.observation if not observed.uses(comms): raise ValueError("Channel mentions belong to another Comms service") - mentions = await view.app.preparation.run_thread(snapshot.mention_candidates, conversation.target.name) - if not await history.source_is_current(source) or view.conversation != conversation: + mentions = await view.app.preparation.run_thread( + snapshot.mention_candidates, + conversation.destination(snapshot.references).name, + ) + if ( + not await history.source_is_current(source) + or view.conversation != conversation + ): return view.query_one(ChannelPrompt).set_mention_candidates(mentions) @dataclass(frozen=True) class DmConversation(ConversationKind): - target: ThreadTarget + peer: ThreadIncarnation history_view = DMDisplayHistory + initial_label = "Direct messages" + + @property + def input_scope(self) -> str: + return self.peer.require_recorded().lookup + + def destination(self, references: CurrentDisplayReferences) -> ThreadTarget: + return ThreadTarget(references.thread(self.peer).name) + + def retained_target(self) -> IncarnationThreadTarget: + return IncarnationThreadTarget(self.peer) def status_thread(self, root, access) -> StatusThread: - return StatusThread.for_root(root, self.target.name, access) + return StatusThread.for_conversation(root, self, access) - def historical_sessions(self, create_view): - return create_view(name=self.target.name) + def historical_sessions(self, create_view, references): + return create_view(name=self.destination(references).name) @classmethod async def toggle_dm(cls, screen) -> None: await screen.action_back_to_agent() def threads_needed(self, snapshot, me): - return (snapshot.require(me).incarnation, snapshot.require(self.target.name).incarnation) + return snapshot.require(me).incarnation, self.peer def unread(self, observation): - return observation.bus_unread(self.target.name) + return observation.bus_unread( + self.destination(observation.require_snapshot().references).name + ) - @classmethod - def page(cls, source: WireSource, bounds: PageBounds) -> MessagePage: + def page(self, source: WireSource, bounds: PageBounds) -> MessagePage: return source.comms.views.dm_display_page( - source.conversation.target.name, worktree=str(source.project), bounds=bounds) + self.retained_target(), + worktree=str(source.project), bounds=bounds, + ) - def resolve(self, comms: Comms, me: str) -> ResolvedConversation: + @classmethod + def resolve(cls, comms: Comms, me: str, name: str) -> ResolvedConversation: snapshot = comms.registry.snapshot() - resolved = self.target.resolve(snapshot, comms.bus.require_channel) + resolved = ThreadTarget(name).resolve(snapshot, comms.bus.require_channel) return ResolvedConversation( - snapshot.require(me).name, replace(self, target=replace(self.target, name=resolved.thread.name)), + snapshot.require(me).name, cls(resolved.thread.incarnation) ) - def label(self) -> str: - return f"@{self.target.name}" + def label(self, references: CurrentDisplayReferences) -> str: + return f"@{self.destination(references).name}" @classmethod async def update_mentions(cls, view, comms) -> None: """Direct-message composers have no channel mention roster.""" + @classmethod + def compose_activity(cls, view) -> ComposeResult: + view.thread_activity = ConversationSessionDetails(view.conversation.peer) + yield view.thread_activity + def prompt(self): from toad.widgets.prompt import MessagePrompt - return MessagePrompt(placeholder=self.placeholder()) + return MessagePrompt() async def status_text(self, comms, *, read_only: bool) -> str: - return (await asyncio.to_thread(comms.configuration.answer, self.target.name)).status_label + def read(): + references = CurrentDisplayReferences( + str(comms.root), registry=comms.registry.snapshot() + ) + return comms.configuration.answer(self.destination(references).name) + + return (await asyncio.to_thread(read)).status_label class IrcConversation(ChannelConversation): @@ -238,11 +312,10 @@ class IrcConversation(ChannelConversation): async def toggle_irc(cls, screen) -> None: await screen.action_back_to_agent() - def label(self) -> str: + def label(self, references: CurrentDisplayReferences) -> str: return f"{self.target.name} · all comms" - def placeholder(self): - return "Message #all (broadcast)" + initial_placeholder = "Message #all (broadcast)" def send_target(self) -> ChannelTarget: return AllChannel.target() diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index a0592f345..f230ee002 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -3,7 +3,16 @@ import asyncio from dataclasses import dataclass -from agent_comms.errors import HumanSendError +from pathlib import Path + +from agent_comms.errors import ( + CommittedMessageError, + HumanSendError, + HumanSendRefusedError, + RelationViolationError, + RouteChangedError, +) +from agent_comms.messages import Message class SendState: @@ -26,8 +35,8 @@ class SendPending(SendState): @dataclass(frozen=True) class SentUndrawn(SendState): - root: str - sent: object + root: Path + sent: Message @property def reason(self) -> str: @@ -46,17 +55,6 @@ def reason(self) -> str: return f"Send blocked: {self.description}" -@dataclass(frozen=True) -class SendUnknown(SendState): - failure: object - - @property - def reason(self) -> str: - failure = self.failure - return (f"Send UNKNOWN {failure.wire_root_id}/{failure.wire_seq}/{failure.message_id}: " - f"{failure.description}; inspect the bus, do not retry") - - class DeliveryFailureView: """Own human-message delivery and its compose/failure controls.""" @@ -93,49 +91,39 @@ async def refresh_composer(self, read_only: bool, status: str = "") -> None: async def submit_comms_message(self, root, conversation, body, current, present): """CLI transcripts and wire pages use the same Core-send checks.""" - if not body.strip(): - return - async with self.app.coordination_access.transcripts.bind(root) as comms: - read_only = await self.app.preparation.run_thread(conversation.read_only, comms) - await self.refresh_composer(read_only) - if read_only: - self.prompt.restore_draft(body) - self.flash(self.status, style="error") - return - await self.deliver_user_message(root, conversation.send_target().name, body, current, present) - - async def deliver_user_message(self, root, target, body, current, present): - """One route check, write and failure path for CLI, DM and channel input.""" - from agent_comms.route_selection import root_is_current from agent_comms.delivery_failure import delivery_failure + from agent_comms.route_selection import root_is_current + if not body.strip(): + return if self.send_block_reason: self.flash(self.send_block_reason, style="error") return - self._enter_send_state(SendPending()) - - async def require_route(): + try: + comms = await self.app.coordination_access.transcripts.service(root) + read_only = await self.app.preparation.run_thread(conversation.read_only, comms) + await self.refresh_composer(read_only) + if read_only: + self.prompt.restore_draft(body) + self.flash(self.status, style="error") + return + self._enter_send_state(SendPending()) route_current = await self.app.preparation.run_thread(root_is_current, root) if not route_current or not current(): - raise ValueError("Message route changed; reopen this view") - - try: - async with self.app.coordination_access.transcripts.bind(root) as comms: - await require_route() + raise HumanSendRefusedError(RelationViolationError("Message route changed; reopen this view")) + sent = await self.commit_user_message(comms, conversation.send_target(), body) except asyncio.CancelledError: self.delivery_refused("Cancelled before sending", body) raise - except HumanSendError as error: + except (HumanSendError, RouteChangedError) as error: delivery_failure(error).present(self, body) return - sent = await self.commit_user_message(comms, target, body) if sent is None: return await present(sent) async def commit_user_message(self, comms, target, body): from agent_comms.route_selection import run_selected_write - from agent_comms.delivery_failure import delivery_failure task = asyncio.create_task(asyncio.to_thread( run_selected_write, comms.root, comms.messaging.send_user_message, @@ -146,9 +134,8 @@ async def commit_user_message(self, comms, target, body): self.delivery_blocked("Send cancelled while worker may still write; outcome UNKNOWN, inspect the bus, do not retry", body) task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None) return None - except HumanSendError as error: - delivery_failure(error).present(self, body) - return None + except CommittedMessageError as error: + sent = error.record self._enter_send_state(SentUndrawn(comms.root, sent)) return sent @@ -157,11 +144,6 @@ def settle_user_message(self) -> None: if self._send_state.drawn_in(self): self._enter_send_state(NoSend()) - def delivery_unknown(self, failure, body: str) -> None: - self.prompt.restore_draft(body) - self._enter_send_state(SendUnknown(failure)) - self.flash(self.send_block_reason, style="error") - def delivery_blocked(self, description: str, body: str) -> None: self.prompt.restore_draft(body) self._enter_send_state(SendBlocked(description)) diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 0c05bc1b8..8465524e7 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -3,20 +3,20 @@ import asyncio from collections.abc import Callable -from functools import partial from dataclasses import replace -from weakref import ref +from functools import partial from typing import TYPE_CHECKING, ClassVar +from weakref import ref from agent_comms.declared_family import DeclaredFamily from textual._paint_state import PaintLayer, PaintState -from textual.color import Color, TRANSPARENT -from ui_theme.scheme import BackgroundColour, ThemeColour from textual.app import ScreenStackError, UnknownModeError -from textual.widget import Widget +from textual.color import TRANSPARENT, Color from textual.strip import Strip +from textual.widget import Widget from ui_theme.clock import Animation from ui_theme.effects import Effect, Fade, Frame, Mix +from ui_theme.scheme import BackgroundColour, ThemeColour if TYPE_CHECKING: from toad.screens.workspace import WorkspaceScreen @@ -40,7 +40,7 @@ def __init__(self, styles, tone: Color | None, opacity: float, paint) -> None: foreground = paint.paint_foreground self.paint_foreground = styles.paint_foreground if foreground is None else foreground self.auto_color = styles.auto_color if foreground is None else foreground.auto - text_style = paint.text_style + text_style = paint.paint_text_style self.text_style = styles.text_style if text_style is None else text_style tint = paint.background_tint self.background_tint = styles.background_tint if tint is None else tint @@ -52,7 +52,7 @@ class TonePaint: """Paints a screen area on its declared theme tone; ``None`` keeps the parent's.""" tone: ClassVar[type[ThemeColour] | None] = None - paint_background = paint_foreground = text_style = background_tint = None + paint_background = paint_foreground = paint_text_style = background_tint = None paint_opacity = 1.0 @property @@ -320,7 +320,7 @@ def effect_phase(self, effect: Effect[FrameT]) -> EffectPhase: return (EFFECT_UNSTARTED if animation is None else EFFECT_FINISHED if animation.finished else EFFECT_RUNNING) - def on_unmount(self) -> None: + async def on_unmount(self) -> None: self.stop_effect() def on_hide(self) -> None: diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index e09ce6f67..c384f2635 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -19,11 +19,11 @@ from toad.owner_preparation import ExistingOwnerPlan from toad.session_tracker import CommsViewKey -from toad.conversation_kind import ConversationKind ResultT = TypeVar("ResultT") if TYPE_CHECKING: + from toad.navigation_target import HistoryRouted from toad.thread_navigation import ThreadNavigator, ThreadOrigin @@ -45,13 +45,13 @@ class CommsNavigationRequest(NavigationRequest[CommsNavigation]): comms: Comms owner_mode: str me: str - conversation: ConversationKind + route: HistoryRouted owner_root: str | None def read(self) -> CommsNavigation: comms = self.comms root = comms.root - resolved = self.conversation.resolve(comms, self.me) + resolved = self.route.prepare(comms, self.me) owner_root = self.owner_root if owner_root is not None and Path(owner_root).expanduser().resolve() != root: owner_root = None diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index a0ae863c4..ed4311bac 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -3,16 +3,26 @@ from __future__ import annotations from abc import abstractmethod -from agent_comms.declared_family import DeclaredFamily -from agent_comms.channel_targets import ChannelSelection, CommandTarget, ChannelTarget as CoreChannelTarget, ThreadTarget as CoreThreadTarget from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, ClassVar -from toad.conversation_kind import ConversationKind, ChannelConversation, DmConversation, IrcConversation +from agent_comms.channel_targets import ChannelSelection, CommandTarget +from agent_comms.channel_targets import ChannelTarget as CoreChannelTarget +from agent_comms.channel_targets import ThreadTarget as CoreThreadTarget +from agent_comms.declared_family import DeclaredFamily + from toad.constants import ALL_COMMS_TARGET +from toad.conversation_kind import ( + ChannelConversation, + ConversationKind, + DmConversation, + IrcConversation, +) if TYPE_CHECKING: + from agent_comms.display_references import CurrentDisplayReferences + from toad.app import ToadApp @@ -86,17 +96,44 @@ async def open(self, context: NavigationContext) -> str: class HistoryRoute: - """Conversation families declare the history source used for navigation.""" + """A route declaration registers its conversation kind exactly once.""" - @property + routes: ClassVar[dict[type[ConversationKind], type[HistoryRoute]]] = {} + + @classmethod @abstractmethod - def history_kind(self) -> type[ConversationKind]: ... + def for_name(cls, name: str) -> HistoryTarget: ... class HistoryTarget(HistoryRoute, NavigationTarget): + @classmethod + def for_name(cls, name: str) -> HistoryTarget: + return cls(name) + + @classmethod + def for_conversation(cls, conversation: ConversationKind, + references: CurrentDisplayReferences) -> HistoryTarget: + return HistoryRoute.routes[type(conversation)].for_name(conversation.destination(references).name) + + +class HistoryRouted(HistoryTarget, declared_name=None): + """Routed destinations supply their kind; the intermediate is not a destination.""" + + history_kind: ClassVar[type[ConversationKind]] + + def __init_subclass__(cls, **kwargs) -> None: + if cls.history_kind in HistoryRoute.routes: + raise TypeError(f"A history route already serves {cls.history_kind.__name__}") + super().__init_subclass__(**kwargs) + HistoryRoute.routes[cls.history_kind] = cls + + def prepare(self, comms, me): + """Resolve opening intent before retaining a stable conversation key.""" + return self.history_kind.resolve(comms, me, self.name) + async def open(self, context: NavigationContext) -> str: return await context.app.session_navigation.history( owner_mode=context.owner_mode, project_path=context.project_path, - me=context.actor, conversation=self.history_kind(CommandTarget.named(self.name)), + me=context.actor, route=self, ) @@ -111,17 +148,20 @@ def menu_context(self, sidebar, **kwargs): @dataclass(frozen=True) -class FeedTarget(ChannelLike, HistoryTarget): +class FeedTarget(ChannelLike, HistoryRouted): name: str = field(default=ALL_COMMS_TARGET, init=False) history_kind = IrcConversation expanded_by_default = True + @classmethod + def for_name(cls, name: str): + return cls() -class ChannelTarget(ChannelLike, HistoryTarget): +class ChannelTarget(ChannelLike, HistoryRouted): history_kind = ChannelConversation -class DirectTarget(HistoryTarget): +class DirectTarget(HistoryRouted): history_kind = DmConversation def selected(self, owner: NavigationOwner) -> None: diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index f3c46e643..de750a74c 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -89,7 +89,8 @@ def origin(self): @property def navigation_target_name(self) -> str: - return self.conversation.target.name + references = self.app.coordination_access.observation.observed().sidebar.observation.require_snapshot().references + return self.conversation.destination(references).name def rebind_identity(self, identity: str) -> None: with self.changing_identity(): @@ -118,7 +119,7 @@ def compose(self) -> ComposeResult: content = ( (self.create_chat(),) if self._content_loaded - else (Static(f"Opening {self.conversation.target.name}…", id="comms-opening"),) + else (Static(f"Opening {self.conversation.initial_label}…", id="comms-opening"),) ) yield containers.Center( ThreadSidebar( @@ -161,7 +162,8 @@ async def action_historical_sessions(self) -> None: self.notify("No preserved history sources are attached yet.") return self.app.push_screen(self.conversation.historical_sessions( - partial(HistoricalSessions, comms, threads))) + partial(HistoricalSessions, comms, threads), + self.app.coordination_access.observation.observed().sidebar.observation.require_snapshot().references)) def on_mount(self) -> None: if not self._content_loaded: diff --git a/src/toad/screens/historical_sessions.py b/src/toad/screens/historical_sessions.py index 0ebdea1fd..af2bcbc1e 100644 --- a/src/toad/screens/historical_sessions.py +++ b/src/toad/screens/historical_sessions.py @@ -23,7 +23,7 @@ from toad.project_path_owner import ProjectPathOwner from toad.widgets.history_anchor import HistoryWindow from toad.widgets.conversation import Contents -from toad.widgets.transcript_history import TranscriptHistory +from toad.widgets.transcript_history import LineHistory class RecordedContents(Contents): @@ -148,8 +148,8 @@ async def load(**kwargs): prepared = await self.app.preparation.submit(RenderPreparation(TranscriptPageRenderTask(page))) if generation != self._selection_generation or not self.is_attached: return - history = TranscriptHistory(prepared, SavedHistory(load, page.after, self.app.preparation), - notification_view=content) + history = LineHistory(prepared, SavedHistory(load, page.after, self.app.preparation), + notification_view=content) window = self.query_one(HistoryWindow) window.anchor() await content.prepare_history( diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index ede2e3bcf..2fc24996a 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -11,6 +11,9 @@ from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily +from agent_comms.display_references import CurrentDisplayReferences, ReferenceContext +from agent_comms.mro_dispatch import handles +from toad.core.projection import MroProjection from agent_comms.route_selection import RouteSelection from toad.owner_preparation import ExistingOwnerPlan from agent_comms.thread_identity import ThreadIncarnation @@ -18,7 +21,6 @@ from agent_comms.ui_model.sidebar import ThreadRowModel from toad.session_tracker import CommsViewKey, OpenTab, NamedTab, ThreadTab, SessionDetails -from agent_comms.ui_model.unread import UnobservedUnread if TYPE_CHECKING: from toad.session_navigation import SessionNavigation @@ -186,7 +188,7 @@ def forget(self, sessions: SessionNavigation) -> None: sessions.app.session_tracker.close_session(self.mode) -class HistorySessionEntry(SessionEntry): +class HistorySessionEntry(SessionEntry, MroProjection): def __init__(self, mode: str, key: CommsViewKey, project: Path, owner_root: str | None, participants: tuple[ThreadIncarnation, ...], owner) -> None: super().__init__(mode) @@ -222,12 +224,20 @@ def source(self, sessions: SessionNavigation) -> MainScreen | None: return sessions.source(self.key.owner_mode) def unobserved_tab(self) -> OpenTab: - return NamedTab(self.mode, self.key.title) + return NamedTab(self.mode, self.key.conversation.initial_label) def tab(self, sessions: SessionNavigation, observed: Observing) -> OpenTab: - unread = (self.key.conversation.unread(observed.sidebar.observation) - if observed.observes(self.key.root) else UnobservedUnread()) - return NamedTab(self.mode, self.key.title, unread) + return self.dispatch_sync(observed.references_for(self.key.root), observed) + + @handles(ReferenceContext) + def preparing_tab(self, references, observed) -> OpenTab: + return self.unobserved_tab() + + @handles(CurrentDisplayReferences) + def referenced_tab(self, references, observed) -> OpenTab: + conversation = self.key.conversation + return NamedTab(self.mode, conversation.label(references), + conversation.unread(observed.sidebar.observation)) def depends_on(self, mode: str) -> bool: return super().depends_on(mode) or self.key.owner_mode == mode diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index be2b8460f..61bf2e7b7 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -1,5 +1,7 @@ """Opening and closing sessions use the existing workspace's declared factories.""" from __future__ import annotations + +from agent_comms.errors import RouteChangedError import asyncio from toad.core import events as core_events from toad.core.events import CoreEventStream @@ -13,7 +15,7 @@ from agent_comms.presentation import NoRevision, ReadState from agent_comms.revision_views import RegistrationsView from agent_comms.errors import UnknownChannelError, UnregisteredThreadError -from agent_comms.route_selection import RouteChanged, root_is_current +from agent_comms.route_selection import root_is_current from agent_comms.thread_identity import ThreadIncarnation from toad.navigation_preparation import CommsNavigationRequest from toad.session_entry import (HistorySessionEntry, NativeSessionEntry, @@ -23,7 +25,7 @@ if TYPE_CHECKING: from agent_comms.ui_model.sidebar import ThreadRowModel from toad.app import ToadApp - from toad.conversation_kind import ConversationKind + from toad.navigation_target import HistoryRouted from toad.screens.main import MainScreen @@ -181,7 +183,7 @@ async def launch(self, agent_identity: str, *, agent_session_id: str | None = No title=title or "New Session") async def history(self, *, owner_mode: str, project_path: Path, me: str, - conversation: ConversationKind) -> str: + route: HistoryRouted) -> str: from toad.thread_navigation import ThreadOrigin app = self.app @@ -196,8 +198,8 @@ async def history(self, *, owner_mode: str, project_path: Path, me: str, if not origin.current(app.thread_navigation, owner_mode): return app.selected_mode prepared = await app.navigation_reader.read(CommsNavigationRequest( - comms, owner_mode, me, conversation, source.coordination_root)) - except (UnknownChannelError, UnregisteredThreadError, RouteChanged) as error: + comms, owner_mode, me, route, source.coordination_root)) + except (UnknownChannelError, UnregisteredThreadError, RouteChangedError) as error: app.show_error(error, "Comms target unavailable") return app.selected_mode if prepared is None: @@ -311,7 +313,7 @@ async def _retire_requested_views(self) -> None: await self.retire_missing() async def retire_missing(self) -> None: - from agent_comms.route_selection import RouteChanged, RouteSelection + from agent_comms.route_selection import RouteSelection # Borrow each open entry, including hidden views. Core decides # which of their sources no longer exist; Toad only closes the views. @@ -339,7 +341,7 @@ def read(): retired = service.views.retired_views(threads, channels) if RouteSelection.capture(root) != selected: continue # Replaced while reading; the next observation reads it. - except RouteChanged: + except RouteChangedError: continue # The next observation reads the replacement route. answers[root] = service, retired return answers diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 1133b8e25..d78230c5c 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -3,26 +3,26 @@ import asyncio from abc import ABC, abstractmethod from contextlib import asynccontextmanager -from toad.resource_release import AsyncResourceRelease from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING from weakref import ref -from textual.widget import Widget -from textual.widgets.text_area import TextAreaState - from agent_comms.acp_extension import CoordinationChangedUpdate -from agent_comms.channel_targets import ThreadTarget from agent_comms.session_load import EnsuringSessionLoadPlan -from toad.owner_preparation import ExistingOwnerPlan +from agent_comms.ui_model.message_category import MessageCategory +from textual.widget import Widget +from textual.widgets.text_area import TextAreaState -from toad.input_history import InputHistories -from toad.agent_surface import AttachedSurfaceBinding from toad.agent_binding import NoAgentBound +from toad.agent_surface import AttachedSurfaceBinding +from toad.input_history import InputHistories +from toad.owner_preparation import ExistingOwnerPlan +from toad.resource_release import AsyncResourceRelease +from toad.screens.session_view import SessionView from toad.widgets.conversation import Conversation from toad.widgets.history_anchor import ParkedPosition -from agent_comms.ui_model.message_category import MessageCategory +from toad.widgets.prompt import MessagePrompt if TYPE_CHECKING: from toad.app import ToadApp @@ -233,10 +233,7 @@ def agent_info(self, conversation: Conversation) -> str: return conversation.thread_activity.row.presentation.title def prompt(self, conversation: Conversation): - from toad.conversation_kind import DmConversation - - root, name = conversation.transcript_scope() - return DmConversation(ThreadTarget(name)).prompt() + return MessagePrompt() def input_ready(self, conversation: Conversation) -> bool: return conversation.transcript.readable @@ -261,7 +258,7 @@ async def presented(sent): self.external_transcript_changed(view) await view.submit_comms_message( - root, DmConversation(ThreadTarget(target)), event.body, + root, DmConversation(view.query_ancestor(SessionView).thread_identity.thread_incarnation()), event.body, lambda: view.is_attached and view.transcript.binding.thread_scope == scope, presented, ) diff --git a/src/toad/session_tracker.py b/src/toad/session_tracker.py index 17fd604a1..d520ab1cc 100644 --- a/src/toad/session_tracker.py +++ b/src/toad/session_tracker.py @@ -79,11 +79,6 @@ class CommsViewKey: owner_mode: str conversation: ConversationKind - @property - def title(self) -> str: - return self.conversation.label() - - @dataclass(frozen=True) class ChannelViewAddress: root: str diff --git a/src/toad/status_thread.py b/src/toad/status_thread.py new file mode 100644 index 000000000..b2cba98c6 --- /dev/null +++ b/src/toad/status_thread.py @@ -0,0 +1,104 @@ +"""Thread status sources, independent of their mounted activity view.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING + +from agent_comms.ui_model.status import ThreadStatusModel + +from toad.core.events import ThreadRowsChanged +from toad.core_event_carrier import ThreadCallbackSubscription + +if TYPE_CHECKING: + from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, + ) + + +class StatusThread(ABC): + name: str + + @staticmethod + def route(root: Path, access) -> type[RoutedStatusThread]: + return ( + LocalStatusThread + if access.observation.observed().observes(root) + else OtherRouteStatusThread + ) + + @classmethod + def for_root(cls, root: Path, name: str, access) -> StatusThread: + return cls.route(root, access)(root, name) + + @classmethod + def for_conversation(cls, root: Path, conversation, access) -> StatusThread: + return cls.route(root, access).conversation(root, conversation, access) + + @abstractmethod + def bind(self, activity: ObservedThreadActivity) -> None: ... + + def refresh(self, view: ThreadStatusOwner) -> None: + view.thread_activity.bind(self) + + def observe(self, activity: ObservedThreadActivity) -> None: + """Only a local source acquires an observation subscription.""" + + +@dataclass(frozen=True) +class NoStatusThread(StatusThread): + name = "" + + def bind(self, activity: ObservedThreadActivity) -> None: + activity.show(ThreadStatusModel.pending(self.name)) + + def refresh(self, view: ThreadStatusOwner) -> None: + """A conversation without a status source has no row to refresh.""" + + +@dataclass(frozen=True) +class RoutedStatusThread(StatusThread): + root: Path + name: str + + @classmethod + @abstractmethod + def conversation(cls, root: Path, conversation, access) -> RoutedStatusThread: ... + + +class LocalStatusThread(RoutedStatusThread): + @classmethod + def conversation(cls, root: Path, conversation, access) -> LocalStatusThread: + references = ( + access.observation.observed() + .sidebar.observation.require_snapshot() + .references + ) + return cls(root, conversation.destination(references).name) + + def bind(self, activity: ObservedThreadActivity) -> None: + activity.show(activity.app.coordination_access.thread_status.open(self.name)) + + def observe(self, activity: ObservedThreadActivity) -> None: + access = activity.app.coordination_access + activity.observe_core_callback( + access.events, + ThreadRowsChanged, + activity.status_changed, + factory=partial( + ThreadCallbackSubscription, scope=(str(self.root), self.name) + ), + ) + + +class OtherRouteStatusThread(RoutedStatusThread): + @classmethod + def conversation(cls, root: Path, conversation, access) -> OtherRouteStatusThread: + return cls(root, conversation.initial_label) + + def bind(self, activity: ObservedThreadActivity) -> None: + activity.show(ThreadStatusModel.other_route(self.name)) diff --git a/src/toad/target_commands.py b/src/toad/target_commands.py index e75ae05ba..a9cc1343d 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -1,6 +1,8 @@ """One contextual command projection for pointer menus and slash discovery.""" from __future__ import annotations + +from agent_comms.errors import RouteChangedError from toad.core import session_requests from abc import ABC, abstractmethod @@ -11,7 +13,7 @@ from agent_comms.comms import Comms from agent_comms.channel_targets import ChannelSelection, CommandTarget, NoChannel -from agent_comms.route_selection import RouteChanged, root_is_current +from agent_comms.route_selection import root_is_current from toad.slash_command import CommandPresentation, LocalCommand, SlashCommand from agent_comms.cli_commands import CliCommand, TargetAction from toad.thread_actions import ThreadAction @@ -42,7 +44,7 @@ def title(self): def current(self): if not root_is_current(self.comms.root): - raise RouteChanged('Comms route changed; reopen this view') + raise RouteChangedError('Comms route changed; reopen this view') return self def available_actions(self): diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index 34f5df7f6..a932e8e4b 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -1,6 +1,8 @@ """Thread menu declarations own presentation, input collection and execution.""" from __future__ import annotations + +from agent_comms.errors import RouteChangedError from toad.core import events as core_events import asyncio @@ -11,7 +13,7 @@ from agent_comms.cli_commands import TargetAction, TargetEdit, TargetBatchResult, TargetFailed from agent_comms.cli_result import CliResult from agent_comms.native_pi import NativePiUnavailable -from agent_comms.route_selection import RouteChanged, RouteSelection +from agent_comms.route_selection import RouteSelection if TYPE_CHECKING: from toad.app import ToadApp @@ -137,7 +139,7 @@ async def prepare(): fields = await ctx.app.preparation.run_thread( lambda: asyncio.run(definition.prepare_fields())) ctx.current() - except (NativePiUnavailable, RouteChanged) as error: + except (NativePiUnavailable, RouteChangedError) as error: ctx.app.show_error(error, definition.label) return if ctx.app.screen is screen: diff --git a/src/toad/transcript_filter.py b/src/toad/transcript_filter.py index a3d228a0c..2f58584ac 100644 --- a/src/toad/transcript_filter.py +++ b/src/toad/transcript_filter.py @@ -233,6 +233,7 @@ async def advance(self, snapshot): class Filtered(FilterState): active = True + overlay: ProjectedTranscriptHistory def __init__(self, owner: LineHistory, projection: ProjectedTranscriptHistory): super().__init__(owner) @@ -250,6 +251,9 @@ def has_older(self): def scan_available(self): return self.overlay.older_page_available + def scan_needed(self) -> bool: + return self.has_older and not self.overlay.drawn_extent[0] + async def prepare_rows(self, current, *, window, target, rows_needed: int) -> None: if rows_needed: await self.overlay.prepare_rows( diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 79b37edc5..3384cac5e 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -159,9 +159,9 @@ async def extend(self, view, prepared: PreparedTranscriptPage, current) -> bool: if prepared.fragments is not self.fragments: raise ValueError("Transcript input replacement requires a new prepared page") selected = slice(min(self.start, prepared.start), max(self.stop, prepared.stop)) - return await self.replace_range(view, selected, current) + return self.replace_range(view, selected, current) - async def replace_range(self, view, selected, current) -> bool: + def replace_range(self, view, selected, current) -> bool: if not current(): return False if self.fragment_range != (selected.start, selected.stop): @@ -560,7 +560,7 @@ async def route_is_current(self) -> bool: return False def status_thread(self, access): - from toad.widgets.observed_thread_activity import NoStatusThread + from toad.status_thread import NoStatusThread return NoStatusThread() diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 9730aa1b2..6f0833c56 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -2,32 +2,54 @@ from __future__ import annotations import asyncio -from collections import deque from abc import ABC, abstractmethod +from collections import deque from contextlib import AsyncExitStack, asynccontextmanager -from toad.resource_release import AsyncResourceRelease, ResourceRelease -from functools import partial from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, TypeGuard -from agent_comms.transcripts import TranscriptCursor, TranscriptPage -from agent_comms.transcript_events import TranscriptCoverage -from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate +from agent_comms.acp_extension import ( + InputStartedUpdate, + TranscriptDraftUpdate, + TranscriptSnapshotUpdate, +) +from agent_comms.child_process import join_retirement +from agent_comms.coordination_errors import ( + CoordinationReadUnavailable, + StaleRevision, + TranscriptChanged, +) from agent_comms.mro_dispatch import MroDispatch, handles -from agent_comms.ui_model.observation import NotPublished, Published, TranscriptPublicationState -from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision, TranscriptChanged -from textual.worker import Worker, WorkerCancelled +from agent_comms.transcript_events import TranscriptCoverage +from agent_comms.transcripts import TranscriptCursor, TranscriptPage +from agent_comms.ui_model.observation import ( + NotPublished, + Published, + TranscriptPublicationState, +) from textual.await_complete import AwaitComplete -from toad.widgets.history_anchor import HeldReaderDestination, TailPosition, HistoryWindow +from textual.worker import Worker, WorkerCancelled + +from toad.core.events import CoreEventStream +from toad.resource_release import AsyncResourceRelease, ResourceRelease +from toad.screens.session_view import SessionView +from toad.transcript_preparation import PreparedPageSource from toad.widgets.committed_presentation import ( - CommitBarrier, CommitParticipant, CommitEvidence, commit_plan, - protected_blocks, retirement_candidates, + CommitBarrier, + CommitEvidence, + CommitParticipant, + commit_plan, + protected_blocks, + retirement_candidates, +) +from toad.widgets.history_anchor import ( + HeldReaderDestination, + HistoryWindow, + TailPosition, ) -from toad.widgets.transcript_history import TranscriptHistory from toad.widgets.session_details import SessionDetails -from toad.transcript_preparation import PreparedPageSource -from toad.core.events import CoreEventStream -from toad.screens.session_view import SessionView +from toad.widgets.transcript_history import TranscriptHistory if TYPE_CHECKING: from toad.screens.session_view import SessionOrigin @@ -346,9 +368,16 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: TranscriptCoverage(page.events).native_inputs, prepared.outgoing, ) + retired = retirement_candidates(contents.children, evidence) + if not plan.permits(view, retired): + return True + def publication_current(): + return is_current() and plan.permits(view, retired) + + if not await prepared.draw(page.after, publication_current, tuple(retired)): + return True async with AsyncExitStack() as retirement: async with plan.publication(view, prepared): - await prepared.draw(page.after) async with window.history_lock: retired = retirement_candidates(contents.children, evidence) if ( @@ -393,7 +422,7 @@ def thread_scope(self): def covers_outgoing(self, message): return False - def covers_input(self, native_id): + def covers_input(self, started: InputStartedUpdate): return False def close_source(self): @@ -515,8 +544,8 @@ def reports_coverage(self): def covers_outgoing(self, message): return self.history.covers_outgoing(message) - def covers_input(self, native_id): - return self.history.covers_input(native_id) + def covers_input(self, started: InputStartedUpdate): + return self.history.covers_input(started) def close_source(self): self.history.close() @@ -696,14 +725,14 @@ def reports_coverage(self) -> bool: def covers_outgoing(self, message) -> bool: return self.binding.covers_outgoing(message) - def covers_input(self, native_id: str | None) -> bool: + def covers_input(self, started: InputStartedUpdate) -> bool: """Whether saved history already draws the input Core started under this identity. A page read can reach the saved input before its start notice reaches the conversation. Retirement only removes live blocks that exist when a page is accepted, so a start arriving afterwards asks here first. """ - return self.binding.covers_input(native_id) + return self.binding.covers_input(started) def invalidate_publications(self) -> None: """Revoke in-flight view work without forgetting its published source.""" @@ -776,7 +805,7 @@ def retry(self) -> None: self.binding.request(self) def source_work_finished(self, history) -> None: - if self.binding.matches(history.view): + if self.binding.matches(history.conversation): self.retry() async def close(self) -> None: @@ -838,19 +867,7 @@ def retire_presentations(self, contents, output, candidates) -> AwaitComplete: self._retire_presentations(contents, output, candidates), name="accepted source retirement", ) - return AwaitComplete(self._join_retirement(retirement)) - - async def _join_retirement(self, retirement) -> None: - caller = asyncio.current_task() - while not retirement.done(): - try: - await asyncio.shield(retirement) - except asyncio.CancelledError: - if retirement.cancelled(): - raise - retirement.result() - if caller.cancelling(): - raise asyncio.CancelledError + return AwaitComplete(join_retirement(retirement)) async def _retire_presentations(self, contents, output, candidates) -> None: try: diff --git a/src/toad/transcript_source_preparation.py b/src/toad/transcript_source_preparation.py index 59d29b112..e1de77aa4 100644 --- a/src/toad/transcript_source_preparation.py +++ b/src/toad/transcript_source_preparation.py @@ -7,19 +7,35 @@ from functools import partial from typing import TYPE_CHECKING -from textual.worker import Worker, WorkerCancelled from textual.await_complete import AwaitComplete +from textual.worker import Worker, WorkerCancelled + +from toad.core.source_events import TranscriptSourceWorkFinished from toad.core_event_carrier import CoreEventReceiver from toad.resource_release import ResourceRelease -from toad.transcript_state import TranscriptState, LiveTranscript, RetiredSourceTranscript, ParkedSourceTranscript, WorkingTranscript, LatestViewportRequest, LatestViewportState, NoLatestRequest from toad.transcript_preparation import PreparedPageSource, PreparedTranscriptPage -from toad.core.source_events import TranscriptSourceWorkFinished -from toad.widgets.history_anchor import HeldReaderDestination, HistoryWindow, LatestPosition -from toad.widgets.presentation_window import PageEnd, PageRequest, OLDER_END, NEWER_END +from toad.transcript_state import ( + LatestViewportRequest, + LatestViewportState, + LiveTranscript, + NoLatestRequest, + ParkedSourceTranscript, + RetiredSourceTranscript, + TranscriptState, + WorkingTranscript, +) +from toad.widgets.history_anchor import ( + HeldReaderDestination, + HistoryWindow, + LatestPosition, +) +from toad.widgets.presentation_window import NEWER_END, OLDER_END, PageEnd, PageRequest if TYPE_CHECKING: from collections.abc import Callable + from textual.screen import Screen + from toad.widgets.transcript_history import TranscriptPageView @@ -47,6 +63,7 @@ class TranscriptSourcePreparation(CoreEventReceiver): pages: deque[TranscriptPageView] + _source_state: TranscriptState @classmethod def initial_state(cls, view) -> TranscriptState: @@ -68,6 +85,12 @@ def __init__(self, *args, source_state: TranscriptState, page_source: PreparedPa def state(self) -> TranscriptState: return self._source_state.observed(self) + def rows_published(self) -> None: + """Admit source rows and withdraw their live predecessors before yielding.""" + self._source_state.rows_published(self) + for page in self.pages: + page.display = True + @property def can_draw_on_screen(self) -> bool: if not self.state.can_draw: @@ -177,7 +200,8 @@ def request_latest(self, destination: LatestPosition) -> None: self.state.request_latest(self, LatestViewportRequest(destination)) async def jump_latest(self, request: LatestViewportRequest, - publication_current: Callable[[], bool] = lambda: True) -> bool: + publication_current: Callable[[], bool] = lambda: True, *, + initial: PreparedTranscriptPage | None = None) -> bool: """Publish one destination using its captured source and reader position. A preceding page's restoration may have left the window off its tail. @@ -193,7 +217,7 @@ async def jump_latest(self, request: LatestViewportRequest, request.restore(self.window) snapshot = self.source_snapshot() current = lambda: snapshot.current(self) and request.current(snapshot.window) and publication_current() - if await self._publish_latest(current): + if await self._publish_latest(current, initial): # Accepting a wire page advances the reader source. Capture the # published source, rather than the request that acceptance replaced. self.call_after_refresh(self._complete_latest, self.source_snapshot(), request) @@ -201,12 +225,13 @@ async def jump_latest(self, request: LatestViewportRequest, self.latest = request.defer(self) return False - async def _publish_latest(self, current: Callable[[], bool]) -> bool: + async def _publish_latest(self, current: Callable[[], bool], + initial: PreparedTranscriptPage | None = None) -> bool: """Prepare the bottom destination, keeping views of unchanged source pages.""" window = self.window edge = self.pages[-1] source = self.page_source - prepared = await source.latest(edge.prepared) + prepared = await source.latest(edge.prepared if initial is None else initial) latest = prepared rows, limit = window.latest_limits(self.budget) pages_read = 1 @@ -257,17 +282,20 @@ async def _publish_latest(self, current: Callable[[], bool]) -> bool: views.appendleft(view) # Keep existing fragments, prepared lines and documents. for prepared, view in destination: - if view is not None and not await view.prepared.replace_range( + if view is not None and not view.prepared.replace_range( view, slice(prepared.start, prepared.stop), current, ): raise _DrawingStopped self.pages = views except _DrawingStopped: return False - await self.remove_children(page for page in previous if page not in views) + retired = tuple(page for page in previous if page not in views) + for page in retired: + page.display = False source.through = latest.page.after self._update_edges() self.rows_published() + await self.remove_children(retired) return True @property diff --git a/src/toad/transcript_state.py b/src/toad/transcript_state.py index 8c057e998..920e17219 100644 --- a/src/toad/transcript_state.py +++ b/src/toad/transcript_state.py @@ -418,17 +418,27 @@ class CommitTranscript(WorkingTranscript): """ def __init__(self, source: TranscriptState, owner, live_blocks: tuple[Widget, ...]): + from textual.await_complete import AwaitComplete + super().__init__(source, owner) self.live_blocks = live_blocks + self.claims = tuple(cast("CommitParticipant", block).commit_claim for block in live_blocks) + self.retirement = AwaitComplete.nothing() + + async def execute(self, owner, work): + try: + return await super().execute(owner, work) + finally: + await self.retirement def arriving(self, prepared, followed_end: bool): # Rows its live blocks already showed have not arrived again; every other row has. # The live blocks are the commit participants `retirement_candidates` chose. - claims = tuple(cast("CommitParticipant", block).commit_claim for block in self.live_blocks) return tuple(fragment for fragment in super().arriving(prepared, followed_end) - if not any(fragment.shown_live_by(claim) for claim in claims)) + if not any(fragment.shown_live_by(claim) for claim in self.claims)) def rows_published(self, owner) -> None: - if not NEWER_END.visible_more(owner): - for block in self.live_blocks: - block.display = False + if self.live_blocks and not NEWER_END.visible_more(owner): + blocks, self.live_blocks = self.live_blocks, () + view = owner.view + self.retirement = view.transcript.retire_presentations(view.contents, view.output, blocks) diff --git a/src/toad/widgets/channel_activity.py b/src/toad/widgets/channel_activity.py index 38c9dd295..5c8ced1a0 100644 --- a/src/toad/widgets/channel_activity.py +++ b/src/toad/widgets/channel_activity.py @@ -1,20 +1,23 @@ """Live channel members use the sidebar's keyed widget reconciliation.""" -from agent_comms.channel_targets import ThreadTarget +from agent_comms.thread_identity import ThreadIncarnation from textual import widgets -from textual.content import Content from textual.containers import VerticalGroup +from textual.content import Content from toad.screens.session_view import SessionView -from toad.widgets.observed_thread_activity import StatusThread +from toad.status_thread import StatusThread +from toad.widgets.session_details import ConversationSessionDetails from toad.widgets.sidebar_tree import KeyedRows, SidebarChange, SidebarModelView -class ChannelActivity(SidebarModelView, KeyedRows, VerticalGroup): +class ChannelActivity(SidebarModelView, KeyedRows[ThreadIncarnation], VerticalGroup): def __init__(self, view): self.view = view self._members = VerticalGroup() - self.details = widgets.Collapsible(self._members, title="0 active", collapsed=True) + self.details = widgets.Collapsible( + self._members, title="0 active", collapsed=True + ) self.details.styles.padding = 0 super().__init__() self.styles.overflow_y = "auto" @@ -29,7 +32,11 @@ def member_container(self): @property def rows(self): - return {row.target.name: row for row in self.member_container.children if not row.is_leaving} + return { + row.peer: row + for row in self.member_container.children + if not row.is_leaving + } @property def members(self): @@ -37,11 +44,12 @@ def members(self): @property def member_keys(self): - return tuple(row.name for row in self.members) + return tuple(row.incarnation for row in self.members) def accepts_observation(self) -> bool: - return (self.view.query_ancestor(SessionView).is_current - and self.model_state.serves(self.view._bound_root)) + return self.view.query_ancestor( + SessionView + ).is_current and self.model_state.serves(self.view._bound_root) def model_replaced(self) -> None: self.member_container.remove_children() @@ -49,16 +57,27 @@ def model_replaced(self) -> None: def sync(self, *, change: SidebarChange, reorder: bool = False) -> None: change.apply_activity(self) - def create_row(self, name): - row = self.view.conversation.activity_details(ThreadTarget(name)) - row.thread = StatusThread.for_root( - self.model_state.root(), name, self.app.coordination_access) - return row + def create_row(self, peer: ThreadIncarnation): + return ConversationSessionDetails(peer) + + def update_row(self, peer: ThreadIncarnation, row) -> None: + references = self.model_state.shown.observation.require_snapshot().references + row.bind(StatusThread.for_root( + self.model_state.root(), references.thread(peer).name, + self.app.coordination_access, + )) + + def changed_keys(self, names: frozenset[str]) -> frozenset[ThreadIncarnation]: + return frozenset( + member.incarnation for member in self.members if member.name in names + ) | frozenset( + row.peer for row in self.rows.values() if row.row.name in names + ) - def sync_members(self, changed: frozenset[str]) -> None: + def sync_members(self, changed: frozenset[ThreadIncarnation]) -> None: if self.is_attached and not self.is_leaving: members = self.members names = ", ".join(row.name for row in members[:2]) remaining = f" +{len(members) - 2}" if len(members) > 2 else "" self.details.title = Content(f"{len(members)} active · {names}{remaining}") - self.reconcile_keys(tuple(row.name for row in members), changed=changed) + self.reconcile_keys(tuple(row.incarnation for row in members), changed=changed) diff --git a/src/toad/widgets/committed_presentation.py b/src/toad/widgets/committed_presentation.py index b05037f7a..a681a5b6b 100644 --- a/src/toad/widgets/committed_presentation.py +++ b/src/toad/widgets/committed_presentation.py @@ -1,10 +1,9 @@ """Source evidence and viewport intent govern retirement of live presentation.""" from __future__ import annotations -from toad.widgets.presentation_window import NEWER_END from abc import ABC, abstractmethod -from collections.abc import Callable, Iterable, Iterator +from collections.abc import Callable, Iterable from functools import partial from dataclasses import dataclass from contextlib import asynccontextmanager @@ -12,17 +11,15 @@ from agent_comms.transcripts import TranscriptCursor, TranscriptPage from textual.widget import Widget -from agent_comms.transcript_events import TranscriptEvent -from toad.transcript_state import CommitTranscript, LatestViewportRequest +from toad.transcript_state import CommitTranscript from agent_comms.sent_message import PublishedSentMessage, OutgoingMessageReference from toad.core.source_events import TranscriptCoverage from agent_comms.acp_extension import InputStartedUpdate -from toad.block_navigation import ConversationEntry -from toad.widgets.history_anchor import LatestPosition from toad.widgets.presentation_window import protected_presentations if TYPE_CHECKING: + from toad.widgets.transcript_history import CommittedHistory from toad.widgets.conversation import Conversation from toad.widgets.history_anchor import HistoryWindow from toad.transcript_preparation import PreparedTranscriptPage @@ -155,105 +152,6 @@ class CommitBarrier: CAPTURED_CLAIM = CapturedClaim() RETAINED_SOURCE_CLAIM = RetainedSourceClaim() -class CommittedHistory(ConversationEntry, SnapshotPresentation): - """A saved source block that precedes the conversation's live tail.""" - - conversation_order = 0 - - @property - def commit_claim(self) -> CommitClaim: - return RETAINED_SOURCE_CLAIM - - def accepts_commit(self, cursor: TranscriptCursor) -> bool: - return self.page_source.accepts_commit(cursor) - - def covered_sequences(self, sequences: frozenset[int]) -> frozenset[int]: - return frozenset(sequence for sequence in sequences if self.covers_incoming(sequence)) - - def covers_outgoing(self, message: PublishedSentMessage) -> bool: - return message.reference in self.outgoing_coverage - - def source_resumed(self) -> None: - if self._source_state.reports_coverage: - self.publish_core(TranscriptCoverage(tuple(self.coverage_events))) - - async def _page_added(self, page: TranscriptPage, fragments: tuple[TranscriptFragment, ...]) -> None: - # The live blocks this page covers are retired as part of adding - # the page, without holding the conversation's message pump. A - # saved viewer outside a conversation has no live blocks. - if self._source_state.reports_coverage and (conversation := self.conversation) is not None: - conversation.transcript.binding.covered( - conversation.transcript, TranscriptCoverage(page.events), self).call_when_ready(conversation) - - def publish_committed(self) -> None: - """Acquire source ownership now; report coverage after its first painted frame.""" - self._source_state = self._source_state.publish() - self.call_after_refresh(self.publish_coverage) - self.request_preparation() - - def publish_coverage(self) -> None: - if self._source_state.reports_coverage: - self.publish_core(TranscriptCoverage(tuple(self.coverage_events))) - - @property - def coverage_events(self) -> Iterator[TranscriptEvent]: - return (event for page in self.pages for event in page.prepared.page.events) - - @property - def outgoing_coverage(self): - if self._source_state.reports_coverage: - references = frozenset(reference for page in self.pages - for reference in page.prepared.outgoing_coverage) - return references | self.filter.overlay.outgoing_coverage - return frozenset() - - def covers_incoming(self, sequence: int) -> bool: - if not self._source_state.reports_coverage: - return False - if self.page_source.through.covers_incoming(sequence): - return True - if sequence in TranscriptCoverage(tuple(self.coverage_events)).sequences: - return True - return self.filter.overlay.covers_incoming(sequence) - - def covers_input(self, native_id: str | None) -> bool: - """Whether the loaded saved pages hold the input Core started under this identity.""" - return (self._source_state.reports_coverage - and native_id in TranscriptCoverage(tuple(self.coverage_events)).native_inputs) - - def retain_committed(self, through: TranscriptCursor) -> None: - """Extend access to saved source without moving the displayed page window.""" - if not self.accepts_commit(through): - raise ValueError("Commit replaces or rewinds the retained source") - self.extend_source(through) - - async def advance_committed( - self, through: TranscriptCursor, is_current: Callable[[], bool], - live_blocks: tuple[Widget, ...], - ) -> bool: - """Extend a complete followed tail; read a bounded destination across a gap. - - The pages already drawn stay. The saved rows replace `live_blocks`, - the live copies of the same turn, in the change that draws them. - """ - async with self.window.history_lock: - if not self.source_ready or not is_current(): - return False - if not self.accepts_commit(through): - raise ValueError("Commit replaces or rewinds the saved source") - operation = self.reserve_source_work(partial(CommitTranscript, live_blocks=live_blocks)) - return await operation.execute(self, partial(self._advance_committed, through, is_current)) - - async def _advance_committed( - self, through: TranscriptCursor, is_current: Callable[[], bool], - ) -> bool: - if not is_current() or not self.state.can_draw: - return False - if not NEWER_END.visible_more(self): - return await self.extend(through, is_current) - self.retain_committed(through) - request = LatestViewportRequest(LatestPosition.hold(self.window)) - return await self.jump_latest(request, is_current) def retirement_candidates(widgets: Iterable[Widget], evidence: CommitEvidence) -> list[Widget]: return [widget for widget in widgets @@ -278,20 +176,50 @@ class PreparedCommit: def outgoing(self) -> frozenset[OutgoingMessageReference]: return self.history.outgoing_coverage - async def draw(self, cursor: TranscriptCursor) -> None: - """An extended range already owns its drawn pages.""" + async def draw(self, cursor: TranscriptCursor, is_current: Callable[[], bool], + live_blocks: tuple[Widget, ...]) -> bool: + """A retained viewport changes source access without drawing rows.""" + return True @dataclass(frozen=True) -class PreparedPageCommit(PreparedCommit): +class PreparedSourceCommit(PreparedCommit, ABC): + """Every saved-row mutation shares the live-block handoff operation.""" + page: PreparedTranscriptPage + @classmethod + async def capture(cls, view, history, page, sequences): + from toad.transcript_preparation import TranscriptPageRenderTask + from toad.work_preparation import RenderPreparation + + prepared = await view.app.preparation.submit(RenderPreparation(TranscriptPageRenderTask(page))) + return cls(history, sequences, prepared) + @property def outgoing(self) -> frozenset[OutgoingMessageReference]: - return self.page.outgoing_coverage + return self.history.outgoing_coverage | self.page.outgoing_coverage + + async def draw(self, cursor, is_current, live_blocks) -> bool: + history = self.history + async with history.window.history_lock: + if not history.source_ready or not is_current(): + return False + operation = history.reserve_source_work(partial(CommitTranscript, live_blocks=live_blocks)) + return await operation.execute(history, partial(self.publish, cursor, is_current)) + + @abstractmethod + async def publish(self, cursor: TranscriptCursor, is_current: Callable[[], bool]) -> bool: ... + + +class PreparedAdvanceCommit(PreparedSourceCommit): + async def publish(self, cursor, is_current): + return await self.history.advance_committed(cursor, is_current) - async def draw(self, cursor: TranscriptCursor) -> None: - await self.history.show_newest(cursor, self.page) + +class PreparedPageCommit(PreparedSourceCommit): + async def publish(self, cursor, is_current): + return await self.history.show_newest(cursor, self.page, publication_current=is_current) class CommitPlan(ABC): @@ -311,13 +239,9 @@ async def prepare(self, view: Conversation, history: CommittedHistory, page: TranscriptPage, captured: frozenset[Widget], is_current: Callable[[], bool]) -> PreparedCommit | None: """Grow a history that accepts the page; otherwise the page replaces it.""" - from toad.transcript_preparation import TranscriptPageRenderTask - from toad.work_preparation import RenderPreparation - if history.accepts_commit(page.after): return await self.extend(view, history, page, captured, is_current) - prepared = await view.app.preparation.submit(RenderPreparation(TranscriptPageRenderTask(page))) - return PreparedPageCommit(history, TranscriptCoverage(page.events).sequences, prepared) + return await PreparedPageCommit.capture(view, history, page, TranscriptCoverage(page.events).sequences) @abstractmethod async def extend(self, view: Conversation, history: CommittedHistory, page: TranscriptPage, @@ -343,15 +267,8 @@ def current(self, window: HistoryWindow) -> bool: return window.follows_tail and window.scroll_revision == self.revision async def extend(self, view, history, page, captured, is_current): - # The drawn history grows in place: unchanged pages keep their views, - # and the captured live blocks leave in the change that draws their saved rows. - live_blocks = tuple(retirement_candidates( - captured, CommitEvidence(frozenset(captured), retained_history=history), - )) - if not await history.advance_committed(page.after, is_current, live_blocks): - return None - return PreparedCommit(history, TranscriptCoverage(page.events).sequences - | history.covered_sequences(required_sequences(captured))) + return await PreparedAdvanceCommit.capture(view, history, page, TranscriptCoverage(page.events).sequences + | history.covered_sequences(required_sequences(captured))) def finish(self, view, cursor): view.transcript.binding.painted(view.transcript, cursor) diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 89265b292..4f95dbaac 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -14,6 +14,7 @@ from agent_comms.comms import Comms from agent_comms.field_codec import FieldCodec +from agent_comms.display_references import CurrentDisplayReferences, ReferenceContext from agent_comms.mro_dispatch import handles from toad.core import events as core_events from toad.core.source_events import TranscriptSourceWorkFinished @@ -33,7 +34,7 @@ CursorContainer, Window, ) -from toad.widgets.observed_thread_activity import StatusThread +from toad.status_thread import StatusThread from toad.widgets.flash import Flash from toad.widgets.prompt import Prompt from toad.screens.session_view import SessionView @@ -75,7 +76,7 @@ def __init__( # The observer's announcement the last refresh began from. self._refreshed_from = None self._bound_root = Path(wire_root).resolve() if wire_root is not None else None - self.input_histories.bind_scope(f"comms:{FieldCodec.encode(type(conversation))}:{conversation.target.name}") + self.input_histories.bind_scope(f"comms:{FieldCodec.encode(type(conversation))}:{conversation.input_scope}") self.message_contents = Contents(id="contents") self.message_history = WireHistory.empty(view=self, notification_view=self.message_contents) @@ -112,12 +113,12 @@ async def initialize_view(self) -> None: f"Conversation root {self._bound_root} differs from observed root {shared.root}" ) comms = shared + await self.app.coordination_access.current_sidebar() self.message_history.open(WireSource( comms, self.conversation, Path(self.project_path), self._me, IrcMessageStyle(), )) - self.agent_info = Content(self.conversation.label()) - self.refresh_thread_status() + self.refresh_identity(comms.root) self.prepare_prompt() self.window.anchor() # CommsScreen has already presented its route before mounting this @@ -197,8 +198,20 @@ def prepare_prompt(self) -> None: prompt.focus() def watch_agent_binding(self, agent) -> None: - """Keep the inherited agent reactive from turning this into a shell session.""" - self.agent_info = Content(self.conversation.label()) + """A message composer has no operational agent projection.""" + + def refresh_identity(self, root) -> None: + self.dispatch_sync(self.app.coordination_access.reference_context(str(root))) + + @handles(ReferenceContext) + def preparing_identity(self, references) -> None: + self.agent_info = Content(self.conversation.initial_label) + + @handles(CurrentDisplayReferences) + def referenced_identity(self, references) -> object: + self.agent_info = Content(self.conversation.label(references)) + self.refresh_thread_status() + return self.DISCARD def transcript_scope(self) -> None: """DM/channel history belongs to WireHistory, not a native transcript.""" @@ -275,6 +288,7 @@ async def _refresh_labels(self, source, changed: bool, newly_announced: bool, re or not self.query_ancestor(SessionView).is_current): return self.call_after_refresh(history.mark_visible) + self.refresh_identity(source.loader.comms.root) if not changed: return status = await source.status_text(read_only=read_only) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index fe6f00798..0a47dee66 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1,30 +1,22 @@ from __future__ import annotations -from toad.core import input_events -from toad.core_event_carrier import CoreEventMessage -from toad.widget_view import WorkspaceHosted - -from toad.conversation_submission import ConversationSubmissions -from toad.live_output import LiveOutput, ThoughtStream -from toad.transcript_publication import BoundView, TranscriptPresentation, ViewBinding -from toad.goal_interaction import GoalSession - -from toad.core.preference_events import PreferenceChanged -from toad.preferences import SETTINGS import asyncio from abc import abstractmethod from contextlib import suppress -from toad.resource_release import AsyncResourceRelease +from functools import cached_property from itertools import filterfalse - from operator import attrgetter from pathlib import Path from time import monotonic, time_ns from typing import TYPE_CHECKING, Any, Callable, Literal +from acp import schema as acp_protocol from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution +from agent_comms.goals import NoGoal from agent_comms.mro_dispatch import handles -from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications +from agent_comms.ui_model.delivery import InputDelivery +from agent_comms.ui_model.goal import GoalDisplay +from agent_comms.ui_model.message_category import MessageCategory, all_categories from rich.segment import Segment from rich.text import Text as RichText from textual import containers, events, getters, on, work @@ -41,64 +33,84 @@ from textual.strip import Strip from textual.widget import Widget from textual.widgets import Static +from ui_theme.effects import CURSOR_BLINK, Mix from toad import messages, paths -from toad.widgets.splash_text import ThreadLoading - -from toad.core import events as core_events -from toad.widgets.comms_menu import ContextMenu -from toad.conversation_comms_handlers import ConversationCommsHandlers -from toad.core_event_carrier import CoreEventReceiver -from acp import schema as acp_protocol -from toad.acp.status import StopReason from toad.acp.comms_updates import NoPendingThreadRead -from toad.core.events import AgentFail, AgentReady +from toad.acp.status import StopReason from toad.agent import AgentBase from toad.agent_binding import AgentBinding, NoAgentBound -from toad.widgets.terminal_tool import TerminalTool +from toad.agent_presentation import AgentAttachmentView from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.app import ToadApp -from toad.core.source_events import CurrentWorkingDirectoryChanged, TranscriptCoverage, TranscriptSourceWorkFinished -from toad.input_history import InputHistories -from toad.widgets.flash import Flash -from toad.widgets.menu import Menu -from toad.widgets.note import Note -from toad.widgets.prompt import Prompt -from toad.widgets.terminal import Terminal -from toad.widgets.throbber import AboveThrobber, BelowThrobber -from toad.frame_presentation import Animated -from ui_theme.effects import CURSOR_BLINK, Mix -from agent_comms.ui_model.delivery import InputDelivery -from agent_comms.goals import NoGoal -from agent_comms.ui_model.goal import GoalDisplay -from toad.session_observation import GoalObservation, InputDeliveryObservation, SessionObservation -from toad.widgets.goal_bar import GoalBar -from toad.widgets.native_history import NativeHistory -from toad.widgets.observed_thread_activity import ( - NoStatusThread, ObservedThreadActivity, StatusThread, ThreadStatusOwner, +from toad.block_navigation import ( + ContentNavigation, + ConversationBlock, + ConversationEntry, + DownCursor, + UpCursor, + require_blocks, ) -from toad.widgets.cli_draft import CliDraft -from toad.widgets.transcript_history import JumpToLatest, TranscriptHistory -from toad.widgets.session_details import OperationalSessionDetails, SessionDetails +from toad.command_catalog import CommandCatalog +from toad.conversation_comms_handlers import ConversationCommsHandlers +from toad.conversation_submission import ConversationSubmissions +from toad.conversation_turn import ConversationTurn, TurnOwner +from toad.core import events as core_events +from toad.core import input_events +from toad.core.events import AgentFail, AgentReady +from toad.core.preference_events import PreferenceChanged +from toad.core.source_events import ( + CurrentWorkingDirectoryChanged, + TranscriptCoverage, + TranscriptSourceWorkFinished, +) +from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver +from toad.frame_presentation import Animated +from toad.goal_interaction import GoalSession +from toad.input_history import InputHistories +from toad.layout import trim_trailing_margin +from toad.live_output import LiveOutput, ThoughtStream +from toad.menus import MenuItem +from toad.preferences import SETTINGS from toad.private_native_cursor import CursorStatus, HiddenCursorStatus -from toad.block_navigation import require_blocks, ConversationBlock, ConversationEntry, ContentNavigation, UpCursor, DownCursor -from functools import cached_property -from toad.agent_presentation import AgentAttachmentView -from toad.conversation_turn import TurnOwner, ConversationTurn +from toad.resource_release import AsyncResourceRelease +from toad.session_observation import ( + GoalObservation, + InputDeliveryObservation, + SessionObservation, +) from toad.shell import Shell +from toad.status_thread import NoStatusThread, StatusThread +from toad.transcript_publication import BoundView, TranscriptPresentation, ViewBinding +from toad.widget_view import WorkspaceHosted +from toad.widgets.cli_draft import CliDraft +from toad.widgets.comms_menu import ContextMenu +from toad.widgets.flash import Flash +from toad.widgets.goal_bar import GoalBar from toad.widgets.history_anchor import HistoryWindow from toad.widgets.input_delivery import ( DetailAction, InputDeliveryBar, InputDeliveryDetails, ) -from toad.widgets.user_input import UserInput -from agent_comms.ui_model.message_category import all_categories, MessageCategory -from toad.layout import trim_trailing_margin -from toad.command_catalog import CommandCatalog -from toad.menus import MenuItem +from toad.widgets.menu import Menu +from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications +from toad.widgets.native_history import NativeHistory +from toad.widgets.note import Note +from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, +) +from toad.widgets.prompt import Prompt +from toad.widgets.session_details import OperationalSessionDetails, SessionDetails from toad.widgets.shell_terminal import ShellTerminal +from toad.widgets.splash_text import ThreadLoading +from toad.widgets.terminal import Terminal +from toad.widgets.terminal_tool import TerminalTool +from toad.widgets.throbber import AboveThrobber, BelowThrobber +from toad.widgets.transcript_history import JumpToLatest, TranscriptHistory +from toad.widgets.user_input import UserInput AUTO_SESSION_TITLE_MAX_LENGTH = 50 @@ -380,9 +392,6 @@ def thread_observed(self, event: core_events.ThreadRowsChanged) -> None: self.arm_thread_read() read.settle(presentation).present(self.thread_presented) - def foreign_thread_read(self, presentation) -> None: - self.thread_presented(presentation) - def thread_presented(self, presentation) -> None: """A settled presentation refreshes the transcript source of the shown view.""" from toad.screens.session_view import SessionView @@ -712,11 +721,9 @@ def watch_agent_binding(self, binding: AgentBinding) -> None: self.update_slash_commands() -from toad.widget_actions import DeclaredWidgetActions from toad.conversation_actions import ConversationAction - - from toad.delivery_failure_view import DeliveryFailureView +from toad.widget_actions import DeclaredWidgetActions class Conversation(DeliveryFailureView, ConversationCommsHandlers, DeclaredWidgetActions, ConversationSessionBinding): @@ -1331,7 +1338,7 @@ async def present_started_input(self, started) -> None: self.output.boundary() if started.text: - if not self.transcript.covers_input(started.native_id): + if not self.transcript.covers_input(started): await self.post(UserInput(started.text, claim=StartedInputClaim(started))) self.submissions.native_input_presented(started) diff --git a/src/toad/widgets/highlighted_textarea.py b/src/toad/widgets/highlighted_textarea.py index 975f54512..01832cc73 100644 --- a/src/toad/widgets/highlighted_textarea.py +++ b/src/toad/widgets/highlighted_textarea.py @@ -151,8 +151,7 @@ def highlight_shell(self, text: str) -> Content: @on(TextArea.Changed) def _on_changed(self) -> None: - self._highlight_lines = None - self._text_cache.clear() + self._clear_caches() def get_line(self, line_index: int) -> Text: if (cached_line := self._text_cache.get(line_index)) is not None: diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index 49f9303f6..d5a2fe5d5 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -171,7 +171,7 @@ class SourceNotifications: notification_view: HistoryNotifications def rows_published(self) -> None: - self._source_state.rows_published(self) + super().rows_published() self.notification_view.call_after_refresh(self.notification_view.check_notifications) def prepare_viewport(self) -> None: diff --git a/src/toad/widgets/observed_thread_activity.py b/src/toad/widgets/observed_thread_activity.py index 685954d5f..6336aacb9 100644 --- a/src/toad/widgets/observed_thread_activity.py +++ b/src/toad/widgets/observed_thread_activity.py @@ -1,98 +1,18 @@ -"""An open thread's status line, observed locally or read from its foreign root.""" +"""An open thread's status line, observed locally or marked on another route.""" from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass -from pathlib import Path -from functools import partial -from agent_comms.coordination_errors import CoordinationReadUnavailable -from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.status import ThreadStatusModel -from agent_comms.ui_model.observation import UnreadableHistory from textual.message import Message from textual.reactive import var from textual.widget import Widget -from toad.core.events import ThreadRowsChanged -from toad.core_event_carrier import CoreEventReceiver, ThreadCallbackSubscription - - -class StatusThread(ABC): - """The status source selected by a view, independent of ACP turn permissions.""" - - name: str - - @classmethod - def for_root(cls, root: Path, name: str, access) -> StatusThread: - """Both the session binding and Comms service supply absolute wire roots.""" - if access.observation.observed().observes(root): - return LocalStatusThread(root, name) - return ForeignStatusThread(root, name) - - @abstractmethod - def bind(self, activity: ObservedThreadActivity) -> None: ... - - def refresh(self, view: ThreadStatusOwner) -> None: - view.thread_activity.bind(self) - - def observe(self, activity: ObservedThreadActivity) -> None: - """Only a local source acquires a scoped observation subscription.""" - - -@dataclass(frozen=True) -class NoStatusThread(StatusThread): - name = "" - def bind(self, activity: ObservedThreadActivity) -> None: - activity.show(ThreadStatusModel.pending(self.name)) - - def refresh(self, view: ThreadStatusOwner) -> None: - """A conversation without a status source has no row to refresh.""" - - -@dataclass(frozen=True) -class LocalStatusThread(StatusThread): - root: Path - name: str - - def bind(self, activity: ObservedThreadActivity) -> None: - activity.show(activity.app.coordination_access.thread_status.open(self.name)) - - def observe(self, activity: ObservedThreadActivity) -> None: - access = activity.app.coordination_access - activity.observe_core_callback( - access.events, ThreadRowsChanged, activity.status_changed, - factory=partial(ThreadCallbackSubscription, - scope=(str(self.root), self.name))) - - -@dataclass(frozen=True) -class ForeignStatusThread(StatusThread): - root: Path - name: str - - def bind(self, activity: ObservedThreadActivity) -> None: - activity.show(ThreadStatusModel.pending(self.name)) - - def refresh(self, view: ThreadStatusOwner) -> None: - super().refresh(view) - view.run_worker(self.read_foreign_thread(view), group="foreign-thread-status", exclusive=True) - - async def read_foreign_thread(self, view: ThreadStatusOwner) -> None: - try: - presentation = await view.app.coordination_access.transcripts.thread_presentation( - str(self.root), self.name) - row = ThreadStatusModel.known(self.name, presentation) - except CoordinationReadUnavailable: - return # The next coordination change retries a busy store. - except (OSError, ValueError, RuntimeError) as error: - presentation = None - row = ThreadStatusModel.unreadable(self.name, UnreadableHistory.capture(error)) - if view.status_thread() != self: - return - view.thread_activity.show(row) - view.foreign_thread_read(presentation) +from toad.app_node import ToadNode +from toad.core.events import ThreadRowsChanged +from toad.core_event_carrier import CoreEventReceiver +from toad.status_thread import NoStatusThread, StatusThread class ActivityView: @@ -124,7 +44,7 @@ def working(self) -> bool: return False -class ObservedThreadActivity(CoreEventReceiver, Widget, ActivityView): +class ObservedThreadActivity(CoreEventReceiver, ToadNode, Widget, ActivityView): """The composed status row; observation never starts or settles an ACP turn.""" row: var[ThreadStatusModel] = var(lambda: ThreadStatusModel.pending(""), init=False) @@ -142,10 +62,11 @@ def on_mount(self) -> None: def bind(self, thread: StatusThread) -> None: if thread != self.thread: - self.retire_core_observations(self.app.coordination_access.events) self.thread = thread - thread.observe(self) - thread.bind(self) + if self.is_mounted: + self.retire_core_observations(self.app.coordination_access.events) + thread.observe(self) + thread.bind(self) def status_changed(self, event: ThreadRowsChanged) -> None: self.thread.bind(self) @@ -164,7 +85,7 @@ def working(self) -> bool: return self.row.status.working -class ThreadStatusOwner: +class ThreadStatusOwner(ToadNode): """A view with a composed row and a declared status source.""" # Before compose there is no status view yet; the kind supplies its view. @@ -181,8 +102,5 @@ def status_thread(self) -> StatusThread: def thread_observed(self, event: ThreadRowsChanged) -> None: """Consume the observed thread's presentation once per store change.""" - def foreign_thread_read(self, presentation: ThreadPresentation | None) -> None: - """Consume a foreign thread's presentation after its own read.""" - def refresh_thread_status(self) -> None: self.status_thread().refresh(self) diff --git a/src/toad/widgets/presentation_window.py b/src/toad/widgets/presentation_window.py index 7244792aa..74e42f524 100644 --- a/src/toad/widgets/presentation_window.py +++ b/src/toad/widgets/presentation_window.py @@ -224,7 +224,7 @@ async def load_page(self, loader, cursor, through): def wire_page(self, source, cursor, through): return source.conversation.page(source, PageBounds( cursor.older(), - source.tail_limit if cursor == through else source.page_limit, source.max_bytes)) + source.tail_limit if cursor == through else source.page_limit)) def advance_empty(self, edge, staged): edge.page = replace(edge.page, before=staged.page.before, has_older=staged.page.has_older) @@ -258,7 +258,7 @@ async def load_page(self, loader, cursor, through): return await through.read_page(loader, after=cursor) def wire_page(self, source, cursor, through): - page = source.conversation.page(source, PageBounds(cursor.newer(), source.page_limit, source.max_bytes)) + page = source.conversation.page(source, PageBounds(cursor.newer(), source.page_limit)) kept = tuple(message for message in page.messages if message.view_key[0] or message.seq <= through.cursor.sequence) return replace(page, messages=kept, has_newer=False) if len(kept) < len(page.messages) else page diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 05184e67f..654b9347f 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -271,6 +271,7 @@ def can_enter_shell(self) -> bool: return self.submission_allowed and not self.text and not self.shell_mode def watch_slash_commands(self, slash_commands: list[SlashCommand]) -> None: + self._clear_caches() self.notify_style_update() self.refresh() @@ -924,13 +925,14 @@ def _update_queue_summary(self) -> None: pass def watch_agent_info(self, agent_info: Content) -> None: - pass + if self.is_mounted: + self.update_prompt() def project_directory_updated(self) -> None: pass def update_prompt(self) -> None: - self.prompt_text_area.placeholder = self.simple_placeholder or "Message thread" + self.prompt_text_area.placeholder = self.simple_placeholder or f"Message {self.agent_info.plain or 'thread'}" self.prompt_text_area.highlight_language = "markdown" self.prompt_label.refresh() diff --git a/src/toad/widgets/selection.py b/src/toad/widgets/selection.py index 7875b3be7..2d861855f 100644 --- a/src/toad/widgets/selection.py +++ b/src/toad/widgets/selection.py @@ -4,20 +4,25 @@ Hover is an input to those owners, never another independently rendered state. """ -from textual import events, on +from typing import ClassVar + from rich.segment import Segment -from textual.content import Content +from rich.style import Style +from textual import events, on from textual._paint_state import PaintState +from textual.content import Content +from textual.message import Message from textual.strip import Strip -from ui_theme.effects import NEW_TEXT, SPINNER, Frame, TextFrame -from ui_theme.scheme import ThemeColour, BlockCursorForegroundColour, BlockCursorBackgroundColour -from typing import ClassVar from textual.visual import VisualType +from textual.widgets import ListItem, ListView, OptionList, Static +from ui_theme.effects import NEW_TEXT, SPINNER, Frame, TextFrame +from ui_theme.scheme import ( + BlockCursorBackgroundColour, + BlockCursorForegroundColour, + ThemeColour, +) from toad.frame_presentation import Animated, UntintedTonePaint -from rich.style import Style -from textual.message import Message -from textual.widgets import ListItem, ListView, OptionList, Static class ChangingText[FrameT: Frame](Animated[FrameT], Static): @@ -92,7 +97,7 @@ def paint_background(self): if self.paint_highlighted and self.highlight_background else None) @property - def text_style(self): + def paint_text_style(self): return self.highlighted_text_style if self.paint_highlighted else None def watch_mouse_hover(self): diff --git a/src/toad/widgets/session_details.py b/src/toad/widgets/session_details.py index 2ac381d8f..752eabd7e 100644 --- a/src/toad/widgets/session_details.py +++ b/src/toad/widgets/session_details.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from agent_comms.mro_dispatch import handles +from agent_comms.thread_identity import ThreadIncarnation from agent_comms.thread_presentation import ( ThreadPresentationStatus, SessionDisconnected, WorkingThreadPresentationStatus, ThreadRetired, @@ -157,18 +158,19 @@ class ConversationSessionDetails(SessionDetails): DEFAULT_ID = None - def __init__(self, target, **kwargs) -> None: - self.target = target - super().__init__(**kwargs) + def __init__(self, peer: ThreadIncarnation) -> None: + self.peer = peer + super().__init__(history=NativeHistory(), delivery=InputDeliveryBar()) @property def label(self) -> str: - return self.target.name + return self.row.name async def _on_collapsible_title_toggle(self, event) -> None: event.prevent_default() event.stop() context = self.app.selected_session.navigation_context + references = self.app.coordination_access.observation.observed().sidebar.observation.require_snapshot().references await self.app.thread_navigation.open( owner_mode=context.owner_mode, project_path=context.project_path, - target=self.target.name) + target=references.thread(self.peer).name) diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 783ab49e7..62e960c9c 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -1,35 +1,40 @@ -from toad.core import events as core_events -from toad.core_event_carrier import CoreEventMessage -from toad.core import session_requests import asyncio from collections.abc import Iterable from typing import ClassVar +from agent_comms.mro_dispatch import handles from rich.style import Style as RichStyle from textual import containers, events, getters, widgets +from textual._activation import PrimaryActivation from textual.app import ComposeResult, RenderResult from textual.binding import BindingType +from textual.color import TRANSPARENT from textual.content import Content -from textual.geometry import Offset, Region, Spacing from textual.css.scalar import Scalar, ScalarOffset -from textual.color import TRANSPARENT +from textual.geometry import Offset, Region, Spacing from textual.layouts.vertical import VerticalLayout -from toad.widget_view import AUTO, FILL, WidgetView from textual.message import Message from textual.renderables.bar import Bar from textual.style import Style -from ui_theme.scheme import AccentColour, ErrorColour, ForegroundColour, HeaderToneColour, MutedColour -from textual._activation import PrimaryActivation from textual.widget import Widget +from ui_theme.effects import NEW_TEXT, Mix +from ui_theme.scheme import ( + AccentColour, + ErrorColour, + ForegroundColour, + HeaderToneColour, + MutedColour, +) from toad import messages -from agent_comms.mro_dispatch import handles -from toad.core.events import SessionChangedEvent -from toad.core_event_carrier import CoreEventReceiver from toad.app import ToadApp -from toad.session_tracker import OpenTab +from toad.core import events as core_events +from toad.core import session_requests +from toad.core.events import SessionChangedEvent +from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from toad.frame_presentation import Animated -from ui_theme.effects import NEW_TEXT, Mix +from toad.session_tracker import OpenTab +from toad.widget_view import AUTO, FILL, WidgetView from toad.widgets.selection import ChangingText, ViewLabel @@ -44,7 +49,7 @@ def current(self) -> bool: return self.id == self.app.selected_mode @property - def text_style(self): + def paint_text_style(self): return RichStyle(dim=not self.mouse_hover) @property @@ -286,7 +291,7 @@ async def _row_state_changed(self, event: CoreEventMessage) -> None: if geometry_changed: self.call_after_refresh(self.show_selected_tab) - @handles(core_events.OpenTabsChanged) + @handles(core_events.OpenTabsChanged, core_events.LinkContextChanged) async def _tabs_changed(self, event: CoreEventMessage) -> None: """A thread tab is a thread row: it repaints when an open session's state changes too.""" if self.screen.is_active: diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index f3e76b445..67f3e3f17 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -147,19 +147,19 @@ def take(self, key, destination): return row -class KeyedRows: +class KeyedRows[KeyT]: """Retain native member widgets by model key; the container owns their order.""" - def keep_row(self, key: str) -> bool: + def keep_row(self, key: KeyT) -> bool: return False - def should_mount(self, key: str) -> bool: + def should_mount(self, key: KeyT) -> bool: return True - def create_row(self, key: str): + def create_row(self, key: KeyT): raise NotImplementedError - def update_row(self, key: str, row) -> None: + def update_row(self, key: KeyT, row) -> None: pass def retire_row(self, row) -> None: @@ -171,7 +171,7 @@ def rows_changed(self) -> None: def place_row(self, row, index: int) -> None: """Publish list-position facts only for a new or moved row.""" - def reconcile_keys(self, keys, *, changed: frozenset[str]): + def reconcile_keys(self, keys, *, changed: frozenset[KeyT]): rows = self.rows retained = set(rows.values()) previous = {row: index for index, row in enumerate( @@ -435,7 +435,8 @@ def apply_relationships(self, groups) -> None: pass def apply_activity(self, view) -> None: - if view.view.conversation.target.name in self.names: + references = view.model_state.shown.observation.require_snapshot().references + if view.view.conversation.destination(references).name in self.names: view.call_next(view.sync_members, frozenset(view.member_keys)) @@ -457,7 +458,7 @@ def apply_relationships(self, groups) -> None: group.sync_members(changed=changed) def apply_activity(self, view) -> None: - changed = self.names.intersection((*view.member_keys, *view.rows)) + changed = view.changed_keys(self.names) if changed: view.call_next(view.sync_members, changed) diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 0c3dfa3c3..5d38ff64d 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -1,65 +1,107 @@ """Bounded, cursor-paged saved history; all transcript interpretation is model-owned.""" from __future__ import annotations -from toad.block_navigation import ConversationEntry, FragmentBlockCursor -from toad.screens.session_view import SessionView - -from agent_comms.ui_model.message_category import OtherCategory import asyncio from bisect import bisect_right from collections import deque -from contextlib import ExitStack, asynccontextmanager +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import AsyncExitStack, ExitStack, asynccontextmanager from dataclasses import dataclass, field, replace from functools import cached_property, partial from itertools import chain, islice -from collections.abc import AsyncIterator, Callable, Iterator from typing import TYPE_CHECKING from weakref import ref -from agent_comms.transcripts import TranscriptCursor, TranscriptPage +from agent_comms.acp_extension import InputStartedUpdate from agent_comms.mro_dispatch import handles +from agent_comms.sent_message import PublishedSentMessage from agent_comms.transcript_events import TranscriptEvent +from agent_comms.transcripts import TranscriptCursor, TranscriptPage +from agent_comms.ui_model.message_category import ( + MessageCategory, + OtherCategory, + all_categories, +) +from rich.style import Style as RichStyle from textual import events, on +from textual._activation import PrimaryActivation from textual._measurement import INDEPENDENT_HEIGHT, height_dependency +from textual._paint_state import PaintState from textual.app import ComposeResult from textual.containers import VerticalGroup from textual.geometry import Region from textual.message import Message from textual.selection import Selection -from textual._activation import PrimaryActivation -from textual.widget import Widget -from textual._paint_state import PaintState from textual.strip import Strip +from textual.widget import Widget from textual.widgets import Static +from ui_theme.effects import NEW_TEXT +from toad.block_navigation import ConversationEntry, FragmentBlockCursor +from toad.core.source_events import TranscriptCoverage +from toad.line_blocks import ToggleDetails +from toad.message_rows import MessageClock, MessageRow +from toad.response_delivery import ResponseDelivery +from toad.screens.session_view import SessionView from toad.transcript_filter import FilterState -from toad.transcript_state import TranscriptState, LiveTranscript, ParkedSourceTranscript, LatestViewportRequest -from toad.transcript_source_preparation import TranscriptSourcePreparation, _DrawingStopped from toad.transcript_preparation import ( - RangeCategoryProjection, PreparedPageSource, PreparedTranscriptPage, PageFragmentRange, - ProjectedTranscriptSource, SavedHistory, ClosedHistory, UnreadTranscriptPage, + ClosedHistory, + PageFragmentRange, + PreparedPageSource, + PreparedTranscriptPage, + ProjectedTranscriptSource, + RangeCategoryProjection, + SavedHistory, + UnreadTranscriptPage, ) -from toad.response_delivery import ResponseDelivery -from toad.message_rows import MessageClock -from toad.widgets.selection import HoverSelection -from rich.style import Style as RichStyle -from ui_theme.effects import NEW_TEXT -from toad.widgets.presentation_window import PresentationBudget, PageEnd, OLDER_END, NEWER_END -from toad.line_blocks import ToggleDetails -from toad.message_rows import MessageRow -from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications -from toad.widgets.linked_rows import HorizontalRows, OpensMessageNames, ReferenceRendering, selection_painted -from toad.widgets.transcript_lines import ( - Lines, BlockLines, BlockSpan, LinePreparation, PreparedLines, TranscriptLinesBatchRenderTask, TranscriptLinesRenderTask, +from toad.transcript_source_preparation import ( + TranscriptSourcePreparation, + _DrawingStopped, ) -from toad.widgets.committed_presentation import CommittedHistory -from toad.widgets.history_anchor import HeldReaderDestination, HistoryWindow, LatestPosition -from agent_comms.ui_model.message_category import ( - all_categories, MessageCategory, +from toad.transcript_state import ( + LatestViewportRequest, + LiveTranscript, + ParkedSourceTranscript, + TranscriptState, +) +from toad.widgets.committed_presentation import ( + RETAINED_SOURCE_CLAIM, + CommitClaim, + SnapshotPresentation, ) +from toad.widgets.history_anchor import ( + HeldReaderDestination, + HistoryWindow, + LatestPosition, +) +from toad.widgets.linked_rows import ( + HorizontalRows, + OpensMessageNames, + ReferenceRendering, + selection_painted, +) +from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications +from toad.widgets.presentation_window import ( + NEWER_END, + OLDER_END, + PageEnd, + PresentationBudget, +) +from toad.widgets.selection import HoverSelection from toad.widgets.transcript_fragments import ( - Shown, TranscriptFragment, transcript_fragments, + Shown, + TranscriptFragment, + transcript_fragments, +) +from toad.widgets.transcript_lines import ( + BlockLines, + BlockSpan, + LinePreparation, + Lines, + PreparedLines, + TranscriptLinesBatchRenderTask, + TranscriptLinesRenderTask, ) if TYPE_CHECKING: @@ -400,13 +442,14 @@ async def acquire( """Acquire the original prepared range, without selecting it a second time.""" view = cls(prepared) view.visible_categories = owner.selected_categories + view.display = False # Styled before it is drawn: a page drawn plain first rewraps under # the reader as each styled batch arrives. await owner.prepare_fragments(prepared.fragments_in_range, current) if not current(): raise _DrawingStopped - with ExitStack() as acquisition: - acquisition.callback(owner.remove_children, (view,)) + async with AsyncExitStack() as acquisition: + acquisition.push_async_callback(owner.remove_children, (view,)) await end.mount(owner, view, pages) if not current(): raise _DrawingStopped @@ -427,7 +470,7 @@ async def select_range( if not current(): return False async with window.preserve_history(root=self): - return await self.prepared.replace_range(self, selected, current) + return self.prepared.replace_range(self, selected, current) class LineHistory(SourceNotifications, OpensMessageNames, TranscriptSourcePreparation, ConversationEntry, VerticalGroup): """History drawn as lines, a bounded range of pages at a time. @@ -439,15 +482,6 @@ class LineHistory(SourceNotifications, OpensMessageNames, TranscriptSourcePrepar CACHE_SUBTREE_GEOMETRY = True DEFAULT_BUDGET = PresentationBudget(max_items=240) - @classmethod - def empty_source(cls, view, through): - return ClosedHistory(through, view.app.preparation) - - @classmethod - def empty(cls, *, view, notification_view): - prepared = cls.empty_page() - return cls(prepared, cls.empty_source(view, prepared.page.after), - view=view, notification_view=notification_view, source_state=cls.initial_state(view)) def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> None: self.projection_changed() @@ -460,9 +494,8 @@ def block_cursor(self): return FragmentBlockCursor(self) def __init__(self, prepared: PreparedTranscriptPage, page_source: PreparedPageSource, - *, view, notification_view: HistoryNotifications, budget: PresentationBudget = DEFAULT_BUDGET, + *, notification_view: HistoryNotifications, budget: PresentationBudget = DEFAULT_BUDGET, source_state: TranscriptState | None = None): - self.view = view self.notification_view = notification_view super().__init__(source_state=source_state or LiveTranscript(), page_source=page_source) self.budget = budget @@ -870,15 +903,31 @@ def on_hide(self) -> None: page.arrivals.clear() async def show_newest(self, through, initial: PreparedTranscriptPage, *, - arrivals: tuple[TranscriptFragment, ...] = ()) -> None: + arrivals: tuple[TranscriptFragment, ...] = (), + publication_current: Callable[[], bool] = lambda: True) -> bool: """Replace the drawn source, retaining the followed tail's active arrivals.""" retained = (tuple(fragment for page in self.pages for fragment in page.prepared.fragments_in_range if id(fragment) in page.arrivals) if self.follows_source_tail else ()) - await self._clear(initial) - self.extend_source(through) - await self.jump_latest(LatestViewportRequest(LatestPosition.hold(self.window))) - self.repaint(arrivals, continuing=retained) + source, previous = self.page_source, self.page_source.through + self._generation += 1 + generation = self._generation + source.restart(through) + published = False + try: + published = await self.jump_latest( + LatestViewportRequest(LatestPosition.hold(self.window)), publication_current, initial=initial) + if published: + self.repaint(arrivals, continuing=retained) + return published + finally: + # Old drawn pages remain valid until admission. A cancellation + # before the swap restores their read bound; teardown after an + # admitted swap cannot rewind that accepted source. + if (not published and self.page_source is source and self._generation == generation + and not self.pages[-1].prepared.page.after.same_position(through)): + source.restart(previous) + self._generation += 1 async def _clear(self, initial: PreparedTranscriptPage) -> None: """Forget every drawn page and everything prepared: they belong to another reading.""" @@ -953,6 +1002,8 @@ def current() -> bool: self._require_can_draw() self.repaint(arrivals) self.rows_published() + if not local: + await self._page_added(prepared.page, prepared.fragments) self.state.delivered(self) advanced = end.edge(self.pages) if (advanced is edge and edge.prepared.capture_range() == page_range @@ -985,7 +1036,6 @@ async def _extend_and_trim( # A nonmatching interval advances the original source span, # not the native tree. No empty page widget or decoded cache. end.advance_empty(edge.prepared, prepared) - await self._page_added(prepared.page, prepared.fragments) elif local: previous = {id(fragment) for fragment in edge.prepared.fragments_in_range} if not await edge.prepared.extend(edge, prepared, current): @@ -1002,7 +1052,6 @@ async def _extend_and_trim( end.append(self.pages, view) protected.update(id(fragment) for fragment in view.prepared.fragments_in_range) protected_pages.add(view) - await self._page_added(prepared.page, prepared.fragments) self._fragment_budget = limit = max( self.budget.item_limit(len(protected)), self._visible_fragment_budget(), ) @@ -1043,7 +1092,109 @@ async def _extend_and_trim( self.filter.canonical_moved(previous_start, overlay_visible) self._update_edges() -class TranscriptHistory(CommittedHistory, LineHistory): +class CommittedHistory(LineHistory, SnapshotPresentation): + """A saved source block that precedes the conversation's live tail.""" + + conversation_order = 0 + + @property + def commit_claim(self) -> CommitClaim: + return RETAINED_SOURCE_CLAIM + + def accepts_commit(self, cursor: TranscriptCursor) -> bool: + return self.page_source.accepts_commit(cursor) + + def covered_sequences(self, sequences: frozenset[int]) -> frozenset[int]: + return frozenset(sequence for sequence in sequences if self.covers_incoming(sequence)) + + def covers_outgoing(self, message: PublishedSentMessage) -> bool: + return message.reference in self.outgoing_coverage + + def source_resumed(self) -> None: + if self._source_state.reports_coverage: + self.publish_core(TranscriptCoverage(tuple(self.coverage_events))) + + def publish_committed(self) -> None: + """Acquire source ownership now; report coverage after its first painted frame.""" + self._source_state = self._source_state.publish() + self.call_after_refresh(self.publish_coverage) + self.request_preparation() + + def publish_coverage(self) -> None: + if self._source_state.reports_coverage: + self.publish_core(TranscriptCoverage(tuple(self.coverage_events))) + + @property + def coverage_events(self) -> Iterator[TranscriptEvent]: + return (event for page in self.pages for event in page.prepared.page.events) + + @property + def outgoing_coverage(self): + if self._source_state.reports_coverage: + references = frozenset(reference for page in self.pages + for reference in page.prepared.outgoing_coverage) + return references | self.filter.overlay.outgoing_coverage + return frozenset() + + def covers_incoming(self, sequence: int) -> bool: + if not self._source_state.reports_coverage: + return False + if self.page_source.through.covers_incoming(sequence): + return True + if sequence in TranscriptCoverage(tuple(self.coverage_events)).sequences: + return True + return self.filter.overlay.covers_incoming(sequence) + + def covers_input(self, started: InputStartedUpdate) -> bool: + """Whether the loaded saved pages hold the input Core started under this identity.""" + return (self._source_state.reports_coverage + and started.native_id in TranscriptCoverage(tuple(self.coverage_events)).native_inputs) + + def retain_committed(self, through: TranscriptCursor) -> None: + """Extend access to saved source without moving the displayed page window.""" + if not self.accepts_commit(through): + raise ValueError("Commit replaces or rewinds the retained source") + self.extend_source(through) + + async def advance_committed( + self, through: TranscriptCursor, is_current: Callable[[], bool], + ) -> bool: + """Extend a complete followed tail; read a bounded destination across a gap. + + The prepared commit owns the shared live-block handoff operation; + this source changes only its followed saved range. + """ + if not is_current() or not self.state.can_draw: + return False + if not self.accepts_commit(through): + raise ValueError("Commit replaces or rewinds the saved source") + if not NEWER_END.visible_more(self): + return await self.extend(through, is_current) + self.retain_committed(through) + request = LatestViewportRequest(LatestPosition.hold(self.window)) + return await self.jump_latest(request, is_current) + + + +class ConversationHistory[ViewT: "Conversation"](LineHistory): + """A conversation owns its source binding and initial history state.""" + + def __init__(self, *args, view: ViewT, **kwargs): + self.view = view + super().__init__(*args, **kwargs) + + @classmethod + def empty_source(cls, view, through): + return ClosedHistory(through, view.app.preparation) + + @classmethod + def empty(cls, *, view, notification_view): + prepared = cls.empty_page() + return cls(prepared, cls.empty_source(view, prepared.page.after), + view=view, notification_view=notification_view, source_state=cls.initial_state(view)) + + +class TranscriptHistory[ViewT: "Conversation"](CommittedHistory, ConversationHistory[ViewT]): """The line history of a saved transcript. Its pages stand in for the conversation's live blocks: it reports which @@ -1063,6 +1214,12 @@ def empty_page(cls): def empty_source(cls, view, through): return SavedHistory(view.transcript.get_transcript_page, through, view.app.preparation) + async def _page_added(self, page: TranscriptPage, fragments: tuple[TranscriptFragment, ...]) -> None: + if self._source_state.reports_coverage: + view = self.view + view.transcript.binding.covered( + view.transcript, TranscriptCoverage(page.events), self).call_when_ready(view) + async def restore_source(self, source: PreparedPageSource, current) -> None: """Put the parked pages back into the same mounted reader.""" pages = source.held_pages @@ -1086,18 +1243,18 @@ async def restore_source(self, source: PreparedPageSource, current) -> None: await self.remove_children(retired) self.acquire_document() -class ProjectedTranscriptHistory(TranscriptHistory): +class ProjectedTranscriptHistory(CommittedHistory): """A filtered source view with the ordinary pager's loading and eviction policy.""" - def __init__(self, owner: TranscriptHistory, source: ProjectedTranscriptSource, + def __init__(self, owner: LineHistory, source: ProjectedTranscriptSource, prepared: PreparedTranscriptPage) -> None: self._projection_owner = ref(owner) - super().__init__(prepared, source, view=owner.view, budget=owner.budget, + super().__init__(prepared, source, budget=owner.budget, notification_view=owner.notification_view) self.add_class("filtered-history-results") @classmethod - def from_source(cls, source: ProjectedTranscriptSource, *, owner: TranscriptHistory): + def from_source(cls, source: ProjectedTranscriptSource, *, owner: LineHistory): if not source.held_pages or source.closed: raise ValueError("No kept projected range") history = cls(owner, source, source.held_pages[0]) diff --git a/src/toad/widgets/wire_history.py b/src/toad/widgets/wire_history.py index f70e45835..d05fbc2ee 100644 --- a/src/toad/widgets/wire_history.py +++ b/src/toad/widgets/wire_history.py @@ -2,19 +2,28 @@ from __future__ import annotations -from agent_comms.historical_views import ConversationObservation +from typing import TYPE_CHECKING -from agent_comms.messages import Message from agent_comms.coordination_errors import StaleRevision +from agent_comms.historical_views import ConversationObservation +from agent_comms.messages import Message from toad.screens.session_view import SessionView from toad.transcript_preparation import ClosedHistory, PreparedTranscriptPage from toad.transcript_source_preparation import HistorySourceSnapshot -from toad.widgets.message_notifications import NothingToRead, PaintedRead, VisibleReadMarking -from toad.widgets.transcript_history import LineHistory +from toad.widgets.message_notifications import ( + NothingToRead, + PaintedRead, + VisibleReadMarking, +) +from toad.widgets.transcript_history import ConversationHistory from toad.wire_history import PreparedWirePage, WirePage, WirePageBuffer, WireSource -class WireHistory(VisibleReadMarking, LineHistory): +if TYPE_CHECKING: + from toad.widgets.comms_chat import CommsChatView + + +class WireHistory[ViewT: CommsChatView](VisibleReadMarking, ConversationHistory[ViewT]): """The line history of a channel or direct conversation. Paging, preparation, trimming and the reader's position are the line @@ -26,7 +35,7 @@ class WireHistory(VisibleReadMarking, LineHistory): page_source: WirePageBuffer | ClosedHistory def __init__(self, prepared: PreparedTranscriptPage, page_source: WirePageBuffer | ClosedHistory, - *, view, notification_view, **kwargs) -> None: + *, view: ViewT, notification_view, **kwargs) -> None: super().__init__(prepared, page_source, view=view, notification_view=notification_view, **kwargs) # Core's source observation is independent of paint readiness. diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 5a06194d0..c7c443306 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -15,25 +15,43 @@ from typing import TYPE_CHECKING, ClassVar from agent_comms.comms import Comms -from agent_comms.message_page import MessagePage -from agent_comms.transcript_events import IncomingTranscript -from agent_comms.page_traversal import LiveCursor, NewerTraversal, OlderTraversal, PageCursor -from agent_comms.messages import Message, ConversationRole, NoticeRole, Membership +from agent_comms.display_references import CurrentDisplayReferences from agent_comms.historical_views import ( - ConversationHistoryView, ConversationObservation, ConversationRevision, + ConversationHistoryView, + ConversationObservation, + ConversationRevision, ) +from agent_comms.message_page import MessagePage +from agent_comms.messages import ConversationRole, Membership, Message, NoticeRole +from agent_comms.page_traversal import ( + LiveCursor, + NewerTraversal, + OlderTraversal, + PageCursor, +) +from agent_comms.transcript_events import IncomingTranscript from toad.conversation_kind import ConversationKind from toad.line_blocks import Body, Gap, LineBlock, MutedRole, Summary from toad.message_rows import MessageRow, WireMessageStyle from toad.transcript_preparation import ( - PageRequest, PreparedTranscriptPage, SavedHistory, SourceObservation, TranscriptPageWork, Unchanged, + PageRequest, + PreparedTranscriptPage, + SavedHistory, + SourceObservation, + TranscriptPageWork, + Unchanged, ) from toad.widgets.message_notifications import PaintedRead, ReadWork -from toad.widgets.transcript_fragments import BusMessageFragment, MessageShown, TranscriptFragment +from toad.widgets.transcript_fragments import ( + BusMessageFragment, + MessageShown, + TranscriptFragment, +) if TYPE_CHECKING: from agent_comms.read_basis import NamedViewer + from toad.widgets.transcript_lines import Lines @@ -85,7 +103,7 @@ class AfterSequence(AfterMessage): kept = False def older(self) -> OlderTraversal: - return OlderTraversal(self.cursor.sequence + 1) + return OlderTraversal(LiveCursor(self.cursor.sequence + 1)) @dataclass(frozen=True) @@ -253,16 +271,19 @@ class WireSource: style: WireMessageStyle tail_limit: int = 8 page_limit: int = 40 - max_bytes: int = 256 * 1024 @property def viewer(self) -> NamedViewer: return self.comms.views.display_viewer(str(self.project)) + def references(self) -> CurrentDisplayReferences: + """Resolve stable destinations through this read's registry cut.""" + return CurrentDisplayReferences(str(self.comms.root), registry=self.comms.registry.snapshot()) + def observe(self, seen: ConversationObservation) -> SourceObservation: """The observed state owns how changed or unchanged history is drawn.""" revision = self.conversation.history_view.revision( - self.comms.views, self.conversation.target.name, seen, worktree=str(self.project)) + self.comms.views, self.conversation.retained_target(), seen, worktree=str(self.project)) if revision == seen: return Unchanged() high = AfterSequence(LiveCursor(self.comms.views.latest_sequence())) @@ -341,10 +362,11 @@ def status_thread(self, access): return self.loader.conversation.status_thread(self.loader.comms.root, access) def command_context(self, view): - from toad.target_commands import TargetContext from toad.screens.session_view import SessionView + from toad.target_commands import TargetContext - return TargetContext(view.app, self.loader.comms, self.loader.conversation.target.name, self.loader.me, + references = view.app.coordination_access.observation.observed().sidebar.observation.require_snapshot().references + return TargetContext(view.app, self.loader.comms, self.loader.conversation.destination(references).name, self.loader.me, self.loader.project, view.query_ancestor(SessionView).id) async def submit_input(self, view, event) -> None: diff --git a/tests/app/test_owner_reader_reuse.py b/tests/app/test_owner_reader_reuse.py index 93fcb7d14..f44611fea 100644 --- a/tests/app/test_owner_reader_reuse.py +++ b/tests/app/test_owner_reader_reuse.py @@ -1,93 +1,30 @@ -"""Owner requests share one registry reader, built off the UI thread, and never send for a replaced owner.""" +"""Agents read the running application's one service and cannot rebind it to another root.""" import asyncio -import threading -from unittest.mock import patch import pytest -from agent_comms.comms import wire -from agent_comms.runtime import RuntimeProxy +from agent_comms.errors import RouteChangedError -from support.coordination import registered_coordination_fact +from support.roots import installed_wire -def two_wires_with_an_owner(workspace): - roots = [workspace.roots.wire, workspace.roots.wire.parent / "second"] - owners = [wire(root) for root in roots] - for comms in owners: - comms.registry.declare(comms.registry.new_thread("owner", frozenset(), str(workspace.project))) - return owners - - -def bind(agent, comms, workspace): - agent.coordination = registered_coordination_fact(comms, "owner", worktree=str(workspace.project)) - - -async def test_concurrent_owner_requests_share_one_reader_and_a_changed_root_builds_another(workspace): - first, second = two_wires_with_an_owner(workspace) - agent = workspace.agents.detached_agent() - bind(agent, first, workspace) - ui_thread = threading.get_ident() - constructors, requests = [], [] - - def construct(source): - constructors.append((str(source), threading.get_ident())) - assert threading.get_ident() != ui_thread, "Registry reconstruction blocked the UI thread" - return wire(source) - - async def request(proxy, method, **params): - requests.append((proxy._comms, proxy.session_id, method, params)) - return {"ok": True} - - with (patch("toad.acp.transcript_reader.wire", construct), patch.object(RuntimeProxy, "request", request)): - await asyncio.gather(*(agent.controller.request_owner("probe") for _ in range(6))) - assert len(constructors) == 1 and len(requests) == 6 - assert all(row[0] is requests[0][0] for row in requests) - bind(agent, second, workspace) - await agent.controller.request_owner("probe", revision=2) - assert len(constructors) == 2 - assert requests[-1][0].root == second.root and requests[-1][3] == {"revision": 2} - - -async def test_an_owner_replaced_while_the_reader_initializes_receives_no_request(workspace): - first, second = two_wires_with_an_owner(workspace) - agent = workspace.agents.detached_agent() - bind(agent, first, workspace) - requests = [] - - async def request(proxy, method, **params): - requests.append(method) - return {"ok": True} - - entered, release = threading.Event(), threading.Event() - - def blocked(source): - entered.set() - if not release.wait(5): - raise TimeoutError("Fixture did not release owner initialization") - return wire(source) - - try: - with (patch("toad.acp.transcript_reader.wire", blocked), patch.object(RuntimeProxy, "request", request)): - pending = asyncio.create_task(agent.controller.request_owner("must-not-send")) - assert await asyncio.to_thread(entered.wait, 2) - bind(agent, second, workspace) - release.set() - with pytest.raises(ValueError, match="identity changed"): - await pending - finally: - release.set() - assert "must-not-send" not in requests +async def test_agents_share_the_running_interfaces_service(workspace): + async with workspace.app() as ui: + agents = (ui.detached_agent("first"), ui.detached_agent("second")) + shared = ui.core_service + readers = await asyncio.gather(*( + agent.controller.transcripts.service(str(shared.root)) for agent in agents)) + assert all(reader is shared for reader in readers) + assert all(agent.controller.transcripts is ui.app.coordination_access.transcripts + for agent in agents) -async def test_a_bound_reader_is_the_running_interfaces_own_service(workspace): - first, _ = two_wires_with_an_owner(workspace) - agent = workspace.agents.detached_agent() - bind(agent, first, workspace) +async def test_another_root_does_not_replace_the_running_interfaces_service(workspace): + other = installed_wire(workspace.roots.attempt / "second") async with workspace.app() as ui: - ui.attach_agent(agent) + agent = ui.detached_agent("reader") shared = ui.core_service - with patch("toad.acp.transcript_reader.wire", side_effect=AssertionError("Shared reader reconstructed")): - async with agent.controller.transcripts.bind(str(shared.root)) as reader: - assert reader is shared - assert reader is shared + with pytest.raises(RouteChangedError): + await agent.controller.transcripts.service(str(other.root)) + assert await agent.controller.transcripts.service(str(shared.root)) is shared + assert ui.core_service is shared diff --git a/tests/guards/test_transcript_filter.py b/tests/guards/test_transcript_filter.py index 2d331e47c..6de9f8272 100644 --- a/tests/guards/test_transcript_filter.py +++ b/tests/guards/test_transcript_filter.py @@ -15,12 +15,22 @@ def test_deleted_scan_flags_and_external_projection_probes(): if isinstance(node, ast.ClassDef) and node.name == 'NoProjectionOverlay') operations = {node.name for node in overlay.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + fields = {target.id for node in overlay.body if isinstance(node, ast.Assign) + for target in node.targets if isinstance(target, ast.Name)} + fields.update(node.target.id for node in overlay.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)) + fields.update(node.name for node in overlay.body if isinstance(node, ast.FunctionDef) + and any(isinstance(marker, ast.Name) and marker.id == 'property' + for marker in node.decorator_list)) for path in (ROOT/'transcript_filter.py', ROOT/'transcript_state.py', ROOT/'widgets/transcript_history.py'): tree = ast.parse(path.read_text()) protocol_receivers = {node.func.value for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in operations} + protocol_receivers.update(node.value for node in ast.walk(tree) + if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) + and node.attr in fields) for node in ast.walk(tree): if isinstance(node, ast.Name): assert node.id not in retired @@ -29,7 +39,7 @@ def test_deleted_scan_flags_and_external_projection_probes(): if isinstance(node, ast.Attribute): assert node.attr not in retired if path.name != 'transcript_filter.py' and ast.unparse(node) in {'self.filter.overlay', 'owner.filter.overlay'}: - # Only calls to the declared overlay operations cross this + # Only declared overlay operations and field reads cross this # boundary. Case/identity decisions belong to FilterState. assert node in protocol_receivers, (path, node.lineno, ast.unparse(node)) elif path.name == 'transcript_filter.py' and node.attr in {'_loading', '_advancing', '_generation', '_selected_categories', '_prefetch_distance'}: diff --git a/tests/model/test_agent_prompt_route.py b/tests/model/test_agent_prompt_route.py index f0d8f69cb..81071106d 100644 --- a/tests/model/test_agent_prompt_route.py +++ b/tests/model/test_agent_prompt_route.py @@ -1,57 +1,32 @@ -"""An ACP child keeps the root selection it was started with: implicit follows the default route, explicit stays put.""" - -from types import SimpleNamespace - -import pytest - -from support.agents import AppAgents -from support.routes import private_root, publish_route, select_root_by_default_route - - -@pytest.fixture -def routes(tmp_path, monkeypatch): - tmp_path.chmod(0o700) - for name in ("CONFIG", "DATA", "STATE"): - monkeypatch.setenv(f"XDG_{name}_HOME", str(tmp_path / name.lower())) - home = select_root_by_default_route(monkeypatch, tmp_path) - project = tmp_path / "project" - project.mkdir() - first, first_id = private_root(tmp_path / "first", tmp_path, "FIRST") - second, second_id = private_root(tmp_path / "second", tmp_path, "SECOND") - publish_route(home, first, first_id) - return SimpleNamespace(home=home, project=project, first=first, second=second, second_id=second_id) - - -async def started_agent(project): - from toad.acp.agent import Agent - from toad.agent_schema import AgentDefinition - - agent = AppAgents.unrun(project).agent(Agent, AgentDefinition("fake", "fake", {"*": "true"})) - await agent.start() - assert agent.process.runner is not None - await agent.process.runner - return agent - - -async def test_the_real_prompt_path_refuses_a_stale_implicit_child(routes, monkeypatch): - agent = await started_agent(routes.project) - publish_route(routes.home, routes.second, routes.second_id) - written = [] - agent.process.process = SimpleNamespace(stdin=SimpleNamespace(write=written.append)) - monkeypatch.setenv("AGENT_COMMS_ROOT", str(routes.first)) - with pytest.raises(ValueError, match="route changed"): - async with agent.process.admitted_prompt(agent): - agent.send(SimpleNamespace(body={"method": "session/prompt"}, body_json=b"{}")) - assert not written - - -async def test_an_explicit_child_stays_on_its_root_after_the_parent_drops_its_override(routes, monkeypatch): - monkeypatch.setenv("AGENT_COMMS_ROOT", str(routes.first)) - agent = await started_agent(routes.project) - monkeypatch.delenv("AGENT_COMMS_ROOT") - assert not agent.process.route_selection.implicit - assert agent.process.env["AGENT_COMMS_ROOT"] == str(routes.first) - written = [] - agent.process.process = SimpleNamespace(stdin=SimpleNamespace(write=written.append)) - agent.send(SimpleNamespace(body={"method": "session/prompt"}, body_json=b"{}")) - assert written == [b"{}\n"] +"""An ACP child takes its application's explicit launch root, never an ambient override.""" + +import json +import sys +from dataclasses import replace + +from support.agents import command +from toad.acp.agent import Agent + + +async def test_the_real_child_receives_the_selected_root_as_arguments_and_drops_the_environment_override( + app_agents, tmp_path, monkeypatch): + from agent_comms.launch_root import LaunchRoot + + output = tmp_path / "child-launch.json" + program = ( + "import json,os,sys; from pathlib import Path; " + "Path(sys.argv[1]).write_text(json.dumps({'argv':sys.argv[2:]," + "'root_env':os.environ.get('AGENT_COMMS_ROOT')}))" + ) + definition = replace(command(sys.executable, "-c", program, str(output)), accepts_comms_root=True) + agent = app_agents.agent(Agent, definition) + selected = LaunchRoot.current() + monkeypatch.setenv("AGENT_COMMS_ROOT", str(tmp_path / "wrong-wire")) + try: + await agent.start() + await agent.process.runner + recorded = json.loads(output.read_text()) + assert recorded == {"argv": list(selected.arguments()), "root_env": None} + assert agent.process.route_selection.root == selected.path + finally: + await agent.stop() diff --git a/tests/model/test_queue_attachment.py b/tests/model/test_queue_attachment.py index 566bf0964..b60f1a0ce 100644 --- a/tests/model/test_queue_attachment.py +++ b/tests/model/test_queue_attachment.py @@ -7,16 +7,16 @@ AvailableQueueProjection, PendingQueueProjection, QueueChangedUpdate, LiveQueueScope, ) from agent_comms.child_process import ProcessIdentity -from agent_comms.comms import Comms from agent_comms.input_origin import HumanInputOrigin from agent_comms.threads import Thread +from support.roots import installed_wire + from toad.acp.queue_attachment import QueueAttachment def declared_scope(tmp_path): - comms = Comms(tmp_path / "wire") - comms.messaging.initialize_private_initial_protocol() + comms = installed_wire(tmp_path / "wire") comms.registry.declare(comms.registry.new_thread("source", frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) return comms, LiveQueueScope("source", comms.registry.snapshot().claimed_identity("source"), os.getpid()) diff --git a/tests/model/test_recorded_transcript_reader.py b/tests/model/test_recorded_transcript_reader.py index 4ec76dfa3..c0a6e672d 100644 --- a/tests/model/test_recorded_transcript_reader.py +++ b/tests/model/test_recorded_transcript_reader.py @@ -1,76 +1,57 @@ """A recorded transcript keeps its selected source through snapshot, refresh and presentation, with no live owner.""" -import asyncio import json from dataclasses import replace import pytest from agent_comms.acp_extension import TranscriptSnapshotUpdate, decode_updates, encode_updates -from agent_comms.comms import Comms -from agent_comms.coordination_errors import StaleRevision +from agent_comms.coordination_errors import HistoricalSourceChanged, StaleRevision +from agent_comms.thread_text import SavedSessionFile from agent_comms.transcripts import RecordedTranscriptReadIdentity +from support.roots import installed_wire +from support.journals import assistant_entry, write_journal from toad.acp.comms_updates import OwnerSnapshotConsumer -from toad.acp.transcript_reader import CoordinationTranscriptReader - - -class ServedReader(CoordinationTranscriptReader): - """Answers each request as the observation service does, in this process.""" - - def __init__(self, comms): - super().__init__() - self.comms = comms - - async def read(self, request): - return request.read(self.comms) @pytest.fixture -def recorded(tmp_path): +def recorded(tmp_path, workspace): - old, live = Comms(tmp_path / "old"), Comms(tmp_path / "live") - native = tmp_path / "authored.jsonl" - native.write_text(json.dumps({"type": "message", "message": { - "role": "assistant", "content": [{"type": "text", "text": "recorded answer"}], - }}) + "\n") + old, live = installed_wire(tmp_path / "old"), workspace.comms + native = write_journal(tmp_path / "authored.jsonl", assistant_entry("recorded answer", id="answer-1")) old.registry.declare(old.registry.new_thread("reader", frozenset(), str(old.root), - created_at=10.0, session_file=str(native))) - old.messaging.initialize_private_initial_protocol() + created_at=10.0, session_file=SavedSessionFile(str(native)))) live.registry.declare(live.registry.new_thread("reader", frozenset(), str(live.root), created_at=20.0)) source = live.views.attach_history(old.root) return live, source, native -def test_recorded_snapshot_wire_and_stale_refresh_keep_selected_source(recorded): +async def test_recorded_snapshot_stays_immutable_when_original_grows_and_rejects_stale_source(recorded, workspace): live, source, native = recorded - async def check(): - reader = ServedReader(live) + async with workspace.app() as ui: + reader = ui.app.coordination_access.transcripts update = await reader.snapshot(str(live.root), "reader", historical_source=source.key) assert isinstance(update.identity, RecordedTranscriptReadIdentity) assert decode_updates(encode_updates(update)) == (update,) with native.open("a") as output: - output.write(json.dumps({"type": "message", "message": { - "role": "assistant", "content": [{"type": "text", "text": "later recorded answer"}], - }}) + "\n") + output.write(json.dumps(assistant_entry("later recorded answer", id="answer-2", parent="answer-1")) + "\n") refreshed = await reader.publication(update) assert refreshed.identity.historical_source == source.key assert refreshed.identity.thread.incarnation == source.provenance.threads["reader"].incarnation - assert [event.text for event in refreshed.page.events] == ["recorded answer", "later recorded answer"] - assert refreshed.identity != update.identity + assert [event.text for event in refreshed.page.events] == ["recorded answer"] + assert refreshed == update with pytest.raises(StaleRevision, match="another recorded source"): - await reader.snapshot(str(live.root), "reader", read_identity=refreshed.identity, - historical_source="another-source") + await reader.publication(replace(refreshed, identity=replace( + refreshed.identity, source=replace(source, original_root="another-source")))) live.bus.history.path.write_text("[]") - with pytest.raises(ValueError, match="detached"): + with pytest.raises(HistoricalSourceChanged, match="detached"): await reader.publication(refreshed) - asyncio.run(check()) - def test_recorded_presentation_does_not_acquire_live_owner(recorded): live, source, _native = recorded - presentation = live.views.thread_presentation("reader") + presentation = live.views.thread_presentations(("reader",)).read("reader") identity = live.transcripts.capture_page_read("reader", historical_source=source.key).identity # There is no live agent/controller to acquire: the recorded declaration # cannot publish a turn even when delivered to the ordinary snapshot consumer. diff --git a/tests/model/test_route_publication_waits_for_agent_processes.py b/tests/model/test_route_publication_waits_for_agent_processes.py index 62d303c41..55a128413 100644 --- a/tests/model/test_route_publication_waits_for_agent_processes.py +++ b/tests/model/test_route_publication_waits_for_agent_processes.py @@ -1,20 +1,14 @@ -"""Stopping an agent retires its whole process group before the default route is published.""" +"""Stopping an agent retires its whole process group, including its child.""" import asyncio import os import shlex import signal import sys -from unittest.mock import patch import psutil -import agent_comms.private_nk_entrypoint as private_nk_entrypoint -from agent_comms.active_route import ActiveRoute, publish_active_route from agent_comms.child_process import AttachedChild from agent_comms.launch_root import LaunchRoot -from agent_comms.route_selection import current_root - -from support.routes import private_root, select_root_by_default_route def live_members(group: int) -> list[int]: @@ -28,22 +22,13 @@ def live_members(group: int) -> list[int]: return found -async def test_stopping_an_agent_ends_its_process_group_before_the_new_route_is_published(app_agents, tmp_path, monkeypatch): +async def test_stopping_an_agent_ends_its_process_group(app_agents, tmp_path): from toad.acp.shell_command import ShellCommand - tmp_path.chmod(0o700) - for name in ("CONFIG", "DATA", "STATE"): - monkeypatch.setenv(f"XDG_{name}_HOME", str(tmp_path / name.lower())) - home = select_root_by_default_route(monkeypatch, tmp_path) - old = home / ".agent-comms" - old.mkdir(mode=0o700) program = ("import subprocess,sys,time; " "child=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)']); " "print(child.pid,flush=True); time.sleep(60)") command = f"{shlex.quote(sys.executable)} -c {shlex.quote(program)}" - new_root, root_id = private_root(tmp_path / "wire", tmp_path, "PRIVATE-ONLY") - route = ActiveRoute(new_root, root_id, tmp_path / "unused-package") - assert current_root() == old process = await AttachedChild.start( ShellCommand.current().argv(command), env=LaunchRoot.environment(dict(os.environ)), cwd=tmp_path) @@ -61,9 +46,6 @@ async def test_stopping_an_agent_ends_its_process_group_before_the_new_route_is_ assert process.returncode is not None assert not live_members(group), "old ACP descendant survived stop" assert agent.process.process is None - with patch.object(private_nk_entrypoint, "_trusted_package", lambda _: None): - publish_active_route(route) - assert current_root() == new_root finally: if live_members(group): os.killpg(group, signal.SIGKILL) diff --git a/tests/native/test_child_route_environment.py b/tests/native/test_child_route_environment.py deleted file mode 100644 index eead66602..000000000 --- a/tests/native/test_child_route_environment.py +++ /dev/null @@ -1,36 +0,0 @@ -"""A write selected on the legacy root is refused once the default route is published.""" - -from types import SimpleNamespace -from unittest.mock import patch - -import pytest -import agent_comms.private_nk_entrypoint as private_nk_entrypoint -from agent_comms.active_route import ActiveRoute, publish_active_route -from agent_comms.route_selection import current_root, run_selected_write - -from support.routes import private_root, select_root_by_default_route - - -@pytest.fixture -def published(tmp_path, monkeypatch, native_package): - """A legacy root, and a private root published as the default route.""" - tmp_path.chmod(0o700) - for name in ("CONFIG", "DATA", "STATE"): - monkeypatch.setenv(f"XDG_{name}_HOME", str(tmp_path / name.lower())) - home = select_root_by_default_route(monkeypatch, tmp_path) - legacy = home / ".agent-comms" - legacy.mkdir(mode=0o700) - private, private_id = private_root(tmp_path / "wire", tmp_path, "PRIVATE-ONLY") - route = ActiveRoute(private, private_id, native_package) - assert current_root() == legacy - with patch.object(private_nk_entrypoint, "_trusted_package", lambda _: None): - publish_active_route(route) - assert current_root() == private - return SimpleNamespace(legacy=legacy, private=private, private_id=private_id, route=route, cwd=tmp_path) - - -def test_a_write_selected_before_the_flip_is_refused_after_it(published): - invoked = [] - with pytest.raises(ValueError): - run_selected_write(published.legacy, invoked.append, None, implicit=True) - assert not invoked diff --git a/tests/native/test_native_client_requests.py b/tests/native/test_native_client_requests.py index 57f930226..27b92b989 100644 --- a/tests/native/test_native_client_requests.py +++ b/tests/native/test_native_client_requests.py @@ -2,7 +2,9 @@ and what happens to them when the tab is left, the session is replaced or the agent is stopped.""" import asyncio +from acp import schema +from support.agents import deliver from support.native import native_session from toad.db import DB from toad.navigation_target import NavigationContext, channel_target @@ -29,36 +31,29 @@ async def test_client_requests_follow_the_session_they_belong_to(workspace, nati assert agent.session.ready await ui.until(lambda: "NATIVE_RESPONSE_1" in ui.painted(view.window), seconds=20, why="history after reattaching") - calls = iter(range(101, 200)) - - async def request(method, **params): - result = await agent.server.call({"jsonrpc": "2.0", "id": next(calls), "method": method, - "params": {"sessionId": agent.session_id, **params}}) - assert "error" not in result, result - return result["result"] - def reviewing(): return isinstance(app.screen, PermissionReview) - await request("fs/write_text_file", path="client.txt", content="old client value\nretained line") - assert (await request("fs/read_text_file", path="client.txt", line=2, limit=1))["content"] == "retained line" + await agent.process.write_text_file(agent.session_id, "client.txt", "old client value\nretained line") + assert (await agent.process.read_text_file(agent.session_id, "client.txt", line=2, limit=1)).content == "retained line" tool = {"toolCallId": "client-edit", "title": "CLIENT_BOUND_PERMISSION", "kind": "edit", "content": [{"type": "diff", "path": str(project / "client.txt"), "oldText": "old client value", "newText": "CLIENT_NEW_VALUE"}]} - agent.updates.accept(agent.session_id, {"sessionUpdate": "tool_call", **tool, "status": "in_progress"}) - permission = asyncio.create_task(request( - "session/request_permission", options=[ALLOW, REJECT], toolCall={"toolCallId": "client-edit"})) + options = [schema.PermissionOption.model_validate(option) for option in (ALLOW, REJECT)] + await deliver(agent, {"sessionUpdate": "tool_call", **tool, "status": "in_progress"}) + permission = asyncio.create_task(agent.process.request_permission( + agent.session_id, schema.ToolCallUpdate(tool_call_id="client-edit"), options)) await ui.until(reviewing, why="the permission review") await ui.until(lambda: ui.shows("CLIENT_NEW_VALUE") and ui.shows("Allow"), why="the proposed edit and its choices") await ui.press("a") - assert (await permission)["outcome"] == {"outcome": "selected", "optionId": "allow"} - await request("fs/write_text_file", path="client.txt", content="CLIENT_NEW_VALUE\nretained line") + assert (await permission).outcome == schema.AllowedOutcome(outcome="selected", option_id="allow") + await agent.process.write_text_file(agent.session_id, "client.txt", "CLIENT_NEW_VALUE\nretained line") assert (project / "client.txt").read_text().startswith("CLIENT_NEW_VALUE") - terminal_id = (await request("terminal/create", command="sh", - args=["-c", "printf 'CLIENT_TERMINAL_RETAINED\\n'"]))["terminalId"] - assert (await request("terminal/wait_for_exit", terminalId=terminal_id))["exitCode"] == 0 - assert "CLIENT_TERMINAL_RETAINED" in (await request("terminal/output", terminalId=terminal_id))["output"] + terminal_id = (await agent.process.create_terminal(agent.session_id, command="sh", + args=["-c", "printf 'CLIENT_TERMINAL_RETAINED\\n'"])).terminal_id + assert (await agent.process.wait_for_terminal_exit(agent.session_id, terminal_id)).exit_code == 0 + assert "CLIENT_TERMINAL_RETAINED" in (await agent.process.terminal_output(agent.session_id, terminal_id)).output await ui.until_shows("CLIENT_TERMINAL_RETAINED") execution = agent.controller.terminals.require(terminal_id) @@ -70,19 +65,18 @@ def reviewing(): await channel_target("#team").open(NavigationContext(app, owner_tab, project, user)) await ui.select_tab(owner_tab) returned = ui.conversation - assert returned.agent is agent and agent.controller.terminals.require(terminal_id) is execution + assert returned.agent_binding is agent and agent.controller.terminals.require(terminal_id) is execution await ui.until(lambda: returned.query_one_optional(f"#{terminal_id}", TerminalTool) is not None, why="the terminal block after returning") await ui.until_shows("CLIENT_TERMINAL_RETAINED") assert ui.draft() == "CLIENT_SESSION_UNSENT_DRAFT" assert returned.prompt.prompt_text_area.document is document assert returned.prompt.prompt_text_area.history is undo - await request("terminal/release", terminalId=terminal_id) + await agent.process.release_terminal(agent.session_id, terminal_id) # A pending permission survives the view being detached and is shown again when it returns. - pending = asyncio.create_task(request( - "session/request_permission", options=[ALLOW, REJECT], - toolCall={**tool, "toolCallId": "retained-permission"})) + pending = asyncio.create_task(agent.process.request_permission( + agent.session_id, schema.ToolCallUpdate.model_validate({**tool, "toolCallId": "retained-permission"}), options)) await ui.until(reviewing, why="the second permission review") owned, = agent.permissions.pending agent.detach_surface(returned) @@ -93,11 +87,11 @@ def reviewing(): await ui.until_shows("CLIENT_NEW_VALUE") assert agent.permissions.pending == (owned,) await ui.press("r") - assert (await pending)["outcome"] == {"outcome": "selected", "optionId": "reject"} + assert (await pending).outcome == schema.AllowedOutcome(outcome="selected", option_id="reject") # Replacing the session cancels its pending permission. - replaced = asyncio.create_task(request( - "session/request_permission", options=[ALLOW], toolCall={**tool, "toolCallId": "replaced-permission"})) + replaced = asyncio.create_task(agent.process.request_permission( + agent.session_id, schema.ToolCallUpdate.model_validate({**tool, "toolCallId": "replaced-permission"}), options[:1])) await ui.until(reviewing, why="the third permission review") first_session = agent.session_id await agent.session.new() @@ -106,13 +100,13 @@ def reviewing(): assert saved is not None and agent.session.ready await agent.set_session_name(agent.session_id) assert (await DB().session_get(saved)).title == agent.session_id - assert (await replaced)["outcome"] == {"outcome": "cancelled"} + assert (await replaced).outcome == schema.DeniedOutcome(outcome="cancelled") # Stopping the agent cancels its pending permission and releases its terminals. - stopped = asyncio.create_task(request( - "session/request_permission", options=[ALLOW], toolCall={**tool, "toolCallId": "stopped-permission"})) + stopped = asyncio.create_task(agent.process.request_permission( + agent.session_id, schema.ToolCallUpdate.model_validate({**tool, "toolCallId": "stopped-permission"}), options[:1])) await ui.until(reviewing, why="the fourth permission review") await agent.stop() - assert (await stopped)["outcome"] == {"outcome": "cancelled"} + assert (await stopped).outcome == schema.DeniedOutcome(outcome="cancelled") assert not agent.permissions.pending and not agent.controller.terminals.executions assert len(provider.requests) == 1 diff --git a/tests/native/test_thread_open_during_slow_load.py b/tests/native/test_thread_open_during_slow_load.py index f3bc20222..01dccf025 100644 --- a/tests/native/test_thread_open_during_slow_load.py +++ b/tests/native/test_thread_open_during_slow_load.py @@ -26,7 +26,7 @@ async def test_a_slow_thread_open_uses_one_tab_and_ends_with_saved_history(works comms.registry.declare(comms.registry.new_thread(name, frozenset({"journey"}), str(project), process_identity=ProcessIdentity.capture(os.getpid()))) comms.channels.create_tag("journey") - comms.messaging.send_initial_cohort("owner", "#journey", "SAVED_CHANNEL_JOURNEY") + comms.messaging.send("owner", "#journey", "SAVED_CHANNEL_JOURNEY") tab_sets_drawn = [] def started(**arguments): diff --git a/tests/open_defects.toml b/tests/open_defects.toml index b80e530cf..48770f098 100644 --- a/tests/open_defects.toml +++ b/tests/open_defects.toml @@ -33,13 +33,6 @@ tests = [ "tests/textual/test_key_bursts_move_focus_to_the_editor.py::test_a_character_a_left_arrow_and_a_character_in_one_burst_keep_their_order", ] -[command-hint-refresh] -defect = "When an agent advertises a command again with a different input hint, the editor keeps showing the first hint after '/command '." -owner = "unassigned: Toad prompt (widgets/prompt.py, slash completion)" -tests = [ - "tests/textual/test_prompt_completion.py::test_a_whole_command_is_selected_backwards_and_the_agents_hint_refreshes", -] - [history-admission-growth] defect = "Loading earlier pages over and over grows the admitted history without bound: the fragment count rises by four per load (84 after twenty loads, against a 32 limit the old test held). Scrolling far up a long history keeps every page admitted." owner = "frame-time / line-view owner (perf/line-history)" @@ -70,15 +63,6 @@ tests = [ "tests/native/test_native_goal_objective_edit.py::test_a_late_notice_of_an_older_goal_does_not_replace_the_current_one", ] -[late-answer-drawn-twice] -defect = "A live answer that is applied after its session file and the end of its turn have arrived is sometimes drawn twice and stays that way (about one run in seven for the queued snapshot, less often for a direct snapshot): the saved history is mounted a second time below the first, so the whole history, not only the answer, appears twice." -owner = "frame-time / line-view owner (perf/line-history), transcript_publication.py" -intermittent = true -tests = [ - "tests/textual/test_late_live_answer.py::test_late_live_answer_is_drawn_once[snapshot]", - "tests/textual/test_late_live_answer.py::test_late_live_answer_is_drawn_once[queued-snapshot]", -] - [reader-row-frame-during-page-load] defect = "While an earlier page of a thread's or a channel's history is added above the reader, one painted frame sometimes shows the reader's row lower by the rows added before it returns (thread: three of eight runs; channel: one of twelve, seen with the whole file running)." owner = "frame-time / line-view owner (perf/line-history), transcript_history.py" diff --git a/tests/support/failed_close_app.py b/tests/support/failed_close_app.py index 4b36465ab..6e6a6825e 100644 --- a/tests/support/failed_close_app.py +++ b/tests/support/failed_close_app.py @@ -1,14 +1,16 @@ """Toad as a user runs it in a terminal, whose conversation raises when it is closed. -Run as a script by ``terminal/test_failed_close.py``: ``failed_close_app.py PROJECT``. +Run as a script by ``terminal/test_failed_close.py``: ``failed_close_app.py PROJECT WIRE_ROOT``. """ from pathlib import Path import sys +from unittest.mock import patch from toad.app import ToadApp from toad.cli import run_terminal from toad.widgets.conversation import Conversation +from toad.goal_interaction import GoalSession from agents import peer from stylesheets import INSTALLED @@ -18,10 +20,6 @@ class CloseStepFailed(Exception): """The controlled failure of the first step of releasing a conversation.""" -def fail(): - raise CloseStepFailed("goal controls") - - class FailedCloseApp(ToadApp): CSS_PATH = INSTALLED @@ -34,9 +32,12 @@ def break_release(self): """Once a conversation is open, break its release and tell the test.""" for conversation in self.screen.query(Conversation): self.noting.stop() - conversation.goal_controls.close = fail (Path(self.project_dir) / "conversation-open").touch() if __name__ == "__main__": - run_terminal(FailedCloseApp(project_dir=sys.argv[1], agent_data=peer("echo"))) + from agent_comms.launch_root import LaunchRoot + + LaunchRoot.from_path(sys.argv[2]).install() + with patch.object(GoalSession, "close", side_effect=CloseStepFailed("goal controls")): + run_terminal(FailedCloseApp(project_dir=sys.argv[1], agent_data=peer("echo"))) diff --git a/tests/support/journals.py b/tests/support/journals.py index c987f9142..f61c936f0 100644 --- a/tests/support/journals.py +++ b/tests/support/journals.py @@ -9,7 +9,9 @@ from __future__ import annotations import json +from datetime import UTC, datetime from pathlib import Path +from uuid import uuid4 def message_entry(role: str, text: str, *, id: str, parent: str | None = None) -> dict: @@ -32,12 +34,16 @@ def user_entry(text: str, *, id: str, parent: str | None = None, input_id: str | def write_journal(path: Path, *entries: dict) -> Path: """Write `entries` as the lines of a session journal and return its path.""" - from agent_comms.native_entries import MessageEntry, NativeEntry + 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): raise ValueError(f"Core does not read this journal line as a known message: {entry}") - path.write_text("".join(json.dumps(entry) + "\n" for entry in entries)) + header = SessionEntry(id=uuid4().hex, version=3, cwd=str(path.parent), + timestamp=RecordedTimestamp.written(datetime.now(UTC))) + path.write_text(json.dumps(header.to_wire()) + "\n" + + "".join(json.dumps(entry) + "\n" for entry in entries)) return path diff --git a/tests/support/native.py b/tests/support/native.py index 0c17d99a7..a7e65f8ad 100644 --- a/tests/support/native.py +++ b/tests/support/native.py @@ -23,6 +23,8 @@ from agent_comms.pi_vocabulary import OffThinkingLevel from agent_comms.thread_model import DeclaredModel +from support.roots import installed_wire + PROVIDER = "selected-offline" MODEL = DeclaredModel(f"{PROVIDER}/fixture") KEY = "offline-only-fixture" @@ -161,10 +163,7 @@ def log_message(self, *_args): def start_private_native_wire(root: Path): """Create a private root; Core derives its root identity and reviewed package.""" - from agent_comms.comms import Comms - - comms = Comms(root) - comms.messaging.initialize_private_initial_protocol() + comms = installed_wire(root) for key in ("PI_PROMPT", "PI_PARENT_ID", "PI_AGENT_ID"): os.environ.pop(key, None) return comms diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index dec9e0cc8..dae4bee41 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -5,7 +5,7 @@ import asyncio from contextlib import asynccontextmanager -from agent_comms.channel_targets import ChannelTarget, ThreadTarget +from agent_comms.channel_targets import ChannelTarget from agent_comms.notification_progress import AnsweredProgress, IgnoredProgress from toad.conversation_kind import ChannelConversation, DmConversation from toad.core import input_events @@ -44,7 +44,7 @@ async def direct_reply_feedback(native, owner_tab: str, *, body: str) -> None: screen = app.selected_session await screen.wait_content_ready() chat = screen.query_one(CommsChatView) - assert chat.conversation == DmConversation(ThreadTarget(target)) + assert chat.conversation == DmConversation(comms.registry.require(target).incarnation) assert not rows_with(chat, lambda message: message.body == body) provider.hold_next_request() await chat.submit_input(input_events.UserInputSubmitted(body)) @@ -88,7 +88,7 @@ def replies(): assert reopened is not screen and reopened.id != screen.id await reopened.wait_content_ready() fresh = reopened.query_one(CommsChatView) - assert fresh.conversation == DmConversation(ThreadTarget(target)) + assert fresh.conversation == DmConversation(comms.registry.require(target).incarnation) await ui.until(lambda: all( sum(fragment.message.reference == reference for fragment in fresh.message_history.fragments_in_range) == 1 diff --git a/tests/support/observed_app.py b/tests/support/observed_app.py index 849b5f365..3fc71cb5b 100644 --- a/tests/support/observed_app.py +++ b/tests/support/observed_app.py @@ -1,6 +1,6 @@ """Toad as a user runs it in a terminal, which also notes the process id of its comms observer. -Run as a script by ``terminal/test_observer_death.py``: ``observed_app.py PROJECT``. +Run as a script by ``terminal/test_observer_death.py``: ``observed_app.py PROJECT WIRE_ROOT``. """ from pathlib import Path @@ -33,4 +33,7 @@ def note_observer(self): if __name__ == "__main__": + from agent_comms.launch_root import LaunchRoot + + LaunchRoot.from_path(sys.argv[2]).install() run_terminal(ObservedApp(project_dir=sys.argv[1], agent_data=peer("echo"))) diff --git a/tests/support/peers/turn_source.py b/tests/support/peers/turn_source.py new file mode 100644 index 000000000..4d6c18040 --- /dev/null +++ b/tests/support/peers/turn_source.py @@ -0,0 +1,35 @@ +"""One registry-backed ACP publisher for authored turn observations.""" + +from contextlib import AsyncExitStack, asynccontextmanager +from functools import partial +import os +from pathlib import Path +from unittest.mock import patch + +from agent_comms.acp import CommsAgent +from agent_comms.comms import wire +from agent_comms.coordinator import Coordination +from agent_comms.launch_root import LaunchRoot + + +@asynccontextmanager +async def turn_source(wire_root: Path, project: Path, session_id: str): + """Retain declaration and attachment custody without sending native input.""" + with patch.dict(os.environ), LaunchRoot.from_path(wire_root).selected(): + for key in ("PI_AGENT_ID", "AGENT_COMMS_THREAD"): + os.environ.pop(key, None) + async with AsyncExitStack() as resources: + project.mkdir(parents=True, exist_ok=True) + comms = await Coordination.run_worker(partial(wire, wire_root)) + producer = CommsAgent(comms, runtime_enabled=False, auto_wake=False) + resources.push_async_callback(producer.shutdown) + + def declare(): + thread = producer.sessions.declare_thread(str(project)) + resources.push_async_callback(Coordination.run_worker, + partial(comms.owners.stop, thread.name)) + return thread + + thread = await Coordination.run_worker(declare) + await producer.sessions.bind_owned(thread, session_id) + yield producer diff --git a/tests/support/peers/typed_turn_events.py b/tests/support/peers/typed_turn_events.py index 7e79d9f45..4baa00e65 100644 --- a/tests/support/peers/typed_turn_events.py +++ b/tests/support/peers/typed_turn_events.py @@ -9,14 +9,13 @@ from contextlib import AsyncExitStack from functools import partial import json -import os from pathlib import Path import sys import agent_comms.agent_events as agent_events -from agent_comms.acp import CommsAgent -from agent_comms.comms import wire +from agent_comms.agent_event_updates import AcpEventConsumer from agent_comms.coordinator import Coordination +from turn_source import turn_source SESSION = "pilot" @@ -30,39 +29,27 @@ async def session_update(self, session_id, update): async def produce(wire_root: Path, project: Path): - # Inherited caller names must not select a different participant during same-process retirement. - for key in ("PI_AGENT_ID", "AGENT_COMMS_THREAD"): - os.environ.pop(key, None) - async with AsyncExitStack() as resources: - project.mkdir(parents=True, exist_ok=True) - comms = await Coordination.run_worker(partial(wire, wire_root)) - producer = CommsAgent(comms, runtime_enabled=False, auto_wake=False) - resources.push_async_callback(producer.shutdown) - - def declare(): - thread = producer.sessions.declare_thread(str(project)) - resources.push_async_callback(Coordination.run_worker, partial(comms.owners.stop, thread.name)) - return thread - - thread = await Coordination.run_worker(declare) - await producer.sessions.bind_owned(thread, SESSION) + async with turn_source(wire_root, project, SESSION) as producer: name = producer.sessions.require(SESSION) async with AsyncExitStack() as turns: retired_owner = await Coordination.run_worker(partial( producer.turns.acquire_turn, turns, SESSION, name, "retired-producer", "Authored protocol; no input")) + retired_lease = retired_owner.turn_lease retired = await Coordination.run_worker(partial(producer.turns.current_turn_update, SESSION)) producer.on_connect(Pipe()) + consumer = AcpEventConsumer(producer, SESSION, producer._runtime) async with AsyncExitStack() as turns: await Coordination.run_worker(partial( producer.turns.acquire_turn, turns, SESSION, name, "actual-producer", "Authored protocol; no input")) - await producer.turns.replay_turn_state(SESSION) - await producer._emit_event(SESSION, agent_events.CompactionStart()) - await producer._emit_event(SESSION, agent_events.CompactionEnd( + await producer.turns.publish_turn_state(consumer) + await consumer.dispatch(agent_events.CompactionStart()) + await consumer.dispatch(agent_events.CompactionEnd( summary="Authored phase observation; not a native committed summary")) - assert await Coordination.run_worker(partial( - producer._comms.agents.finish_turn, retired_owner.turn_lease)) is None + before = await Coordination.run_worker(partial(producer.turns.current_turn_update, SESSION)) + await Coordination.run_worker(partial(producer._comms.agents.finish_turn, retired_lease)) + assert await Coordination.run_worker(partial(producer.turns.current_turn_update, SESSION)) == before # An earlier finished cut: the current binding must refuse it. - await producer._emit_event(SESSION, retired) + await consumer.dispatch(retired) await producer.turns.goals.sync_goal_execution(SESSION, name) diff --git a/tests/support/roots.py b/tests/support/roots.py index 24a5ec3ef..a242a77bc 100644 --- a/tests/support/roots.py +++ b/tests/support/roots.py @@ -6,10 +6,25 @@ import os from pathlib import Path import tempfile +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from agent_comms.comms import Comms ATTEMPT = "TOAD_TEST_ATTEMPT" +def installed_wire(root: Path) -> Comms: + """Prepare the same bus and native schemas that an installed owner uses.""" + from agent_comms.bus import Bus + from agent_comms.comms import Comms + + comms = Comms(root) + comms.registry.prepare_native_root() + Bus.install(Bus.path_in(root)) + return comms + + @dataclass(frozen=True) class Roots: """One disposable directory holding the wire, the settings and the project of a test.""" diff --git a/tests/support/routes.py b/tests/support/routes.py index 8e68a63f6..148c8c263 100644 --- a/tests/support/routes.py +++ b/tests/support/routes.py @@ -5,42 +5,46 @@ import json import os from pathlib import Path +from support.roots import installed_wire -def private_root(path: Path, project: Path, message: str) -> tuple[Path, str]: - """A private wire at `path` holding `message` in #team from `peer`; returns it and its root id.""" - from agent_comms.comms import Comms - from agent_comms.thread_identity import ThreadRole +def private_root(path: Path, project: Path, message: str) -> Path: + """A private bus at `path` holding `message` in #team from `peer`; returns `path`.""" + from agent_comms.thread_identity import UserThreadRole path.mkdir(mode=0o700) - comms = Comms(path, private_initial_writes=True) - comms.registry.declare(comms.registry.new_thread("user", frozenset(), str(project), role=ThreadRole.USER)) + comms = installed_wire(path) + comms.registry.declare(comms.registry.new_thread("user", frozenset(), str(project), role=UserThreadRole)) comms.registry.declare(comms.registry.new_thread("owner", frozenset({"team"}), str(project / "owner-project"))) comms.registry.declare(comms.registry.new_thread("peer", frozenset({"team"}), str(project / "peer-project"))) - root_id = comms.messaging.initialize_private_initial_protocol() - comms.messaging.send_initial_cohort("peer", "#team", message) - return path, root_id + comms.messaging.send("peer", "#team", message) + return path -def publish_route(home: Path, root: Path, root_id: str) -> Path: +def selecting_route(root: Path): + """The default route that selects `root` and the bus it holds now.""" + from agent_comms.active_route import ActiveRoute + from agent_comms.bus import Bus + from agent_comms.recorded_bus import RecordedBusIdentity + + return ActiveRoute(root, RecordedBusIdentity.capture(Bus.path_in(root))) + + +def publish_route(home: Path, root: Path) -> Path: """Replace the default-route file under `home` so it selects `root`; returns the file.""" directory = home / ".local/state/agent-comms" directory.mkdir(parents=True, mode=0o700, exist_ok=True) directory.chmod(0o700) destination = directory / "active-route.json" replacement = directory / "replacement.json" - replacement.write_text(json.dumps({"version": 1, "root": str(root), "wire_root_id": root_id, - "native_package": str(home / "unused-package")})) + replacement.write_text(json.dumps(selecting_route(root).to_record())) replacement.chmod(0o600) os.replace(replacement, destination) return destination -def select_root_by_default_route(monkeypatch, parent: Path) -> Path: - """Make this process choose its comms root from a home's route file, not AGENT_COMMS_ROOT. - - Returns the new home, under `parent`; `monkeypatch` restores the environment afterwards. - """ +def route_home(monkeypatch, parent: Path) -> Path: + """Isolate publication of the default used by a subsequent explicit launch.""" home = parent / "home" home.mkdir(mode=0o700) monkeypatch.setenv("HOME", str(home)) diff --git a/tests/support/terminal.py b/tests/support/terminal.py index e7fe9094a..c5d088706 100644 --- a/tests/support/terminal.py +++ b/tests/support/terminal.py @@ -47,6 +47,7 @@ async def start(self, command: tuple[str, ...], *, environment: dict[str, str] | master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.rows, self.columns, 0, 0)) env = self.workspace.roots.environment(dict(os.environ)) | (environment or {}) + env["PYTHONPATH"] = os.pathsep.join(str(Path(path).absolute()) for path in sys.path) self._master = master self.process = await asyncio.create_subprocess_exec( *command, cwd=str(self.workspace.project), env=env, @@ -155,9 +156,3 @@ async def terminal_running(workspace, emulator, command: tuple[str, ...], *, siz raise finally: await terminal.stop() - - -def native_wire_environment(workspace, native_package: Path) -> dict[str, str]: - """What a child needs to use the native route Core declares on this test's wire.""" - return {"AGENT_COMMS_PRIVATE_NK_WIRE_ROOT_ID": workspace.comms.messaging.initialize_private_initial_protocol(), - "AGENT_COMMS_PRIVATE_NK_NATIVE_PACKAGE": str(native_package)} diff --git a/tests/support/terminal_crash_app.py b/tests/support/terminal_crash_app.py index 35bf889e9..2f387623b 100644 --- a/tests/support/terminal_crash_app.py +++ b/tests/support/terminal_crash_app.py @@ -1,6 +1,6 @@ """Toad in a terminal whose UI raises a controlled error after its first paint. -Run as a script by ``terminal/test_terminal_crash.py``: ``terminal_crash_app.py PROJECT``. +Run as a script by ``terminal/test_terminal_crash.py``: ``terminal_crash_app.py PROJECT WIRE_ROOT``. """ import sys @@ -9,6 +9,7 @@ from toad.cli import run_terminal from stylesheets import INSTALLED +from agents import peer class TerminalCrashApp(ToadApp): @@ -24,4 +25,7 @@ def fail_after_paint(self): if __name__ == "__main__": - run_terminal(TerminalCrashApp(project_dir=sys.argv[1])) + from agent_comms.launch_root import LaunchRoot + + LaunchRoot.from_path(sys.argv[2]).install() + run_terminal(TerminalCrashApp(project_dir=sys.argv[1], agent_data=peer("echo"))) diff --git a/tests/support/textual_steps.py b/tests/support/textual_steps.py index b7121fba8..5eab7ce8b 100644 --- a/tests/support/textual_steps.py +++ b/tests/support/textual_steps.py @@ -155,9 +155,11 @@ def painted_text(widget, text) -> bool: async def saved_history(view, page, loader, *, committed=True): """A saved transcript history prepared and paged the way the application builds one.""" from toad.transcript_preparation import SavedHistory, TranscriptPageRenderTask + from toad.transcript_state import LiveTranscript, ProvisionalTranscript from toad.widgets.transcript_history import TranscriptHistory from toad.work_preparation import RenderPreparation prepared = await view.app.preparation.submit(RenderPreparation(TranscriptPageRenderTask(page))) return TranscriptHistory(prepared, SavedHistory(loader, page.after, view.app.preparation), - committed=committed, notification_view=view.contents) + view=view, notification_view=view.contents, + source_state=LiveTranscript() if committed else ProvisionalTranscript()) diff --git a/tests/support/ui.py b/tests/support/ui.py index d560ceddd..44c9f5fc3 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -344,7 +344,7 @@ async def step(self, seconds: float | None = None) -> None: @property def core_service(self): - return self.app.coordination_access.service + return self.app.coordination_access.observation.observed().service def failure(self) -> BaseException | None: return self.app._exception diff --git a/tests/support/workspace.py b/tests/support/workspace.py index ec4ed9feb..3f2d71786 100644 --- a/tests/support/workspace.py +++ b/tests/support/workspace.py @@ -8,11 +8,10 @@ from typing import TYPE_CHECKING from support.agents import AppAgents -from support.roots import Roots +from support.roots import Roots, installed_wire from support.ui import TextualUi if TYPE_CHECKING: - from agent_comms.comms import Comms from toad.agent_schema import AgentDefinition @@ -26,13 +25,7 @@ def __init__(self, roots: Roots) -> None: self.project = roots.project self.project.mkdir(parents=True, exist_ok=True) LaunchRoot.from_path(roots.wire).install() - - @cached_property - def comms(self) -> Comms: - """The test's own wire, as Core serves it.""" - from agent_comms.comms import wire - - return wire(self.roots.wire) + self.comms = installed_wire(roots.wire) @property def viewer(self) -> str: diff --git a/tests/terminal/test_failed_close.py b/tests/terminal/test_failed_close.py index 4386b829e..1d9c27ee4 100644 --- a/tests/terminal/test_failed_close.py +++ b/tests/terminal/test_failed_close.py @@ -11,13 +11,11 @@ async def test_a_close_step_that_raises_still_ends_the_process_and_saves_the_failure(workspace, terminal_emulator): import psutil - workspace.comms.messaging.initialize_private_initial_protocol() logs = workspace.roots.attempt / "state" / "toad" / "logs" - async with terminal_running(workspace, terminal_emulator, (sys.executable, str(APP), str(workspace.project)), + async with terminal_running(workspace, terminal_emulator, (sys.executable, str(APP), str(workspace.project), str(workspace.roots.wire)), size=(43, 140)) as terminal: - # Toad starts with no tab open; Enter on the welcome view's New Session opens the conversation. - await terminal.until_shows("New Session") - await terminal.send("\r") + await terminal.until_shows("#comms") + await terminal.click_text("#comms") await terminal.until((workspace.project / "conversation-open").exists, seconds=60, why="a conversation to open") started = psutil.Process(terminal.process.pid).children(recursive=True) diff --git a/tests/terminal/test_observer_death.py b/tests/terminal/test_observer_death.py index 12a2316b4..a27421b8b 100644 --- a/tests/terminal/test_observer_death.py +++ b/tests/terminal/test_observer_death.py @@ -1,4 +1,4 @@ -"""Killing the comms observer of a running Toad ends the app: one saved failure naming the observer, exit status 1, the terminal restored and no process left behind.""" +"""A killed observer is reported while Toad stays usable and can quit cleanly.""" from pathlib import Path import signal @@ -10,13 +10,11 @@ LEAVE_ALTERNATE_SCREEN = "\x1b[?1049l" -async def test_a_killed_observer_ends_the_app_with_one_saved_failure(workspace, terminal_emulator): +async def test_a_killed_observer_is_reported_without_ending_the_app(workspace, terminal_emulator): import psutil - workspace.comms.messaging.initialize_private_initial_protocol() - logs = workspace.roots.attempt / "state" / "toad" / "logs" noted = workspace.project / "observer-pid" - async with terminal_running(workspace, terminal_emulator, (sys.executable, str(APP), str(workspace.project)), + async with terminal_running(workspace, terminal_emulator, (sys.executable, str(APP), str(workspace.project), str(workspace.roots.wire)), size=(43, 140)) as terminal: await terminal.until(noted.exists, seconds=60, why="the observer to answer the sidebar") await terminal.until_shows("New Session", seconds=30) @@ -26,14 +24,14 @@ async def test_a_killed_observer_ends_the_app_with_one_saved_failure(workspace, started = app.children(recursive=True) shown = len(terminal.output) observer.send_signal(signal.SIGKILL) + await terminal.until(lambda: terminal.shows("was killed by signal") or terminal.shows("ObserverDied"), + seconds=20, why="the observer's actual failure") + assert terminal.returncode is None + await terminal.send("\x11") status = await terminal.until_exit(seconds=20) tape = terminal.output[shown:].decode(errors="replace") _, alive = psutil.wait_procs(started, timeout=5) - saved = [path.read_text() for path in logs.glob("Terminal_crash*.txt")] - assert status == 1, (status, tape[-2000:]) - assert len(saved) == 1, saved - assert "ObserverDied" in saved[0], saved[0] - assert f"The comms observer (pid {observer.pid}) was killed by signal 9" in saved[0], saved[0] - assert "BrokenPipeError" not in saved[0] and "BrokenPipeError" not in tape + assert status == 0, (status, tape[-2000:]) + assert "BrokenPipeError" not in tape assert LEAVE_ALTERNATE_SCREEN in tape, tape[-2000:] assert not alive, [(process.pid, process.cmdline()) for process in alive] diff --git a/tests/terminal/test_resize.py b/tests/terminal/test_resize.py index 177f01c10..1524eca73 100644 --- a/tests/terminal/test_resize.py +++ b/tests/terminal/test_resize.py @@ -2,8 +2,10 @@ import sys +from agent_comms.thread_text import SavedSessionFile + from support.journals import assistant_entry, write_journal -from support.terminal import native_wire_environment, terminal_running, toad_command +from support.terminal import terminal_running, toad_command async def test_every_terminal_resize_repaints_transcript_sidebar_and_composer( @@ -12,11 +14,11 @@ async def test_every_terminal_resize_repaints_transcript_sidebar_and_composer( journal = write_journal(workspace.project / "session.jsonl", *( assistant_entry(f"REPLAY_MARKER_{i}\n\n" + "A paragraph to wrap. " * 100, id=f"a{i}", parent=f"a{i - 1}" if i else None) for i in range(40))) - environment = native_wire_environment(workspace, native_package) | { + environment = { "AGENT_COMMS_AGENT_BIN": "pi", "AGENT_COMMS_AGENT_MODELS": "test/model", "AGENT_COMMS_AGENT_ARGS": "--model test/model"} workspace.comms.registry.declare(workspace.comms.registry.new_thread( - "resize", frozenset(), str(workspace.project), session_file=str(journal))) + "resize", frozenset(), str(workspace.project), session_file=SavedSessionFile(str(journal)))) command = toad_command("acp", f"{sys.executable} -m agent_comms.acp", "--session", "resize") async with terminal_running(workspace, terminal_emulator, command, environment=environment) as terminal: await terminal.until(lambda: terminal.shows("A paragraph") or terminal.shows("REPLAY_MARKER_39"), diff --git a/tests/terminal/test_terminal_crash.py b/tests/terminal/test_terminal_crash.py index ce490870a..802e87f27 100644 --- a/tests/terminal/test_terminal_crash.py +++ b/tests/terminal/test_terminal_crash.py @@ -1,4 +1,4 @@ -"""A UI exception in a real terminal leaves the error printed, exits with status 1 and saves one owner-only traceback file without local variables.""" +"""A mounted UI exception is shown while the actual terminal remains usable.""" from pathlib import Path import sys @@ -8,20 +8,18 @@ APP = Path(__file__).parents[1] / "support" / "terminal_crash_app.py" -async def test_a_ui_exception_prints_the_error_and_saves_one_private_traceback(workspace, terminal_emulator): - workspace.comms.messaging.initialize_private_initial_protocol() - logs = workspace.roots.attempt / "state" / "toad" / "logs" - async with terminal_running(workspace, terminal_emulator, (sys.executable, str(APP), str(workspace.project)), +async def test_a_ui_exception_is_shown_and_the_terminal_can_keep_working(workspace, terminal_emulator): + async with terminal_running(workspace, terminal_emulator, (sys.executable, str(APP), str(workspace.project), str(workspace.roots.wire)), size=(43, 140)) as terminal: - status = await terminal.until_exit(seconds=30) + await terminal.until_shows("CONTROLLED_TERMINAL_CAPTURE_PROOF") + assert terminal.returncode is None + await terminal.until_shows("#comms") + await terminal.click_text("#comms") + await terminal.until_shows("❯") + await terminal.send("RECOVERED_DRAFT") + await terminal.until_shows("RECOVERED_DRAFT") + await terminal.send("\x11") + status = await terminal.until_exit(seconds=20) tape = terminal.output.decode(errors="replace") - files = list(logs.glob("Terminal_crash*.txt")) - assert status == 1, (status, tape[-2000:]) - assert len(files) == 1, files - body = files[0].read_text() - assert "RecursionError: CONTROLLED_TERMINAL_CAPTURE_PROOF" in body - assert "fail_after_paint" in body and "Traceback" in body - assert files[0].stat().st_mode & 0o777 == 0o600 - assert "Terminal traceback saved:" in tape + assert status == 0, (status, tape[-2000:]) assert "CONTROLLED_TERMINAL_CAPTURE_PROOF" in tape - assert "locals" not in body diff --git a/tests/textual/test_app_close_order.py b/tests/textual/test_app_close_order.py index 2b0dd2da7..00671ae93 100644 --- a/tests/textual/test_app_close_order.py +++ b/tests/textual/test_app_close_order.py @@ -30,7 +30,6 @@ async def test_native_unmount_retires_the_acquired_history_after_children_leave( async def test_closing_the_app_after_the_observation_process_exits_raises_nothing(workspace): project = workspace.project comms = workspace.comms - comms.messaging.initialize_private_initial_protocol() saved = create_fresh_private_session(project / "sessions", worktree=project).path with saved.open("a") as stream: stream.write(json.dumps({"type": "message", "id": "ask", diff --git a/tests/textual/test_arrival_fade.py b/tests/textual/test_arrival_fade.py index 44c83d72b..be7e20aa6 100644 --- a/tests/textual/test_arrival_fade.py +++ b/tests/textual/test_arrival_fade.py @@ -38,7 +38,6 @@ async def test_every_live_conversation_block_runs_the_arrival_fade_on_mount(work async def test_a_commit_retiring_a_live_block_still_fades_another_senders_row(workspace): project = workspace.project comms = workspace.comms - comms.messaging.initialize_private_initial_protocol() saved = create_fresh_private_session(project / "sessions", worktree=project).path prompt = saved.read_text() + json.dumps({"type": "message", "id": "first-user", "timestamp": "2026-10-22T00:00:00Z", "message": { diff --git a/tests/textual/test_channel_participants.py b/tests/textual/test_channel_participants.py deleted file mode 100644 index a56fe672a..000000000 --- a/tests/textual/test_channel_participants.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Textual-facing: the participants line above a channel composer is prepared off the UI thread (a Textual widget's paint input).""" - -import asyncio -from collections import Counter -import os -import sys -from threading import get_ident - -from agent_comms.child_process import ProcessIdentity -from agent_comms.thread_execution import ExternalThreadExecution - -PARTICIPANTS = 24 - - -async def test_private_channel_update_changes_participants_without_blocking_the_ui(workspace): - from toad.widgets.channel_participants import ChannelParticipants - - service = workspace.comms - service.messaging.initialize_private_initial_protocol() - leases = [] - for index in range(PARTICIPANTS): - owner = service.registry.declare(service.registry.new_thread( - f"participant-{index:02}", frozenset({"team"}), str(workspace.project), - process_identity=ProcessIdentity.capture(os.getpid()), execution=ExternalThreadExecution)) - leased, _ = service.registry.lease_local_turn(owner.name, f"private-turn-{index}") - leases.append(leased.require_turn_lease()) - - loop_thread = get_ident() - calls = Counter() - monitor = sys.monitoring - monitor.use_tool_id(monitor.PROFILER_ID, "channel-participants") - code = ChannelParticipants.prepare_participants.__code__ - monitor.register_callback(monitor.PROFILER_ID, monitor.events.PY_START, lambda *_: calls.update( - ("UI" if get_ident() == loop_thread else "worker",))) - monitor.set_local_events(monitor.PROFILER_ID, code, monitor.events.PY_START) - try: - async with workspace.app(size=(110, 38)) as ui: - await ui.open_channel("#team", actor="participant-00") - names = ui.textual[0].screen.query_one(ChannelParticipants).names - await ui.until(lambda: "participant-23" in names.content.plain, why="every participant listed") - assert all(f"participant-{index:02}" in names.content.plain for index in range(PARTICIPANTS)) - - await ui.write_draft("Retained draft") - message = await asyncio.to_thread( - service.messaging.send_initial_cohort, - "participant-00", "#team", "Private update") - await ui.until_shows("Private update") - await ui.press("x") - assert ui.draft() == "Retained draftx" - - for lease in leases: - await asyncio.to_thread(service.registry.release_turn, lease) - leases.clear() - await ui.until(lambda: names.content.plain == "No active turns", why="participants to retire") - assert message.message_id - await ui.write_draft("@participant-23") - await ui.until_shows("Mention a thread") # Idle members remain mentionable. - assert calls["worker"] > 0 - assert calls["UI"] == 0 - finally: - for lease in leases: - await asyncio.to_thread(service.registry.release_turn, lease) - monitor.set_local_events(monitor.PROFILER_ID, code, 0) - monitor.free_tool_id(monitor.PROFILER_ID) diff --git a/tests/textual/test_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index 692eebd09..1377908e3 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -10,7 +10,6 @@ async def test_live_update_paints_parked_channel_unsubscribes_and_return_shows_t comms = workspace.comms for name in ("sender", "reader", session_thread_name(workspace.project)): workspace.thread(name, "alpha") - comms.messaging.initialize_private_initial_protocol() comms.messaging.send("sender", "#alpha", "INITIAL ROW") async with workspace.app(size=(113, 34)) as ui: app = ui.app diff --git a/tests/textual/test_compaction_turn_events.py b/tests/textual/test_compaction_turn_events.py index b9d118361..75b8772ff 100644 --- a/tests/textual/test_compaction_turn_events.py +++ b/tests/textual/test_compaction_turn_events.py @@ -1,4 +1,4 @@ -"""Textual-facing: turn and compaction facts from Core's owners, delivered as ACP updates, drive the conversation's activity line.""" +"""Core turn and compaction publications preserve custody in a mounted conversation.""" from pathlib import Path import subprocess @@ -9,17 +9,16 @@ from agent_comms.coordinator import Coordination from agent_comms.goal_presentation import IdleGoalExecution from agent_comms.goals import NoGoal +from support.agents import deliver PRODUCER = Path(__file__).resolve().parents[1] / "support/peers/typed_turn_events.py" -async def test_phase_events_from_core_owners_reach_the_turn_activity_and_a_finished_cut_is_refused(workspace): +async def test_core_compaction_publications_retain_the_active_turn_and_refuse_a_finished_cut(workspace): import json - from toad.widgets.conversation import TurnActivity - - command = (sys.executable, str(PRODUCER), str(workspace.roots.attempt / "producer-wire"), str(workspace.project)) + command = (sys.executable, str(PRODUCER), str(workspace.roots.wire), str(workspace.project)) with SpawnedProcess.launch(command, output=subprocess.PIPE) as child: stdout, stderr = await Coordination.run_worker(child.process.communicate) outcome = child.reap() @@ -35,7 +34,7 @@ async def test_phase_events_from_core_owners_reach_the_turn_activity_and_a_finis async def consume_until(member): for request in requests: - assert await agent.server.call(request) is None + await deliver(agent, request["params"]["update"], session_id=request["params"]["sessionId"]) await ui.step() for fact in decode_updates(request["params"]["update"].get("_meta")): if isinstance(fact, member): @@ -43,19 +42,19 @@ async def consume_until(member): raise AssertionError(f"the producer did not publish {member.__name__}") active = await consume_until(TurnChangedUpdate) - turn_id = active.state.managed_id + turn_id = active.state.active.id assert agent.current_turn.managed_id == view.turns.managed_id == turn_id assert agent.current_turn.busy and view.turns.owner.busy await consume_until(CompactionChangedUpdate) - assert "Compacting context" in str(view.query_one(TurnActivity).render()) + assert "Compacting context" in view.turns.owner.activity # Pi runs the compaction and reports its start and its end; there is no progress in between. await consume_until(CompactionChangedUpdate) assert "Compacting context" not in view.turns.owner.activity stale = await consume_until(TurnChangedUpdate) - assert not stale.state.busy and not stale.state.matches(turn_id) + assert not stale.state.active.phase.busy and not stale.state.matches(turn_id) assert agent.current_turn.managed_id == view.turns.managed_id == turn_id terminal = await consume_until(TurnChangedUpdate) - assert not terminal.state.busy and terminal.state.matches(turn_id) + assert not terminal.state.active.phase.busy and terminal.state.matches(turn_id) assert agent.current_turn.managed_id is view.turns.managed_id is None assert not agent.current_turn.busy and not view.turns.owner.busy await consume_until(GoalChangedUpdate) diff --git a/tests/textual/test_conversation_kind.py b/tests/textual/test_conversation_kind.py index 5dddb59a9..6843ee871 100644 --- a/tests/textual/test_conversation_kind.py +++ b/tests/textual/test_conversation_kind.py @@ -1,52 +1,57 @@ -"""Textual-facing: the historical sessions screen and observed thread activity widget; conversation values retain Core destination identities through copied history.""" +"""Textual-facing: stable conversation identities resolve current names through Core references.""" -from dataclasses import FrozenInstanceError, fields +from dataclasses import FrozenInstanceError from functools import partial import pytest - -from agent_comms.candidate_maintenance import drain_candidate_catchup -from agent_comms.channel_targets import ChannelTarget, ThreadTarget -from agent_comms.comms import Comms, wire +from agent_comms.channel_targets import ChannelTarget +from agent_comms.comms import Comms +from support.roots import installed_wire from agent_comms.field_codec import FieldCodec from agent_comms.installer.backup import StoreBackup from agent_comms.message_page import PageBounds + from toad.constants import ALL_COMMS_TARGET -from toad.conversation_kind import ( - ChannelConversation, ConversationKind, DmConversation, IrcConversation, ResolvedConversation, -) -from toad.session_tracker import CommsViewKey +from toad.conversation_kind import ChannelConversation, ConversationKind, IrcConversation from toad.message_rows import IrcMessageStyle +from toad.navigation_target import ChannelTarget as ChannelRoute +from toad.navigation_target import DirectTarget, FeedTarget, HistoryTarget +from toad.session_tracker import CommsViewKey +from toad.status_thread import NoStatusThread from toad.wire_history import WireSource @pytest.fixture def copied_conversations(tmp_path): - original = wire(tmp_path / "original") - original.registry.declare(original.registry.new_thread("peer", frozenset({"team"}), str(tmp_path))) + original = installed_wire(tmp_path / "original") + original.registry.declare( + original.registry.new_thread("peer", frozenset({"team"}), str(tmp_path)) + ) viewer = original.messaging.user_identity(str(tmp_path)).name original.messaging.send("peer", "#team", "channel body") original.messaging.send("peer", viewer, "direct body") - drain_candidate_catchup(original.root) StoreBackup.copy_tree(original.root, tmp_path / "copied") return Comms(tmp_path / "copied"), viewer -@pytest.mark.parametrize("conversation,label,body", [ - (DmConversation(ThreadTarget("peer")), "@peer", "direct body"), - (ChannelConversation(ChannelTarget("#team")), "#team", "channel body"), - (IrcConversation(ChannelTarget(ALL_COMMS_TARGET)), f"{ALL_COMMS_TARGET} · all comms", "direct body"), +@pytest.mark.parametrize("route,label,body", [ + (DirectTarget("peer"), "@peer", "direct body"), + (ChannelRoute("#team"), "#team", "channel body"), + (FeedTarget(), f"{ALL_COMMS_TARGET} · all comms", "direct body"), ]) -def test_typed_conversation_resolves_pages_and_labels_on_a_copied_bus( - copied_conversations, tmp_path, conversation, label, body, +def test_conversation_family_resolves_copied_pages_and_current_labels( + copied_conversations, tmp_path, route, label, body, ): comms, viewer = copied_conversations - resolved = conversation.resolve(comms, viewer) - assert resolved == ResolvedConversation(viewer, conversation) - assert resolved.conversation.label() == label - assert resolved.conversation.target == conversation.target - page = resolved.conversation.page( - WireSource(comms, resolved.conversation, tmp_path, viewer, IrcMessageStyle()), PageBounds(limit=8), + resolved = route.prepare(comms, viewer) + conversation = resolved.conversation + references = comms.views.viewer_snapshot(str(tmp_path)).references + assert resolved.viewer == viewer + assert conversation.label(references) == label + assert HistoryTarget.for_conversation(conversation, references) == route + page = conversation.page( + WireSource(comms, conversation, tmp_path, viewer, IrcMessageStyle()), + PageBounds(limit=8), ) assert body in [message.body for message in page.messages] assert FieldCodec.decode(type[ConversationKind], FieldCodec.encode(type(conversation))) is type(conversation) @@ -54,53 +59,90 @@ def test_typed_conversation_resolves_pages_and_labels_on_a_copied_bus( resolved.viewer = "another-viewer" -def test_new_channel_kind_inherits_its_typed_destination_and_resolution(copied_conversations): +def test_new_channel_kind_inherits_destination_resolution(copied_conversations): class TeamConversation(ChannelConversation): pass comms, viewer = copied_conversations - conversation = TeamConversation(ChannelTarget("#team")) + conversation = TeamConversation.resolve(comms, viewer, "#team").conversation + references = comms.views.viewer_snapshot(str(comms.root.parent)).references assert ConversationKind.member_named("team") is TeamConversation - assert conversation.resolve(comms, viewer).conversation == conversation - assert conversation.label() == "#team" + assert conversation == TeamConversation(ChannelTarget("#team")) + assert conversation.label(references) == "#team" + + +def test_routes_reject_duplicate_declarations_and_unregistered_kinds(copied_conversations): + with pytest.raises(TypeError): + class DuplicateDirectTarget(DirectTarget): + pass + + class UnroutedConversation(ChannelConversation): + pass + comms, _ = copied_conversations + references = comms.views.viewer_snapshot(str(comms.root.parent)).references + with pytest.raises(KeyError): + HistoryTarget.for_conversation(UnroutedConversation(ChannelTarget("#team")), references) -def test_conversation_value_is_frozen_and_its_kind_is_part_of_tab_identity(): + +def test_kind_and_owner_are_part_of_identity_but_channel_address_is_shared(copied_conversations): + comms, viewer = copied_conversations conversation = ChannelConversation(ChannelTarget("#team")) - assert [field.name for field in fields(conversation)] == ["target"] with pytest.raises(FrozenInstanceError): conversation.target = ChannelTarget("#other") - key = CommsViewKey("/wire", "owner", conversation) - assert key == CommsViewKey("/wire", "owner", ChannelConversation(ChannelTarget("#team"))) - assert key != CommsViewKey("/wire", "owner", IrcConversation(ChannelTarget("#team"))) - assert key != CommsViewKey("/wire", "owner", DmConversation(ThreadTarget("peer"))) - assert len({key, CommsViewKey("/wire", "owner", conversation)}) == 1 + key = CommsViewKey(str(comms.root), "owner", conversation) + assert key == CommsViewKey(str(comms.root), "owner", ChannelConversation(ChannelTarget("#team"))) + assert key != CommsViewKey(str(comms.root), "owner", IrcConversation(ChannelTarget("#team"))) + direct = DirectTarget("peer").prepare(comms, viewer).conversation + assert key != CommsViewKey(str(comms.root), "owner", direct) + assert len({key, CommsViewKey(str(comms.root), "owner", conversation)}) == 1 assert conversation.view_identity(key) == conversation.view_identity( - CommsViewKey("/wire", "another-owner", conversation)) - direct = DmConversation(ThreadTarget("peer")) - assert direct.view_identity(CommsViewKey("/wire", "owner", direct)) != direct.view_identity( - CommsViewKey("/wire", "another-owner", direct)) + CommsViewKey(str(comms.root), "another-owner", conversation) + ) + assert direct.view_identity(CommsViewKey(str(comms.root), "owner", direct)) != direct.view_identity( + CommsViewKey(str(comms.root), "another-owner", direct) + ) -def test_conversation_resolves_a_renamed_peer_before_presentation(tmp_path): - comms = wire(tmp_path / "wire") - comms.registry.declare(comms.registry.new_thread("peer", frozenset(), str(tmp_path))) - viewer = comms.messaging.user_identity(str(tmp_path)).name +def test_dm_rename_retains_key_and_history_scope_but_resolves_current_name(copied_conversations): + comms, viewer = copied_conversations + direct = DirectTarget("peer").prepare(comms, viewer).conversation + key = CommsViewKey(str(comms.root), "owner", direct) + scope = direct.input_scope comms.registry.rename("peer", "renamed") - conversation = DmConversation(ThreadTarget("peer")) - assert conversation.resolve(comms, viewer) == ResolvedConversation( - viewer, DmConversation(ThreadTarget("renamed")), - ) - assert conversation.target == ThreadTarget("peer") + reopened = DirectTarget("renamed").prepare(comms, viewer).conversation + references = comms.views.viewer_snapshot(str(comms.root.parent)).references + assert direct == reopened + assert key == CommsViewKey(str(comms.root), "owner", reopened) + assert direct.input_scope == reopened.input_scope == scope + assert direct.label(references) == "@renamed" + assert direct.send_target().selected_name(references.registry) == "renamed" + assert HistoryTarget.for_conversation(direct, references) == DirectTarget("renamed") -def test_conversation_selects_the_saved_history_identity_and_channel_status(tmp_path): +def test_recreated_name_cannot_resolve_the_retired_dm(copied_conversations): + comms, viewer = copied_conversations + direct = DirectTarget("peer").prepare(comms, viewer).conversation + comms.registry.unregister("peer") + comms.threads.delete("peer") + comms.registry.declare(comms.registry.new_thread("peer", frozenset(), str(comms.root.parent))) + recreated = DirectTarget("peer").prepare(comms, viewer).conversation + references = comms.views.viewer_snapshot(str(comms.root.parent)).references + assert recreated != direct + assert CommsViewKey(str(comms.root), "owner", recreated) != CommsViewKey(str(comms.root), "owner", direct) + assert recreated.label(references) == "@peer" + with pytest.raises(KeyError): + direct.destination(references) + + +def test_history_picker_and_channel_status_use_the_same_declared_value(copied_conversations): from toad.screens.historical_sessions import HistoricalSessions - from toad.widgets.observed_thread_activity import NoStatusThread - comms = wire(tmp_path / "wire") + comms, viewer = copied_conversations + references = comms.views.viewer_snapshot(str(comms.root.parent)).references create_view = partial(HistoricalSessions, comms, ()) channel = ChannelConversation(ChannelTarget("#team")) - assert channel.historical_sessions(create_view).initial_name is None - assert DmConversation(ThreadTarget("peer")).historical_sessions(create_view).initial_name == "peer" + direct = DirectTarget("peer").prepare(comms, viewer).conversation + assert channel.historical_sessions(create_view, references).initial_name is None + assert direct.historical_sessions(create_view, references).initial_name == "peer" assert isinstance(channel.status_thread(comms.root, object()), NoStatusThread) diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index 514b4e13b..20f4ceec7 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -1,15 +1,20 @@ """Textual-facing: the conversation tabs; typed channel, DM and all-comms values survive actual UI open/send/return.""" -from agent_comms.channel_targets import ChannelTarget, ThreadTarget +import asyncio + +from agent_comms.channel_targets import ChannelTarget from agent_comms.channels import AllChannel from toad.constants import ALL_COMMS_TARGET from toad.conversation_kind import ChannelConversation, DmConversation, IrcConversation -from toad.navigation_target import DirectTarget, FeedTarget, NavigationContext, channel_target +from toad.navigation_target import NavigationContext +from toad.navigation_target import DirectTarget, FeedTarget, channel_target from toad.widgets.comms_chat import CommsChatView from toad.widgets.channel_activity import ChannelActivity -async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and_return(workspace): +async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and_return( + workspace, +): workspace.thread("peer", "team", title="Conversation peer") viewer = workspace.viewer workspace.comms.messaging.send("peer", "#team", "CHANNEL_VALUE_HISTORY") @@ -19,35 +24,65 @@ async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and owner = ui.selected_tab context = NavigationContext(app, owner, workspace.project, viewer) for target, expected, body, status_name, destination in ( - (channel_target("#team"), ChannelConversation(ChannelTarget("#team")), - "CHANNEL_VALUE_HISTORY", "", "#team"), - (DirectTarget("peer"), DmConversation(ThreadTarget("peer")), - "DM_VALUE_HISTORY", "peer", "peer"), - (FeedTarget(), IrcConversation(ChannelTarget(ALL_COMMS_TARGET)), - "CHANNEL_VALUE_HISTORY", "", AllChannel.declared_name), + ( + channel_target("#team"), + ChannelConversation(ChannelTarget("#team")), + "CHANNEL_VALUE_HISTORY", + "", + "#team", + ), + ( + DirectTarget("peer"), + DmConversation(workspace.comms.registry.require("peer").incarnation), + "DM_VALUE_HISTORY", + "peer", + "peer", + ), + ( + FeedTarget(), + IrcConversation(ChannelTarget(ALL_COMMS_TARGET)), + "CHANNEL_VALUE_HISTORY", + "", + AllChannel.address(), + ), ): mode = await target.open(context) screen = app.selected_session + assert screen.navigation_target_name == target.name await screen.wait_content_ready() chat = screen.query_one(CommsChatView) - await ui.until(lambda: body in ui.painted(chat.window), - why="the conversation's saved message on screen") + await ui.until( + lambda: body in ui.painted(chat.window), + why="the conversation's saved message on screen", + ) entry = app.session_navigation.get(mode) conversation = entry.key.conversation assert conversation == expected assert screen.conversation is chat.conversation is conversation assert chat.message_history.page_source.loader.conversation is conversation - assert next(tab.title for tab in ui.tabs() if tab.identity == mode).startswith(expected.label()) - status = chat.message_history.page_source.status_thread(app.coordination_access) + assert next( + tab.title for tab in ui.tabs() if tab.identity == mode + ).startswith(expected.label( + app.coordination_access.observation.observed() + .sidebar.observation.require_snapshot().references + )) + status = chat.message_history.page_source.status_thread( + app.coordination_access + ) assert status.name == status_name outgoing = f"{workspace.roots.attempt.name}: P390_SENT_TO_{destination}" await ui.write_draft(outgoing) await ui.press("enter") - await ui.until(lambda: outgoing in ui.painted(chat.window), - why="the sent message returning through Core history") - sent, = (fragment.message for fragment in chat.message_history.fragments_in_range - if fragment.message.body == outgoing) + await ui.until( + lambda: outgoing in ui.painted(chat.window), + why="the sent message returning through Core history", + ) + (sent,) = ( + fragment.message + for fragment in chat.message_history.fragments_in_range + if fragment.message.body == outgoing + ) assert (sent.sender, sent.target) == (viewer, destination) chat.prompt.text = "retained draft" @@ -60,6 +95,128 @@ async def test_channel_dm_and_feed_keep_typed_destinations_through_open_send_and await channel_target("#team").open(context) chat = app.selected_session.query_one(CommsChatView) - workspace.thread("late-peer", "team", running=True, title="Late conversation peer") - await ui.until(lambda: "late-peer" in chat.query_one(ChannelActivity).rows, - why="the new member in the channel's activity rows") + late_peer = workspace.thread( + "late-peer", "team", running=True, title="Late conversation peer" + ) + activity = chat.query_one(ChannelActivity) + await ui.until( + lambda: late_peer.incarnation in activity.rows, + why="the new member in the channel's activity rows", + ) + member = activity.rows[late_peer.incarnation] + await asyncio.to_thread(workspace.comms.registry.rename, "late-peer", "renamed-late-peer") + await ui.until( + lambda: member.label == "renamed-late-peer", + why="the retained activity row following its peer's current name", + ) + assert activity.rows[late_peer.incarnation] is member + + +async def test_renamed_dm_reopens_the_same_tab_and_sends_to_the_current_name(workspace): + peer = workspace.thread("peer", "team") + viewer = workspace.viewer + workspace.comms.messaging.send("peer", viewer, "BEFORE_RENAME") + async with workspace.app(size=(110, 35)) as ui: + app, _ = ui.textual + owner = ui.selected_tab + context = NavigationContext(app, owner, workspace.project, viewer) + mode = await DirectTarget("peer").open(context) + screen = app.selected_session + await screen.wait_content_ready() + chat = screen.query_one(CommsChatView) + await ui.until(lambda: "BEFORE_RENAME" in ui.painted(chat.window), why="the original DM history") + entry = app.session_navigation.get(mode) + window = chat.window + chat.prompt.text = "draft survives rename" + await asyncio.to_thread(workspace.comms.registry.rename, "peer", "renamed") + await ui.until( + lambda: any(tab.identity == mode and tab.title.startswith("@renamed") for tab in ui.tabs()), + why="the retained DM tab following its incarnation's current name", + ) + assert next(tab.title for tab in app.open_tabs if tab.mode_name == mode) == "@renamed" + history = chat.message_history + await ui.until( + lambda: history.source_ready + and history.revision.selection.peer.name == "renamed" + and history.revision.selection.peer == peer.incarnation + and "BEFORE_RENAME" in ui.painted(window), + why="the renamed conversation's captured source and painted message", + ) + fragments = history.fragments_in_range + width, theme = history.line_width, app.colours.line_theme + lines = tuple(fragment.lines_for(width, theme) for fragment in fragments) + assert lines and all(value is not None for value in lines) + await ui.select_tab(owner) + assert await ui.open_direct("renamed", actor=viewer, owner=owner) == mode + assert app.session_navigation.get(mode) is entry + assert app.selected_session is screen + assert screen.query_one(CommsChatView) is chat + assert chat.window is window + assert all(before is after for before, after in zip( + fragments, history.fragments_in_range, strict=True, + )) + assert history.line_width == width + assert all(before is fragment.lines_for(width, theme) for before, fragment in zip( + lines, history.fragments_in_range, strict=True, + )) + assert entry.key.conversation.peer == peer.incarnation + assert chat.prompt.text == "draft survives rename" + assert "BEFORE_RENAME" in ui.painted(window) + await ui.write_draft("AFTER_RENAME") + await ui.press("enter") + await ui.until(lambda: "AFTER_RENAME" in ui.painted(window), why="a fresh send to the renamed peer") + sent = [fragment.message for fragment in chat.message_history.fragments_in_range if fragment.message.body == "AFTER_RENAME"] + assert len(sent) == 1 + assert sent[0].target == "renamed" + + +async def test_recreated_dm_peer_retires_the_old_view_and_opens_a_new_incarnation(workspace): + peer = workspace.thread("peer", "team") + viewer = workspace.viewer + async with workspace.app(size=(110, 35)) as ui: + app, _ = ui.textual + owner = ui.selected_tab + context = NavigationContext(app, owner, workspace.project, viewer) + old_mode = await DirectTarget("peer").open(context) + old_screen = app.selected_session + await old_screen.wait_content_ready() + old_chat = old_screen.query_one(CommsChatView) + old_window = old_chat.window + old_chat.prompt.text = "belongs only to the original peer" + + def replace_peer(): + registry = workspace.comms.registry + registry.unregister(peer.name) + registry.remove_originals((registry.require(peer.name),)) + return registry.declare(registry.new_thread( + peer.name, peer.tags, peer.worktree, created_at=peer.created_at + 1, + )) + + successor = await asyncio.to_thread(replace_peer) + await ui.until( + lambda: app.session_navigation.get(old_mode) is None, + why="Core retiring the deleted peer's visible DM view", + ) + await ui.select_tab(owner) + new_mode = await DirectTarget("peer").open(context) + screen = app.selected_session + await screen.wait_content_ready() + chat = screen.query_one(CommsChatView) + entry = app.session_navigation.get(new_mode) + assert new_mode != old_mode + assert screen is not old_screen + assert chat is not old_chat + assert chat.window is not old_window + assert entry.key.conversation.peer == successor.incarnation + assert entry.key.conversation.peer != peer.incarnation + assert chat.prompt.text == "" + await ui.write_draft("FOR_THE_SUCCESSOR") + await ui.press("enter") + await ui.until( + lambda: "FOR_THE_SUCCESSOR" in ui.painted(chat.window), + why="a fresh message in the successor's distinct DM view", + ) + sent = [fragment.message for fragment in chat.message_history.fragments_in_range + if fragment.message.body == "FOR_THE_SUCCESSOR"] + assert len(sent) == 1 + assert sent[0].target == successor.name diff --git a/tests/textual/test_default_route_flip.py b/tests/textual/test_default_route_flip.py index 18bdfe8e4..fda656d16 100644 --- a/tests/textual/test_default_route_flip.py +++ b/tests/textual/test_default_route_flip.py @@ -1,126 +1,97 @@ -"""Textual-facing: a mounted chat view (widget state: display, composer, status) follows the default comms route when it flips.""" +"""Published defaults select fresh launches; a running application's root stays explicit.""" import asyncio import threading from unittest.mock import patch -from agent_comms.errors import HumanClaimBlockedError -from agent_comms.route_selection import root_is_current +import pytest +from agent_comms.active_route import ActiveRoute +from agent_comms.errors import HumanClaimBlockedError, HumanSendRefusedError, RelationViolationError +from agent_comms.launch_root import LaunchRoot +from agent_comms.route_selection import current_root -from support.routes import private_root, publish_route, select_root_by_default_route -from support.textual_steps import refresh_chat +from support.routes import private_root, publish_route, route_home from toad.wire_history import WireSource - - -async def test_a_late_read_from_the_former_wire_is_not_painted_after_the_route_flips(workspace, monkeypatch): - from toad.widgets.comms_chat import CommsChatView - from toad.widgets.comms_sidebar import CommsSidebar - - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - first, first_id = private_root(attempt / "first", workspace.project, "OLD-WIRE-ONLY") - second, second_id = private_root(attempt / "second", workspace.project, "NEW-WIRE-ONLY") - publish_route(home, first, first_id) - - async with workspace.app(size=(115, 38)) as ui: - app = ui.textual[0] - assert app.coordination_access.service.root == first - mode = await ui.open_channel("#team", actor="user") - assert mode == ui.selected_tab - view = app.screen.query_one(CommsChatView) - await refresh_chat(view) - await ui.step() - assert view.message_history.source.comms.root == first - assert any("OLD-WIRE-ONLY" in str(message) for message in view.message_history.messages), view.message_history.messages - - # A read already in flight when the route flips must not paint a late page - # from the former wire into the successor view. - view.message_history.source.comms.messaging.send_initial_cohort("peer", "#team", "LATE-OLD-EDGE") - entered, release = threading.Event(), threading.Event() - real_read = WireSource.read - - def delayed_read(source, request, through): - entered.set() - assert release.wait(8), "the held read was not released" - return real_read(source, request, through) - - with patch.object(WireSource, "read", delayed_read): - pending = asyncio.create_task(refresh_chat(view)) - assert await asyncio.to_thread(entered.wait, 5) - publish_route(home, second, second_id) - release.set() - await pending - await ui.until(lambda: view.message_history.state.accepts_source_work, why="the held read to end") - # The route check runs once a second; the app's observed service follows it. - await ui.until(lambda: app.coordination_access.service.root == second, why="the app to follow the route") - assert all("LATE-OLD-EDGE" not in str(message) for message in view.message_history.messages) - await refresh_chat(view) - async with asyncio.timeout(10): - await app.coordination_access.current_sidebar() - await ui.step() - assert not root_is_current(first) - assert not view.display - if app.screen.query_one(CommsSidebar).display: - # The shared roster may already publish the successor while this old view stays hidden. - assert app.coordination_access.observed_service.root == second - assert not ui.shows("OLD-WIRE-ONLY") - - -async def test_a_fresh_connection_after_a_flip_paints_only_the_newly_published_wire_and_keeps_rejected_drafts_editable( - workspace, monkeypatch): +from toad.widgets.comms_chat import CommsChatView + + +async def test_a_published_default_does_not_rebind_a_running_app_or_its_acquired_read(workspace, monkeypatch): + home = route_home(monkeypatch, workspace.roots.attempt) + first = private_root(workspace.roots.attempt / "first", workspace.project, "FIRST_HISTORY") + second = private_root(workspace.roots.attempt / "second", workspace.project, "SECOND_HISTORY") + publish_route(home, first) + with ActiveRoute.published_launch().selected(): + async with workspace.app(size=(115, 38)) as ui: + await ui.open_channel("#team", actor="user") + await ui.until_shows("FIRST_HISTORY") + view = ui.app.selected_session.query_one(CommsChatView) + source = view.message_history.page_source.loader + entered, release = threading.Event(), threading.Event() + read = WireSource.read + + def held_read(source, request, through): + entered.set() + assert release.wait(8), "the acquired page was not released" + return read(source, request, through) + + with patch.object(WireSource, "read", held_read): + try: + source.comms.messaging.send("peer", "#team", "FIRST_LATE_PAGE") + await view._refresh() + assert await asyncio.to_thread(entered.wait, 8) + publish_route(home, second) + assert ActiveRoute.published_launch().path == second + assert current_root() == first + release.set() + await ui.until_shows("FIRST_LATE_PAGE") + assert ui.app.coordination_access.observation.observed().service.root == first + assert not ui.shows("SECOND_HISTORY") + finally: + release.set() + + +async def test_a_fresh_launch_uses_the_published_root_and_preserves_refused_and_blocked_drafts(workspace, monkeypatch): from toad.core import input_events - from toad.widgets.comms_chat import CommsChatView - - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - first, first_id = private_root(attempt / "first", workspace.project, "OLD-WIRE-ONLY") - second, second_id = private_root(attempt / "second", workspace.project, "NEW-WIRE-ONLY") - route_path = publish_route(home, first, first_id) - - # The same process first shows the former wire, then the route flips and that app closes. - async with workspace.app(size=(115, 38)) as ui: - await ui.open_channel("#team", actor="user") - await ui.until_shows("OLD-WIRE-ONLY") - publish_route(home, second, second_id) - await ui.until(lambda: ui.textual[0].coordination_access.service.root == second, why="the app to follow the route") - - async with workspace.app(size=(115, 38)) as ui: - app = ui.textual[0] - assert app.coordination_access.service.root == second - await ui.open_channel("#team", actor="user") - view = app.screen.query_one(CommsChatView) - await refresh_chat(view) - await ui.step() - assert view.display and view.message_history.source.comms.root == second - assert any("NEW-WIRE-ONLY" in str(message) for message in view.message_history.messages), view.message_history.messages - assert all("OLD-WIRE-ONLY" not in str(message) for message in view.message_history.messages) - - # A proven pre-append rejection remains editable; an UNKNOWN or an interrupted - # committed send does not. - with patch.object(view.message_history.source.comms.messaging, "send_user_message", - side_effect=ValueError("pre-append rejection")) as rejected: - await view.submit_input(input_events.UserInputSubmitted("EDITABLE-REJECTION")) - assert rejected.call_count == 1 - assert view.prompt.text == "EDITABLE-REJECTION" - assert not view.prompt.prompt_text_area.disabled - assert not view._send_block_reason - view.prompt.text = "" - # A send Core refuses to retry retains text for inspection and disables compose. - error = HumanClaimBlockedError("prior reservation cannot be retried") - with patch.object(view.message_history.source.comms.messaging, "send_user_message", - side_effect=error) as sender: - event = input_events.UserInputSubmitted("BLOCKED-NO-RETRY") - await view.submit_input(event) - assert sender.call_count == 1 - assert view.send_block_reason == "Send blocked: prior reservation cannot be retried" - assert view.prompt.text == event.body - assert view.prompt.prompt_text_area.disabled - await view.submit_input(event) - assert sender.call_count == 1 - route_path.write_text("{") - route_path.chmod(0o600) - await refresh_chat(view) - await ui.step() - assert not view.display - assert not ui.shows("NEW-WIRE-ONLY") + home = route_home(monkeypatch, workspace.roots.attempt) + first = private_root(workspace.roots.attempt / "first", workspace.project, "FIRST_HISTORY") + second = private_root(workspace.roots.attempt / "second", workspace.project, "SECOND_HISTORY") + publish_route(home, first) + with ActiveRoute.published_launch().selected(): + async with workspace.app(size=(115, 38)) as ui: + await ui.open_channel("#team", actor="user") + await ui.until_shows("FIRST_HISTORY") + publish_route(home, second) + assert ui.app.coordination_access.launch_root.path == first + + with ActiveRoute.published_launch().selected(): + async with workspace.app(size=(115, 38)) as ui: + await ui.open_channel("#team", actor="user") + await ui.until_shows("SECOND_HISTORY") + view = ui.app.selected_session.query_one(CommsChatView) + source = view.message_history.page_source.loader + assert source.comms.root == second + assert not ui.shows("FIRST_HISTORY") + with patch.object(source.comms.messaging, "send_user_message", + side_effect=HumanSendRefusedError(RelationViolationError("pre-append rejection"))) as rejected: + await view.submit_input(input_events.UserInputSubmitted("EDITABLE_REJECTION")) + await ui.until(lambda: not view.prompt.prompt_text_area.disabled, + why="the rejected draft editable on the fresh root") + assert rejected.call_count == 1 + assert view.prompt.text == "EDITABLE_REJECTION" + assert not view.prompt.prompt_text_area.disabled + assert not view.send_block_reason + view.prompt.text = "" + with patch.object(source.comms.messaging, "send_user_message", + side_effect=HumanClaimBlockedError("prior reservation cannot be retried")) as sender: + event = input_events.UserInputSubmitted("BLOCKED_NO_RETRY") + await view.submit_input(event) + await ui.until(lambda: view.prompt.prompt_text_area.disabled, + why="the blocked send visibly holds compose") + assert sender.call_count == 1 + assert view.send_block_reason == "Send blocked: prior reservation cannot be retried" + assert view.prompt.text == event.body + assert view.prompt.prompt_text_area.disabled + await view.submit_input(event) + assert sender.call_count == 1 + assert LaunchRoot.current().path == second diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index 95d083885..9714241fd 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -8,6 +8,8 @@ from agent_comms.registry_store import RegistryStore from textual.widgets import OptionList +from support.roots import installed_wire + from toad.core.events import CoordinationObserved from toad.screens.main import MainScreen from toad.target_commands import TargetContext @@ -29,9 +31,7 @@ async def open_hidden_sessions(workspace, ui, owners): first = ui.selected_tab def source(owner): - view = MainScreen(Path(owner.worktree)) - view.initial_coordination_root = str(workspace.comms.root) - return view + return MainScreen.from_thread(Path(owner.worktree), str(workspace.comms.root), owner.incarnation) modes = {} for owner in owners[1:]: @@ -49,7 +49,7 @@ async def test_idle_retirement_passes_read_nothing_and_a_hidden_prompt_skips_dis async with workspace.app(size=(140, 48)) as ui: app, _ = ui.textual modes = await open_hidden_sessions(workspace, ui, owners) - observed = app.coordination_access.observed_service + observed = app.coordination_access.observation.observed() calls = Counter() watched = {Comms.__init__.__code__: "service_constructions", RegistryStore._decode.__code__: "registry_decodes", @@ -66,7 +66,7 @@ async def test_idle_retirement_passes_read_nothing_and_a_hidden_prompt_skips_dis await app.session_navigation.retire_missing() app.coordination_access.events.publish(CoordinationObserved(None)) await ui.step() - assert app.coordination_access.observed_service is observed + assert app.coordination_access.observation.observed() is observed assert calls["service_constructions"] == calls["registry_decodes"] == 0 assert calls["action_discovery"] == calls["option_rebuilds"] == 0 before = calls["action_discovery"] @@ -124,13 +124,12 @@ async def test_a_same_name_successor_does_not_keep_the_deleted_threads_hidden_se why="the deleted thread's hidden session to retire") -async def test_an_unavailable_route_is_no_evidence_that_a_hidden_thread_is_gone(workspace, monkeypatch): +async def test_an_ambient_root_change_does_not_retire_a_hidden_thread(workspace, monkeypatch): owners = declare_owners(workspace) async with workspace.app(size=(140, 48)) as ui: app, _ = ui.textual modes = await open_hidden_sessions(workspace, ui, owners) - other = Comms(workspace.roots.wire.parent / "other", private_initial_writes=True) - other.messaging.initialize_private_initial_protocol() + other = installed_wire(workspace.roots.wire.parent / "other") monkeypatch.setenv("AGENT_COMMS_ROOT", str(other.root)) await app.session_navigation.retire_missing() assert app.session_navigation.get(modes["agent-2"]) is not None diff --git a/tests/textual/test_historical_notification_source.py b/tests/textual/test_historical_notification_source.py index bb750cc9d..58ae8456c 100644 --- a/tests/textual/test_historical_notification_source.py +++ b/tests/textual/test_historical_notification_source.py @@ -1,62 +1,40 @@ -"""Textual-facing: a historical-sessions screen reads handling notifications from the source it selected, and drops them when its body, screen or selection is gone.""" +"""The mounted historical screen observes its selected archive and retires its subscription.""" -import asyncio +from agent_comms.historical_views import HistoricalThread +from agent_comms.ui_model.observation import ReadRecordedNotifications -from agent_comms.comms import Comms -import pytest +from support.roots import installed_wire +from toad.screens.historical_sessions import HistoricalSessions, RecordedContents -@pytest.mark.parametrize("retirement", (None, "selection", "body", "screen", "detached")) -def test_historical_notification_keeps_selected_source_and_lifetime( - tmp_path, monkeypatch, retirement, -): - """Source callback boundary only; no mounted app.""" - from types import SimpleNamespace - - from agent_comms.historical_views import HistoricalThread, HistorySource - from toad.screens.historical_sessions import HistoricalSessions - from toad.widgets.wire_message_handling import WireMessageHandling - - old, live = Comms(tmp_path / "old"), Comms(tmp_path / "live") - old.registry.declare(old.registry.new_thread("sender", frozenset(), str(tmp_path), created_at=10.0)) - old.registry.declare(old.registry.new_thread("agent", frozenset(), str(tmp_path), created_at=12.0)) - live.registry.declare(live.registry.new_thread("agent", frozenset(), str(tmp_path), created_at=212.0)) - old.messaging.initialize_private_initial_protocol() - messages = (old.messaging.send_initial_cohort("sender", "agent", "historical notification"),) +async def test_historical_notification_keeps_selected_source_and_lifetime(workspace): + old, live = installed_wire(workspace.roots.attempt / "old"), workspace.comms + for name in ("sender", "agent"): + old.registry.declare(old.registry.new_thread(name, frozenset(), str(workspace.project))) + workspace.thread("agent") + message = old.messaging.send("sender", "agent", "HISTORICAL_NOTIFICATION").message source = live.views.attach_history(old.root) item = HistoricalThread(source, source.provenance.require("agent")) - published, errors, selected = [], [], [] - body = SimpleNamespace( - is_attached=True, handling_references=(messages[0].reference,), - show_notifications=published.append, show_notification_error=errors.append, - ) - owner = SimpleNamespace(comms=live, _selection_generation=1, is_attached=True, - query_one=lambda *_: object()) - monkeypatch.setattr(WireMessageHandling, "within", lambda *_: (body,)) - unwrapped = HistorySource.notification_references - - def read(captured, archive, references): - selected.append(captured.key) - if retirement == "detached": - live.bus.history.path.write_text("[]") - result = unwrapped(captured, archive, references) - if retirement == "selection": - owner._selection_generation += 1 - elif retirement == "body": - body.is_attached = False - elif retirement == "screen": - owner.is_attached = False - return result - monkeypatch.setattr(HistorySource, "notification_references", read) - asyncio.run(HistoricalSessions.publish_handling(owner, item, 1)) - assert selected == [source.key] - if retirement is None: - assert not errors and len(published) == 1 - notification, = published[0][messages[0].seq, messages[0].message_id] - assert notification.state == "Pending" and not notification.busy - elif retirement == "detached": - assert not published and len(errors) == 1 - assert "detached" in str(errors[0]) - else: - assert not published and not errors + async with workspace.app() as ui: + screen = HistoricalSessions(live, (item,)) + await ui.app.push_screen(screen) + await ui.until(lambda: "HISTORICAL_NOTIFICATION" in ui.painted(screen), + why="the selected archive's saved message") + content = screen.query_one(RecordedContents) + access = ui.app.coordination_access + await ui.until(lambda: content in access.notification_views, + why="the historical body's notification subscription") + request = access.notification_views[content] + assert isinstance(request, ReadRecordedNotifications) + assert request.source == source.key + assert request.references == (message.reference,) + await ui.until(lambda: request in access.notification_outcomes, + why="the archive's actual observation reply") + outcomes = access.notification_outcomes[request] + assert outcomes[message.seq, message.message_id] + + await screen.dismiss() + await ui.until(lambda: content not in access.notification_views, + why="the closed historical body's subscription to retire") + assert ui.failure() is None diff --git a/tests/textual/test_input_failure_draft.py b/tests/textual/test_input_failure_draft.py index 0a21475ce..68c923b44 100644 --- a/tests/textual/test_input_failure_draft.py +++ b/tests/textual/test_input_failure_draft.py @@ -64,7 +64,6 @@ async def test_a_successful_wire_send_keeps_a_different_unsent_draft(workspace): comms = workspace.comms workspace.thread("peer") - comms.messaging.initialize_private_initial_protocol() async with workspace.app(size=(110, 32)) as ui: await ui.open_direct("peer", actor=workspace.viewer) chat = ui.app.selected_session.query_one(CommsChatView) diff --git a/tests/textual/test_interrupted_send.py b/tests/textual/test_interrupted_send.py index 85c2eada3..f51949e44 100644 --- a/tests/textual/test_interrupted_send.py +++ b/tests/textual/test_interrupted_send.py @@ -3,30 +3,37 @@ import asyncio import threading from unittest.mock import patch +import pytest from agent_comms.comms import wire -from support.routes import private_root, publish_route, select_root_by_default_route -from support.textual_steps import refresh_chat + +@pytest.fixture +def conversation(workspace): + workspace.thread("peer", "team") + workspace.comms.messaging.send("peer", "#team", "INITIAL") + return workspace + + +def on_wire(root, body): + bus = wire(root).bus.bus + return [row for row in bus.since(0, bus.revision()) if row.body == body] def times_on_wire(root, body): - return [row.body for row in wire(root).bus.log.full_history()].count(body) + return len(on_wire(root, body)) -async def test_a_direct_message_interrupted_while_its_worker_survives_is_not_replayed(workspace, monkeypatch): +async def test_a_direct_message_interrupted_while_its_worker_survives_is_not_replayed(conversation): from toad.core import input_events from toad.widgets.comms_chat import CommsChatView - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - root, root_id = private_root(attempt / "wire-private", workspace.project, "INITIAL") - publish_route(home, root, root_id) + root = conversation.roots.wire - async with workspace.app(size=(105, 32)) as ui: - await ui.open_direct("peer", actor="user") + async with conversation.app(size=(105, 32)) as ui: + await ui.open_direct("peer", actor=conversation.viewer) view = ui.textual[0].screen.query_one(CommsChatView) - messaging = view.message_history.source.comms.messaging + messaging = view.message_history.page_source.loader.comms.messaging original_send = messaging.send_user_message started, release, settled = threading.Event(), threading.Event(), threading.Event() calls = 0 @@ -48,7 +55,7 @@ def delayed_send(*args, **kwargs): assert await asyncio.to_thread(started.wait, 8) task.cancel() await task - assert view._send_block_reason + assert view.send_block_reason assert view.prompt.text == event.body assert view.prompt.prompt_text_area.disabled await view.submit_input(event) @@ -62,20 +69,17 @@ def delayed_send(*args, **kwargs): async def test_a_committed_channel_send_keeps_its_identity_and_cannot_be_resent_until_it_is_drawn( - workspace, monkeypatch): + conversation): from toad.core import input_events from toad.widgets.comms_chat import CommsChatView from toad.wire_history import WireSource - attempt = workspace.roots.attempt - home = select_root_by_default_route(monkeypatch, attempt) - root, root_id = private_root(attempt / "wire-private", workspace.project, "INITIAL") - publish_route(home, root, root_id) + root = conversation.roots.wire - async with workspace.app(size=(105, 32)) as ui: - await ui.open_channel("#team", actor="user") + async with conversation.app(size=(105, 32)) as ui: + await ui.open_channel("#team", actor=conversation.viewer) channel = ui.textual[0].screen.query_one(CommsChatView) - await refresh_chat(channel) + await ui.until_shows("INITIAL") release = threading.Event() real_read = WireSource.read @@ -83,22 +87,74 @@ def held_read(source, request, through): assert release.wait(8), "the held read was not released" return real_read(source, request, through) - messaging = channel.message_history.source.comms.messaging + messaging = channel.message_history.page_source.loader.comms.messaging with (patch.object(WireSource, "read", held_read), patch.object(messaging, "send_user_message", wraps=messaging.send_user_message) as sender): + channel.prompt.text = "DIFFERENT_UNSENT_DRAFT" sent = input_events.UserInputSubmitted("COMMITTED-NOT-DRAWN") await channel.submit_input(sent) - rows = [row for row in wire(root).bus.log.full_history() if row.body == sent.body] + rows = on_wire(root, sent.body) assert len(rows) == 1 and sender.call_count == 1 - # The message is on the wire but not drawn: its text stays, unsendable, under its identity. - assert channel._send_block_reason - assert channel.prompt.text == sent.body + await ui.until(lambda: rows[0].message_id in channel.status, + why="the undrawn send's exact record in its visible status") + # The sent record owns the hold; a different draft remains untouched. + assert channel.send_block_reason + assert channel.prompt.text == "DIFFERENT_UNSENT_DRAFT" assert channel.prompt.prompt_text_area.disabled assert str(rows[0].seq) in channel.status assert rows[0].message_id in channel.status await channel.submit_input(sent) assert sender.call_count == 1 release.set() - await ui.until(lambda: not channel._send_block_reason, why="the sent message drawn") + await ui.until(lambda: not channel.send_block_reason, why="the sent message drawn") + assert ui.shows(sent.body) + assert channel.prompt.text == "DIFFERENT_UNSENT_DRAFT" and not channel.prompt.prompt_text_area.disabled + + +async def test_a_send_whose_completion_fails_after_commit_shows_the_committed_record_and_is_not_resent( + conversation): + from agent_comms.errors import CommittedMessageError + from toad.core import input_events + from toad.widgets.comms_chat import CommsChatView + from toad.wire_history import WireSource + + root = conversation.roots.wire + + async with conversation.app(size=(105, 32)) as ui: + await ui.open_channel("#team", actor=conversation.viewer) + channel = ui.textual[0].screen.query_one(CommsChatView) + await ui.until_shows("INITIAL") + release = threading.Event() + real_read = WireSource.read + messaging = channel.message_history.page_source.loader.comms.messaging + original_send = messaging.send_user_message + committed = [] + + def held_read(source, request, through): + assert release.wait(8), "the held read was not released" + return real_read(source, request, through) + + def commit_then_fail(*args, **kwargs): + committed.append(original_send(*args, **kwargs)) + raise CommittedMessageError(committed[-1]) + + with (patch.object(WireSource, "read", held_read), + patch.object(messaging, "send_user_message", side_effect=commit_then_fail) as sender): + channel.prompt.text = "DIFFERENT_UNSENT_DRAFT" + sent = input_events.UserInputSubmitted("COMMITTED-THEN-FAILED") + await channel.submit_input(sent) + [record] = committed + await ui.until(lambda: record.message_id in channel.status, + why="the committed record in its visible status after completion failed") + assert times_on_wire(root, sent.body) == 1 and sender.call_count == 1 + # The exact committed record owns the status; nothing claims the message was not sent. + assert channel.send_block_reason == f"Sent {root}/{record.seq}/{record.message_id}; waiting to be drawn, do not retry" + assert channel.prompt.text == "DIFFERENT_UNSENT_DRAFT" + assert channel.prompt.prompt_text_area.disabled + await channel.submit_input(sent) + assert sender.call_count == 1 + release.set() + await ui.until(lambda: not channel.send_block_reason, why="the committed message drawn") assert ui.shows(sent.body) - assert channel.prompt.text == "" and not channel.prompt.prompt_text_area.disabled + assert times_on_wire(root, sent.body) == 1 + assert channel.prompt.text == "DIFFERENT_UNSENT_DRAFT" diff --git a/tests/textual/test_late_live_answer.py b/tests/textual/test_late_live_answer.py index 74b2d4024..a5ae19fdf 100644 --- a/tests/textual/test_late_live_answer.py +++ b/tests/textual/test_late_live_answer.py @@ -3,20 +3,20 @@ import asyncio from dataclasses import replace -import json import os import pytest from agent_comms.acp_extension import ( - CoordinationChangedUpdate, TranscriptSnapshotUpdate, TurnChangedUpdate, encode_updates, + TranscriptSnapshotUpdate, TurnChangedUpdate, encode_updates, ) -from agent_comms.comms import Comms +from agent_comms.thread_text import SavedSessionFile from agent_comms.turn_lease import ActiveTurn, TurnState from agent_comms.turn_phase import PublishingPhase from toad.core.events import CommsUpdated, Update from toad.transcript_publication import CommitPublication -from support.textual_steps import saved_history +from support.agents import deliver +from support.journals import assistant_entry, write_journal TOKEN = "LATE_LIVE_ANSWER" @@ -24,25 +24,36 @@ @pytest.mark.parametrize("arrival", ["read-in-flight", "commit", "queued-snapshot"]) async def test_late_live_answer_is_drawn_once(workspace, arrival): project = workspace.project - comms = Comms(workspace.roots.wire) - comms.messaging.initialize_private_initial_protocol() + comms = workspace.comms thread = workspace.thread("source") workspace.thread("peer") comms.messaging.send("peer", "source", "WIRE_NOTICE") - first_page = comms.transcripts.thread_transcript_page("source") held, release, applied = asyncio.Event(), asyncio.Event(), asyncio.Event() read_release = asyncio.Event() pending = [] - async with workspace.app(size=(120, 35)) as ui: + async with workspace.app(size=(120, 35), resume="source") as ui: app, _pilot = ui.textual view = ui.conversation + await ui.until_shows("WIRE_NOTICE") + reader = view.transcript.binding.reader + read_snapshot = reader.snapshot copies_drawn = [] + duplicate_frame = [] draw = app._display def recording_draw(screen, renderable): draw(screen, renderable) if renderable is not None and screen is app.screen: - copies_drawn.append(ui.painted(view.window).count(TOKEN)) + copies = ui.painted(view.window).count(TOKEN) + copies_drawn.append(copies) + if copies > 1 and not duplicate_frame: + duplicate_frame.append(( + type(view.transcript.binding.history.state).__name__, + view.turns.owner.busy, + tuple((type(child).__name__, child.display, child.region, + ui.painted(child).count(TOKEN)) + for child in view.contents.children), + )) app._display = recording_draw try: @@ -61,44 +72,33 @@ async def hold_the_live_answer(message): view._dispatch_message = hold_the_live_answer agent = ui.detached_agent("late-live") - view.set_reactive(type(view).agent, agent) view.bind_agent(agent) - agent.coordination = CoordinationChangedUpdate( - thread.incarnation, str(workspace.roots.wire), os.getpid(), str(project), None, None, thread.name, None) turns = agent.presentation.managed_turns() turns.receive(TurnState(ActiveTurn("first-turn", os.getpid(), phase=PublishingPhase()))) - agent.session.settled.clear() - history = await saved_history(view, first_page, view.transcript.get_transcript_page) - await view.contents.mount(history) await ui.step() - agent.session.settled.set() view.window.anchor() view.prompt.focus() if arrival == "read-in-flight": read_entered = asyncio.Event() - read_page = view.transcript.get_transcript_page - async def held_read(*arguments, **options): read_entered.set() await read_release.wait() - return await read_page(*arguments, **options) + return await read_snapshot(*arguments, **options) - view.transcript.get_transcript_page = held_read + reader.snapshot = held_read view.transcript.dirty = view.transcript.commit_required = True pending.append(asyncio.create_task(view.transcript.publish(CommitPublication))) await asyncio.wait_for(read_entered.wait(), 5) - answer = asyncio.create_task(agent.updates.receive("late-live", { + answer = asyncio.create_task(deliver(agent, { "sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": TOKEN}})) pending.append(answer) await asyncio.wait_for(held.wait(), 5) # The session file and the end of the turn arrive while the live answer is still unapplied. - saved = project / "first-native.jsonl" - saved.write_text(json.dumps({"type": "message", "id": "first-assistant", "message": { - "role": "assistant", "content": [{"type": "text", "text": TOKEN}]}}) + "\n") - comms.registry.declare(replace(thread, session_file=str(saved))) - pending.append(asyncio.create_task(agent.updates.receive("late-live", { + saved = write_journal(project / "first-native.jsonl", assistant_entry(TOKEN, id="first-assistant")) + comms.registry.declare(replace(thread, session_file=SavedSessionFile(str(saved)))) + pending.append(asyncio.create_task(deliver(agent, { "sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": ""}, "_meta": encode_updates(TurnChangedUpdate(TurnState(finished_turn_id="first-turn")))}))) await asyncio.sleep(.05) @@ -116,8 +116,7 @@ async def held_read(*arguments, **options): async def saved_history_shown(): async with asyncio.timeout(8): - while not any(item.page_source.through == page.after - for item in view.window.histories): + while view.transcript.binding.history.page_source.through != page.after: await asyncio.sleep(.02) pending.append(asyncio.create_task(saved_history_shown())) @@ -129,12 +128,63 @@ async def saved_history_shown(): why="exactly one drawn copy of the answer") await ui.step(.5) assert ui.painted(view.window).count(TOKEN) == 1 - assert max(copies_drawn, default=0) <= 1, f"a frame drew the answer {max(copies_drawn)} times" + assert max(copies_drawn, default=0) <= 1, f"a frame drew the answer {max(copies_drawn)} times: {duplicate_frame!r}" assert agent.process.runner is None finally: release.set() read_release.set() + reader.snapshot = read_snapshot for task in pending: if not task.done(): task.cancel() await asyncio.gather(*pending, return_exceptions=True) + + +async def test_an_advancing_commit_retires_the_outgoing_body_and_its_tool_report(workspace): + from acp.schema import ToolCall as AcpToolCall + from agent_comms.sent_message import PublishedSentMessage + from agent_comms.transcript_events import SentTranscript + from toad.acp.status import ToolCallStatus + from toad.tool_output import ToolOutput + from toad.widgets.outgoing_message import OutgoingMessage + from toad.widgets.tool_call import ToolCall + + source = workspace.thread("source") + workspace.thread("peer") + journal = write_journal(workspace.project / "source.jsonl", + assistant_entry("SAVED_NATIVE_PREFIX", id="prefix")) + workspace.comms.registry.declare(replace(source, session_file=SavedSessionFile(str(journal)))) + async with workspace.app(size=(120, 35), resume="source") as ui: + view = ui.conversation + await ui.until_shows("SAVED_NATIVE_PREFIX") + await view.transcript.suspend() + history = view.transcript.binding.history + previous = history.outgoing_coverage + record = workspace.comms.messaging.send("source", "peer", "ADVANCING_OUTGOING_BODY").message + snapshot = TranscriptSnapshotUpdate.capture(workspace.comms.transcripts, "source") + assert history.accepts_commit(snapshot.page.after), "This case must extend the existing source" + report = SentTranscript.from_message(record) + publication, = snapshot.identity.wire_frontier.resolve_sent((report,)) + assert isinstance(publication, PublishedSentMessage) + status = ToolCallStatus.from_acp(AcpToolCall.model_validate({ + "toolCallId": "published-send", "title": "Send", "status": "completed", + "content": [report.tool_content("published-send")], + })) + parts = ToolOutput.capture_projection(status, publication.scope) + part, = parts + tool = await view.post(ToolCall(status, output_parts=parts)) + tool.set_expanded(False) + outgoing = await view.post(OutgoingMessage(part.message, row=part.row, binding=None, request=None)) + await ui.until_shows("ADVANCING_OUTGOING_BODY") + assert publication.reference in tool.commit_claim.outgoing + assert publication.reference not in previous + view.window.anchor() + view.prompt.focus() + view.transcript.dirty = view.transcript.commit_required = True + await view.transcript.publish(CommitPublication) + await ui.until(lambda: not outgoing.is_attached and not tool.is_attached, + why="the saved outgoing body and its tool report handed off together") + await ui.until(lambda: ui.painted(view.window).count("ADVANCING_OUTGOING_BODY") == 1, + why="one saved outgoing body after the live report retires") + assert view.transcript.binding.history is history + assert previous | {publication.reference} <= history.outgoing_coverage diff --git a/tests/textual/test_message_filter_restyle.py b/tests/textual/test_message_filter_restyle.py index 9c9cb9564..9595cb983 100644 --- a/tests/textual/test_message_filter_restyle.py +++ b/tests/textual/test_message_filter_restyle.py @@ -1,10 +1,15 @@ """Textual-facing: changing which message categories show restyles only the affected blocks, never the whole conversation.""" from unittest.mock import patch +from dataclasses import replace from toad.widgets.agent_response import AgentResponse from toad.widgets.agent_thought import AgentThought -from agent_comms.ui_model.message_category import ThinkingCategory, all_categories +from agent_comms.thread_text import SavedSessionFile +from agent_comms.ui_model.message_category import ThinkingCategory, UserCategory, all_categories + +from support.journals import assistant_entry, user_entry, write_journal +from toad.widgets.transcript_history import ProjectedTranscriptHistory async def test_category_change_does_not_restyle_the_whole_conversation(workspace): @@ -23,3 +28,34 @@ async def test_category_change_does_not_restyle_the_whole_conversation(workspace await ui.step() whole_view = sum(call.args == (view,) for call in styles.call_args_list) assert whole_view == 0, "Category change restyled unrelated descendants" + + +async def test_filtering_saved_history_paints_an_older_projection_and_returns_to_the_tail(workspace): + journal = write_journal(workspace.project / "filtered.jsonl", + user_entry("FILTER_USER", id="user"), *( + assistant_entry(f"SAVED_ASSISTANT_{index}", id=f"a{index}", + parent=f"a{index - 1}" if index else "user") + for index in range(300))) + workspace.comms.registry.declare(replace( + workspace.thread("source"), session_file=SavedSessionFile(str(journal)))) + async with workspace.app(size=(120, 35), resume="source") as ui: + view = ui.conversation + await ui.until(lambda: "SAVED_ASSISTANT_299" in ui.painted(view.window), + why="the saved source's tail") + history = view.transcript.binding.history + view.visible_categories = frozenset({UserCategory}) + view.window.release_anchor() + view.window.scroll_home(animate=False, immediate=True) + await ui.until(lambda: isinstance(history.filter.overlay, ProjectedTranscriptHistory) + and "FILTER_USER" in ui.painted(view.window), + why="the older filtered projection") + projection = history.filter.overlay + assert projection.conversation is view + assert "SAVED_ASSISTANT_299" not in ui.painted(view.window) + + view.visible_categories = all_categories() + view.window.jump_to_latest() + await ui.until(lambda: projection not in history.children + and "SAVED_ASSISTANT_299" in ui.painted(view.window), + why="the original tail after removing the filter") + assert ui.failure() is None diff --git a/tests/textual/test_observation_shutdown.py b/tests/textual/test_observation_shutdown.py index fae1be444..ba4bd29b6 100644 --- a/tests/textual/test_observation_shutdown.py +++ b/tests/textual/test_observation_shutdown.py @@ -2,11 +2,73 @@ import asyncio import sys +import threading +import pytest -from agent_comms.ui_model.observation import ObservationProcess, ObserveNotifications, ReadNotifications +from agent_comms.ui_model.observation import ObservationProcess, ObserverDied, ObserveNotifications, ReadNotifications from toad.comms_root import AnsweringReads +async def test_an_observer_dying_during_shutdown_still_releases_its_resources(workspace): + cleaned = threading.Event() + with pytest.raises(BaseExceptionGroup) as failures: + async with workspace.app() as ui: + access = ui.app.coordination_access + await access.current_sidebar() + observed = access.observation.observed() + process = observed.process + access.cleanup.callback(cleaned.set) + # The death has not been delivered to receive(); stop_updates must + # encounter it at its actual StopObserving request during shutdown. + asyncio.get_running_loop().remove_reader(process.fileno()) + process.process.kill() + await asyncio.to_thread(process.process.join, 5) + assert process.process.exitcode is not None + assert not process.connection.closed + assert failures.value.subgroup(ObserverDied) is not None + assert process.connection.closed + assert not process.process.is_alive() + assert not access.retiring + assert cleaned.is_set() + + +async def test_shutdown_drains_an_acquired_service_read_after_its_waiter_is_cancelled(workspace, monkeypatch): + entered, release = threading.Event(), threading.Event() + acquired = [] + async with workspace.app() as ui: + access = ui.app.coordination_access + observing = access.observation.observed() + require = access.require + + def held_require(selected): + entered.set() + release.wait() + observed = require(selected) + acquired.append(observed) + return observed + + monkeypatch.setattr(access, "require", held_require) + reading = asyncio.create_task(access.transcripts.service(str(workspace.roots.wire))) + await asyncio.to_thread(entered.wait) + reading.cancel() + await asyncio.gather(reading, return_exceptions=True) + close_preparation = ui.app.preparation.aclose + + async def finish_accepted_read(): + release.set() + await close_preparation() + + monkeypatch.setattr(ui.app.preparation, "aclose", finish_accepted_read) + try: + observed, = acquired + assert observed.process is observing.process, "Shutdown reopened the observer after closing its original owner" + assert observed.service is observing.service + assert not observing.process.process.is_alive() + finally: + release.set() + await access.close() + + async def test_stopping_observation_keeps_reads_but_sends_no_late_interest(workspace): workspace.thread("sender", title="Sender") workspace.thread("reader", title="Reader") diff --git a/tests/textual/test_other_route_status.py b/tests/textual/test_other_route_status.py new file mode 100644 index 000000000..d4e79ac8c --- /dev/null +++ b/tests/textual/test_other_route_status.py @@ -0,0 +1,43 @@ +"""A mounted status row explains another route without reading its stores.""" + +from agent_comms.ui_model.status import ThreadStatusModel +from textual.app import ComposeResult +from textual.containers import Vertical + +from toad.status_thread import StatusThread +from toad.widgets.observed_thread_activity import ( + ObservedThreadActivity, + ThreadStatusOwner, +) + + +class OtherRouteStatusSurface(ThreadStatusOwner, Vertical): + def __init__(self, source: StatusThread): + super().__init__() + self.source = source + self.thread_activity = ObservedThreadActivity() + + def compose(self) -> ComposeResult: + yield self.thread_activity + + def on_mount(self) -> None: + self.thread_activity.bind(self.source) + + def status_thread(self) -> StatusThread: + return self.source + + +async def test_other_route_binds_its_status_without_starting_a_read(workspace): + other_root = workspace.roots.attempt / "not-selected" + async with workspace.app() as ui: + app, _ = ui.textual + source = StatusThread.for_root(other_root, "peer", app.coordination_access) + surface = OtherRouteStatusSurface(source) + await app.selected_session.mount(surface) + source.refresh(surface) + assert surface.thread_activity.row == ThreadStatusModel.other_route("peer") + assert not any( + surface in worker.node.walk_ancestors(with_self=True) + for worker in app.workers + ) + assert not other_root.exists() diff --git a/tests/textual/test_prompt_completion.py b/tests/textual/test_prompt_completion.py index 7d5607d92..868d48c3b 100644 --- a/tests/textual/test_prompt_completion.py +++ b/tests/textual/test_prompt_completion.py @@ -3,7 +3,6 @@ import json -from agent_comms.mentions import MentionCandidate from textual.widgets.text_area import Selection from support.agents import peer @@ -27,7 +26,7 @@ def wire_records(workspace): async def ready_prompt(workspace, ui): await ui.until_agent_ready() view = ui.conversation - return view, view.prompt, view.agent + return view, view.prompt, view.agent_binding async def test_slash_command_discovered_from_the_agent_is_completed_by_keyboard_and_executed(workspace): @@ -104,12 +103,11 @@ async def test_a_whole_command_is_selected_backwards_and_the_agents_hint_refresh async def test_channel_composer_completes_a_mention_with_tab_and_keeps_focus(workspace): - async with workspace.app(size=(130, 44), agent=completion_peer()) as ui: - app, _pilot = ui.textual - view, prompt, agent = await ready_prompt(workspace, ui) - channel = ChannelPrompt(simple_input=True) - await app.screen.mount(channel) - channel.set_mention_candidates((MentionCandidate("cursor-peer", "Cursor peer"),)) + workspace.thread("cursor-peer", "comms") + workspace.thread("reader", "comms") + async with workspace.app(size=(130, 44)) as ui: + await ui.open_channel("#comms", actor="reader") + channel = ui.app.selected_session.query_one(ChannelPrompt) channel.focus() await ui.press("/", "c", "f7", "@", "c", "u") await ui.until(lambda: channel.mention_list.display, why="the mention list") @@ -199,7 +197,7 @@ async def test_model_picker_selects_through_the_agent_and_keeps_the_draft(worksp await ui.press(*"second") await ui.until(lambda: painted_text(picker, "Local second"), why="the model in the picker") await ui.press("enter") - await ui.until(lambda: view.agent.configuration.model.current == "local/second", why="the selected model") + await ui.until(lambda: view.agent_binding.configuration.model.current == "local/second", why="the selected model") assert {"config_id": "model", "value": "local/second"} in wire_records(workspace) await ui.until(lambda: not picker.is_open and prompt.prompt_text_area.has_focus, why="the picker closed") assert prompt.text == "Unfinished draft" diff --git a/tests/textual/test_stale_route_writes.py b/tests/textual/test_stale_route_writes.py index cf7a1c717..99c1a74a8 100644 --- a/tests/textual/test_stale_route_writes.py +++ b/tests/textual/test_stale_route_writes.py @@ -1,92 +1,97 @@ -"""Textual-facing: mounted view writes (read marks, thread actions) started before a route publication are refused after it.""" +"""A mounted sender refuses a stale destination before entering the write worker.""" import asyncio -import threading - -import pytest from unittest.mock import patch -import agent_comms.private_nk_entrypoint as private_nk_entrypoint -from agent_comms.active_route import ActiveRoute, publish_active_route -from agent_comms.cli_commands import StartCliCommand -from agent_comms.comms import Comms -from agent_comms.route_selection import current_root, run_selected_write -from agent_comms.thread_identity import ThreadRole +from agent_comms import route_selection +from agent_comms.errors import RouteChangedError +from agent_comms.launch_root import LaunchRoot -from support.routes import private_root, select_root_by_default_route -from support.textual_steps import refresh_chat, request_target_command +from support.roots import installed_wire +from toad.core.input_events import UserInputSubmitted +from toad.widgets.comms_chat import CommsChatView -async def test_delayed_view_writes_wait_out_a_route_publication_and_are_then_refused(workspace, monkeypatch, native_package): - from toad import comms_root - from toad.widgets.comms_chat import CommsChatView +async def test_a_view_that_changed_during_send_preflight_keeps_the_draft_editable(workspace): + workspace.thread("peer", "team") + workspace.comms.messaging.send("peer", "#team", "CURRENT_HISTORY") + async with workspace.app() as ui: + await ui.open_channel("#team", actor=workspace.viewer) + await ui.until_shows("CURRENT_HISTORY") + view = ui.app.selected_session.query_one(CommsChatView) + messaging = view.message_history.page_source.loader.comms.messaging + before = messaging.bus.bus.revision() + await view.submit_comms_message(workspace.roots.wire, view.conversation, "STALE_DRAFT", + lambda: False, view._show_sent) + await ui.until(lambda: not view.prompt.prompt_text_area.disabled, + why="the refused draft editable again") + assert not view.send_block_reason + assert view.prompt.text == "STALE_DRAFT" + assert not view.prompt.prompt_text_area.disabled + assert messaging.bus.bus.revision() == before - attempt = workspace.roots.attempt - project = workspace.project - home = select_root_by_default_route(monkeypatch, attempt) - legacy = home / ".agent-comms" - legacy.mkdir(mode=0o700) - old = Comms(legacy) - old.registry.declare(old.registry.new_thread("user", frozenset(), str(project), role=ThreadRole.USER)) - old.registry.declare(old.registry.new_thread("peer", frozenset({"team"}), str(project / "peer"))) - old.messaging.send("peer", "#team", "LEGACY-PAINT") - private, private_id = private_root(attempt / "private", project, "PRIVATE-PAINT") - route = ActiveRoute(private, private_id, native_package) - assert current_root() == legacy - async with workspace.app(size=(115, 38)) as ui: - app = ui.textual[0] - await ui.open_channel("#team", actor="user") - view = app.screen.query_one(CommsChatView) - await refresh_chat(view) - await ui.step() - assert view.message_history.source.comms.root == legacy - old.messaging.send("peer", "#team", "UNREAD-OLD") - page = old.views.channel_display_page("#team", worktree=str(project), limit=8) - assert page.newest_seq == 2 - marker = legacy / "read_markers.json" - before = marker.read_bytes() if marker.exists() else b"" +async def test_a_route_change_during_service_acquisition_restores_the_draft(workspace): + workspace.thread("peer", "team") + second = installed_wire(workspace.roots.attempt / "second") + async with workspace.app() as ui: + await ui.open_channel("#team", actor=workspace.viewer) + view = ui.app.selected_session.query_one(CommsChatView) + first = view.message_history.page_source.loader.comms + before = first.bus.bus.revision(), second.bus.bus.revision() + with LaunchRoot.from_path(second.root).selected(): + await view.submit_input(UserInputSubmitted("SERVICE_ROUTE_REFUSED")) + await ui.until(lambda: view.prompt.text == "SERVICE_ROUTE_REFUSED" + and not view.prompt.prompt_text_area.disabled, + why="the service-refused draft to be editable") + assert not view.send_block_reason + assert (first.bus.bus.revision(), second.bus.bus.revision()) == before + assert ui.failure() is None - entered_ack, entered_action = asyncio.Event(), threading.Event() - release = threading.Event() - original_to_thread = asyncio.to_thread - real_write = comms_root.run_selected_write - invoked = [] - async def delayed_dispatch(operation, *args, **kwargs): - if operation is run_selected_write and getattr(args[1], "__name__", "") == "mark_channel_view_read": - entered_ack.set() - await original_to_thread(release.wait, 20) - return await original_to_thread(operation, *args, **kwargs) +async def test_a_route_change_at_the_actual_write_boundary_restores_the_draft(workspace): + workspace.thread("peer", "team") + second = installed_wire(workspace.roots.attempt / "second") + async with workspace.app() as ui: + await ui.open_channel("#team", actor=workspace.viewer) + view = ui.app.selected_session.query_one(CommsChatView) + first = view.message_history.page_source.loader.comms + before = first.bus.bus.revision(), second.bus.bus.revision() + entered, release = asyncio.Event(), asyncio.Event() + refused = [] + selected_write = route_selection.run_selected_write + dispatch = asyncio.to_thread - def delayed_write(root, operation, *args, **kwargs): - entered_action.set() - release.wait(20) - return real_write(root, operation, *args, **kwargs) + def in_changed_route(operation, *args, **kwargs): + with LaunchRoot.from_path(second.root).selected(): + try: + return operation(*args, **kwargs) + except RouteChangedError as error: + refused.append(error) + raise - def safe_spy(command, comms): - invoked.append(command.name) - return {"thread": command.name, "pid": 0, "launched": False} + async def dispatch_after_route_change(operation, *args, **kwargs): + if operation is selected_write and args[1] == first.messaging.send_user_message: + entered.set() + await release.wait() + return await dispatch(in_changed_route, operation, *args, **kwargs) + return await dispatch(operation, *args, **kwargs) - with (patch("asyncio.to_thread", delayed_dispatch), - patch.object(comms_root, "run_selected_write", delayed_write), - patch.object(StartCliCommand, "apply", safe_spy), - patch.object(private_nk_entrypoint, "_trusted_package", lambda _: None)): - history = view.message_history - ack = asyncio.create_task(history.mark_page(page, None, snapshot=history.source_snapshot())) - request_target_command(app, StartCliCommand, "peer") - async with asyncio.timeout(8): - while not (entered_ack.is_set() and entered_action.is_set()): - await original_to_thread(entered_action.wait, 0.05) - await asyncio.sleep(0) - # The delayed view writes have not taken the route's lock yet, so publication passes them. - async with asyncio.timeout(5): - await asyncio.get_running_loop().run_in_executor(None, publish_active_route, route) - assert current_root() == private - release.set() - await ack - await ui.step() - assert not invoked, "stale old-root start reached its core operation" - assert (marker.read_bytes() if marker.exists() else b"") == before - with pytest.raises(ValueError): - run_selected_write(legacy, safe_spy, None, None, implicit=True) + with patch.object(asyncio, "to_thread", dispatch_after_route_change): + sending = asyncio.create_task(view.submit_input(UserInputSubmitted("WORKER_ROUTE_REFUSED"))) + try: + await asyncio.wait_for(entered.wait(), 8) + await ui.until(lambda: view.prompt.prompt_text_area.disabled, + why="the in-flight send to hold compose") + release.set() + await sending + await ui.until(lambda: view.prompt.text == "WORKER_ROUTE_REFUSED" + and not view.prompt.prompt_text_area.disabled, + why="the worker-refused draft to be editable") + assert len(refused) == 1 + finally: + release.set() + await asyncio.gather(sending, return_exceptions=True) + assert not view.send_block_reason + assert (first.bus.bus.revision(), second.bus.bus.revision()) == before + assert ui.failure() is None diff --git a/tests/textual/test_transcript_presentation_fencing.py b/tests/textual/test_transcript_presentation_fencing.py index 9806dbebd..219211a96 100644 --- a/tests/textual/test_transcript_presentation_fencing.py +++ b/tests/textual/test_transcript_presentation_fencing.py @@ -1,49 +1,46 @@ """Textual-facing: transcript presentations queue behind Textual's callback pump and paint into the conversation window.""" import asyncio +from contextlib import asynccontextmanager -from support.coordination import registered_coordination_fact from support.journals import assistant_entry, write_journal -from toad.transcript_publication import CanonicalSourcePublication, TranscriptPublication +from agent_comms.thread_text import SavedSessionFile +from toad.transcript_publication import CommitPublication, TranscriptPublication from toad.widgets.agent_response import AgentResponse class DeclaredCase(TranscriptPublication): """A newly declared case: it paints one response, unless it is no longer current.""" - def __init__(self, owner, view, window, contents, entered, release): - super().__init__(owner, view, window, contents) + def __init__(self, binding, owner, generation, entered, release): + super().__init__(binding, owner, generation) self.entered, self.release = entered, release async def publish_applied(self): self.entered.set() await self.release.wait() if self.current(): - await self.contents.mount(AgentResponse("Declared case painted")) + await self.binding.contents.mount(AgentResponse("Declared case painted")) return True -class HeldSourceCase(CanonicalSourcePublication): +class HeldSourceCase(CommitPublication): """A real certified read, held before its application is joined.""" - def __init__(self, owner, view, window, contents, entered, release): - super().__init__(owner, view, window, contents) + def __init__(self, binding, owner, generation, entered, release): + super().__init__(binding, owner, generation) self.entered, self.release = entered, release - async def read_page(self): - page = await super().read_page() + async def read_snapshot(self): + page = await super().read_snapshot() self.entered.set() await self.release.wait() return page -async def settle_ready(ui): - await ui.app.screen.prepare_navigation() - await ui.app.screen.layout_navigation() - - -async def hold_application(ui, view): - """Occupy the view's callback pump until the returned event is set.""" +@asynccontextmanager +async def hold_application(view): + """Release the occupied callback pump even when its caller's assertion fails.""" started, release = asyncio.Event(), asyncio.Event() async def held(): @@ -52,8 +49,11 @@ async def held(): view.call_later(held) # The pump is busy once the callback runs, so the pilot cannot step: wait on the event itself. - await asyncio.wait_for(started.wait(), 10) - return release + try: + await asyncio.wait_for(started.wait(), 10) + yield + finally: + release.set() async def stays_waiting(task) -> bool: @@ -69,44 +69,39 @@ async def finish(ui, task, why): async def test_a_case_queued_behind_the_pump_does_not_adopt_a_replacement_generation(workspace): async with workspace.app() as ui: - await settle_ready(ui) view = ui.conversation - release_pump = await hold_application(ui, view) - entered, release = asyncio.Event(), asyncio.Event() - release.set() - case = view.transcript.capture(DeclaredCase, entered, release) - pending = asyncio.create_task(case.publish()) - assert await stays_waiting(pending) and not entered.is_set() - view.transcript.invalidate() - release_pump.set() + async with hold_application(view): + entered, release = asyncio.Event(), asyncio.Event() + release.set() + case = view.transcript.binding.capture(view.transcript, DeclaredCase, entered, release) + pending = asyncio.create_task(case.publish()) + assert await stays_waiting(pending) and not entered.is_set() + view.transcript.invalidate_publications() await finish(ui, pending, "the queued case to return") assert not entered.is_set() async def test_cancelling_a_waiting_caller_leaves_the_pump_and_runs_nothing(workspace): async with workspace.app() as ui: - await settle_ready(ui) view = ui.conversation - release_pump = await hold_application(ui, view) - entered, release = asyncio.Event(), asyncio.Event() - pending = asyncio.create_task(view.transcript.publish(DeclaredCase, entered, release)) - assert await stays_waiting(pending) - pending.cancel() - result = await asyncio.gather(pending, return_exceptions=True) - assert isinstance(result[0], asyncio.CancelledError) - assert not entered.is_set() and not view.task.done() - release_pump.set() + async with hold_application(view): + entered, release = asyncio.Event(), asyncio.Event() + pending = asyncio.create_task(view.transcript.publish(DeclaredCase, entered, release)) + assert await stays_waiting(pending) + pending.cancel() + result = await asyncio.gather(pending, return_exceptions=True) + assert isinstance(result[0], asyncio.CancelledError) + assert not entered.is_set() and not view.task.done() await ui.step() async def test_a_case_invalidated_while_applying_paints_nothing_and_the_next_one_paints(workspace): async with workspace.app() as ui: - await settle_ready(ui) view = ui.conversation entered, release = asyncio.Event(), asyncio.Event() pending = asyncio.create_task(view.transcript.publish(DeclaredCase, entered, release)) await ui.until(entered.is_set, why="the case to start applying") - view.transcript.invalidate() + view.transcript.invalidate_publications() release.set() await finish(ui, pending, "the invalidated case to return") assert not view.contents.query(AgentResponse) @@ -116,7 +111,6 @@ async def test_a_case_invalidated_while_applying_paints_nothing_and_the_next_one async def test_a_pending_request_does_not_paint_a_replacement_source(workspace): async with workspace.app() as ui: - await settle_ready(ui) view = ui.conversation requests = view.transcript.source_requests entered, release = asyncio.Event(), asyncio.Event() @@ -125,7 +119,7 @@ async def test_a_pending_request_does_not_paint_a_replacement_source(workspace): queued_entered, queued_release = asyncio.Event(), asyncio.Event() queued_release.set() requests.request(DeclaredCase, queued_entered, queued_release) - view.transcript.invalidate() + view.transcript.invalidate_publications() release.set() await requests.worker.wait() await ui.step() @@ -138,28 +132,27 @@ async def test_a_pending_request_does_not_paint_a_replacement_source(workspace): async def test_a_certified_read_that_expired_acquires_no_application(workspace): journal = write_journal(workspace.project / "source.jsonl", assistant_entry("Certified source", id="source")) - workspace.comms.registry.declare(workspace.comms.registry.new_thread("journal-source", frozenset(), str(workspace.project), session_file=str(journal))) - async with workspace.app() as ui: - await settle_ready(ui) + workspace.comms.registry.declare(workspace.comms.registry.new_thread("journal-source", frozenset(), str(workspace.project), session_file=SavedSessionFile(str(journal)))) + async with workspace.app(resume="journal-source") as ui: view = ui.conversation - agent = ui.detached_agent("journal-source") - agent.coordination = registered_coordination_fact(workspace.comms, "journal-source", worktree=str(workspace.project)) - view.agent = agent + await ui.until_shows("Certified source") + await view.transcript.suspend() + view.transcript.dirty = view.transcript.commit_required = True + original = view.transcript.binding.history entered, release = asyncio.Event(), asyncio.Event() pending = asyncio.create_task(view.transcript.publish(HeldSourceCase, entered, release)) await ui.until(entered.is_set, why="the certified read to be held") - view.transcript.invalidate() + view.transcript.invalidate_publications() release.set() await finish(ui, pending, "the expired read to return") - assert not view.transcript.histories, "Expired read acquired a replacement application" + assert view.transcript.binding.history is original, "Expired read replaced the mounted history" await view.transcript.publish(HeldSourceCase, entered, release) - await ui.until(lambda: len(view.transcript.histories) == 1, why="the current read to acquire its application") - assert view.transcript.histories[0].page_source.through.session_file == str(journal) + assert view.transcript.binding.history is original + assert view.transcript.binding.history.page_source.through.session_file == str(journal) async def test_closing_the_transcript_mid_application_paints_nothing(workspace): async with workspace.app() as ui: - await settle_ready(ui) view = ui.conversation entered, release = asyncio.Event(), asyncio.Event() pending = asyncio.create_task(view.transcript.publish(DeclaredCase, entered, release)) @@ -168,4 +161,62 @@ async def test_closing_the_transcript_mid_application_paints_nothing(workspace): release.set() await finish(ui, pending, "the case to return after close") assert not view.contents.query(AgentResponse) - assert view.transcript.view is None + assert not view.transcript.binding.matches(view) + + +async def test_cancelling_a_staged_replacement_keeps_old_rows_and_restores_the_read_bound(workspace, monkeypatch): + from dataclasses import replace + from toad.widgets.transcript_lines import TranscriptLinesBatchRenderTask + + old = write_journal(workspace.project / "old.jsonl", assistant_entry("OLD_VISIBLE_SOURCE", id="old")) + thread = workspace.thread("source") + workspace.comms.registry.declare(replace(thread, session_file=SavedSessionFile(str(old)))) + async with workspace.app(resume="source") as ui: + await ui.until_shows("OLD_VISIBLE_SOURCE") + view = ui.conversation + await ui.app.coordination_access.stop_observing() + await view.transcript.suspend() + history = view.transcript.binding.history + pages, through = tuple(history.pages), history.page_source.through + new = write_journal(workspace.project / "new.jsonl", assistant_entry("NEW_VISIBLE_SOURCE", id="new")) + workspace.comms.registry.declare(replace(thread, session_file=SavedSessionFile(str(new)))) + entered, release = asyncio.Event(), asyncio.Event() + render_for = TranscriptLinesBatchRenderTask.render_for + render_owners = [] + + async def held_rows(cls, target, preparations): + if target is history and not release.is_set(): + render_owners.append(asyncio.current_task()) + entered.set() + await release.wait() + return await render_for(target, preparations) + + monkeypatch.setattr(TranscriptLinesBatchRenderTask, "render_for", classmethod(held_rows)) + view.window.anchor() + view.prompt.focus() + view.transcript.dirty = view.transcript.commit_required = True + pending = asyncio.create_task(view.transcript.publish(CommitPublication)) + try: + await asyncio.wait_for(entered.wait(), 8) + await ui.step() + assert "OLD_VISIBLE_SOURCE" in ui.painted(view.window) + assert "NEW_VISIBLE_SOURCE" not in ui.painted(view.window) + assert tuple(history.pages) == pages + applying, = render_owners + assert applying.cancel() + await asyncio.gather(applying, return_exceptions=True) + assert history.page_source.through == through + assert tuple(history.pages) == pages and all(page.display for page in pages) + release.set() + await view.transcript.publish(CommitPublication) + await ui.until_shows("NEW_VISIBLE_SOURCE") + assert "OLD_VISIBLE_SOURCE" not in ui.painted(view.window) + finally: + release.set() + for applying in render_owners: + if not applying.done(): + applying.cancel() + await asyncio.gather(*render_owners, return_exceptions=True) + if not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) diff --git a/tests/textual/test_wire_source.py b/tests/textual/test_wire_source.py index 098156083..06b0c6b8f 100644 --- a/tests/textual/test_wire_source.py +++ b/tests/textual/test_wire_source.py @@ -1,8 +1,7 @@ """Textual-facing: the presentation window; a wire source pages one conversation and notices when Core replaces its reading.""" -from agent_comms.candidate_maintenance import drain_candidate_catchup from agent_comms.channel_targets import ChannelTarget -from agent_comms.comms import wire +from support.roots import installed_wire from agent_comms.historical_views import ConversationObservation from agent_comms.page_traversal import LiveCursor @@ -31,7 +30,7 @@ def test_a_turn_claim_on_the_peer_keeps_the_direct_message_page_identity(tmp_pat from agent_comms.child_process import ProcessIdentity - comms = wire(tmp_path / "wire") + comms = installed_wire(tmp_path / "wire") comms.registry.declare(comms.registry.new_thread("peer", frozenset(), str(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) viewer = comms.messaging.user_identity(str(tmp_path)).name comms.messaging.send("peer", viewer, "shown across turn claim") @@ -42,7 +41,7 @@ def test_a_turn_claim_on_the_peer_keeps_the_direct_message_page_identity(tmp_pat def test_pages_lie_between_cuts_and_a_read_never_passes_its_bound(tmp_path): - comms = wire(tmp_path / "wire") + comms = installed_wire(tmp_path / "wire") comms.registry.declare(comms.registry.new_thread("sender", frozenset({"one", "two"}), str(tmp_path))) for index in range(60): comms.messaging.send("sender", "#one", f"Message {index}") @@ -75,13 +74,12 @@ def bodies(prepared): later = AfterSequence(LiveCursor(grown.through.cursor.sequence + 1)) bounded = source.read(PageRequest(NEWER_END, grown.through), later) assert bodies(bounded) == ["arrived later"] and bounded.page.after == later and not bounded.page.has_newer - drain_candidate_catchup(comms.root) current = source.observe(grown.revision) assert isinstance(source.observe(current.revision), Unchanged) def test_any_mode_expansion_replaces_the_reading_without_new_bus_rows(tmp_path): - comms = wire(tmp_path / "wire") + comms = installed_wire(tmp_path / "wire") comms.registry.declare(comms.registry.new_thread("alice", frozenset({"team"}), str(tmp_path))) comms.registry.declare(comms.registry.new_thread("carol", frozenset({"team"}), str(tmp_path))) comms.registry.declare(comms.registry.new_thread("dave", frozenset(), str(tmp_path))) @@ -91,7 +89,7 @@ def test_any_mode_expansion_replaces_the_reading_without_new_bus_rows(tmp_path): first, newest = observed(source) assert [fragment.message.body for fragment in newest.fragments] == ["channel message"] comms.channels.set_channel_any_mode("#team", True) - assert comms.bus.log.latest_sequence() == first.through.cursor.sequence + assert comms.bus.bus.revision() == first.through.cursor.sequence expanded = source.observe(first.revision) assert expanded.replaced and expanded.through == first.through again = source.read(PageRequest(OLDER_END, expanded.through), expanded.through) @@ -99,8 +97,8 @@ def test_any_mode_expansion_replaces_the_reading_without_new_bus_rows(tmp_path): def test_attaching_or_detaching_history_replaces_the_reading_without_new_live_messages(tmp_path): - old = wire(tmp_path / "old") - comms = wire(tmp_path / "live") + old = installed_wire(tmp_path / "old") + comms = installed_wire(tmp_path / "live") for bus in (old, comms): bus.registry.declare(bus.registry.new_thread("alice", frozenset({"team"}), str(tmp_path))) bus.messaging.send("alice", "#team", bus.root.name)