From 3772cf0f796cba739a1c6103eeeefcf77ed9e797 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 06:33:59 -0400 Subject: [PATCH 01/22] Input delivery reads through Core's one owner-live operation (agent-comms#3538) ReadDeliveryOverview restated Core's input-delivery read with its own include_history; Core deleted it. Toad now sends ReadOwnerOperation carrying InputDeliveryRead(include_history=...) to the held owner and wraps the owner's DeliveryOverview in ReadDeliveryView. No-coordination DeliveryView, require_owner and dismiss_historical_inputs are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/toad/acp/agent_controller.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index 91057609d..c2302c28d 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -22,8 +22,9 @@ from agent_comms.acp_failure import ACPFailure, RequestFailure from agent_comms.coordination_errors import Refused from agent_comms.transcripts import TranscriptReadIdentity -from agent_comms.ui_model.delivery import DeliveryView -from agent_comms.ui_model.observation import ReadDeliveryOverview +from agent_comms.order_tools import InputDeliveryRead +from agent_comms.ui_model.delivery import DeliveryView, ReadDeliveryView +from agent_comms.ui_model.observation import ReadOwnerOperation from toad.acp.client_session import ClientSessionRequest from toad.acp.status import StopReason @@ -430,11 +431,11 @@ async def input_delivery(self, *, include_history: bool = False) -> DeliveryView if coordination is None: return DeliveryView() authority = ClientSessionRequest(self.agent, self.agent.session_id) - delivery = await self.transcripts.read( - ReadDeliveryOverview(coordination.wire_root, coordination.thread, include_history=include_history) - ) + overview = await self.transcripts.read(ReadOwnerOperation( + coordination.wire_root, coordination.thread, InputDeliveryRead(include_history=include_history), + )) self.require_owner(coordination, authority) - return delivery + return ReadDeliveryView(overview) async def dismiss_historical_inputs(self): return await self.request_owner("dismiss_historical_inputs") From 0be58d8a3e560b80f55400af10707f4568253fe9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 07:11:08 -0400 Subject: [PATCH 02/22] Native Toad reads input delivery from the live owner queue (agent-comms#3538) tests/native/test_native_goal_owner.py: with the real native session, after until_agent_ready(), controller.input_delivery() and controller.input_delivery(include_history=True) are each a ReadDeliveryView whose overview has current_scope OwnerQueueInputDeliveryScope (only the live owner yields it; the no-coordination DeliveryView and the unobserved projection never do), and on the fresh private wire empty inputs and historical_inputs with zero counts. No mocked read, operation, owner or reply. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/native/test_native_goal_owner.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/native/test_native_goal_owner.py b/tests/native/test_native_goal_owner.py index 8fc1016d1..8c7cf29dd 100644 --- a/tests/native/test_native_goal_owner.py +++ b/tests/native/test_native_goal_owner.py @@ -3,6 +3,8 @@ import pytest from agent_comms.goal_actions import SetGoalAction from agent_comms.goal_history import RecordedGoalHistory +from agent_comms.input_attempt import OwnerQueueInputDeliveryScope +from agent_comms.ui_model.delivery import ReadDeliveryView from support.native import native_session @@ -37,3 +39,18 @@ async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(worksp for entry in history.history) snapshot = await agent.get_goal_snapshot() assert snapshot.goal == changed and snapshot.execution.goal_id == changed.id + + +async def test_input_delivery_is_read_from_the_live_native_owner_queue(workspace, native_package): + """The owner_queue scope can only come from the live owner; the no-coordination + DeliveryView and the unobserved projection never carry it.""" + async with native_session(workspace) as native: + await native.ui.until_agent_ready() + controller = native.agent.controller + for delivery in (await controller.input_delivery(), + await controller.input_delivery(include_history=True)): + assert type(delivery) is ReadDeliveryView + overview = delivery.overview + assert overview.current_scope is OwnerQueueInputDeliveryScope + assert overview.inputs == () and overview.historical_inputs == () + assert overview.historical_count == 0 and overview.dismissed_historical_count == 0 From a9a330ba6d6ac9ae93ba11d151ad7cf25f816d74 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 08:26:30 -0400 Subject: [PATCH 03/22] Notification views build a root-free operation; one envelope binds the root (agent-comms#3538) Core moved the notification read off the UI request: LiveNotificationRead and RecordedNotificationRead own references (and the archive), and ReadNotifications(root, operation) is the one UI envelope. - HistoryNotifications.notification_request (a ReadNotifications class used as a root+references constructor) becomes notification_operation, a root-free operation constructor (LiveNotificationRead by default). notification_read binds the ProjectPathOwner root: ReadNotifications(root, operation(refs)). - RecordedContents sets partial(RecordedNotificationRead, source=source). ReadRecordedNotifications is gone. - comms_root keeps keying views and outcomes by the full UI request. - tests: the historical source test requires the generic envelope with RecordedNotificationRead((reference,), source.key); the observation shutdown test builds its request with LiveNotificationRead. Assertions unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/toad/screens/historical_sessions.py | 5 +++-- src/toad/widgets/message_notifications.py | 5 +++-- tests/textual/test_historical_notification_source.py | 8 ++++---- tests/textual/test_observation_shutdown.py | 3 ++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/toad/screens/historical_sessions.py b/src/toad/screens/historical_sessions.py index 2395cc0a4..367524055 100644 --- a/src/toad/screens/historical_sessions.py +++ b/src/toad/screens/historical_sessions.py @@ -9,7 +9,8 @@ from agent_comms.comms import Comms from agent_comms.coordination_errors import StaleRevision from metaclass_registry.mro_dispatch import handles -from agent_comms.ui_model.observation import ReadTranscript, ReadRecordedNotifications +from agent_comms.notification_reads import RecordedNotificationRead +from agent_comms.ui_model.observation import ReadTranscript from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from agent_comms.historical_views import HistoricalThread from textual import on, work @@ -29,7 +30,7 @@ class RecordedContents(Contents): """The notification subscriber reads the same archive as its history.""" def __init__(self, source: str, **kwargs): - self.notification_request = partial(ReadRecordedNotifications, source=source) + self.notification_operation = partial(RecordedNotificationRead, source=source) super().__init__(**kwargs) diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index d5a2fe5d5..4f73df4fb 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -11,6 +11,7 @@ from textual.content import Content from textual.widgets import Collapsible +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ReadNotifications from toad.notification_status import ( @@ -110,7 +111,7 @@ class HistoryNotifications: """Visible history rows subscribe to Core; paging never decides when outcomes changed.""" NOTIFICATION_GROUP = "message-notifications" - notification_request = ReadNotifications + notification_operation = LiveNotificationRead # Core feedback for every visible bus message, native or wire. @@ -126,7 +127,7 @@ def notification_read(self, references) -> ReadNotifications: root = ProjectPathOwner.containing(self).coordination_root if root is None: raise ValueError("Notification observation requires a bound thread root") - return self.notification_request(root, references) + return ReadNotifications(root, self.notification_operation(references)) def subscribe_notifications(self, references) -> None: access = self.app.coordination_access diff --git a/tests/textual/test_historical_notification_source.py b/tests/textual/test_historical_notification_source.py index 4959f5871..307672f37 100644 --- a/tests/textual/test_historical_notification_source.py +++ b/tests/textual/test_historical_notification_source.py @@ -2,7 +2,8 @@ from agent_comms.repo import Checkout from agent_comms.historical_views import HistoricalThread -from agent_comms.ui_model.observation import ReadRecordedNotifications +from agent_comms.notification_reads import RecordedNotificationRead +from agent_comms.ui_model.observation import ReadNotifications from agent_comms.threads import Thread from support.roots import installed_wire @@ -28,9 +29,8 @@ async def test_historical_notification_keeps_selected_source_and_lifetime(worksp 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,) + assert type(request) is ReadNotifications + assert request.operation == RecordedNotificationRead((message.reference,), source.key) await ui.until(lambda: request in access.notification_outcomes, why="the archive's actual observation reply") outcomes = access.notification_outcomes[request] diff --git a/tests/textual/test_observation_shutdown.py b/tests/textual/test_observation_shutdown.py index 4e4aa236c..90bf272ca 100644 --- a/tests/textual/test_observation_shutdown.py +++ b/tests/textual/test_observation_shutdown.py @@ -4,6 +4,7 @@ import sys import pytest +from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ObservationProcess, ObserverDied, ObserveNotifications, ReadNotifications from toad.comms_root import AnsweringReads @@ -45,7 +46,7 @@ async def test_stopping_observation_keeps_reads_but_sends_no_late_interest(works process = observing.process loop = asyncio.get_running_loop() acquired = asyncio.Event() - request = ReadNotifications(str(workspace.roots.wire), (message.reference,)) + request = ReadNotifications(str(workspace.roots.wire), LiveNotificationRead((message.reference,))) async def read_notifications(): acquired.set() From 54db5d11ad7e320ddd1d74cb03fc0873078bc823 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 08:54:05 -0400 Subject: [PATCH 04/22] Shutdown tests hold the real observer child; paint fixtures read the operation's references (agent-comms#3538) Both shutdown tests called process.fileno() to unhook a readiness callback that ObservationProcess no longer has. The one receiver started at activation keeps running after stop_observing, so the answer has to be held where it is made: in this test's own observer child. - test_stopping_observation_keeps_reads_but_sends_no_late_interest proves the child is ready with an empty-window read and SIGSTOPs it (stop confirmed by a bounded WNOHANG|WUNTRACED wait). It then registers the real read, stops observing with every AnsweringReads identity check and the no-late-interest profile, and SIGCONTs the child. The same receiver settles the read: set(result.outcomes) is the message's seq/id. - test_an_observer_dying_during_shutdown_still_releases_its_resources holds the child, registers a pending empty-window read and SIGKILLs the child. - The read fails with ObserverDied carrying that pid and -SIGKILL. - StopSidebar and StopObserving each raise that same object. - Teardown may report it only as that same cause, and other grouped errors propagate. - The connection is closed, the child is gone and nothing is retiring. - Every path resumes a still-stopped live child and settles any unconsumed read. - The four paint-fixture reads of request.references now read request.operation.references; ReadNotifications has no references of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- tests/textual/test_message_notifications.py | 8 +- tests/textual/test_observation_shutdown.py | 173 +++++++++++++------- 2 files changed, 118 insertions(+), 63 deletions(-) diff --git a/tests/textual/test_message_notifications.py b/tests/textual/test_message_notifications.py index 670fa0bc2..7e9908783 100644 --- a/tests/textual/test_message_notifications.py +++ b/tests/textual/test_message_notifications.py @@ -35,7 +35,7 @@ async def publish(self, ui, chat): view = chat.message_history.notification_view access = ui.app.coordination_access request = access.notification_views[view] - outcomes = self.answer(request.references) + outcomes = self.answer(request.operation.references) expected, = CheckedNotifications.for_references( chat.message_history.visible_fragments()[-1].notification_references, outcomes) access.notifications_observed(NotificationsObserved(access.revision, request, outcomes), access.observation.observed()) @@ -88,8 +88,8 @@ async def test_visible_references_are_bounded_and_typed_outcomes_paint_their_det request = ui.app.coordination_access.notification_views[history.notification_view] on_screen = {reference for fragment in history.visible_fragments() for reference in fragment.notification_references} - assert 0 < len(request.references) <= len(on_screen) < 30 - assert set(request.references) <= on_screen + assert 0 < len(request.operation.references) <= len(on_screen) < 30 + assert set(request.operation.references) <= on_screen await notifications.publish(ui, chat) await ui.until_shows("Delivered 45/45 · Consumed 0/45 · Read 0/45") assert newest_status(chat).working @@ -144,7 +144,7 @@ async def test_a_hidden_view_unsubscribes_and_discards_a_late_reply(workspace): await ui.select_tab(owner) assert view not in ui.app.coordination_access.notification_views notifications.state = CompletedProgress(ShownToModelProgress(0)) - view.notifications_observed(notifications.answer(request.references)) + view.notifications_observed(notifications.answer(request.operation.references)) await ui.step() assert view not in ui.app.coordination_access.notification_views assert {fragment.message.reference: fragment.shown.status diff --git a/tests/textual/test_observation_shutdown.py b/tests/textual/test_observation_shutdown.py index 90bf272ca..0a2b134db 100644 --- a/tests/textual/test_observation_shutdown.py +++ b/tests/textual/test_observation_shutdown.py @@ -1,36 +1,93 @@ """Textual-facing: widget unmount; unmount ends interest, not the real observer's already-acquired reads.""" import asyncio +import os +import signal import sys +from contextlib import asynccontextmanager + import pytest from agent_comms.notification_reads import LiveNotificationRead -from agent_comms.ui_model.observation import ObservationProcess, ObserverDied, ObserveNotifications, ReadNotifications +from agent_comms.ui_model.observation import ( + ObservationProcess, ObserverDied, ObserveNotifications, ReadNotifications, StopObserving, + StopSidebar, +) from toad.comms_root import AnsweringReads +async def stopped(pid: int) -> None: + """Wait, bounded, until the stop is reported; WUNTRACED reports it without reaping.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + 5 + while loop.time() < deadline: + found, status = os.waitpid(pid, os.WNOHANG | os.WUNTRACED) + if found: + assert os.WIFSTOPPED(status) and os.WSTOPSIG(status) == signal.SIGSTOP + return + await asyncio.sleep(0.01) + raise AssertionError(f"observer child {pid} did not stop") + + +@asynccontextmanager +async def held(process: ObservationProcess): + """Hold this test's own observer child; resume it on every path while it lives.""" + pid = process.process.pid + os.kill(pid, signal.SIGSTOP) + try: + await stopped(pid) + yield pid + finally: + if process.process.is_alive(): + os.kill(pid, signal.SIGCONT) + + +async def settled(task: asyncio.Task) -> None: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + +def empty_read(workspace) -> ReadNotifications: + return ReadNotifications(str(workspace.roots.wire), LiveNotificationRead(())) + + async def test_an_observer_dying_during_shutdown_still_releases_its_resources(workspace): - with pytest.raises(BaseExceptionGroup) as failures: + died = None + try: async with workspace.app() as ui: access = ui.app.coordination_access await access.current_sidebar() - observed = access.observation.observed() - process = observed.process - # 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 + process = access.observation.observed().process + assert not (await asyncio.wait_for(process.read(empty_read(workspace)), 30)).outcomes + reading = None + try: + async with held(process) as pid: + reading = asyncio.create_task(process.read(empty_read(workspace))) + await asyncio.sleep(0) + assert process.reads and not reading.done() + os.kill(pid, signal.SIGKILL) + with pytest.raises(ObserverDied) as death: + await asyncio.wait_for(reading, 10) + finally: + if reading is not None: + await settled(reading) + died = death.value + assert died.pid == pid + assert died.exit_status == -signal.SIGKILL + for request in (StopSidebar(), StopObserving()): + with pytest.raises(ObserverDied) as again: + process.request(request) + assert again.value is died + except* ObserverDied as failures: + assert died is not None + _same, other = failures.split(lambda error: error is died) + assert other is None assert process.connection.closed assert not process.process.is_alive() assert not access.retiring - - async def test_stopping_observation_keeps_reads_but_sends_no_late_interest(workspace): workspace.thread("sender", title="Sender") workspace.thread("reader", title="Reader") @@ -44,50 +101,48 @@ async def test_stopping_observation_keeps_reads_but_sends_no_late_interest(works # Retiring the old process must not publish its state over the new one. assert access.observation is observing process = observing.process - loop = asyncio.get_running_loop() - acquired = asyncio.Event() + assert not (await asyncio.wait_for(process.read(empty_read(workspace)), 30)).outcomes request = ReadNotifications(str(workspace.roots.wire), LiveNotificationRead((message.reference,))) - - async def read_notifications(): - acquired.set() - return await process.read(request) - - # Hold the readiness callback, not the service, so this acquired read - # cannot resolve until stop_observing installs the read-only reader. - loop.remove_reader(process.fileno()) - async with asyncio.TaskGroup() as reads: - reading = reads.create_task(read_notifications()) - await acquired.wait() - assert process.reads and not reading.done() - await access.stop_observing() - answering = access.observation - assert type(answering) is AnsweringReads - assert answering.process is process - assert answering.service is observing.service - assert answering.selection is observing.selection - assert answering.sidebar is observing.sidebar - assert answering.subscriptions is observing.subscriptions - assert answering.updates is observing.updates - - requests = [] - - def record_request(frame, event, result): - if (event == "call" and frame.f_code is ObservationProcess.request.__code__ - and frame.f_locals["self"] is process): - requests.append(frame.f_locals["request"]) - - previous = sys.getprofile() - sys.setprofile(record_request) - try: - answering.views_changed() - answering.thread_interest(access) - answering.observe_sidebar(access.sidebar_request()) - answering.observe_notifications(ObserveNotifications(frozenset())) + reading = None + try: + # The observer child itself holds the answer; the UI's one receiver keeps running. + async with held(process): + reading = asyncio.create_task(process.read(request)) + await asyncio.sleep(0) + assert process.reads and not reading.done() await access.stop_observing() - assert access.observation is answering - finally: - sys.setprofile(previous) - assert requests == [] - result = await reading - assert set(result) == {(message.seq, message.message_id)} - assert process.process.is_alive() + answering = access.observation + assert type(answering) is AnsweringReads + assert answering.process is process + assert answering.service is observing.service + assert answering.selection is observing.selection + assert answering.sidebar is observing.sidebar + assert answering.subscriptions is observing.subscriptions + assert answering.updates is observing.updates + + requests = [] + + def record_request(frame, event, result): + if (event == "call" and frame.f_code is ObservationProcess.request.__code__ + and frame.f_locals["self"] is process): + requests.append(frame.f_locals["request"]) + + previous = sys.getprofile() + sys.setprofile(record_request) + try: + answering.views_changed() + answering.thread_interest(access) + answering.observe_sidebar(access.sidebar_request()) + answering.observe_notifications(ObserveNotifications(frozenset())) + await access.stop_observing() + assert access.observation is answering + finally: + sys.setprofile(previous) + assert requests == [] + assert not reading.done() + result = await asyncio.wait_for(reading, 30) + finally: + if reading is not None: + await settled(reading) + assert set(result.outcomes) == {(message.seq, message.message_id)} + assert process.process.is_alive() From cdd6e15d612860a0978a73fbb14b1aedaf38dd93 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 10:19:02 -0400 Subject: [PATCH 05/22] Owner presentation reads leave their worker as the published projection (agent-comms#3538) read_thread_presentation returned the acquired presentation to Conversation and ObservedSourcePublication, carrying the notification window and backend records into the UI. It now returns .published(), the source-free ThreadPresentation; settlement still reads its read_identity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/toad/owner_preparation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/toad/owner_preparation.py b/src/toad/owner_preparation.py index 04a5c8eb1..3e2372860 100644 --- a/src/toad/owner_preparation.py +++ b/src/toad/owner_preparation.py @@ -68,5 +68,6 @@ class OwnerRequestContext: def read_thread_presentation(comms: Comms, name: str) -> ThreadPresentation: - """Read the existing core presentation on a worker, without UI dependencies.""" - return comms.views.thread_presentations((name,)).read(name) + """Read the existing core presentation on a worker, without UI dependencies; only its + published projection leaves the worker, never the acquired delivery evidence.""" + return comms.views.thread_presentations((name,)).read(name).published() From 9b6c482e2d56ea59cb07b0437e10125f17da1236 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:05:58 -0400 Subject: [PATCH 06/22] Commander detail reads through the shared comms_commander_unit operation (#1302) The commander page wraps CommanderUnitRead(commander, unit, orders) in the UI's ReadOperation envelope instead of the deleted ReadCommanderUnit. Request id, root, card/collapsed fencing, worker cancellation and the unavailable-detail display are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/toad/screens/commander.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/toad/screens/commander.py b/src/toad/screens/commander.py index 5be6bf5e4..4528d3af2 100644 --- a/src/toad/screens/commander.py +++ b/src/toad/screens/commander.py @@ -11,7 +11,8 @@ from agent_comms.coordination_errors import DeferredRead from agent_comms.errors import RelationViolationError, RouteChangedError, ToolRefusedError from agent_comms.thread_identity import ThreadIncarnation -from agent_comms.ui_model.observation import ReadCommanderUnit, UnreadableHistory +from agent_comms.order_tools import CommanderUnitRead +from agent_comms.ui_model.observation import ReadOperation, UnreadableHistory from agent_comms.work_branches import WorkBranchError from metaclass_registry.mro_dispatch import MroDispatch, handles from textual import on, work @@ -118,10 +119,10 @@ async def read_detail(self) -> None: access = self.app.coordination_access try: observed = await access.preparation.run_thread(access.observation.observed) - request = ReadCommanderUnit( - str(observed.service.root), self.commander, card.unit, - tuple(dict.fromkeys(order.order for order in card.orders)), - ) + request = ReadOperation(str(observed.service.root), CommanderUnitRead( + commander=self.commander, unit=card.unit, + orders=tuple(dict.fromkeys(order.order for order in card.orders)), + )) detail = await observed.process.read(request) except ( DeferredRead, RelationViolationError, RouteChangedError, From 6f41bc6f9f88e14329b6731c03e942c64e85cbab Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:09:56 -0400 Subject: [PATCH 07/22] The goal snapshot reads through the shared comms_owner_goal operation (#1302) acp/agent.get_goal_snapshot sends ReadOwnerOperation(wire_root, thread, operation=OwnerGoalRead()) through its existing transcript reader instead of the deleted ReadOwnerGoal. The local NoGoal/Idle answer without coordination, the ClientSessionRequest authority, OwnerSnapshotConsumer's captured turn token, the post-read require_owner and dispatch of every meta.batch update are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/toad/acp/agent.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/toad/acp/agent.py b/src/toad/acp/agent.py index db02cf442..cb0c79bbe 100644 --- a/src/toad/acp/agent.py +++ b/src/toad/acp/agent.py @@ -15,7 +15,8 @@ from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution from agent_comms.goals import Goal, GoalDeclaration, NoGoal from agent_comms.goal_history import RecordedGoalHistory -from agent_comms.ui_model.observation import ReadOwnerGoal +from agent_comms.order_tools import OwnerGoalRead +from agent_comms.ui_model.observation import ReadOwnerOperation from toad import paths from toad.acp.agent_configuration import ( @@ -238,7 +239,7 @@ async def get_goal_snapshot(self) -> GoalSnapshot: authority = ClientSessionRequest(self, self.session_id) consumer = OwnerSnapshotConsumer(self, self.session_id, turn_token=self.presentation.turns.sequence) snapshot = await self.controller.transcripts.read( - ReadOwnerGoal(coordination.wire_root, coordination.thread)) + ReadOwnerOperation(coordination.wire_root, coordination.thread, operation=OwnerGoalRead())) self.controller.require_owner(coordination, authority) for fact in snapshot.meta.batch.updates: consumer.dispatch_sync(fact) From 9572b2f2c45923d6f065e846287617eceef47c51 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 12:18:24 -0400 Subject: [PATCH 08/22] Transcript reads carry the shared comms_transcript_page operation (#1302) KnownTranscript.snapshot_request and update_request, and the historical sessions screen, build their UI envelope around TranscriptPageRead with every original cursor and the attached source key; root and publication state stay on the envelope. acp/transcript_reader's RefreshTranscript keeps its original identity. A recorded-reader test asserts the exact page arguments on the operation and root/previous on the envelope. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- src/toad/screens/historical_sessions.py | 5 +++-- src/toad/session_scope.py | 12 +++++++----- tests/app/test_recorded_transcript_reader.py | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/toad/screens/historical_sessions.py b/src/toad/screens/historical_sessions.py index 367524055..7125d6c46 100644 --- a/src/toad/screens/historical_sessions.py +++ b/src/toad/screens/historical_sessions.py @@ -10,6 +10,7 @@ from agent_comms.coordination_errors import StaleRevision from metaclass_registry.mro_dispatch import handles from agent_comms.notification_reads import RecordedNotificationRead +from agent_comms.order_tools import TranscriptPageRead from agent_comms.ui_model.observation import ReadTranscript from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from agent_comms.historical_views import HistoricalThread @@ -122,8 +123,8 @@ async def selected(self, event: Select.Changed) -> None: access, root = self.app.coordination_access, str(self.comms.root) async def load(**kwargs): - snapshot = await access.transcripts.read(ReadTranscript( - root, item.thread.name, historical_source=item.source.key, **kwargs)) + snapshot = await access.transcripts.read(ReadTranscript(root, TranscriptPageRead( + thread=item.thread.name, historical_source=item.source.key, **kwargs))) return snapshot.page try: diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index fed3c6f28..a3c9d0375 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -15,6 +15,7 @@ from agent_comms.errors import RelationViolationError from agent_comms.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.order_tools import TranscriptPageRead from agent_comms.ui_model.observation import ( ReadTranscript, ReadTranscriptUpdate, @@ -158,14 +159,13 @@ def snapshot_request( through: TranscriptCursor | None = None, historical_source: str | None = None, ) -> ReadTranscript: - return ReadTranscript( - self.root, - self.name, + return ReadTranscript(self.root, TranscriptPageRead( + thread=self.name, before=before, after=after, through=through, historical_source=historical_source, - ) + )) def update_request( self, @@ -173,7 +173,9 @@ def update_request( *, after: TranscriptCursor | None = None, ) -> ReadTranscriptUpdate: - return ReadTranscriptUpdate(self.root, self.name, previous=previous, after=after) + return ReadTranscriptUpdate( + self.root, TranscriptPageRead(thread=self.name, after=after), previous=previous + ) def mailbox_target(self) -> ThreadTarget: return ThreadTarget(self.name) diff --git a/tests/app/test_recorded_transcript_reader.py b/tests/app/test_recorded_transcript_reader.py index 09fbe55ed..1263b70f9 100644 --- a/tests/app/test_recorded_transcript_reader.py +++ b/tests/app/test_recorded_transcript_reader.py @@ -2,15 +2,17 @@ from agent_comms.repo import Checkout import json -from dataclasses import replace +from dataclasses import fields, replace import pytest from agent_comms.acp_extension import TranscriptSnapshotUpdate, decode_updates, encode_updates from agent_comms.coordination_errors import HistoricalSourceChanged, StaleRevision +from agent_comms.order_tools import TranscriptPageRead from agent_comms.session_file import RegistrySessionLocation from agent_comms.transcripts import RecordedTranscriptReadIdentity from agent_comms.threads import Thread +from agent_comms.ui_model.observation import NotPublished from support.roots import installed_wire from support.journals import assistant_entry, write_journal from toad.acp.comms_updates import OwnerSnapshotConsumer @@ -62,3 +64,19 @@ def test_recorded_presentation_does_not_acquire_live_owner(recorded): consumer.thread_presentation(replace(presentation, read_identity=identity)) assert TranscriptSnapshotUpdate.capture(live.transcripts, "reader", historical_source=source.key).identity == identity + + +def test_known_transcript_requests_carry_the_page_operation_and_keep_root_at_the_envelope(recorded): + live, source, _native = recorded + scope = KnownTranscript(str(live.root), "reader") + cursor = live.transcripts.capture_page_read("reader", historical_source=source.key).read().after + snapshot = scope.snapshot_request(before=cursor, historical_source=source.key) + assert snapshot.root == str(live.root) + assert snapshot.operation == TranscriptPageRead( + thread="reader", before=cursor, historical_source=source.key) + previous = NotPublished() + update = scope.update_request(previous, after=cursor) + assert update.root == str(live.root) and update.previous is previous + assert update.operation == TranscriptPageRead(thread="reader", after=cursor) + # The shared operation never carries the UI's root, request or publication witness. + assert not {"root", "request_id", "previous", "identity"} & {f.name for f in fields(TranscriptPageRead)} From ef21cbf251516e2ad486e38f3531fc50f4245786 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 21:31:38 -0400 Subject: [PATCH 09/22] mypy reads Toad's declared source roots, src and tools Config only, authorized by root (bus 36182) for the P883 matched cut. The checker guessed the root from src/dot; tools/acceptance.py:34-36 already declares tools as a script root for its sibling imports (performance.*), so mypy_path names both roots instead of guessing one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 564a0629e..f709fc75e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,3 +84,4 @@ markers = ["refactor_guard: permanent architecture deletion guards"] [tool.mypy] follow_untyped_imports = true +mypy_path = ["src", "tools"] From 44abe7ce378c208bca6cdb194b5a22670b54e888 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 22:49:16 -0400 Subject: [PATCH 10/22] Share source-owned queue inspection across native and bus composers --- src/toad/agent.py | 3 - src/toad/agent_binding.py | 3 - src/toad/agent_presentation.py | 40 ++++- src/toad/conversation_kind.py | 10 +- src/toad/conversation_submission.py | 50 ++++-- src/toad/delivery_failure_view.py | 4 + src/toad/notification_status.py | 25 +++ src/toad/screens/goal_edit.py | 4 +- src/toad/session_presentation.py | 7 +- src/toad/submission_queue.py | 205 ++++++++++++++++++++++ src/toad/transcript_preparation.py | 6 + src/toad/widgets/channel_prompt.py | 10 ++ src/toad/widgets/comms_chat.py | 6 +- src/toad/widgets/conversation.py | 29 ++- src/toad/widgets/message_notifications.py | 4 + src/toad/widgets/prompt.py | 33 +--- src/toad/widgets/transcript_fragments.py | 8 + src/toad/wire_history.py | 7 +- tests/textual/test_common_queue.py | 73 ++++++++ 19 files changed, 452 insertions(+), 75 deletions(-) create mode 100644 src/toad/submission_queue.py create mode 100644 tests/textual/test_common_queue.py diff --git a/src/toad/agent.py b/src/toad/agent.py index adca76eb7..5cb6de45f 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -78,9 +78,6 @@ def accepts_failed_input(self, message): else True ) - def send_now(self, owner): - return owner.send_bound_now(self) - def invalidate_observation(self, observation: SessionObservation) -> None: observation.schedule(self) diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 1b8989060..e2bde404e 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -167,9 +167,6 @@ def begin_submission(self, owner: ConversationSubmissions, submission: AgentInpu def accepts_failed_input(self, message: CoreEventMessage) -> bool: return False - def send_now(self, owner: ConversationSubmissions) -> None: - """No remote queue is bound.""" - async def get_goal_history(self, goal_id: str) -> RecordedGoalHistory: return RecordedGoalHistory.empty() diff --git a/src/toad/agent_presentation.py b/src/toad/agent_presentation.py index 55d878b82..4fa243948 100644 --- a/src/toad/agent_presentation.py +++ b/src/toad/agent_presentation.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from pathlib import Path from agent_comms.declared_family import DeclaredFamily -from agent_comms.acp_extension import PendingQueueProjection, QueueProjection +from agent_comms.acp_extension import NoQueueScope, PendingQueueProjection, QueueProjection from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.conversation_turn import TurnBinding, LocalTurnBinding, ManagedTurnBinding @@ -38,6 +38,21 @@ def queue_supported(self) -> bool: def queue(self): """The actual source owns its input queue projection.""" + @property + def queue_scope(self): + return NoQueueScope() + + @property + def queue_identity(self) -> str: + return "No live ACP queue attachment" + + @property + def queue_actions_available(self) -> bool: + return False + + def require_queue_action(self, scope): + raise ValueError("No live ACP queue authority is available") + @property def prompt_in_flight(self): return 0 @@ -87,6 +102,29 @@ def queue_supported(self) -> bool: """Advertisement belongs to the original coordination declaration.""" return self.agent.coordination is not None + @property + def queue_scope(self): + return self.agent.queue_attachment.scope + + @property + def queue_identity(self) -> str: + attachment = self.agent.queue_attachment + return attachment.state.project( + attachment.current, + available=lambda current: f"Scope {current.scope!r} · revision {current.revision}", + pending="Checking queue attachment", + unavailable="Queue attachment unavailable", + ) + + @property + def queue_actions_available(self) -> bool: + return self.queue_supported and self.agent.queue_attachment.accepts_request(self.queue_scope) + + def require_queue_action(self, scope): + if not self.queue_supported or not self.agent.queue_attachment.accepts_request(scope): + raise ValueError("Queue attachment changed or is unavailable; inspect the current queue again") + return self.agent + @property def uses_managed_turns(self): return self.turns.managed diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index a6e386cde..34fce26d5 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -133,7 +133,7 @@ def active_members(self, model: SidebarModel): def compose_activity(cls, view: CommsChatView) -> ComposeResult: ... @abstractmethod - def prompt(self, project: Checkout): ... + def prompt(self, project: Checkout, *, submissions): ... @classmethod def seen_page(cls, history: WireHistory, seen): @@ -206,10 +206,10 @@ async def status_text(self, comms: Comms, *, read_only: bool) -> str: def initial_placeholder(self) -> str: return f"Message {self.target.name}" - def prompt(self, project: Checkout): + def prompt(self, project: Checkout, *, submissions): from toad.widgets.channel_prompt import ChannelPrompt - return ChannelPrompt(project=project, placeholder=self.initial_placeholder) + return ChannelPrompt(project=project, placeholder=self.initial_placeholder, submissions=submissions) def read_only(self, comms: Comms) -> bool: return comms.bus.require_channel(self.target.name).read_only @@ -293,10 +293,10 @@ def compose_activity(cls, view) -> ComposeResult: view.thread_activity = ConversationSessionDetails(view.conversation.peer) yield view.thread_activity - def prompt(self, project: Checkout): + def prompt(self, project: Checkout, *, submissions): from toad.widgets.prompt import MessagePrompt - return MessagePrompt(project=project) + return MessagePrompt(project=project, submissions=submissions) async def status_text(self, comms: Comms, *, read_only: bool) -> str: def read(): diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 8fa854b58..9d2dd56f1 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -24,6 +24,7 @@ from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventMessage from toad.widgets.prompt import Prompt +from agent_comms.notification_evidence import NotificationWindow if TYPE_CHECKING: from toad.acp.agent import Agent @@ -292,6 +293,33 @@ async def submit(self, event: UserInputSubmitted): def accepts_failure(self, message: CoreEventMessage): return self.view.agent_binding.accepts_failed_input(message) + @property + def queue_source(self): + return self.view.queue_source + + @property + def queue_summary(self) -> str: + return self.queue_source.queue_summary(self) + + @property + def message_queue_entries(self): + """Borrow the existing visible fragment proofs; never retain another inbox.""" + entries = {} + for target in self.view.contents.notification_targets(): + for fragment in target.visible_fragments(): + for entry in fragment.queue_entries(target.notification_view): + entries[entry.request] = entry + if len(entries) >= NotificationWindow.window_limit: + return tuple(entries.values()) + return tuple(entries.values()) + + def inspect_queue(self): + from toad.submission_queue import QueueInspection + + inspection = QueueInspection(self) + self.view.app.push_screen(inspection) + return inspection + @property def queue_projection(self): return self.view.agent_binding.presentation.queue @@ -335,20 +363,18 @@ def reset(self): self.publish_pending() def send_now(self): - return self.view.agent_binding.send_now(self) - - def send_bound_now(self, agent: Agent): - session, scope = agent.session_id, agent.queue_attachment.scope + source = self.queue_source return self.view.run_worker( - partial(self._send_now, agent, session, scope), group="send-queued-now", exclusive=True + partial(self._send_now, source, self.view.agent_binding, source.queue_scope(self)), + group="send-queued-now", exclusive=True, ) - async def _send_now(self, agent: Agent, session, scope: QueueScope): + async def _send_now(self, source, agent, scope: QueueScope): try: - if await agent.controller.send_now(): - return - except RequestError as error: - if self.view.agent_binding is agent and agent.session_id == session: + if self.queue_source is not source or self.view.agent_binding is not agent: + raise ValueError("The source changed before send now") + await source.queue_send_now(self, scope) + except (RequestError, ValueError) as error: + if self.queue_source is source and self.view.agent_binding is agent: self.view.feedback.flash(f"Send now failed: {error}", style="error") - if self.view.agent_binding is agent and agent.queue_attachment.accepts_request(scope): - self.publish_pending() + self.publish_pending() diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index 04c2a3cec..cb6a6533c 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -91,9 +91,13 @@ def send_block_reason(self) -> str: """Why this view's own send holds compose; empty when it does not.""" return self._send_state.reason + def queue_evidence_changed(self) -> None: + """An unbound delivery surface has no queue consumer to repaint.""" + def _enter_send_state(self, state: SendState) -> None: """The one place a send changes state; compose and the status line follow it.""" self._send_state = state + self.queue_evidence_changed() if self.is_attached: async def refresh() -> None: read_only = await self.composer_read_only() diff --git a/src/toad/notification_status.py b/src/toad/notification_status.py index c4e687879..17fccce8d 100644 --- a/src/toad/notification_status.py +++ b/src/toad/notification_status.py @@ -38,10 +38,18 @@ class NotificationStatus: def title(self) -> str: return "" + @property + def queue_title(self) -> str: + return self.title + @property def details(self) -> DetailLines: return () + @property + def queue_details(self) -> DetailLines: + return self.details + @property def disclosure(self) -> SpanColumns: return SpanColumns(self.details, indent=2) @@ -81,6 +89,23 @@ def working(self) -> bool: def title(self) -> str: return MessageNotification.summary(self.notifications) + @property + def queue_title(self) -> str: + # Keep each recipient's actual proof label; do not turn routing into arrival. + return " · ".join(f"@{item.recipient} {item.label}" for item in self.notifications[:1]) or "Unobserved" + + @property + def queue_details(self) -> DetailLines: + leading = CheckedNotifications(self.notifications[:1]) + return ( + (Span(f"{len(self.notifications)} observed recipients; leading result shown", MutedRole),), + *((Span(f"Recipient {item.recipient_identity.recipient_lookup}", MutedRole),) + for item in leading.notifications), + *leading.details, + *((Span(f"Answer {item.answer.seq}/{item.answer.message_id}", MutedRole),) + for item in leading.notifications if item.answer is not None), + ) + @property def details(self) -> DetailLines: lines = [] diff --git a/src/toad/screens/goal_edit.py b/src/toad/screens/goal_edit.py index 89a9bb795..79f72a6b0 100644 --- a/src/toad/screens/goal_edit.py +++ b/src/toad/screens/goal_edit.py @@ -16,7 +16,7 @@ from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventReceiver, CoreEventMessage from metaclass_registry.mro_dispatch import handles -from toad.widgets.channel_prompt import ChannelPrompt +from toad.widgets.channel_prompt import DraftPrompt class GoalEdit(CoreEventReceiver, DismissibleScreen[str | None]): @@ -43,7 +43,7 @@ def __init__( self.goal = goal self.on_save = on_save self.saving = False - self.editor = ChannelPrompt(project=project) + self.editor = DraftPrompt(project=project) self.editor.set_mention_candidates(candidates) def compose(self) -> ComposeResult: diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index e13b2259c..edd469e04 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -23,6 +23,7 @@ from toad.widgets.conversation import Conversation from toad.widgets.history_anchor import ParkedPosition from toad.widgets.prompt import MailboxPrompt +from toad.submission_queue import BusQueueSource, NativeQueueSource if TYPE_CHECKING: from toad.app import ToadApp @@ -104,7 +105,7 @@ async def release_binding( async def attach_binding(self, conversation: Conversation) -> None: ... -class OperationalSessionSources: +class OperationalSessionSources(NativeQueueSource): """Own existing operational resources while the view is detached.""" def __init__(self) -> None: @@ -235,7 +236,7 @@ async def close(self) -> None: release.push_async_callback(agent.stop) -class MailboxSessionSources(OperationalSessionSources): +class MailboxSessionSources(BusQueueSource, OperationalSessionSources): """CLI execution receives user messages through Core's inbox, not ACP.""" def activity_row(self, conversation: Conversation): @@ -245,7 +246,7 @@ def agent_info(self, conversation: Conversation) -> str: return conversation.thread_activity.row.presentation.title def prompt(self, conversation: Conversation): - return MailboxPrompt(project=conversation.project_path) + return MailboxPrompt(project=conversation.project_path, submissions=conversation.submissions) def thread_presented(self, conversation: Conversation) -> None: conversation.run_worker( diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py new file mode 100644 index 000000000..4757fd408 --- /dev/null +++ b/src/toad/submission_queue.py @@ -0,0 +1,205 @@ +"""Source-owned queue capabilities; the prompt is their one presentation consumer. + +These are read-only views of existing attachments, transport and notification +proofs. They never reserve input, keep membership, or turn BUS references into +ACP request identities. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from acp.exceptions import RequestError +from agent_comms.acp_extension import NoQueueScope, QueueItem +from agent_comms.message_reference import MessageReference +from agent_comms.ui_model.observation import ReadNotifications +from textual import on +from textual.containers import HorizontalGroup, Vertical, VerticalScroll +from textual.message import Message +from textual.widgets import Button, Static + +from toad.line_blocks import MutedRole, Span, SpanColumns +from toad.notification_status import DetailLines, NotificationStatus +from toad.screens.modal import DismissibleScreen +from toad.widgets.tool_content import ToolContent + +if TYPE_CHECKING: + from agent_comms.acp_extension import QueueScope + from toad.conversation_submission import ConversationSubmissions + + +class QueueEvidenceChanged(Message): + """Local repaint after existing notification/viewport evidence changes.""" + + +@dataclass(frozen=True) +class MessageQueueEntry: + request: ReadNotifications + status: NotificationStatus + + @property + def reference(self) -> MessageReference: + return self.request.references[0] + + @property + def summary(self) -> str: + return f"{self.reference.seq}: {self.status.queue_title}" + + @property + def details(self) -> DetailLines: + return ( + (Span(f"{self.request.root} · {self.reference.seq}/{self.reference.message_id}", MutedRole),), + *self.status.queue_details, + ) + + +class QueueSource: + """An absent source grants neither queue membership nor mutation authority.""" + + preview_limit = 3 + + def queue_summary(self, owner: ConversationSubmissions) -> str: + return "Queue unavailable" + + def queue_details(self, owner: ConversationSubmissions) -> DetailLines: + return ((Span("No queue observation is available for this source.", MutedRole),),) + + +class ReadOnlyQueueSource(QueueSource): + """A source may expose delivery evidence without granting native actions.""" + + def queue_scope(self, owner: ConversationSubmissions) -> QueueScope: + return NoQueueScope() + + def queue_actions_available(self, owner: ConversationSubmissions) -> bool: + return False + + async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: + raise ValueError("Send now unavailable: this source has no native queue authority") + + async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: + raise ValueError("Clear unavailable: BUS messages cannot be withdrawn through an ACP queue") + + +class NativeQueueSource(QueueSource): + """Native queue presentation and actions borrow the actual ACP attachment.""" + + def queue_summary(self, owner: ConversationSubmissions) -> str: + projection = owner.queue_projection + feedback = projection.feedback(owner.view.agent_binding.presentation.queue_supported) + parts = [feedback] if feedback else [] + for label, texts, detail in ( + ("Submitting", tuple(QueueItem.summarize(text) for text in owner.delivering), ""), + ("Queued", tuple(row.preview for row in projection.items), ""), + ("Restored", tuple(row.preview for row in projection.restored), ", read-only"), + ): + if texts: + parts.append(f"{label} ({len(texts)}{detail}): " + " · ".join(texts[:self.preview_limit])) + return " | ".join(parts) + + def queue_details(self, owner: ConversationSubmissions) -> DetailLines: + agent = owner.view.agent_binding + projection = owner.queue_projection + lines = [(Span("Submitting is local intent; Queued is producer acceptance, not consumption.", MutedRole),)] + lines.extend((Span(f"Submitting: {QueueItem.summarize(text)}", MutedRole),) + for text in owner.delivering) + lines.extend((Span(f"Queued {row.input_id}: {row.preview}", MutedRole),) + for row in projection.items) + lines.extend((Span(f"Restored {row.input_id} (read-only): {row.preview}", MutedRole),) + for row in projection.restored) + lines.append((Span(agent.presentation.queue_identity, MutedRole),)) + return tuple(lines) + + def queue_scope(self, owner: ConversationSubmissions) -> QueueScope: + return owner.view.agent_binding.presentation.queue_scope + + def queue_actions_available(self, owner: ConversationSubmissions) -> bool: + return owner.view.agent_binding.presentation.queue_actions_available + + def require_queue_action(self, owner: ConversationSubmissions, scope: QueueScope): + return owner.view.agent_binding.presentation.require_queue_action(scope) + + async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: + agent = self.require_queue_action(owner, scope) + if not await agent.controller.send_now(): + raise ValueError("The native owner did not accept send now") + + async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: + await self.require_queue_action(owner, scope).controller.clear_queue() + + +class BusQueueSource(ReadOnlyQueueSource): + """BUS views borrow original-reference/per-recipient evidence, not ACP rows.""" + + def queue_summary(self, owner: ConversationSubmissions) -> str: + entries = owner.message_queue_entries + transport = owner.view.send_block_reason + evidence = " · ".join(entry.summary for entry in entries[:self.preview_limit]) + coverage = f"Delivery ({len(entries)} visible messages)" + return " | ".join(part for part in (transport, coverage + (f": {evidence}" if evidence else ": unobserved")) if part) + + def queue_details(self, owner: ConversationSubmissions) -> DetailLines: + return ( + (Span("Visible original message references only; this is not an ACP queue or a complete inbox count.", MutedRole),), + (Span(owner.view.send_block_reason, MutedRole),), + (Span(f"Showing up to {self.preview_limit} visible messages and their leading observed recipient; not complete recipient coverage.", MutedRole),), + *(line for entry in owner.message_queue_entries[:self.preview_limit] for line in entry.details), + (Span("Clear/send-now unavailable: this BUS source has no native queue authority.", MutedRole),), + ) + + +class QueueInspection(DismissibleScreen[None]): + """One inspect/action surface for native, channel and bridge composers.""" + + DEFAULT_CSS = """ + QueueInspection { align: center middle; background: $background 40%; } + QueueInspection > Vertical { width: 85%; max-width: 110; height: 75%; padding: 1 2; background: $surface; } + QueueInspection VerticalScroll { height: 1fr; } + QueueInspection HorizontalGroup { height: auto; } + QueueInspection #queue-error { height: auto; color: $error; } + """ + + def __init__(self, owner: ConversationSubmissions): + super().__init__() + self.submissions = owner + self.source = owner.queue_source + self.agent = owner.view.agent_binding + self.scope = self.source.queue_scope(owner) + + def compose(self): + with Vertical(): + yield Static("Queue and delivery") + with VerticalScroll(): + details = ToolContent() + details.update((SpanColumns(self.source.queue_details(self.submissions)),)) + yield details + yield Static("Individual edit/delete/reorder requires ID-targeted, revision-checked backend support.") + yield Static("", id="queue-error") + with HorizontalGroup(): + available = self.source.queue_actions_available(self.submissions) + yield Button("Send now", id="queue-send-now", disabled=not available) + yield Button("Clear queued inputs", id="queue-clear", disabled=not available) + yield Button("Close", id="queue-close") + + async def apply_action(self, action): + try: + if self.submissions.queue_source is not self.source or self.submissions.view.agent_binding is not self.agent: + raise ValueError("The source changed; inspect its current queue again") + await action(self.submissions, self.scope) + except (ValueError, RequestError) as error: + self.query_one("#queue-error", Static).update(str(error)) + return + self.submissions.publish_pending() + self.dismiss() + + @on(Button.Pressed, "#queue-send-now") + async def send_now(self): + await self.apply_action(self.source.queue_send_now) + + @on(Button.Pressed, "#queue-clear") + async def clear_queue(self): + await self.apply_action(self.source.queue_clear) + + @on(Button.Pressed, "#queue-close") + def close_queue(self): + self.dismiss() diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 6738dda57..b643021e7 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -290,6 +290,12 @@ def close(self) -> None: class PreparedPageSource(ABC): + @property + def queue_source(self): + from toad.submission_queue import ReadOnlyQueueSource + + return ReadOnlyQueueSource() + """One lifetime-owned source of prepared pages, independent of widget paging.""" runtime: PreparationRuntime diff --git a/src/toad/widgets/channel_prompt.py b/src/toad/widgets/channel_prompt.py index a17b86465..b76262f18 100644 --- a/src/toad/widgets/channel_prompt.py +++ b/src/toad/widgets/channel_prompt.py @@ -118,3 +118,13 @@ def dismiss_control(self) -> None: self.mention_list.display = False else: super().dismiss_control() + + +class DraftPrompt(ChannelPrompt): + """Mention-enabled editing without a conversation or delivery authority.""" + + def compose_delivery_controls(self) -> ComposeResult: + return () + + def _update_queue_summary(self) -> None: + pass diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 1caaeca1c..76c454fab 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -81,6 +81,10 @@ def __init__( self.message_contents = Contents(id="contents") self.message_history = WireHistory.empty(view=self, notification_view=self.message_contents) + @property + def queue_source(self): + return self.message_history.page_source.queue_source + def transcript_binding(self) -> ViewBinding: """A wire conversation has no native transcript to publish.""" return NoView() @@ -96,7 +100,7 @@ def compose(self) -> ComposeResult: with containers.Vertical(id="prompt-stack"): yield from self.conversation.compose_activity(self) yield self.throbber_above - yield self.conversation.prompt(self.project_path).data_bind( + yield self.conversation.prompt(self.project_path, submissions=self.submissions).data_bind( project_path=Conversation.project_path, working_directory=Conversation.working_directory, agent_info=Conversation.agent_info, diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 47c98594d..6d8f3e61e 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -42,6 +42,7 @@ from toad.acp.status import StopReason from toad.agent import AgentBase from toad.agent_binding import AgentConnection, NoAgentConnection +from toad.submission_queue import QueueEvidenceChanged from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.conversation_actions import ConversationAction, WindowAction @@ -1622,22 +1623,20 @@ async def compact_context(self, instructions: str | None) -> None: await self.agent_binding.compact_context(instructions) self.feedback.flash("Context compacted", style="success") - def open_queue_menu(self) -> None: - """Remote edits require exact input IDs and backend revision-CAS support.""" - if self.submissions.queue_projection.items: - self._queue_edit_unavailable() - - def _queue_edit_unavailable(self) -> None: - self.feedback.flash( - "Remote queue editing unavailable: requires ID-targeted, revision-checked backend support", - style="error", - ) + @property + def queue_source(self): + return self.operational_sources + + def queue_evidence_changed(self) -> None: + self.submissions.publish_pending() - def _on_queue_choice(self, action: str | None) -> None: - # A stale menu callback must not edit the draft or mutate the remote - # queue. Text/index matching cannot identify a sent input safely. - if action: - self._queue_edit_unavailable() + @on(QueueEvidenceChanged) + def queue_observation_changed(self, event: QueueEvidenceChanged) -> None: + event.stop() + self.queue_evidence_changed() + + def open_queue_menu(self) -> None: + self.submissions.inspect_queue() @handles(PreferenceChanged) async def _settings_changed(self, event: PreferenceChanged) -> None: diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index d5a2fe5d5..a4538b942 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -12,6 +12,7 @@ from textual.widgets import Collapsible from agent_comms.ui_model.observation import ReadNotifications +from toad.submission_queue import QueueEvidenceChanged from toad.notification_status import ( CheckedNotifications, NotificationStatus, UnavailableNotifications, UncheckedNotifications, @@ -119,6 +120,7 @@ def check_notifications(self) -> None: references = tuple(dict.fromkeys( reference for fragment in fragments for reference in fragment.notification_references)) self.subscribe_notifications(references) + self.post_message(QueueEvidenceChanged()) def notification_read(self, references) -> ReadNotifications: from toad.project_path_owner import ProjectPathOwner @@ -144,6 +146,7 @@ def notifications_observed(self, results) -> None: async def _show_notifications(self, results): for target in self.notification_targets(): await target.show_notifications(results) + self.post_message(QueueEvidenceChanged()) def notifications_failed(self, failure: Exception) -> None: if self.can_draw_on_screen: @@ -153,6 +156,7 @@ def notifications_failed(self, failure: Exception) -> None: async def _show_notification_error(self, status): for target in self.notification_targets(): await target.show_notification_status(status) + self.post_message(QueueEvidenceChanged()) def on_mount(self) -> None: self.call_after_refresh(self.check_notifications) diff --git a/src/toad/widgets/prompt.py b/src/toad/widgets/prompt.py index 8a9085b77..4d9423e19 100644 --- a/src/toad/widgets/prompt.py +++ b/src/toad/widgets/prompt.py @@ -7,7 +7,6 @@ from agent_comms.repo import Checkout from typing import TYPE_CHECKING, Literal, Self -from agent_comms.acp_extension import QueueItem from rich.text import Text as RichText from textual import containers, events, getters, on, work @@ -666,29 +665,11 @@ def on_mount(self) -> None: self.call_after_refresh(self.sync_queue) def _update_queue_summary(self) -> None: - submissions = self.operational_submissions - projection = submissions.queue_projection - queued = [row.preview for row in submissions.queued_inputs] - restored = [row.preview for row in projection.restored] - feedback = projection.feedback(self.agent_binding.presentation.queue_supported) - delivering = [QueueItem.summarize(text) for text in submissions.delivering] - self.set_class(bool(queued or restored or delivering or feedback), "-has-queue") - parts: list[str] = [] - if feedback: - parts.append(feedback) - for label, texts, detail in ( - ("Submitting", delivering, ""), - ("Queued", queued, ""), - ("Restored", restored, ", read-only"), - ): - if texts: - parts.append( - f"{label} ({len(texts)}{detail}): " - + " · ".join(texts[:3]) - ) + text = self.operational_submissions.queue_summary + self.set_class(bool(text), "-has-queue") summary = self.query_one(".queue-summary", Label) - summary.tooltip = "Submitting is an outstanding local request, not acceptance. Queued is the producer's accepted input, not consumption or completion. Restored rows are not local drafts." - summary.update(Content(" | ".join(parts))) + summary.tooltip = "Inspect this source's actual queue or visible message delivery evidence. Acceptance is not consumption; unsupported actions remain unavailable." + summary.update(Content(text)) @on(messages.SendPromptNow) async def on_send_prompt_now(self, event): @@ -914,9 +895,6 @@ def sync_turn(self) -> None: def sync_configuration(self) -> None: pass - def _update_queue_summary(self) -> None: - pass - def watch_agent_info(self, agent_info: Content) -> None: if self.is_mounted: self.update_prompt() @@ -934,9 +912,6 @@ def update_prompt(self) -> None: def label_glyph(self) -> Content: return PromptLabel.AI - def compose_delivery_controls(self) -> ComposeResult: - return () - def compose_info_popups(self) -> ComposeResult: return () diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 77489a592..464929ac0 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -46,6 +46,7 @@ from toad.menus import MenuItem from toad.message_rows import MarkdownMessageStyle, MessageRow, WireMessageStyle from toad.notification_status import NotificationStatus, UncheckedNotifications +from toad.submission_queue import MessageQueueEntry from toad.widgets.agent_activity import AgentActivityPresentation from toad.widgets.transcript_lines import AcquiredRows, NoRows, RowAcquisition @@ -340,6 +341,9 @@ def shown_live_by(self, claim: CommitClaim) -> bool: def notification_references(self) -> tuple[MessageReference, ...]: return () + def queue_entries(self, notification_view): + return () + def draws(self, message: BusMessage) -> bool: """Whether this fragment draws that bus message; only bus-message fragments do.""" return False @@ -516,6 +520,10 @@ def has_details(self) -> bool: def notification_references(self) -> tuple[MessageReference, ...]: return self.part_position.notification_references(self.events[0]) + def queue_entries(self, notification_view): + return tuple(MessageQueueEntry(notification_view.notification_read((reference,)), self.shown.status) + for reference in self.notification_references) + def draws(self, message: BusMessage) -> bool: return self.events[0].source == message.reference diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 93e9d610c..e6f7afacd 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -50,6 +50,7 @@ ) from toad.widgets.history_anchor import LatestPosition from toad.widgets.message_notifications import PaintedRead, ReadWork +from toad.submission_queue import BusQueueSource from toad.widgets.transcript_fragments import ( BusMessageFragment, MessageShown, @@ -282,7 +283,7 @@ def leading(self): @dataclass(frozen=True, eq=False) -class WireSource: +class WireSource(BusQueueSource): """One conversation as one viewer reads it from Core, and how its messages are styled. A page read to the newest messages is short so that it is drawn at once; @@ -409,6 +410,10 @@ def __init__(self, *args, revision: ConversationObservation, **kwargs): super().__init__(*args, **kwargs) self.revision = revision + @property + def queue_source(self): + return self.loader + def replacement(self, through) -> WirePageBuffer: return type(self)(self.loader, through, self.runtime, revision=self.revision) diff --git a/tests/textual/test_common_queue.py b/tests/textual/test_common_queue.py new file mode 100644 index 000000000..089285510 --- /dev/null +++ b/tests/textual/test_common_queue.py @@ -0,0 +1,73 @@ +"""Common queue chrome uses real source references and never grants BUS ACP actions. + +These controls use the private wire, real observer, prompt and submission owner. +No product monkeypatches and no provider input are needed. +""" +import pytest +from agent_comms.acp_extension import NoQueueScope +from toad.session_presentation import MailboxSessionSources +from toad.widgets.comms_chat import CommsChatView +from toad.widgets.prompt import QueueSummary +from textual.widgets import Button + + +async def open_channel(ui, viewer): + return await ui.open_channel("#team", actor=viewer) + + +async def open_direct(ui, viewer): + return await ui.open_direct("peer", actor=viewer) + + +@pytest.mark.parametrize("open_surface", [open_channel, open_direct]) +async def test_bus_queue_borrows_original_proofs_and_preserves_draft(workspace, open_surface): + workspace.thread("peer", "team") + workspace.thread("recipient", "team") + originals = ( + workspace.comms.messaging.send("peer", "#team", "CHANNEL ORIGINAL").message, + workspace.comms.messaging.send("peer", workspace.viewer, "DIRECT ORIGINAL").message, + ) + async with workspace.app(size=(115, 38)) as ui: + await open_surface(ui, workspace.viewer) + view = ui.app.selected_session.query_one(CommsChatView) + owner = view.submissions + await ui.until(lambda: bool(owner.message_queue_entries), why="visible original-reference evidence") + await ui.write_draft("@peer kept draft") + entries = owner.message_queue_entries + assert all(entry.reference in {message.reference for message in originals} for entry in entries) + assert all(entry.request.root == str(workspace.comms.root) for entry in entries) + assert view.query_one(QueueSummary) in ui.app.screen._compositor.visible_widgets + assert not owner.queue_source.queue_actions_available(owner) + with pytest.raises(ValueError): + await owner.queue_source.queue_clear(owner, NoQueueScope()) + with pytest.raises(ValueError): + await owner.queue_source.queue_send_now(owner, NoQueueScope()) + + previous_screen = ui.app.screen + screen = owner.inspect_queue() + await ui.until(lambda: ui.app.screen is screen, why="shared queue inspection") + assert screen.query_one("#queue-send-now", Button).disabled + assert screen.query_one("#queue-clear", Button).disabled + await ui.textual[1].click("#queue-close") + await ui.until(lambda: ui.app.screen is previous_screen, why="queue inspection closed") + assert ui.draft() == "@peer kept draft" + + workspace.comms.messaging.send("peer", "#team", "LATER CHANNEL ORIGINAL") + workspace.comms.messaging.send("peer", workspace.viewer, "LATER DIRECT ORIGINAL") + previous = {entry.reference for entry in entries} + await ui.until(lambda: bool({entry.reference for entry in owner.message_queue_entries} - previous), + why="source subscription to refresh the existing queue consumer") + + +async def test_mailbox_factory_supplies_same_owner_and_readonly_capability_wins_mro(workspace): + async with workspace.app(size=(110, 35)) as ui: + view = ui.conversation + source = MailboxSessionSources() + prompt = source.prompt(view) + assert prompt.operational_submissions is view.submissions + assert not source.queue_actions_available(view.submissions) + with pytest.raises(ValueError): + await source.queue_send_now(view.submissions, source.queue_scope(view.submissions)) + with pytest.raises(ValueError): + await source.queue_clear(view.submissions, source.queue_scope(view.submissions)) + await source.close() From 9494d869150412c137bc3c666bc812d96524299d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 23:36:02 -0400 Subject: [PATCH 11/22] Declare queue capabilities and remove duplicated title facts --- src/toad/agent_presentation.py | 17 +++++++++---- src/toad/conversation_submission.py | 10 +++++--- src/toad/submission_queue.py | 25 +++++++++++++++---- src/toad/widgets/conversation.py | 4 +-- src/toad/wire_history.py | 2 +- .../test_message_notification_title.py | 2 -- tests/textual/test_message_notifications.py | 10 ++++---- 7 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/toad/agent_presentation.py b/src/toad/agent_presentation.py index 4fa243948..e017da152 100644 --- a/src/toad/agent_presentation.py +++ b/src/toad/agent_presentation.py @@ -1,13 +1,20 @@ """Agent-owned optional capabilities and derived attachment presentation.""" +from __future__ import annotations + +from typing import TYPE_CHECKING from abc import abstractmethod from dataclasses import dataclass from pathlib import Path from agent_comms.declared_family import DeclaredFamily -from agent_comms.acp_extension import NoQueueScope, PendingQueueProjection, QueueProjection +from agent_comms.acp_extension import NoQueueScope, PendingQueueProjection, QueueProjection, QueueScope from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.conversation_turn import TurnBinding, LocalTurnBinding, ManagedTurnBinding +if TYPE_CHECKING: + from toad.acp.agent import Agent + + @dataclass(frozen=True) class AgentAttachmentView: cursor: type[CursorStatus] @@ -39,7 +46,7 @@ def queue(self): """The actual source owns its input queue projection.""" @property - def queue_scope(self): + def queue_scope(self) -> QueueScope: return NoQueueScope() @property @@ -50,7 +57,7 @@ def queue_identity(self) -> str: def queue_actions_available(self) -> bool: return False - def require_queue_action(self, scope): + def require_queue_action(self, scope: QueueScope) -> Agent: raise ValueError("No live ACP queue authority is available") @property @@ -103,7 +110,7 @@ def queue_supported(self) -> bool: return self.agent.coordination is not None @property - def queue_scope(self): + def queue_scope(self) -> QueueScope: return self.agent.queue_attachment.scope @property @@ -120,7 +127,7 @@ def queue_identity(self) -> str: def queue_actions_available(self) -> bool: return self.queue_supported and self.agent.queue_attachment.accepts_request(self.queue_scope) - def require_queue_action(self, scope): + def require_queue_action(self, scope: QueueScope) -> Agent: if not self.queue_supported or not self.agent.queue_attachment.accepts_request(scope): raise ValueError("Queue attachment changed or is unavailable; inspect the current queue again") return self.agent diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 9d2dd56f1..48322d7ad 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -28,6 +28,8 @@ if TYPE_CHECKING: from toad.acp.agent import Agent + from toad.submission_queue import QueueSource, MessageQueueEntry + from agent_comms.ui_model.observation import ReadNotifications from toad.widgets.conversation import Conversation @@ -294,7 +296,7 @@ def accepts_failure(self, message: CoreEventMessage): return self.view.agent_binding.accepts_failed_input(message) @property - def queue_source(self): + def queue_source(self) -> QueueSource: return self.view.queue_source @property @@ -302,9 +304,9 @@ def queue_summary(self) -> str: return self.queue_source.queue_summary(self) @property - def message_queue_entries(self): + def message_queue_entries(self) -> tuple[MessageQueueEntry, ...]: """Borrow the existing visible fragment proofs; never retain another inbox.""" - entries = {} + entries: dict[ReadNotifications, MessageQueueEntry] = {} for target in self.view.contents.notification_targets(): for fragment in target.visible_fragments(): for entry in fragment.queue_entries(target.notification_view): @@ -369,7 +371,7 @@ def send_now(self): group="send-queued-now", exclusive=True, ) - async def _send_now(self, source, agent, scope: QueueScope): + async def _send_now(self, source: QueueSource, agent, scope: QueueScope) -> None: try: if self.queue_source is not source or self.view.agent_binding is not agent: raise ValueError("The source changed before send now") diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index 4757fd408..a280f5ea4 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -6,8 +6,10 @@ """ from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from acp.exceptions import RequestError from agent_comms.acp_extension import NoQueueScope, QueueItem @@ -25,6 +27,7 @@ if TYPE_CHECKING: from agent_comms.acp_extension import QueueScope + from toad.acp.agent import Agent from toad.conversation_submission import ConversationSubmissions @@ -53,10 +56,22 @@ def details(self) -> DetailLines: ) -class QueueSource: +class QueueSource(ABC): """An absent source grants neither queue membership nor mutation authority.""" - preview_limit = 3 + preview_limit: ClassVar[int] = 3 + + @abstractmethod + def queue_scope(self, owner: ConversationSubmissions) -> QueueScope: ... + + @abstractmethod + def queue_actions_available(self, owner: ConversationSubmissions) -> bool: ... + + @abstractmethod + async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: ... + + @abstractmethod + async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: ... def queue_summary(self, owner: ConversationSubmissions) -> str: return "Queue unavailable" @@ -116,7 +131,7 @@ def queue_scope(self, owner: ConversationSubmissions) -> QueueScope: def queue_actions_available(self, owner: ConversationSubmissions) -> bool: return owner.view.agent_binding.presentation.queue_actions_available - def require_queue_action(self, owner: ConversationSubmissions, scope: QueueScope): + def require_queue_action(self, owner: ConversationSubmissions, scope: QueueScope) -> Agent: return owner.view.agent_binding.presentation.require_queue_action(scope) async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: @@ -181,7 +196,7 @@ def compose(self): yield Button("Clear queued inputs", id="queue-clear", disabled=not available) yield Button("Close", id="queue-close") - async def apply_action(self, action): + async def apply_action(self, action: Callable[[ConversationSubmissions, QueueScope], Awaitable[None]]) -> None: try: if self.submissions.queue_source is not self.source or self.submissions.view.agent_binding is not self.agent: raise ValueError("The source changed; inspect its current queue again") diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 6d8f3e61e..e12c50085 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -42,7 +42,7 @@ from toad.acp.status import StopReason from toad.agent import AgentBase from toad.agent_binding import AgentConnection, NoAgentConnection -from toad.submission_queue import QueueEvidenceChanged +from toad.submission_queue import QueueEvidenceChanged, QueueSource from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.conversation_actions import ConversationAction, WindowAction @@ -1624,7 +1624,7 @@ async def compact_context(self, instructions: str | None) -> None: self.feedback.flash("Context compacted", style="success") @property - def queue_source(self): + def queue_source(self) -> QueueSource: return self.operational_sources def queue_evidence_changed(self) -> None: diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index e6f7afacd..a7bc45277 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -411,7 +411,7 @@ def __init__(self, *args, revision: ConversationObservation, **kwargs): self.revision = revision @property - def queue_source(self): + def queue_source(self) -> WireSource: return self.loader def replacement(self, through) -> WirePageBuffer: diff --git a/tests/textual/test_message_notification_title.py b/tests/textual/test_message_notification_title.py index 1bd02b4f3..0d917efe5 100644 --- a/tests/textual/test_message_notification_title.py +++ b/tests/textual/test_message_notification_title.py @@ -38,14 +38,12 @@ async def test_changed_details_and_errors_keep_the_title_layout_but_changed_summ await ui.until(lambda: "waiting-peer: Queued" in " ".join(ui.painted(feedback).split()), why="the changed detail") assert str(feedback.title) == MessageNotification.summary((responding, queued)) assert str(feedback.title) == MessageNotification.summary((responding, waiting)) - assert str(feedback.title) == "Answering 1 · Delivered 2/2 · Consumed 1/2 · Read 0/2" assert feedback.has_class("-working") completed = replace(responding, state=CompletedProgress(ShownToModelProgress(1))) feedback.show(CheckedNotifications((completed, queued))) await ui.step() assert str(feedback.title) == MessageNotification.summary((completed, queued)) assert str(feedback.title) != MessageNotification.summary((responding, queued)) - assert str(feedback.title) == queued.label + " · Delivered 2/2 · Consumed 1/2 · Read 0/2" assert not feedback.has_class("-working") feedback.show(UnavailableNotifications("First read error")) await ui.step() diff --git a/tests/textual/test_message_notifications.py b/tests/textual/test_message_notifications.py index 670fa0bc2..b7d7f5e1a 100644 --- a/tests/textual/test_message_notifications.py +++ b/tests/textual/test_message_notifications.py @@ -91,7 +91,7 @@ async def test_visible_references_are_bounded_and_typed_outcomes_paint_their_det assert 0 < len(request.references) <= len(on_screen) < 30 assert set(request.references) <= on_screen await notifications.publish(ui, chat) - await ui.until_shows("Delivered 45/45 · Consumed 0/45 · Read 0/45") + await ui.until_shows(newest_status(chat).title) assert newest_status(chat).working await ui.click("▶ Sent", occurrence=len(on_screen) - 1) await ui.until_shows("waiting-9: Sent") @@ -110,11 +110,11 @@ async def test_the_line_follows_typed_state_and_read_errors_without_new_messages await settled(ui, chat) notifications.state = ConsumingProgress(0) await notifications.publish(ui, chat) - await ui.until_shows("Answering 1 · Delivered 45/45 · Consumed 1/45 · Read 0/45") + await ui.until_shows(newest_status(chat).title) assert newest_status(chat).working notifications.state = IgnoredProgress(ShownToModelProgress(0)) await notifications.publish(ui, chat) - await ui.until_shows("Seen, not answering 1 · Delivered 45/45 · Consumed 1/45 · Read 0/45") + await ui.until_shows(newest_status(chat).title) assert not newest_status(chat).working access = ui.app.coordination_access request = access.notification_views[chat.message_history.notification_view] @@ -124,7 +124,7 @@ async def test_the_line_follows_typed_state_and_read_errors_without_new_messages assert "fixture busy database" in newest_status(chat).error notifications.state = CompletedProgress(ShownToModelProgress(0)) await notifications.publish(ui, chat) - await ui.until_shows("▶ Answered · Delivered 45/45 · Consumed 1/45 · Read 0/45") + await ui.until_shows(newest_status(chat).title) async def test_a_hidden_view_unsubscribes_and_discards_a_late_reply(workspace): @@ -162,7 +162,7 @@ async def test_markdown_uses_the_same_typed_line_and_an_empty_recorded_result(wo await chat.message_history.toggle_style() await ui.step() await notifications.publish(ui, chat) - await ui.until_shows("▶ Answered · Delivered 45/45 · Consumed 1/45 · Read 0/45") + await ui.until_shows(newest_status(chat).title) notifications.recorded = False await notifications.publish(ui, chat) await ui.until_shows("▶ No recorded notification result") From 0a757e54b666d3c11f64fe034933f19856b3d37b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:05:51 -0400 Subject: [PATCH 12/22] Read queue references from the root-free notification operation --- src/toad/submission_queue.py | 2 +- tests/textual/test_historical_notification_source.py | 6 ++---- tests/textual/test_message_notifications.py | 8 ++++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index a280f5ea4..c4a601330 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -42,7 +42,7 @@ class MessageQueueEntry: @property def reference(self) -> MessageReference: - return self.request.references[0] + return self.request.operation.references[0] @property def summary(self) -> str: diff --git a/tests/textual/test_historical_notification_source.py b/tests/textual/test_historical_notification_source.py index 4959f5871..fcf3798e1 100644 --- a/tests/textual/test_historical_notification_source.py +++ b/tests/textual/test_historical_notification_source.py @@ -2,7 +2,7 @@ from agent_comms.repo import Checkout from agent_comms.historical_views import HistoricalThread -from agent_comms.ui_model.observation import ReadRecordedNotifications +from agent_comms.notification_reads import RecordedNotificationRead from agent_comms.threads import Thread from support.roots import installed_wire @@ -28,9 +28,7 @@ async def test_historical_notification_keeps_selected_source_and_lifetime(worksp 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,) + assert request.operation == RecordedNotificationRead((message.reference,), source.key) await ui.until(lambda: request in access.notification_outcomes, why="the archive's actual observation reply") outcomes = access.notification_outcomes[request] diff --git a/tests/textual/test_message_notifications.py b/tests/textual/test_message_notifications.py index b7d7f5e1a..5a03cdfa4 100644 --- a/tests/textual/test_message_notifications.py +++ b/tests/textual/test_message_notifications.py @@ -35,7 +35,7 @@ async def publish(self, ui, chat): view = chat.message_history.notification_view access = ui.app.coordination_access request = access.notification_views[view] - outcomes = self.answer(request.references) + outcomes = self.answer(request.operation.references) expected, = CheckedNotifications.for_references( chat.message_history.visible_fragments()[-1].notification_references, outcomes) access.notifications_observed(NotificationsObserved(access.revision, request, outcomes), access.observation.observed()) @@ -88,8 +88,8 @@ async def test_visible_references_are_bounded_and_typed_outcomes_paint_their_det request = ui.app.coordination_access.notification_views[history.notification_view] on_screen = {reference for fragment in history.visible_fragments() for reference in fragment.notification_references} - assert 0 < len(request.references) <= len(on_screen) < 30 - assert set(request.references) <= on_screen + assert 0 < len(request.operation.references) <= len(on_screen) < 30 + assert set(request.operation.references) <= on_screen await notifications.publish(ui, chat) await ui.until_shows(newest_status(chat).title) assert newest_status(chat).working @@ -144,7 +144,7 @@ async def test_a_hidden_view_unsubscribes_and_discards_a_late_reply(workspace): await ui.select_tab(owner) assert view not in ui.app.coordination_access.notification_views notifications.state = CompletedProgress(ShownToModelProgress(0)) - view.notifications_observed(notifications.answer(request.references)) + view.notifications_observed(notifications.answer(request.operation.references)) await ui.step() assert view not in ui.app.coordination_access.notification_views assert {fragment.message.reference: fragment.shown.status From 5f72bcfeb2a334d2485f9b0378dfc539a6b1fae5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:41:16 -0400 Subject: [PATCH 13/22] Retain actual Core display scope through notification observation --- src/toad/notification_status.py | 5 ++- src/toad/screens/historical_sessions.py | 5 +-- src/toad/transcript_preparation.py | 6 ++++ src/toad/widgets/message_notifications.py | 17 ++++++++-- src/toad/wire_history.py | 36 ++++++++++++++++----- tests/textual/test_common_queue.py | 8 +++++ tests/textual/test_message_notifications.py | 3 +- tests/textual/test_wire_source.py | 22 ++++++++++++- 8 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/toad/notification_status.py b/src/toad/notification_status.py index 17fccce8d..4ea8184fa 100644 --- a/src/toad/notification_status.py +++ b/src/toad/notification_status.py @@ -98,7 +98,7 @@ def queue_title(self) -> str: def queue_details(self) -> DetailLines: leading = CheckedNotifications(self.notifications[:1]) return ( - (Span(f"{len(self.notifications)} observed recipients; leading result shown", MutedRole),), + (Span(self.title, MutedRole),), *((Span(f"Recipient {item.recipient_identity.recipient_lookup}", MutedRole),) for item in leading.notifications), *leading.details, @@ -116,8 +116,7 @@ def details(self) -> DetailLines: Span(reader.name, NameRole, OpenTarget(reader.name))) lines.append(line) return (tuple(lines) if lines else ( - (Span("No notification result is recorded for this message. " - "This does not confirm the message was received.", MutedRole),),)) + (Span("No recipient feedback to display.", MutedRole),),)) @dataclass(frozen=True) diff --git a/src/toad/screens/historical_sessions.py b/src/toad/screens/historical_sessions.py index 2395cc0a4..6a7d41755 100644 --- a/src/toad/screens/historical_sessions.py +++ b/src/toad/screens/historical_sessions.py @@ -9,7 +9,8 @@ from agent_comms.comms import Comms from agent_comms.coordination_errors import StaleRevision from metaclass_registry.mro_dispatch import handles -from agent_comms.ui_model.observation import ReadTranscript, ReadRecordedNotifications +from agent_comms.ui_model.observation import ReadTranscript +from agent_comms.notification_reads import RecordedNotificationRead from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver from agent_comms.historical_views import HistoricalThread from textual import on, work @@ -29,7 +30,7 @@ class RecordedContents(Contents): """The notification subscriber reads the same archive as its history.""" def __init__(self, source: str, **kwargs): - self.notification_request = partial(ReadRecordedNotifications, source=source) + self.notification_operation = partial(RecordedNotificationRead, source=source) super().__init__(**kwargs) diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index b643021e7..1ab9f2869 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -11,6 +11,7 @@ from weakref import ReferenceType, ref from agent_comms.historical_views import ConversationObservation +from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.sent_message import OutgoingMessageReference from agent_comms.transcripts import ( TranscriptCursor, @@ -78,6 +79,11 @@ def measured(cls, page, fragments: tuple[TranscriptFragment, ...], **view): prepared.measure_sources() return prepared + @property + def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: + """Native transcript pages have no captured BUS display selection.""" + return NoDisplaySelection() + @property def source_payload_bytes(self) -> int: return sum(256 + 4 * event.text_size for event in self.page.events) diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index a4538b942..4d5ce9f40 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -12,6 +12,8 @@ from textual.widgets import Collapsible from agent_comms.ui_model.observation import ReadNotifications +from agent_comms.notification_reads import LiveNotificationRead +from agent_comms.read_basis import NoDisplaySelection from toad.submission_queue import QueueEvidenceChanged from toad.notification_status import ( @@ -111,7 +113,7 @@ class HistoryNotifications: """Visible history rows subscribe to Core; paging never decides when outcomes changed.""" NOTIFICATION_GROUP = "message-notifications" - notification_request = ReadNotifications + notification_operation = LiveNotificationRead # Core feedback for every visible bus message, native or wire. @@ -128,7 +130,12 @@ def notification_read(self, references) -> ReadNotifications: root = ProjectPathOwner.containing(self).coordination_root if root is None: raise ValueError("Notification observation requires a bound thread root") - return self.notification_request(root, references) + references = tuple(references) + wanted = frozenset(references) + display = next((target.notification_identity_for(wanted) + for target in self.notification_targets() + if wanted.intersection(target.notification_references)), NoDisplaySelection()) + return ReadNotifications(root, self.notification_operation(references), display=display) def subscribe_notifications(self, references) -> None: access = self.app.coordination_access @@ -174,6 +181,12 @@ def on_show(self) -> None: class SourceNotifications: notification_view: HistoryNotifications + def notification_identity_for(self, references): + """Use retained prepared Core pages, even after the pending paint was retired.""" + return next((page.prepared.notification_identity for page in reversed(self.pages) + if any(references.intersection(fragment.notification_references) + for fragment in page.prepared.fragments_in_range)), NoDisplaySelection()) + def rows_published(self) -> None: super().rows_published() self.notification_view.call_after_refresh(self.notification_view.check_notifications) diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index a7bc45277..7e00e0383 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -23,6 +23,7 @@ ConversationRevision, ) from agent_comms.message_page import MessagePage +from agent_comms.read_basis import NoDisplaySelection from agent_comms.messages import ConversationRole, Membership, Message, NoticeRole from agent_comms.page_traversal import ( LiveCursor, @@ -103,6 +104,13 @@ class AfterMessage(WireCut): bounded = True kept = True + @property + def undrawn(self) -> bool: + return False + + def observed(self) -> AfterMessage: + return self + @dataclass(frozen=True) class AfterSequence(AfterMessage): @@ -128,6 +136,13 @@ class NothingRead(AfterSequence): cursor: LiveCursor = field(default_factory=lambda: LiveCursor(0)) + @property + def undrawn(self) -> bool: + return True + + def observed(self) -> AfterSequence: + return AfterSequence(self.cursor) + @dataclass(frozen=True) class WirePage: @@ -138,21 +153,21 @@ class WirePage: after: AfterMessage has_older: bool has_newer: bool - source: MessagePage | None + source: MessagePage @classmethod def unread(cls) -> WirePage: - """What is drawn before the first read: nothing, ending before any answer.""" - return cls((), None, NothingRead(), False, False, None) + """The declared unread cursor carries an empty, unscoped placeholder.""" + return cls((), None, NothingRead(), False, False, + MessagePage((), False, False, NoDisplaySelection())) @property def undrawn(self) -> bool: - return self.source is None + return self.after.undrawn def accepts(self, page: MessagePage, view: type[ConversationHistoryView]) -> bool: """An initial placeholder accepts its first scope; Core compares actual pages.""" - source = self.source - return source is None or view.same_display(page, source) + return self.undrawn or view.same_display(page, self.source) def advanced(self, interval: WirePage, *, older: bool) -> WirePage: """An empty interval advances its cut and establishes the first Core page. @@ -162,7 +177,8 @@ def advanced(self, interval: WirePage, *, older: bool) -> WirePage: """ source = interval.source if self.undrawn else self.source return ( - replace(self, before=interval.before, has_older=interval.has_older, source=source) + replace(self, before=interval.before, after=self.after.observed(), + has_older=interval.has_older, source=source) if older else replace(self, after=interval.after, has_newer=interval.has_newer, source=source) ) @@ -171,6 +187,10 @@ def advanced(self, interval: WirePage, *, older: bool) -> WirePage: class PreparedWirePage(PreparedTranscriptPage): """Wire intervals preserve Core's original display page when advanced empty.""" + @property + def notification_identity(self): + return self.page.source.selection.notification_identity + def advance_empty(self, prepared: PreparedTranscriptPage, older: bool) -> None: self.page = self.page.advanced(prepared.page, older=older) @@ -330,7 +350,7 @@ def read(self, request: PageRequest[WireCut], through: AfterSequence) -> Prepare wire = WirePage( page.messages, BeforeMessage(page.messages[0].view_cursor) if page.messages else None, - AfterMessage(page.messages[-1].view_cursor) if page.has_newer and page.messages else through, + AfterMessage(page.messages[-1].view_cursor) if page.has_newer and page.messages else through.observed(), page.has_older and bool(page.messages), page.has_newer and bool(page.messages), page, diff --git a/tests/textual/test_common_queue.py b/tests/textual/test_common_queue.py index 089285510..514c35667 100644 --- a/tests/textual/test_common_queue.py +++ b/tests/textual/test_common_queue.py @@ -36,6 +36,11 @@ async def test_bus_queue_borrows_original_proofs_and_preserves_draft(workspace, entries = owner.message_queue_entries assert all(entry.reference in {message.reference for message in originals} for entry in entries) assert all(entry.request.root == str(workspace.comms.root) for entry in entries) + captured = {page.prepared.notification_identity for page in view.message_history.pages} + human = workspace.comms.registry.require(workspace.viewer).incarnation + assert all(entry.request.display in captured for entry in entries) + assert all(entry.request.display.viewer == human for entry in entries) + assert not view.message_history.undrawn assert view.query_one(QueueSummary) in ui.app.screen._compositor.visible_widgets assert not owner.queue_source.queue_actions_available(owner) with pytest.raises(ValueError): @@ -57,6 +62,9 @@ async def test_bus_queue_borrows_original_proofs_and_preserves_draft(workspace, previous = {entry.reference for entry in entries} await ui.until(lambda: bool({entry.reference for entry in owner.message_queue_entries} - previous), why="source subscription to refresh the existing queue consumer") + captured = {page.prepared.notification_identity for page in view.message_history.pages} + assert all(entry.request.display in captured for entry in owner.message_queue_entries) + assert ui.draft() == "@peer kept draft" async def test_mailbox_factory_supplies_same_owner_and_readonly_capability_wins_mro(workspace): diff --git a/tests/textual/test_message_notifications.py b/tests/textual/test_message_notifications.py index 5a03cdfa4..7097802ba 100644 --- a/tests/textual/test_message_notifications.py +++ b/tests/textual/test_message_notifications.py @@ -165,4 +165,5 @@ async def test_markdown_uses_the_same_typed_line_and_an_empty_recorded_result(wo await ui.until_shows(newest_status(chat).title) notifications.recorded = False await notifications.publish(ui, chat) - await ui.until_shows("▶ No recorded notification result") + await ui.until_shows(newest_status(chat).title) + assert newest_status(chat).notifications == () diff --git a/tests/textual/test_wire_source.py b/tests/textual/test_wire_source.py index 59635665e..c4e08a650 100644 --- a/tests/textual/test_wire_source.py +++ b/tests/textual/test_wire_source.py @@ -15,7 +15,10 @@ from toad.transcript_preparation import PageRequest, Unchanged from toad.widgets.presentation_window import OLDER_END, NEWER_END from toad.message_rows import IrcMessageStyle -from toad.wire_history import AfterSequence, GrowingWireObservation, MessageFragment, ReplacedWireObservation, WireSource +from toad.wire_history import ( + AfterSequence, GrowingWireObservation, MessageFragment, NothingRead, + PreparedWirePage, ReplacedWireObservation, WirePage, WireSource, +) def channel_source(comms, target, project): @@ -32,6 +35,23 @@ def observed(source): return first, newest +def test_empty_first_read_keeps_its_real_identity_after_the_unread_placeholder(tmp_path): + comms = installed_wire(tmp_path / 'wire') + comms.registry.declare(Thread('peer', frozenset({'team'}), Checkout(tmp_path))) + source = channel_source(comms, '#team', tmp_path) + placeholder = PreparedWirePage.empty(WirePage.unread()) + assert placeholder.page.undrawn and not placeholder.page.source.selection.has_evidence + initial = NothingRead() + prepared = source.read(PageRequest(OLDER_END, initial), initial) + assert not prepared.fragments and not prepared.page.undrawn + identity = prepared.page.source.selection.notification_identity + assert prepared.notification_identity == identity + assert identity.viewer == comms.registry.require(source.viewer.name).incarnation + assert placeholder.page.accepts(prepared.page.source, source.conversation.history_view) + placeholder.advance_empty(prepared, older=True) + assert not placeholder.page.undrawn and placeholder.notification_identity == identity + + def test_a_turn_claim_on_the_peer_keeps_the_direct_message_page_identity(tmp_path): comms = installed_wire(tmp_path / "wire") comms.registry.declare(Thread("peer", frozenset(), Checkout(tmp_path), process_identity=ProcessIdentity.capture(os.getpid()))) From 4debca4df7854894ceef628dfcd457bd6b4aa7fb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 00:52:41 -0400 Subject: [PATCH 14/22] Close captured viewer and matched empty-feedback controls --- src/toad/widgets/message_notifications.py | 9 +++-- src/toad/wire_history.py | 4 +- tests/textual/test_common_queue.py | 46 ++++++++++++++++++++--- tests/textual/test_wire_source.py | 3 +- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index 4d5ce9f40..0e71542f8 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Awaitable +from collections.abc import Awaitable, Iterable from dataclasses import dataclass, replace from typing import TYPE_CHECKING @@ -13,7 +13,8 @@ from agent_comms.ui_model.observation import ReadNotifications from agent_comms.notification_reads import LiveNotificationRead -from agent_comms.read_basis import NoDisplaySelection +from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection +from agent_comms.message_reference import MessageReference from toad.submission_queue import QueueEvidenceChanged from toad.notification_status import ( @@ -124,7 +125,7 @@ def check_notifications(self) -> None: self.subscribe_notifications(references) self.post_message(QueueEvidenceChanged()) - def notification_read(self, references) -> ReadNotifications: + def notification_read(self, references: Iterable[MessageReference]) -> ReadNotifications: from toad.project_path_owner import ProjectPathOwner root = ProjectPathOwner.containing(self).coordination_root @@ -181,7 +182,7 @@ def on_show(self) -> None: class SourceNotifications: notification_view: HistoryNotifications - def notification_identity_for(self, references): + def notification_identity_for(self, references: frozenset[MessageReference]) -> DisplayIdentity | NoDisplaySelection: """Use retained prepared Core pages, even after the pending paint was retired.""" return next((page.prepared.notification_identity for page in reversed(self.pages) if any(references.intersection(fragment.notification_references) diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 7e00e0383..af7e6911d 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -23,7 +23,7 @@ ConversationRevision, ) from agent_comms.message_page import MessagePage -from agent_comms.read_basis import NoDisplaySelection +from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.messages import ConversationRole, Membership, Message, NoticeRole from agent_comms.page_traversal import ( LiveCursor, @@ -188,7 +188,7 @@ class PreparedWirePage(PreparedTranscriptPage): """Wire intervals preserve Core's original display page when advanced empty.""" @property - def notification_identity(self): + def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: return self.page.source.selection.notification_identity def advance_empty(self, prepared: PreparedTranscriptPage, older: bool) -> None: diff --git a/tests/textual/test_common_queue.py b/tests/textual/test_common_queue.py index 514c35667..bbd7e47c4 100644 --- a/tests/textual/test_common_queue.py +++ b/tests/textual/test_common_queue.py @@ -3,24 +3,36 @@ These controls use the private wire, real observer, prompt and submission owner. No product monkeypatches and no provider input are needed. """ +from collections.abc import Awaitable, Callable + import pytest +from agent_comms.bus import Bus +from agent_comms.channel_targets import ThreadTarget +from agent_comms.presentation import DisplayOutcomes, MessageNotification +from agent_comms.recorded_bus import RecordedBusIdentity +from support.ui import Ui +from support.workspace import Workspace from agent_comms.acp_extension import NoQueueScope +from toad.notification_status import CheckedNotifications from toad.session_presentation import MailboxSessionSources from toad.widgets.comms_chat import CommsChatView from toad.widgets.prompt import QueueSummary from textual.widgets import Button -async def open_channel(ui, viewer): - return await ui.open_channel("#team", actor=viewer) +async def open_channel(ui: Ui, viewer: str) -> str: + # The tab acts as an agent; the Core page is still read by the actual human. + return await ui.open_channel("#team", actor="peer") -async def open_direct(ui, viewer): +async def open_direct(ui: Ui, viewer: str) -> str: return await ui.open_direct("peer", actor=viewer) @pytest.mark.parametrize("open_surface", [open_channel, open_direct]) -async def test_bus_queue_borrows_original_proofs_and_preserves_draft(workspace, open_surface): +async def test_bus_queue_borrows_original_proofs_and_preserves_draft( + workspace: Workspace, open_surface: Callable[[Ui, str], Awaitable[str]], +) -> None: workspace.thread("peer", "team") workspace.thread("recipient", "team") originals = ( @@ -39,7 +51,9 @@ async def test_bus_queue_borrows_original_proofs_and_preserves_draft(workspace, captured = {page.prepared.notification_identity for page in view.message_history.pages} human = workspace.comms.registry.require(workspace.viewer).incarnation assert all(entry.request.display in captured for entry in entries) - assert all(entry.request.display.viewer == human for entry in entries) + namespace = RecordedBusIdentity.capture(Bus.path_in(workspace.comms.root)) + assert all(entry.request.display.outcome_viewer() == DisplayOutcomes(human, namespace) + for entry in entries) assert not view.message_history.undrawn assert view.query_one(QueueSummary) in ui.app.screen._compositor.visible_widgets assert not owner.queue_source.queue_actions_available(owner) @@ -67,7 +81,7 @@ async def test_bus_queue_borrows_original_proofs_and_preserves_draft(workspace, assert ui.draft() == "@peer kept draft" -async def test_mailbox_factory_supplies_same_owner_and_readonly_capability_wins_mro(workspace): +async def test_mailbox_factory_supplies_same_owner_and_readonly_capability_wins_mro(workspace: Workspace) -> None: async with workspace.app(size=(110, 35)) as ui: view = ui.conversation source = MailboxSessionSources() @@ -79,3 +93,23 @@ async def test_mailbox_factory_supplies_same_owner_and_readonly_capability_wins_ with pytest.raises(ValueError): await source.queue_clear(view.submissions, source.queue_scope(view.submissions)) await source.close() + + +def test_matched_empty_feedback_is_not_a_missing_original(workspace: Workspace) -> None: + workspace.thread('peer', 'team') + comms = workspace.comms + human = comms.registry.require(workspace.viewer) + first = comms.messaging.send('peer', human.name, 'matched original').message + missing = comms.messaging.send('peer', human.name, 'not in this result window').message + page = comms.views.dm_display_page(ThreadTarget('peer'), worktree=human.worktree) + comms.views.mark_page_read(page, worktree=human.worktree) + document = comms.bus.reads.read() + window = comms.views.message_notifications_for_references( + page.selection.notification_identity.outcome_viewer(), (first.reference,), + ) + checked, = CheckedNotifications.for_references((first.reference,), window.outcomes) + assert checked.notifications == () + assert not checked.unavailable and not checked.working + assert checked.title == MessageNotification.summary(checked.notifications) + assert tuple(CheckedNotifications.for_references((missing.reference,), window.outcomes)) == () + assert comms.bus.reads.read() == document diff --git a/tests/textual/test_wire_source.py b/tests/textual/test_wire_source.py index c4e08a650..f29c6b420 100644 --- a/tests/textual/test_wire_source.py +++ b/tests/textual/test_wire_source.py @@ -1,6 +1,7 @@ """Textual-facing: the presentation window; a wire source pages one conversation and notices when Core replaces its reading.""" import os +from pathlib import Path from agent_comms.repo import Checkout from agent_comms.channel_targets import ChannelTarget, ThreadTarget @@ -35,7 +36,7 @@ def observed(source): return first, newest -def test_empty_first_read_keeps_its_real_identity_after_the_unread_placeholder(tmp_path): +def test_empty_first_read_keeps_its_real_identity_after_the_unread_placeholder(tmp_path: Path) -> None: comms = installed_wire(tmp_path / 'wire') comms.registry.declare(Thread('peer', frozenset({'team'}), Checkout(tmp_path))) source = channel_source(comms, '#team', tmp_path) From 27999b1ba0216f290478f0e637b5e81e636fd2a6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:17:21 -0400 Subject: [PATCH 15/22] Keep queue refusals typed and source identity lifetime-owned --- src/toad/acp/agent_controller.py | 8 +- src/toad/agent_presentation.py | 5 +- src/toad/conversation_submission.py | 8 +- src/toad/notification_status.py | 24 ++++- src/toad/submission_queue.py | 21 ++-- src/toad/transcript_preparation.py | 13 ++- tests/support/peers/refuses_queue.py | 15 +++ tests/textual/test_common_queue.py | 13 ++- tests/textual/test_queue_action_boundary.py | 114 ++++++++++++++++++++ tests/textual/test_wire_message_lines.py | 2 +- 10 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 tests/support/peers/refuses_queue.py create mode 100644 tests/textual/test_queue_action_boundary.py diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index 91057609d..a96c8fa13 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -32,6 +32,8 @@ from toad.plan import PlanItem from toad.surface_binding import DetachedSurfaceBinding, SurfaceBinding +from toad.submission_queue import QueueActionRefused + from .prompt import build as build_prompt from .session_output import SessionOutput from .terminal_owner import OperationalTerminalOwner @@ -284,7 +286,7 @@ async def _prompt(self, content, command, authority: ClientSessionRequest, queue def require(): authority.require() if not agent.queue_attachment.accepts_request(queue_scope): - raise ValueError("The queued input owner changed before submission; inspect the current queue.") + raise QueueActionRefused("The queued input owner changed before submission; inspect the current queue.") try: result = await agent.process.prompt( @@ -334,8 +336,8 @@ def _prompt_failed( if not published: report(LogAgentFail(failure.title, failure.feedback, log_path=agent.presentation.log_path)) - async def clear_queue(self) -> None: - await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest()) + async def clear_queue(self) -> bool: + return await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest()) is not None async def send_now(self) -> bool: authority = ClientSessionRequest(self.agent, self.agent.session_id) diff --git a/src/toad/agent_presentation.py b/src/toad/agent_presentation.py index e017da152..200445a20 100644 --- a/src/toad/agent_presentation.py +++ b/src/toad/agent_presentation.py @@ -9,6 +9,7 @@ from agent_comms.acp_extension import NoQueueScope, PendingQueueProjection, QueueProjection, QueueScope from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.conversation_turn import TurnBinding, LocalTurnBinding, ManagedTurnBinding +from toad.submission_queue import QueueActionRefused if TYPE_CHECKING: @@ -58,7 +59,7 @@ def queue_actions_available(self) -> bool: return False def require_queue_action(self, scope: QueueScope) -> Agent: - raise ValueError("No live ACP queue authority is available") + raise QueueActionRefused("No live ACP queue authority is available") @property def prompt_in_flight(self): @@ -129,7 +130,7 @@ def queue_actions_available(self) -> bool: def require_queue_action(self, scope: QueueScope) -> Agent: if not self.queue_supported or not self.agent.queue_attachment.accepts_request(scope): - raise ValueError("Queue attachment changed or is unavailable; inspect the current queue again") + raise QueueActionRefused("Queue attachment changed or is unavailable; inspect the current queue again") return self.agent @property diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 48322d7ad..7b199b684 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -21,6 +21,7 @@ from toad import messages from toad.acp.client_session import ClientSessionRequest from toad.command_catalog import CommandCatalog +from toad.submission_queue import QueueActionRefused from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventMessage from toad.widgets.prompt import Prompt @@ -372,11 +373,12 @@ def send_now(self): ) async def _send_now(self, source: QueueSource, agent, scope: QueueScope) -> None: + if self.queue_source is not source or self.view.agent_binding is not agent: + self.view.feedback.flash("The source changed before send now", style="error") + return try: - if self.queue_source is not source or self.view.agent_binding is not agent: - raise ValueError("The source changed before send now") await source.queue_send_now(self, scope) - except (RequestError, ValueError) as error: + except (RequestError, QueueActionRefused) as error: if self.queue_source is source and self.view.agent_binding is agent: self.view.feedback.flash(f"Send now failed: {error}", style="error") self.publish_pending() diff --git a/src/toad/notification_status.py b/src/toad/notification_status.py index 4ea8184fa..4004d3a81 100644 --- a/src/toad/notification_status.py +++ b/src/toad/notification_status.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import ClassVar, TYPE_CHECKING from agent_comms.message_reference import MessageReference @@ -79,7 +79,12 @@ def for_references(cls, references: Iterable[MessageReference], """Yield nothing when this answer has no matching reference.""" loaded = results.keys() & {(reference.seq, reference.message_id) for reference in references} if loaded: - yield cls(tuple(item for reference in loaded for item in results[reference])) + yield cls.from_notifications(tuple(item for reference in loaded for item in results[reference])) + + @classmethod + def from_notifications(cls, notifications: tuple[MessageNotification, ...]) -> CheckedNotifications: + """Keep a matched empty result distinct from an observed recipient.""" + return cls(notifications) if notifications else EmptyCheckedNotifications() @property def working(self) -> bool: @@ -91,12 +96,13 @@ def title(self) -> str: @property def queue_title(self) -> str: - # Keep each recipient's actual proof label; do not turn routing into arrival. - return " · ".join(f"@{item.recipient} {item.label}" for item in self.notifications[:1]) or "Unobserved" + # Keep the leading recipient's actual proof; routing is not arrival. + first = self.notifications[0] + return f"@{first.recipient} {first.label}" @property def queue_details(self) -> DetailLines: - leading = CheckedNotifications(self.notifications[:1]) + leading = CheckedNotifications.from_notifications(self.notifications[:1]) return ( (Span(self.title, MutedRole),), *((Span(f"Recipient {item.recipient_identity.recipient_lookup}", MutedRole),) @@ -119,6 +125,14 @@ def details(self) -> DetailLines: (Span("No recipient feedback to display.", MutedRole),),)) +@dataclass(frozen=True) +class EmptyCheckedNotifications(CheckedNotifications): + """The original matched, but no recipient feedback is visible to this viewer.""" + + notifications: tuple[()] = field(default=(), init=False) + queue_title = "No recipient feedback" + + @dataclass(frozen=True) class UnavailableNotifications(NotificationStatus): error: str diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index c4a601330..9fda6d5d5 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -31,6 +31,10 @@ from toad.conversation_submission import ConversationSubmissions +class QueueActionRefused(Exception): + """A deliberate queue refusal, not an unexpected programming failure.""" + + class QueueEvidenceChanged(Message): """Local repaint after existing notification/viewport evidence changes.""" @@ -90,10 +94,10 @@ def queue_actions_available(self, owner: ConversationSubmissions) -> bool: return False async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: - raise ValueError("Send now unavailable: this source has no native queue authority") + raise QueueActionRefused("Send now unavailable: this source has no native queue authority") async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: - raise ValueError("Clear unavailable: BUS messages cannot be withdrawn through an ACP queue") + raise QueueActionRefused("Clear unavailable: BUS messages cannot be withdrawn through an ACP queue") class NativeQueueSource(QueueSource): @@ -137,10 +141,12 @@ def require_queue_action(self, owner: ConversationSubmissions, scope: QueueScope async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: agent = self.require_queue_action(owner, scope) if not await agent.controller.send_now(): - raise ValueError("The native owner did not accept send now") + raise QueueActionRefused("The native owner did not accept send now") async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: - await self.require_queue_action(owner, scope).controller.clear_queue() + agent = self.require_queue_action(owner, scope) + if not await agent.controller.clear_queue(): + raise QueueActionRefused("The native owner did not accept clear queue") class BusQueueSource(ReadOnlyQueueSource): @@ -197,11 +203,12 @@ def compose(self): yield Button("Close", id="queue-close") async def apply_action(self, action: Callable[[ConversationSubmissions, QueueScope], Awaitable[None]]) -> None: + if self.submissions.queue_source is not self.source or self.submissions.view.agent_binding is not self.agent: + self.query_one("#queue-error", Static).update("The source changed; inspect its current queue again") + return try: - if self.submissions.queue_source is not self.source or self.submissions.view.agent_binding is not self.agent: - raise ValueError("The source changed; inspect its current queue again") await action(self.submissions, self.scope) - except (ValueError, RequestError) as error: + except (QueueActionRefused, RequestError) as error: self.query_one("#queue-error", Static).update(str(error)) return self.submissions.publish_pending() diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 1ab9f2869..9525348f2 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -21,6 +21,7 @@ from agent_comms.ui_model.message_category import MessageCategory from toad.render_backend import ReusableRenderTask +from toad.submission_queue import QueueSource, ReadOnlyQueueSource from toad.transcript_state import LatestViewportRequest from toad.widgets.history_anchor import LatestPosition from toad.widgets.message_notifications import NothingToRead, PaintedRead @@ -295,15 +296,13 @@ def close(self) -> None: pass -class PreparedPageSource(ABC): - @property - def queue_source(self): - from toad.submission_queue import ReadOnlyQueueSource - - return ReadOnlyQueueSource() - +class PreparedPageSource(ReadOnlyQueueSource): """One lifetime-owned source of prepared pages, independent of widget paging.""" + @property + def queue_source(self) -> QueueSource: + return self + runtime: PreparationRuntime scope: PreparationScope through: TranscriptCursor diff --git a/tests/support/peers/refuses_queue.py b/tests/support/peers/refuses_queue.py new file mode 100644 index 000000000..dcba5a39e --- /dev/null +++ b/tests/support/peers/refuses_queue.py @@ -0,0 +1,15 @@ +"""An external ACP peer refuses a queue request without losing the session.""" + +from acp.exceptions import RequestError + +from base import Peer + + +class RefusesQueuePeer(Peer): + async def prompt(self, session_id, prompt, **kwargs): + self.record(prompt=prompt[0].text) + raise RequestError.invalid_params({"details": "Queue action refused by peer"}) + + +if __name__ == "__main__": + RefusesQueuePeer.main() diff --git a/tests/textual/test_common_queue.py b/tests/textual/test_common_queue.py index bbd7e47c4..84ccfd223 100644 --- a/tests/textual/test_common_queue.py +++ b/tests/textual/test_common_queue.py @@ -13,7 +13,8 @@ from support.ui import Ui from support.workspace import Workspace from agent_comms.acp_extension import NoQueueScope -from toad.notification_status import CheckedNotifications +from toad.notification_status import CheckedNotifications, EmptyCheckedNotifications +from toad.submission_queue import QueueActionRefused from toad.session_presentation import MailboxSessionSources from toad.widgets.comms_chat import CommsChatView from toad.widgets.prompt import QueueSummary @@ -57,9 +58,9 @@ async def test_bus_queue_borrows_original_proofs_and_preserves_draft( assert not view.message_history.undrawn assert view.query_one(QueueSummary) in ui.app.screen._compositor.visible_widgets assert not owner.queue_source.queue_actions_available(owner) - with pytest.raises(ValueError): + with pytest.raises(QueueActionRefused): await owner.queue_source.queue_clear(owner, NoQueueScope()) - with pytest.raises(ValueError): + with pytest.raises(QueueActionRefused): await owner.queue_source.queue_send_now(owner, NoQueueScope()) previous_screen = ui.app.screen @@ -88,9 +89,9 @@ async def test_mailbox_factory_supplies_same_owner_and_readonly_capability_wins_ prompt = source.prompt(view) assert prompt.operational_submissions is view.submissions assert not source.queue_actions_available(view.submissions) - with pytest.raises(ValueError): + with pytest.raises(QueueActionRefused): await source.queue_send_now(view.submissions, source.queue_scope(view.submissions)) - with pytest.raises(ValueError): + with pytest.raises(QueueActionRefused): await source.queue_clear(view.submissions, source.queue_scope(view.submissions)) await source.close() @@ -108,7 +109,9 @@ def test_matched_empty_feedback_is_not_a_missing_original(workspace: Workspace) page.selection.notification_identity.outcome_viewer(), (first.reference,), ) checked, = CheckedNotifications.for_references((first.reference,), window.outcomes) + assert isinstance(checked, EmptyCheckedNotifications) assert checked.notifications == () + assert checked.queue_title == "No recipient feedback" assert not checked.unavailable and not checked.working assert checked.title == MessageNotification.summary(checked.notifications) assert tuple(CheckedNotifications.for_references((missing.reference,), window.outcomes)) == () diff --git a/tests/textual/test_queue_action_boundary.py b/tests/textual/test_queue_action_boundary.py new file mode 100644 index 000000000..c10894f48 --- /dev/null +++ b/tests/textual/test_queue_action_boundary.py @@ -0,0 +1,114 @@ +"""Queue actions keep stale sources and deliberate refusals apart from bugs. + +Use the actual app, source retirement, agent binding and ACP transport. The +unexpected-error callback exercises the inspection's public action boundary; +it neither patches a product owner nor submits input to a model. +""" + +import pytest +from agent_comms.acp_extension import ClearQueueRequest, QueueScope, encode_request +from textual.widgets import Static + +from support.agents import peer +from support.workspace import Workspace +from toad.acp.client_session import ClientSessionRequest +from toad.conversation_submission import ConversationSubmissions +from toad.session_scope import AgentAttached +from toad.widgets.comms_chat import CommsChatView + + +async def unexpected_value_error(owner: ConversationSubmissions, scope: QueueScope) -> None: + raise ValueError("Unexpected action defect") + + +async def test_stale_source_stops_before_the_action_and_closed_source_identity_is_stable( + workspace: Workspace, +) -> None: + workspace.thread("peer", "team") + async with workspace.app(size=(115, 38)) as ui: + await ui.open_channel("#team", actor=workspace.viewer) + view = ui.app.selected_session.query_one(CommsChatView) + owner = view.submissions + await ui.write_draft("kept across source retirement") + inspection = owner.inspect_queue() + await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") + await view.message_history.retire_source() + view.message_history.close() + replacement = view.message_history.page_source + assert owner.queue_source is replacement + assert owner.queue_source is owner.queue_source + assert owner.queue_source is not inspection.source + # If stale checks reach this action, ValueError must fail the control. + await inspection.apply_action(unexpected_value_error) + assert ui.app.screen is inspection + assert inspection.query_one("#queue-error", Static).content + inspection.dismiss() + await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") + assert ui.draft() == "kept across source retirement" + + +async def test_stale_agent_stops_before_inspection_or_send_now_action(workspace: Workspace) -> None: + async with workspace.app(size=(115, 38)) as ui: + owner = ui.conversation.submissions + await ui.write_draft("kept across agent replacement") + source, previous = owner.queue_source, owner.view.agent_binding + scope = source.queue_scope(owner) + inspection = owner.inspect_queue() + await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") + replacement = ui.detached_agent("replacement") + ui.attach_agent(replacement) + assert owner.queue_source is source + assert owner.view.agent_binding is not previous + await inspection.apply_action(unexpected_value_error) + assert ui.app.screen is inspection + assert inspection.query_one("#queue-error", Static).content + await owner._send_now(source, previous, scope) + assert owner.view.feedback.display + assert owner.view.feedback.content + inspection.dismiss() + await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") + assert ui.draft() == "kept across agent replacement" + await replacement.stop() + + +async def test_unsupported_action_stays_visible_and_unrelated_value_error_escapes(workspace: Workspace) -> None: + async with workspace.app(size=(115, 38)) as ui: + owner = ui.conversation.submissions + await ui.write_draft("kept after refusal") + inspection = owner.inspect_queue() + await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") + await inspection.apply_action(inspection.source.queue_clear) + assert ui.app.screen is inspection + assert inspection.query_one("#queue-error", Static).content + with pytest.raises(ValueError, match="Unexpected action defect"): + await inspection.apply_action(unexpected_value_error) + assert ui.app.screen is inspection + inspection.dismiss() + await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") + assert ui.draft() == "kept after refusal" + + +async def test_real_acp_refusal_keeps_inspection_open_without_retry(workspace: Workspace) -> None: + async with workspace.app(size=(115, 38), binding=AgentAttached(peer("refuses_queue"))) as ui: + await ui.until(ui.agent_ready, why="external ACP peer ready") + owner = ui.conversation.submissions + agent = owner.view.agent_binding + authority = ClientSessionRequest(agent, agent.session_id) + await ui.write_draft("kept after ACP refusal") + inspection = owner.inspect_queue() + await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") + + async def refused_at_acp(submissions: ConversationSubmissions, scope: QueueScope) -> None: + # The official SDK returns RequestError from an actual child process. + await agent.process.prompt( + agent, [{"type": "text", "text": " "}], encode_request(ClearQueueRequest()), authority.require, + ) + + await inspection.apply_action(refused_at_acp) + assert ui.app.screen is inspection + assert inspection.query_one("#queue-error", Static).content + assert authority.current + assert workspace.prompts_received() == [" "] + inspection.dismiss() + await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") + assert ui.draft() == "kept after ACP refusal" diff --git a/tests/textual/test_wire_message_lines.py b/tests/textual/test_wire_message_lines.py index d1ec839c4..6851be392 100644 --- a/tests/textual/test_wire_message_lines.py +++ b/tests/textual/test_wire_message_lines.py @@ -60,7 +60,7 @@ def test_rows_fit_the_width_wrap_on_words_and_keep_drawn_names_clickable_at_ever def test_opening_notification_details_draws_more_rows_than_the_collapsed_message(): - status = CheckedNotifications(()) + status = CheckedNotifications.from_notifications(()) for style in (IrcMessageStyle(), MarkdownMessageStyle()): shown = fragment("#all", style) shown.request_show(MessageShown(style=style, status=status)) From 55b055b934f8d3c5fbfa92b86e1e509332c350d7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:19:13 -0400 Subject: [PATCH 16/22] Do not describe an unconfirmed queue action as definitely refused --- src/toad/submission_queue.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index 9fda6d5d5..9f62ef8d5 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -141,12 +141,12 @@ def require_queue_action(self, owner: ConversationSubmissions, scope: QueueScope async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: agent = self.require_queue_action(owner, scope) if not await agent.controller.send_now(): - raise QueueActionRefused("The native owner did not accept send now") + raise QueueActionRefused("Send now was not confirmed; inspect the current queue before another action") async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: agent = self.require_queue_action(owner, scope) if not await agent.controller.clear_queue(): - raise QueueActionRefused("The native owner did not accept clear queue") + raise QueueActionRefused("Clear queue was not confirmed; inspect the current queue before another action") class BusQueueSource(ReadOnlyQueueSource): From d92ac585eb8a27fd3772de2a9606a12b3344c683 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 04:06:36 -0400 Subject: [PATCH 17/22] Read queue viewer projection from its shared display owner --- tests/textual/test_common_queue.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/textual/test_common_queue.py b/tests/textual/test_common_queue.py index 84ccfd223..b74461330 100644 --- a/tests/textual/test_common_queue.py +++ b/tests/textual/test_common_queue.py @@ -8,7 +8,8 @@ import pytest from agent_comms.bus import Bus from agent_comms.channel_targets import ThreadTarget -from agent_comms.presentation import DisplayOutcomes, MessageNotification +from agent_comms.read_basis import DisplayOutcomes +from agent_comms.presentation import MessageNotification from agent_comms.recorded_bus import RecordedBusIdentity from support.ui import Ui from support.workspace import Workspace From 1247a5965065d3f6582ac6592cedd6e3123d94ec Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 11:39:31 -0400 Subject: [PATCH 18/22] Page transcript reads by PagePosition and PageLimit members The page API carried before/after/through as TranscriptCursor | None, restating "no bound" as None. Readers, scopes and windows now pass a PagePosition (LatestPage, EarlierThan, LaterThan) and a PageLimit (AtFrontier, Through); cursors build their own positions, and a changed transcript asks its cursor whether a commit is required. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- src/toad/acp/transcript_reader.py | 25 +++++++--------- src/toad/session_scope.py | 30 ++++++++----------- src/toad/transcript_publication.py | 20 +++++++------ src/toad/widgets/committed_presentation.py | 2 +- src/toad/widgets/presentation_window.py | 6 ++-- tests/app/test_recorded_transcript_reader.py | 10 +++---- tests/native/test_native_reconnect_history.py | 2 +- tests/textual/test_late_live_answer.py | 2 +- 8 files changed, 46 insertions(+), 51 deletions(-) diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index aa5f6fac8..e21a936b1 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -17,6 +17,7 @@ from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate from agent_comms.route_selection import RouteSelection +from agent_comms.transcripts import AtFrontier, LatestPage, PageLimit, PagePosition from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.observation import ( AwaitedRead, @@ -69,16 +70,14 @@ async def page( self, scope: TranscriptScope, *, - before=None, - after=None, - through=None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), historical_source: str | None = None, ): snapshot = await self.snapshot( scope, - before=before, - after=after, - through=through, + position=position, + limit=limit, historical_source=historical_source, ) return snapshot.page @@ -87,16 +86,14 @@ async def snapshot( self, scope: TranscriptScope, *, - before=None, - after=None, - through=None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), historical_source: str | None = None, ) -> TranscriptSnapshotUpdate: return await self.read( scope.snapshot_request( - before=before, - after=after, - through=through, + position=position, + limit=limit, historical_source=historical_source, ) ) @@ -106,6 +103,6 @@ async def updates( scope: TranscriptScope, previous: TranscriptPublicationState, *, - after=None, + position: PagePosition = LatestPage(), ) -> TranscriptSnapshotUpdate | TranscriptDraftUpdate: - return await self.read(scope.update_request(previous, after=after)) + return await self.read(scope.update_request(previous, position=position)) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index a17675a12..5bd79a8a7 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -16,6 +16,7 @@ from agent_comms.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation from agent_comms.order_tools import TranscriptPageRead +from agent_comms.transcripts import AtFrontier, LatestPage, PageLimit, PagePosition from agent_comms.ui_model.observation import ( ReadTranscript, ReadTranscriptUpdate, @@ -36,7 +37,6 @@ if TYPE_CHECKING: from toad.widget_actions import DeclaredWidgetActions from agent_comms.messages import Message - from agent_comms.transcripts import TranscriptCursor from agent_comms.thread_presentation import ThreadPresentation from textual.command import Provider from textual.dom import DOMNode @@ -84,9 +84,8 @@ def observe( def snapshot_request( self, *, - before: TranscriptCursor | None = None, - after: TranscriptCursor | None = None, - through: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), historical_source: str | None = None, ) -> ReadTranscript: ... @@ -95,7 +94,7 @@ def update_request( self, previous: TranscriptPublicationState, *, - after: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), ) -> ReadTranscriptUpdate: ... @abstractmethod @@ -163,16 +162,14 @@ def observe( def snapshot_request( self, *, - before: TranscriptCursor | None = None, - after: TranscriptCursor | None = None, - through: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), historical_source: str | None = None, ) -> ReadTranscript: return ReadTranscript(self.root, TranscriptPageRead( thread=self.name, - before=before, - after=after, - through=through, + position=position, + limit=limit, historical_source=historical_source, )) @@ -180,10 +177,10 @@ def update_request( self, previous: TranscriptPublicationState, *, - after: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), ) -> ReadTranscriptUpdate: return ReadTranscriptUpdate( - self.root, TranscriptPageRead(thread=self.name, after=after), previous=previous + self.root, TranscriptPageRead(thread=self.name, position=position), previous=previous ) def mailbox_target(self) -> ThreadTarget: @@ -264,9 +261,8 @@ def unavailable(self) -> Never: def snapshot_request( self, *, - before: TranscriptCursor | None = None, - after: TranscriptCursor | None = None, - through: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), + limit: PageLimit = AtFrontier(), historical_source: str | None = None, ) -> Never: self.unavailable() @@ -275,7 +271,7 @@ def update_request( self, previous: TranscriptPublicationState, *, - after: TranscriptCursor | None = None, + position: PagePosition = LatestPage(), ) -> Never: self.unavailable() diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 75f7bc1ec..119e72e36 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -28,6 +28,11 @@ from agent_comms.thread_presentation import ThreadPresentation from agent_comms.transcript_events import TranscriptCoverage from agent_comms.transcripts import ( + AtFrontier, + LatestPage, + PageLimit, + PagePosition, + Through, TranscriptCursor, TranscriptPage, TranscriptReadIdentity, @@ -272,7 +277,7 @@ async def publish_snapshot(self, snapshot: TranscriptSnapshotUpdate) -> bool: if bound != page.after: self.owner.dirty = self.owner.commit_required = True page = await self.owner.get_transcript_page( - after=snapshot.identity.after, through=bound + position=snapshot.identity.position.later_only(), limit=Through(bound) ) # Preserve this cut's original cohort across the bounded read. if not await self.application_current(): @@ -425,7 +430,7 @@ async def publish_source_page(self, page: TranscriptPage) -> bool: prepared.history, TranscriptCoverage(page.events).native_inputs, prepared.outgoing, - identity=replace(self.identity, through=page.after), + identity=replace(self.identity, limit=Through(page.after)), ) retired = retirement_candidates(contents.children, evidence) if not plan.permits(view, retired): @@ -799,16 +804,13 @@ def readable(self) -> bool: return self.binding.thread_scope.readable async def get_transcript_page( - self, *, before=None, after=None, - through=None + self, *, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), ) -> TranscriptPage: frame = self.binding.capture(self, BoundFrame) scope = self.binding.thread_scope if frame is None: raise ValueError("Transcript paging requires a bound thread") - page = await frame.binding.reader.page( - scope, before=before, after=after, through=through - ) + page = await frame.binding.reader.page(scope, position=position, limit=limit) if self.binding.thread_scope != scope: raise ValueError("Thread source changed while reading transcript") return page @@ -850,10 +852,10 @@ async def publish(self, kind: type[TranscriptPublication], *args: ThreadPresenta return await publication.publish() return True - def changed(self, cursor: TranscriptCursor | None) -> None: + def changed(self, cursor: TranscriptCursor) -> None: self.invalidate_publications() self.dirty = True - if cursor is None: + if cursor.requires_commit(): self.commit_required = True self.binding.request(self) diff --git a/src/toad/widgets/committed_presentation.py b/src/toad/widgets/committed_presentation.py index 54100346f..82267b020 100644 --- a/src/toad/widgets/committed_presentation.py +++ b/src/toad/widgets/committed_presentation.py @@ -367,7 +367,7 @@ async def read_snapshot[Snapshot]( self, read: Callable[..., Awaitable[Snapshot]], history: CommittedHistory, ) -> Snapshot: try: - return await read(after=history.page_source.through) + return await read(position=history.page_source.through.later_page()) except TranscriptChanged: # A replaced source cannot extend the old reader's source range. return await read() diff --git a/src/toad/widgets/presentation_window.py b/src/toad/widgets/presentation_window.py index 013505056..d62f2077d 100644 --- a/src/toad/widgets/presentation_window.py +++ b/src/toad/widgets/presentation_window.py @@ -176,7 +176,7 @@ def at_boundary(self, window: HistoryWindow) -> bool: def work_cursor[Cursor](self, through: Cursor) -> Cursor | None: return self.edge((None, through)) @abstractmethod - async def load_page(self, loader, cursor: TranscriptCursor | None, through) -> TranscriptPage: ... + async def load_page(self, loader, cursor: TranscriptCursor, through) -> TranscriptPage: ... def progressed(self, page: "TranscriptPage", cursor: TranscriptCursor) -> bool: edge = self.edge((page.before, page.after)) start, stop = self.edge(((edge, cursor), (cursor, edge))) @@ -238,7 +238,7 @@ class OlderEnd(PageEnd): append = staticmethod(deque.appendleft) async def load_page(self, loader, cursor, through) -> TranscriptPage: - return await through.read_page(loader, before=cursor) + return await through.read_page(loader, cursor.earlier_page()) def wire_page(self, source: WireSource, cursor: WireCut, through: AfterSequence) -> MessagePage: return source.conversation.page(source, PageBounds( @@ -274,7 +274,7 @@ def has_more(self, through, page): return super().has_more(through, page) or page.after != through async def load_page(self, loader, cursor, through) -> TranscriptPage: - return await through.read_page(loader, after=cursor) + return await through.read_page(loader, cursor.later_page()) def wire_page(self, source: WireSource, cursor: WireCut, through: AfterSequence) -> MessagePage: page = source.conversation.page(source, PageBounds(cursor.newer(), source.page_limit)) diff --git a/tests/app/test_recorded_transcript_reader.py b/tests/app/test_recorded_transcript_reader.py index 1263b70f9..efa039d2f 100644 --- a/tests/app/test_recorded_transcript_reader.py +++ b/tests/app/test_recorded_transcript_reader.py @@ -10,7 +10,7 @@ from agent_comms.coordination_errors import HistoricalSourceChanged, StaleRevision from agent_comms.order_tools import TranscriptPageRead from agent_comms.session_file import RegistrySessionLocation -from agent_comms.transcripts import RecordedTranscriptReadIdentity +from agent_comms.transcripts import EarlierThan, LaterThan, RecordedTranscriptReadIdentity from agent_comms.threads import Thread from agent_comms.ui_model.observation import NotPublished from support.roots import installed_wire @@ -70,13 +70,13 @@ def test_known_transcript_requests_carry_the_page_operation_and_keep_root_at_the live, source, _native = recorded scope = KnownTranscript(str(live.root), "reader") cursor = live.transcripts.capture_page_read("reader", historical_source=source.key).read().after - snapshot = scope.snapshot_request(before=cursor, historical_source=source.key) + snapshot = scope.snapshot_request(position=EarlierThan(cursor), historical_source=source.key) assert snapshot.root == str(live.root) assert snapshot.operation == TranscriptPageRead( - thread="reader", before=cursor, historical_source=source.key) + thread="reader", position=EarlierThan(cursor), historical_source=source.key) previous = NotPublished() - update = scope.update_request(previous, after=cursor) + update = scope.update_request(previous, position=LaterThan(cursor)) assert update.root == str(live.root) and update.previous is previous - assert update.operation == TranscriptPageRead(thread="reader", after=cursor) + assert update.operation == TranscriptPageRead(thread="reader", position=LaterThan(cursor)) # The shared operation never carries the UI's root, request or publication witness. assert not {"root", "request_id", "previous", "identity"} & {f.name for f in fields(TranscriptPageRead)} diff --git a/tests/native/test_native_reconnect_history.py b/tests/native/test_native_reconnect_history.py index 1aa14d0dd..14641f93b 100644 --- a/tests/native/test_native_reconnect_history.py +++ b/tests/native/test_native_reconnect_history.py @@ -87,7 +87,7 @@ def reply(request, number): await ui.until(return_ready.is_set, seconds=90, why="the hidden native rounds to finish") if hidden_rounds > 1: pending = await view.transcript.get_transcript_page( - after=view.transcript.binding.history.page_source.through) + position=view.transcript.binding.history.page_source.through.later_page()) assert pending.has_newer, 'The native hidden gap must exceed one real source page' await ui.select_tab(tab) assert ui.conversation is view diff --git a/tests/textual/test_late_live_answer.py b/tests/textual/test_late_live_answer.py index de08978ea..f9891a2f0 100644 --- a/tests/textual/test_late_live_answer.py +++ b/tests/textual/test_late_live_answer.py @@ -237,7 +237,7 @@ async def test_returning_to_busy_tab_loads_the_saved_gap(workspace, hidden_count parent=f'hidden-{index - 1}' if index else 'before')) + '\n') hidden = latest_assistant_commit(workspace.comms, "background", journal) pending_page = workspace.comms.transcripts.capture_page_read( - 'background', after=view.transcript.binding.history.page_source.through).read() + 'background', position=view.transcript.binding.history.page_source.through.later_page()).read() assert pending_page.has_newer == (hidden_count > 20) if pending_page.has_newer: assert pending_page.after.offset < hidden.page_bound.offset From 3cb4850b5885f718c6e4b3a150a9cf86087f1345 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:34:28 -0400 Subject: [PATCH 19/22] Make queue UI consume captured evidence and source-owned actions --- src/toad/acp/agent_controller.py | 33 ++--- src/toad/acp/client_session.py | 13 +- src/toad/acp/queue_attachment.py | 4 + src/toad/agent_presentation.py | 14 +- src/toad/conversation_submission.py | 83 +++++------ src/toad/notification_status.py | 11 +- src/toad/screens/main.py | 2 - src/toad/session_presentation.py | 2 +- src/toad/submission_queue.py | 150 ++++++++------------ src/toad/transcript_preparation.py | 24 +--- src/toad/transcript_publication.py | 11 +- src/toad/widgets/comms_chat.py | 11 +- src/toad/widgets/conversation.py | 24 ++-- src/toad/widgets/message_notifications.py | 7 +- src/toad/widgets/presentation_window.py | 32 ++++- src/toad/widgets/transcript_fragments.py | 13 +- src/toad/wire_history.py | 33 ++--- tests/support/peers/refuses_queue.py | 15 -- tests/textual/test_common_queue.py | 119 ---------------- tests/textual/test_queue_action_boundary.py | 114 --------------- 20 files changed, 230 insertions(+), 485 deletions(-) delete mode 100644 tests/support/peers/refuses_queue.py delete mode 100644 tests/textual/test_common_queue.py delete mode 100644 tests/textual/test_queue_action_boundary.py diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index 861d74443..e8c117672 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -26,14 +26,14 @@ from agent_comms.ui_model.delivery import DeliveryView, ReadDeliveryView from agent_comms.ui_model.observation import ReadOwnerOperation -from toad.acp.client_session import ClientSessionRequest +from toad.acp.client_session import ClientSessionRequest, QueueSessionRequest from toad.acp.status import StopReason from toad.core import events as core_events from toad.core.events import LogAgentFail from toad.plan import PlanItem from toad.surface_binding import DetachedSurfaceBinding, SurfaceBinding -from toad.submission_queue import QueueActionRefused +from toad.acp.queue_attachment import QueueActionRefused from .prompt import build as build_prompt from .session_output import SessionOutput @@ -272,9 +272,9 @@ async def _submit( if submission is not None: self._deferred_submissions.discard(submission) - async def submit_blocks(self, content: list[dict[str, str]], command: ClearQueueRequest | None = None): + async def submit_blocks(self, content: list[dict[str, str]], command: PromptRequest): agent = self.agent - authority = ClientSessionRequest(agent, agent.session_id) + authority = QueueSessionRequest(agent, agent.session_id) return await self.operate( self._prompt_operation( partial(self._prompt, content, command, authority, agent.queue_attachment.scope), authority @@ -303,15 +303,15 @@ def require(): report = agent.session.record_failure else: if authority.retired: - return None + return authority.unconfirmed("the captured ACP session retired before its reply") return StopReason.member_named(result.stop_reason) if authority.retired: - return None + return authority.unconfirmed("the captured ACP session retired during the request") failure = request_failure.failure self._prompt_failed( command, authority, queue_scope, failure, report, published=request_failure.notification_published ) - return None + return authority.unconfirmed(failure.feedback) def _prompt_failed( self, @@ -337,22 +337,19 @@ def _prompt_failed( if not published: report(LogAgentFail(failure.title, failure.feedback, log_path=agent.presentation.log_path)) - async def clear_queue(self) -> bool: - return await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest()) is not None + async def clear_queue(self) -> None: + await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest()) - async def send_now(self) -> bool: - authority = ClientSessionRequest(self.agent, self.agent.session_id) + async def send_now(self) -> None: + authority = QueueSessionRequest(self.agent, self.agent.session_id) queue_scope = self.agent.queue_attachment.scope if pending := tuple(self._deferred_submissions): await asyncio.gather(*(asyncio.shield(task) for task in pending)) - return ( - await self.operate( - self._prompt_operation( - partial(self._prompt, [{"type": "text", "text": " "}], SendNowRequest(), authority, queue_scope), - authority, - ) + await self.operate( + self._prompt_operation( + partial(self._prompt, [{"type": "text", "text": " "}], SendNowRequest(), authority, queue_scope), + authority, ) - is not None ) async def compact_context(self, instructions=None): diff --git a/src/toad/acp/client_session.py b/src/toad/acp/client_session.py index dba294c23..d38ec88f0 100644 --- a/src/toad/acp/client_session.py +++ b/src/toad/acp/client_session.py @@ -1,6 +1,7 @@ """One captured session authority for ACP client-side effects.""" from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NoReturn +from .queue_attachment import QueueActionRefused if TYPE_CHECKING: from .agent import Agent @@ -34,6 +35,16 @@ def require(self): if not self.current: raise RequestError.invalid_params({'details': 'ACP client request belongs to a retired session'}) + def unconfirmed(self, reason: str) -> None: + """Ordinary input failure is presented by its existing failure publication.""" + + +class QueueSessionRequest(ClientSessionRequest): + """A queue mutation requires confirmation; an absent reply is never success.""" + + def unconfirmed(self, reason: str) -> NoReturn: + raise QueueActionRefused(f"Queue action not confirmed: {reason}. Inspect the queue before another action.") + class ClientRequestOwner: def __init__(self, agent): diff --git a/src/toad/acp/queue_attachment.py b/src/toad/acp/queue_attachment.py index c1db617d5..81e60896b 100644 --- a/src/toad/acp/queue_attachment.py +++ b/src/toad/acp/queue_attachment.py @@ -14,6 +14,10 @@ from .projection_attachment import ProjectionAttachment +class QueueActionRefused(Exception): + """The captured queue cannot confirm or authorize this action; never replay it.""" + + class QueueAttachment(ProjectionAttachment): def __init__(self): super().__init__() diff --git a/src/toad/agent_presentation.py b/src/toad/agent_presentation.py index 200445a20..127d77205 100644 --- a/src/toad/agent_presentation.py +++ b/src/toad/agent_presentation.py @@ -1,7 +1,6 @@ """Agent-owned optional capabilities and derived attachment presentation.""" from __future__ import annotations -from typing import TYPE_CHECKING from abc import abstractmethod from dataclasses import dataclass from pathlib import Path @@ -9,11 +8,8 @@ from agent_comms.acp_extension import NoQueueScope, PendingQueueProjection, QueueProjection, QueueScope from toad.private_native_cursor import CursorStatus, HiddenCursorStatus from toad.conversation_turn import TurnBinding, LocalTurnBinding, ManagedTurnBinding -from toad.submission_queue import QueueActionRefused - - -if TYPE_CHECKING: - from toad.acp.agent import Agent +from toad.acp.queue_attachment import QueueActionRefused +from toad.slash_command import AgentAdvertisedCommand @dataclass(frozen=True) @@ -58,7 +54,7 @@ def queue_identity(self) -> str: def queue_actions_available(self) -> bool: return False - def require_queue_action(self, scope: QueueScope) -> Agent: + def require_queue_action(self, scope: QueueScope) -> None: raise QueueActionRefused("No live ACP queue authority is available") @property @@ -97,7 +93,6 @@ def auth_methods(self): @property def commands(self): - from toad.slash_command import AgentAdvertisedCommand return tuple(AgentAdvertisedCommand.from_acp(record) for record in self.agent.controller.commands) @@ -128,10 +123,9 @@ def queue_identity(self) -> str: def queue_actions_available(self) -> bool: return self.queue_supported and self.agent.queue_attachment.accepts_request(self.queue_scope) - def require_queue_action(self, scope: QueueScope) -> Agent: + def require_queue_action(self, scope: QueueScope) -> None: if not self.queue_supported or not self.agent.queue_attachment.accepts_request(scope): raise QueueActionRefused("Queue attachment changed or is unavailable; inspect the current queue again") - return self.agent @property def uses_managed_turns(self): diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 1f387945d..af1ad8dba 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from functools import partial -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar from acp.exceptions import RequestError from agent_comms.acp_extension import ( @@ -21,20 +21,16 @@ from toad import messages from toad.acp.client_session import ClientSessionRequest from toad.command_catalog import CommandCatalog -from toad.submission_queue import QueueActionRefused +from toad.acp.agent import Agent +from toad.acp.queue_attachment import QueueActionRefused +from toad.submission_queue import MessageQueueEntry, QueueInspection, QueueSource +from agent_comms.message_reference import MessageReference from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventMessage from toad.input_decision import InputDecision, InputRefused from toad.widgets.prompt import Prompt from agent_comms.notification_evidence import NotificationWindow -if TYPE_CHECKING: - from toad.acp.agent import Agent - from toad.submission_queue import QueueSource, MessageQueueEntry - from agent_comms.ui_model.observation import ReadNotifications - from toad.widgets.conversation import Conversation - - @dataclass(frozen=True) class InputSubmission(DeclaredFamily, affix="InputSubmission"): source_text: str @@ -46,17 +42,17 @@ def text(self): @classmethod @abstractmethod - def matches(cls, event: UserInputSubmitted, view: Conversation): ... + def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): ... @abstractmethod async def execute(self, owner: ConversationSubmissions): ... @classmethod - def from_event(cls, event: UserInputSubmitted, view: Conversation): + def from_event(cls, event: UserInputSubmitted, owner: ConversationSubmissions): kind = next( kind for kind in sorted(cls.members_with(InputSubmission), key=lambda kind: kind.priority, reverse=True) - if kind.matches(event, view) + if kind.matches(event, owner) ) return kind(event.body) @@ -65,7 +61,7 @@ class NoInputSubmission(InputSubmission): priority = -1 @classmethod - def matches(cls, event: UserInputSubmitted, view: Conversation): + def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): return True async def execute(self, owner: ConversationSubmissions): @@ -76,10 +72,10 @@ class QueueNowInputSubmission(InputSubmission): priority = 100 @classmethod - def matches(cls, event: UserInputSubmitted, view: Conversation): + def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): if event.shell or event.body.strip(): return False - return event.immediate and view.agent_binding.presentation.queue_supported + return event.immediate and owner.view.agent_binding.presentation.queue_supported async def execute(self, owner: ConversationSubmissions): projection = owner.queue_projection @@ -91,7 +87,7 @@ class ShellInputSubmission(InputSubmission): priority = 90 @classmethod - def matches(cls, event: UserInputSubmitted, view: Conversation): + def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): return event.shell and bool(event.body.strip()) async def execute(self, owner): @@ -151,7 +147,7 @@ async def execute(self, owner: ConversationSubmissions): class OrdinaryInputSubmission(AgentInputSubmission): @classmethod - def matches(cls, event: UserInputSubmitted, view: Conversation): + def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): if event.shell: return False return bool(event.body.strip()) @@ -165,11 +161,12 @@ class DeferredInputSubmission(OrdinaryInputSubmission): priority = 70 @classmethod - def matches(cls, event: UserInputSubmitted, view: Conversation): - if not super().matches(event, view): + def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): + if not super().matches(event, owner): return False if event.immediate: return False + view = owner.view return view.turns.owner.busy and view.agent_binding.presentation.queue_supported @property @@ -185,10 +182,10 @@ class ImmediateInputSubmission(OrdinaryInputSubmission): priority = 80 @classmethod - def matches(cls, event: UserInputSubmitted, view: Conversation): - if not super().matches(event, view): + def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): + if not super().matches(event, owner): return False - return event.immediate and view.agent_binding.presentation.queue_supported + return event.immediate and owner.view.agent_binding.presentation.queue_supported @property def request(self): @@ -244,17 +241,7 @@ async def send(self): reason = await self.agent.send_prompt(self.submission.text) except RequestError as error: if self.current: - from toad.widgets.conversation import INTERNAL_EROR - from toad.widgets.markdown_note import MarkdownNote - - view.turns.finish_client() - view.prompt.restore_draft(self.submission.text) - await view.post( - MarkdownNote( - INTERNAL_EROR.replace("$ERROR", str(error) or "no details were provided"), - classes="-stop-reason", - ) - ) + await view.submission_request_failed(error, self.submission.text) finally: self.owner.finish(self) if self.current and local: @@ -302,24 +289,27 @@ def queue_source(self) -> QueueSource: @property def queue_summary(self) -> str: - return self.queue_source.queue_summary(self) + return self.queue_source.queue_summary( + self.view.agent_binding.presentation, self.delivering, + self.message_queue_entries, self.view.send_block_reason) @property def message_queue_entries(self) -> tuple[MessageQueueEntry, ...]: """Borrow the existing visible fragment proofs; never retain another inbox.""" - entries: dict[ReadNotifications, MessageQueueEntry] = {} + entries: dict[MessageReference, MessageQueueEntry] = {} for target in self.view.contents.notification_targets(): for fragment in target.visible_fragments(): - for entry in fragment.queue_entries(target.notification_view): - entries[entry.request] = entry + for entry in fragment.queue_entries(): + entries[entry.reference] = entry if len(entries) >= NotificationWindow.window_limit: return tuple(entries.values()) return tuple(entries.values()) def inspect_queue(self): - from toad.submission_queue import QueueInspection - - inspection = QueueInspection(self) + presentation = self.view.agent_binding.presentation + details = self.queue_source.queue_details( + presentation, self.delivering, self.message_queue_entries, self.view.send_block_reason) + inspection = QueueInspection(self.queue_source, presentation, details, self.publish_pending) self.view.app.push_screen(inspection) return inspection @@ -368,17 +358,14 @@ def reset(self): def send_now(self): source = self.queue_source return self.view.run_worker( - partial(self._send_now, source, self.view.agent_binding, source.queue_scope(self)), + partial(self._send_now, source, self.view.agent_binding.presentation, + source.queue_scope(self.view.agent_binding.presentation)), group="send-queued-now", exclusive=True, ) - async def _send_now(self, source: QueueSource, agent, scope: QueueScope) -> None: - if self.queue_source is not source or self.view.agent_binding is not agent: - self.view.feedback.flash("The source changed before send now", style="error") - return + async def _send_now(self, source: QueueSource, presentation, scope: QueueScope) -> None: try: - await source.queue_send_now(self, scope) + await source.queue_send_now(presentation, scope) except (RequestError, QueueActionRefused) as error: - if self.queue_source is source and self.view.agent_binding is agent: - self.view.feedback.flash(f"Send now failed: {error}", style="error") + self.view.feedback.flash(f"Send now failed: {error}", style="error") self.publish_pending() diff --git a/src/toad/notification_status.py b/src/toad/notification_status.py index 4004d3a81..19edf137a 100644 --- a/src/toad/notification_status.py +++ b/src/toad/notification_status.py @@ -108,8 +108,8 @@ def queue_details(self) -> DetailLines: *((Span(f"Recipient {item.recipient_identity.recipient_lookup}", MutedRole),) for item in leading.notifications), *leading.details, - *((Span(f"Answer {item.answer.seq}/{item.answer.message_id}", MutedRole),) - for item in leading.notifications if item.answer is not None), + *((Span(text, MutedRole),) + for item in leading.notifications for text in item.answer.detail_texts()), ) @property @@ -121,8 +121,7 @@ def details(self) -> DetailLines: line += (Span(" · Read by ", MutedRole), Span(reader.name, NameRole, OpenTarget(reader.name))) lines.append(line) - return (tuple(lines) if lines else ( - (Span("No recipient feedback to display.", MutedRole),),)) + return tuple(lines) @dataclass(frozen=True) @@ -132,6 +131,10 @@ class EmptyCheckedNotifications(CheckedNotifications): notifications: tuple[()] = field(default=(), init=False) queue_title = "No recipient feedback" + @property + def details(self) -> DetailLines: + return ((Span("No recipient feedback to display.", MutedRole),),) + @dataclass(frozen=True) class UnavailableNotifications(NotificationStatus): diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 398bb77fb..354c9ea19 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -228,8 +228,6 @@ def read_visible(self, screen: MainScreen) -> PaintedRead: """Only mounted content can supply a painted reader cursor.""" conversation = screen.conversation through = conversation.visible_read_cursor - if through is None: - return NothingToRead() app = screen.app access = app.coordination_access identity = screen.thread_identity diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 83ec32c3d..7ce8ecb70 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -145,7 +145,7 @@ def prompt(self, conversation: Conversation): async def submit(self, owner, event): from toad.conversation_submission import InputSubmission - return await InputSubmission.from_event(event, owner.view).execute(owner) + return await InputSubmission.from_event(event, owner).execute(owner) def input_decision(self, conversation: Conversation) -> InputDecision: agent = conversation.agent_binding diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index 9f62ef8d5..d60e281b6 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -1,52 +1,37 @@ -"""Source-owned queue capabilities; the prompt is their one presentation consumer. - -These are read-only views of existing attachments, transport and notification -proofs. They never reserve input, keep membership, or turn BUS references into -ACP request identities. -""" +"""One queue screen over the source's actual attachment or captured BUS evidence.""" from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar from acp.exceptions import RequestError -from agent_comms.acp_extension import NoQueueScope, QueueItem +from agent_comms.acp_extension import NoQueueScope, QueueItem, QueueScope from agent_comms.message_reference import MessageReference -from agent_comms.ui_model.observation import ReadNotifications +from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from textual import on from textual.containers import HorizontalGroup, Vertical, VerticalScroll from textual.message import Message from textual.widgets import Button, Static +from toad.acp.queue_attachment import QueueActionRefused +from toad.agent_presentation import AgentPresentation from toad.line_blocks import MutedRole, Span, SpanColumns from toad.notification_status import DetailLines, NotificationStatus from toad.screens.modal import DismissibleScreen from toad.widgets.tool_content import ToolContent -if TYPE_CHECKING: - from agent_comms.acp_extension import QueueScope - from toad.acp.agent import Agent - from toad.conversation_submission import ConversationSubmissions - - -class QueueActionRefused(Exception): - """A deliberate queue refusal, not an unexpected programming failure.""" - class QueueEvidenceChanged(Message): - """Local repaint after existing notification/viewport evidence changes.""" + """Repaint after existing notification, viewport or transport evidence changes.""" @dataclass(frozen=True) class MessageQueueEntry: - request: ReadNotifications + reference: MessageReference status: NotificationStatus - - @property - def reference(self) -> MessageReference: - return self.request.operation.references[0] + display: DisplayIdentity | NoDisplaySelection @property def summary(self) -> str: @@ -55,60 +40,61 @@ def summary(self) -> str: @property def details(self) -> DetailLines: return ( - (Span(f"{self.request.root} · {self.reference.seq}/{self.reference.message_id}", MutedRole),), + (Span(f"Original {self.reference.seq}/{self.reference.message_id}", MutedRole),), *self.status.queue_details, ) class QueueSource(ABC): - """An absent source grants neither queue membership nor mutation authority.""" + """The source selects its capability; the actual presentation owns native facts.""" preview_limit: ClassVar[int] = 3 @abstractmethod - def queue_scope(self, owner: ConversationSubmissions) -> QueueScope: ... + def queue_scope(self, presentation: AgentPresentation) -> QueueScope: ... @abstractmethod - def queue_actions_available(self, owner: ConversationSubmissions) -> bool: ... + def queue_actions_available(self, presentation: AgentPresentation) -> bool: ... @abstractmethod - async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: ... + async def queue_send_now(self, presentation: AgentPresentation, scope: QueueScope) -> None: ... @abstractmethod - async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: ... + async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) -> None: ... - def queue_summary(self, owner: ConversationSubmissions) -> str: + def queue_summary(self, presentation: AgentPresentation, delivering: tuple[str, ...], + entries: tuple[MessageQueueEntry, ...], transport: str) -> str: return "Queue unavailable" - def queue_details(self, owner: ConversationSubmissions) -> DetailLines: + def queue_details(self, presentation: AgentPresentation, delivering: tuple[str, ...], + entries: tuple[MessageQueueEntry, ...], transport: str) -> DetailLines: return ((Span("No queue observation is available for this source.", MutedRole),),) class ReadOnlyQueueSource(QueueSource): - """A source may expose delivery evidence without granting native actions.""" + """Delivery evidence grants no native queue mutation authority.""" - def queue_scope(self, owner: ConversationSubmissions) -> QueueScope: + def queue_scope(self, presentation: AgentPresentation) -> QueueScope: return NoQueueScope() - def queue_actions_available(self, owner: ConversationSubmissions) -> bool: + def queue_actions_available(self, presentation: AgentPresentation) -> bool: return False - async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: + async def queue_send_now(self, presentation: AgentPresentation, scope: QueueScope) -> None: raise QueueActionRefused("Send now unavailable: this source has no native queue authority") - async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: + async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) -> None: raise QueueActionRefused("Clear unavailable: BUS messages cannot be withdrawn through an ACP queue") class NativeQueueSource(QueueSource): - """Native queue presentation and actions borrow the actual ACP attachment.""" - - def queue_summary(self, owner: ConversationSubmissions) -> str: - projection = owner.queue_projection - feedback = projection.feedback(owner.view.agent_binding.presentation.queue_supported) + def queue_summary(self, presentation: AgentPresentation, delivering: tuple[str, ...], + entries: tuple[MessageQueueEntry, ...], transport: str) -> str: + projection = presentation.queue + feedback = projection.feedback(presentation.queue_supported) parts = [feedback] if feedback else [] for label, texts, detail in ( - ("Submitting", tuple(QueueItem.summarize(text) for text in owner.delivering), ""), + ("Submitting", tuple(QueueItem.summarize(text) for text in delivering), ""), ("Queued", tuple(row.preview for row in projection.items), ""), ("Restored", tuple(row.preview for row in projection.restored), ", read-only"), ): @@ -116,61 +102,52 @@ def queue_summary(self, owner: ConversationSubmissions) -> str: parts.append(f"{label} ({len(texts)}{detail}): " + " · ".join(texts[:self.preview_limit])) return " | ".join(parts) - def queue_details(self, owner: ConversationSubmissions) -> DetailLines: - agent = owner.view.agent_binding - projection = owner.queue_projection + def queue_details(self, presentation: AgentPresentation, delivering: tuple[str, ...], + entries: tuple[MessageQueueEntry, ...], transport: str) -> DetailLines: + projection = presentation.queue lines = [(Span("Submitting is local intent; Queued is producer acceptance, not consumption.", MutedRole),)] - lines.extend((Span(f"Submitting: {QueueItem.summarize(text)}", MutedRole),) - for text in owner.delivering) - lines.extend((Span(f"Queued {row.input_id}: {row.preview}", MutedRole),) - for row in projection.items) + lines.extend((Span(f"Submitting: {QueueItem.summarize(text)}", MutedRole),) for text in delivering) + lines.extend((Span(f"Queued {row.input_id}: {row.preview}", MutedRole),) for row in projection.items) lines.extend((Span(f"Restored {row.input_id} (read-only): {row.preview}", MutedRole),) for row in projection.restored) - lines.append((Span(agent.presentation.queue_identity, MutedRole),)) + lines.append((Span(presentation.queue_identity, MutedRole),)) return tuple(lines) - def queue_scope(self, owner: ConversationSubmissions) -> QueueScope: - return owner.view.agent_binding.presentation.queue_scope - - def queue_actions_available(self, owner: ConversationSubmissions) -> bool: - return owner.view.agent_binding.presentation.queue_actions_available + def queue_scope(self, presentation: AgentPresentation) -> QueueScope: + return presentation.queue_scope - def require_queue_action(self, owner: ConversationSubmissions, scope: QueueScope) -> Agent: - return owner.view.agent_binding.presentation.require_queue_action(scope) + def queue_actions_available(self, presentation: AgentPresentation) -> bool: + return presentation.queue_actions_available - async def queue_send_now(self, owner: ConversationSubmissions, scope: QueueScope) -> None: - agent = self.require_queue_action(owner, scope) - if not await agent.controller.send_now(): - raise QueueActionRefused("Send now was not confirmed; inspect the current queue before another action") + async def queue_send_now(self, presentation: AgentPresentation, scope: QueueScope) -> None: + presentation.require_queue_action(scope) + await presentation.agent.controller.send_now() - async def queue_clear(self, owner: ConversationSubmissions, scope: QueueScope) -> None: - agent = self.require_queue_action(owner, scope) - if not await agent.controller.clear_queue(): - raise QueueActionRefused("Clear queue was not confirmed; inspect the current queue before another action") + async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) -> None: + presentation.require_queue_action(scope) + await presentation.agent.controller.clear_queue() class BusQueueSource(ReadOnlyQueueSource): - """BUS views borrow original-reference/per-recipient evidence, not ACP rows.""" - - def queue_summary(self, owner: ConversationSubmissions) -> str: - entries = owner.message_queue_entries - transport = owner.view.send_block_reason + def queue_summary(self, presentation: AgentPresentation, delivering: tuple[str, ...], + entries: tuple[MessageQueueEntry, ...], transport: str) -> str: evidence = " · ".join(entry.summary for entry in entries[:self.preview_limit]) coverage = f"Delivery ({len(entries)} visible messages)" return " | ".join(part for part in (transport, coverage + (f": {evidence}" if evidence else ": unobserved")) if part) - def queue_details(self, owner: ConversationSubmissions) -> DetailLines: + def queue_details(self, presentation: AgentPresentation, delivering: tuple[str, ...], + entries: tuple[MessageQueueEntry, ...], transport: str) -> DetailLines: return ( (Span("Visible original message references only; this is not an ACP queue or a complete inbox count.", MutedRole),), - (Span(owner.view.send_block_reason, MutedRole),), + (Span(transport, MutedRole),), (Span(f"Showing up to {self.preview_limit} visible messages and their leading observed recipient; not complete recipient coverage.", MutedRole),), - *(line for entry in owner.message_queue_entries[:self.preview_limit] for line in entry.details), + *(line for entry in entries[:self.preview_limit] for line in entry.details), (Span("Clear/send-now unavailable: this BUS source has no native queue authority.", MutedRole),), ) class QueueInspection(DismissibleScreen[None]): - """One inspect/action surface for native, channel and bridge composers.""" + """One screen captures the source, presentation and scope it actually displays.""" DEFAULT_CSS = """ QueueInspection { align: center middle; background: $background 40%; } @@ -180,38 +157,35 @@ class QueueInspection(DismissibleScreen[None]): QueueInspection #queue-error { height: auto; color: $error; } """ - def __init__(self, owner: ConversationSubmissions): + def __init__(self, source: QueueSource, presentation: AgentPresentation, + details: DetailLines, changed: Callable[[], None]): super().__init__() - self.submissions = owner - self.source = owner.queue_source - self.agent = owner.view.agent_binding - self.scope = self.source.queue_scope(owner) + self.source, self.presentation = source, presentation + self.scope = source.queue_scope(presentation) + self.details, self.changed = details, changed def compose(self): with Vertical(): yield Static("Queue and delivery") with VerticalScroll(): details = ToolContent() - details.update((SpanColumns(self.source.queue_details(self.submissions)),)) + details.update((SpanColumns(self.details),)) yield details yield Static("Individual edit/delete/reorder requires ID-targeted, revision-checked backend support.") yield Static("", id="queue-error") with HorizontalGroup(): - available = self.source.queue_actions_available(self.submissions) + available = self.source.queue_actions_available(self.presentation) yield Button("Send now", id="queue-send-now", disabled=not available) yield Button("Clear queued inputs", id="queue-clear", disabled=not available) yield Button("Close", id="queue-close") - async def apply_action(self, action: Callable[[ConversationSubmissions, QueueScope], Awaitable[None]]) -> None: - if self.submissions.queue_source is not self.source or self.submissions.view.agent_binding is not self.agent: - self.query_one("#queue-error", Static).update("The source changed; inspect its current queue again") - return + async def apply_action(self, action: Callable[[AgentPresentation, QueueScope], Awaitable[None]]) -> None: try: - await action(self.submissions, self.scope) + await action(self.presentation, self.scope) except (QueueActionRefused, RequestError) as error: self.query_one("#queue-error", Static).update(str(error)) return - self.submissions.publish_pending() + self.changed() self.dismiss() @on(Button.Pressed, "#queue-send-now") diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 9525348f2..e1bc8b23a 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -21,7 +21,6 @@ from agent_comms.ui_model.message_category import MessageCategory from toad.render_backend import ReusableRenderTask -from toad.submission_queue import QueueSource, ReadOnlyQueueSource from toad.transcript_state import LatestViewportRequest from toad.widgets.history_anchor import LatestPosition from toad.widgets.message_notifications import NothingToRead, PaintedRead @@ -77,6 +76,8 @@ def measured(cls, page, fragments: tuple[TranscriptFragment, ...], **view): coverage = frozenset(reference for fragment in fragments for reference in fragment.outgoing_sources) prepared = cls(page, fragments, 0, outgoing_coverage=coverage, **view) + for fragment in fragments: + fragment.capture_notification_identity(prepared.notification_identity) prepared.measure_sources() return prepared @@ -226,13 +227,6 @@ def __eq__(self, other): and self.start == other.start and self.stop == other.stop) -@dataclass(frozen=True) -class PageRevision[Cursor = TranscriptCursor | None](WorkRevision): - through: Cursor | None - request: PageRequest[Cursor] - size: ClassVar[int] = 512 - - @dataclass(frozen=True) class FilteredTranscriptBatch: prepared: PreparedTranscriptPage @@ -296,13 +290,9 @@ def close(self) -> None: pass -class PreparedPageSource(ReadOnlyQueueSource): +class PreparedPageSource: """One lifetime-owned source of prepared pages, independent of widget paging.""" - @property - def queue_source(self) -> QueueSource: - return self - runtime: PreparationRuntime scope: PreparationScope through: TranscriptCursor @@ -451,7 +441,7 @@ def execute(self) -> PreparedTranscriptPage: class TranscriptNoProgress(ValueError): """A page read returned no new edge while its end still claimed more.""" - def __init__(self, request: PageRequest, through: TranscriptCursor | None, page: TranscriptPage) -> None: + def __init__(self, request: PageRequest, through: TranscriptCursor, page: TranscriptPage) -> None: super().__init__( f"Transcript history made no cursor progress: {type(request.end).__name__} " f"from {request.cursor!r} through {through!r} returned before={page.before!r} " @@ -460,7 +450,7 @@ def __init__(self, request: PageRequest, through: TranscriptCursor | None, page: @dataclass(frozen=True) -class TranscriptPageWork[Cursor = TranscriptCursor | None](ReusableWork[PreparedTranscriptPage], ScopedWork[PreparedTranscriptPage]): +class TranscriptPageWork[Cursor = TranscriptCursor](ReusableWork[PreparedTranscriptPage], ScopedWork[PreparedTranscriptPage]): scope: PreparationScope loader: Callable[..., Awaitable[TranscriptPage]] through: Cursor @@ -470,13 +460,13 @@ class TranscriptPageWork[Cursor = TranscriptCursor | None](ReusableWork[Prepared @property def work_key(self) -> WorkKey: # An older page is immutable as its source grows; a newer read ends at the source end. - return WorkKey(type(self), PageRevision(self.request.end.work_cursor(self.through), self.request), self.scope) + return WorkKey(type(self), self.request.end.work_revision(self.request, self.through), self.scope) def result_size(self, result: PreparedTranscriptPage) -> int: return result.retained_bytes async def execute( - self: TranscriptPageWork[TranscriptCursor | None], runtime: PreparationRuntime, key: WorkKey, + self: TranscriptPageWork[TranscriptCursor], runtime: PreparationRuntime, key: WorkKey, ) -> PreparedResult[PreparedTranscriptPage]: request = self.request page = await request.end.load_page(self.loader, request.cursor, self.through) diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 119e72e36..e5f125bd0 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -36,6 +36,7 @@ TranscriptCursor, TranscriptPage, TranscriptReadIdentity, + UnreadTranscriptCursor, ) from agent_comms.ui_model.observation import ( NotPublished, @@ -772,7 +773,7 @@ def __init__(self) -> None: self.generation = 0 self.dirty = False self.commit_required = False - self.displayed_cursor: TranscriptCursor | None = None + self.displayed_cursor: TranscriptCursor = UnreadTranscriptCursor() self.current_read: TranscriptPublicationState = NotPublished() self.worker: Worker[None] | None = None self.source_requests = SourcePublicationRequests(self) @@ -899,7 +900,7 @@ async def refresh_cursor(self) -> TranscriptCursor: lambda: ready.set_result(self.displayed_cursor) ) cursor = await ready - if cursor is None: + if cursor.requires_commit(): raise TranscriptChanged() return cursor @@ -928,7 +929,7 @@ async def close(self) -> None: await self.suspend() finally: self._unbind_view() - self.displayed_cursor = None + self.displayed_cursor = UnreadTranscriptCursor() def _close_documents(self) -> None: documents, self.documents = self.documents, () @@ -1009,7 +1010,7 @@ async def changing_source(self, origin: SessionOrigin): self.current_read = NotPublished() self._close_documents() self.dirty = self.commit_required = False - self.displayed_cursor = None + self.displayed_cursor = UnreadTranscriptCursor() worker, self.worker = self.worker, None if worker is not None: worker.cancel() @@ -1043,4 +1044,4 @@ async def detach(self, view: Conversation) -> None: self._close_documents() self.documents = tuple(documents) self._unbind_view() - self.displayed_cursor = None + self.displayed_cursor = UnreadTranscriptCursor() diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 76c454fab..e28e95a9e 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -7,6 +7,7 @@ from toad.widgets.wire_history import WireHistory from toad.message_rows import IrcMessageStyle from toad.wire_history import WireSource +from toad.submission_queue import QueueSource, ReadOnlyQueueSource from toad.transcript_publication import NoView, ViewBinding import re @@ -73,6 +74,7 @@ def __init__( ) -> None: super().__init__(project_path, transcript=transcript) self.conversation = conversation + self._queue_source: QueueSource = ReadOnlyQueueSource() self._me = me # The observer's announcement the last refresh began from. self._refreshed_from = None @@ -83,7 +85,7 @@ def __init__( @property def queue_source(self): - return self.message_history.page_source.queue_source + return self._queue_source def transcript_binding(self) -> ViewBinding: """A wire conversation has no native transcript to publish.""" @@ -119,10 +121,9 @@ async def initialize_view(self) -> None: ) comms = shared await self.app.coordination_access.current_sidebar() - self.message_history.initialize_source(WireSource( - comms, self.conversation, self.project_path, self._me, - IrcMessageStyle(), - )) + source = WireSource(comms, self.conversation, self.project_path, self._me, IrcMessageStyle()) + self._queue_source = source + self.message_history.initialize_source(source) self.refresh_identity(comms.root) self.prepare_prompt() self.window.anchor() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 0da29110d..c6b5886df 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -11,8 +11,11 @@ from typing import TYPE_CHECKING, Callable from acp import schema as acp_protocol +from acp.exceptions import RequestError +from toad.widgets.markdown_note import MarkdownNote from agent_comms.channel_targets import CommandTarget from agent_comms.repo import Checkout +from agent_comms.transcripts import UnreadTranscriptCursor from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution from agent_comms.goals import NoGoal from metaclass_registry.mro_dispatch import MroDispatch, handles @@ -735,7 +738,6 @@ async def session_ready(self) -> None: ): welcome = agent_data.welcome if welcome is not None: - from toad.widgets.markdown_note import MarkdownNote await self.post(MarkdownNote(welcome)) if ( @@ -827,18 +829,25 @@ def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: window = getters.query_one(Window) cursor = getters.query_one(CursorContainer) + async def submission_request_failed(self, error: RequestError, text: str) -> None: + self.turns.finish_client() + self.prompt.restore_draft(text) + await self.post(MarkdownNote( + INTERNAL_EROR.replace("$ERROR", str(error) or "no details were provided"), + classes="-stop-reason", + )) + @property def visible_read_cursor(self): """Only a painted, unfiltered tail can acknowledge the full transcript.""" if not self.is_mounted or self.visible_categories != all_categories(): - return None + return UnreadTranscriptCursor() window = self.window - through = self.transcript.displayed_cursor - if through is None or not window.follows_tail: - return None + if not window.follows_tail: + return UnreadTranscriptCursor() if any(history.blocks_visible_read for history in window.histories): - return None - return through + return UnreadTranscriptCursor() + return self.transcript.displayed_cursor def watch_visible_categories( self, @@ -1296,7 +1305,6 @@ async def agent_turn_over(self, stop_reason: type[StopReason] | None) -> None: self._turn_count += 1 if stop_reason is not None and stop_reason.note: - from toad.widgets.markdown_note import MarkdownNote await self.post( MarkdownNote( diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index ed8060174..f62f88418 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -13,7 +13,6 @@ from agent_comms.notification_reads import LiveNotificationRead from agent_comms.ui_model.observation import ReadNotifications -from agent_comms.notification_reads import LiveNotificationRead from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.message_reference import MessageReference from toad.submission_queue import QueueEvidenceChanged @@ -185,9 +184,9 @@ class SourceNotifications: def notification_identity_for(self, references: frozenset[MessageReference]) -> DisplayIdentity | NoDisplaySelection: """Use retained prepared Core pages, even after the pending paint was retired.""" - return next((page.prepared.notification_identity for page in reversed(self.pages) - if any(references.intersection(fragment.notification_references) - for fragment in page.prepared.fragments_in_range)), NoDisplaySelection()) + return next((fragment.notification_identity + for fragment in reversed(tuple(self.visible_fragments())) + if references.intersection(fragment.notification_references)), NoDisplaySelection()) def rows_published(self) -> None: super().rows_published() diff --git a/src/toad/widgets/presentation_window.py b/src/toad/widgets/presentation_window.py index d62f2077d..3a6d3ba53 100644 --- a/src/toad/widgets/presentation_window.py +++ b/src/toad/widgets/presentation_window.py @@ -13,6 +13,7 @@ from agent_comms.message_page import PageBounds from agent_comms.transcripts import TranscriptCursor +from toad.work_preparation import WorkRevision if TYPE_CHECKING: from collections.abc import Callable @@ -173,8 +174,8 @@ def at_boundary(self, window: HistoryWindow) -> bool: return all((position - boundary) * self.step >= 0 for position in (window.scroll_y, window.scroll_target_y)) - def work_cursor[Cursor](self, through: Cursor) -> Cursor | None: - return self.edge((None, through)) + @abstractmethod + def work_revision[Cursor](self, request: "PageRequest[Cursor]", through: Cursor) -> WorkRevision: ... @abstractmethod async def load_page(self, loader, cursor: TranscriptCursor, through) -> TranscriptPage: ... def progressed(self, page: "TranscriptPage", cursor: TranscriptCursor) -> bool: @@ -233,6 +234,9 @@ def trim(self, view: TranscriptPageView, count: int) -> None: class OlderEnd(PageEnd): + def work_revision[Cursor](self, request: "PageRequest[Cursor]", through: Cursor) -> WorkRevision: + return PageRevision(request) + step = -1 pop = staticmethod(deque.popleft) append = staticmethod(deque.appendleft) @@ -265,6 +269,9 @@ def leave_tail(self, window: HistoryWindow) -> None: class NewerEnd(PageEnd): + def work_revision[Cursor](self, request: "PageRequest[Cursor]", through: Cursor) -> WorkRevision: + return GrowingPageRevision(request, through) + step = 1 accepts_destination = False pop = staticmethod(deque.pop) @@ -308,12 +315,27 @@ async def mount(self, history: LineHistory, view: TranscriptPageView, pages: deq @dataclass(frozen=True) -class PageRequest[Cursor = TranscriptCursor | None]: +class PageRequest[Cursor = TranscriptCursor]: end: PageEnd cursor: Cursor - def progressed(self: PageRequest[TranscriptCursor | None], page: "TranscriptPage") -> bool: - return self.cursor is None or self.end.progressed(page, self.cursor) + def progressed(self: PageRequest[TranscriptCursor], page: "TranscriptPage") -> bool: + return self.end.progressed(page, self.cursor) + + +@dataclass(frozen=True) +class PageRevision[Cursor = TranscriptCursor](WorkRevision): + """An older request is bounded by its own cursor, independent of new arrivals.""" + + request: PageRequest[Cursor] + size: ClassVar[int] = 512 + + +@dataclass(frozen=True) +class GrowingPageRevision[Cursor = TranscriptCursor](PageRevision[Cursor]): + """A newer request also depends on the frontier admitted for this read.""" + + through: Cursor class PreparationDemand(ABC): diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 464929ac0..178e063a2 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -8,6 +8,7 @@ from agent_comms.field_codec import ToolCallId from agent_comms.message_reference import MessageReference +from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.turn_item import NativeItemId from metaclass_registry.mro_dispatch import handles from agent_comms.sent_message import PublishedSentMessage @@ -341,7 +342,13 @@ def shown_live_by(self, claim: CommitClaim) -> bool: def notification_references(self) -> tuple[MessageReference, ...]: return () - def queue_entries(self, notification_view): + notification_identity: DisplayIdentity | NoDisplaySelection = field( + default=NoDisplaySelection(), kw_only=True, repr=False, compare=False) + + def capture_notification_identity(self, display: DisplayIdentity | NoDisplaySelection) -> None: + self.notification_identity = display + + def queue_entries(self) -> tuple[MessageQueueEntry, ...]: return () def draws(self, message: BusMessage) -> bool: @@ -520,8 +527,8 @@ def has_details(self) -> bool: def notification_references(self) -> tuple[MessageReference, ...]: return self.part_position.notification_references(self.events[0]) - def queue_entries(self, notification_view): - return tuple(MessageQueueEntry(notification_view.notification_read((reference,)), self.shown.status) + def queue_entries(self) -> tuple[MessageQueueEntry, ...]: + return tuple(MessageQueueEntry(reference, self.shown.status, self.notification_identity) for reference in self.notification_references) def draws(self, message: BusMessage) -> bool: diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index af7e6911d..aa2a725ab 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -22,7 +22,7 @@ ConversationObservation, ConversationRevision, ) -from agent_comms.message_page import MessagePage +from agent_comms.message_page import MessagePage, MessagePageRequest from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.messages import ConversationRole, Membership, Message, NoticeRole from agent_comms.page_traversal import ( @@ -104,9 +104,12 @@ class AfterMessage(WireCut): bounded = True kept = True - @property - def undrawn(self) -> bool: - return False + def accepts(self, current: MessagePage, incoming: MessagePage, + view: type[ConversationHistoryView]) -> bool: + return view.same_display(incoming, current) + + def advanced_source(self, current: MessagePage, incoming: MessagePage) -> MessagePage: + return current def observed(self) -> AfterMessage: return self @@ -136,10 +139,13 @@ class NothingRead(AfterSequence): cursor: LiveCursor = field(default_factory=lambda: LiveCursor(0)) - @property - def undrawn(self) -> bool: + def accepts(self, current: MessagePage, incoming: MessagePage, + view: type[ConversationHistoryView]) -> bool: return True + def advanced_source(self, current: MessagePage, incoming: MessagePage) -> MessagePage: + return incoming + def observed(self) -> AfterSequence: return AfterSequence(self.cursor) @@ -158,16 +164,11 @@ class WirePage: @classmethod def unread(cls) -> WirePage: """The declared unread cursor carries an empty, unscoped placeholder.""" - return cls((), None, NothingRead(), False, False, - MessagePage((), False, False, NoDisplaySelection())) - - @property - def undrawn(self) -> bool: - return self.after.undrawn + return cls((), None, NothingRead(), False, False, MessagePageRequest.empty) def accepts(self, page: MessagePage, view: type[ConversationHistoryView]) -> bool: """An initial placeholder accepts its first scope; Core compares actual pages.""" - return self.undrawn or view.same_display(page, self.source) + return self.after.accepts(self.source, page, view) def advanced(self, interval: WirePage, *, older: bool) -> WirePage: """An empty interval advances its cut and establishes the first Core page. @@ -175,7 +176,7 @@ def advanced(self, interval: WirePage, *, older: bool) -> WirePage: Existing fragments retain their original page evidence; a placeholder must adopt the actual empty Core page rather than remain unobserved. """ - source = interval.source if self.undrawn else self.source + source = self.after.advanced_source(self.source, interval.source) return ( replace(self, before=interval.before, after=self.after.observed(), has_older=interval.has_older, source=source) @@ -430,10 +431,6 @@ def __init__(self, *args, revision: ConversationObservation, **kwargs): super().__init__(*args, **kwargs) self.revision = revision - @property - def queue_source(self) -> WireSource: - return self.loader - def replacement(self, through) -> WirePageBuffer: return type(self)(self.loader, through, self.runtime, revision=self.revision) diff --git a/tests/support/peers/refuses_queue.py b/tests/support/peers/refuses_queue.py deleted file mode 100644 index dcba5a39e..000000000 --- a/tests/support/peers/refuses_queue.py +++ /dev/null @@ -1,15 +0,0 @@ -"""An external ACP peer refuses a queue request without losing the session.""" - -from acp.exceptions import RequestError - -from base import Peer - - -class RefusesQueuePeer(Peer): - async def prompt(self, session_id, prompt, **kwargs): - self.record(prompt=prompt[0].text) - raise RequestError.invalid_params({"details": "Queue action refused by peer"}) - - -if __name__ == "__main__": - RefusesQueuePeer.main() diff --git a/tests/textual/test_common_queue.py b/tests/textual/test_common_queue.py deleted file mode 100644 index b74461330..000000000 --- a/tests/textual/test_common_queue.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Common queue chrome uses real source references and never grants BUS ACP actions. - -These controls use the private wire, real observer, prompt and submission owner. -No product monkeypatches and no provider input are needed. -""" -from collections.abc import Awaitable, Callable - -import pytest -from agent_comms.bus import Bus -from agent_comms.channel_targets import ThreadTarget -from agent_comms.read_basis import DisplayOutcomes -from agent_comms.presentation import MessageNotification -from agent_comms.recorded_bus import RecordedBusIdentity -from support.ui import Ui -from support.workspace import Workspace -from agent_comms.acp_extension import NoQueueScope -from toad.notification_status import CheckedNotifications, EmptyCheckedNotifications -from toad.submission_queue import QueueActionRefused -from toad.session_presentation import MailboxSessionSources -from toad.widgets.comms_chat import CommsChatView -from toad.widgets.prompt import QueueSummary -from textual.widgets import Button - - -async def open_channel(ui: Ui, viewer: str) -> str: - # The tab acts as an agent; the Core page is still read by the actual human. - return await ui.open_channel("#team", actor="peer") - - -async def open_direct(ui: Ui, viewer: str) -> str: - return await ui.open_direct("peer", actor=viewer) - - -@pytest.mark.parametrize("open_surface", [open_channel, open_direct]) -async def test_bus_queue_borrows_original_proofs_and_preserves_draft( - workspace: Workspace, open_surface: Callable[[Ui, str], Awaitable[str]], -) -> None: - workspace.thread("peer", "team") - workspace.thread("recipient", "team") - originals = ( - workspace.comms.messaging.send("peer", "#team", "CHANNEL ORIGINAL").message, - workspace.comms.messaging.send("peer", workspace.viewer, "DIRECT ORIGINAL").message, - ) - async with workspace.app(size=(115, 38)) as ui: - await open_surface(ui, workspace.viewer) - view = ui.app.selected_session.query_one(CommsChatView) - owner = view.submissions - await ui.until(lambda: bool(owner.message_queue_entries), why="visible original-reference evidence") - await ui.write_draft("@peer kept draft") - entries = owner.message_queue_entries - assert all(entry.reference in {message.reference for message in originals} for entry in entries) - assert all(entry.request.root == str(workspace.comms.root) for entry in entries) - captured = {page.prepared.notification_identity for page in view.message_history.pages} - human = workspace.comms.registry.require(workspace.viewer).incarnation - assert all(entry.request.display in captured for entry in entries) - namespace = RecordedBusIdentity.capture(Bus.path_in(workspace.comms.root)) - assert all(entry.request.display.outcome_viewer() == DisplayOutcomes(human, namespace) - for entry in entries) - assert not view.message_history.undrawn - assert view.query_one(QueueSummary) in ui.app.screen._compositor.visible_widgets - assert not owner.queue_source.queue_actions_available(owner) - with pytest.raises(QueueActionRefused): - await owner.queue_source.queue_clear(owner, NoQueueScope()) - with pytest.raises(QueueActionRefused): - await owner.queue_source.queue_send_now(owner, NoQueueScope()) - - previous_screen = ui.app.screen - screen = owner.inspect_queue() - await ui.until(lambda: ui.app.screen is screen, why="shared queue inspection") - assert screen.query_one("#queue-send-now", Button).disabled - assert screen.query_one("#queue-clear", Button).disabled - await ui.textual[1].click("#queue-close") - await ui.until(lambda: ui.app.screen is previous_screen, why="queue inspection closed") - assert ui.draft() == "@peer kept draft" - - workspace.comms.messaging.send("peer", "#team", "LATER CHANNEL ORIGINAL") - workspace.comms.messaging.send("peer", workspace.viewer, "LATER DIRECT ORIGINAL") - previous = {entry.reference for entry in entries} - await ui.until(lambda: bool({entry.reference for entry in owner.message_queue_entries} - previous), - why="source subscription to refresh the existing queue consumer") - captured = {page.prepared.notification_identity for page in view.message_history.pages} - assert all(entry.request.display in captured for entry in owner.message_queue_entries) - assert ui.draft() == "@peer kept draft" - - -async def test_mailbox_factory_supplies_same_owner_and_readonly_capability_wins_mro(workspace: Workspace) -> None: - async with workspace.app(size=(110, 35)) as ui: - view = ui.conversation - source = MailboxSessionSources() - prompt = source.prompt(view) - assert prompt.operational_submissions is view.submissions - assert not source.queue_actions_available(view.submissions) - with pytest.raises(QueueActionRefused): - await source.queue_send_now(view.submissions, source.queue_scope(view.submissions)) - with pytest.raises(QueueActionRefused): - await source.queue_clear(view.submissions, source.queue_scope(view.submissions)) - await source.close() - - -def test_matched_empty_feedback_is_not_a_missing_original(workspace: Workspace) -> None: - workspace.thread('peer', 'team') - comms = workspace.comms - human = comms.registry.require(workspace.viewer) - first = comms.messaging.send('peer', human.name, 'matched original').message - missing = comms.messaging.send('peer', human.name, 'not in this result window').message - page = comms.views.dm_display_page(ThreadTarget('peer'), worktree=human.worktree) - comms.views.mark_page_read(page, worktree=human.worktree) - document = comms.bus.reads.read() - window = comms.views.message_notifications_for_references( - page.selection.notification_identity.outcome_viewer(), (first.reference,), - ) - checked, = CheckedNotifications.for_references((first.reference,), window.outcomes) - assert isinstance(checked, EmptyCheckedNotifications) - assert checked.notifications == () - assert checked.queue_title == "No recipient feedback" - assert not checked.unavailable and not checked.working - assert checked.title == MessageNotification.summary(checked.notifications) - assert tuple(CheckedNotifications.for_references((missing.reference,), window.outcomes)) == () - assert comms.bus.reads.read() == document diff --git a/tests/textual/test_queue_action_boundary.py b/tests/textual/test_queue_action_boundary.py deleted file mode 100644 index c10894f48..000000000 --- a/tests/textual/test_queue_action_boundary.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Queue actions keep stale sources and deliberate refusals apart from bugs. - -Use the actual app, source retirement, agent binding and ACP transport. The -unexpected-error callback exercises the inspection's public action boundary; -it neither patches a product owner nor submits input to a model. -""" - -import pytest -from agent_comms.acp_extension import ClearQueueRequest, QueueScope, encode_request -from textual.widgets import Static - -from support.agents import peer -from support.workspace import Workspace -from toad.acp.client_session import ClientSessionRequest -from toad.conversation_submission import ConversationSubmissions -from toad.session_scope import AgentAttached -from toad.widgets.comms_chat import CommsChatView - - -async def unexpected_value_error(owner: ConversationSubmissions, scope: QueueScope) -> None: - raise ValueError("Unexpected action defect") - - -async def test_stale_source_stops_before_the_action_and_closed_source_identity_is_stable( - workspace: Workspace, -) -> None: - workspace.thread("peer", "team") - async with workspace.app(size=(115, 38)) as ui: - await ui.open_channel("#team", actor=workspace.viewer) - view = ui.app.selected_session.query_one(CommsChatView) - owner = view.submissions - await ui.write_draft("kept across source retirement") - inspection = owner.inspect_queue() - await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") - await view.message_history.retire_source() - view.message_history.close() - replacement = view.message_history.page_source - assert owner.queue_source is replacement - assert owner.queue_source is owner.queue_source - assert owner.queue_source is not inspection.source - # If stale checks reach this action, ValueError must fail the control. - await inspection.apply_action(unexpected_value_error) - assert ui.app.screen is inspection - assert inspection.query_one("#queue-error", Static).content - inspection.dismiss() - await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") - assert ui.draft() == "kept across source retirement" - - -async def test_stale_agent_stops_before_inspection_or_send_now_action(workspace: Workspace) -> None: - async with workspace.app(size=(115, 38)) as ui: - owner = ui.conversation.submissions - await ui.write_draft("kept across agent replacement") - source, previous = owner.queue_source, owner.view.agent_binding - scope = source.queue_scope(owner) - inspection = owner.inspect_queue() - await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") - replacement = ui.detached_agent("replacement") - ui.attach_agent(replacement) - assert owner.queue_source is source - assert owner.view.agent_binding is not previous - await inspection.apply_action(unexpected_value_error) - assert ui.app.screen is inspection - assert inspection.query_one("#queue-error", Static).content - await owner._send_now(source, previous, scope) - assert owner.view.feedback.display - assert owner.view.feedback.content - inspection.dismiss() - await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") - assert ui.draft() == "kept across agent replacement" - await replacement.stop() - - -async def test_unsupported_action_stays_visible_and_unrelated_value_error_escapes(workspace: Workspace) -> None: - async with workspace.app(size=(115, 38)) as ui: - owner = ui.conversation.submissions - await ui.write_draft("kept after refusal") - inspection = owner.inspect_queue() - await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") - await inspection.apply_action(inspection.source.queue_clear) - assert ui.app.screen is inspection - assert inspection.query_one("#queue-error", Static).content - with pytest.raises(ValueError, match="Unexpected action defect"): - await inspection.apply_action(unexpected_value_error) - assert ui.app.screen is inspection - inspection.dismiss() - await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") - assert ui.draft() == "kept after refusal" - - -async def test_real_acp_refusal_keeps_inspection_open_without_retry(workspace: Workspace) -> None: - async with workspace.app(size=(115, 38), binding=AgentAttached(peer("refuses_queue"))) as ui: - await ui.until(ui.agent_ready, why="external ACP peer ready") - owner = ui.conversation.submissions - agent = owner.view.agent_binding - authority = ClientSessionRequest(agent, agent.session_id) - await ui.write_draft("kept after ACP refusal") - inspection = owner.inspect_queue() - await ui.until(lambda: ui.app.screen is inspection, why="queue inspection mounted") - - async def refused_at_acp(submissions: ConversationSubmissions, scope: QueueScope) -> None: - # The official SDK returns RequestError from an actual child process. - await agent.process.prompt( - agent, [{"type": "text", "text": " "}], encode_request(ClearQueueRequest()), authority.require, - ) - - await inspection.apply_action(refused_at_acp) - assert ui.app.screen is inspection - assert inspection.query_one("#queue-error", Static).content - assert authority.current - assert workspace.prompts_received() == [" "] - inspection.dismiss() - await ui.until(lambda: ui.app.screen is not inspection, why="inspection closed") - assert ui.draft() == "kept after ACP refusal" From 20a96a56b1911122104cab36808d50b9c697fdb0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 12:57:23 -0400 Subject: [PATCH 20/22] Choose painted reads before work and acquire queue evidence lazily --- src/toad/acp/agent_controller.py | 7 +- src/toad/acp/transcript_reader.py | 10 +-- src/toad/conversation_submission.py | 27 ++----- src/toad/messages.py | 4 ++ src/toad/notification_status.py | 15 ++++ src/toad/screens/historical_sessions.py | 7 +- src/toad/screens/main.py | 6 +- src/toad/session_scope.py | 10 +-- src/toad/submission_queue.py | 88 +++++++++++------------ src/toad/transcript_preparation.py | 2 - src/toad/widgets/comms_chat.py | 8 +-- src/toad/widgets/conversation.py | 20 +++--- src/toad/widgets/message_notifications.py | 10 ++- src/toad/widgets/presentation_window.py | 2 +- src/toad/widgets/transcript_fragments.py | 15 ++-- src/toad/wire_history.py | 7 +- 16 files changed, 121 insertions(+), 117 deletions(-) diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index e8c117672..8b2923135 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -11,6 +11,7 @@ from acp.exceptions import RequestError from agent_comms.acp_extension import ( ClearQueueRequest, + CommsRequest, CompactRequest, CoordinationChangedUpdate, InputFailedUpdate, @@ -272,7 +273,7 @@ async def _submit( if submission is not None: self._deferred_submissions.discard(submission) - async def submit_blocks(self, content: list[dict[str, str]], command: PromptRequest): + async def submit_blocks(self, content: list[dict[str, str]], command: CommsRequest): agent = self.agent authority = QueueSessionRequest(agent, agent.session_id) return await self.operate( @@ -291,7 +292,7 @@ def require(): try: result = await agent.process.prompt( - agent, content, encode_request(command) if command is not None else {}, require + agent, content, encode_request(command), require ) except RequestError as error: # The agent answered: this request failed and its session stays open. @@ -324,7 +325,7 @@ def _prompt_failed( published: bool = False, ) -> None: agent = self.agent - user_text = command.draft_text if command is not None else None + user_text = command.draft_text if user_text: agent.events.publish( core_events.CommsUpdated( diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index e21a936b1..aa7d4d80a 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -17,7 +17,7 @@ from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate from agent_comms.route_selection import RouteSelection -from agent_comms.transcripts import AtFrontier, LatestPage, PageLimit, PagePosition +from agent_comms.transcripts import AtFrontier, LatestPage, LivePageSource, PageLimit, PagePosition, PageSource from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.observation import ( AwaitedRead, @@ -72,13 +72,13 @@ async def page( *, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), - historical_source: str | None = None, + page_source: PageSource = LivePageSource(), ): snapshot = await self.snapshot( scope, position=position, limit=limit, - historical_source=historical_source, + page_source=page_source, ) return snapshot.page @@ -88,13 +88,13 @@ async def snapshot( *, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), - historical_source: str | None = None, + page_source: PageSource = LivePageSource(), ) -> TranscriptSnapshotUpdate: return await self.read( scope.snapshot_request( position=position, limit=limit, - historical_source=historical_source, + page_source=page_source, ) ) diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index af1ad8dba..dc69978ae 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -23,13 +23,11 @@ from toad.command_catalog import CommandCatalog from toad.acp.agent import Agent from toad.acp.queue_attachment import QueueActionRefused -from toad.submission_queue import MessageQueueEntry, QueueInspection, QueueSource -from agent_comms.message_reference import MessageReference +from toad.submission_queue import QueueEvidence, QueueInspection, QueueSource from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventMessage from toad.input_decision import InputDecision, InputRefused from toad.widgets.prompt import Prompt -from agent_comms.notification_evidence import NotificationWindow @dataclass(frozen=True) class InputSubmission(DeclaredFamily, affix="InputSubmission"): @@ -288,28 +286,17 @@ def queue_source(self) -> QueueSource: return self.view.queue_source @property - def queue_summary(self) -> str: - return self.queue_source.queue_summary( + def queue_evidence(self) -> QueueEvidence: + return QueueEvidence( self.view.agent_binding.presentation, self.delivering, - self.message_queue_entries, self.view.send_block_reason) + self.view.send_block_reason, self.view.contents.notification_targets()) @property - def message_queue_entries(self) -> tuple[MessageQueueEntry, ...]: - """Borrow the existing visible fragment proofs; never retain another inbox.""" - entries: dict[MessageReference, MessageQueueEntry] = {} - for target in self.view.contents.notification_targets(): - for fragment in target.visible_fragments(): - for entry in fragment.queue_entries(): - entries[entry.reference] = entry - if len(entries) >= NotificationWindow.window_limit: - return tuple(entries.values()) - return tuple(entries.values()) + def queue_summary(self) -> str: + return self.queue_source.queue_summary(self.queue_evidence) def inspect_queue(self): - presentation = self.view.agent_binding.presentation - details = self.queue_source.queue_details( - presentation, self.delivering, self.message_queue_entries, self.view.send_block_reason) - inspection = QueueInspection(self.queue_source, presentation, details, self.publish_pending) + inspection = QueueInspection(self.queue_source, self.queue_evidence, self.publish_pending) self.view.app.push_screen(inspection) return inspection diff --git a/src/toad/messages.py b/src/toad/messages.py index e9f92abf7..3c5ddc543 100644 --- a/src/toad/messages.py +++ b/src/toad/messages.py @@ -7,6 +7,10 @@ from textual.message import Message +class QueueEvidenceChanged(Message): + """Repaint the local queue after its existing evidence changes.""" + + class SendPromptNow(Message): pass diff --git a/src/toad/notification_status.py b/src/toad/notification_status.py index 19edf137a..78ef9da51 100644 --- a/src/toad/notification_status.py +++ b/src/toad/notification_status.py @@ -62,6 +62,21 @@ def blocks(self, shown: Shown, link: LineLink) -> tuple[LineBlock, ...]: if shown.details_open else (summary,)) +@dataclass(frozen=True) +class MessageQueueEntry: + reference: MessageReference + status: NotificationStatus + + @property + def summary(self) -> str: + return f"{self.reference.seq}: {self.status.queue_title}" + + @property + def details(self) -> DetailLines: + return ((Span(f"Original {self.reference.seq}/{self.reference.message_id}", MutedRole),), + *self.status.queue_details) + + @dataclass(frozen=True) class UncheckedNotifications(NotificationStatus): # The row is drawn before Core answers, so the answer changes its text diff --git a/src/toad/screens/historical_sessions.py b/src/toad/screens/historical_sessions.py index 7125d6c46..d43970c8e 100644 --- a/src/toad/screens/historical_sessions.py +++ b/src/toad/screens/historical_sessions.py @@ -13,7 +13,8 @@ from agent_comms.order_tools import TranscriptPageRead from agent_comms.ui_model.observation import ReadTranscript from toad.core_event_carrier import CoreEventMessage, CoreEventReceiver -from agent_comms.historical_views import HistoricalThread +from agent_comms.historical_views import HistoricalThread, HistorySourceKey +from agent_comms.transcripts import RecordedPageSource from textual import on, work from textual.app import ComposeResult from textual.containers import Vertical @@ -30,7 +31,7 @@ class RecordedContents(Contents): """The notification subscriber reads the same archive as its history.""" - def __init__(self, source: str, **kwargs): + def __init__(self, source: HistorySourceKey, **kwargs): self.notification_operation = partial(RecordedNotificationRead, source=source) super().__init__(**kwargs) @@ -124,7 +125,7 @@ async def selected(self, event: Select.Changed) -> None: async def load(**kwargs): snapshot = await access.transcripts.read(ReadTranscript(root, TranscriptPageRead( - thread=item.thread.name, historical_source=item.source.key, **kwargs))) + thread=item.thread.name, page_source=RecordedPageSource(item.source.key), **kwargs))) return snapshot.page try: diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 354c9ea19..170b0d4d9 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -225,9 +225,11 @@ def focus_sidebar_return(self, screen: MainScreen) -> None: screen.screen.set_focus(target, scroll_visible=False) def read_visible(self, screen: MainScreen) -> PaintedRead: - """Only mounted content can supply a painted reader cursor.""" + return screen.conversation.painted_read(partial(self.read_work, screen)) + + def read_work(self, screen: MainScreen, through) -> ReadWork: + """Only a painted cursor reaches route selection and the write worker.""" conversation = screen.conversation - through = conversation.visible_read_cursor app = screen.app access = app.coordination_access identity = screen.thread_identity diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 5bd79a8a7..2871661f2 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -16,7 +16,7 @@ from agent_comms.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation from agent_comms.order_tools import TranscriptPageRead -from agent_comms.transcripts import AtFrontier, LatestPage, PageLimit, PagePosition +from agent_comms.transcripts import AtFrontier, LatestPage, LivePageSource, PageLimit, PagePosition, PageSource from agent_comms.ui_model.observation import ( ReadTranscript, ReadTranscriptUpdate, @@ -86,7 +86,7 @@ def snapshot_request( *, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), - historical_source: str | None = None, + page_source: PageSource = LivePageSource(), ) -> ReadTranscript: ... @abstractmethod @@ -164,13 +164,13 @@ def snapshot_request( *, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), - historical_source: str | None = None, + page_source: PageSource = LivePageSource(), ) -> ReadTranscript: return ReadTranscript(self.root, TranscriptPageRead( thread=self.name, position=position, limit=limit, - historical_source=historical_source, + page_source=page_source, )) def update_request( @@ -263,7 +263,7 @@ def snapshot_request( *, position: PagePosition = LatestPage(), limit: PageLimit = AtFrontier(), - historical_source: str | None = None, + page_source: PageSource = LivePageSource(), ) -> Never: self.unavailable() diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index d60e281b6..7c3967850 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -4,45 +4,44 @@ from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass +from functools import cached_property from typing import ClassVar from acp.exceptions import RequestError from agent_comms.acp_extension import NoQueueScope, QueueItem, QueueScope from agent_comms.message_reference import MessageReference -from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection +from agent_comms.notification_evidence import NotificationWindow from textual import on from textual.containers import HorizontalGroup, Vertical, VerticalScroll -from textual.message import Message from textual.widgets import Button, Static from toad.acp.queue_attachment import QueueActionRefused from toad.agent_presentation import AgentPresentation from toad.line_blocks import MutedRole, Span, SpanColumns -from toad.notification_status import DetailLines, NotificationStatus +from toad.notification_status import DetailLines, MessageQueueEntry from toad.screens.modal import DismissibleScreen +from toad.widgets.message_notifications import SourceNotifications from toad.widgets.tool_content import ToolContent -class QueueEvidenceChanged(Message): - """Repaint after existing notification, viewport or transport evidence changes.""" - - @dataclass(frozen=True) -class MessageQueueEntry: - reference: MessageReference - status: NotificationStatus - display: DisplayIdentity | NoDisplaySelection +class QueueEvidence: + """One UI read; only a BUS source asks for its visible message entries.""" - @property - def summary(self) -> str: - return f"{self.reference.seq}: {self.status.queue_title}" + presentation: AgentPresentation + delivering: tuple[str, ...] + transport: str + targets: tuple[SourceNotifications, ...] - @property - def details(self) -> DetailLines: - return ( - (Span(f"Original {self.reference.seq}/{self.reference.message_id}", MutedRole),), - *self.status.queue_details, - ) + @cached_property + def entries(self) -> tuple[MessageQueueEntry, ...]: + entries: dict[MessageReference, MessageQueueEntry] = {} + for target in self.targets: + for entry in target.queue_entries(): + entries[entry.reference] = entry + if len(entries) >= NotificationWindow.window_limit: + return tuple(entries.values()) + return tuple(entries.values()) class QueueSource(ABC): @@ -62,12 +61,10 @@ async def queue_send_now(self, presentation: AgentPresentation, scope: QueueScop @abstractmethod async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) -> None: ... - def queue_summary(self, presentation: AgentPresentation, delivering: tuple[str, ...], - entries: tuple[MessageQueueEntry, ...], transport: str) -> str: + def queue_summary(self, evidence: QueueEvidence) -> str: return "Queue unavailable" - def queue_details(self, presentation: AgentPresentation, delivering: tuple[str, ...], - entries: tuple[MessageQueueEntry, ...], transport: str) -> DetailLines: + def queue_details(self, evidence: QueueEvidence) -> DetailLines: return ((Span("No queue observation is available for this source.", MutedRole),),) @@ -88,13 +85,13 @@ async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) class NativeQueueSource(QueueSource): - def queue_summary(self, presentation: AgentPresentation, delivering: tuple[str, ...], - entries: tuple[MessageQueueEntry, ...], transport: str) -> str: + def queue_summary(self, evidence: QueueEvidence) -> str: + presentation = evidence.presentation projection = presentation.queue feedback = projection.feedback(presentation.queue_supported) parts = [feedback] if feedback else [] for label, texts, detail in ( - ("Submitting", tuple(QueueItem.summarize(text) for text in delivering), ""), + ("Submitting", tuple(QueueItem.summarize(text) for text in evidence.delivering), ""), ("Queued", tuple(row.preview for row in projection.items), ""), ("Restored", tuple(row.preview for row in projection.restored), ", read-only"), ): @@ -102,15 +99,14 @@ def queue_summary(self, presentation: AgentPresentation, delivering: tuple[str, parts.append(f"{label} ({len(texts)}{detail}): " + " · ".join(texts[:self.preview_limit])) return " | ".join(parts) - def queue_details(self, presentation: AgentPresentation, delivering: tuple[str, ...], - entries: tuple[MessageQueueEntry, ...], transport: str) -> DetailLines: - projection = presentation.queue + def queue_details(self, evidence: QueueEvidence) -> DetailLines: + projection = evidence.presentation.queue lines = [(Span("Submitting is local intent; Queued is producer acceptance, not consumption.", MutedRole),)] - lines.extend((Span(f"Submitting: {QueueItem.summarize(text)}", MutedRole),) for text in delivering) + lines.extend((Span(f"Submitting: {QueueItem.summarize(text)}", MutedRole),) for text in evidence.delivering) lines.extend((Span(f"Queued {row.input_id}: {row.preview}", MutedRole),) for row in projection.items) lines.extend((Span(f"Restored {row.input_id} (read-only): {row.preview}", MutedRole),) for row in projection.restored) - lines.append((Span(presentation.queue_identity, MutedRole),)) + lines.append((Span(evidence.presentation.queue_identity, MutedRole),)) return tuple(lines) def queue_scope(self, presentation: AgentPresentation) -> QueueScope: @@ -129,19 +125,19 @@ async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) class BusQueueSource(ReadOnlyQueueSource): - def queue_summary(self, presentation: AgentPresentation, delivering: tuple[str, ...], - entries: tuple[MessageQueueEntry, ...], transport: str) -> str: - evidence = " · ".join(entry.summary for entry in entries[:self.preview_limit]) + def queue_summary(self, evidence: QueueEvidence) -> str: + entries = evidence.entries + previews = " · ".join(entry.summary for entry in entries[:self.preview_limit]) coverage = f"Delivery ({len(entries)} visible messages)" - return " | ".join(part for part in (transport, coverage + (f": {evidence}" if evidence else ": unobserved")) if part) + return " | ".join(part for part in ( + evidence.transport, coverage + (f": {previews}" if previews else ": unobserved")) if part) - def queue_details(self, presentation: AgentPresentation, delivering: tuple[str, ...], - entries: tuple[MessageQueueEntry, ...], transport: str) -> DetailLines: + def queue_details(self, evidence: QueueEvidence) -> DetailLines: return ( (Span("Visible original message references only; this is not an ACP queue or a complete inbox count.", MutedRole),), - (Span(transport, MutedRole),), + (Span(evidence.transport, MutedRole),), (Span(f"Showing up to {self.preview_limit} visible messages and their leading observed recipient; not complete recipient coverage.", MutedRole),), - *(line for entry in entries[:self.preview_limit] for line in entry.details), + *(line for entry in evidence.entries[:self.preview_limit] for line in entry.details), (Span("Clear/send-now unavailable: this BUS source has no native queue authority.", MutedRole),), ) @@ -157,19 +153,19 @@ class QueueInspection(DismissibleScreen[None]): QueueInspection #queue-error { height: auto; color: $error; } """ - def __init__(self, source: QueueSource, presentation: AgentPresentation, - details: DetailLines, changed: Callable[[], None]): + def __init__(self, source: QueueSource, evidence: QueueEvidence, changed: Callable[[], None]): super().__init__() - self.source, self.presentation = source, presentation - self.scope = source.queue_scope(presentation) - self.details, self.changed = details, changed + self.source, self.evidence = source, evidence + self.presentation = evidence.presentation + self.scope = source.queue_scope(self.presentation) + self.changed = changed def compose(self): with Vertical(): yield Static("Queue and delivery") with VerticalScroll(): details = ToolContent() - details.update((SpanColumns(self.details),)) + details.update((SpanColumns(self.source.queue_details(self.evidence)),)) yield details yield Static("Individual edit/delete/reorder requires ID-targeted, revision-checked backend support.") yield Static("", id="queue-error") diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index e1bc8b23a..c3f814a40 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -76,8 +76,6 @@ def measured(cls, page, fragments: tuple[TranscriptFragment, ...], **view): coverage = frozenset(reference for fragment in fragments for reference in fragment.outgoing_sources) prepared = cls(page, fragments, 0, outgoing_coverage=coverage, **view) - for fragment in fragments: - fragment.capture_notification_identity(prepared.notification_identity) prepared.measure_sources() return prepared diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index e28e95a9e..0999a516c 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -62,6 +62,7 @@ class HistoryLoading(ConversationBlock, Static): class CommsChatView(Conversation): """A wire-backed conversation using Toad's normal transcript primitives.""" + queue_source: QueueSource = ReadOnlyQueueSource() def __init__( self, @@ -74,7 +75,6 @@ def __init__( ) -> None: super().__init__(project_path, transcript=transcript) self.conversation = conversation - self._queue_source: QueueSource = ReadOnlyQueueSource() self._me = me # The observer's announcement the last refresh began from. self._refreshed_from = None @@ -83,10 +83,6 @@ def __init__( self.message_contents = Contents(id="contents") self.message_history = WireHistory.empty(view=self, notification_view=self.message_contents) - @property - def queue_source(self): - return self._queue_source - def transcript_binding(self) -> ViewBinding: """A wire conversation has no native transcript to publish.""" return NoView() @@ -122,7 +118,7 @@ async def initialize_view(self) -> None: comms = shared await self.app.coordination_access.current_sidebar() source = WireSource(comms, self.conversation, self.project_path, self._me, IrcMessageStyle()) - self._queue_source = source + self.queue_source = source self.message_history.initialize_source(source) self.refresh_identity(comms.root) self.prepare_prompt() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c6b5886df..d5a5cfb6e 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -15,7 +15,7 @@ from toad.widgets.markdown_note import MarkdownNote from agent_comms.channel_targets import CommandTarget from agent_comms.repo import Checkout -from agent_comms.transcripts import UnreadTranscriptCursor +from agent_comms.transcripts import TranscriptCursor from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution from agent_comms.goals import NoGoal from metaclass_registry.mro_dispatch import MroDispatch, handles @@ -45,7 +45,8 @@ from toad.acp.status import StopReason from toad.agent import AgentBase from toad.agent_binding import AgentConnection, NoAgentConnection -from toad.submission_queue import QueueEvidenceChanged, QueueSource +from toad.messages import QueueEvidenceChanged +from toad.submission_queue import QueueSource from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.conversation_actions import ConversationAction, WindowAction @@ -105,7 +106,7 @@ ) from toad.widgets.menu import Menu from toad.widgets.message_divider import StickyHeader -from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications +from toad.widgets.message_notifications import HistoryNotifications, NothingToRead, PaintedRead, SourceNotifications from toad.widgets.native_history import NativeHistory from toad.widgets.note import Note from toad.widgets.observed_thread_activity import ( @@ -837,17 +838,16 @@ async def submission_request_failed(self, error: RequestError, text: str) -> Non classes="-stop-reason", )) - @property - def visible_read_cursor(self): - """Only a painted, unfiltered tail can acknowledge the full transcript.""" + def painted_read(self, acknowledge: Callable[[TranscriptCursor], PaintedRead]) -> PaintedRead: + """Decide before constructing a worker or opening a read/write context.""" if not self.is_mounted or self.visible_categories != all_categories(): - return UnreadTranscriptCursor() + return NothingToRead() window = self.window if not window.follows_tail: - return UnreadTranscriptCursor() + return NothingToRead() if any(history.blocks_visible_read for history in window.histories): - return UnreadTranscriptCursor() - return self.transcript.displayed_cursor + return NothingToRead() + return self.transcript.displayed_cursor.painted_read(acknowledge, NothingToRead()) def watch_visible_categories( self, diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index f62f88418..7a1bee21a 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -15,10 +15,10 @@ from agent_comms.ui_model.observation import ReadNotifications from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.message_reference import MessageReference -from toad.submission_queue import QueueEvidenceChanged +from toad.messages import QueueEvidenceChanged from toad.notification_status import ( - CheckedNotifications, NotificationStatus, UnavailableNotifications, UncheckedNotifications, + CheckedNotifications, MessageQueueEntry, NotificationStatus, UnavailableNotifications, UncheckedNotifications, ) if TYPE_CHECKING: from toad.widgets.tool_content import ToolContent @@ -182,8 +182,12 @@ def on_show(self) -> None: class SourceNotifications: notification_view: HistoryNotifications + def queue_entries(self) -> Iterable[MessageQueueEntry]: + for fragment in self.visible_fragments(): + yield from fragment.queue_entries() + def notification_identity_for(self, references: frozenset[MessageReference]) -> DisplayIdentity | NoDisplaySelection: - """Use retained prepared Core pages, even after the pending paint was retired.""" + """Use the visible BUS fragments' construction-time display identity.""" return next((fragment.notification_identity for fragment in reversed(tuple(self.visible_fragments())) if references.intersection(fragment.notification_references)), NoDisplaySelection()) diff --git a/src/toad/widgets/presentation_window.py b/src/toad/widgets/presentation_window.py index 3a6d3ba53..5320891a3 100644 --- a/src/toad/widgets/presentation_window.py +++ b/src/toad/widgets/presentation_window.py @@ -333,7 +333,7 @@ class PageRevision[Cursor = TranscriptCursor](WorkRevision): @dataclass(frozen=True) class GrowingPageRevision[Cursor = TranscriptCursor](PageRevision[Cursor]): - """A newer request also depends on the frontier admitted for this read.""" + """A newer request also depends on the frontier this read reached.""" through: Cursor diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 178e063a2..0e5a5daab 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -46,8 +46,7 @@ from toad.markdown_preparation import PreparedMarkdownPart from toad.menus import MenuItem from toad.message_rows import MarkdownMessageStyle, MessageRow, WireMessageStyle -from toad.notification_status import NotificationStatus, UncheckedNotifications -from toad.submission_queue import MessageQueueEntry +from toad.notification_status import MessageQueueEntry, NotificationStatus, UncheckedNotifications from toad.widgets.agent_activity import AgentActivityPresentation from toad.widgets.transcript_lines import AcquiredRows, NoRows, RowAcquisition @@ -342,11 +341,9 @@ def shown_live_by(self, claim: CommitClaim) -> bool: def notification_references(self) -> tuple[MessageReference, ...]: return () - notification_identity: DisplayIdentity | NoDisplaySelection = field( - default=NoDisplaySelection(), kw_only=True, repr=False, compare=False) - - def capture_notification_identity(self, display: DisplayIdentity | NoDisplaySelection) -> None: - self.notification_identity = display + @property + def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: + return NoDisplaySelection() def queue_entries(self) -> tuple[MessageQueueEntry, ...]: return () @@ -509,6 +506,8 @@ class BusMessageFragment(TranscriptFragment): shown: MessageShown = field(default_factory=MessageShown) text_part: str row: MessageRow + notification_identity: DisplayIdentity | NoDisplaySelection = field( + default=NoDisplaySelection(), repr=False, compare=False) @classmethod def capture_part(cls, event: TextTranscript, text: str, **kwargs): @@ -528,7 +527,7 @@ def notification_references(self) -> tuple[MessageReference, ...]: return self.part_position.notification_references(self.events[0]) def queue_entries(self) -> tuple[MessageQueueEntry, ...]: - return tuple(MessageQueueEntry(reference, self.shown.status, self.notification_identity) + return tuple(MessageQueueEntry(reference, self.shown.status) for reference in self.notification_references) def draws(self, message: BusMessage) -> bool: diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index aa2a725ab..11cee3b79 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -223,7 +223,7 @@ def of(cls, message: Message, *, reader: str, **fields) -> WireFragment: return cls._roles.dispatch(type(message.role))(message, reader=reader, **fields) @classmethod - def from_message(cls, message, *, reader, **fields): + def from_message(cls, message, *, reader, page: MessagePage, **fields): return cls((IncomingTranscript.of(message, reader=reader),), **fields) @property @@ -276,11 +276,12 @@ class MessageFragment(BusMessageFragment, WireFragment, roles=(ConversationRole, """A sent message: its header, text and the state of its notifications.""" @classmethod - def from_message(cls, message, *, reader, **fields): + def from_message(cls, message, *, reader, page: MessagePage, **fields): return cls( (IncomingTranscript.of(message, reader=reader),), text_part=message.body, row=MessageRow(message, reader), + notification_identity=page.selection.notification_identity, **fields, ) @@ -345,7 +346,7 @@ def read(self, request: PageRequest[WireCut], through: AfterSequence) -> Prepare page = request.end.wire_page(self, request.cursor, through) shown = MessageShown(style=self.style) fragments = tuple( - WireFragment.of(message, shown=shown, evidence=page.evidence_for(message), reader=self.me) + WireFragment.of(message, shown=shown, evidence=page.evidence_for(message), reader=self.me, page=page) for message in page.messages ) wire = WirePage( From 599d34314ccb91a4622caf7d44065cf48b4fbd60 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 13:13:30 -0400 Subject: [PATCH 21/22] Keep queue presentation on its captured evidence --- src/toad/screens/main.py | 3 ++- src/toad/submission_queue.py | 7 +++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 170b0d4d9..efe0d6ffc 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -14,6 +14,7 @@ from metaclass_registry.mro_dispatch import handles from agent_comms.route_selection import RouteSelection from agent_comms.repo import Checkout +from agent_comms.transcripts import TranscriptCursor from textual import containers, getters, on from textual.app import ComposeResult from textual.binding import ModeBinding @@ -227,7 +228,7 @@ def focus_sidebar_return(self, screen: MainScreen) -> None: def read_visible(self, screen: MainScreen) -> PaintedRead: return screen.conversation.painted_read(partial(self.read_work, screen)) - def read_work(self, screen: MainScreen, through) -> ReadWork: + def read_work(self, screen: MainScreen, through: TranscriptCursor) -> ReadWork: """Only a painted cursor reaches route selection and the write worker.""" conversation = screen.conversation app = screen.app diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index 7c3967850..f92c76e01 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -156,8 +156,7 @@ class QueueInspection(DismissibleScreen[None]): def __init__(self, source: QueueSource, evidence: QueueEvidence, changed: Callable[[], None]): super().__init__() self.source, self.evidence = source, evidence - self.presentation = evidence.presentation - self.scope = source.queue_scope(self.presentation) + self.scope = source.queue_scope(evidence.presentation) self.changed = changed def compose(self): @@ -170,14 +169,14 @@ def compose(self): yield Static("Individual edit/delete/reorder requires ID-targeted, revision-checked backend support.") yield Static("", id="queue-error") with HorizontalGroup(): - available = self.source.queue_actions_available(self.presentation) + available = self.source.queue_actions_available(self.evidence.presentation) yield Button("Send now", id="queue-send-now", disabled=not available) yield Button("Clear queued inputs", id="queue-clear", disabled=not available) yield Button("Close", id="queue-close") async def apply_action(self, action: Callable[[AgentPresentation, QueueScope], Awaitable[None]]) -> None: try: - await action(self.presentation, self.scope) + await action(self.evidence.presentation, self.scope) except (QueueActionRefused, RequestError) as error: self.query_one("#queue-error", Static).update(str(error)) return From 3a864eb45855f80f7271582ae98ab33404bc844f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 14:08:20 -0400 Subject: [PATCH 22/22] Type conversation inputs and remove queue UI import cycles --- src/toad/conversation_kind.py | 15 ++--- src/toad/conversation_submission.py | 15 +---- src/toad/core/input_events.py | 6 -- src/toad/navigation_target.py | 6 ++ src/toad/screens/comms.py | 3 +- src/toad/screens/goal_details.py | 2 +- src/toad/screens/main.py | 2 +- src/toad/screens/queue.py | 71 ++++++++++++++++++++ src/toad/submission_queue.py | 70 ++----------------- src/toad/transcript_preparation.py | 6 -- src/toad/widgets/conversation.py | 4 +- src/toad/widgets/linked_rows.py | 4 +- src/toad/widgets/thread_comms.py | 3 +- src/toad/widgets/wire_history.py | 2 +- src/toad/wire_history.py | 11 +-- tests/app/test_recorded_transcript_reader.py | 21 +++--- tests/support/native_steps.py | 8 +-- tests/textual/test_goal_owner_views.py | 2 +- tests/textual/test_wire_source.py | 10 +-- 19 files changed, 128 insertions(+), 133 deletions(-) create mode 100644 src/toad/screens/queue.py diff --git a/src/toad/conversation_kind.py b/src/toad/conversation_kind.py index 34fce26d5..24c2b4b36 100644 --- a/src/toad/conversation_kind.py +++ b/src/toad/conversation_kind.py @@ -31,6 +31,9 @@ from agent_comms.thread_identity import ThreadIncarnation from textual.app import ComposeResult +from toad.conversation_submission import ConversationSubmissions +from toad.widgets.channel_prompt import ChannelPrompt +from toad.widgets.prompt import MessagePrompt from toad.status_thread import NoStatusThread, StatusThread from toad.widgets.channel_activity import ChannelActivity from toad.widgets.session_details import ConversationSessionDetails @@ -133,7 +136,7 @@ def active_members(self, model: SidebarModel): def compose_activity(cls, view: CommsChatView) -> ComposeResult: ... @abstractmethod - def prompt(self, project: Checkout, *, submissions): ... + def prompt(self, project: Checkout, *, submissions: ConversationSubmissions): ... @classmethod def seen_page(cls, history: WireHistory, seen): @@ -206,9 +209,7 @@ async def status_text(self, comms: Comms, *, read_only: bool) -> str: def initial_placeholder(self) -> str: return f"Message {self.target.name}" - def prompt(self, project: Checkout, *, submissions): - from toad.widgets.channel_prompt import ChannelPrompt - + def prompt(self, project: Checkout, *, submissions: ConversationSubmissions): return ChannelPrompt(project=project, placeholder=self.initial_placeholder, submissions=submissions) def read_only(self, comms: Comms) -> bool: @@ -216,8 +217,6 @@ def read_only(self, comms: Comms) -> bool: @classmethod async def update_mentions(cls, view: CommsChatView, comms: Comms): - from toad.widgets.channel_prompt import ChannelPrompt - history = view.message_history source = history.source_snapshot() conversation = view.conversation @@ -293,9 +292,7 @@ def compose_activity(cls, view) -> ComposeResult: view.thread_activity = ConversationSessionDetails(view.conversation.peer) yield view.thread_activity - def prompt(self, project: Checkout, *, submissions): - from toad.widgets.prompt import MessagePrompt - + def prompt(self, project: Checkout, *, submissions: ConversationSubmissions): return MessagePrompt(project=project, submissions=submissions) async def status_text(self, comms: Comms, *, read_only: bool) -> str: diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index dc69978ae..18ba4ffc8 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -23,7 +23,7 @@ from toad.command_catalog import CommandCatalog from toad.acp.agent import Agent from toad.acp.queue_attachment import QueueActionRefused -from toad.submission_queue import QueueEvidence, QueueInspection, QueueSource +from toad.submission_queue import QueueEvidence from toad.core.input_events import UserInputSubmitted from toad.core_event_carrier import CoreEventMessage from toad.input_decision import InputDecision, InputRefused @@ -281,24 +281,15 @@ async def submit(self, event: UserInputSubmitted): def accepts_failure(self, message: CoreEventMessage): return self.view.agent_binding.accepts_failed_input(message) - @property - def queue_source(self) -> QueueSource: - return self.view.queue_source - @property def queue_evidence(self) -> QueueEvidence: return QueueEvidence( self.view.agent_binding.presentation, self.delivering, - self.view.send_block_reason, self.view.contents.notification_targets()) + self.view.send_block_reason, self.view.contents.notification_targets) @property def queue_summary(self) -> str: - return self.queue_source.queue_summary(self.queue_evidence) - - def inspect_queue(self): - inspection = QueueInspection(self.queue_source, self.queue_evidence, self.publish_pending) - self.view.app.push_screen(inspection) - return inspection + return self.view.queue_source.queue_summary(self.queue_evidence) @property def queue_projection(self): diff --git a/src/toad/core/input_events.py b/src/toad/core/input_events.py index 139e6fa7d..694fee7f3 100644 --- a/src/toad/core/input_events.py +++ b/src/toad/core/input_events.py @@ -4,7 +4,6 @@ from typing import Literal from toad.input_history import InputHistory -from toad.navigation_target import NavigationTarget from toad.goal_interaction import GoalInteraction from .events import CoreEvent @@ -61,11 +60,6 @@ class OpenAgentDetails(CoreEvent): identity: str -@dataclass(frozen=True) -class SelectTarget(CoreEvent): - target: NavigationTarget - - @dataclass(frozen=True) class SelectHistoricalIdentity(CoreEvent): name: str diff --git a/src/toad/navigation_target.py b/src/toad/navigation_target.py index 1a4eb6769..063e28bde 100644 --- a/src/toad/navigation_target.py +++ b/src/toad/navigation_target.py @@ -11,6 +11,7 @@ from agent_comms.channel_targets import ThreadTarget as CoreThreadTarget from agent_comms.declared_family import DeclaredFamily +from toad.core.events import CoreEvent from toad.constants import ALL_COMMS_TARGET from toad.navigation_context import NavigationContext, NavigationOwner from toad.conversation_kind import ( @@ -63,6 +64,11 @@ async def open(self, context: NavigationContext) -> str: pass +@dataclass(frozen=True) +class SelectTarget(CoreEvent): + target: NavigationTarget + + @dataclass(frozen=True) class NoTarget(NavigationTarget): name: str = field(default="", init=False) diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 4a5ba97c4..2a21c160b 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -23,11 +23,10 @@ from toad.conversation_kind import ConversationKind from toad.widgets.comms_chat import CommsChatView from toad.core.input_events import SelectHistoricalIdentity -from toad.core.input_events import SelectTarget from toad.widgets.comms_sidebar import CoordinationStatus, CommsSidebar from toad.session_tracker import SidebarState from toad.navigation_context import NavigationContext -from toad.navigation_target import HistoryTarget, NavigationTarget +from toad.navigation_target import HistoryTarget, NavigationTarget, SelectTarget from toad.widgets.session_tabs import SessionsTabs from toad.widgets.side_bar import SideBar, ThreadSidebar, TabHistoryControls from toad.widgets.thread_comms import RelationshipSort, ThreadCommsSidebar diff --git a/src/toad/screens/goal_details.py b/src/toad/screens/goal_details.py index 905851b41..8a6974487 100644 --- a/src/toad/screens/goal_details.py +++ b/src/toad/screens/goal_details.py @@ -11,7 +11,7 @@ from toad.widget_actions import DismissAction from textual.widgets import Button, Static -from toad.core.input_events import SelectTarget +from toad.navigation_target import SelectTarget from toad.goal_interaction import GoalSession from toad.project_path_owner import ProjectPathOwner from toad.widgets.goal_text import GoalText diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index efe0d6ffc..61565a6e9 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -37,13 +37,13 @@ from toad.core import events as core_events from toad.core import input_events, session_requests from toad.core.events import SessionChangedEvent -from toad.core.input_events import SelectTarget from toad.format_path import format_path from toad.navigation_context import NavigationContext from toad.navigation_target import ( DirectTarget, FeedTarget, NavigationTarget, + SelectTarget, ThreadTarget, ) from toad.screens.mcp_inventory import MCPInventoryScreen diff --git a/src/toad/screens/queue.py b/src/toad/screens/queue.py new file mode 100644 index 000000000..816ef9ece --- /dev/null +++ b/src/toad/screens/queue.py @@ -0,0 +1,71 @@ +"""Inspect the captured queue source through the actual conversation view.""" + +from collections.abc import Awaitable, Callable + +from acp.exceptions import RequestError +from agent_comms.acp_extension import QueueScope +from textual import on +from textual.app import ComposeResult +from textual.containers import HorizontalGroup, Vertical, VerticalScroll +from textual.widgets import Button, Static + +from toad.acp.queue_attachment import QueueActionRefused +from toad.agent_presentation import AgentPresentation +from toad.line_blocks import SpanColumns +from toad.screens.modal import DismissibleScreen +from toad.submission_queue import QueueEvidence, QueueSource +from toad.widgets.tool_content import ToolContent + + +class QueueInspection(DismissibleScreen[None]): + """One screen captures the source, presentation and scope it actually displays.""" + + DEFAULT_CSS = """ + QueueInspection { align: center middle; background: $background 40%; } + QueueInspection > Vertical { width: 85%; max-width: 110; height: 75%; padding: 1 2; background: $surface; } + QueueInspection VerticalScroll { height: 1fr; } + QueueInspection HorizontalGroup { height: auto; } + QueueInspection #queue-error { height: auto; color: $error; } + """ + + def __init__(self, source: QueueSource, evidence: QueueEvidence, changed: Callable[[], None]) -> None: + super().__init__() + self.source, self.evidence = source, evidence + self.scope = source.queue_scope(evidence.presentation) + self.changed = changed + + def compose(self) -> ComposeResult: + with Vertical(): + yield Static("Queue and delivery") + with VerticalScroll(): + details = ToolContent() + details.update((SpanColumns(self.source.queue_details(self.evidence)),)) + yield details + yield Static("Individual edit/delete/reorder requires ID-targeted, revision-checked backend support.") + yield Static("", id="queue-error") + with HorizontalGroup(): + available = self.source.queue_actions_available(self.evidence.presentation) + yield Button("Send now", id="queue-send-now", disabled=not available) + yield Button("Clear queued inputs", id="queue-clear", disabled=not available) + yield Button("Close", id="queue-close") + + async def apply_action(self, action: Callable[[AgentPresentation, QueueScope], Awaitable[None]]) -> None: + try: + await action(self.evidence.presentation, self.scope) + except (QueueActionRefused, RequestError) as error: + self.query_one("#queue-error", Static).update(str(error)) + return + self.changed() + self.dismiss() + + @on(Button.Pressed, "#queue-send-now") + async def send_now(self) -> None: + await self.apply_action(self.source.queue_send_now) + + @on(Button.Pressed, "#queue-clear") + async def clear_queue(self) -> None: + await self.apply_action(self.source.queue_clear) + + @on(Button.Pressed, "#queue-close") + def close_queue(self) -> None: + self.dismiss() diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index f92c76e01..8c3d57bf9 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -1,27 +1,21 @@ -"""One queue screen over the source's actual attachment or captured BUS evidence.""" +"""Source-owned queue capabilities and captured evidence, independent of screens.""" from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass from functools import cached_property from typing import ClassVar -from acp.exceptions import RequestError from agent_comms.acp_extension import NoQueueScope, QueueItem, QueueScope from agent_comms.message_reference import MessageReference from agent_comms.notification_evidence import NotificationWindow -from textual import on -from textual.containers import HorizontalGroup, Vertical, VerticalScroll -from textual.widgets import Button, Static from toad.acp.queue_attachment import QueueActionRefused from toad.agent_presentation import AgentPresentation -from toad.line_blocks import MutedRole, Span, SpanColumns +from toad.line_blocks import MutedRole, Span from toad.notification_status import DetailLines, MessageQueueEntry -from toad.screens.modal import DismissibleScreen from toad.widgets.message_notifications import SourceNotifications -from toad.widgets.tool_content import ToolContent @dataclass(frozen=True) @@ -31,12 +25,12 @@ class QueueEvidence: presentation: AgentPresentation delivering: tuple[str, ...] transport: str - targets: tuple[SourceNotifications, ...] + read_targets: Callable[[], tuple[SourceNotifications, ...]] @cached_property def entries(self) -> tuple[MessageQueueEntry, ...]: entries: dict[MessageReference, MessageQueueEntry] = {} - for target in self.targets: + for target in self.read_targets(): for entry in target.queue_entries(): entries[entry.reference] = entry if len(entries) >= NotificationWindow.window_limit: @@ -140,57 +134,3 @@ def queue_details(self, evidence: QueueEvidence) -> DetailLines: *(line for entry in evidence.entries[:self.preview_limit] for line in entry.details), (Span("Clear/send-now unavailable: this BUS source has no native queue authority.", MutedRole),), ) - - -class QueueInspection(DismissibleScreen[None]): - """One screen captures the source, presentation and scope it actually displays.""" - - DEFAULT_CSS = """ - QueueInspection { align: center middle; background: $background 40%; } - QueueInspection > Vertical { width: 85%; max-width: 110; height: 75%; padding: 1 2; background: $surface; } - QueueInspection VerticalScroll { height: 1fr; } - QueueInspection HorizontalGroup { height: auto; } - QueueInspection #queue-error { height: auto; color: $error; } - """ - - def __init__(self, source: QueueSource, evidence: QueueEvidence, changed: Callable[[], None]): - super().__init__() - self.source, self.evidence = source, evidence - self.scope = source.queue_scope(evidence.presentation) - self.changed = changed - - def compose(self): - with Vertical(): - yield Static("Queue and delivery") - with VerticalScroll(): - details = ToolContent() - details.update((SpanColumns(self.source.queue_details(self.evidence)),)) - yield details - yield Static("Individual edit/delete/reorder requires ID-targeted, revision-checked backend support.") - yield Static("", id="queue-error") - with HorizontalGroup(): - available = self.source.queue_actions_available(self.evidence.presentation) - yield Button("Send now", id="queue-send-now", disabled=not available) - yield Button("Clear queued inputs", id="queue-clear", disabled=not available) - yield Button("Close", id="queue-close") - - async def apply_action(self, action: Callable[[AgentPresentation, QueueScope], Awaitable[None]]) -> None: - try: - await action(self.evidence.presentation, self.scope) - except (QueueActionRefused, RequestError) as error: - self.query_one("#queue-error", Static).update(str(error)) - return - self.changed() - self.dismiss() - - @on(Button.Pressed, "#queue-send-now") - async def send_now(self): - await self.apply_action(self.source.queue_send_now) - - @on(Button.Pressed, "#queue-clear") - async def clear_queue(self): - await self.apply_action(self.source.queue_clear) - - @on(Button.Pressed, "#queue-close") - def close_queue(self): - self.dismiss() diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index c3f814a40..0aae526a0 100644 --- a/src/toad/transcript_preparation.py +++ b/src/toad/transcript_preparation.py @@ -11,7 +11,6 @@ from weakref import ReferenceType, ref from agent_comms.historical_views import ConversationObservation -from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.sent_message import OutgoingMessageReference from agent_comms.transcripts import ( TranscriptCursor, @@ -79,11 +78,6 @@ def measured(cls, page, fragments: tuple[TranscriptFragment, ...], **view): prepared.measure_sources() return prepared - @property - def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: - """Native transcript pages have no captured BUS display selection.""" - return NoDisplaySelection() - @property def source_payload_bytes(self) -> int: return sum(256 + 4 * event.text_size for event in self.page.events) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index d5a5cfb6e..aea69e648 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -47,6 +47,7 @@ from toad.agent_binding import AgentConnection, NoAgentConnection from toad.messages import QueueEvidenceChanged from toad.submission_queue import QueueSource +from toad.screens.queue import QueueInspection from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.conversation_actions import ConversationAction, WindowAction @@ -1644,7 +1645,8 @@ def queue_observation_changed(self, event: QueueEvidenceChanged) -> None: self.queue_evidence_changed() def open_queue_menu(self) -> None: - self.submissions.inspect_queue() + self.app.push_screen(QueueInspection( + self.queue_source, self.submissions.queue_evidence, self.submissions.publish_pending)) @handles(PreferenceChanged) async def _settings_changed(self, event: PreferenceChanged) -> None: diff --git a/src/toad/widgets/linked_rows.py b/src/toad/widgets/linked_rows.py index c261d84a0..f63cd73e0 100644 --- a/src/toad/widgets/linked_rows.py +++ b/src/toad/widgets/linked_rows.py @@ -18,13 +18,13 @@ from toad.block_navigation import ConversationEntry from toad.core.events import LinkContextChanged -from toad.core.input_events import SelectHistoricalIdentity, SelectTarget +from toad.core.input_events import SelectHistoricalIdentity from toad.core_event_carrier import CoreEventReceiver from toad.frame_presentation import AnimationEffects from toad.line_blocks import LineLink, OpenHistoricalTarget, OpenReference, OpenTarget from toad.menus import MenuItem from toad.message_rows import MessageRow -from toad.navigation_target import NavigationTarget +from toad.navigation_target import NavigationTarget, SelectTarget from toad.project_link import ProjectLink from toad.project_path_owner import ProjectPathOwner from toad.widgets.transcript_lines import Lines diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index a2f2747bb..c10c16079 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -7,7 +7,7 @@ from toad.widgets.thread_identity import ShowsThreadIdentity from toad.core import events as core_events -from toad.navigation_target import NavigationTarget, ThreadTarget +from toad.navigation_target import NavigationTarget, SelectTarget, ThreadTarget import asyncio from dataclasses import dataclass, field @@ -22,7 +22,6 @@ from textual.geometry import Offset from textual.widgets import Checkbox, Static -from toad.core.input_events import SelectTarget from toad.widgets.comms_sidebar import CommsRow, ThreadStatusRow from agent_comms.ui_model.message_category import MessageCategory from toad.widgets.session_sort import SortControl, ThreadSortForm diff --git a/src/toad/widgets/wire_history.py b/src/toad/widgets/wire_history.py index b34faf17e..a8eb51097 100644 --- a/src/toad/widgets/wire_history.py +++ b/src/toad/widgets/wire_history.py @@ -57,7 +57,7 @@ def initialize_source(self, source: WireSource) -> None: @property def undrawn(self) -> bool: """Nothing of the bound source has been drawn yet.""" - return all(view.prepared.page.undrawn for view in self.pages) + return all(view.prepared.page.after.requires_read() for view in self.pages) @property def source_identity(self): diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 11cee3b79..0ac7fe6b9 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -23,7 +23,6 @@ ConversationRevision, ) from agent_comms.message_page import MessagePage, MessagePageRequest -from agent_comms.read_basis import DisplayIdentity, NoDisplaySelection from agent_comms.messages import ConversationRole, Membership, Message, NoticeRole from agent_comms.page_traversal import ( LiveCursor, @@ -114,6 +113,9 @@ def advanced_source(self, current: MessagePage, incoming: MessagePage) -> Messag def observed(self) -> AfterMessage: return self + def requires_read(self) -> bool: + return False + @dataclass(frozen=True) class AfterSequence(AfterMessage): @@ -149,6 +151,9 @@ def advanced_source(self, current: MessagePage, incoming: MessagePage) -> Messag def observed(self) -> AfterSequence: return AfterSequence(self.cursor) + def requires_read(self) -> bool: + return True + @dataclass(frozen=True) class WirePage: @@ -188,10 +193,6 @@ def advanced(self, interval: WirePage, *, older: bool) -> WirePage: class PreparedWirePage(PreparedTranscriptPage): """Wire intervals preserve Core's original display page when advanced empty.""" - @property - def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: - return self.page.source.selection.notification_identity - def advance_empty(self, prepared: PreparedTranscriptPage, older: bool) -> None: self.page = self.page.advanced(prepared.page, older=older) diff --git a/tests/app/test_recorded_transcript_reader.py b/tests/app/test_recorded_transcript_reader.py index efa039d2f..2aa5b5ee3 100644 --- a/tests/app/test_recorded_transcript_reader.py +++ b/tests/app/test_recorded_transcript_reader.py @@ -2,7 +2,7 @@ from agent_comms.repo import Checkout import json -from dataclasses import fields, replace +from dataclasses import replace import pytest @@ -10,7 +10,7 @@ from agent_comms.coordination_errors import HistoricalSourceChanged, StaleRevision from agent_comms.order_tools import TranscriptPageRead from agent_comms.session_file import RegistrySessionLocation -from agent_comms.transcripts import EarlierThan, LaterThan, RecordedTranscriptReadIdentity +from agent_comms.transcripts import EarlierThan, LaterThan, RecordedPageSource, RecordedTranscriptReadIdentity from agent_comms.threads import Thread from agent_comms.ui_model.observation import NotPublished from support.roots import installed_wire @@ -36,13 +36,14 @@ async def test_recorded_snapshot_stays_immutable_when_original_grows_and_rejects async with workspace.app() as ui: reader = ui.app.coordination_access.transcripts - update = await reader.snapshot(KnownTranscript(str(live.root), "reader"), historical_source=source.key) + update = await reader.snapshot(KnownTranscript(str(live.root), "reader"), + page_source=RecordedPageSource(source.key)) assert isinstance(update.identity, RecordedTranscriptReadIdentity) assert decode_updates(encode_updates(update)) == (update,) with native.open("a") as output: 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.page_source == RecordedPageSource(source.key) assert refreshed.identity.thread.incarnation == source.provenance.threads["reader"].incarnation assert [event.text for event in refreshed.page.events] == ["recorded answer"] assert refreshed == update @@ -57,26 +58,24 @@ async def test_recorded_snapshot_stays_immutable_when_original_grows_and_rejects def test_recorded_presentation_does_not_acquire_live_owner(recorded): live, source, _native = recorded presentation = live.views.thread_presentations(("reader",)).read("reader") - identity = live.transcripts.capture_page_read("reader", historical_source=source.key).identity + identity = live.transcripts.capture_page_read("reader", page_source=RecordedPageSource(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. consumer = OwnerSnapshotConsumer(None, "authored", turn_token=1) consumer.thread_presentation(replace(presentation, read_identity=identity)) assert TranscriptSnapshotUpdate.capture(live.transcripts, "reader", - historical_source=source.key).identity == identity + page_source=RecordedPageSource(source.key)).identity == identity def test_known_transcript_requests_carry_the_page_operation_and_keep_root_at_the_envelope(recorded): live, source, _native = recorded scope = KnownTranscript(str(live.root), "reader") - cursor = live.transcripts.capture_page_read("reader", historical_source=source.key).read().after - snapshot = scope.snapshot_request(position=EarlierThan(cursor), historical_source=source.key) + cursor = live.transcripts.capture_page_read("reader", page_source=RecordedPageSource(source.key)).read().after + snapshot = scope.snapshot_request(position=EarlierThan(cursor), page_source=RecordedPageSource(source.key)) assert snapshot.root == str(live.root) assert snapshot.operation == TranscriptPageRead( - thread="reader", position=EarlierThan(cursor), historical_source=source.key) + thread="reader", position=EarlierThan(cursor), page_source=RecordedPageSource(source.key)) previous = NotPublished() update = scope.update_request(previous, position=LaterThan(cursor)) assert update.root == str(live.root) and update.previous is previous assert update.operation == TranscriptPageRead(thread="reader", position=LaterThan(cursor)) - # The shared operation never carries the UI's root, request or publication witness. - assert not {"root", "request_id", "previous", "identity"} & {f.name for f in fields(TranscriptPageRead)} diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index cd5819cfa..ac501ca90 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager from agent_comms.channel_targets import ChannelTarget -from agent_comms.notification_progress import AnsweredProgress, ConsumedDelivery, IgnoredProgress +from agent_comms.notification_progress import AnsweredProgress, ConsumedDelivery from toad.conversation_kind import ChannelConversation, DmConversation from toad.core import input_events from toad.navigation_target import DirectTarget, channel_target @@ -82,7 +82,7 @@ def replies(): feedback = feedback_of(chat, sent) assert isinstance(feedback, CheckedNotifications) assert any(item.recipient == target and isinstance(item.state, AnsweredProgress) - and item.answer == reply.reference + and item.state.response.reference == reply.reference for item in feedback.notifications) await ui.close_tab(screen.id) await DirectTarget(target).open(navigation) @@ -130,8 +130,8 @@ async def channel_message_feedback(native, owner_tab: str) -> None: why="seen, not answering feedback after declining the channel message") feedback = feedback_of(chat, sent) assert isinstance(feedback, CheckedNotifications) - assert any(item.recipient == native.thread and isinstance(item.state, IgnoredProgress) - and item.state.seen_not_answering and item.answer is None for item in feedback.notifications) + assert any(item.recipient == native.thread and item.state.seen_not_answering + for item in feedback.notifications) @asynccontextmanager diff --git a/tests/textual/test_goal_owner_views.py b/tests/textual/test_goal_owner_views.py index 18a78e60c..6ead72c29 100644 --- a/tests/textual/test_goal_owner_views.py +++ b/tests/textual/test_goal_owner_views.py @@ -23,7 +23,7 @@ from support.agents import RecordedEvents from support.textual_steps import channel_roster from toad.core.events import CommsUpdated -from toad.core.input_events import SelectTarget +from toad.navigation_target import SelectTarget from toad.screens.goal_details import GoalDetails from toad.screens.goal_edit import GoalEdit from toad.widgets.goal_bar import GoalBar diff --git a/tests/textual/test_wire_source.py b/tests/textual/test_wire_source.py index f29c6b420..385569e54 100644 --- a/tests/textual/test_wire_source.py +++ b/tests/textual/test_wire_source.py @@ -41,16 +41,16 @@ def test_empty_first_read_keeps_its_real_identity_after_the_unread_placeholder(t comms.registry.declare(Thread('peer', frozenset({'team'}), Checkout(tmp_path))) source = channel_source(comms, '#team', tmp_path) placeholder = PreparedWirePage.empty(WirePage.unread()) - assert placeholder.page.undrawn and not placeholder.page.source.selection.has_evidence + assert placeholder.page.after.requires_read() and not placeholder.page.source.selection.has_evidence initial = NothingRead() prepared = source.read(PageRequest(OLDER_END, initial), initial) - assert not prepared.fragments and not prepared.page.undrawn + assert not prepared.fragments and not prepared.page.after.requires_read() identity = prepared.page.source.selection.notification_identity - assert prepared.notification_identity == identity assert identity.viewer == comms.registry.require(source.viewer.name).incarnation assert placeholder.page.accepts(prepared.page.source, source.conversation.history_view) placeholder.advance_empty(prepared, older=True) - assert not placeholder.page.undrawn and placeholder.notification_identity == identity + assert not placeholder.page.after.requires_read() + assert placeholder.page.source.selection.notification_identity == identity def test_a_turn_claim_on_the_peer_keeps_the_direct_message_page_identity(tmp_path): @@ -80,6 +80,8 @@ def bodies(prepared): assert newest.page.after == first.through and not newest.page.has_newer and newest.page.has_older assert all(isinstance(fragment, MessageFragment) and not fragment.outgoing_sources for fragment in newest.fragments) + assert all(fragment.notification_identity == newest.page.source.selection.notification_identity + for fragment in newest.fragments) earlier = source.read(PageRequest(OLDER_END, newest.page.before), first.through) assert bodies(earlier) == [f"Message {index}" for index in range(12, 52)] assert earlier.page.has_newer and earlier.page.has_older