diff --git a/src/toad/acp/agent.py b/src/toad/acp/agent.py index bab886c5d..b13f78a32 100644 --- a/src/toad/acp/agent.py +++ b/src/toad/acp/agent.py @@ -1,7 +1,8 @@ import asyncio -from dataclasses import replace +from dataclasses import dataclass from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING import rich.repr from agent_comms.acp_extension import ( @@ -12,11 +13,9 @@ from agent_comms.context_reading import UnmeasuredContext from agent_comms.nominal.field_codec import FieldCodec, GoalId from agent_comms.goal_actions import GoalAction -from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution -from agent_comms.goals import Goal, GoalDeclaration, NoGoal +from agent_comms.goal_presentation import GoalSnapshot +from agent_comms.goals import Goal, GoalDeclaration from agent_comms.goal_history import RecordedGoalHistory -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 ( @@ -26,29 +25,36 @@ from toad.acp.agent_controller import AgentController from toad.acp.agent_log import AgentLog from toad.acp.agent_process import AgentProcess -from toad.acp.agent_session import AgentSession +from toad.acp.agent_session import AgentSession, SessionDisposition from toad.acp.attachment_presentation import CursorPresentation, QueuePresentation from toad.acp.client_files import FileClientRequestOwner from toad.acp.client_session import ClientSessionRequest -from toad.acp.comms_updates import ( - CommsUpdateConsumer, - NoPendingThreadRead, - PendingThreadRead, - ThreadRead, -) +from toad.acp.comms_updates import CommsUpdateConsumer +from toad.acp.coordination_attachment import CoordinationAttachment, CoordinatedThread +from toad.acp.thread_read import ThreadRead from toad.acp.context_measurement import ContextMeasurement from toad.acp.permission_controller import PermissionController from toad.acp.projection_attachment import CursorAttachment from toad.acp.queue_attachment import QueueAttachment from toad.acp.session_updates import SessionNotificationOwner +from toad.acp.status import PromptResult from toad.acp.tool_calls import SessionToolCalls from toad.acp.transcript_reader import CoordinationTranscriptReader from toad.agent import AgentBase from toad.agent_presentation import ACPAgentPresentation from toad.agent_schema import AgentDefinition from toad.core import events as core_events +from toad.conversation_submission import ( + PromptInputSubmission, ConversationSubmissions, NoSend, SendPending, SendState, SubmissionExecution, +) from toad.platform import host +if TYPE_CHECKING: + from agent_comms.session_load import ExistingSessionLoadPlan + from toad.core_event_carrier import CoreEventMessage + from toad.screens.main import MainScreen + from toad.widgets.conversation import Conversation + def generate_datetime_filename( prefix: str, suffix: str, datetime_format: str | None = None @@ -101,13 +107,17 @@ def __init__( project_root: Project root path. command: Command to launch agent. """ + self.definition = agent super().__init__(project_root) self.process = AgentProcess(self) - self.presentation = ACPAgentPresentation(self) + log_filename = generate_datetime_filename(self.definition.name, ".txt") + if (log_path := AgentLog.selected()) is None: + log_path = paths.get_log() / log_filename + self._presentation = ACPAgentPresentation(self, log_path) self.permissions = PermissionController(self) + self.coordination_attachment: CoordinationAttachment = CoordinationAttachment() self.controller = AgentController(self, transcripts) self.tools = SessionToolCalls(self) - self.definition = agent self.session_id = session_id self.updates = SessionNotificationOwner(self) self.files = FileClientRequestOwner(self) @@ -116,13 +126,49 @@ def __init__( self._private_cursor = CursorAttachment() self._private_cursor_sequence = 0 self.queue_attachment = QueueAttachment() - log_filename: str = generate_datetime_filename(f"{agent.name}", ".txt") - if (log_path := AgentLog.selected()) is not None: - self.presentation.log_path = log_path - else: - self.presentation.log_path = paths.get_log() / log_filename self.context_measurement = ContextMeasurement.saved(UnmeasuredContext()) + @property + def presentation(self) -> ACPAgentPresentation: + return self._presentation + + @property + def session_disposition(self) -> SessionDisposition: + return self.session.disposition + + def accepts_publication( + self, message: CoreEventMessage, session_id: str | None + ) -> bool: + return message.publisher is self and self.session_id == session_id + + def borrowed_processes(self) -> tuple[AgentProcess, ...]: + return (self.process,) + + async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None: + await self.session.reconnect(plan) + + async def change_project(self, view: Conversation, path: Path) -> None: + from textual.content import Content + + project = await self.update_project(str(path)) + view.feedback.flash(Content(f"Project changed to {project}"), style="success") + + def request_compaction(self, view: Conversation, instructions: str | None) -> None: + view.compact_context(instructions) + + async def project_changed(self, path: Path) -> None: + from toad.db import DB + + await super().project_changed(path) + if (session_pk := self.session.pk) is not None: + await DB().session_update_project(session_pk, path) + + def refresh_context(self, view: Conversation) -> None: + view.thread_status = self.context_measurement.status() + + async def compact_context(self, instructions: str | None) -> None: + await self.controller.compact_context(instructions) + @property def command(self) -> str | None: """The command used to launch the agent, or `None` if there isn't one.""" @@ -157,7 +203,10 @@ async def stop(self) -> None: await self.process.stop(self) - async def send_prompt(self, prompt: str, *, request: PromptRequest | None = None): + def begin_submission(self, owner: ConversationSubmissions, submission: PromptInputSubmission): + return owner.start(NativeSubmissionExecution(owner, submission, self)) + + async def send_prompt(self, prompt: str, *, request: PromptRequest | None = None) -> PromptResult: """Submit through AgentBase's contract; the controller runs the request.""" return await self.controller.submit(prompt, request=request) @@ -189,13 +238,15 @@ def attach_surface(self, surface) -> None: def detach_surface(self, surface) -> None: self.controller.detach(surface) - @property - def coordination(self) -> CoordinationChangedUpdate | None: - return self.controller.coordination - - @coordination.setter - def coordination(self, value: CoordinationChangedUpdate | None) -> None: - self.controller.coordination = value + def receive_coordination(self, update: CoordinationChangedUpdate, session_id: str) -> None: + """Admit one original publication and refresh its derived projections.""" + self.controller.transcripts.access.selected(update.wire_root) + self.coordination_attachment = CoordinatedThread(update) + self.project_root_path = update.worktree.path + self.context_measurement = ContextMeasurement.saved(update.context_usage) + self.update_status_line() + self.coordination_attachment.publish(self, session_id) + self.session.coordinated_title(update.title) def _post_queue_view(self, starts: tuple[InputStartedUpdate, ...] = ()) -> None: self.controller.surface.sync_queue() @@ -204,7 +255,10 @@ def _post_queue_view(self, starts: tuple[InputStartedUpdate, ...] = ()) -> None: def _post_private_cursor(self) -> None: self._private_cursor_sequence += 1 - self.events.publish(core_events.CommsUpdated(CursorPresentation(self._private_cursor.status), self.session_id, self._private_cursor_sequence)) + self.events.publish(core_events.CommsUpdated( + CursorPresentation(self._private_cursor.status, self._private_cursor_sequence), + self.session_id, + )) def _invalidate_attachment_views(self) -> None: self._private_cursor.invalidate() @@ -232,18 +286,7 @@ async def set_thinking_level(self, level: str) -> str | None: async def get_goal_snapshot(self) -> GoalSnapshot: """The owner's goal snapshot, read by the observation service; its turn settles here.""" - from .comms_updates import OwnerSnapshotConsumer - coordination = self.coordination - if coordination is None: - return GoalSnapshot(NoGoal(), IdleGoalExecution()) - authority = ClientSessionRequest(self, self.session_id) - consumer = OwnerSnapshotConsumer(self, self.session_id, turn_token=self.presentation.turns.sequence) - snapshot = await self.controller.transcripts.read( - 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) - return snapshot + return await self.coordination_attachment.goal_snapshot(self) async def get_goal_history(self, goal_id: GoalId) -> RecordedGoalHistory: result = await self.controller.request_owner("goal_history", goal_id=goal_id) @@ -256,42 +299,17 @@ async def edit_goal(self, goal: Goal, text: str) -> Goal: return FieldCodec.decode(Goal, result["goal"]) def observed_thread(self) -> tuple[str, str] | None: - coordination = self.coordination - if coordination is None or coordination.wire_root is None or coordination.thread.name is None: - return None - return coordination.wire_root, coordination.thread.name + return self.coordination_attachment.observed_thread() def thread_read(self) -> ThreadRead: """Capture, before a presentation read, what its result must still match.""" - from .comms_updates import OwnerSnapshotConsumer - - coordination = self.coordination - if coordination is None: - return NoPendingThreadRead() - return PendingThreadRead(self, coordination, ClientSessionRequest(self, self.session_id), - OwnerSnapshotConsumer(self, self.session_id, - turn_token=self.presentation.turns.sequence)) + return self.coordination_attachment.thread_read(self) async def observe_thread_presentation(self, presentation) -> None: - await self.session.observe_owner(presentation) + await self.coordination_attachment.observe_owner(self, presentation) async def update_project(self, path: str) -> str: - if (self.coordination.wire_root if self.coordination else None) is None or ( - self.coordination.thread.name if self.coordination else None - ) is None: - raise ValueError("Project changes require an agent-comms thread.") - authority = ClientSessionRequest(self, self.session_id) - coordination = self.coordination - comms = await self.controller.transcripts.service(coordination.wire_root) - authority.require() - result = await asyncio.to_thread( - comms.threads.set_project, coordination.thread.name, path, - ) - authority.require() - self.coordination = replace(self.coordination, worktree=result.current) - self.project_root_path = Path(result.current) - self.events.publish(core_events.CommsUpdated(self.coordination, self.session_id)) - return result.current + return await self.coordination_attachment.update_project(self, path) async def update_goal(self, action: type[GoalAction], text: str = "") -> GoalDeclaration: result = await action.submit_control( @@ -306,3 +324,53 @@ async def cancel(self) -> bool: async def set_session_name(self, name: str) -> None: await self.session.set_name(name) + + +@dataclass(frozen=True) +class NativeInputPresented(SendState): + started: InputStartedUpdate + + def response_finished(self, execution) -> None: + execution.enter(NoSend()) + + +class NativeSubmissionExecution(SubmissionExecution): + """ACP owns its captured request and native acknowledgement, not the shared transaction.""" + + def __init__(self, owner: ConversationSubmissions, submission: PromptInputSubmission, agent: Agent): + super().__init__(owner, submission) + self.agent = agent + self.authority = ClientSessionRequest(agent, agent.session_id) + self.scope = agent.queue_attachment.scope + self.request = submission.request + + @property + def current(self): + view = self.owner.view + return (view.agent_binding is self.agent and not self.authority.retired + and self.agent.queue_attachment.accepts_request(self.scope)) + + async def feedback(self): + await self.submission.feedback(self) + + def accepted(self, accepted) -> bool: + return self.request.input_id in accepted or not self.agent.presentation.uses_managed_turns + + def native_input_presented(self, started: InputStartedUpdate) -> None: + if self.current and started.started_request(self.request.input_id): + self.state.presented(self, NativeInputPresented(started)) + + async def deliver(self): + view = self.owner.view + local = not self.agent.presentation.uses_managed_turns + if local: + view.turns.start_client() + self.enter(SendPending()) + outcome = await self.agent.send_prompt(self.submission.text, request=self.request) + outcome.settle_submission(self) + if self.current and local: + view.call_later(self.complete, outcome) + + async def complete(self, outcome: PromptResult): + if self.current: + await outcome.finish_turn(self.owner.view) diff --git a/src/toad/acp/agent_controller.py b/src/toad/acp/agent_controller.py index dd074fa6e..a3b7e0d3f 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -4,7 +4,7 @@ import asyncio from abc import ABC, abstractmethod -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from functools import partial from typing import TYPE_CHECKING @@ -13,25 +13,18 @@ ClearQueueRequest, CommsRequest, CompactRequest, - CoordinationChangedUpdate, - InputFailedUpdate, PromptRequest, QueuePromptRequest, SendNowRequest, encode_request, ) -from agent_comms.acp_failure import ACPFailure, RequestFailure -from agent_comms.owner_connection import socket_path -from agent_comms.runtime import RuntimeConnection +from agent_comms.nominal.field_codec import AcpSessionId from agent_comms.transcripts import TranscriptReadIdentity -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 agent_comms.ui_model.delivery import DeliveryView from toad.acp.client_session import ClientSessionRequest, QueueSessionRequest -from toad.acp.status import StopReason +from toad.acp.status import PromptResult 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 @@ -51,7 +44,7 @@ from pydantic import JsonValue from toad.acp.agent import Agent - from toad.core.events import AgentFail + from toad.acp.coordination_attachment import CoordinationAttachment from toad.render_backend import Renderer, RenderTask @@ -70,11 +63,10 @@ def accepts_notification(self, session_id: str): return self.bound and self.session_id == session_id def accepts_snapshot( - self, identity: TranscriptReadIdentity, coordination: CoordinationChangedUpdate | None + self, identity: TranscriptReadIdentity, coordination: CoordinationAttachment, + destination: AcpSessionId, ) -> bool: - if not self.accepts_notification(identity.requested_name) or coordination is None: - return False - return identity.root == coordination.wire_root and identity.thread.incarnation == coordination.thread + return self.accepts_notification(destination) and coordination.accepts_read(identity) class TaskRunner(ABC): @@ -105,7 +97,6 @@ def __init__(self, agent: Agent, transcripts: CoordinationTranscriptReader) -> N self._deferred_submissions: set[asyncio.Task] = set() self.prompt_in_flight = 0 self.transcripts = transcripts - self.coordination = None self.session = SessionBinding(None) self.commands: list[AvailableCommand] = [] self.plan_entries: list[PlanItem] | None = None @@ -141,7 +132,7 @@ def detach(self, target) -> None: self.terminals.detach() async def restore(self, binding: SurfaceBinding) -> None: - from toad.core.events import AvailableCommandsUpdate, CommsUpdated + from toad.core.events import AvailableCommandsUpdate if self.surface is not binding: return @@ -157,22 +148,28 @@ async def restore(self, binding: SurfaceBinding) -> None: agent.events.publish(Plan(self.plan_entries)) agent._post_queue_view() agent._post_private_cursor() - if agent.coordination is not None: - # The conversation's own refresh reads its history; this only reports the thread. - self.agent.events.publish(CommsUpdated(agent.coordination, agent.session_id)) + # The conversation owns its history refresh; only report the attachment. + agent.coordination_attachment.publish(agent, agent.session_id) binding.schedule_terminal_presentation(self) async def publish_transcript_snapshot( - self, update: TranscriptSnapshotUpdate, binding: SurfaceBinding, session: SessionBinding + self, update: TranscriptSnapshotUpdate, binding: SurfaceBinding, session: SessionBinding, + *, destination: AcpSessionId, ) -> None: - if binding.target is None or not session.accepts_snapshot(update.identity, self.coordination): + if self.session is not session or self.surface is not binding: + return + if binding.target is None or not session.accepts_snapshot( + update.identity, self.agent.coordination_attachment, destination, + ): return # No view or matching request owns this publication. snapshot = await self.transcripts.publication(update) if self.session is not session: return if self.surface is not binding: return - if not session.accepts_snapshot(snapshot.identity, self.coordination): + if not session.accepts_snapshot( + snapshot.identity, self.agent.coordination_attachment, destination, + ): return self.agent.events.publish(core_events.CommsUpdated(snapshot, session.session_id)) @@ -209,7 +206,7 @@ def start_operation[Result](self, operation: Coroutine[None, None, Result]) -> a async def operate[Result](self, operation: Coroutine[None, None, Result]) -> Result: return await asyncio.shield(self.start_operation(operation)) - async def submit(self, prompt: str, *, request: PromptRequest | None = None): + async def submit(self, prompt: str, *, request: PromptRequest | None = None) -> PromptResult: agent = self.agent command = request if request is not None else QueuePromptRequest(prompt) authority = ClientSessionRequest(agent, agent.session_id) @@ -226,19 +223,14 @@ async def _prompt_operation[Result]( self, operation: Callable[[], Awaitable[Result]], authority: ClientSessionRequest ) -> Result: """Keep the request in flight until its operation ends.""" - coordination = self.coordination + coordination = self.agent.coordination_attachment self.prompt_in_flight += 1 try: return await operation() finally: self.prompt_in_flight -= 1 - if self.prompt_in_flight == 0 and authority.current and coordination is not None: - self.start_operation(self.reconcile_turn(coordination, authority)) - - async def reconcile_turn(self, coordination: CoordinationChangedUpdate, authority: ClientSessionRequest) -> None: - """Release snapshot ordering through a fresh read of the same owner.""" - self.require_owner(coordination, authority) - await self.agent.thread_read().reconcile(self.transcripts) + if self.prompt_in_flight == 0 and authority.current: + coordination.finish_prompt(self.agent, authority) def accepts_turn_snapshot(self, sequence: int): """A remote busy turn does not imply an ordered response on this client.""" @@ -251,30 +243,24 @@ async def _submit( authority: ClientSessionRequest, queue_scope: QueueScope, project: Path, - ): + ) -> PromptResult: submission = asyncio.current_task() if command.defer_display else None if submission is not None: self._deferred_submissions.add(submission) try: - coordination = self.coordination - if coordination is not None: - self.require_owner(coordination, authority) - comms = await self.transcripts.service(coordination.wire_root) - origin = await asyncio.to_thread(self.agent.queue_attachment.capture_human_input, comms, queue_scope) - self.require_owner(coordination, authority) - command = replace(command, origin=origin) + coordination = self.agent.coordination_attachment + command = await coordination.prepare_command(self.agent, command, authority, queue_scope) content = await asyncio.to_thread(build_prompt, project, prompt) + coordination.require_input_source(self.agent, authority) if any(block.type == "image" for block in content): - coordinated = self.coordination is not None - supported = coordinated or self.agent.session.supports_images - if not supported: + if not coordination.supports_images(self.agent): raise ValueError("This agent owner does not support images yet; refresh it while idle.") return await self._prompt(content, command, authority, queue_scope) finally: if submission is not None: self._deferred_submissions.discard(submission) - async def submit_blocks(self, content: list[dict[str, str]], command: CommsRequest): + async def submit_blocks(self, content: list[dict[str, str]], command: CommsRequest) -> PromptResult: agent = self.agent authority = QueueSessionRequest(agent, agent.session_id) return await self.operate( @@ -283,7 +269,7 @@ async def submit_blocks(self, content: list[dict[str, str]], command: CommsReque ) ) - async def _prompt(self, content, command, authority: ClientSessionRequest, queue_scope: QueueScope): + async def _prompt(self, content, command, authority: ClientSessionRequest, queue_scope: QueueScope) -> PromptResult: agent = self.agent def require(): @@ -291,63 +277,19 @@ def require(): if not agent.queue_attachment.accepts_request(queue_scope): raise QueueActionRefused("The queued input owner changed before submission; inspect the current queue.") - try: - result = await agent.process.prompt( - agent, content, encode_request(command), require - ) - except RequestError as error: - # The agent answered: this request failed and its session stays open. - request_failure = RequestFailure.from_error(error) - report = agent.events.publish - except OSError as error: - # The transport is gone, and the session with it. - request_failure = RequestFailure(ACPFailure.from_detail(None, f"{type(error).__name__}: {error}"), False) - report = agent.session.record_failure - else: - if authority.retired: - return authority.unconfirmed("the captured ACP session retired before its reply") - return StopReason.member_named(result.stop_reason) - if authority.retired: - 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 authority.unconfirmed(failure.feedback) - - def _prompt_failed( - self, - command, - authority: ClientSessionRequest, - queue_scope: QueueScope, - failure: ACPFailure, - report: Callable[[AgentFail], None], - *, - published: bool = False, - ) -> None: - agent = self.agent - user_text = command.draft_text - if user_text: - agent.events.publish( - core_events.CommsUpdated( - InputFailedUpdate(user_text, failure), - recover_draft=True, - session_id=authority.session_id, - queue_scope=queue_scope, - ) - ) - if not published: - report(LogAgentFail(failure.title, failure.feedback, log_path=agent.presentation.log_path)) + outcome = await authority.prompt(content, encode_request(command), require) + outcome.publish_failure() + return authority.resolve(outcome) - async def clear_queue(self) -> None: - await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest()) + async def clear_queue(self) -> PromptResult: + return await self.submit_blocks([{"type": "text", "text": " "}], ClearQueueRequest()) - async def send_now(self) -> None: + async def send_now(self) -> PromptResult: 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)) - await self.operate( + return await self.operate( self._prompt_operation( partial(self._prompt, [{"type": "text", "text": " "}], SendNowRequest(), authority, queue_scope), authority, @@ -362,20 +304,14 @@ async def compact_context(self, instructions=None): async def _compact_context(self, instructions, authority: ClientSessionRequest): agent = self.agent - try: - response = await agent.process.prompt( - agent, [{"type": "text", "text": " "}], encode_request(CompactRequest(instructions)), authority.require - ) - except RequestError as error: - failure = RequestFailure.from_error(error).failure - raise ValueError( - f"{failure.title}: {failure.detail}\n{failure.input_disposition}\n{failure.action}" - ) from error + outcome = await self._prompt( + [{"type": "text", "text": " "}], CompactRequest(instructions), authority, + agent.queue_attachment.scope, + ) + confirmed = outcome.require_confirmation() authority.require() - if response is None: - raise ValueError("Compaction returned no result") consumer = agent.comms_consumer_class(agent, authority.session_id) - await consumer.consume_metadata(response.field_meta) + await consumer.consume_metadata(confirmed.response.field_meta) return consumer.compaction.require() async def cancel_prompt(self): @@ -390,47 +326,12 @@ async def _cancel_prompt(self, authority: ClientSessionRequest): return authority.current async def request_owner(self, method: str, **params: JsonValue): - if self.coordination is None: - raise ValueError("This action requires an agent-comms thread.") - coordination = self.coordination - authority = ClientSessionRequest(self.agent, self.agent.session_id) - self.require_owner(coordination, authority) - comms = await self.transcripts.service(coordination.wire_root) - - def resolve(): - owner = comms.registry.require(coordination.thread.name) - if owner.incarnation != coordination.thread: - raise ValueError("The owner incarnation changed while preparing the request.") - return RuntimeConnection( - comms, owner.incarnation, socket_path(comms.root, owner.require_process().pid) - ) - - proxy = await asyncio.to_thread(resolve) - try: - self.require_owner(coordination, authority) - result = await proxy.request(method, **params) - self.require_owner(coordination, authority) - return result - finally: - await proxy.close() - - def require_owner(self, coordination: CoordinationChangedUpdate, authority: ClientSessionRequest): - if authority.retired or self.coordination is None: - raise ValueError("The connected owner session retired; refresh its state.") - if (self.coordination.wire_root, self.coordination.thread) != (coordination.wire_root, coordination.thread): - raise ValueError("The owner identity changed while preparing the request.") + return await self.agent.coordination_attachment.request_owner(self.agent, method, **params) async def input_delivery(self, *, include_history: bool = False) -> DeliveryView: """The owner's delivery notices, read by the observation service.""" - coordination = self.coordination - if coordination is None: - return DeliveryView() - authority = ClientSessionRequest(self.agent, self.agent.session_id) - overview = await self.transcripts.read(ReadOwnerOperation( - coordination.wire_root, coordination.thread, InputDeliveryRead(include_history=include_history), - )) - self.require_owner(coordination, authority) - return ReadDeliveryView(overview) + return await self.agent.coordination_attachment.input_delivery( + self.agent, include_history=include_history) async def dismiss_historical_inputs(self): return await self.request_owner("dismiss_historical_inputs") diff --git a/src/toad/acp/agent_process.py b/src/toad/acp/agent_process.py index 9d69ffa45..4b3905f44 100644 --- a/src/toad/acp/agent_process.py +++ b/src/toad/acp/agent_process.py @@ -1,4 +1,4 @@ -"""ACP connection, admitted subprocess and attached-session lifetime.""" +"""ACP connection, subprocess and attached-session lifetime.""" from __future__ import annotations import asyncio @@ -8,7 +8,7 @@ from contextvars import ContextVar from functools import partial from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional from acp import Client, ClientSideConnection, schema from acp.connection import StreamDirection, StreamEvent @@ -27,20 +27,19 @@ from toad.core.events import LogAgentFail from toad.resource_release import AsyncResourceRelease +if TYPE_CHECKING: + from toad.acp.agent import Agent + class AgentProcess(Client): - def __init__(self, agent) -> None: + def __init__(self, agent: Agent) -> None: self.agent = agent - self.env = None - self.cwd = None - self.route_selection = None - self.process = None + self.process: AttachedChild | None = None self.connection: ClientSideConnection | None = None - self.runner = None - self.retirement = None - self.sessions = set() - self.initialization = None - self.command = None + self.runner: asyncio.Task[None] | None = None + self.retirement: asyncio.Task[None] | None = None + self.sessions: set[AgentSession] = set() + self.initialization: schema.InitializeResponse | None = None self.attachment_lock = asyncio.Lock() self._incoming: ContextVar[dict] = ContextVar('acp_incoming') @@ -50,20 +49,12 @@ def rpc(self) -> ClientSideConnection: raise ConnectionError('ACP process is not connected') return self.connection - def accepts_attachment(self, agent, env: dict[str, str], cwd: str, selection: RouteSelection): - response = self.initialization - capabilities = response.agent_capabilities if response is not None else None - lifecycle = capabilities.session_capabilities if capabilities is not None else None - return (self.runner is not None and not self.runner.done() - and self.retirement is None and self.process is not None - and self.process.returncode is None - and lifecycle is not None and lifecycle.close is not None - and self.command == agent.command and self.env == env - and self.cwd == cwd and self.route_selection == selection - and agent.session_id is not None - and all(member.agent.session_id != agent.session_id for member in self.sessions)) + def accepts_attachment(self, agent: Agent, env: dict[str, str], cwd: str, + selection: RouteSelection) -> bool: + """An unconfigured group has no reusable child.""" + return False - async def start(self, agent, processes: tuple[AgentProcess, ...]=()): + async def start(self, agent: Agent, processes: tuple[AgentProcess, ...]=()) -> AgentProcess: agent.session.reopen() agent.session.starting() await asyncio.to_thread( @@ -80,24 +71,19 @@ async def start(self, agent, processes: tuple[AgentProcess, ...]=()): agent.controller.replace_terminal_session() agent.session.start() return connection - connection = AgentProcess(agent) if self.sessions else self - connection.agent = agent - connection.env, connection.cwd, connection.route_selection = env, cwd, selection - connection.command = agent.command - connection.initialization = None - connection.retirement = None + command = agent.command + if command is None: + agent.session.startup_failed('No run command for this OS') + return self + connection = ConfiguredAgentProcess(agent, env, cwd, selection, command) agent.process = connection connection.sessions.add(agent.session) agent.controller.replace_terminal_session() connection.runner = asyncio.create_task(connection.run()) return connection - async def prompt(self, agent, content, metadata: dict[str, JsonValue], require: Callable[[], None]): - """The agent-comms-acp child writes the prompt under Core's maintenance gate.""" - access = agent.controller.transcripts.access - await access.preparation.run_thread(partial(access.require, self.route_selection)) - require() - return await self.rpc.prompt(agent.session_id, content, **metadata) + async def prepare_prompt(self, agent: Agent, require: Callable[[], None]) -> None: + raise ConnectionError('ACP process has no selected launch') def observe_stream(self, event: StreamEvent) -> None: """Keep the original frame with its SDK handler.""" @@ -224,8 +210,7 @@ def on_connect(self, conn: ClientSideConnection) -> None: async def retire(self) -> None: for session in tuple(self.sessions): session.close() - for task in tuple(session.responses): - task.cancel() + session.cancel_responses() if self.retirement is None: self.retirement = asyncio.create_task(self._retire()) await join_retirement(self.retirement) @@ -257,15 +242,11 @@ async def detach(self, session: AgentSession): try: if (self.retirement is None and len(self.sessions) > 1 and session.disposition.held_by_agent): - task = asyncio.create_task(self.rpc.close_session(session.agent.session_id)) - session.responses.add(task) - task.add_done_callback(session.responses.discard) - await task - except BaseException: - if self.runner is not None: - self.runner.cancel() - raise + await session.close_in(self.rpc) finally: + # A request-local close refusal is not process retirement. + # Transport death is owned by communicate/run; the caller + # retains this session's actual close error. self.sessions.discard(session) if not self.sessions and self.retirement is None and self.runner is not None: self.runner.cancel() @@ -280,6 +261,38 @@ async def stop(self, agent) -> None: await self.retire() self.runner = None + +class ConfiguredAgentProcess(AgentProcess): + """One original launch, selected before its asynchronous resources arrive.""" + + def __init__(self, agent: Agent, env: dict[str, str], cwd: str, + selection: RouteSelection, command: str) -> None: + super().__init__(agent) + self.env = env + self.cwd = cwd + self.route_selection = selection + self.command = command + + def accepts_attachment(self, agent: Agent, env: dict[str, str], cwd: str, + selection: RouteSelection) -> bool: + response = self.initialization + capabilities = response.agent_capabilities if response is not None else None + lifecycle = capabilities.session_capabilities if capabilities is not None else None + return (self.runner is not None and not self.runner.done() + and self.retirement is None and self.process is not None + and self.process.returncode is None + and lifecycle is not None and lifecycle.close is not None + and self.command == agent.command and self.env == env + and self.cwd == cwd and self.route_selection == selection + and agent.session_id is not None + and all(member.agent.session_id != agent.session_id for member in self.sessions)) + + async def prepare_prompt(self, agent: Agent, require: Callable[[], None]) -> None: + """Only this captured launch's route may admit the request.""" + access = agent.controller.transcripts.access + await access.preparation.run_thread(partial(access.require, self.route_selection)) + require() + async def run(self) -> None: try: await self.communicate() @@ -288,16 +301,13 @@ async def run(self) -> None: async def communicate(self) -> None: agent = self.agent - env = (self.env or os.environ).copy() + env = self.env.copy() env['TOAD_CWD'] = str(Path('./').absolute()) - if (command := self.command) is None: - agent.session.startup_failed('No run command for this OS') - return try: accepts_root = agent.definition.accepts_comms_root launch = LaunchRoot.from_path(self.route_selection.root) process = self.process = await AttachedChild.start( - (*ShellCommand.current().argv(command, accepts_comms_root=accepts_root), + (*ShellCommand.current().argv(self.command, accepts_comms_root=accepts_root), *(launch.arguments() if accepts_root else ())), env=LaunchRoot.environment(env), cwd=self.cwd, stdio=StreamingChildStdio(limit=10 * 1024 * 1024), @@ -319,6 +329,6 @@ async def communicate(self) -> None: session.agent.log(f'[error] {fail_details}') session.record_failure(LogAgentFail( f'Agent {outcome.summary}', - details=fail_details, log_path=session.agent.presentation.log_path)) + description=fail_details, log_path=session.agent.presentation.log_path)) elif not session.settled.is_set(): session.startup_failed('ACP process closed before session initialization completed.') diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 0197b1ba2..c5004a5f6 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -5,11 +5,12 @@ import asyncio import os -from collections.abc import Callable +from abc import abstractmethod +from collections.abc import Callable, Coroutine from dataclasses import dataclass, replace -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar -from acp import schema +from acp import ClientSideConnection, schema from acp.exceptions import RequestError from agent_comms.acp_extension import HistoryOnlyQueueProjection from agent_comms.acp_failure import ACPFailure, RequestFailure @@ -20,11 +21,10 @@ from agent_comms.owner_protocol import IncompatibleOwner from agent_comms.session_load import ( EnsuringSessionLoadPlan, - ExistingSessionLoadPlan, FailedSessionLoadPlan, SessionLoadPlan, ) -from agent_comms.thread_presentation import SessionDisconnected, ThreadPresentation +from agent_comms.thread_presentation import SessionDisconnected import toad from toad import constants @@ -38,6 +38,9 @@ ) from toad.db import DB, SessionMeta +if TYPE_CHECKING: + from toad.acp.agent import Agent + PROTOCOL_VERSION = 1 @@ -69,7 +72,9 @@ class UnopenedSessionDisposition(SessionDisposition): class UnavailableSessionDisposition(SessionDisposition): - status: SessionDisconnected + @property + @abstractmethod + def status(self) -> SessionDisconnected: ... def present[T](self, ordinary: T, show: Callable[[SessionDisconnected], T]) -> T: return show(self.status) @@ -135,25 +140,23 @@ class AgentSession: reconnect_timeout_seconds = 30 - def __init__(self, agent, pk, app_events: core_events.CoreEventStream) -> None: + def __init__(self, agent: Agent, pk, app_events: core_events.CoreEventStream) -> None: self.agent = agent # The app's stream: its thread rows show this session's state, attached to a view or not. self.app_events = app_events - self.task = None - self.responses = set() - self.retirement = None + self.task: asyncio.Task[None] | None = None + self.responses: set[asyncio.Task[object]] = set() + self.retirement: asyncio.Task[None] | None = None self._disposition: SessionDisposition = ActiveSessionDisposition(self) self.pk = pk - self.chosen_name = None + self.chosen_name: str | None = None self.connected = False self.reconnecting = False self.load_plan: SessionLoadPlan = EnsuringSessionLoadPlan() self.settled = asyncio.Event() def publish_thread_row_state(self) -> None: - if coordination := self.agent.coordination: - self.app_events.publish(core_events.ThreadRowStateChanged( - frozenset((coordination.thread.name,)))) + self.agent.coordination_attachment.publish_row_state(self.app_events) @property def disposition(self) -> SessionDisposition: @@ -179,14 +182,27 @@ def accepts_session(self, session_id: str | None) -> bool: """Only this active session may consume work for its current binding.""" return self.accepts_updates and self.agent.session_id == session_id - def start_operation(self, operation): + def _track_operation[Result](self, operation: Coroutine[None, None, Result]) -> asyncio.Task[Result]: task = asyncio.create_task(operation) - if not self.accepts_updates: - task.cancel() self.responses.add(task) task.add_done_callback(self.responses.discard) return task + def start_operation[Result](self, operation: Coroutine[None, None, Result]) -> asyncio.Task[Result]: + task = self._track_operation(operation) + if not self.accepts_updates: + task.cancel() + return task + + def cancel_responses(self) -> None: + """Transport retirement cancels session work through its actual owner.""" + for task in tuple(self.responses): + task.cancel() + + async def close_in(self, connection: ClientSideConnection) -> None: + """Closing the remote session is tracked even after input admission closes.""" + await self._track_operation(connection.close_session(self.agent.session_id)) + def close(self) -> None: self.disposition = self.disposition.close(self.agent) @@ -211,22 +227,23 @@ async def retire(self) -> None: async def _retire(self) -> None: pending = tuple(task for task in (self.task, *self.responses) if task is not None) - for task in pending: - task.cancel() + if self.task is not None: + self.task.cancel() + self.cancel_responses() if pending: await asyncio.gather(*pending, return_exceptions=True) self.responses.clear() self.task = None await self.agent.process.detach(self) - def session_finished(self, task) -> None: + def session_finished(self, task: asyncio.Task[None]) -> None: if task.cancelled(): return error = task.exception() if error is not None and self.accepts_updates: self.startup_failed(f"{type(error).__name__}: {error}") - def retirement_finished(self, task) -> None: + def retirement_finished(self, task: asyncio.Task[None]) -> None: if task.cancelled(): return error = task.exception() @@ -253,7 +270,7 @@ def session_failed(self, failure: ACPFailure) -> None: def startup_failed(self, details: str) -> None: self.record_failure( - LogAgentFail("ACP session startup failed", details=details, + LogAgentFail("ACP session startup failed", description=details, log_path=self.agent.presentation.log_path), FailedSessionDisposition, ) @@ -294,9 +311,7 @@ def coordinated_title(self, title) -> None: from toad.core.events import SessionInfoUpdate, SessionThreadTitle self.agent.events.publish(SessionInfoUpdate(self.chosen_name or title)) if self.chosen_name is not None: - coordination = self.agent.coordination - if coordination is not None and coordination.title != self.chosen_name: - self.start_operation(self.rename_coordination(self.chosen_name)) + self.agent.coordination_attachment.rename_if_different(self.agent, self.chosen_name) return if title: self.agent.events.publish(SessionThreadTitle(title)) @@ -337,22 +352,6 @@ async def run(self) -> None: self.agent.events.publish(AgentReady(session_id=self.agent.session_id, reconnected=self.reconnecting)) - async def observe_owner(self, presentation: ThreadPresentation) -> None: - """Load again when Core shows a newer owner process than the one this session loaded. - - A restarted owner answers through the still-open connection, but the queue - and cursor views are bound to the process named in the load response and - refuse the new one's updates until a new load binds them. - """ - if not self.settled.is_set() or self.reconnecting: - return - if self.agent.coordination is None: - return - binding = presentation.binding - if not self.load_plan.superseded_by(binding): - return - await self.reconnect(ExistingSessionLoadPlan(binding)) - async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: """Reattach the existing view after login or an explicit owner start.""" if self.reconnecting: @@ -446,7 +445,7 @@ async def new(self) -> None: session_name = ( self.chosen_name if self.chosen_name is not None - else (self.agent.coordination.title if self.agent.coordination else "New Session") + else self.agent.coordination_attachment.title ) session_pk = await db.session_new( session_name, @@ -521,31 +520,6 @@ async def load(self) -> None: self.publish_configuration(response) - async def rename_coordination(self, display_name: str) -> None: - if not self.accepts_session(self.agent.session_id): - return - thread = self.agent.coordination.thread.name if self.agent.coordination else None - wire_root = self.agent.coordination.wire_root if self.agent.coordination else None - if thread is None or wire_root is None: - return - - authority = ClientSessionRequest(self.agent, self.agent.session_id) - owner = self.agent.coordination.owner - comms = await self.agent.controller.transcripts.service(wire_root) - authority.require() - result = await asyncio.to_thread( - comms.threads.rename_managed_thread, - thread, display_name, owner=owner, - ) - authority.require() - self.agent.coordination = replace( - self.agent.coordination, - thread=replace(self.agent.coordination.thread, name=result.current), - title=display_name, - ) - self.agent.events.publish(core_events.CommsUpdated(self.agent.coordination, self.agent.session_id)) - - async def set_name(self, name: str) -> None: """The user or agent process named this session. @@ -555,7 +529,7 @@ async def set_name(self, name: str) -> None: if self.chosen_name == name: return self.chosen_name = name or None - await self.rename_coordination(name) + await self.agent.coordination_attachment.rename(self.agent, name) if self.pk is None: return db = DB() diff --git a/src/toad/acp/attachment_presentation.py b/src/toad/acp/attachment_presentation.py index 371988878..b66e5d46f 100644 --- a/src/toad/acp/attachment_presentation.py +++ b/src/toad/acp/attachment_presentation.py @@ -18,6 +18,7 @@ def matches_turn(self, turn: ReportedTurnState) -> bool: @dataclass(frozen=True) class CursorPresentation(AttachmentPresentation): status: type[CursorStatus] + sequence: int @dataclass(frozen=True) diff --git a/src/toad/acp/client_session.py b/src/toad/acp/client_session.py index d38ec88f0..9809655a2 100644 --- a/src/toad/acp/client_session.py +++ b/src/toad/acp/client_session.py @@ -1,7 +1,17 @@ """One captured session authority for ACP client-side effects.""" -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, NoReturn +from __future__ import annotations + +from abc import abstractmethod +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING + +from acp.schema import PromptResponse +from agent_comms.acp_failure import ACPFailure, RequestFailure +from agent_comms.errors import RouteChangedError +from agent_comms.input_attempt import NotSentInput +from toad.core.events import LogAgentFail from .queue_attachment import QueueActionRefused +from .status import ConfirmedPromptResult, PromptResult, StopReason if TYPE_CHECKING: from .agent import Agent @@ -35,15 +45,96 @@ 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.""" + async def prompt(self, content, metadata, require) -> PromptResult: + """Decode once at the captured request's real SDK/write boundary.""" + process = self.agent.process + try: + await process.prepare_prompt(self.agent, require) + except RequestError as error: + failure = RequestFailure.from_error(error) + return ResolvedFailurePromptResult(self, replace(failure, failure=replace( + failure.failure, input_state=NotSentInput))) + except (OSError, QueueActionRefused, RouteChangedError) as error: + return ResolvedFailurePromptResult(self, RequestFailure(replace(ACPFailure.from_detail( + None, f"{type(error).__name__}: {error}"), input_state=NotSentInput), False)) + try: + response = await process.rpc.prompt(self.session_id, content, **metadata) + except RequestError as error: + return self.failed(RequestFailure.from_error(error)) + except OSError as error: + return self.unconfirmed(RequestFailure(ACPFailure.from_detail( + None, f"{type(error).__name__}: {error}"), False)) + return self.completed(response) + + def completed(self, response: PromptResponse) -> PromptResult: + if self.retired: + return self.unconfirmed(RequestFailure(ACPFailure.from_detail( + None, "the captured ACP session retired before its reply"), False)) + return StopReason.from_acp(response) + + def failed(self, failure: RequestFailure) -> PromptResult: + if self.retired: + return self.unconfirmed(failure) + return failure.failure.input_failure_result( + lambda: ResolvedFailurePromptResult(self, failure), + lambda: FailedPromptResult(self, failure), + ) + + def unconfirmed(self, failure: RequestFailure) -> UnconfirmedPromptResult: + return UnconfirmedPromptResult(self, failure) + + def resolve(self, outcome: PromptResult) -> PromptResult: + """Ordinary callers retain the answer, including unconfirmed input.""" + return outcome + + +@dataclass(frozen=True) +class UnfinishedPromptResult(PromptResult): + request: ClientSessionRequest + failure: RequestFailure + + def publish_failure(self) -> None: + if self.request.current and not self.failure.notification_published: + agent, failure = self.request.agent, self.failure.failure + self.report_failure(agent, LogAgentFail( + failure.title, failure.feedback, log_path=agent.presentation.log_path)) + + @abstractmethod + def report_failure(self, agent, event) -> None: ... + + def require_confirmation(self) -> StopReason: + raise QueueActionRefused( + f"Request not confirmed: {self.failure.failure.feedback}. Inspect its state before another action." + ) + + +class FailedPromptResult(UnfinishedPromptResult): + """A negative reply preserves the session, not a claim that input was accounted for.""" + + def report_failure(self, agent, event) -> None: + agent.events.publish(event) + + +class ResolvedFailurePromptResult(ConfirmedPromptResult, FailedPromptResult): + """A definite input disposition, including a proved refusal before SDK invocation.""" + + def settle_submission(self, execution) -> None: + execution.state.response_failed(execution, self.failure.failure) + + +class UnconfirmedPromptResult(UnfinishedPromptResult): + """The captured request lost its reply or authority; it grants no retry.""" + + def report_failure(self, agent, event) -> None: + agent.session.record_failure(event) 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.") + def resolve(self, outcome: PromptResult) -> PromptResult: + outcome.require_confirmation() + return outcome class ClientRequestOwner: diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 59229b187..c4f84efbd 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING from agent_comms.acp_extension import ( CompactionChangedUpdate, @@ -29,6 +28,7 @@ ) from agent_comms.context_reading import UnmeasuredContext from agent_comms.native_output import AssistantOutput, ToolOutput +from agent_comms.nominal.field_codec import AcpSessionId, FieldCodec from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation from agent_comms.transcripts import NativeCommittedPrefix from metaclass_registry.mro_dispatch import MroDispatch, handles @@ -39,10 +39,6 @@ from toad.live_output import CompleteResponseStream, ResponseStream from toad.response_delivery import ResponseDelivery, UnroutedResponse -if TYPE_CHECKING: - from toad.acp.agent import Agent - - class CompactionReply: """The reply owns whether /compact supplied a committed summary.""" @@ -68,7 +64,7 @@ def __init__( queue_token: int | None = None, ) -> None: self.agent = agent - self.session_id = session_id + self.session_id = FieldCodec.decode(AcpSessionId, session_id) self.delivery: ResponseDelivery = UnroutedResponse() self.response_type: type[ResponseStream] = ResponseStream self.compaction: CompactionReply = CompactionReply() @@ -108,7 +104,7 @@ def response_stream(self) -> ResponseStream: def input_failed(self, update: InputFailedUpdate) -> None: self.agent.events.publish( core_events.CommsUpdated( - update, recover_draft=False, session_id=self.session_id + update, session_id=self.session_id ) ) @@ -183,16 +179,7 @@ def _post_unidentified(self, update: InputStartedUpdate) -> None: @handles(CoordinationChangedUpdate) def coordination_changed(self, update: CoordinationChangedUpdate) -> None: - agent = self.agent - agent.controller.transcripts.access.selected(update.wire_root) - agent.coordination = update - agent.project_root_path = update.worktree.path - agent.context_measurement = ContextMeasurement.saved(update.context_usage) - agent.update_status_line() - agent.events.publish( - core_events.CommsUpdated(agent.coordination, self.session_id) - ) - agent.session.coordinated_title(update.title) + self.agent.receive_coordination(update, self.session_id) @handles(GoalChangedUpdate) def goal_changed(self, update: GoalChangedUpdate) -> None: @@ -213,7 +200,8 @@ def transcript_snapshot(self, update: TranscriptSnapshotUpdate) -> None: controller = self.agent.controller controller.start_operation( controller.publish_transcript_snapshot( - update, controller.surface, controller.session + update, controller.surface, controller.session, + destination=self.session_id, ) ) @@ -278,80 +266,8 @@ def thread_presentation(self, presentation: ThreadPresentation) -> None: def publish_thread_turn(self, root, thread): """Only the live transcript member can supply actual owner settlement.""" - coordination = self.agent.coordination - self.agent.controller.require_owner( - coordination, ClientSessionRequest(self.agent, self.session_id) - ) - if root != coordination.wire_root or thread.incarnation != coordination.thread: + coordination = self.agent.coordination_attachment + coordination.require_current(self.agent, ClientSessionRequest(self.agent, self.session_id)) + if not coordination.matches_identity(root, thread.incarnation): raise ValueError("Turn snapshot belongs to another original thread source") self.turn_changed(TurnChangedUpdate(thread.turn_state)) - - -class ThreadReadOutcome: - """A status read may publish only while its captured source still holds.""" - - def present(self, consumer) -> None: - """Expired observations leave the newer session's presentation alone.""" - - -class EndedSessionRead(ThreadReadOutcome): - """The read's client session ended before its result arrived.""" - - -class ChangedAttachmentRead(ThreadReadOutcome): - """The read's original thread is no longer attached to this client.""" - - -@dataclass(frozen=True) -class SettledThreadRead(ThreadReadOutcome): - presentation: ThreadPresentation - - def present(self, consumer) -> None: - consumer(self.presentation) - - -class ThreadRead: - """A presentation read, with settlement owned by its captured source.""" - - def settle(self, presentation: ThreadPresentation) -> ThreadReadOutcome: - return SettledThreadRead(presentation) - - async def reconcile(self, transcripts) -> None: - """An uncaptured read has no owner snapshot to reconcile.""" - - -class NoPendingThreadRead(ThreadRead): - """No captured client authority; present the current observation directly.""" - - -@dataclass(frozen=True) -class PendingThreadRead(ThreadRead): - """One presentation read of an agent's thread, captured before the read. - - Whether the read ran here or in Core's observation process, its result - settles the turn only if the attachment and client session it was - requested for still hold. - """ - - agent: Agent - coordination: CoordinationChangedUpdate - authority: ClientSessionRequest - snapshot: OwnerSnapshotConsumer - - def settle(self, presentation: ThreadPresentation) -> ThreadReadOutcome: - current = self.agent.coordination - if current is None or (current.wire_root, current.thread) != ( - self.coordination.wire_root, - self.coordination.thread, - ): - return ChangedAttachmentRead() - if self.authority.retired: - return EndedSessionRead() - self.snapshot.dispatch_sync(presentation) - return SettledThreadRead(presentation) - - async def reconcile(self, transcripts) -> None: - read = await transcripts.thread_presentation( - self.coordination.wire_root, self.coordination.thread.name - ) - self.settle(read.raised()) diff --git a/src/toad/acp/coordination_attachment.py b/src/toad/acp/coordination_attachment.py new file mode 100644 index 000000000..0ab7bbff1 --- /dev/null +++ b/src/toad/acp/coordination_attachment.py @@ -0,0 +1,286 @@ +"""An agent's coordination attachment owns thread availability and source fencing.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, replace +from pathlib import Path +from typing import TYPE_CHECKING + +from agent_comms.acp_extension import CoordinationChangedUpdate, PromptRequest +from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution +from agent_comms.goals import NoGoal +from agent_comms.order_tools import InputDeliveryRead, OwnerGoalRead +from agent_comms.owner_connection import socket_path +from agent_comms.repo import Checkout +from agent_comms.runtime import RuntimeConnection +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.sent_message import AssignedSourceIdentity +from agent_comms.transcripts import TranscriptReadIdentity +from agent_comms.ui_model.delivery import DeliveryView, ReadDeliveryView +from agent_comms.ui_model.observation import ReadOwnerOperation +from pydantic import JsonValue + +from toad.acp.client_session import ClientSessionRequest +from toad.acp.comms_updates import OwnerSnapshotConsumer +from toad.acp.thread_read import NoPendingThreadRead, PendingThreadRead, ThreadRead +from toad.core.events import CommsUpdated, ThreadRowStateChanged + +if TYPE_CHECKING: + from agent_comms.acp_extension import QueueScope + from agent_comms.thread_presentation import ThreadPresentation + from toad.acp.agent import Agent + from toad.core.events import CoreEventStream + from toad.widgets.conversation import Conversation + + +class CoordinationAttachment: + """A genuine non-coordinated ACP session, not an optional packet.""" + + queue_supported = False + retired_notice = "The connected owner session retired; refresh its state." + changed_notice = "The owner identity changed while preparing the request." + + @property + def title(self) -> str: + return "New Session" + + @property + def tool_scope(self) -> AssignedSourceIdentity | None: + return None + + def observed_thread(self) -> tuple[str, str] | None: + return None + + def matches_source(self, update: CoordinationChangedUpdate) -> bool: + return False + + def matches_identity(self, root: str, thread: ThreadIncarnation) -> bool: + return False + + def accepts_read(self, identity: TranscriptReadIdentity) -> bool: + return False + + def require_current(self, agent: Agent, authority: ClientSessionRequest) -> None: + raise ValueError(self.retired_notice) + + def require_source(self, update: CoordinationChangedUpdate, authority: ClientSessionRequest) -> None: + raise ValueError(self.retired_notice) + + def publish(self, agent: Agent, session_id: str | None) -> None: + """No coordination publication exists.""" + + def publish_row_state(self, events: CoreEventStream) -> None: + """No coordinated thread row changed.""" + + def thread_read(self, agent: Agent) -> ThreadRead: + return NoPendingThreadRead() + + def finish_prompt(self, agent: Agent, authority: ClientSessionRequest) -> None: + """No coordinated turn needs a canonical read.""" + + async def prepare_command(self, agent: Agent, command: PromptRequest, + authority: ClientSessionRequest, scope: QueueScope) -> PromptRequest: + return command + + def require_input_source(self, agent: Agent, authority: ClientSessionRequest) -> None: + if agent.coordination_attachment is not self: + raise ValueError(self.changed_notice) + + def supports_images(self, agent: Agent) -> bool: + return agent.session.supports_images + + async def request_owner(self, agent: Agent, method: str, **params: JsonValue): + raise ValueError("This action requires an agent-comms thread.") + + async def input_delivery(self, agent: Agent, *, include_history: bool = False) -> DeliveryView: + return DeliveryView() + + async def goal_snapshot(self, agent: Agent) -> GoalSnapshot: + return GoalSnapshot(NoGoal(), IdleGoalExecution()) + + async def update_project(self, agent: Agent, path: str) -> str: + raise ValueError("Project changes require an agent-comms thread.") + + def apply_project(self, agent: Agent, project: Checkout) -> None: + raise ValueError(self.retired_notice) + + async def rename(self, agent: Agent, name: str) -> None: + """A non-coordinated session has no managed thread to rename.""" + + def apply_name(self, agent: Agent, name: str, title: str) -> None: + raise ValueError(self.retired_notice) + + def rename_if_different(self, agent: Agent, name: str) -> None: + """No coordinated title needs synchronization.""" + + async def observe_owner(self, agent: Agent, presentation: ThreadPresentation) -> None: + """No coordinated process can supersede this attachment.""" + + async def prepare_login(self, view: Conversation) -> None: + await view.prune_window(0, 0) + view.output.boundary() + + +@dataclass(frozen=True) +class CoordinatedThread(CoordinationAttachment): + """The original decoded publication; no second copy of its fields.""" + + update: CoordinationChangedUpdate + queue_supported = True + + @property + def title(self) -> str: + return self.update.title + + @property + def tool_scope(self) -> AssignedSourceIdentity | None: + return self.update.thread.recorded_or( + lambda recorded: AssignedSourceIdentity(self.update.wire_root, recorded), + lambda: None, + ) + + def observed_thread(self) -> tuple[str, str]: + return self.update.wire_root, self.update.thread.name + + def matches_source(self, update: CoordinationChangedUpdate) -> bool: + return self.matches_identity(update.wire_root, update.thread) + + def matches_identity(self, root: str, thread: ThreadIncarnation) -> bool: + return self.update.wire_root == root and self.update.thread == thread + + def accepts_read(self, identity: TranscriptReadIdentity) -> bool: + return self.matches_identity(identity.root, identity.thread.incarnation) + + def require_current(self, agent: Agent, authority: ClientSessionRequest) -> None: + agent.coordination_attachment.require_source(self.update, authority) + + def require_source(self, update: CoordinationChangedUpdate, authority: ClientSessionRequest) -> None: + if authority.retired: + raise ValueError(self.retired_notice) + if not self.matches_source(update): + raise ValueError(self.changed_notice) + + def publish(self, agent: Agent, session_id: str | None) -> None: + agent.events.publish(CommsUpdated(self.update, session_id)) + + def publish_row_state(self, events: CoreEventStream) -> None: + events.publish(ThreadRowStateChanged(frozenset((self.update.thread.name,)))) + + def thread_read(self, agent: Agent) -> ThreadRead: + return PendingThreadRead( + agent, self, ClientSessionRequest(agent, agent.session_id), + OwnerSnapshotConsumer(agent, agent.session_id, turn_token=agent.presentation.turns.sequence), + ) + + def finish_prompt(self, agent: Agent, authority: ClientSessionRequest) -> None: + agent.controller.start_operation(self.reconcile(agent, authority)) + + async def reconcile(self, agent: Agent, authority: ClientSessionRequest) -> None: + self.require_current(agent, authority) + await agent.thread_read().reconcile(agent.controller.transcripts) + + async def prepare_command(self, agent: Agent, command: PromptRequest, + authority: ClientSessionRequest, scope: QueueScope) -> PromptRequest: + self.require_current(agent, authority) + comms = await agent.controller.transcripts.service(self.update.wire_root) + self.require_current(agent, authority) + origin = await asyncio.to_thread(agent.queue_attachment.capture_human_input, comms, scope) + self.require_current(agent, authority) + return replace(command, origin=origin) + + def require_input_source(self, agent: Agent, authority: ClientSessionRequest) -> None: + self.require_current(agent, authority) + + def supports_images(self, agent: Agent) -> bool: + return True + + async def request_owner(self, agent: Agent, method: str, **params: JsonValue): + authority = ClientSessionRequest(agent, agent.session_id) + self.require_current(agent, authority) + comms = await agent.controller.transcripts.service(self.update.wire_root) + self.require_current(agent, authority) + + def resolve(): + owner = comms.registry.require(self.update.thread.name) + if owner.incarnation != self.update.thread: + raise ValueError("The owner incarnation changed while preparing the request.") + return RuntimeConnection( + comms, owner.incarnation, socket_path(comms.root, owner.require_process().pid) + ) + + proxy = await asyncio.to_thread(resolve) + try: + self.require_current(agent, authority) + result = await proxy.request(method, **params) + self.require_current(agent, authority) + return result + finally: + await proxy.close() + + async def input_delivery(self, agent: Agent, *, include_history: bool = False) -> DeliveryView: + authority = ClientSessionRequest(agent, agent.session_id) + overview = await agent.controller.transcripts.read(ReadOwnerOperation( + self.update.wire_root, self.update.thread, + InputDeliveryRead(include_history=include_history), + )) + self.require_current(agent, authority) + return ReadDeliveryView(overview) + + async def goal_snapshot(self, agent: Agent) -> GoalSnapshot: + authority = ClientSessionRequest(agent, agent.session_id) + consumer = OwnerSnapshotConsumer(agent, agent.session_id, + turn_token=agent.presentation.turns.sequence) + snapshot = await agent.controller.transcripts.read(ReadOwnerOperation( + self.update.wire_root, self.update.thread, operation=OwnerGoalRead())) + self.require_current(agent, authority) + for fact in snapshot.meta.batch.updates: + consumer.dispatch_sync(fact) + return snapshot + + async def update_project(self, agent: Agent, path: str) -> str: + authority = ClientSessionRequest(agent, agent.session_id) + self.require_current(agent, authority) + comms = await agent.controller.transcripts.service(self.update.wire_root) + self.require_current(agent, authority) + result = await asyncio.to_thread(comms.threads.set_project, self.update.thread, path) + self.require_current(agent, authority) + agent.coordination_attachment.apply_project(agent, Checkout(Path(result.current))) + return result.current + + def apply_project(self, agent: Agent, project: Checkout) -> None: + agent.coordination_attachment = replace(self, update=replace(self.update, worktree=project)) + agent.project_root_path = project.path + agent.coordination_attachment.publish(agent, agent.session_id) + + async def rename(self, agent: Agent, name: str) -> None: + if not agent.session.accepts_session(agent.session_id): + return + authority = ClientSessionRequest(agent, agent.session_id) + self.require_current(agent, authority) + comms = await agent.controller.transcripts.service(self.update.wire_root) + self.require_current(agent, authority) + result = await asyncio.to_thread( + comms.threads.rename_managed_thread, self.update.thread.name, + name, owner=self.update.owner, + ) + self.require_current(agent, authority) + agent.coordination_attachment.apply_name(agent, result.current, name) + agent.coordination_attachment.publish(agent, agent.session_id) + + def apply_name(self, agent: Agent, name: str, title: str) -> None: + agent.coordination_attachment = replace(self, update=replace( + self.update, thread=replace(self.update.thread, name=name), title=title, + )) + + def rename_if_different(self, agent: Agent, name: str) -> None: + if self.update.title != name: + agent.session.start_operation(self.rename(agent, name)) + + async def observe_owner(self, agent: Agent, presentation: ThreadPresentation) -> None: + session = agent.session + if not session.settled.is_set() or session.reconnecting: + return + await presentation.binding.reload_if_superseded(session.load_plan, session.reconnect) + + async def prepare_login(self, view: Conversation) -> None: + """The coordinated source keeps its saved transcript during login.""" diff --git a/src/toad/acp/status.py b/src/toad/acp/status.py index afa149c28..3201a10fd 100644 --- a/src/toad/acp/status.py +++ b/src/toad/acp/status.py @@ -1,7 +1,9 @@ """Specification-spelled reasons and statuses own toolkit independent facts.""" -from dataclasses import dataclass +from __future__ import annotations -from acp.schema import ToolCall +from abc import ABC, abstractmethod +from dataclasses import dataclass +from acp.schema import PromptResponse, ToolCall from agent_comms.agent_event_updates import AcpToolAction from agent_comms.nominal.declared_family import DeclaredFamily from metaclass_registry.mro_dispatch import MroDispatch, handles @@ -22,13 +24,55 @@ ) -class StopReason(DeclaredFamily, affix='StopReason'): - completed = False +class PromptResult(ABC): + """A request's answer, not an optional SDK stop reason.""" + + @abstractmethod + def require_confirmation(self) -> StopReason: ... + + def publish_failure(self) -> None: + """A confirmed completion has no failure to publish.""" + + def settle_submission(self, execution) -> None: + execution.state.response_finished(execution) + + async def finish_turn(self, view) -> None: + """No confirmed completion: release local activity, not a completed turn.""" + view.turns.finish_client() + + +class ConfirmedPromptResult(PromptResult): + """An SDK completion or definite input disposition gives this request a known result.""" + + def settle_submission(self, execution) -> None: + execution.state.response_confirmed(execution) + + +@dataclass(frozen=True) +class StopReason(ConfirmedPromptResult, DeclaredFamily, affix='StopReason'): + """Only the SDK's declared stop reasons are confirmed prompt completions.""" + + response: PromptResponse note = '' + @classmethod + def from_acp(cls, response: PromptResponse) -> StopReason: + return cls.member_named(response.stop_reason)(response) + + def require_confirmation(self) -> StopReason: + """A returned SDK completion confirms a queue mutation.""" + return self + + async def finish_turn(self, view) -> None: + await view.agent_turn_over(self) + + def record_model_usage(self, view) -> None: + """Only a successful end-turn records recent model use.""" + class EndTurnStopReason(StopReason): - completed = True + def record_model_usage(self, view) -> None: + view.record_model_usage() class MaxTokensStopReason(StopReason): diff --git a/src/toad/acp/thread_read.py b/src/toad/acp/thread_read.py new file mode 100644 index 000000000..a9a1c6885 --- /dev/null +++ b/src/toad/acp/thread_read.py @@ -0,0 +1,74 @@ +"""Captured thread reads; settlement belongs to their original attachment.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from agent_comms.thread_presentation import ThreadPresentation + +if TYPE_CHECKING: + from toad.acp.agent import Agent + from toad.acp.client_session import ClientSessionRequest + from toad.acp.comms_updates import OwnerSnapshotConsumer + from toad.acp.coordination_attachment import CoordinatedThread + + +class ThreadReadOutcome: + """A status read may publish only while its captured source still holds.""" + + def present(self, consumer) -> None: + """Expired observations leave the newer session's presentation alone.""" + + +class EndedSessionRead(ThreadReadOutcome): + """The read's client session ended before its result arrived.""" + + +class ChangedAttachmentRead(ThreadReadOutcome): + """The read's original thread is no longer attached to this client.""" + + +@dataclass(frozen=True) +class SettledThreadRead(ThreadReadOutcome): + presentation: ThreadPresentation + + def present(self, consumer) -> None: + consumer(self.presentation) + + +class ThreadRead: + """A presentation read, with settlement owned by its captured source.""" + + def settle(self, presentation: ThreadPresentation) -> ThreadReadOutcome: + return SettledThreadRead(presentation) + + async def reconcile(self, transcripts) -> None: + """An uncaptured read has no owner snapshot to reconcile.""" + + +class NoPendingThreadRead(ThreadRead): + """No captured client authority; present the current observation directly.""" + + +@dataclass(frozen=True) +class PendingThreadRead(ThreadRead): + """Retain attachment, request and turn identities from before the read.""" + + agent: Agent + coordination: CoordinatedThread + authority: ClientSessionRequest + snapshot: OwnerSnapshotConsumer + + def settle(self, presentation: ThreadPresentation) -> ThreadReadOutcome: + if not self.agent.coordination_attachment.matches_source(self.coordination.update): + return ChangedAttachmentRead() + if self.authority.retired: + return EndedSessionRead() + self.snapshot.dispatch_sync(presentation) + return SettledThreadRead(presentation) + + async def reconcile(self, transcripts) -> None: + read = await transcripts.thread_presentation( + self.coordination.update.wire_root, self.coordination.update.thread.name + ) + self.settle(read.raised()) diff --git a/src/toad/acp/tool_calls.py b/src/toad/acp/tool_calls.py index b20bcef6d..04c74d26d 100644 --- a/src/toad/acp/tool_calls.py +++ b/src/toad/acp/tool_calls.py @@ -3,7 +3,7 @@ from acp.schema import ToolCall, ToolCallStart, ToolCallUpdate from agent_comms.nominal.field_codec import ToolCallId -from agent_comms.transcript_sources import AssignedSourceIdentity +from agent_comms.sent_message import AssignedSourceIdentity from agent_comms.transcripts import NativeCommittedPrefix from toad.core import events from toad.acp.client_session import ClientSessionRequest @@ -42,11 +42,7 @@ def committed(self, tool_id: ToolCallId, source: NativeCommittedPrefix) -> None: @property def scope(self): - coordination = self.agent.coordination - return None if coordination is None else coordination.thread.recorded_or( - lambda recorded: AssignedSourceIdentity(coordination.wire_root, recorded), - lambda: None, - ) + return self.agent.coordination_attachment.tool_scope def capture(self, call): return SessionToolCall(call, ClientSessionRequest(self.agent, self.agent.session_id), self.scope) diff --git a/src/toad/agent.py b/src/toad/agent.py index 5cb6de45f..a66c17dc7 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -1,83 +1,48 @@ +"""A bound agent's shared surface and observation lifetime.""" +from __future__ import annotations + from abc import abstractmethod +from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING -from agent_comms.thread_presentation import ThreadPresentation -from textual.content import Content - -from toad.acp.status import StopReason +from toad.acp.status import PromptResult from toad.agent_binding import AgentConnection -from toad.agent_presentation import LocalAgentPresentation -from toad.conversation_turn import NoTurn +from toad.agent_presentation import AgentPresentation, LocalAgentPresentation from toad.core.events import CoreEventStream from toad.surface_binding import SurfaceBinding if TYPE_CHECKING: - from agent_comms.session_load import ExistingSessionLoadPlan - from toad.conversation_turn import TurnOwner - from toad.screens.main import MainScreen from toad.session_observation import SessionObservation from toad.session_presentation import OperationalSessionSources from toad.widgets.conversation import Conversation, ConversationSessionBinding class AgentBase(AgentConnection): - """Base class for an 'agent'.""" + """A connected surface; native protocol resources belong to its concrete agent.""" def __init__(self, project_root: Path) -> None: self.project_root_path = project_root self.events = CoreEventStream(self) - self.presentation = LocalAgentPresentation(self) - from toad.acp.agent_configuration import AgentConfiguration - - self.configuration = AgentConfiguration(self) - ready = False + @cached_property + def presentation(self) -> AgentPresentation: + return LocalAgentPresentation(self) - def attach_view(self, view) -> None: + def attach_view(self, view: Conversation) -> None: from toad.agent_surface import AttachedSurfaceBinding from toad.core.events import SessionAgentAttached self.attach_surface(AttachedSurfaceBinding(view, self.events)) view.publish_core(SessionAgentAttached()) - def matches_agent(self, agent) -> bool: + def matches_agent(self, agent: object) -> bool: return self is agent - def cancel_allowed(self, turn: TurnOwner): + def cancel_allowed(self, turn: TurnOwner) -> bool: return turn.can_cancel - def matches_session(self, session_id): - return self.session_id == session_id - - def borrowed_processes(self): - return (self.process,) - - async def reconnect_session( - self, source: MainScreen, plan: ExistingSessionLoadPlan - ) -> None: - await self.session.reconnect(plan) - - async def change_project(self, view: Conversation, path: Path) -> None: - project = await self.update_project(str(path)) - view.feedback.flash(Content(f"Project changed to {project}"), style="success") - - def request_compaction(self, view: Conversation, instructions) -> None: - view.compact_context(instructions) - - def begin_submission(self, owner, submission): - return owner.start(submission, self) - - def accepts_failed_input(self, message): - if message.publisher is not self or message.event.session_id != self.session_id: - return False - return ( - self.queue_attachment.accepts_request(message.event.queue_scope) - if message.event.recover_draft - else True - ) - def invalidate_observation(self, observation: SessionObservation) -> None: observation.schedule(self) @@ -86,26 +51,13 @@ def automatic_titles(self) -> bool: return self.observed_thread() is None async def project_changed(self, path: Path) -> None: - from toad.db import DB - self.project_root_path = path - if (session_pk := self.session.pk) is not None: - await DB().session_update_project(session_pk, path) - - def refresh_context(self, view: Conversation) -> None: - view.thread_status = self.context_measurement.status() - - async def compact_context(self, instructions) -> None: - await self.controller.compact_context(instructions) async def cancel_turn(self, view: Conversation) -> None: await view.cancel_bound_turn(self) - def accepts_message(self, message) -> bool: - return message.publisher is self and message.event.session_id == self.session_id - - def refresh_projection(self, view: ConversationSessionBinding) -> None: - view.thread_status = self.context_measurement.status() + def refresh_projection(self, view: Conversation) -> None: + self.refresh_context(view) view.turns.bound() view.submissions.publish_pending() view.delivery_observation.invalidate() @@ -114,9 +66,7 @@ def start_session(self, view: ConversationSessionBinding) -> None: if self.ready: view.session_ready() - def start_agent( - self, sources: OperationalSessionSources, view: Conversation - ) -> None: + def start_agent(self, sources: OperationalSessionSources, view: Conversation) -> None: """A connected agent is not created a second time.""" def update_view(self, view: ConversationSessionBinding) -> None: @@ -125,60 +75,12 @@ def update_view(self, view: ConversationSessionBinding) -> None: def connected(self, view: ConversationSessionBinding) -> None: view.agent_connected(self) - @property - def session_disposition(self): - return self.session.disposition - - @property - def current_turn(self): - return NoTurn() - def attach_surface(self, binding: SurfaceBinding) -> None: binding.close() async def retire_surface(self, surface: Conversation) -> None: await self.stop() - def observed_thread(self) -> tuple[str, str] | None: - """The (wire root, thread name) whose status this agent shows; none without coordination.""" - return None - - async def observe_thread_presentation( - self, presentation: ThreadPresentation - ) -> None: - """Consume a published canonical change; unmanaged agents need no binding.""" - @abstractmethod - async def send_prompt(self, prompt: str) -> type[StopReason] | None: - """Send a prompt; return its stop reason.""" - - @property - def available_modes(self): - return () - - @property - def current_mode(self): - return None - - async def set_mode(self, mode_id: str) -> str | None: - return "This agent does not support mode selection" - - async def cancel(self) -> bool: - """Cancel the active prompt if supported.""" - return False - - async def set_model(self, model_id: str) -> str | None: - return "This agent does not support model selection" - - async def set_session_name(self, name: str) -> None: - """Set the session name. - - Args: - name: New name for the session. - """ - - def get_info(self) -> str: - return "" - - async def stop(self) -> None: - """Stop the agent (gracefully exit the process)""" + async def send_prompt(self, prompt: str) -> PromptResult: + """Preserve the source's completed, failed or unconfirmed answer.""" diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 6497fabf1..8ec3af6c1 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -1,5 +1,4 @@ -"""The agent itself is a bound connection; a saved view may be unbound.""" - +"""Connection capabilities; concrete transports own their native resources.""" from __future__ import annotations from abc import ABC, abstractmethod @@ -14,8 +13,10 @@ from toad.acp.agent_configuration import AgentConfiguration from toad.acp.agent_session import SessionDisposition, UnopenedSessionDisposition +from toad.acp.thread_read import NoPendingThreadRead, ThreadRead from toad.agent_presentation import AgentPresentation, LocalAgentPresentation -from toad.input_decision import InputAccepted, InputDecision, NoLiveAgent +from toad.conversation_turn import NoTurn +from toad.input_decision import InputDecision, InputAccepted, NoLiveAgent from toad.widgets.committed_presentation import CommitSource if TYPE_CHECKING: @@ -23,12 +24,8 @@ from agent_comms.goals import Goal, GoalDeclaration from agent_comms.nominal.field_codec import GoalId from agent_comms.session_load import ExistingSessionLoadPlan - - from toad.conversation_submission import ( - AgentInputSubmission, - ConversationSubmissions, - SubmissionStart, - ) + from toad.acp.agent_process import AgentProcess + from toad.conversation_submission import PromptInputSubmission, ConversationSubmissions, SubmissionStart from toad.conversation_turn import TurnOwner from toad.core_event_carrier import CoreEventMessage from toad.screens.main import MainScreen @@ -38,10 +35,23 @@ class AgentConnection(ABC): - configuration: AgentConfiguration - presentation: AgentPresentation - ready: bool - session_disposition: SessionDisposition + """A connection defaults to no optional native capability.""" + + @cached_property + def configuration(self) -> AgentConfiguration: + return AgentConfiguration(self) + + @cached_property + def session_disposition(self) -> SessionDisposition: + return UnopenedSessionDisposition() + + @property + def ready(self) -> bool: + return False + + @property + @abstractmethod + def presentation(self) -> AgentPresentation: ... @property def readiness_refusal(self) -> NoLiveAgent: @@ -55,12 +65,10 @@ def readiness_decision(self) -> InputDecision: def committed_native_output(self) -> CommitSource: return CommitSource() - @abstractmethod - async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: ... - - def thread_read(self): - from toad.acp.comms_updates import NoPendingThreadRead + async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: + """A connection without a coordinated owner has no snapshot to consume.""" + def thread_read(self) -> ThreadRead: return NoPendingThreadRead() def model_info(self, session_name: str) -> Content: @@ -71,10 +79,10 @@ def model_info(self, session_name: str) -> Content: return Content(model.name + (f" · {level}" if level else "")) @abstractmethod - def attach_view(self, view: ConversationSessionBinding) -> None: ... + def attach_view(self, view: Conversation) -> None: ... @abstractmethod - def refresh_projection(self, view: ConversationSessionBinding) -> None: ... + def refresh_projection(self, view: Conversation) -> None: ... @abstractmethod def start_session(self, view: ConversationSessionBinding) -> None: ... @@ -88,17 +96,80 @@ def update_view(self, view: ConversationSessionBinding) -> None: ... @abstractmethod def connected(self, view: ConversationSessionBinding) -> None: ... - @abstractmethod - def accepts_message(self, message: CoreEventMessage) -> bool: ... + def begin_submission(self, owner: ConversationSubmissions, submission: PromptInputSubmission) -> SubmissionStart: + return owner.unbound(submission, self.readiness_refusal) @abstractmethod - def begin_submission(self, owner: ConversationSubmissions, submission: AgentInputSubmission) -> SubmissionStart: ... + def matches_agent(self, agent: object) -> bool: ... - @abstractmethod - def accepts_failed_input(self, message: CoreEventMessage) -> bool: ... + def accepts_publication( + self, message: CoreEventMessage, session_id: str | None + ) -> bool: + """A connection without an ACP session never reads its publisher.""" + return False - @abstractmethod - def matches_agent(self, agent) -> bool: ... + def borrowed_processes(self) -> tuple[AgentProcess, ...]: + return () + + async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None: + source.presentation.sources.attach_started(source, plan) + + async def change_project(self, view: Conversation, path: Path) -> None: + view.feedback.flash("Project changes require an agent-comms session", style="error") + + async def project_changed(self, path: Path) -> None: + """A connection without a native session has no project row to persist.""" + + def refresh_context(self, view: Conversation) -> None: + """A connection without native measurements leaves the display unchanged.""" + + def request_compaction(self, view: Conversation, instructions: str | None) -> None: + view.feedback.flash("Context compaction requires an agent-comms session", style="error") + + async def compact_context(self, instructions: str | None) -> None: + """No native context exists to compact.""" + + async def cancel_turn(self, view: Conversation) -> None: + """No native turn exists to cancel.""" + + def cancel_allowed(self, turn: TurnOwner) -> bool: + return False + + async def cancel(self) -> bool: + return False + + def invalidate_observation(self, observation: SessionObservation) -> None: + """A connection without a native source schedules no observation read.""" + + def get_info(self) -> str: + return "" + + @property + def current_turn(self): + return NoTurn() + + @property + def available_modes(self): + return () + + @property + def current_mode(self): + return None + + async def set_mode(self, mode_id: str) -> str | None: + return "This agent does not support mode selection" + + async def set_model(self, model_id: str) -> str | None: + return "This agent does not support model selection" + + async def set_session_name(self, name: str) -> None: + """A connection without a native session has no remote title to change.""" + + async def stop(self) -> None: + """A connection without a native process has nothing to stop.""" + + def observed_thread(self) -> tuple[str, str] | None: + return None goal_control_refusal: ClassVar[str] = "Persistent goals require an agent-comms session" @@ -115,30 +186,19 @@ async def update_goal(self, action: type[GoalAction], text: str = "") -> GoalDec class NoAgentConnection(AgentConnection): """A real saved view before it acquires any native connection.""" - ready = False automatic_titles = True - available_modes = () - current_mode = None - session_disposition = UnopenedSessionDisposition() @cached_property - def presentation(self): + def presentation(self) -> AgentPresentation: return LocalAgentPresentation(self) - @cached_property - def configuration(self): - return AgentConfiguration(self) - - def get_info(self) -> str: - return "" - async def retire_surface(self, view: Conversation) -> None: """There is no native surface to retire.""" - def attach_view(self, view: ConversationSessionBinding) -> None: + def attach_view(self, view: Conversation) -> None: """There is no event stream to subscribe to.""" - def refresh_projection(self, view: ConversationSessionBinding) -> None: + def refresh_projection(self, view: Conversation) -> None: """There is no native projection to read.""" def start_session(self, view: ConversationSessionBinding) -> None: @@ -153,68 +213,14 @@ def update_view(self, view: ConversationSessionBinding) -> None: def connected(self, view: ConversationSessionBinding) -> None: """There is no connected-agent notification.""" - async def set_session_name(self, name: str) -> None: - """A view can have a title before acquiring its native session.""" - - async def stop(self) -> None: - """There is no agent process to stop.""" - def detach_surface(self, view: Conversation) -> None: """There is no agent subscription to detach.""" - def accepts_message(self, message: CoreEventMessage) -> bool: + def matches_agent(self, agent: object) -> bool: return False - def matches_agent(self, agent) -> bool: - return False - - async def project_changed(self, path: Path) -> None: - """There is no native session project to persist.""" - - def refresh_context(self, view: Conversation) -> None: - """There is no native measurement to display.""" - async def set_model(self, model_id: str) -> str: return "No agent is bound yet" async def set_mode(self, mode_id: str) -> str: return "No agent is bound yet" - - async def compact_context(self, instructions) -> None: - """No native context exists to compact.""" - - async def cancel_turn(self, view: Conversation) -> None: - """No native turn exists to cancel.""" - - def begin_submission(self, owner: ConversationSubmissions, submission: AgentInputSubmission): - return owner.unbound(submission, self.readiness_refusal) - - def accepts_failed_input(self, message: CoreEventMessage) -> bool: - return False - - def cancel_allowed(self, turn: TurnOwner) -> bool: - return False - - async def cancel(self) -> bool: - return False - - def matches_session(self, session_id: str) -> bool: - return False - - def borrowed_processes(self): - return () - - async def reconnect_session(self, source: MainScreen, plan: ExistingSessionLoadPlan) -> None: - source.presentation.sources.attach_started(source, plan) - - async def change_project(self, view: Conversation, path: Path) -> None: - view.feedback.flash("Project changes require an agent-comms session", style="error") - - def request_compaction(self, view: Conversation, instructions) -> None: - view.feedback.flash("Context compaction requires an agent-comms session", style="error") - - async def observe_thread_presentation(self, presentation: ThreadPresentation) -> None: - """There is no connected agent to inform.""" - - def invalidate_observation(self, observation: SessionObservation) -> None: - """No native source exists to schedule a read against.""" diff --git a/src/toad/agent_presentation.py b/src/toad/agent_presentation.py index 1aef2f56d..81650925f 100644 --- a/src/toad/agent_presentation.py +++ b/src/toad/agent_presentation.py @@ -4,6 +4,7 @@ from abc import abstractmethod from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING from agent_comms.nominal.declared_family import DeclaredFamily from agent_comms.acp_extension import NoQueueScope, PendingQueueProjection, QueueProjection, QueueScope from toad.private_native_cursor import CursorStatus, HiddenCursorStatus @@ -11,6 +12,9 @@ from toad.acp.queue_attachment import QueueActionRefused from toad.slash_command import AgentAdvertisedCommand +if TYPE_CHECKING: + from toad.acp.agent import Agent + @dataclass(frozen=True) class AgentAttachmentView: @@ -23,7 +27,11 @@ class AgentPresentation(DeclaredFamily, affix="AgentPresentation"): def __init__(self, agent): self.agent = agent self.turns = self.TURN_BINDING(agent) - self.log_path: Path | None = None + + @property + def log_path(self) -> Path | None: + """Sources without an ACP process have no process log.""" + return None @property def auth_methods(self): @@ -86,6 +94,14 @@ def attachments(self): class ACPAgentPresentation(AgentPresentation): TURN_BINDING = LocalTurnBinding + def __init__(self, agent: Agent, log_path: Path) -> None: + super().__init__(agent) + self._log_path = log_path + + @property + def log_path(self) -> Path: + return self._log_path + @property def auth_methods(self): response = self.agent.process.initialization @@ -103,7 +119,7 @@ def queue(self): @property def queue_supported(self) -> bool: """Advertisement belongs to the original coordination declaration.""" - return self.agent.coordination is not None + return self.agent.coordination_attachment.queue_supported @property def queue_scope(self) -> QueueScope: diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 027cdb3b9..0a844b2f5 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Iterable from functools import cached_property from typing import TYPE_CHECKING, Callable, ClassVar, cast @@ -16,7 +15,7 @@ from toad.block_content import BlockContent from toad.frame_presentation import AnimationEffects -from toad.notification_status import MessageQueueEntry +from toad.notification_status import QueueEntries from toad.widgets.presentation_window import NewerEnd, OlderEnd, PageEnd if TYPE_CHECKING: @@ -377,7 +376,7 @@ def clear(self): self._child = None -class ConversationEntry(AnimationEffects[Mix], BlockContent): +class ConversationEntry(AnimationEffects[Mix], BlockContent, QueueEntries): """Conversation content, with an owned atomic cursor by default. The rows drawn inside an entry paint its fades (`LinkedRows.arrival`). @@ -404,10 +403,6 @@ def notification_identity_for(self, references: frozenset[MessageReference]) -> """Live entries have no captured BUS display cut; source rows override with theirs.""" return NoDisplaySelection() - def queue_entries(self) -> Iterable[MessageQueueEntry]: - """Live entries supply no captured BUS delivery rows; source rows override with theirs.""" - return () - @cached_property def block_cursor(self) -> BlockCursor: return AtomicBlockCursor(self) diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 5d74faeb1..343a51044 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -75,6 +75,7 @@ from toad.resource_release import AsyncResourceRelease, ResourceRelease if TYPE_CHECKING: + from textual.worker import Worker from agent_comms.display_order import DisplayOrder from agent_comms.display_references import ReferenceContext from agent_comms.thread_identity import ThreadIncarnation @@ -503,7 +504,7 @@ def take_sidebar_waiters(self, answered: ObserveSidebar) -> list[asyncio.Future[ @handles(ThreadsObserved) def threads_observed(self, result: ThreadsObserved, observed: Observing) -> None: self.thread_status.observed(result) - self.events.publish(ThreadRowsChanged(names=result.changed, root=str(observed.service.root))) + self.events.publish(ThreadRowsChanged(threads=result.changed, root=str(observed.service.root))) @handles(ViewsRetired) def views_retired(self, result: ViewsRetired, observed: Observing) -> None: @@ -623,6 +624,12 @@ async def current_sidebar(self) -> SidebarObservation: self.sidebar_waiters.remove(pending) self.update_sidebar_interest() + def request_thread_interest(self) -> Worker[None]: + """Schedule this access owner's current thread observation interest.""" + return self.app.run_worker( + self.update_thread_interest, group="thread-interest", exclusive=True + ) + async def update_thread_interest(self) -> None: """Acquire the observer through preparation before sending current-root interest.""" observed = await self.preparation.run_thread(self.observation.observed) diff --git a/src/toad/conversation_comms_handlers.py b/src/toad/conversation_comms_handlers.py deleted file mode 100644 index cea4218a0..000000000 --- a/src/toad/conversation_comms_handlers.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Conversation comms behavior on the existing receiver MRO.""" - -from __future__ import annotations -from toad.core_event_carrier import CoreEventReceiver -from toad.core_event_carrier import CoreEventMessage -import agent_comms.agent_events as comms_events -from agent_comms.acp_extension import ( - CompactionChangedUpdate, - CoordinationChangedUpdate, - GoalChangedUpdate, - InputFailedUpdate, - InputStartedUpdate, - PromptCancelledUpdate, - McpClientAcknowledgementUpdate, - TranscriptChangedUpdate, - TranscriptSnapshotUpdate, - TurnChangedUpdate, -) -from metaclass_registry.mro_dispatch import handles -from textual.content import Content -from toad.core import events as core_events -from toad.acp.attachment_presentation import CursorPresentation, QueuePresentation -from toad.widgets.note import Note -from toad.widgets.markdown_note import MarkdownNote -from typing import TYPE_CHECKING -from agent_comms.acp_extension import NativeRoundCommittedUpdate - -if TYPE_CHECKING: - from toad.transcript_publication import TranscriptPresentation - - -class ConversationCommsHandlers(CoreEventReceiver): - transcript: TranscriptPresentation - - def accepts(self, message: CoreEventMessage) -> bool: - if not message.event.session_scoped: - return True - if not self.agent_binding.accepts_message(message): - return False - return self.turns.owner.accepts_update(message.event.update) - - @handles(CursorPresentation) - def on_private_native_cursor_update( - self, update: CursorPresentation, message: CoreEventMessage - ) -> None: - message.stop() - if message.event.sequence <= self._private_cursor_sequence: - return - self._private_cursor_sequence = message.event.sequence - self.native_history_status = update.status - - @handles(McpClientAcknowledgementUpdate) - async def on_mcp_client_status( - self, update: McpClientAcknowledgementUpdate, message: CoreEventMessage - ) -> None: - """Show the MCP server summary only within the active turn it describes.""" - if self._mcp_live_note: - return # The package emits at most one server summary per turn. - rows = update.acknowledgement.servers - summary = ( - "; ".join( - f"{row.id}[{row.scope}] {row.state.declared_name} calls={row.calls.declared_name}" - f" tools={row.tools} resources={row.resources} prompts={row.prompts}" - for row in rows - ) - or "no approved servers" - ) - self.output.boundary() - self._mcp_live_note = Note( - Content.styled( - f"MCP live (this turn, not a grant): {summary}", "$text-muted" - ), - ) - await self.post(self._mcp_live_note) - - @handles(TurnChangedUpdate) - async def on_turn_changed( - self, update: TurnChangedUpdate, message: CoreEventMessage - ) -> None: - if not self.turns.changed(message): - return - if self.turns.owner.busy: - return - await self._clear_mcp_live() - self._agent_activity_boundary.reset() - await self.output.settle() - self.transcript.retry() - - @handles(QueuePresentation) - async def on_queue_view_update( - self, update: QueuePresentation, message: CoreEventMessage - ) -> None: - async with self.window.preserve_history(root=self.contents): - for started in update.starts: - await self.present_started_input(started) - self.submissions.publish_pending() - - @handles(InputStartedUpdate) - async def on_input_started( - self, update: InputStartedUpdate, message: CoreEventMessage - ) -> None: - async with self.window.preserve_history(root=self.contents): - await self.present_started_input(update) - self.submissions.publish_pending() - - @handles(InputFailedUpdate) - def on_input_failed( - self, update: InputFailedUpdate, message: CoreEventMessage - ) -> None: - """Only a locally failed request may recover its own draft text.""" - if not self.submissions.accepts_failure(message): - return - if not message.event.recover_draft: - # Server notices (including unstarted queued/restored inputs) are - # read-only evidence, not permission to change the local composer. - self.feedback.flash( - f"{message.event.update.failure.title}: {message.event.update.failure.description}\n{message.event.update.failure.input_disposition}\n{message.event.update.failure.action}", - style="error", - ) - return - self.prompt.restore_draft(message.event.update.text) - - self.feedback.flash( - f"{message.event.update.failure.title}; draft restored: {message.event.update.failure.description}\n{message.event.update.failure.input_disposition}\n{message.event.update.failure.action}", - style="error", - ) - - @handles(core_events.CommsUpdated) - async def on_comms_updated(self, event: core_events.CommsUpdated, message: CoreEventMessage) -> None: - await self.dispatch(event.update, message) - - @handles(CoordinationChangedUpdate) - def on_coordination_changed( - self, update: CoordinationChangedUpdate, message: CoreEventMessage - ) -> None: - # Source binding is published independently of registry/activity changes: - # a newly bound thread joins the observed threads (or reads as foreign). - self.arm_thread_read() - self.refresh_thread_status() - self.prompt.sync_session() - self.prompt.sync_queue() - - @handles(PromptCancelledUpdate) - async def on_prompt_cancelled( - self, update: PromptCancelledUpdate, message: CoreEventMessage - ) -> None: - await self.post( - MarkdownNote( - f"## Turn cancelled\n\n{update.feedback}", classes="-stop-reason" - ) - ) - - @handles(TranscriptChangedUpdate) - def on_transcript_changed( - self, update: TranscriptChangedUpdate, message: CoreEventMessage - ) -> None: - self.transcript.changed(update.cursor) - - @handles(NativeRoundCommittedUpdate) - def on_native_round_committed( - self, update: NativeRoundCommittedUpdate, message: CoreEventMessage - ) -> None: - self.transcript.require_commit() - - @handles(GoalChangedUpdate) - def on_goal_changed( - self, update: GoalChangedUpdate, message: CoreEventMessage - ) -> None: - self.goal_observation.receive(message.publisher, update) - - @handles(CompactionChangedUpdate) - async def on_compaction_changed( - self, update: CompactionChangedUpdate, message: CoreEventMessage - ) -> None: - await self.dispatch(update.event, message) - - @handles(comms_events.CompactionEnd) - def on_compaction_end( - self, event: comms_events.CompactionEnd, message: CoreEventMessage - ) -> None: - # Pi's saved compaction entry owns the notice; re-read the transcript. - self.transcript.require_commit() - - @handles(TranscriptSnapshotUpdate) - def on_transcript_snapshot( - self, update: TranscriptSnapshotUpdate, message: CoreEventMessage - ) -> None: - self.transcript.received(update.page) diff --git a/src/toad/conversation_submission.py b/src/toad/conversation_submission.py index 3e442566a..1a2113979 100644 --- a/src/toad/conversation_submission.py +++ b/src/toad/conversation_submission.py @@ -4,35 +4,35 @@ import asyncio from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import partial -from typing import ClassVar +from pathlib import Path from acp.exceptions import RequestError from agent_comms.acp_extension import ( InputStartedUpdate, - PendingQueueProjection, QueuePromptRequest, QueueScope, SteerPromptRequest, ) from agent_comms.nominal.declared_family import DeclaredFamily +from agent_comms.delivery_failure import delivery_failure +from agent_comms.errors import CommittedMessageError, HumanSendError, HumanSendRefusedError, RelationViolationError, RouteChangedError +from agent_comms.messages import Message from toad import messages -from toad.acp.client_session import ClientSessionRequest +from agent_comms.acp_failure import ACPFailure 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, QueueSource +from toad.queue_capability import QueueActions, QueueEvidence 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 @dataclass(frozen=True) class InputSubmission(DeclaredFamily, affix="InputSubmission"): source_text: str - priority: ClassVar[int] = 0 @property def text(self): @@ -49,41 +49,35 @@ async def execute(self, owner: ConversationSubmissions): ... 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) + for kind in sorted(cls.members_with(InputSubmission), key=lambda kind: len(kind.__mro__), reverse=True) if kind.matches(event, owner) ) return kind(event.body) class NoInputSubmission(InputSubmission): - priority = -1 - @classmethod def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): - return True + return not event.body.strip() async def execute(self, owner: ConversationSubmissions): pass -class QueueNowInputSubmission(InputSubmission): - priority = 100 - +class QueueNowInputSubmission(NoInputSubmission): @classmethod def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): if event.shell or event.body.strip(): return False - return event.immediate and owner.view.agent_binding.presentation.queue_supported + return event.immediate and owner.view.queue_source.queue_actions_available(owner.view.agent_binding.presentation) async def execute(self, owner: ConversationSubmissions): projection = owner.queue_projection - if projection.status == "available" and projection.items: + if projection.items: owner.send_now() class ShellInputSubmission(InputSubmission): - priority = 90 - @classmethod def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): return event.shell and bool(event.body.strip()) @@ -109,7 +103,7 @@ async def prepare(self): ... class UnboundSubmissionStart(SubmissionStart): def __init__( - self, owner: ConversationSubmissions, submission: AgentInputSubmission, refusal: InputRefused + self, owner: ConversationSubmissions, submission: PromptInputSubmission, refusal: InputRefused ): self.owner, self.submission, self.refusal = owner, submission, refusal @@ -120,7 +114,7 @@ async def prepare(self): ) -class AgentInputSubmission(InputSubmission): +class PromptInputSubmission(InputSubmission): @property @abstractmethod def request(self): ... @@ -143,7 +137,7 @@ async def execute(self, owner: ConversationSubmissions): return await owner.begin(self).prepare() -class OrdinaryInputSubmission(AgentInputSubmission): +class OrdinaryInputSubmission(PromptInputSubmission): @classmethod def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): if event.shell: @@ -156,8 +150,6 @@ def request(self): class DeferredInputSubmission(OrdinaryInputSubmission): - priority = 70 - @classmethod def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): if not super().matches(event, owner): @@ -165,7 +157,7 @@ def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): if event.immediate: return False view = owner.view - return view.turns.owner.busy and view.agent_binding.presentation.queue_supported + return view.queue_source.defers_input(view) @property def request(self): @@ -177,77 +169,255 @@ async def feedback(self, execution: SubmissionExecution): class ImmediateInputSubmission(OrdinaryInputSubmission): - priority = 80 - @classmethod def matches(cls, event: UserInputSubmitted, owner: ConversationSubmissions): if not super().matches(event, owner): return False - return event.immediate and owner.view.agent_binding.presentation.queue_supported + return event.immediate and owner.view.queue_source.queue_actions_available(owner.view.agent_binding.presentation) @property def request(self): return SteerPromptRequest(self.text) +class SendState: + """One transaction's local outcome; widgets only draw this answer.""" + + @property + def reason(self) -> str: + return "" + + def settle(self, execution) -> None: + execution.owner.retire(execution) + + def acknowledged_by(self, view) -> bool: + return False + + def response_finished(self, execution) -> None: + """A retained outcome already names the transaction's answer.""" + + def response_failed(self, execution, failure: ACPFailure) -> None: + """An observed input acknowledgement is not undone by a failed reply.""" + + def response_confirmed(self, execution) -> None: + """A confirmed input disposition settles input, not merely an RPC reply.""" + execution.enter(NoSend()) + + def presented(self, execution, evidence: SendState) -> None: + """An in-flight request retains actual input acknowledgement until its reply.""" + execution.enter(evidence) + + def local_intent(self, execution, accepted) -> tuple[str, ...]: + return () + + async def present_written(self, execution) -> None: + """Only a definite original publication supplies a message to present.""" + + def waiter_cancelled(self, execution) -> None: + """Cancellation cannot undo an already definite completion.""" + + +class NoSend(SendState): + pass + + +class RetainedSend(SendState): + def settle(self, execution) -> None: + """A pending, undrawn or UNKNOWN result keeps its original transaction.""" + + +class CheckingSend(RetainedSend): + reason = "Checking source; do not retry" + + def response_failed(self, execution, failure: ACPFailure) -> None: + execution.delivery_refused(failure.feedback, execution.submission.source_text) + + def response_finished(self, execution) -> None: + execution.delivery_refused( + "Request ended before handing input to its source", execution.submission.source_text, + ) + + def local_intent(self, execution, accepted) -> tuple[str, ...]: + return () if execution.accepted(accepted) else (execution.submission.text,) + + +class SendPending(CheckingSend): + reason = "Send pending; do not retry" + + def waiter_cancelled(self, execution) -> None: + execution.delivery_blocked( + "Send cancelled while worker may still write; outcome UNKNOWN, inspect the bus, do not retry", + execution.submission.source_text, + ) + + def response_finished(self, execution) -> None: + execution.delivery_blocked( + "Request ended without a known outcome; inspect the source, do not retry", + execution.submission.source_text, + ) + + +@dataclass(frozen=True) +class SentAwaitingSource(RetainedSend): + root: Path + sent: Message + + @property + def reason(self) -> str: + return f"Sent {self.root}/{self.sent.seq}/{self.sent.message_id}; waiting for source acknowledgement, do not retry" + + def acknowledged_by(self, view) -> bool: + return view.queue_source.contains_message(view, self.root, self.sent) + + async def present_written(self, execution) -> None: + if execution.current: + await execution.present(self.sent) + + +@dataclass(frozen=True) +class SendBlocked(RetainedSend): + description: str + + def presented(self, execution, evidence: SendState) -> None: + """A late original acknowledgement resolves the already-finished request.""" + execution.enter(NoSend()) + execution.owner.finish(execution) + + @property + def reason(self) -> str: + return f"Send blocked: {self.description}" + + class SubmissionExecution(SubmissionStart): - """Immutable source binding and the real outstanding local request.""" + """Shared history, worker and outcome lifetime over an immutable source binding.""" - def __init__(self, owner: ConversationSubmissions, submission: AgentInputSubmission, agent: Agent): - self.owner, self.submission, self.agent = owner, submission, agent - self.authority = ClientSessionRequest(agent, agent.session_id) - self.scope = agent.queue_attachment.scope - self.request = submission.request - self.started_update: InputStartedUpdate | None = None + worker_group = __qualname__ + + def __init__(self, owner: ConversationSubmissions, submission: PromptInputSubmission): + self.owner, self.submission = owner, submission + self.state: SendState = CheckingSend() + + @property + @abstractmethod + def current(self) -> bool: ... + + @property + def retired(self): + return not self.current + + def enter(self, state: SendState) -> None: + self.state = state + self.owner.view.refresh_submission_state() + + def accepted(self, accepted) -> bool: + return False + + def native_input_presented(self, started: InputStartedUpdate) -> None: + """A non-native source has no native start to observe.""" + + async def feedback(self): + """Only a source's own acceptance can confirm its input.""" async def prepare(self): - owner, submission = self.owner, self.submission - view = owner.view - owner.publish_pending() + view, submission = self.owner.view, self.submission + self.owner.publish_pending() view.run_worker(partial(view.input_histories.prompt.record, submission.source_text), group="history") try: - await submission.feedback(self) - if self.current: - return view.run_worker(partial(self.send), group="conversation-submission") - owner.finish(self) + worker = view.run_worker(partial(self.send), group=self.worker_group) + worker._add_done_callback(lambda _worker: self.finish()) + return worker except BaseException: - owner.finish(self) + self.finish() raise - @property - def current(self): - view = self.owner.view - if view.agent_binding is not self.agent or self.authority.retired: - return False - return self.agent.queue_attachment.accepts_request(self.scope) + @abstractmethod + async def deliver(self): ... + + async def send(self): + await self.feedback() + if self.current: + await self.deliver() + else: + self.delivery_refused("Message source changed; reopen this view", self.submission.source_text) + + def finish(self) -> None: + self.state.response_finished(self) + self.owner.finish(self) + + def delivery_blocked(self, description: str, body: str) -> None: + self.enter(SendBlocked(description)) + if self.owner.view.is_attached: + self.owner.view.prompt.restore_draft(body) + self.owner.view.feedback.flash(self.state.reason, style="error") + + def delivery_refused(self, description: str, body: str) -> None: + self.enter(NoSend()) + if self.owner.view.is_attached: + self.owner.view.delivery_refused(description, body) + + +class MessageSubmissionExecution(SubmissionExecution): + """An original Core publication, including a worker whose outcome is UNKNOWN.""" + + def __init__(self, owner: ConversationSubmissions, submission: PromptInputSubmission, + root: Path, conversation, current: Callable[[], bool], + present: Callable[[Message], Awaitable[None]]): + super().__init__(owner, submission) + self.root, self.conversation = root, conversation + self.source_current, self.present = current, present + self.publication: asyncio.Task[Message] | None = None @property - def retired(self): - return not self.current + def current(self) -> bool: + return self.source_current() - async def send(self): - view = self.owner.view - managed = self.agent.presentation.uses_managed_turns - local = not managed - reason = None - if local: - view.turns.start_client() + async def deliver(self): + view, body = self.owner.view, self.submission.source_text try: - if self.agent.presentation.queue_supported: - reason = await self.agent.send_prompt(self.submission.text, request=self.request) - else: - reason = await self.agent.send_prompt(self.submission.text) - except RequestError as error: - if self.current: - await view.submission_request_failed(error, self.submission.text) - finally: - self.owner.finish(self) - if self.current and local: - view.call_later(self.complete, reason) + comms = await view.app.coordination_access.transcripts.service(self.root) + read_only = await view.app.preparation.run_thread(self.conversation.read_only, comms) + if read_only: + raise HumanSendRefusedError(RelationViolationError("Read-only saved view")) + route_current = await view.app.preparation.run_thread( + lambda: view.app.coordination_access.current(self.root)) + if not route_current or not self.current: + raise HumanSendRefusedError(RelationViolationError("Message route changed; reopen this view")) + runtime = view.app.preparation + self.publication = await runtime.admit_thread(partial( + comms.messaging.send_user_message, self.conversation.send_target(), body, + worktree=view.project_path)) + self.enter(SendPending()) + self.publication.add_done_callback( + self.publication_finished, context=runtime.renderer.execution_context(), + ) + try: + await runtime.renderer.wait_for_work(self.publication) + except asyncio.CancelledError: + self.state.waiter_cancelled(self) + return + except asyncio.CancelledError: + self.delivery_refused("Cancelled before sending", body) + raise + except (HumanSendError, RouteChangedError) as error: + delivery_failure(error).present(self, body) + return + await self.state.present_written(self) - async def complete(self, reason): + def publication_finished(self, completed: asyncio.Task[Message]) -> None: + """The original physical writer resolves UNKNOWN; no waiter may replay it.""" + try: + sent = completed.result() + except CommittedMessageError as error: + sent = error.record + except (HumanSendError, RouteChangedError) as error: + view = self.owner.view + view.log.error("Original message publication failed", self.root, error) + delivery_failure(error).present(self, self.submission.source_text) + self.owner.finish(self) + return + self.enter(SentAwaitingSource(self.root, sent)) if self.current: - await self.owner.view.agent_turn_over(reason) + self.owner.settle_acknowledged() class ConversationSubmissions: @@ -257,35 +427,54 @@ def __init__(self, view): self.view = view self.active: list[SubmissionExecution] = [] - def begin(self, submission: AgentInputSubmission): - return self.view.agent_binding.begin_submission(self, submission) + @property + def retains_surface(self) -> bool: + """Pending, unacknowledged and UNKNOWN intent still owns this view's resources.""" + return bool(self.active) + + async def close(self) -> None: + workers = self.view.workers.cancel_group(self.view, SubmissionExecution.worker_group) + # Worker reports errors itself; retirement waits for every completion, + # including cancellation, before the view releases its draft resources. + await asyncio.gather(*(worker.wait() for worker in workers), return_exceptions=True) + + def begin(self, submission: PromptInputSubmission): + return self.view.queue_source.begin_submission(self, submission) - def unbound(self, submission: AgentInputSubmission, refusal: InputRefused): + def unbound(self, submission: PromptInputSubmission, refusal: InputRefused): return UnboundSubmissionStart(self, submission, refusal) - def start(self, submission: AgentInputSubmission, agent: Agent): - execution = SubmissionExecution(self, submission, agent) + def start(self, execution: SubmissionExecution): self.active.append(execution) + self.view.refresh_submission_state() return execution + def start_message(self, submission: PromptInputSubmission, root: Path, conversation, + current: Callable[[], bool], present: Callable[[Message], Awaitable[None]]): + return self.start(MessageSubmissionExecution(self, submission, root, conversation, current, present)) + + @property + def send_block_reason(self) -> str: + return next((item.state.reason for item in self.active if item.state.reason), "") + @property def input_decision(self) -> InputDecision: - return self.view.operational_sources.input_decision(self.view) + return self.view.queue_source.input_decision(self.view) async def submit(self, event: UserInputSubmitted): text = event.body.strip() if not event.shell and text.startswith("/") and await CommandCatalog.execute(text, self.view): return - return await self.view.operational_sources.submit(self, event) - - def accepts_failure(self, message: CoreEventMessage): - return self.view.agent_binding.accepts_failed_input(message) + if self.send_block_reason: + self.view.delivery_refused(self.send_block_reason, event.body) + return + return await InputSubmission.from_event(event, self).execute(self) @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.send_block_reason, self.view.contents.notification_targets) @property def queue_summary(self) -> str: @@ -304,35 +493,32 @@ def queued_inputs(self): def delivering(self): queue = self.queue_projection accepted = {row.input_id for row in (*queue.items, *queue.restored)} - return tuple( - item.submission.text - for item in self.active - if item.current - and item.agent.presentation.uses_managed_turns - and item.request.input_id not in accepted - and item.started_update is None - ) + return tuple(text for item in self.active if item.current + for text in item.state.local_intent(item, accepted)) def native_input_presented(self, started: InputStartedUpdate): """Retain this request's actual response while its RPC is outstanding.""" for execution in self.active: - if execution.current and started.started_request(execution.request.input_id): - execution.started_update = started + execution.native_input_presented(started) def publish_pending(self): if (prompt := self.view.query_one_optional(Prompt)) is not None: prompt.sync_queue() - def finish(self, execution: SubmissionExecution): + def retire(self, execution: SubmissionExecution) -> None: if execution in self.active: self.active.remove(execution) - if execution.current: - self.publish_pending() - def reset(self): - self.active.clear() + def finish(self, execution: SubmissionExecution): + execution.state.settle(execution) self.publish_pending() + def settle_acknowledged(self) -> None: + for execution in tuple(self.active): + if execution.state.acknowledged_by(self.view): + execution.enter(NoSend()) + self.finish(execution) + def send_now(self): source = self.view.queue_source presentation = self.view.agent_binding.presentation @@ -342,7 +528,7 @@ def send_now(self): group="send-queued-now", exclusive=True, ) - async def _send_now(self, source: QueueSource, presentation, scope: QueueScope) -> None: + async def _send_now(self, source: QueueActions, presentation, scope: QueueScope) -> None: try: await source.queue_send_now(presentation, scope) except (RequestError, QueueActionRefused) as error: diff --git a/src/toad/core/events.py b/src/toad/core/events.py index 390a5563e..8bbca9221 100644 --- a/src/toad/core/events.py +++ b/src/toad/core/events.py @@ -7,7 +7,7 @@ from dataclasses import InitVar, dataclass, field from functools import cached_property from pathlib import Path -from typing import TYPE_CHECKING, Annotated, ClassVar +from typing import TYPE_CHECKING, Annotated from weakref import WeakMethod, WeakSet, WeakValueDictionary, ref from agent_comms.nominal.declared_family import DeclaredFamily @@ -18,17 +18,22 @@ from toad.acp.status import ToolCallStatus from toad.acp.sdk_boundary import ToolCallWire from toad.live_output import OutputStream, ThoughtStream -from agent_comms.acp_extension import AgentCommsUpdate, QueueScope, NoQueueScope, RequestFailedUpdate +from agent_comms.acp_extension import AgentCommsUpdate, RequestFailedUpdate from toad.acp.attachment_presentation import CursorPresentation, QueuePresentation if TYPE_CHECKING: from agent_comms.commander_hud import CommanderHud + from toad.comms_root import CoordinationAccess + from toad.core_event_carrier import CoreEventMessage + from toad.widgets.conversation import Conversation class CoreEvent(DeclaredFamily, affix="Event"): """An application publication; its wire shape belongs to FieldCodec.""" - session_scoped: ClassVar[bool] = False + def accepted_by(self, message: CoreEventMessage, view: Conversation) -> bool: + """Ordinary publications never inspect a conversation's native state.""" + return True def subscribers(self, stream: CoreEventStream): for event_type in type(self).__mro__: @@ -82,13 +87,15 @@ class Update(CoreEvent): class CommsUpdated(CoreEvent): """The original typed publication retains its emission-time context.""" - session_scoped = True - update: AgentCommsUpdate | QueuePresentation | CursorPresentation session_id: str | None = None sequence: int | None = None - recover_draft: bool = False - queue_scope: QueueScope = field(default_factory=NoQueueScope) + + def accepted_by(self, message: CoreEventMessage, view: Conversation) -> bool: + return ( + view.agent_binding.accepts_publication(message, self.session_id) + and view.turns.owner.accepts_update(self.update) + ) @dataclass(frozen=True) @@ -155,7 +162,7 @@ class TabHistoryChanged(CoreEvent): class NamedThreadRowsChanged(CoreEvent): """The named rows changed; tab membership belongs to OpenTabsChanged.""" - names: frozenset[str] = field() + threads: frozenset[str] @dataclass(frozen=True) @@ -253,7 +260,7 @@ class ThreadRowsChanged(NamedThreadRowsChanged): root: str def subscribers(self, stream: CoreEventStream): - for thread in self.names: + for thread in self.threads: yield from stream.thread_subscriptions.get((self.root, thread), ()) yield from super().subscribers(stream) @@ -319,8 +326,13 @@ class AgentReady(CoreEvent): class AgentFail(CoreEvent): """Failure feedback; its source owns any persistent presentation.""" - message: str - details: str + @property + @abstractmethod + def message(self) -> str: ... + + @property + @abstractmethod + def details(self) -> str: ... async def present_session(self, view) -> None: """Specialized setup/help feedback survives the session's failure label.""" @@ -345,8 +357,16 @@ async def present(self, view) -> None: class TextAgentFail(AgentFail): """A failure without a saved journal row owns its visible notice.""" - message: str - details: str = "" + title: str + description: str = "" + + @property + def message(self) -> str: + return self.title + + @property + def details(self) -> str: + return self.description class HelpAgentFail(TextAgentFail): @@ -433,10 +453,10 @@ async def present(self, view) -> None: @dataclass(eq=False) -class Subscription: +class Subscription[Publisher]: """A subscriber owns this resource for as long as it wants publications.""" - stream: CoreEventStream + stream: CoreEventStream[Publisher] event_type: type[CoreEvent] listener: InitVar[Callable[[CoreEvent, Subscription], object]] _listener: Callable[[], Callable[[CoreEvent, Subscription], object] | None] = field(init=False, repr=False) @@ -489,7 +509,7 @@ def __exit__(self, *exc) -> None: @dataclass(eq=False) -class ThreadChangeSubscription(Subscription): +class ThreadChangeSubscription(Subscription["CoordinationAccess"]): """One live thread scope owns both delivery and observation interest.""" scope: tuple[str, str] = field(kw_only=True) @@ -499,24 +519,22 @@ def bucket(self) -> WeakSet[Subscription]: return self.stream.thread_subscriptions.setdefault(self.scope, WeakSet()) def bucket_opened(self) -> None: - publisher = self.stream.publisher - publisher.app.run_worker(publisher.update_thread_interest, - group="thread-interest", exclusive=True) + self.stream.publisher.request_thread_interest() def bucket_emptied(self) -> None: self.bucket_opened() -class CoreEventStream: +class CoreEventStream[Publisher]: """Publish original events; neither retain state nor schedule frontend work.""" - def __init__(self, publisher: object) -> None: + def __init__(self, publisher: Publisher) -> None: self._publisher = ref(publisher) self.subscriptions: dict[type[CoreEvent], WeakSet[Subscription]] = {} self.thread_subscriptions: WeakValueDictionary[tuple[str, str], WeakSet[Subscription]] = WeakValueDictionary() @property - def publisher(self) -> object: + def publisher(self) -> Publisher: publisher = self._publisher() if publisher is None: raise RuntimeError("The publication resource outlived its original owner") diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index 7ff10a298..604281921 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -1,18 +1,7 @@ -"""The concrete compose controls used by shared delivery-failure declarations.""" +"""Compose and error presentation derived from the submission owner.""" - -import asyncio from abc import abstractmethod -from dataclasses import dataclass -from pathlib import Path -from agent_comms.errors import ( - CommittedMessageError, - HumanSendError, - HumanSendRefusedError, - RelationViolationError, - RouteChangedError, -) from agent_comms.messages import Message from rich.text import Text as RichText from textual import getters @@ -21,87 +10,39 @@ from textual.widget import Widget from toad.app_node import ToadNode +from toad.conversation_submission import ConversationSubmissions from toad.widgets.flash import Flash from toad.widgets.prompt import Prompt -class SendState: - """Where this view's own human-message send stands; compose follows it.""" - - reason = "" - - def drawn_in(self, view) -> bool: - """Whether this send is now drawn in `view`, so compose can open again.""" - return False - - -class NoSend(SendState): - pass - - -class SendPending(SendState): - reason = "Send pending; do not retry" - - -@dataclass(frozen=True) -class SentUndrawn(SendState): - root: Path - sent: Message - - @property - def reason(self) -> str: - return f"Sent {self.root}/{self.sent.seq}/{self.sent.message_id}; waiting to be drawn, do not retry" - - def drawn_in(self, view) -> bool: - return view.draws_user_message(self.sent) - - -@dataclass(frozen=True) -class SendBlocked(SendState): - description: str - - @property - def reason(self) -> str: - return f"Send blocked: {self.description}" - - class DeliveryFailureView(ToadNode, Widget): - """The composed input, status and delivery behavior shared by conversations.""" + """Draw submission state; this widget owns no transport or send lifecycle.""" + submissions: ConversationSubmissions prompt = getters.query_one(Prompt) feedback = getters.query_one(Flash) status: var[str | Content | RichText] = var("") thread_status: var[str | Content | RichText] = var("") - """The thread's own status from its owner; a send or read-only block shows over it.""" composer_block: var[str] = var("") - """Why compose is held: this view's send, or a read-only saved view; empty when open.""" @abstractmethod - def draws_user_message(self, message: Message) -> bool: ... + def contains_user_message(self, message: Message) -> bool: ... async def composer_read_only(self) -> bool: return False - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._send_state: SendState = NoSend() - @property def send_block_reason(self) -> str: - """Why this view's own send holds compose; empty when it does not.""" - return self._send_state.reason + return self.submissions.send_block_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 + def refresh_submission_state(self) -> None: self.queue_evidence_changed() if self.is_attached: async def refresh() -> None: - read_only = await self.composer_read_only() - await self.refresh_composer(read_only) + await self.refresh_composer(await self.composer_read_only()) self.run_worker(refresh(), group="composer-access", exclusive=True) @@ -109,7 +50,6 @@ def compute_status(self) -> str | Content | RichText: return self.composer_block or self.thread_status async def refresh_composer(self, read_only: bool) -> None: - """Draw compose from the caller's Core answer and the current send state.""" if not self.is_attached: return reason = self.send_block_reason or ("Read-only saved view" if read_only else "") @@ -120,65 +60,6 @@ async def refresh_composer(self, read_only: bool) -> None: if not area.disabled and self.screen.focused is None: self.screen.set_focus(area) - async def submit_comms_message(self, root, conversation, body, current, present): - """CLI transcripts and wire pages use the same Core-send checks.""" - from agent_comms.delivery_failure import delivery_failure - - if not body.strip(): - return - if self.send_block_reason: - self.feedback.flash(self.send_block_reason, style="error") - return - try: - comms = await self.app.coordination_access.transcripts.service(root) - read_only = await self.app.preparation.run_thread(conversation.read_only, comms) - await self.refresh_composer(read_only) - if read_only: - self.prompt.restore_draft(body) - self.feedback.flash(self.status, style="error") - return - self._enter_send_state(SendPending()) - route_current = await self.app.preparation.run_thread( - lambda: self.app.coordination_access.current(root) - ) - if not route_current or not current(): - raise HumanSendRefusedError(RelationViolationError("Message route changed; reopen this view")) - sent = await self.commit_user_message(comms, conversation.send_target(), body) - except asyncio.CancelledError: - self.delivery_refused("Cancelled before sending", body) - raise - except (HumanSendError, RouteChangedError) as error: - delivery_failure(error).present(self, body) - return - if sent is None: - return - await present(sent) - - async def commit_user_message(self, comms, target, body): - task = asyncio.create_task(asyncio.to_thread( - comms.messaging.send_user_message, target, body, worktree=self.project_path)) - try: - sent = await asyncio.shield(task) - except asyncio.CancelledError: - self.delivery_blocked("Send cancelled while worker may still write; outcome UNKNOWN, inspect the bus, do not retry", body) - task.add_done_callback(lambda done: done.exception() if not done.cancelled() else None) - return None - except CommittedMessageError as error: - sent = error.record - self._enter_send_state(SentUndrawn(comms.root, sent)) - return sent - - def settle_user_message(self) -> None: - """Enable compose only when the sent message is drawn.""" - if self._send_state.drawn_in(self): - self._enter_send_state(NoSend()) - - def delivery_blocked(self, description: str, body: str) -> None: - self.prompt.restore_draft(body) - self._enter_send_state(SendBlocked(description)) - self.feedback.flash(self.send_block_reason, style="error") - def delivery_refused(self, description: str, body: str) -> None: self.prompt.restore_draft(body) - self._enter_send_state(NoSend()) self.feedback.flash(f"Send failed: {description}", style="error") diff --git a/src/toad/input_decision.py b/src/toad/input_decision.py index 9656ef8ed..8834a22b8 100644 --- a/src/toad/input_decision.py +++ b/src/toad/input_decision.py @@ -28,6 +28,9 @@ def agent_info_text(self, agent_info: Content) -> Content: ... @abstractmethod def announce(self, editor: PromptTextArea) -> None: ... + @abstractmethod + def begin_submission(self, owner, submission): ... + class InputAccepted(InputDecision): allows = True @@ -41,6 +44,9 @@ def agent_info_text(self, agent_info: Content) -> Content: def announce(self, editor: PromptTextArea) -> None: """Accepted input needs no refusal feedback.""" + def begin_submission(self, owner, submission): + return owner.view.agent_binding.begin_submission(owner, submission) + class InputRefused(InputDecision): allows = False @@ -59,6 +65,9 @@ def announce(self, editor: PromptTextArea) -> None: editor.app.bell() editor.post_message(messages.Flash(self.message, "error")) + def begin_submission(self, owner, submission): + return owner.unbound(submission, self) + @dataclass(frozen=True) class NoLiveAgent(InputRefused): @@ -75,3 +84,7 @@ class ModelSelectionRequired(InputRefused): class OwnerNotAttached(InputRefused): message = "No live owner binding for this thread; its input stays read-only. Your draft is kept." + + +class InputSourceUnavailable(InputRefused): + message = "Conversation source is not ready. Your draft is kept." diff --git a/src/toad/notification_status.py b/src/toad/notification_status.py index 78ef9da51..05b37b071 100644 --- a/src/toad/notification_status.py +++ b/src/toad/notification_status.py @@ -77,6 +77,13 @@ def details(self) -> DetailLines: *self.status.queue_details) +class QueueEntries: + """A drawn target supplies its original message queue evidence, or none.""" + + def queue_entries(self) -> Iterable[MessageQueueEntry]: + return () + + @dataclass(frozen=True) class UncheckedNotifications(NotificationStatus): # The row is drawn before Core answers, so the answer changes its text diff --git a/src/toad/platform.py b/src/toad/platform.py index fa8ec47f4..61744d468 100644 --- a/src/toad/platform.py +++ b/src/toad/platform.py @@ -3,9 +3,7 @@ from __future__ import annotations import platform -from typing import ClassVar, Literal, Mapping - -type OS = Literal["linux", "macos", "windows", "*"] +from typing import ClassVar, Mapping class DesktopService: @@ -76,7 +74,7 @@ class Platform: """The operating system Toad runs on; each member owns what differs there.""" system: ClassVar[str] - key: ClassVar[OS] + key: ClassVar[str] application_name: ClassVar[str] = "Toad" members: ClassVar[dict[str, type[Platform]]] = {} @@ -85,9 +83,9 @@ def __init_subclass__(cls, **kwargs) -> None: Platform.members[cls.system] = cls @classmethod - def select[V](cls, matrix: Mapping[OS, V]) -> V | None: + def select[V](cls, matrix: Mapping[str, V]) -> V | None: """The catalog's exact platform entry, or its declared wildcard.""" - return matrix.get(cls.key, matrix.get("*")) + return matrix.get(cls.key, matrix.get(UnknownPlatform.key)) @classmethod def desktop(cls) -> DesktopService: diff --git a/src/toad/queue_capability.py b/src/toad/queue_capability.py new file mode 100644 index 000000000..6767f4296 --- /dev/null +++ b/src/toad/queue_capability.py @@ -0,0 +1,50 @@ +"""Captured queue evidence and mutation capability, below submission execution.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from functools import cached_property + +from agent_comms.acp_extension import QueueScope +from agent_comms.message_reference import MessageReference +from agent_comms.notification_assignment import NotificationSelection + +from toad.agent_presentation import AgentPresentation +from toad.notification_status import MessageQueueEntry, QueueEntries + + +@dataclass(frozen=True) +class QueueEvidence: + """One UI read; only a BUS source asks for its visible message entries.""" + + presentation: AgentPresentation + delivering: tuple[str, ...] + transport: str + read_targets: Callable[[], tuple[QueueEntries, ...]] + + @cached_property + def entries(self) -> tuple[MessageQueueEntry, ...]: + entries: dict[MessageReference, MessageQueueEntry] = {} + for target in self.read_targets(): + for entry in target.queue_entries(): + entries[entry.reference] = entry + if len(entries) >= NotificationSelection.window_limit: + return tuple(entries.values()) + return tuple(entries.values()) + + +class QueueActions(ABC): + """An exact captured source, not a queue label, grants mutation authority.""" + + @abstractmethod + def queue_scope(self, presentation: AgentPresentation) -> QueueScope: ... + + @abstractmethod + def queue_actions_available(self, presentation: AgentPresentation) -> bool: ... + + @abstractmethod + async def queue_send_now(self, presentation: AgentPresentation, scope: QueueScope) -> None: ... + + @abstractmethod + async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) -> None: ... diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 47c3f7a94..091a35570 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -9,6 +9,7 @@ from weakref import ref from agent_comms.acp_extension import CoordinationChangedUpdate +from agent_comms.messages import Message from agent_comms.session_load import EnsuringSessionLoadPlan from agent_comms.ui_model.message_category import MessageCategory from textual.document._editing import EditingMode @@ -18,6 +19,7 @@ from toad.agent_binding import NoAgentConnection from toad.agent_surface import AttachedSurfaceBinding from toad.conversation_kind import DmConversation +from toad.conversation_submission import PromptInputSubmission, ConversationSubmissions, SubmissionStart from toad.input_history import InputHistories from toad.input_decision import InputDecision from toad.resource_release import AsyncResourceRelease @@ -179,11 +181,6 @@ def prompt(self, conversation: Conversation): return Prompt(project=conversation.project_path, turns=conversation.turns, submissions=conversation.submissions) - async def submit(self, owner, event): - from toad.conversation_submission import InputSubmission - - return await InputSubmission.from_event(event, owner).execute(owner) - def input_decision(self, conversation: Conversation) -> InputDecision: agent = conversation.agent_binding return agent.readiness_decision.then( @@ -256,6 +253,9 @@ async def close(self) -> None: class MailboxSessionSources(BusQueueSource, OperationalSessionSources): """CLI execution receives user messages through Core's inbox, not ACP.""" + def contains_message(self, view: Conversation, root: Path, sent: Message) -> bool: + return view.transcript.binding.thread_scope.contains_message(view, root, sent) + def activity_row(self, conversation: Conversation): return conversation.thread_activity.row @@ -279,21 +279,16 @@ async def present(self, conversation: Conversation) -> None: self.external_transcript_changed(conversation) self.thread_presented(conversation) - async def submit(self, owner, event): + def begin_submission(self, owner: ConversationSubmissions, + submission: PromptInputSubmission) -> SubmissionStart: view = owner.view async def presented(sent): - view.run_worker( - view.input_histories.prompt.record(event.body), group="history" - ) view.window.jump_to_latest() self.external_transcript_changed(view) - await view.transcript.binding.thread_scope.submit_mailbox( - view, - event, - DmConversation, - presented, + return view.transcript.binding.thread_scope.bind_submission( + owner, submission, DmConversation, presented, ) @@ -416,6 +411,8 @@ async def evict(self, screen: MainScreen) -> None: editor = conversation.prompt.prompt_text_area if not await editor.editing_mode.flush(editor): return # Retain the actual view/engine while its modal input is pending. + if conversation.submissions.retains_surface: + return # Eviction cannot discard an unresolved transaction or its draft recovery. self.state = RetainedSessionViewState.capture(conversation) await self.remove_native_tree(screen) diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 02a40a697..0b226223c 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -13,6 +13,7 @@ from agent_comms.repo import Checkout from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision from agent_comms.errors import RelationViolationError +from agent_comms.messages import Message from agent_comms.thread_identity import ThreadIncarnation from agent_comms.order_tools import TranscriptPageRead from agent_comms.transcripts import AtFrontier, LatestPage, LivePageSource, PageLimit, PagePosition, PageSource @@ -24,9 +25,10 @@ from toad.app_node import ToadNode from toad.constants import WELCOME +from toad.conversation_submission import PromptInputSubmission, ConversationSubmissions, SubmissionStart from toad.core.events import ThreadRowsChanged from toad.core_event_carrier import ThreadCallbackSubscription -from toad.input_decision import InputAccepted, InputDecision +from toad.input_decision import InputAccepted, InputDecision, InputSourceUnavailable from toad.navigation_context import NavigationOwner from toad.owner_preparation import ExistingOwnerPlan from toad.project_path_owner import CheckoutRoot, ProjectPathOwner @@ -35,7 +37,6 @@ if TYPE_CHECKING: from toad.widget_actions import DeclaredWidgetActions - from agent_comms.messages import Message from agent_comms.thread_presentation import ThreadPresentation from textual.command import Provider from toad.widgets.side_bar import SideBar @@ -43,7 +44,6 @@ from toad.agent_schema import AgentDefinition from toad.comms_root import CoordinationAccess from toad.conversation_kind import DmConversation - from toad.core.input_events import UserInputSubmitted from toad.navigation_target import NavigationTarget from toad.session_presentation import OperationalSessionPresentation from toad.thread_identity import SessionIdentity @@ -96,17 +96,18 @@ def update_request( position: PagePosition = LatestPage(), ) -> ReadTranscriptUpdate: ... + def contains_message(self, view: Conversation, root: Path, sent: Message) -> bool: + return False + @abstractmethod def mailbox_target(self) -> ThreadTarget: ... @abstractmethod - async def submit_mailbox( - self, - view: Conversation, - event: UserInputSubmitted, + def bind_submission( + self, owner: ConversationSubmissions, submission: PromptInputSubmission, conversation_type: type[DmConversation], - presented: Callable[[Message | None], Awaitable[None]], - ) -> None: ... + presented: Callable[[Message], Awaitable[None]], + ) -> SubmissionStart: ... @abstractmethod async def show_mailbox_status( @@ -182,22 +183,21 @@ def update_request( self.root, TranscriptPageRead(thread=self.name, position=position), previous=previous ) + def contains_message(self, view: Conversation, root: Path, sent: Message) -> bool: + return Path(self.root) == root and self.shown_by(view) and view.contains_user_message(sent) + def mailbox_target(self) -> ThreadTarget: return ThreadTarget(self.name) - async def submit_mailbox( - self, - view: Conversation, - event: UserInputSubmitted, + def bind_submission( + self, owner: ConversationSubmissions, submission: PromptInputSubmission, conversation_type: type[DmConversation], - presented: Callable[[Message | None], Awaitable[None]], - ) -> None: - await view.submit_comms_message( - self.root, - self.mailbox_conversation(view, conversation_type), - event.body, - lambda: self.shown_by(view), - presented, + presented: Callable[[Message], Awaitable[None]], + ) -> SubmissionStart: + view = owner.view + return owner.start_message( + submission, Path(self.root), self.mailbox_conversation(view, conversation_type), + lambda: self.shown_by(view), presented, ) async def show_mailbox_status( @@ -277,14 +277,12 @@ def update_request( def mailbox_target(self) -> Never: self.unavailable() - async def submit_mailbox( - self, - view: Conversation, - event: UserInputSubmitted, + def bind_submission( + self, owner: ConversationSubmissions, submission: PromptInputSubmission, conversation_type: type[DmConversation], - presented: Callable[[Message | None], Awaitable[None]], - ) -> None: - view.delivery_refused("CLI transcript is not ready", event.body) + presented: Callable[[Message], Awaitable[None]], + ) -> SubmissionStart: + return owner.unbound(submission, InputSourceUnavailable()) async def show_mailbox_status( self, view: Conversation, conversation_type: type[DmConversation] diff --git a/src/toad/submission_queue.py b/src/toad/submission_queue.py index 60902c09c..00c8c1f9f 100644 --- a/src/toad/submission_queue.py +++ b/src/toad/submission_queue.py @@ -1,64 +1,61 @@ -"""Source-owned queue capabilities and captured evidence, independent of screens.""" +"""Source-bound submission capabilities and one queue presentation workflow.""" from __future__ import annotations -from abc import ABC, abstractmethod -from collections.abc import Callable -from dataclasses import dataclass -from functools import cached_property +from pathlib import Path from typing import ClassVar from agent_comms.acp_extension import NoQueueScope, QueueItem, QueueScope -from agent_comms.message_reference import MessageReference -from agent_comms.notification_assignment import NotificationSelection +from agent_comms.messages import Message from toad.acp.queue_attachment import QueueActionRefused from toad.agent_presentation import AgentPresentation -from toad.block_navigation import ConversationEntry +from toad.conversation_submission import PromptInputSubmission, ConversationSubmissions, SubmissionStart +from toad.input_decision import InputAccepted, InputDecision, InputSourceUnavailable from toad.line_blocks import MutedRole, Span -from toad.notification_status import DetailLines, MessageQueueEntry +from toad.notification_status import DetailLines +from toad.queue_capability import QueueActions, QueueEvidence -@dataclass(frozen=True) -class QueueEvidence: - """One UI read; only a BUS source asks for its visible message entries.""" - - presentation: AgentPresentation - delivering: tuple[str, ...] - transport: str - read_targets: Callable[[], tuple[ConversationEntry, ...]] - - @cached_property - def entries(self) -> tuple[MessageQueueEntry, ...]: - entries: dict[MessageReference, MessageQueueEntry] = {} - for target in self.read_targets(): - for entry in target.queue_entries(): - entries[entry.reference] = entry - if len(entries) >= NotificationSelection.window_limit: - return tuple(entries.values()) - return tuple(entries.values()) - - -class QueueSource(ABC): - """The source selects its capability; the actual presentation owns native facts.""" +class QueueSource(QueueActions): + """The captured source controls sending, observation and queue changes.""" preview_limit: ClassVar[int] = 3 - @abstractmethod - def queue_scope(self, presentation: AgentPresentation) -> QueueScope: ... + def begin_submission(self, owner: ConversationSubmissions, + submission: PromptInputSubmission) -> SubmissionStart: + return owner.unbound(submission, InputSourceUnavailable()) - @abstractmethod - def queue_actions_available(self, presentation: AgentPresentation) -> bool: ... + def input_decision(self, view) -> InputDecision: + return InputSourceUnavailable() - @abstractmethod - async def queue_send_now(self, presentation: AgentPresentation, scope: QueueScope) -> None: ... + def defers_input(self, view) -> bool: + return False - @abstractmethod - async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) -> None: ... + def contains_message(self, view, root: Path, sent: Message) -> bool: + """Only the original message source can confirm its committed row.""" + return False def queue_summary(self, evidence: QueueEvidence) -> str: + local = evidence.delivering + submitting = (f"Submitting ({len(local)}): " + " · ".join( + QueueItem.summarize(text) for text in local[:self.preview_limit])) if local else "" + return " | ".join(part for part in ( + evidence.transport, submitting, self.observed_summary(evidence)) if part) + + def observed_summary(self, evidence: QueueEvidence) -> str: return "Queue unavailable" def queue_details(self, evidence: QueueEvidence) -> DetailLines: + lines: DetailLines = ( + (Span("Submitting is local intent; acceptance is not consumption.", MutedRole),), + *((Span(f"Submitting: {QueueItem.summarize(text)}", MutedRole),) + for text in evidence.delivering), + ) + if evidence.transport: + lines += ((Span(evidence.transport, MutedRole),),) + return (*lines, *self.observed_details(evidence)) + + def observed_details(self, evidence: QueueEvidence) -> DetailLines: return ((Span("No queue observation is available for this source.", MutedRole),),) @@ -79,29 +76,35 @@ async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) class NativeQueueSource(QueueSource): - def queue_summary(self, evidence: QueueEvidence) -> str: + def begin_submission(self, owner: ConversationSubmissions, + submission: PromptInputSubmission) -> SubmissionStart: + return self.input_decision(owner.view).begin_submission(owner, submission) + + def defers_input(self, view) -> bool: + return view.turns.owner.busy and view.agent_binding.presentation.queue_supported + + def observed_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 evidence.delivering), ""), - ("Queued", tuple(row.preview for row in projection.items), ""), - ("Restored", tuple(row.preview for row in projection.restored), ", read-only"), + for label, rows, detail in ( + ("Queued", projection.items, ""), + ("Restored", projection.restored, ", read-only"), ): - if texts: - parts.append(f"{label} ({len(texts)}{detail}): " + " · ".join(texts[:self.preview_limit])) + if rows: + parts.append(f"{label} ({len(rows)}{detail}): " + " · ".join( + row.preview for row in rows[:self.preview_limit])) return " | ".join(parts) - def queue_details(self, evidence: QueueEvidence) -> DetailLines: + def observed_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 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(evidence.presentation.queue_identity, MutedRole),)) - return tuple(lines) + return ( + *((Span(f"Queued {row.input_id}: {row.preview}", MutedRole),) for row in projection.items), + *((Span(f"Restored {row.input_id} (read-only): {row.preview}", MutedRole),) + for row in projection.restored), + (Span(evidence.presentation.queue_identity, MutedRole),), + ) def queue_scope(self, presentation: AgentPresentation) -> QueueScope: return presentation.queue_scope @@ -119,17 +122,18 @@ async def queue_clear(self, presentation: AgentPresentation, scope: QueueScope) class BusQueueSource(ReadOnlyQueueSource): - def queue_summary(self, evidence: QueueEvidence) -> str: + def input_decision(self, view) -> InputDecision: + return InputAccepted() + + def observed_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 ( - evidence.transport, coverage + (f": {previews}" if previews else ": unobserved")) if part) + return coverage + (f": {previews}" if previews else ": unobserved") - def queue_details(self, evidence: QueueEvidence) -> DetailLines: + def observed_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(evidence.transport, MutedRole),), + (Span("Visible original message references only; not a complete inbox count.", 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 evidence.entries[:self.preview_limit] for line in entry.details), (Span("Clear/send-now unavailable: this BUS source has no native queue authority.", MutedRole),), diff --git a/src/toad/transcript_preparation.py b/src/toad/transcript_preparation.py index 0aae526a0..071de7291 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.messages import Message from agent_comms.sent_message import OutgoingMessageReference from agent_comms.transcripts import ( TranscriptCursor, @@ -42,7 +43,6 @@ from agent_comms.message_page import MessagePage from toad.comms_root import CoordinationAccess, Observation - from toad.core.input_events import UserInputSubmitted from toad.transcript_source_preparation import TranscriptSourcePreparation from toad.widgets.comms_chat import CommsChatView from toad.widgets.conversation import Contents @@ -102,6 +102,10 @@ def configure(self, *, batch_size: int) -> None: if self.fragment_range is None: self.set_fragment_range(slice(max(0, len(self.fragments) - batch_size), len(self.fragments))) + def contains_message(self, message: Message) -> bool: + """Loaded source evidence does not disappear when its drawing range moves.""" + return any(fragment.contains_message(message) for fragment in reversed(self.fragments)) + @property def fragments_in_range(self): return self.fragments[self.start:self.stop] @@ -584,9 +588,6 @@ def status_thread(self, access: CoordinationAccess): def command_context(self, view: CommsChatView): return None - async def submit_input(self, view: CommsChatView, event: UserInputSubmitted) -> None: - view.delivery_refused("Conversation source is not ready", event.body) - def schedule_refresh(self, view: CommsChatView): return None diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index aef0c8613..7f3fd0378 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -109,7 +109,7 @@ def painted(self, cursor: TranscriptCursor) -> None: self.owner.displayed_cursor = cursor view.app.history_displayed() view.query_ancestor(SessionView).history_painted() - view.settle_user_message() + view.submissions.settle_acknowledged() class TranscriptPublication(BoundFrame, ABC): diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index c8998f076..937bc8992 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -2,7 +2,6 @@ from __future__ import annotations from toad.widgets.presentation_window import OLDER_END, NEWER_END -from toad.core import input_events from functools import partial from toad.widgets.wire_history import WireHistory from toad.message_rows import IrcMessageStyle @@ -41,7 +40,6 @@ from toad.widgets.flash import Flash from toad.widgets.prompt import Prompt from toad.session_scope import NoTranscript, SessionScope -from toad.command_catalog import CommandCatalog def session_thread_name(project_path) -> str: @@ -153,7 +151,7 @@ async def on_transcript_source_work_finished(self, event: TranscriptSourceWorkFi access = self.app.coordination_access if message.publisher is not history: return - self.settle_user_message() + self.submissions.settle_acknowledged() if (not history.state.accepts_source_work or not source.uses_observation(access.observation.observed())): return @@ -255,8 +253,8 @@ async def _refresh_source(self) -> None: return await self.refresh_composer(read_only) self._refreshed_from = announced - # A sent message may now be drawn: composing resumes before mentions are read. - self.settle_user_message() + # The source may now acknowledge the commit, before viewport paint or mention reads. + self.submissions.settle_acknowledged() except (CoordinationReadUnavailable, StaleRevision): raise # Composer mentions and DM status read the observer's snapshot @@ -312,19 +310,11 @@ async def composer_read_only(self) -> bool: async def command_target_context(self): return self.message_history.page_source.command_context(self) - async def submit_input(self, event: input_events.UserInputSubmitted) -> None: - if event.body.strip().startswith("/") and await CommandCatalog.execute(event.body.strip(), self): - return - if not event.body.strip(): - return - await self.message_history.page_source.submit_input(self, event) - - async def _show_sent(self, sent: WireMessage, body: str) -> None: - """Follow the conversation's end and read it now; composing resumes once `sent` is drawn.""" - self.run_worker(partial(self.input_histories.prompt.record, body), group="history") + async def _show_sent(self, sent: WireMessage) -> None: + """Read the conversation's end; composing resumes when its source acknowledges `sent`.""" self.window.jump_to_latest() await self._refresh() - self.settle_user_message() + self.submissions.settle_acknowledged() - def draws_user_message(self, sent) -> bool: - return self.message_history.draws(sent) + def contains_user_message(self, sent) -> bool: + return self.message_history.contains_message(sent) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 91e2c5093..2c466692b 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -11,7 +11,6 @@ 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 @@ -41,8 +40,9 @@ from ui_theme.effects import CURSOR_BLINK, Mix from toad import messages, paths -from toad.acp.comms_updates import NoPendingThreadRead +from toad.acp.thread_read import NoPendingThreadRead from toad.acp.status import StopReason +from toad.acp.queue_attachment import QueueActionRefused from toad.agent import AgentBase from toad.agent_binding import AgentConnection, NoAgentConnection from toad.messages import QueueEvidenceChanged @@ -61,7 +61,14 @@ ) from toad.command_catalog import CommandCatalog from toad.target_commands import CommandContextChanged, TargetContext -from toad.conversation_comms_handlers import ConversationCommsHandlers +import agent_comms.agent_events as comms_events +from agent_comms.acp_extension import ( + CompactionChangedUpdate, CoordinationChangedUpdate, GoalChangedUpdate, + InputFailedUpdate, InputStartedUpdate, McpClientAcknowledgementUpdate, + NativeRoundCommittedUpdate, PromptCancelledUpdate, TranscriptChangedUpdate, + TranscriptSnapshotUpdate, TurnChangedUpdate, +) +from toad.acp.attachment_presentation import CursorPresentation, QueuePresentation from toad.conversation_submission import ConversationSubmissions from toad.conversation_turn import ConversationTurn, TurnOwner from toad.core import events as core_events @@ -144,27 +151,6 @@ def make_session_title(prompt: str) -> str: from toad.widgets.transcript_history import LineHistory -HELP_URL = "https://github.com/batrachianai/toad/discussions" - -INTERNAL_EROR = f"""\ -## Internal error - -The agent reported an internal error: - -``` -$ERROR -``` - -This is likely an issue with the agent, and not Toad. - -- Try the prompt again -- Report the issue to the Agent developer - -Ask on {HELP_URL} if you need assistance. - -""" - - class Loading(ConversationBlock, Static): """Tiny widget to show loading indicator.""" @@ -686,7 +672,8 @@ def _initialize_session( ) self.cli_draft = CliDraft() self.tool_expansions: dict[str, bool] = {} - # Both explicit eviction and Textual unmount consume this same lifetime. + # Explicit eviction and message-loop exit consume this same lifetime + # before the child editor and feedback surfaces are released. # The stack removes callbacks before calling them and continues on error. self._source_resources = AsyncResourceRelease() self._source_resources.push_async_callback( @@ -697,12 +684,17 @@ def _initialize_session( self._source_resources.callback(self.output.retire) self._source_resources.push_async_callback(self.transcript.detach, self) self._source_resources.callback(self.goal_controls.close) + self._source_resources.push_async_callback(self.submissions.close) + + async def _message_loop_exit(self) -> None: + async with AsyncResourceRelease() as release: + release.push_async_callback(super()._message_loop_exit) + await self._release_source_resources() async def on_unmount(self) -> None: async with AsyncResourceRelease() as release: release.callback(self._publish_session_end) release.push_async_callback(self.agent_binding.retire_surface, self) - release.push_async_callback(self._release_source_resources) def _publish_session_end(self) -> None: if self._agent_data is not None and self.session_start_time is not None: @@ -835,7 +827,7 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: attachments = binding.presentation.attachments self.native_history_status = attachments.cursor self._private_cursor_sequence = attachments.cursor_sequence - self.submissions.reset() + self.submissions.publish_pending() # The observed presentation is Core's answer for this thread, not the # agent's; it stays until the next observation replaces it. self.arm_thread_read() @@ -852,13 +844,152 @@ def watch_agent_binding(self, binding: AgentConnection) -> None: class Conversation( - ConversationCommsHandlers, + CoreEventReceiver, NativeMethodKeys, ConversationSessionBinding, ToadNode, MenuConversation, actions=(ConversationAction,), ): """Holds the agent conversation (input, output, and various controls / information).""" + def accepts(self, message: CoreEventMessage) -> bool: + return message.event.accepted_by(message, self) + + @handles(CursorPresentation) + def on_private_native_cursor_update( + self, update: CursorPresentation, message: CoreEventMessage + ) -> None: + message.stop() + if update.sequence <= self._private_cursor_sequence: + return + self._private_cursor_sequence = update.sequence + self.native_history_status = update.status + + @handles(McpClientAcknowledgementUpdate) + async def on_mcp_client_status( + self, update: McpClientAcknowledgementUpdate, message: CoreEventMessage + ) -> None: + """Show the MCP server summary only within the active turn it describes.""" + if self._mcp_live_note: + return # The package emits at most one server summary per turn. + rows = update.acknowledgement.servers + summary = ( + "; ".join( + f"{row.id}[{row.scope}] {row.state.declared_name} calls={row.calls.declared_name}" + f" tools={row.tools} resources={row.resources} prompts={row.prompts}" + for row in rows + ) + or "no approved servers" + ) + self.output.boundary() + self._mcp_live_note = Note( + Content.styled( + f"MCP live (this turn, not a grant): {summary}", "$text-muted" + ), + ) + await self.post(self._mcp_live_note) + + @handles(TurnChangedUpdate) + async def on_turn_changed( + self, update: TurnChangedUpdate, message: CoreEventMessage + ) -> None: + if not self.turns.changed(message): + return + if self.turns.owner.busy: + return + await self._clear_mcp_live() + self._agent_activity_boundary.reset() + await self.output.settle() + self.transcript.retry() + + @handles(QueuePresentation) + async def on_queue_view_update( + self, update: QueuePresentation, message: CoreEventMessage + ) -> None: + async with self.window.preserve_history(root=self.contents): + for started in update.starts: + await self.present_started_input(started) + self.submissions.publish_pending() + + @handles(InputStartedUpdate) + async def on_input_started( + self, update: InputStartedUpdate, message: CoreEventMessage + ) -> None: + async with self.window.preserve_history(root=self.contents): + await self.present_started_input(update) + self.submissions.publish_pending() + + @handles(InputFailedUpdate) + def on_input_failed( + self, update: InputFailedUpdate, message: CoreEventMessage + ) -> None: + """The accepted publication supplies feedback, never the local draft.""" + self.feedback.flash( + f"{update.failure.title}: {update.failure.description}\n{update.failure.input_disposition}\n{update.failure.action}", + style="error", + ) + + @handles(core_events.CommsUpdated) + async def on_comms_updated(self, event: core_events.CommsUpdated, message: CoreEventMessage) -> None: + await self.dispatch(event.update, message) + + @handles(CoordinationChangedUpdate) + def on_coordination_changed( + self, update: CoordinationChangedUpdate, message: CoreEventMessage + ) -> None: + # Source binding is published independently of registry/activity changes: + # a newly bound thread joins the observed threads (or reads as foreign). + self.arm_thread_read() + self.refresh_thread_status() + self.prompt.sync_session() + self.prompt.sync_queue() + + @handles(PromptCancelledUpdate) + async def on_prompt_cancelled( + self, update: PromptCancelledUpdate, message: CoreEventMessage + ) -> None: + await self.post( + MarkdownNote( + f"## Turn cancelled\n\n{update.feedback}", classes="-stop-reason" + ) + ) + + @handles(TranscriptChangedUpdate) + def on_transcript_changed( + self, update: TranscriptChangedUpdate, message: CoreEventMessage + ) -> None: + self.transcript.changed(update.cursor) + + @handles(NativeRoundCommittedUpdate) + def on_native_round_committed( + self, update: NativeRoundCommittedUpdate, message: CoreEventMessage + ) -> None: + self.transcript.require_commit() + + @handles(GoalChangedUpdate) + def on_goal_changed( + self, update: GoalChangedUpdate, message: CoreEventMessage + ) -> None: + self.goal_observation.receive(message.publisher, update) + + @handles(CompactionChangedUpdate) + async def on_compaction_changed( + self, update: CompactionChangedUpdate, message: CoreEventMessage + ) -> None: + await self.dispatch(update.event, message) + + @handles(comms_events.CompactionEnd) + def on_compaction_end( + self, event: comms_events.CompactionEnd, message: CoreEventMessage + ) -> None: + # Pi's saved compaction entry owns the notice; re-read the transcript. + self.transcript.require_commit() + + @handles(TranscriptSnapshotUpdate) + def on_transcript_snapshot( + self, update: TranscriptSnapshotUpdate, message: CoreEventMessage + ) -> None: + self.transcript.received(update.page) + def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: """The conversation owns its persistent reader and composer key targets.""" return (self, self.window, self.prompt) @@ -870,14 +1001,6 @@ 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", - )) - 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(): @@ -1132,8 +1255,8 @@ async def interrupt_shell(self) -> None: def cursor_block(self) -> ConversationEntry | None: return self.navigation.current - def draws_user_message(self, sent) -> bool: - return self.transcript.binding.history.draws(sent) + def contains_user_message(self, sent) -> bool: + return self.transcript.binding.history.contains_message(sent) async def _apply_session_name(self, name: str) -> None: await self.agent_binding.set_session_name(name) @@ -1244,13 +1367,7 @@ async def action_provider_login(self) -> None: ): await asyncio.sleep(0.1) self.prompt.disabled = True - if ( - agent.coordination.wire_root - if agent and agent.coordination - else None - ) is None: - await self.prune_window(0, 0) - self.output.boundary() + await agent.coordination_attachment.prepare_login(self) await agent.session.reconnect_after_login() else: await agent.session.authenticate(method_id) @@ -1333,25 +1450,20 @@ async def submit_input(self, event: input_events.UserInputSubmitted) -> None: """Wire views override the same submission boundary.""" await self.submissions.submit(event) - async def agent_turn_over(self, stop_reason: type[StopReason] | None) -> None: - """Called when the agent's turn is over. - - Args: - stop_reason: The stop reason returned from the Agent, or `None`. - """ - self.turns.finish_client() - self._agent_activity_boundary.reset() + def record_model_usage(self) -> None: model = self.agent_binding.configuration.model.selected - if stop_reason is not None and stop_reason.completed and model is not None: + if model is not None: from toad.db import DB - # Complete visible turn settlement independently of recent-model - # database contention. The native worker owns this optional write. - self.run_worker( - DB().record_model_usage( - self.model_history_scope, model.value, last_used=time_ns() - ) - ) + # The native worker owns this optional write, not turn settlement. + self.run_worker(DB().record_model_usage( + self.model_history_scope, model.value, last_used=time_ns())) + + async def agent_turn_over(self, stop_reason: StopReason) -> None: + """Present a confirmed SDK turn completion, never a missing reply.""" + self.turns.finish_client() + self._agent_activity_boundary.reset() + stop_reason.record_model_usage(self) await self.output.settle() pending_loading, self._loading = self._loading, None if pending_loading is not None and pending_loading.is_attached: @@ -1362,7 +1474,7 @@ 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: + if stop_reason.note: await self.post( MarkdownNote( @@ -1677,7 +1789,11 @@ async def compact_context(self, instructions: str | None) -> None: """Keep processing owner notifications while compaction is in flight.""" self.window.anchor() self.feedback.flash("Compaction requested") - await self.agent_binding.compact_context(instructions) + try: + await self.agent_binding.compact_context(instructions) + except QueueActionRefused as error: + self.feedback.flash(f"Compaction not confirmed: {error}", style="error") + return self.feedback.flash("Context compacted", style="success") @property diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index 4eee1f4cc..678d8d4c6 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -20,7 +20,7 @@ from toad.messages import QueueEvidenceChanged from toad.notification_status import ( - CheckedNotifications, MessageQueueEntry, NotificationStatus, UnavailableNotifications, UncheckedNotifications, + CheckedNotifications, MessageQueueEntry, QueueEntries, NotificationStatus, UnavailableNotifications, UncheckedNotifications, ) if TYPE_CHECKING: from toad.widgets.tool_content import ToolContent @@ -191,7 +191,7 @@ def on_show(self) -> None: self.call_after_refresh(self.check_notifications) -class SourceNotifications: +class SourceNotifications(QueueEntries): notification_view: HistoryNotifications def queue_entries(self) -> Iterable[MessageQueueEntry]: diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index e6c113be5..3d7a87473 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -322,7 +322,7 @@ async def _row_state_changed(self, event: core_events.NamedThreadRowsChanged) -> members = {entry.mode: entry for entry in sessions.members} for mode in self._last_tabs.keys() & members.keys(): previous = self._last_tabs[mode] - if previous.affected_by(event.names): + if previous.affected_by(event.threads): if (label := self.query_one_optional(f"#{mode}", SessionLabel)) is not None: tab = observation.tab(members[mode], sessions) geometry_changed |= label.present(tab) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index f10b2f71e..a03f652cd 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -723,7 +723,7 @@ async def coordination_observed(self, event: CoordinationObserved) -> None: @handles(ThreadRowStateChanged) async def thread_row_state_changed(self, event: ThreadRowStateChanged) -> None: """Repaint only the thread rows named by the app event.""" - self.present(change=ChangedThreads(event.names)) + self.present(change=ChangedThreads(event.threads)) class SearchFollower: diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 7ce9011b2..715a5d352 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -352,8 +352,8 @@ def notification_identity(self) -> DisplayIdentity | NoDisplaySelection: def queue_entries(self) -> tuple[MessageQueueEntry, ...]: return () - def draws(self, message: BusMessage) -> bool: - """Whether this fragment draws that bus message; only bus-message fragments do.""" + def contains_message(self, message: BusMessage) -> bool: + """Only bus-message fragments contain the original message reference.""" return False @classmethod @@ -541,7 +541,7 @@ def queue_entries(self) -> tuple[MessageQueueEntry, ...]: return tuple(MessageQueueEntry(reference, self.shown.status) for reference in self.notification_references) - def draws(self, message: BusMessage) -> bool: + def contains_message(self, message: BusMessage) -> bool: return self.events[0].source == message.reference def shown_live_by(self, claim: CommitClaim) -> bool: diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index 1dbb4f3b8..f0e1535dc 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -611,7 +611,7 @@ async def select_range( async with window.preserve_history(root=self): return self.prepared.replace_range(self, selected, current) -class DrawsBusMessages: +class ContainsBusMessages: """The fragments the loaded pages draw, and whether they include a bus message, scrolled into view or not.""" pages: deque[TranscriptPageView] @@ -620,11 +620,11 @@ class DrawsBusMessages: def fragments_in_range(self) -> tuple[TranscriptFragment, ...]: return tuple(fragment for page in self.pages for fragment in page.prepared.fragments_in_range) - def draws(self, message: BusMessage) -> bool: - return any(fragment.draws(message) for fragment in reversed(self.fragments_in_range)) + def contains_message(self, message: BusMessage) -> bool: + return any(page.prepared.contains_message(message) for page in reversed(self.pages)) -class LineHistory(DrawsBusMessages, SourceNotifications, OpensLinks, TranscriptSourcePreparation, ConversationEntry, VerticalGroup): +class LineHistory(ContainsBusMessages, SourceNotifications, OpensLinks, TranscriptSourcePreparation, ConversationEntry, VerticalGroup): """History drawn as lines, a bounded range of pages at a time. It owns paging in both directions, lookahead, trimming, the jump to the newest page and the reader's position. diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index e8070a13f..d72033832 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -34,6 +34,7 @@ from toad.comms_root import CoordinationAccess from toad.conversation_kind import ConversationKind +from toad.conversation_submission import PromptInputSubmission, ConversationSubmissions, SubmissionStart from toad.line_blocks import Body, Gap, LineBlock, MutedRole, Summary from toad.message_rows import MessageRow, WireMessageStyle from toad.transcript_preparation import ( @@ -62,7 +63,6 @@ from agent_comms.read_basis import NamedViewer from toad.comms_root import Observation - from toad.core.input_events import UserInputSubmitted from toad.widgets.comms_chat import CommsChatView from toad.widgets.transcript_lines import Lines from toad.widgets.wire_history import WireHistory @@ -323,6 +323,20 @@ class WireSource(BusQueueSource): tail_limit: int = 8 page_limit: int = 40 + def contains_message(self, view, root: Path, sent: Message) -> bool: + return self.comms.root == root and view.contains_user_message(sent) + + def begin_submission(self, owner: ConversationSubmissions, + submission: PromptInputSubmission) -> SubmissionStart: + view = owner.view + source = view.message_history.page_source + return owner.start_message( + submission, self.comms.root, self.conversation, + lambda: (view.is_attached and source is view.message_history.page_source + and self.conversation == view.conversation), + view._show_sent, + ) + @property def viewer(self) -> NamedViewer: return self.comms.views.display_viewer(self.project) @@ -465,15 +479,6 @@ def command_context(self, view): view.query_ancestor(SessionScope).id, ) - async def submit_input(self, view: CommsChatView, event: UserInputSubmitted) -> None: - conversation = self.loader.conversation - current = lambda: ( - view.is_attached and self is view.message_history.page_source and conversation == view.conversation - ) - await view.submit_comms_message( - self.loader.comms.root, conversation, event.body, current, partial(view._show_sent, body=event.body) - ) - def schedule_refresh(self, view: CommsChatView): history = view.message_history return history.schedule_source_work(view._refresh_source) diff --git a/src/toad/work_preparation.py b/src/toad/work_preparation.py index a6ca9be7f..beb64646f 100644 --- a/src/toad/work_preparation.py +++ b/src/toad/work_preparation.py @@ -271,6 +271,14 @@ def __init__(self, renderer: Renderer, *, max_entries: int = 256, async def run_thread[*Args, Value]( self, function: Callable[[*Args], Value], *args: *Args ) -> Value: + task = await self.admit_thread(function, *args) + await self.renderer.wait_for_work(task) + return task.result() + + async def admit_thread[*Args, Value]( + self, function: Callable[[*Args], Value], *args: *Args + ) -> asyncio.Task[Value]: + """Retain the actual accepted operation independently of its UI waiter.""" if self._closed: raise asyncio.CancelledError await self._threads.acquire() @@ -289,8 +297,7 @@ def finished(completed): self._threads.release() task.add_done_callback(finished, context=context) - await self.renderer.wait_for_work(task) - return task.result() + return task def discard_scope(self, scope: PreparationScope) -> None: scope.closed = True diff --git a/tests/app/test_coordination_attachment.py b/tests/app/test_coordination_attachment.py new file mode 100644 index 000000000..ed1b0009b --- /dev/null +++ b/tests/app/test_coordination_attachment.py @@ -0,0 +1,67 @@ +"""Real Core thread mutations keep their declared data and original source.""" +import os + +import pytest +from agent_comms.child_process import ProcessIdentity + +from support.agents import RecordedEvents, deliver +from support.coordination import carrying, registered_coordination_fact +from toad.core.events import CoreEvent + + +async def bind(ui, workspace, name): + workspace.thread(name, running=True) + agent = ui.detached_agent(name) + ui.attach_agent(agent) + await deliver(agent, carrying(registered_coordination_fact( + workspace.comms, name, worktree=workspace.project, + owner=ProcessIdentity.capture(os.getpid()), + ))) + return agent + + +async def test_project_change_publishes_the_real_checkout(workspace): + target = workspace.project / "changed-project" + target.mkdir() + async with workspace.app() as ui: + agent = await bind(ui, workspace, "source") + events = RecordedEvents() + with agent.events.subscribe(CoreEvent, events.receive): + result = await agent.update_project(str(target)) + assert result == str(target) + assert agent.project_root_path == target + assert workspace.comms.registry.require("source").project_path == target + event, = events + assert event.update.worktree.path == target + assert agent.observed_thread() == (str(workspace.comms.root), "source") + + +async def test_rename_keeps_canonical_name_distinct_from_display_title(workspace): + async with workspace.app() as ui: + agent = await bind(ui, workspace, "source") + await agent.set_session_name("A chosen display title") + root, name = agent.observed_thread() + assert root == str(workspace.comms.root) + current = workspace.comms.registry.require(name) + assert current.title.require("renaming must publish a title") == "A chosen display title" + assert agent.coordination_attachment.title == "A chosen display title" + assert current.incarnation == agent.coordination_attachment.tool_scope.recipient + + +async def test_captured_attachment_cannot_mutate_a_replacement_thread(workspace): + target = workspace.project / "refused-project" + target.mkdir() + async with workspace.app() as ui: + agent = await bind(ui, workspace, "original") + original = agent.coordination_attachment + workspace.thread("replacement", running=True) + await deliver(agent, carrying(registered_coordination_fact( + workspace.comms, "replacement", worktree=workspace.project, + owner=ProcessIdentity.capture(os.getpid()), + ))) + with pytest.raises(ValueError, match="owner identity changed"): + await original.update_project(agent, str(target)) + assert workspace.comms.registry.require("original").project_path == workspace.project + assert workspace.comms.registry.require("replacement").project_path == workspace.project + assert agent.project_root_path == workspace.project + assert agent.observed_thread() == (str(workspace.comms.root), "replacement") diff --git a/tests/app/test_transcript_recipient.py b/tests/app/test_transcript_recipient.py index 41b839ed7..2ad38b0ba 100644 --- a/tests/app/test_transcript_recipient.py +++ b/tests/app/test_transcript_recipient.py @@ -5,8 +5,10 @@ from dataclasses import replace from agent_comms.acp_extension import TranscriptSnapshotUpdate +from agent_comms.nominal.field_codec import AcpSessionId from agent_comms.threads import Thread from toad.acp.agent_controller import SessionBinding +from toad.acp.coordination_attachment import CoordinatedThread from support.agents import deliver from support.coordination import carrying, registered_coordination_fact @@ -24,13 +26,17 @@ async def test_unbound_tab_refuses_another_threads_snapshot(workspace): updates[name] = TranscriptSnapshotUpdate(read.read(), read.identity) expected = registered_coordination_fact(workspace.comms, "alpha") + attachment = CoordinatedThread(expected) identity = updates["alpha"].identity - assert SessionBinding("alpha").accepts_snapshot(identity, expected) - assert not SessionBinding("beta").accepts_snapshot(identity, expected) - assert not SessionBinding("alpha").accepts_snapshot( - replace(identity, root=str(workspace.project)), expected) - assert not SessionBinding("alpha").accepts_snapshot(replace( - identity, thread=replace(identity.thread, created_at=identity.thread.created_at - 0.5)), expected) + destination = AcpSessionId("original-acp-destination") + assert SessionBinding(destination).accepts_snapshot(identity, attachment, destination) + assert not SessionBinding("beta").accepts_snapshot(identity, attachment, destination) + assert not SessionBinding(None).accepts_snapshot(identity, attachment, destination) + assert not SessionBinding(destination).accepts_snapshot( + replace(identity, root=str(workspace.project)), attachment, destination) + assert not SessionBinding(destination).accepts_snapshot(replace( + identity, thread=replace(identity.thread, created_at=identity.thread.created_at - 0.5)), + attachment, destination) async with workspace.app(size=(110, 38)) as ui: alpha_tab = await ui.app.thread_navigation.open( @@ -44,12 +50,39 @@ async def test_unbound_tab_refuses_another_threads_snapshot(workspace): ui.attach_agent(beta) assert not beta.controller.session.accepts_notification("alpha") await deliver(beta, carrying(expected, updates["alpha"]), session_id="alpha") - assert beta.coordination is None + assert not beta.presentation.queue_supported # Publication also refuses foreign work without relying on its envelope. await beta.controller.publish_transcript_snapshot( - updates["alpha"], beta.controller.surface, beta.controller.session) + updates["alpha"], beta.controller.surface, beta.controller.session, + destination=AcpSessionId("alpha")) await ui.step() assert not ui.shows("Alpha-only history") await ui.select_tab(alpha_tab) await ui.until(lambda: ui.shows("Alpha-only history"), why="alpha's retained tab") + + +async def test_bound_acp_destination_accepts_its_different_canonical_history(workspace): + journal = write_journal( + workspace.project / "canonical-history.jsonl", + assistant_entry("CANONICAL_HISTORY_ON_ORIGINAL_DESTINATION", id="canonical-answer"), + ) + workspace.comms.registry.declare(Thread( + "canonical-history", frozenset(), Checkout(workspace.project), + session_file=RegistrySessionLocation.from_path(journal, workspace.comms.root), + )) + read = workspace.comms.transcripts.capture_page_read("canonical-history") + snapshot = TranscriptSnapshotUpdate(read.read(), read.identity) + expected = registered_coordination_fact(workspace.comms, "canonical-history") + destination = "original-acp-destination" + async with workspace.app(size=(110, 38)) as ui: + agent = ui.detached_agent(destination) + ui.attach_agent(agent) + await deliver(agent, carrying(expected, snapshot), session_id=destination) + await ui.until( + lambda: ui.shows("CANONICAL_HISTORY_ON_ORIGINAL_DESTINATION"), + why="canonical history painted only on its original ACP recipient", + ) + assert agent.controller.session.accepts_notification(destination) + assert snapshot.identity.requested_name != destination + assert agent.coordination_attachment.accepts_read(snapshot.identity) diff --git a/tests/guards/test_agent_controller.py b/tests/guards/test_agent_controller.py deleted file mode 100644 index 2ea8b5208..000000000 --- a/tests/guards/test_agent_controller.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Operational ownership cannot return to optional rich presentation.""" -import ast -from pathlib import Path - - -def test_operational_owner_deletions(): - source = Path(__file__).parents[2] / "src/toad" - agent = ast.parse((source / "acp/agent.py").read_text()) - for node in ast.walk(agent): - if isinstance(node, ast.Attribute): - assert node.attr not in {"_message_target", "_pending_permission_answers", "coordination_facts"} - conversation = ast.parse((source / "widgets/conversation.py").read_text()) - unmount = next(node for node in ast.walk(conversation) if isinstance(node, ast.AsyncFunctionDef) and node.name == "on_unmount") - assert not any(isinstance(node, ast.Attribute) and node.attr == "stop" for node in ast.walk(unmount) - if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute) and node.value.attr == "agent") - - -def test_human_capture_uses_the_queue_owner(): - source = Path(__file__).parents[2] / 'src/toad/acp/agent_controller.py' - module = ast.parse(source.read_text()) - submit = next(node for node in ast.walk(module) - if isinstance(node, ast.AsyncFunctionDef) and node.name == '_submit') - assert not any(isinstance(node, ast.Name) and node.id == 'HumanInputOrigin' - for node in ast.walk(submit)) - assert not any(isinstance(node, ast.Compare) - and isinstance(node.left, ast.Name) and node.left.id == 'queue_scope' - for node in ast.walk(submit)) - assert any(isinstance(node, ast.Attribute) and node.attr == 'capture_human_input' - for node in ast.walk(submit)) diff --git a/tests/model/test_agent_process_retirement.py b/tests/model/test_agent_process_retirement.py index fd7567459..86adb37f5 100644 --- a/tests/model/test_agent_process_retirement.py +++ b/tests/model/test_agent_process_retirement.py @@ -1,58 +1,95 @@ -"""An agent process ends through EOF, cancellation or an explicit stop with its child gone and its session retired.""" +"""Real ACP handshake/EOF/cancellation retire the original process group.""" import asyncio -import sys +from collections.abc import Callable -import psutil import pytest +from acp import text_block +from acp.exceptions import RequestError -from support.agents import command +from agent_comms.child_process import ProcessIdentity +from agent_comms.nominal.field_codec import FieldCodec +from support.agents import peer +from support.workspace import Workspace from toad.acp.agent import Agent -@pytest.fixture -def home(tmp_path, monkeypatch): - """The user's home is private to this test.""" - monkeypatch.setenv("HOME", str(tmp_path)) - return tmp_path - - -async def until(ui_condition, why): +async def until(condition: Callable[[], bool], why: str) -> None: try: async with asyncio.timeout(8): - while not ui_condition(): + while not condition(): await asyncio.sleep(.02) except TimeoutError: raise AssertionError(f"{why} not reached in 8s") from None -@pytest.mark.parametrize("how", ["cancel", "stop", "eof"]) -async def test_the_child_and_session_are_retired(home, how, app_agents): - pid_file = home / f"{how}.pid" - code = ( - "import subprocess,sys,time;from pathlib import Path;" - "p=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)']," - "stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);" - f"Path({str(pid_file)!r}).write_text(str(p.pid));" - + ("time.sleep(60)" if how != "eof" else "") - ) - agent = app_agents.agent( - Agent, command(sys.executable, "-c", code, name="Retirement", identity="retirement"), - ) - - async def session(): - await asyncio.Future() - - agent.session.run = session - await agent.start() - runner = agent.process.runner - await until(pid_file.exists, "the child's pid file") - descendant = psutil.Process(int(pid_file.read_text())) - if how == "cancel": - runner.cancel() - elif how == "stop": +@pytest.mark.parametrize("how", ("cancel", "stop", "eof")) +async def test_the_child_and_session_are_retired(workspace: Workspace, how: str) -> None: + agent = workspace.agents.agent(Agent, peer( + "retirement_eof" if how == "eof" else "retirement", + name="Retirement", identity="retirement", + )) + try: + await agent.start() + runner = agent.process.runner + assert runner is not None + await until(lambda: bool(workspace.received()), "the actual descendant receipt") + descendant = FieldCodec.decode(ProcessIdentity, workspace.received()[0]["descendant"]) + if how != "eof": + await until(lambda: agent.ready, "the actual SDK handshake") + if how == "cancel": + runner.cancel() + else: + await agent.stop() + await asyncio.gather(runner, return_exceptions=True) + await until(lambda: not descendant.alive(), "the original descendant birth to retire") + assert not agent.session.accepts_updates + assert not agent.session.responses + finally: await agent.stop() - await asyncio.gather(runner, return_exceptions=True) - await until(lambda: not descendant.is_running() or descendant.status() == psutil.STATUS_ZOMBIE, "the child to end") - assert agent.process.process is None and agent.session.task is None - assert not agent.session.responses and not agent.session.accepts_updates + + +@pytest.mark.parametrize("close_refused", (False, True)) +async def test_stopping_one_shared_session_keeps_the_other_usable( + workspace: Workspace, close_refused: bool, +) -> None: + definition = peer("shared_session", name="Shared sessions", identity="shared-sessions") + first_name = "refuse-close" if close_refused else "first" + first = workspace.agents.agent(Agent, definition, first_name) + second = workspace.agents.agent(Agent, definition, "second") + try: + await first.start() + await until(lambda: first.ready, "the first loaded session") + process = first.process + child = process.process + assert child is not None + await second.start(first.borrowed_processes()) + await until(lambda: second.ready, "the second loaded session") + assert second.process is process + if close_refused: + with pytest.raises(RequestError) as refused: + await first.stop() + assert refused.value.code == -32602 + assert refused.value.data["details"] == "Original session close refused" + else: + await first.stop() + assert not first.session.accepts_updates + assert not first.session.responses + assert second.ready and second.session.accepts_updates + assert child.identity.alive() + response = await second.process.rpc.prompt("second", [text_block("still-connected")]) + assert response is not None + assert response.stop_reason == "end_turn" + receipt = "close_refused" if close_refused else "closed" + assert {row[receipt] for row in workspace.received() if receipt in row} == {first_name} + assert any(row.get("prompt") == "still-connected" for row in workspace.received()) + finally: + try: + await first.stop() + except RequestError: + # Cleanup joins the same failed close; the original refusal was + # asserted above and must not prevent releasing the second owner. + if not close_refused: + raise + finally: + await second.stop() diff --git a/tests/model/test_pending_prompt_refused_after_session_change.py b/tests/model/test_pending_prompt_refused_after_session_change.py index 637da8a93..6b04e7cda 100644 --- a/tests/model/test_pending_prompt_refused_after_session_change.py +++ b/tests/model/test_pending_prompt_refused_after_session_change.py @@ -1,36 +1,36 @@ -"""A prompt still being built is refused, and never sent, when the session changes and returns meanwhile.""" +"""A real late reply cannot confirm a captured session that changed and returned.""" + import asyncio -import threading + +import pytest from agent_comms.acp_extension import QueuePromptRequest -from toad.acp import agent_controller + +from support.agents import peer, script +from toad.acp.queue_attachment import QueueActionRefused +from toad.acp.client_session import UnconfirmedPromptResult +from toad.session_scope import AgentAttached -async def test_pending_prompt_build_is_refused_when_the_session_changes_and_returns(app_agents, monkeypatch): - agent = app_agents.detached_agent('A', identity='prompt-session') - entered, release = threading.Event(), threading.Event() - real_build = agent_controller.build_prompt - def pending_build(project, text): - entered.set() - assert release.wait(5), 'Test failed to release resource preparation' - return real_build(project, text) - dispatched = [] - monkeypatch.setattr(agent_controller, 'build_prompt', pending_build) - async def recorded_prompt(agent, content, metadata, require): - require() - dispatched.append(content) - monkeypatch.setattr(agent.process, 'prompt', recorded_prompt) - pending = asyncio.create_task(agent.send_prompt('unchanged local input', request=QueuePromptRequest('unchanged local input', True))) - assert await asyncio.to_thread(entered.wait, 5) - assert agent.controller.prompt_in_flight == 1 - sending_now = asyncio.create_task(agent.controller.send_now()) - await asyncio.sleep(0) - agent.session_id = 'B' - agent.session_id = 'A' - release.set() - # The retired prompt's controller refuses it at the write and reports no stop reason. - assert await pending is None - assert await sending_now is False - assert dispatched == [] - assert agent.controller.prompt_in_flight == 0 - assert not agent.controller._deferred_submissions - await agent.stop() +async def test_pending_prompt_reply_is_unconfirmed_when_the_session_changes_and_returns(workspace): + async with workspace.app(binding=AgentAttached(peer("scripted"))) as ui: + await ui.until_agent_ready() + agent = ui.conversation.agent_binding + session_id, original = agent.session_id, agent.controller.session + body = script({"wait": "retired-prompt"}) + pending = asyncio.create_task(agent.send_prompt(body, request=QueuePromptRequest(body, True))) + await ui.until(lambda: workspace.prompts_received() == [body], + why="the original request held by the actual peer") + assert agent.controller.prompt_in_flight == 1 + # Exercise the binding owner's setter, not a patched prompt or ambient root. + agent.session_id = "replacement-session" + agent.session_id = session_id + assert agent.controller.session is not original + workspace.release("retired-prompt") + outcome = await asyncio.wait_for(pending, 10) + assert isinstance(outcome, UnconfirmedPromptResult) + assert outcome.request.binding is original + with pytest.raises(QueueActionRefused): + outcome.require_confirmation() + assert workspace.prompts_received() == [body] + assert agent.controller.prompt_in_flight == 0 + assert not agent.controller._deferred_submissions diff --git a/tests/native/test_native_journey.py b/tests/native/test_native_journey.py index 6a4e8f1d9..5df02ea8e 100644 --- a/tests/native/test_native_journey.py +++ b/tests/native/test_native_journey.py @@ -159,11 +159,15 @@ async def test_input_queued_behind_a_running_turn_is_consumed_and_saved(workspac async def test_input_queued_while_the_tab_is_hidden_is_shown_on_return(workspace, native_package): async with native_session(workspace) as native: ui, agent = native.ui, native.agent - owner_tab = ui.selected_tab + owner_tab, owner = ui.selected_tab, ui.app.selected_session await send_first_input_and_hold_its_reply(native) await ui.write_draft("unsent local draft") + original = ui.conversation process, queue = agent.process.process, agent.queue_attachment await ui.new_session() + assert original.submissions.retains_surface + await owner.presentation.evict(owner) + assert owner.presentation.widget is original, "Eviction discarded a running submission owner" assert agent.process.process is process and process.returncode is None await asyncio.wait_for(agent.send_prompt( "QUEUED_NATIVE_INPUT", request=QueuePromptRequest("QUEUED_NATIVE_INPUT", True)), 10) diff --git a/tests/native/test_native_project_resume.py b/tests/native/test_native_project_resume.py index 2bba09db1..abad5a34c 100644 --- a/tests/native/test_native_project_resume.py +++ b/tests/native/test_native_project_resume.py @@ -25,8 +25,8 @@ async def test_saved_project_and_later_changes_replace_the_launch_directory(work saved.mkdir() without_provider() comms = start_private_native_wire(workspace.roots.wire) - comms.registry.declare(Thread("saved", frozenset({"acp"}), Checkout(launched))) - comms.threads.set_project("saved", str(saved)) + original = comms.registry.declare(Thread("saved", frozenset({"acp"}), Checkout(launched))) + comms.threads.set_project(original.incarnation, str(saved)) async with attached_app(workspace, comms, "saved", size=(100, 35)) as (ui, agent): app, _pilot = ui.textual await ui.until_agent_ready() @@ -50,7 +50,7 @@ def shell_directory(): assert conversation.project_path == saved assert Path(conversation.prompt.current_directory.path) == saved - await asyncio.to_thread(comms.threads.set_project, "saved", str(launched)) + await asyncio.to_thread(comms.threads.set_project, original.incarnation, str(launched)) await ui.until(lambda: session.project_path == launched, seconds=10, why="the project change from the wire") await ui.until(lambda: conversation.project_path == launched, why="the conversation's project") assert Path(session.query_one(ProjectDirectoryTree).path) == launched @@ -58,7 +58,7 @@ def shell_directory(): async def test_a_project_change_reported_by_the_agent_moves_the_session(workspace, native_package): - from support.coordination import coordination_fact + from support.coordination import registered_coordination_fact moved = workspace.roots.attempt / "moved-project" moved.mkdir() @@ -70,7 +70,7 @@ async def test_a_project_change_reported_by_the_agent_moves_the_session(workspac await ui.until_agent_ready() session = app.selected_session agent.comms_consumer_class(agent, agent.session_id).dispatch_sync( - coordination_fact("saved", str(workspace.roots.wire), worktree=str(moved))) + registered_coordination_fact(comms, "saved", worktree=moved)) await ui.until(lambda: session.project_path == moved, why="the session's project to follow the agent's report") await ui.until(lambda: ui.conversation.project_path == moved, why="the conversation's project") await project_applied(ui, moved) diff --git a/tests/support/agents.py b/tests/support/agents.py index 6512cce11..85101ef93 100644 --- a/tests/support/agents.py +++ b/tests/support/agents.py @@ -84,7 +84,7 @@ async def deliver(agent, update: dict, *, session_id: str | None = None): def script(*steps: dict) -> str: - """A prompt for ``peer("scripted")``: steps of {"update": ...}, {"say": ...} or {"wait": name}.""" + """A prompt for ``peer("scripted")``: updates, replies, waits or an SDK error's error object.""" import json return json.dumps(list(steps)) diff --git a/tests/support/core_adapters/human_input_observer.py b/tests/support/core_adapters/human_input_observer.py index 54d133c4c..d523cbb18 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -93,7 +93,7 @@ async def submit(self, text, *, image=None, while_running=None, assert original.source_text == prompt with self.comms.bus.log.locked(): assert origin.root_id == self.comms.bus.log.read_metadata_unlocked().wire_root_id - assert Path(agent.coordination.wire_root) == self.comms.root + assert agent.observed_thread() == (str(self.comms.root), self.subject.name) receipt = {'original': FieldCodec.encode(original), 'wire_sequence_before': wire_sequence} self.receipts.append(receipt) diff --git a/tests/support/peers/base.py b/tests/support/peers/base.py index 7910b0c57..99e94e800 100644 --- a/tests/support/peers/base.py +++ b/tests/support/peers/base.py @@ -7,6 +7,7 @@ import asyncio import json from pathlib import Path +from typing import ClassVar from acp import Agent, Client, run_agent from acp.schema import ( @@ -17,6 +18,7 @@ class Peer(Agent): + use_unstable_protocol: ClassVar[bool] = False session_id = "peer-session" def on_connect(self, conn: Client) -> None: @@ -31,7 +33,7 @@ async def new_session(self, cwd: str, **kwargs): async def prompt(self, session_id, prompt, **kwargs): text = prompt[0].text - self.record(prompt=text) + self.record(prompt=text, metadata=kwargs) await self.answer(session_id, text) return PromptResponse(stopReason="end_turn") @@ -48,4 +50,4 @@ def record(self, **fact) -> None: @classmethod def main(cls) -> None: - asyncio.run(run_agent(cls())) + asyncio.run(run_agent(cls(), use_unstable_protocol=cls.use_unstable_protocol)) diff --git a/tests/support/peers/resumable_session_response.py b/tests/support/peers/resumable_session_response.py new file mode 100644 index 000000000..349079978 --- /dev/null +++ b/tests/support/peers/resumable_session_response.py @@ -0,0 +1,17 @@ +"""The response peer's load capability, through the real ACP SDK connection.""" +from acp.schema import AgentCapabilities, LoadSessionResponse +from session_response import SessionResponsePeer + + +class ResumableSessionResponsePeer(SessionResponsePeer): + async def initialize(self, protocol_version: int, **kwargs): + response = await super().initialize(protocol_version, **kwargs) + return response.model_copy(update={"agent_capabilities": AgentCapabilities(loadSession=True)}) + + async def load_session(self, cwd: str, session_id: str, **kwargs): + response = await self.new_session(cwd, **kwargs) + return LoadSessionResponse(_meta=response.field_meta, configOptions=response.config_options) + + +if __name__ == "__main__": + ResumableSessionResponsePeer.main() diff --git a/tests/support/peers/retirement.py b/tests/support/peers/retirement.py new file mode 100644 index 000000000..a2cce24e0 --- /dev/null +++ b/tests/support/peers/retirement.py @@ -0,0 +1,28 @@ +"""A real SDK peer with a foreign descendant, for process-group retirement.""" +import asyncio +from pathlib import Path +import sys + +from acp.schema import NewSessionResponse +from agent_comms.child_process import ProcessIdentity +from agent_comms.nominal.field_codec import FieldCodec +from base import Peer + + +class RetirementPeer(Peer): + async def new_session(self, cwd: str, **kwargs: object) -> NewSessionResponse: + response = await super().new_session(cwd, **kwargs) + child = await asyncio.create_subprocess_exec( + sys.executable, str(Path(__file__).with_name("retirement_child.py")), + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + self.record(descendant=FieldCodec.encode(ProcessIdentity.capture(child.pid))) + return self.after_child(response) + + def after_child(self, response: NewSessionResponse) -> NewSessionResponse: + return response + + +if __name__ == "__main__": + RetirementPeer.main() diff --git a/tests/support/peers/retirement_child.py b/tests/support/peers/retirement_child.py new file mode 100644 index 000000000..f0ac8b55f --- /dev/null +++ b/tests/support/peers/retirement_child.py @@ -0,0 +1,5 @@ +"""A foreign descendant that only the process-group owner can retire.""" +import time + +if __name__ == "__main__": + time.sleep(60) diff --git a/tests/support/peers/retirement_eof.py b/tests/support/peers/retirement_eof.py new file mode 100644 index 000000000..d474c73c6 --- /dev/null +++ b/tests/support/peers/retirement_eof.py @@ -0,0 +1,14 @@ +"""Exit before answering the real handshake; the foreign descendant remains.""" +import os + +from acp.schema import NewSessionResponse +from retirement import RetirementPeer + + +class ExitingRetirementPeer(RetirementPeer): + def after_child(self, response: NewSessionResponse) -> NewSessionResponse: + os._exit(0) + + +if __name__ == "__main__": + ExitingRetirementPeer.main() diff --git a/tests/support/peers/scripted.py b/tests/support/peers/scripted.py index 8ef2338d6..0a7c6ec41 100644 --- a/tests/support/peers/scripted.py +++ b/tests/support/peers/scripted.py @@ -3,6 +3,7 @@ - ``{"update": {...}}`` sends that ``session/update`` as written, valid or not. - ``{"say": "text"}`` sends an agent message chunk. - ``{"wait": "name"}`` waits until the test releases ``name`` (``Workspace.release``). +- ``{"error": RequestError.to_error_obj()}`` raises that SDK error through the real request connection. - ``{"ask": "title", "options": ["Allow", "Reject"]}`` asks the user for permission and records the answer as ``{"answered":