From 0c7d6ea30bfeccbec556e4363866d6f36883f607 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 02:21:10 -0400 Subject: [PATCH] Revert toad#1349: its agent-comms pair #3607 is not on main toad#1349 merged alone at 05:47:30Z because agent-comms/review is Toad's only required check. Toad main then called RelationshipEntry.person.present(), which exists only on agent-comms#3607 (unmerged, conflicting), so every relationship row raises. Reland together with #3607. Co-Authored-By: Claude Opus 5.5 --- src/toad/conversation_kind.py | 4 +- src/toad/widgets/comms_chat.py | 1 + src/toad/widgets/thread_comms.py | 55 ++++++++-------- src/toad/wire_history.py | 5 ++ .../test_channel_updates_and_returns.py | 63 ------------------- tests/textual/test_conversation_kind.py | 31 +-------- .../test_thread_relationship_sidebar.py | 42 ------------- 7 files changed, 35 insertions(+), 166 deletions(-) diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index 29e758594..a6e386cde 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -300,8 +300,8 @@ def prompt(self, project: Checkout): async def status_text(self, comms: Comms, *, read_only: bool) -> str: def read(): - snapshot = comms.registry.snapshot() - return comms.configuration.answer(self.peer.require_current(snapshot).name) + references = CurrentDisplayReferences.from_registry(str(comms.root), comms.registry.snapshot()) + return comms.configuration.answer(self.destination(references).name) return (await asyncio.to_thread(read)).status_label diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 5724eb231..1caaeca1c 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -114,6 +114,7 @@ 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.initialize_source(WireSource( comms, self.conversation, self.project_path, self._me, IrcMessageStyle(), diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index 33a730f0f..a2f2747bb 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -10,7 +10,6 @@ from toad.navigation_target import NavigationTarget, ThreadTarget import asyncio -from collections.abc import Mapping from dataclasses import dataclass, field from agent_comms.display_order import ThreadSort @@ -45,9 +44,10 @@ def entry_target(entry: RelationshipEntry) -> NavigationTarget: - for person in entry.person.present(): - return ThreadTarget(person.thread.name) - return NavigationTarget.named(entry.target) + person = entry.person + if person is None: + return NavigationTarget.named(entry.target) + return ThreadTarget(person.thread.name) @dataclass @@ -111,9 +111,7 @@ class RelationshipRow(ThreadStatusRow, actions=(CopyIdentityAction,)): available = True entry: RelationshipEntry | None = None - def present( - self, entry: RelationshipEntry, rows: Mapping[str, ThreadRowModel] - ) -> None: + def present(self, entry, model) -> None: previous, self.entry = self.entry, entry self.available, self.target = entry.available, entry_target(entry) if not entry.available: @@ -122,39 +120,36 @@ def present( Content(f"? {entry.target}\n Unavailable ยท Ctrl+C copies name"), layout=False, ) - self._show_entry_detail(entry) - return - for person in entry.person.present(): - model = rows.get(person.thread.name) - if model is not None and model.incarnation == person.thread.incarnation: + elif entry.person is not None: + if ( + model is not None + and model.incarnation == entry.person.thread.incarnation + ): self.workers.cancel_group(self, "relationship-row") self._show_person(entry, model) elif previous != entry: self.run_worker( - self._present_person(entry, person), + self._present_person(entry), group="relationship-row", exclusive=True, ) - return - if previous != entry: + elif previous != entry: self.set_label(entry.target) - self._show_entry_detail(entry) - - def _show_entry_detail(self, entry): - self.tooltip = ( - Content(f"{entry.target}\n{entry.detail}") - if entry.detail - else entry.target - ) + if entry.person is None: + self.tooltip = ( + Content(f"{entry.target}\n{entry.detail}") + if entry.detail + else entry.target + ) def _show_person(self, entry, model): self.show(model) if entry.detail: - self._show_entry_detail(entry) + self.tooltip = Content(f"{entry.target}\n{entry.detail}") - async def _present_person(self, entry, person): + async def _present_person(self, entry): model = await self.app.preparation.run_thread( - ThreadRowModel.of, person, UnobservedUnread() + ThreadRowModel.of, entry.person, UnobservedUnread() ) if self.entry == entry: self._show_person(entry, model) @@ -212,8 +207,7 @@ def keys_for_threads(self, names: frozenset[str]) -> frozenset[str]: return frozenset( entry.target for entry in self.model.entries.values() - for person in entry.person.present() - if person.thread.name in names + if entry.person is not None and entry.person.thread.name in names ) def new_row(self, key: str): @@ -223,7 +217,10 @@ def new_row(self, key: str): def show_member(self, key: str, row: RelationshipRow) -> None: entry = self.model.entries[key] threads = self.query_ancestor(ThreadCommsSidebar).model_state.shown.threads.rows - row.present(entry, threads) + row.present( + entry, + threads.get(entry.person.thread.name) if entry.person is not None else None, + ) def sync_members(self, changed: frozenset[str]) -> None: """Reconcile member rows from this group's entries and the tree's derived thread rows.""" diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index e6039da68..93e9d610c 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -16,6 +16,7 @@ from agent_comms.comms import Comms from agent_comms.repo import Checkout +from agent_comms.display_references import CurrentDisplayReferences from agent_comms.historical_views import ( ConversationHistoryView, ConversationObservation, @@ -300,6 +301,10 @@ class WireSource: def viewer(self) -> NamedViewer: return self.comms.views.display_viewer(self.project) + def references(self) -> CurrentDisplayReferences: + """Resolve stable destinations through this read's registry cut.""" + return CurrentDisplayReferences.from_registry(str(self.comms.root), 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( diff --git a/tests/textual/test_channel_updates_and_returns.py b/tests/textual/test_channel_updates_and_returns.py index 60971cec8..be4771fdc 100644 --- a/tests/textual/test_channel_updates_and_returns.py +++ b/tests/textual/test_channel_updates_and_returns.py @@ -1,7 +1,5 @@ """A real shared observer updates a shown channel and leaves its parked view alone.""" -import asyncio - async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_the_update(workspace): from toad.widgets.comms_chat import CommsChatView, session_thread_name @@ -34,64 +32,3 @@ async def test_live_update_paints_parked_channel_reads_nothing_and_return_shows_ await ui.until_shows("PARKED UPDATE") await ui.until(lambda: bool(access.notification_interest.reads), why="the returned channel to resubscribe") assert app.selected_session.query_one(CommsChatView) is chat - - -async def test_first_wire_page_paints_before_pending_sidebar_and_keeps_source_fences(workspace): - from agent_comms.launch_root import LaunchRoot - from support.roots import installed_wire - from toad.preferences import SETTINGS - from toad.widgets.channel_prompt import ChannelPrompt - from toad.widgets.comms_chat import CommsChatView - - sender = workspace.thread("sender", "first-page") - workspace.thread("reader", "first-page") - workspace.comms.messaging.send("sender", "#first-page", "FIRST_PAGE_WITHOUT_SIDEBAR") - other = installed_wire(workspace.roots.attempt / "other-route") - async with workspace.app(size=(113, 34)) as ui: - app = ui.app - access = app.coordination_access - await access.current_sidebar() - observed = access.observation.observed() - process = observed.process - # Join the actual receiver, not a replacement service or fake answer. - # Its child keeps running and the real sidebar reply remains in its pipe. - await process._reader.aclose() - async with asyncio.timeout(20), asyncio.TaskGroup() as work: - try: - app.saved_settings.toggle(SETTINGS.sidebar.show_archived) - sidebar = work.create_task(access.current_sidebar()) - references = work.create_task( - observed.current_references(access, str(workspace.comms.root)), - ) - await ui.until(lambda: bool(access.sidebar_waiters), - why="the genuine changed-interest sidebar answer to remain pending") - opening = work.create_task(ui.open_channel("#first-page", actor="reader")) - await ui.until_shows("FIRST_PAGE_WITHOUT_SIDEBAR") - chat = app.selected_session.query_one(CommsChatView) - history = chat.message_history - source = history.page_source - assert not history.undrawn - assert not sidebar.done() and not references.done() - assert not chat.query_one(ChannelPrompt)._candidates - finally: - # Reattach the same observer's one reader; deliver its original reply. - process.start_receiving(observed.receive, observed.receive_failed) - mode = await opening - answer = await sidebar - catalog = await references - assert catalog.thread(sender.incarnation).name == sender.name - assert answer.require_snapshot().references.thread(sender.incarnation).name == sender.name - await ui.until( - lambda: any(candidate.name == sender.name - for candidate in chat.query_one(ChannelPrompt)._candidates), - why="the genuine channel mention label to finish after its sidebar answer", - ) - assert chat.agent_info.plain == chat.conversation.label(catalog) - snapshot = history.source_snapshot() - source = history.page_source - with LaunchRoot.from_path(other.root).selected(): - assert not await history.source_is_current(snapshot) - assert await history.source_is_current(snapshot) - await app.session_navigation.close(mode) - assert source.closed - assert not await source.route_is_current() diff --git a/tests/textual/test_conversation_kind.py b/tests/textual/test_conversation_kind.py index 880d1822f..80df2a19b 100644 --- a/tests/textual/test_conversation_kind.py +++ b/tests/textual/test_conversation_kind.py @@ -9,11 +9,9 @@ from agent_comms.comms import Comms from agent_comms.active_route import resolve_comms_route from support.roots import installed_wire -from agent_comms.errors import UnregisteredThreadError from agent_comms.field_codec import FieldCodec from agent_comms.installer.backup import StoreBackup from agent_comms.message_page import PageBounds -from agent_comms.thread_creation import RecordedCreationTime from agent_comms.threads import Thread from toad.constants import ALL_COMMS_TARGET @@ -109,14 +107,11 @@ def test_kind_and_owner_are_part_of_identity_but_channel_address_is_shared(copie ) -async def test_dm_rename_retains_key_and_history_scope_but_resolves_current_name(copied_conversations): +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 - assert await direct.status_text(comms, read_only=False) == ( - comms.configuration.answer("peer").status_label - ) comms.registry.rename("peer", "renamed") reopened = DirectTarget("renamed").prepare(comms, viewer).conversation references = comms.views.viewer_snapshot(Checkout(comms.root.parent)).references @@ -126,9 +121,6 @@ async def test_dm_rename_retains_key_and_history_scope_but_resolves_current_name assert direct.label(references) == "@renamed" assert direct.send_target().selected_name(comms.registry.snapshot()) == "renamed" assert HistoryTarget.for_conversation(direct, references) == DirectTarget("renamed") - assert await direct.status_text(comms, read_only=False) == ( - comms.configuration.answer("renamed").status_label - ) def test_recreated_name_cannot_resolve_the_retired_dm(copied_conversations): @@ -146,27 +138,6 @@ def test_recreated_name_cannot_resolve_the_retired_dm(copied_conversations): direct.destination(references) -@pytest.mark.parametrize("recreate", (False, True)) -async def test_dm_status_refuses_deleted_or_recreated_peer(copied_conversations, recreate): - comms, viewer = copied_conversations - original = comms.registry.require("peer") - direct = DirectTarget("peer").prepare(comms, viewer).conversation - comms.registry.unregister("peer") - comms.threads.delete("peer") - if recreate: - comms.registry.declare(Thread( - "peer", frozenset(), Checkout(comms.root.parent), - created_at=RecordedCreationTime(original.created_at + 1), - )) - replacement = DirectTarget("peer").prepare(comms, viewer).conversation - assert replacement != direct - assert await replacement.status_text(comms, read_only=False) == ( - comms.configuration.answer("peer").status_label - ) - with pytest.raises(UnregisteredThreadError): - await direct.status_text(comms, read_only=False) - - def test_history_picker_and_channel_status_use_the_same_declared_value(copied_conversations): from toad.screens.historical_sessions import HistoricalSessions diff --git a/tests/textual/test_thread_relationship_sidebar.py b/tests/textual/test_thread_relationship_sidebar.py index 0c4dd39cb..4191d2d3e 100644 --- a/tests/textual/test_thread_relationship_sidebar.py +++ b/tests/textual/test_thread_relationship_sidebar.py @@ -76,48 +76,6 @@ async def test_relationship_groups_follow_core_edits_tools_sorting_and_renames(w assert peer_group.entries["owner"].target == "owner" assert peer_group.entries["owner"].detail == "Review the sidebar" - # Use Core's actual entries and models in this already-open panel. - collaboration = tree.groups[CollaboratingRelationshipGroup] - person_entry = collaboration.model.entries["peer"] - person, = person_entry.person.present() - person_row = collaboration.new_row("peer") - channel_group = tree.groups[OutboundRelationshipGroup] - channel_entry = channel_group.model.entries["#team"] - channel_row = channel_group.new_row("#team") - await collaboration.member_container.mount(person_row) - await channel_group.member_container.mount(channel_row) - try: - person_row.present(person_entry, {}) - await ui.until( - lambda: person_row.thread_row is not None, - why="an uncached person to finish real model preparation", - ) - assert person_row.thread_row is not None - assert person_row.thread_row.incarnation == person.thread.incarnation - assert person_row.thread_row.name == "peer" - assert "Review the sidebar" in str(person_row.tooltip) - - cached = tree.model_state.shown.threads.rows["peer"] - person_row.retire_thread_row() - person_row.present(person_entry, {"peer": cached}) - assert person_row.thread_row is cached - - assert tuple(channel_entry.person.present()) == () - channel_row.present(channel_entry, tree.model_state.shown.threads.rows) - await ui.until( - lambda: "#team" in ui.painted(channel_row), - why="the nominally absent person to keep its channel label", - ) - assert channel_row.thread_row is None - assert channel_row.entry == channel_entry - assert str(channel_row.tooltip) == ( - f"{channel_entry.target}\n{channel_entry.detail}" - if channel_entry.detail else channel_entry.target - ) - finally: - await person_row.remove() - await channel_row.remove() - invoke_tool(comms, "comms_collaboration", {"action": "remove", "peer": "owner"}, caller=ThreadCaller("peer")) await ui.until(lambda: not tree.groups[CollaboratingRelationshipGroup].model.entries