diff --git a/src/toad/about.py b/src/toad/about.py index 33f418b1c..2dde4ae2f 100644 --- a/src/toad/about.py +++ b/src/toad/about.py @@ -6,7 +6,6 @@ import platform from string import Template -from pathlib import Path from toad.preferences import SavedSettings from toad.terminal_attention import TerminalAttention from toad import paths @@ -78,7 +77,7 @@ ) -def render(root: Path) -> str: +def render(core: str) -> str: """Render about markdown. Returns: @@ -95,7 +94,7 @@ def render(root: Path) -> str: "COLORTERM": os.environ.get("COLORTERM", ""), "COMMS_AGENT_ARGS": shlex.join(InheritedArguments().configured().argv), "COMMS_AGENT_BIN": ConfiguredBinary().configured(), - "COMMS_ROOT": str(root), + "COMMS_ROOT": core, "CONFIG": config, "DATA_PATH": paths.get_data(), "LOG_PATH": paths.get_log(), diff --git a/src/toad/app.py b/src/toad/app.py index c5194777e..02f2fceb8 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -1,7 +1,7 @@ from toad.core import events as core_events from toad.workspace_sessions import WorkspaceSessionShutdown from inspect import isabstract -from toad.comms_root import CoordinationAccess +from toad.comms_root import CoreConnection from toad.thread_actions import ThreadActions from toad.widgets.comms_transfer import Transfers @@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Callable, Never, TypeVar, cast from agent_comms.acp_extension import TranscriptChangedUpdate -from agent_comms.launch_root import LaunchRoot from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repo import Checkout from agent_comms.root_runtime import RootRuntime @@ -151,7 +150,7 @@ def __init__( self, agent_binding: AgentBinding, project_dir: Checkout, - launch_root: LaunchRoot, + connection: CoreConnection, mode: str | None = None, agent_session_id: str | None = None, ) -> None: @@ -160,7 +159,7 @@ def __init__( Args: agent_binding: The agent selected at startup. project_dir: Project directory. - launch_root: Coordination root selected by the launch owner. + connection: Core transport selected by the launch owner. mode: Initial mode. """ from toad.render_processes import RenderProcessPool @@ -191,7 +190,7 @@ def __init__( db=DB(), ) self._session_tracker = SessionTracker(self.tab_order) - self.coordination_access = CoordinationAccess(self, launch_root) + self.coordination_access = connection.open_access(self) from toad.file_kind import FileKind FileKind.prepare_terminal() diff --git a/src/toad/cli.py b/src/toad/cli.py index 6452ea71c..0924a573d 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -6,9 +6,9 @@ import click from agent_comms.nominal.field_codec import FieldCodec -from agent_comms.launch_root import LaunchRoot from agent_comms.repo import Checkout from toad.session_scope import AgentAttached, AgentBinding, NoAgent +from toad.comms_root import CoreConnection if TYPE_CHECKING: from toad.app import ToadApp @@ -78,7 +78,7 @@ def parse_args(self, ctx, args): from agent_comms.active_route import ActiveRoute # Select once at the process boundary; apps receive this captured value. - ctx.obj = LaunchRoot.startup(args, ActiveRoute.published_launch) + ctx.obj = CoreConnection.startup(args, ActiveRoute.published_launch) # Check if first arg is a known subcommand if not args or args[0] not in self.commands: # If not a subcommand, prepend the default command name @@ -124,7 +124,7 @@ def main(ctx, version): @click.argument("project_dir", metavar="PATH", required=True) @click.option("-a", "--agent", metavar="AGENT", default="") @click.pass_obj -def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"): +def run(connection: CoreConnection, project_dir: str, agent: str = "1"): """Run an installed agent.""" project = FieldCodec.decode(Checkout, project_dir).resolved(strict=True) @@ -138,7 +138,7 @@ def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"): mode=binding.startup_mode, agent_binding=binding, project_dir=project, - launch_root=launch_root, + connection=connection, ) run_terminal(app) @@ -162,7 +162,7 @@ def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"): @click.option("-d", "--project-dir", "project_option", metavar="PATH", default=None) @click.pass_obj def acp( - launch_root: LaunchRoot, + connection: CoreConnection, command: str, title: str | None, project_dir: str | None, @@ -199,7 +199,7 @@ def acp( app = ToadApp( agent_binding=AgentAttached(agent_data), project_dir=FieldCodec.decode(Checkout, project_dir).resolved(strict=True), - launch_root=launch_root, + connection=connection, agent_session_id=session_id, ) run_terminal(app) @@ -275,12 +275,12 @@ def serve(port: int, host: str, toad_args: tuple[str, ...]) -> None: @main.command("about") @click.pass_obj -def about(launch_root: LaunchRoot) -> None: +def about(connection: CoreConnection) -> None: """Show about information.""" from toad import about - print(about.render(launch_root.path)) + print(about.render(connection.location)) if __name__ == "__main__": diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index 1ef8747e6..26cbc4ad3 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -18,13 +18,14 @@ from dataclasses import dataclass, field, replace from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, ClassVar, TypeVar from weakref import WeakKeyDictionary, WeakSet from agent_comms.message_reference import MessageReference from agent_comms.child_process import join_retirement from agent_comms.comms import Comms from agent_comms.core_access import CoreAccess, LocalCoreAccess +from agent_comms.remote_core import RemoteCoreAccess from agent_comms.display_references import PendingReferences, UnavailableReferences from agent_comms.errors import RouteChangedError from agent_comms.launch_root import LaunchRoot @@ -95,6 +96,91 @@ ROUTE_CHECK_SECONDS = 1.0 +class CoreConnection(ABC): + """The selected Core transport: it owns its route selection and opens its observation.""" + + @staticmethod + def startup(arguments: list[str], published: Callable[[], LaunchRoot]) -> CoreConnection: + """Select once at the process boundary; a UI profile selects the remote Core.""" + if RemoteCoreConnection.argument in arguments: + return RemoteCoreConnection.startup(arguments) + return LocalCoreConnection.from_launch(LaunchRoot.startup(arguments, published)) + + def open_access(self, app: ToadApp) -> CoordinationAccess: + return CoordinationAccess(app, self) + + @property + @abstractmethod + def location(self) -> str: + """Where this connection's Core is, as shown to the owner.""" + + @abstractmethod + def route_selection(self) -> RouteSelection: ... + + @abstractmethod + def open_current(self, access: CoordinationAccess) -> Observing | FailedObservation: + """This connection's current observation, or the error that refuses it.""" + + +class LocalCoreConnection(LaunchRoot, CoreConnection): + """A local connection inherits the selected root and its launch behavior.""" + + @classmethod + def from_launch(cls, root: LaunchRoot) -> LocalCoreConnection: + return cls(root.path) + + @property + def location(self) -> str: + return str(self.path) + + def route_selection(self) -> RouteSelection: + return RouteSelection.of(self) + + def open_current(self, access: CoordinationAccess) -> Observing | FailedObservation: + """Capture the current route and open its service, or expose the opening error.""" + selected: RouteSelection | UnselectedRoute = UnselectedRoute() + try: + selected = self.route_selection() + return access.require(selected) + except (OSError, ValueError, RuntimeError) as error: + return access.observation.failed(error, selected) + + +class RemoteObservationNotServed(RuntimeError): + """The connector answers this UI's Core requests but does not yet carry its observation.""" + + +@dataclass(frozen=True) +class RemoteCoreConnection(RemoteCoreAccess, CoreConnection): + """The owner's UI profile is the remote connection: Core answers through the connector.""" + + argument: ClassVar[str] = "--comms-profile" + + @classmethod + def startup(cls, arguments: list[str]) -> RemoteCoreConnection: + index = arguments.index(cls.argument) + if index + 1 == len(arguments): + raise ValueError(f"{cls.argument} requires a UI profile path") + connection = cls.read(Path(arguments[index + 1])) + del arguments[index : index + 2] + return connection + + @property + def location(self) -> str: + return str(self.endpoint.url) + + def route_selection(self) -> RouteSelection: + raise RemoteObservationNotServed( + f"{self.endpoint.url} answers Core requests; local route selection does not apply") + + def open_current(self, access: CoordinationAccess) -> Observing | FailedObservation: + return access.observation.adopt(ConnectorObservation( + RemoteObservationNotServed( + f"{self.endpoint.url} does not yet carry the sidebar, status and notification " + "observation; conversation pages are read through its Core requests"), + UnselectedRoute(), connection=self, access=access)) + + @dataclass(frozen=True) class UnselectedRoute: """Route capture failed before a validated selection existed.""" @@ -135,12 +221,15 @@ async def current_references(self, access: CoordinationAccess, root: str): return access.reference_context(root) def failed(self, error: Exception, selected): - failed = FailedObservation(error, selected, access=self.access) - if failed == self: + return self.adopt(FailedObservation(error, selected, access=self.access)) + + def adopt[O: Observation](self, observation: O) -> O | Observation: + """Make ``observation`` current unless it equals this one.""" + if observation == self: return self - self.access.observation = failed - self.access.app.running_loop.call_soon_threadsafe(failed.attach, self.access, self) - return failed + self.access.observation = observation + self.access.app.running_loop.call_soon_threadsafe(observation.attach, self.access, self) + return observation def stop_updates(self) -> Observation: self.updates.close() @@ -230,6 +319,18 @@ def observe_team(self, wanted: ObserveTeamHud | StopTeamHud): self.access.events.publish(TeamHudFailed(self.error)) +@dataclass(frozen=True, eq=False) +class ConnectorObservation(FailedObservation): + """Core answers this UI's requests through the connector. Every observed surface + refuses with the declared error until the connector carries the observation.""" + + connection: RemoteCoreConnection = field(kw_only=True, compare=False) + + @property + def core(self) -> CoreAccess: + return self.connection + + @dataclass(frozen=True) class Observing(Observation): selection: RouteSelection @@ -357,9 +458,9 @@ def observe_team(self, wanted: ObserveTeamHud | StopTeamHud): class CoordinationAccess(MroDispatch): """A validated Core route owns cached access, observations and UI write checks.""" - def __init__(self, app: ToadApp, launch_root: LaunchRoot) -> None: + def __init__(self, app: ToadApp, connection: CoreConnection) -> None: self.app = app - self.launch_root = launch_root + self.connection = connection from toad.acp.transcript_reader import CoordinationTranscriptReader self.transcripts = CoordinationTranscriptReader(self) @@ -392,7 +493,7 @@ def __init__(self, app: ToadApp, launch_root: LaunchRoot) -> None: @property def selection(self) -> RouteSelection: """The current route resolved from this app's held launch root.""" - return RouteSelection.of(self.launch_root) + return self.connection.route_selection() def selected(self, source: str | Path) -> RouteSelection: """Select this app's route and refuse a foreign source.""" @@ -403,14 +504,9 @@ def current(self, source: str | Path) -> bool: return self.selection.root_is_current(source) def open_current(self) -> Observing | FailedObservation: - """Capture the current route and open its service, or expose the opening error.""" + """The connection's current observation, or the error that refuses it.""" with self.opening: - selected: RouteSelection | UnselectedRoute = UnselectedRoute() - try: - selected = self.selection - return self.require(selected) - except (OSError, ValueError, RuntimeError) as error: - return self.observation.failed(error, selected) + return self.connection.open_current(self) def confirm(self, selected: RouteSelection) -> None: if self.selection != selected: diff --git a/src/toad/slash_command.py b/src/toad/slash_command.py index 18b4189ee..90c196841 100644 --- a/src/toad/slash_command.py +++ b/src/toad/slash_command.py @@ -256,7 +256,7 @@ async def apply(self, conversation: Conversation) -> bool: from toad import about from toad.widgets.markdown_note import MarkdownNote - markdown = about.render(conversation.app.coordination_access.selection.root) + markdown = about.render(conversation.app.coordination_access.connection.location) await conversation.post(MarkdownNote(markdown, classes="about")) conversation.app.copy_to_clipboard(markdown) conversation.notify( diff --git a/tests/native/test_native_catalog_launch.py b/tests/native/test_native_catalog_launch.py index 906fb1403..1738d9002 100644 --- a/tests/native/test_native_catalog_launch.py +++ b/tests/native/test_native_catalog_launch.py @@ -15,8 +15,8 @@ async def test_store_launch_starts_a_session_and_leaves_the_open_one_alone(works with private_catalog_entry() as definition: from toad.app import ToadApp - def with_catalog_agent(*, launch_root, **arguments): - return ToadApp(launch_root=launch_root, + def with_catalog_agent(*, connection, **arguments): + return ToadApp(connection=connection, **{**arguments, "agent_binding": AgentAttached(definition)}) async with native_session(workspace, application=with_catalog_agent) as native: diff --git a/tests/support/agents.py b/tests/support/agents.py index 6512cce11..849f74b2e 100644 --- a/tests/support/agents.py +++ b/tests/support/agents.py @@ -57,8 +57,10 @@ def __init__(self, app: ToadApp) -> None: def unrun(cls, project: Path, launch_root: LaunchRoot) -> AppAgents: """For a test with no app running: agents of an app built on ``project`` and never run.""" from toad.app import ToadApp + from toad.comms_root import LocalCoreConnection - return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project), launch_root=launch_root)) + return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project), + connection=LocalCoreConnection.from_launch(launch_root))) def agent[A: Agent](self, kind: type[A], definition: AgentDefinition, session_id: str | None = None, **fields: object) -> A: diff --git a/tests/support/core_adapters/human_input_observer.py b/tests/support/core_adapters/human_input_observer.py index 54d133c4c..90e02c45f 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -25,6 +25,7 @@ from toad.agent import AgentBase from toad.agent_schema import AgentDefinition from toad.app import ToadApp +from toad.comms_root import LocalCoreConnection from toad.session_scope import AgentAttached @@ -180,7 +181,8 @@ def phase(name): 'accepts_comms_root': True, }) app = ToadApp(agent_binding=AgentAttached(definition), project_dir=subject.worktree, - agent_session_id=subject.name, launch_root=LaunchRoot.from_path(owner._comms.root)) + agent_session_id=subject.name, + connection=LocalCoreConnection.from_launch(LaunchRoot.from_path(owner._comms.root))) phase('before_actual_toad_run_test') async with app.run_test(headless=True, size=(160, 44)) as pilot: phase('actual_toad_run_test_yielded_before_saved_view_ready') diff --git a/tests/support/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index 9490b7dc8..637b0581b 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -146,6 +146,7 @@ async def disconnected(self): @asynccontextmanager async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer]: from toad.app import ToadApp + from toad.comms_root import LocalCoreConnection from toad.session_scope import NoAgent root = Path(artifact_dir).resolve() @@ -157,7 +158,7 @@ async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer os.environ.update(env) try: app = ToadApp(agent_binding=NoAgent(), project_dir=Checkout(root / "project"), - launch_root=LaunchRoot.from_path(root / "wire")) + connection=LocalCoreConnection.from_launch(LaunchRoot.from_path(root / "wire"))) async with app.run_test(size=(120, 40)) as pilot: # Toad starts with no tab open; the observed session is opened here. definition = AgentDefinition.decode(AGENT_DATA) diff --git a/tests/support/workspace.py b/tests/support/workspace.py index 4691c6424..938c62c93 100644 --- a/tests/support/workspace.py +++ b/tests/support/workspace.py @@ -102,10 +102,11 @@ async def app(self, *, size: tuple[int, int] = (120, 40), binding: AgentBinding to resume (``resume``); ``session=False`` yields what it started with. """ from toad.app import ToadApp + from toad.comms_root import LocalCoreConnection launch = {} if resume is None else {"agent_session_id": resume} app = (application or ToadApp)(agent_binding=binding, project_dir=Checkout(self.project), - launch_root=self.launch_root, **launch) + connection=LocalCoreConnection.from_launch(self.launch_root), **launch) async with app.run_test(size=size, notifications=notifications) as pilot: await app.application.start_worker.wait() ui = TextualUi(app, pilot) diff --git a/tests/textual/test_relaunch_signal.py b/tests/textual/test_relaunch_signal.py index fdcfebb7f..dda1dbcc5 100644 --- a/tests/textual/test_relaunch_signal.py +++ b/tests/textual/test_relaunch_signal.py @@ -9,13 +9,14 @@ from agent_comms.root_runtime import RootRuntime, RootRuntimeStore from toad.app import ToadApp +from toad.comms_root import LocalCoreConnection from toad.application_lifetime import Succeeded from toad.session_scope import NoAgent async def relaunched_onto(workspace, publish) -> Succeeded: app = ToadApp(agent_binding=NoAgent(), mode="store", project_dir=Checkout(workspace.project), - launch_root=workspace.launch_root) + connection=LocalCoreConnection.from_launch(workspace.launch_root)) async with app.run_test(size=(140, 48)): publish(app.coordination_access.selection.root) os.kill(os.getpid(), signal.SIGUSR1) diff --git a/tests/textual/test_scroll_down_bounce.py b/tests/textual/test_scroll_down_bounce.py index fc7017643..92875a9a8 100644 --- a/tests/textual/test_scroll_down_bounce.py +++ b/tests/textual/test_scroll_down_bounce.py @@ -59,8 +59,8 @@ def _on_frame_published(self): self.frames.append((y, reader, page.prepared.start, page.prepared.stop)) -def recorded_app(*, launch_root, **kwargs): - app = ToadApp(launch_root=launch_root, **kwargs) +def recorded_app(*, connection, **kwargs): + app = ToadApp(connection=connection, **kwargs) app.remove_mode("workspace") app.add_mode("workspace", partial(RecordedHistoryScreen, id="workspace")) return app diff --git a/tests/textual/test_store_agent_actions.py b/tests/textual/test_store_agent_actions.py index e71d326f9..de82b74a2 100644 --- a/tests/textual/test_store_agent_actions.py +++ b/tests/textual/test_store_agent_actions.py @@ -11,6 +11,7 @@ from support.catalog_steps import choose_action, open_agent, run_action from toad.agent_schema import ChatAgentKind from toad.app import ToadApp +from toad.comms_root import LocalCoreConnection from toad.session_scope import NoAgent from toad.catalog_actions import CatalogCommandAction from toad.screens.command_edit_modal import CommandEditModal @@ -39,7 +40,7 @@ async def test_agent_actions_run_edit_fail_cancel_and_take_input(workspace): with private_catalog_entry(): from agent_comms.repo import Checkout app = ToadApp(agent_binding=NoAgent(), mode="store", project_dir=Checkout(workspace.project), - launch_root=workspace.launch_root) # starts on the Store: no session to wait for + connection=LocalCoreConnection.from_launch(workspace.launch_root)) # starts on the Store async with app.run_test(size=(140, 48)) as pilot: ui = TextualUi(app, pilot) await ui.until(lambda: isinstance(app.screen, StoreScreen), why="the Store") diff --git a/tests/textual/test_target_command_context.py b/tests/textual/test_target_command_context.py index c6bbd4646..d94efc0c1 100644 --- a/tests/textual/test_target_command_context.py +++ b/tests/textual/test_target_command_context.py @@ -27,4 +27,4 @@ async def test_target_discovery_keeps_declared_channel_selection(workspace, sele for channel in action.channels.channels_for("owner")} assert offered == set(selection.channels_for("owner")) - {NoChannel()} assert all(action.channels == selection for action in channel_actions) - assert ConfiguredBinary().configured() in about.render(workspace.launch_root.path) + assert ConfiguredBinary().configured() in about.render(str(workspace.launch_root.path)) diff --git a/tests/textual/test_window_top_edge.py b/tests/textual/test_window_top_edge.py index 95d039749..a2209ed3c 100644 --- a/tests/textual/test_window_top_edge.py +++ b/tests/textual/test_window_top_edge.py @@ -197,10 +197,10 @@ def block_top_row_text(window, block) -> str: return "".join(segment.text for segment in block.render_line(row, block._paint_state)) -def top_row_app(*, launch_root, **kwargs): +def top_row_app(*, connection, **kwargs): from toad.app import ToadApp - app = ToadApp(launch_root=launch_root, **kwargs) + app = ToadApp(connection=connection, **kwargs) app.remove_mode("workspace") app.add_mode("workspace", partial(TopRowScreen, id="workspace")) return app