From e82a85a28fcd4219d93f7f43ffda9907b865b9ab Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:17:49 -0400 Subject: [PATCH 1/6] Keep reconnect failure with its session and complete application cleanup --- src/toad/acp/agent_session.py | 53 +++++++++++++++++--------------- src/toad/app.py | 26 ++++++++-------- src/toad/widgets/conversation.py | 2 ++ 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index 7128cf6af..a8cc2f8cc 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -1,10 +1,13 @@ -"""ACP session handshake, durable metadata and reconnection custody.""" +"""ACP session handshake, durable metadata and reconnection.""" from __future__ import annotations from agent_comms.declared_family import DeclaredFamily from agent_comms.child_process import join_retirement import asyncio import os +from pathlib import Path from dataclasses import dataclass, replace +from agent_comms.acp_ingress import AcpIngress +from agent_comms.route_selection import RouteSelection import toad from toad import constants, jsonrpc from toad.core import events as core_events @@ -72,6 +75,9 @@ class RefusedSessionDisposition(FailedSessionDisposition): class AgentSession: """Own readiness and durable session effects; binding identity stays on the controller.""" + + reconnect_timeout_seconds = 30 + def __init__(self, agent, pk): self.agent = agent self.task = None @@ -118,11 +124,15 @@ def start(self): self.task = asyncio.create_task(self.run()) self.task.add_done_callback(self.session_finished) - async def retire(self): + def begin_retirement(self): self.close() if self.retirement is None: self.retirement = asyncio.create_task(self._retire()) - await join_retirement(self.retirement) + self.retirement.add_done_callback(self.retirement_finished) + return self.retirement + + async def retire(self): + await join_retirement(self.begin_retirement()) async def _retire(self): pending = tuple(task for task in (self.task, *self.responses) @@ -141,8 +151,6 @@ def session_finished(self, task): error = task.exception() if error is not None and self.accepts_updates: self.startup_failed(f"{type(error).__name__}: {error}") - self.retirement = asyncio.create_task(self._retire()) - self.retirement.add_done_callback(self.retirement_finished) def retirement_finished(self, task): if task.cancelled(): @@ -161,6 +169,7 @@ def record_failure( self.failed() self.disposition = state(event) self.agent.events.publish(event) + self.begin_retirement() def session_failed(self, failure): self.record_failure( @@ -189,7 +198,7 @@ def supports_load(self): return self.capabilities.load_session def starting(self): - """Start admission clears readiness before any asynchronous work.""" + """Starting clears readiness before any asynchronous work.""" self.connected = False self.settled.clear() self.agent.controller.reset_configuration() @@ -277,31 +286,27 @@ async def reconnect(self, admission: SessionLoadAdmission | None = None) -> None """Reattach the existing view after login or an explicit owner start.""" if self.reconnecting: return - if self.agent.controller.session.bound and not self.supports_load: - raise ValueError("This agent cannot resume its session.") - from pathlib import Path - from agent_comms.acp_ingress import AcpIngress - from agent_comms.route_selection import RouteSelection - requested_cwd = str(self.agent.project_root_path.resolve()) self.reconnecting = True try: - try: - selection = await asyncio.to_thread( - RouteSelection.for_child, os.environ.copy(), requested_cwd) - await AcpIngress(selection, Path(requested_cwd), - self.agent.process.attached_root(self.agent)).preflight() - except Exception as error: - raise ValueError( - f"Reconnect not attempted: maintenance admission denied: {error}" - ) from error + selection = await asyncio.to_thread( + RouteSelection.for_child, os.environ.copy(), requested_cwd) + await AcpIngress(selection, Path(requested_cwd), + self.agent.process.attached_root(self.agent)).preflight() await self.agent.stop() self.load_admission = admission or EnsuringSessionLoadAdmission() self.settled.clear() await self.agent.start((self.agent.process,)) - await asyncio.wait_for(self.settled.wait(), timeout=30) - if not self.connected: - raise ValueError("The agent could not reconnect.") + try: + await asyncio.wait_for( + self.settled.wait(), timeout=self.reconnect_timeout_seconds) + except TimeoutError: + self.startup_failed( + f"Reconnect timed out after {self.reconnect_timeout_seconds} seconds " + "waiting for the session handshake to settle." + ) + except (OSError, ValueError, RuntimeError, jsonrpc.APIError) as error: + self.startup_failed(f"Reconnect failed: {type(error).__name__}: {error}") finally: self.reconnecting = False diff --git a/src/toad/app.py b/src/toad/app.py index b384b98df..05e414e6c 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -56,6 +56,7 @@ from toad.application_actions import ApplicationAction, ApplicationCommands from toad.widget_actions import DeclaredWidgetActions from toad.application_lifetime import ApplicationLifetime +from toad.resource_release import AsyncResourceRelease if TYPE_CHECKING: from toad.db import DB @@ -319,19 +320,18 @@ def __init__( async def _close_all(self) -> None: - await self.thread_navigation.close() - await self.thread_actions.close() - await self.coordination_access.stop_observing() - await super()._close_all() - # Native clients and the preparation owner have joined actual I/O. - # A failed retirement keeps its route resource instead of certifying exit. - await self.coordination_access.close() - - async def on_unmount(self) -> None: - self.colours.stop() - self.terminal_attention.close() - await self.navigation_reader.aclose() - await self.render_processes.aclose() + # View release can fail. The application still owns its observation + # pipe and process pools; an Unmount event may never follow that error. + async with AsyncResourceRelease() as release: + release.push_async_callback(self.render_processes.aclose) + release.push_async_callback(self.navigation_reader.aclose) + release.callback(self.terminal_attention.close) + release.callback(self.colours.stop) + release.push_async_callback(self.coordination_access.close) + release.push_async_callback(super()._close_all) + release.push_async_callback(self.coordination_access.stop_observing) + release.push_async_callback(self.thread_actions.close) + release.push_async_callback(self.thread_navigation.close) if self._background_render_tasks: await asyncio.gather(*tuple(self._background_render_tasks), return_exceptions=True) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index c6acfae9f..233709061 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1210,6 +1210,8 @@ async def action_provider_login(self) -> None: await self.prune_window(0, 0) self.output.boundary() await agent.session.reconnect() + if not agent.session.ready: + return else: await agent.session.authenticate(method_id) self.flash( From 9dbe41fe2d7b84a24bbbf7d1438789c887587af7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:23:36 -0400 Subject: [PATCH 2/6] Rename the complete Toad session-load family to SessionLoadPlan --- src/toad/acp/agent_session.py | 22 +++++++++++----------- src/toad/navigation_preparation.py | 15 +++++++-------- src/toad/owner_preparation.py | 10 +++++----- src/toad/session_entry.py | 14 ++++++-------- src/toad/session_presentation.py | 21 ++++++++++----------- 5 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index a8cc2f8cc..d4ed8c050 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -18,7 +18,7 @@ from toad.db import DB, SessionMeta from agent_comms.acp_failure import ACPFailure from agent_comms.input_attempt import NotSentInput -from agent_comms.session_load import EnsuringSessionLoadAdmission, ExistingSessionLoadAdmission, FailedSessionLoadAdmission, SessionLoadAdmission +from agent_comms.session_load import EnsuringSessionLoadPlan, ExistingSessionLoadPlan, FailedSessionLoadPlan, SessionLoadPlan PROTOCOL_VERSION = 1 @@ -88,7 +88,7 @@ def __init__(self, agent, pk): self.chosen_name = None self.connected = False self.reconnecting = False - self.load_admission: SessionLoadAdmission = EnsuringSessionLoadAdmission() + self.load_plan: SessionLoadPlan = EnsuringSessionLoadPlan() self.settled = asyncio.Event() @property @@ -278,11 +278,11 @@ async def observe_owner(self, presentation) -> None: if self.agent.coordination is None: return binding = presentation.binding - if not self.load_admission.superseded_by(binding): + if not self.load_plan.superseded_by(binding): return - await self.reconnect(ExistingSessionLoadAdmission(binding)) + await self.reconnect(ExistingSessionLoadPlan(binding)) - async def reconnect(self, admission: SessionLoadAdmission | None = None) -> None: + async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: """Reattach the existing view after login or an explicit owner start.""" if self.reconnecting: return @@ -294,7 +294,7 @@ async def reconnect(self, admission: SessionLoadAdmission | None = None) -> None await AcpIngress(selection, Path(requested_cwd), self.agent.process.attached_root(self.agent)).preflight() await self.agent.stop() - self.load_admission = admission or EnsuringSessionLoadAdmission() + self.load_plan = plan or EnsuringSessionLoadPlan() self.settled.clear() await self.agent.start((self.agent.process,)) try: @@ -418,17 +418,17 @@ async def load(self) -> None: if agent_data := session.meta_json.agent_data: self.agent.definition = agent_data - admission, self.load_admission = self.load_admission, EnsuringSessionLoadAdmission() + plan, self.load_plan = self.load_plan, EnsuringSessionLoadPlan() with self.agent.request(): session_load_response = api.session_load(cwd, [], request_session_id, - admission.metadata()) + plan.metadata()) try: response = await session_load_response.wait() except jsonrpc.APIError as error: authority.require() if self.agent.queue_attachment.is_current_request(queue_token): - if failed_command := FailedSessionLoadAdmission.from_failure(error.data): - self.load_admission = failed_command + if failed_command := FailedSessionLoadPlan.from_failure(error.data): + self.load_plan = failed_command raise authority.require() if ( @@ -501,7 +501,7 @@ async def set_name(self, name: str) -> None: def publish_configuration(self, response): - self.load_admission = SessionLoadAdmission.at_response(response.field_meta) + self.load_plan = SessionLoadPlan.at_response(response.field_meta) if (modes := response.modes) is not None: self.agent.controller.publish_modes(modes) self.agent.configuration.receive(response.config_options) diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index d8a0ca5d0..948a398b2 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -14,7 +14,8 @@ from agent_comms.registry_document import RegistrySnapshot from agent_comms.declared_family import DeclaredFamily from agent_comms.thread_execution import ConversationPreparation -from agent_comms.session_load import ExistingSessionLoadAdmission +from agent_comms.session_load import ExistingSessionLoadPlan +from toad.owner_preparation import ExistingOwnerPlan from toad.session_tracker import CommsViewKey from toad.conversation_kind import ConversationKind @@ -99,10 +100,10 @@ def bind(self, screen) -> None: @dataclass(frozen=True) class NativeThreadNavigation(ThreadNavigation): - admission: ExistingSessionLoadAdmission + plan: ExistingSessionLoadPlan def bind(self, screen) -> None: - screen.presentation.sources.load_admission = self.admission + screen.presentation.sources.load_plan = self.plan @dataclass(frozen=True) @@ -157,13 +158,11 @@ def external(self) -> ThreadNavigation: return ExternalThreadNavigation(self.root, self.thread, self.project) def native(self) -> ThreadNavigation: - from toad.owner_preparation import ExistingOwnerAdmission - binding = self.thread.execution.owner_binding(self.snapshot, self.thread) - admission = ExistingOwnerAdmission.from_binding(binding) - if admission is None: + plan = ExistingOwnerPlan.from_binding(binding) + if plan is None: return ThreadNavigation(self.root, self.thread, self.project) - return NativeThreadNavigation(self.root, self.thread, self.project, admission) + return NativeThreadNavigation(self.root, self.thread, self.project, plan) class ThreadOpening: diff --git a/src/toad/owner_preparation.py b/src/toad/owner_preparation.py index 7136f055a..12b44a68f 100644 --- a/src/toad/owner_preparation.py +++ b/src/toad/owner_preparation.py @@ -4,13 +4,13 @@ from agent_comms.comms import Comms from agent_comms.thread_presentation import ThreadPresentation, ThreadOwnerBinding, LiveThreadOwnerBinding -from agent_comms.session_load import ExistingSessionLoadAdmission +from agent_comms.session_load import ExistingSessionLoadPlan from agent_comms.mro_dispatch import handles from toad.core.projection import MroProjection -class ExistingOwnerAdmission(MroProjection): - """Project Core's binding into read-only attachment admission, without starting an owner.""" +class ExistingOwnerPlan(MroProjection): + """Project Core's binding into a read-only attachment plan without starting an owner.""" @handles(ThreadOwnerBinding) def unavailable(self, binding): @@ -18,10 +18,10 @@ def unavailable(self, binding): @handles(LiveThreadOwnerBinding) def live(self, binding): - return ExistingSessionLoadAdmission(binding) + return ExistingSessionLoadPlan(binding) @classmethod - def from_binding(cls, binding: ThreadOwnerBinding) -> ExistingSessionLoadAdmission | None: + def from_binding(cls, binding: ThreadOwnerBinding) -> ExistingSessionLoadPlan | None: return cls().dispatch_sync(binding) diff --git a/src/toad/session_entry.py b/src/toad/session_entry.py index 6dbc7a351..6ccc63a61 100644 --- a/src/toad/session_entry.py +++ b/src/toad/session_entry.py @@ -13,6 +13,8 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.comms import wire +from agent_comms.route_selection import RouteSelection +from toad.owner_preparation import ExistingOwnerPlan from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.unread import UnobservedUnread, UnreadPresentation @@ -109,8 +111,6 @@ def source(self, sessions: SessionNavigation) -> MainScreen | None: return sessions.app.workspace_sessions.views.get(self.mode) async def reconnect(self, sessions, selected, snapshot, targets): - from agent_comms.route_selection import RouteSelection - if sessions.get(self.mode) is not self or RouteSelection.capture() != selected: return source = self.source(sessions) @@ -123,16 +123,14 @@ async def reconnect(self, sessions, selected, snapshot, targets): if original is None: return thread = snapshot.require(snapshot.canonical_name(original.name)) - from toad.owner_preparation import ExistingOwnerAdmission - binding = thread.execution.owner_binding(snapshot, thread) - admission = ExistingOwnerAdmission.from_binding(binding) - if admission is None: + plan = ExistingOwnerPlan.from_binding(binding) + if plan is None: return if agent := source.presentation.operational_agent: - await agent.session.reconnect(admission) + await agent.session.reconnect(plan) else: - source.presentation.sources.attach_started(source, admission) + source.presentation.sources.attach_started(source, plan) def accepts_launch(self, sessions: SessionNavigation, agent_identity: str, session_id: str) -> bool: source = self.source(sessions) diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index 0e0e2cabb..f7afee8e4 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -1,4 +1,4 @@ -"""Session-owned mounted presentation, with one bounded workspace admission owner.""" +"""Session-owned mounted presentation, with one bounded workspace owner.""" import asyncio from abc import ABC, abstractmethod @@ -13,7 +13,8 @@ from textual.widgets.text_area import TextAreaState from agent_comms.acp_extension import CoordinationChangedUpdate -from toad.owner_preparation import ExistingOwnerAdmission +from agent_comms.session_load import EnsuringSessionLoadPlan +from toad.owner_preparation import ExistingOwnerPlan from toad.input_history import InputHistories from toad.agent_surface import AttachedSurfaceBinding @@ -101,11 +102,9 @@ class OperationalSessionSources: """Own existing operational resources while the view is detached.""" def __init__(self) -> None: - from agent_comms.session_load import EnsuringSessionLoadAdmission - self.agent = None self.shell = None - self.load_admission = EnsuringSessionLoadAdmission() + self.load_plan = EnsuringSessionLoadPlan() def status_row(self, conversation: Conversation): return self.activity_row(conversation).with_delivery(conversation.input_delivery) @@ -146,13 +145,13 @@ def input_ready(self, conversation: Conversation) -> bool: if conversation.transcript.readable: presentation = conversation.thread_presentation return (presentation is not None - and ExistingOwnerAdmission.from_binding(presentation.binding) is not None) + and ExistingOwnerPlan.from_binding(presentation.binding) is not None) return True def unavailable_reason(self, conversation: Conversation) -> str: presentation = conversation.thread_presentation if (conversation.transcript.readable and presentation is not None - and ExistingOwnerAdmission.from_binding(presentation.binding) is None): + and ExistingOwnerPlan.from_binding(presentation.binding) is None): return "Thread is stopped. Choose Start thread from its menu; your draft is kept." agent = conversation.agent if agent is not None and agent.configuration.model.requires_selection: @@ -160,7 +159,7 @@ def unavailable_reason(self, conversation: Conversation) -> str: return "Agent is not ready. Please wait while the agent connects…" def start_agent(self, conversation: Conversation) -> None: - """The operational source owns connection creation and load admission.""" + """The operational source owns connection creation and the load plan.""" from toad.acp.agent import Agent from toad.core import events as core_events @@ -169,16 +168,16 @@ def start_agent(self, conversation: Conversation) -> None: assert conversation._agent_data is not None agent = Agent(conversation.project_path, conversation._agent_data, conversation._agent_session_id, conversation._session_pk) - agent.session.load_admission = self.load_admission + agent.session.load_plan = self.load_plan conversation.agent = agent conversation._native_agent_started_here = True conversation.bind_agent(agent) agent.controller.start_operation(agent.start(conversation.app.session_navigation.processes)) conversation.publish_core(core_events.SessionSubtitleChanged(conversation.agent_title)) - def attach_started(self, screen: "MainScreen", admission) -> None: + def attach_started(self, screen: "MainScreen", plan) -> None: """An explicit Start action may attach the same retained view to its new owner.""" - self.load_admission = admission + self.load_plan = plan screen._agent = screen._agent or screen.app.agent_data conversation = screen.presentation.widget if conversation is not None: From c4029f08e0a0f4fb036963160decf1f9b3ecb3b9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:24:35 -0400 Subject: [PATCH 3/6] Limit reconnect handling to named route and transport failures --- src/toad/acp/agent_session.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index d4ed8c050..bc24420d6 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -7,7 +7,8 @@ from pathlib import Path from dataclasses import dataclass, replace from agent_comms.acp_ingress import AcpIngress -from agent_comms.route_selection import RouteSelection +from agent_comms.errors import RelationViolationError +from agent_comms.route_selection import RouteChanged, RouteSelection import toad from toad import constants, jsonrpc from toad.core import events as core_events @@ -289,10 +290,14 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: requested_cwd = str(self.agent.project_root_path.resolve()) self.reconnecting = True try: - selection = await asyncio.to_thread( - RouteSelection.for_child, os.environ.copy(), requested_cwd) - await AcpIngress(selection, Path(requested_cwd), - self.agent.process.attached_root(self.agent)).preflight() + try: + selection = await asyncio.to_thread( + RouteSelection.for_child, os.environ.copy(), requested_cwd) + await AcpIngress(selection, Path(requested_cwd), + self.agent.process.attached_root(self.agent)).preflight() + except (RouteChanged, RelationViolationError) as error: + self.startup_failed(f"Reconnect blocked before restart: {error}") + return await self.agent.stop() self.load_plan = plan or EnsuringSessionLoadPlan() self.settled.clear() @@ -305,7 +310,7 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: f"Reconnect timed out after {self.reconnect_timeout_seconds} seconds " "waiting for the session handshake to settle." ) - except (OSError, ValueError, RuntimeError, jsonrpc.APIError) as error: + except (OSError, jsonrpc.APIError) as error: self.startup_failed(f"Reconnect failed: {type(error).__name__}: {error}") finally: self.reconnecting = False From 091a7f047201676987399ec5c37b167a66749a8c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:42:42 -0400 Subject: [PATCH 4/6] Render disconnection from its session instead of an opening exception --- src/toad/screens/main.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index cd41ea4c7..1a407f10f 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -128,9 +128,14 @@ def close(self, screen: "MainScreen") -> None: """Terminal content has no unfinished waiters to release.""" async def attached(self, screen: "MainScreen") -> None: - """Admitted content already presents the live session.""" + """Refresh connection feedback from the session that just became ready.""" + self.render(screen) def label(self, screen: "MainScreen", text: str, *, visible: bool) -> Static: + agent = screen.presentation.operational_agent + if agent is not None and (failure := agent.session.disposition.current_failure()) is not None: + text = f"Thread disconnected: {failure.message}: {failure.details}" + visible = True label = screen.query_one_optional("#session-opening", Static) if label is None: label = Static(id="session-opening", markup=False) @@ -452,10 +457,10 @@ async def on_session_attached(self, message: CoreEventMessage) -> None: @handles(core_events.AgentFail) def on_session_failure(self, message: CoreEventMessage) -> None: agent = self.presentation.operational_agent - if agent is not None and (failure := agent.session.disposition.current_failure()) is not None: - # Prompt/tool failures do not fail the session. Its disposition is - # the original handshake/connection authority, not this view. - self.opening_failed(RuntimeError(f"{failure.message}: {failure.details}")) + if agent is not None and agent.session.disposition.current_failure() is not None: + # A disconnected session is not a failed history-opening operation. + # Keep its reason on the session; every content view reads it there. + self.content.render(self) _last_dm_target: str | None = None From 943e71ba8a3f269c3fd200c258fb9429c3cc5ae4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:53:20 -0400 Subject: [PATCH 5/6] Preserve stopped P68 verification for Claude handoff --- src/toad/screens/main.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 1a407f10f..1e9abc891 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -112,7 +112,16 @@ class ScreenContent(DeclaredFamily, affix="Content"): """Content state owns its label, preparation admission and completion.""" @abstractmethod - def render(self, screen: "MainScreen") -> Static: ... + def line(self) -> str: + """The opening line this content state shows; empty hides it.""" + + def render(self, screen: "MainScreen") -> Static: + """The one decision of the opening line: a session failure outranks the state's line.""" + agent = screen.presentation.operational_agent + if agent is not None and (failure := agent.session.disposition.current_failure()) is not None: + return self.label(screen, f"Thread disconnected: {failure.message}: {failure.details}", visible=True) + line = self.line() + return self.label(screen, line, visible=bool(line)) @abstractmethod async def wait(self, screen: "MainScreen") -> None: ... @@ -132,10 +141,6 @@ async def attached(self, screen: "MainScreen") -> None: self.render(screen) def label(self, screen: "MainScreen", text: str, *, visible: bool) -> Static: - agent = screen.presentation.operational_agent - if agent is not None and (failure := agent.session.disposition.current_failure()) is not None: - text = f"Thread disconnected: {failure.message}: {failure.details}" - visible = True label = screen.query_one_optional("#session-opening", Static) if label is None: label = Static(id="session-opening", markup=False) @@ -148,10 +153,10 @@ class OpeningContent(ScreenContent): def __init__(self) -> None: self.completion = asyncio.get_running_loop().create_future() - def render(self, screen: "MainScreen") -> Static: + def line(self) -> str: from toad.widgets.conversation import ThreadLoading - return self.label(screen, ThreadLoading.LABEL, visible=True) + return ThreadLoading.LABEL async def wait(self, screen: "MainScreen") -> None: await asyncio.shield(self.completion) @@ -177,8 +182,8 @@ class ReadyContent(ScreenContent): async def prepare(self, screen: "MainScreen") -> None: await screen._prepare_content() - def render(self, screen: "MainScreen") -> Static: - return self.label(screen, "", visible=False) + def line(self) -> str: + return "" async def wait(self, screen: "MainScreen") -> None: """The content has already been admitted.""" @@ -188,8 +193,8 @@ class FailedContent(ScreenContent): def __init__(self, error: BaseException) -> None: self.error = error - def render(self, screen: "MainScreen") -> Static: - return self.label(screen, f"Thread unavailable: {self.error}", visible=True) + def line(self) -> str: + return f"Thread unavailable: {self.error}" async def wait(self, screen: "MainScreen") -> None: raise self.error From a31e33a0dd3cd31f59e013eb589ce4ee661db04a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 17:59:13 -0400 Subject: [PATCH 6/6] Let the session decide whether a terminal login finished AgentSession.reconnect_after_login() restarts the session and raises LoginNotReady with the session's own recorded failure when the restart did not become ready. Conversation calls it instead of reconnect() and no longer checks readiness itself; its existing ValueError handler reports the failure and the success flash runs only for a ready session. Co-Authored-By: Claude Opus 5.5 --- src/toad/acp/agent_session.py | 12 ++++++++++++ src/toad/widgets/conversation.py | 4 +--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index bc24420d6..1f0c3c3c6 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -74,6 +74,10 @@ class RefusedSessionDisposition(FailedSessionDisposition): held_by_agent = False +class LoginNotReady(ValueError): + """A terminal login finished, but the session it restarted did not become ready.""" + + class AgentSession: """Own readiness and durable session effects; binding identity stays on the controller.""" @@ -316,6 +320,14 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: self.reconnecting = False + async def reconnect_after_login(self) -> None: + """Only a session that is ready after its restart counts as a finished login.""" + await self.reconnect() + if not self.ready: + failure = self.disposition.current_failure() + raise LoginNotReady("Session did not become ready after login" if failure is None + else f"{failure.message}: {failure.details}") + async def authenticate(self, method_id: str) -> None: authority = ClientSessionRequest(self.agent, self.agent.session_id) authority.require() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 233709061..7c1309ef7 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1209,9 +1209,7 @@ async def action_provider_login(self) -> None: ) is None: await self.prune_window(0, 0) self.output.boundary() - await agent.session.reconnect() - if not agent.session.ready: - return + await agent.session.reconnect_after_login() else: await agent.session.authenticate(method_id) self.flash(