diff --git a/src/toad/about.py b/src/toad/about.py index 2c2309edb..33f418b1c 100644 --- a/src/toad/about.py +++ b/src/toad/about.py @@ -6,7 +6,7 @@ import platform from string import Template -from agent_comms.launch_root import LaunchRoot +from pathlib import Path from toad.preferences import SavedSettings from toad.terminal_attention import TerminalAttention from toad import paths @@ -78,7 +78,7 @@ ) -def render() -> str: +def render(root: Path) -> str: """Render about markdown. Returns: @@ -95,7 +95,7 @@ def render() -> str: "COLORTERM": os.environ.get("COLORTERM", ""), "COMMS_AGENT_ARGS": shlex.join(InheritedArguments().configured().argv), "COMMS_AGENT_BIN": ConfiguredBinary().configured(), - "COMMS_ROOT": str(LaunchRoot.current().path), + "COMMS_ROOT": str(root), "CONFIG": config, "DATA_PATH": paths.get_data(), "LOG_PATH": paths.get_log(), diff --git a/src/toad/acp/agent_process.py b/src/toad/acp/agent_process.py index 452b871c0..9d69ffa45 100644 --- a/src/toad/acp/agent_process.py +++ b/src/toad/acp/agent_process.py @@ -70,7 +70,7 @@ async def start(self, agent, processes: tuple[AgentProcess, ...]=()): agent.presentation.log_path.parent.mkdir, parents=True, exist_ok=True) env = os.environ.copy() cwd = str(agent.project_root_path.resolve()) - selection = RouteSelection.capture(agent.controller.transcripts.access.launch_root.path) + selection = agent.controller.transcripts.access.selection if not agent.session.accepts_updates: return self for connection in dict.fromkeys((self, *processes)): diff --git a/src/toad/acp/agent_session.py b/src/toad/acp/agent_session.py index c8cc30efe..23bf93902 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -18,7 +18,6 @@ from agent_comms.errors import RouteChangedError from agent_comms.input_attempt import NotSentInput from agent_comms.owner_protocol import IncompatibleOwner -from agent_comms.route_selection import RouteSelection from agent_comms.session_load import ( EnsuringSessionLoadPlan, ExistingSessionLoadPlan, @@ -362,7 +361,7 @@ async def reconnect(self, plan: SessionLoadPlan | None = None) -> None: try: try: await asyncio.to_thread( - RouteSelection.capture, self.agent.controller.transcripts.access.launch_root.path) + lambda: self.agent.controller.transcripts.access.selection) except RouteChangedError as error: self.startup_failed(f"Reconnect blocked before restart: {error}") return diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index be03e082b..59229b187 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 pathlib import Path from typing import TYPE_CHECKING from agent_comms.acp_extension import ( @@ -29,7 +28,6 @@ TurnChangedUpdate, ) from agent_comms.context_reading import UnmeasuredContext -from agent_comms.errors import RouteChangedError from agent_comms.native_output import AssistantOutput, ToolOutput from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation from agent_comms.transcripts import NativeCommittedPrefix @@ -186,9 +184,7 @@ def _post_unidentified(self, update: InputStartedUpdate) -> None: @handles(CoordinationChangedUpdate) def coordination_changed(self, update: CoordinationChangedUpdate) -> None: agent = self.agent - root = agent.controller.transcripts.access.launch_root.path - if Path(update.wire_root).resolve() != root.resolve(): - raise RouteChangedError("ACP owner reported a different coordination root") + 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) diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index aa5f6fac8..d5c87ac80 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -16,7 +16,6 @@ from functools import partial from agent_comms.acp_extension import TranscriptDraftUpdate, TranscriptSnapshotUpdate -from agent_comms.route_selection import RouteSelection from agent_comms.thread_presentation import ThreadPresentation from agent_comms.ui_model.observation import ( AwaitedRead, @@ -44,7 +43,7 @@ async def read(self, request: AwaitedRead): async def service(self, root): return await self.access.preparation.run_thread( - lambda: self.access.require(RouteSelection.capture(root)).service + lambda: self.access.require(self.access.selected(root)).service ) async def thread_presentation( diff --git a/src/toad/app.py b/src/toad/app.py index 7f88a2e3f..5ec4cf855 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -18,6 +18,7 @@ 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.repo import Checkout from toad.core import input_events from rich.console import RenderableType @@ -150,6 +151,7 @@ def __init__( self, agent_binding: AgentBinding, project_dir: Checkout, + launch_root: LaunchRoot, mode: str | None = None, agent_session_id: str | None = None, ) -> None: @@ -158,6 +160,7 @@ def __init__( Args: agent_binding: The agent selected at startup. project_dir: Project directory. + launch_root: Coordination root selected by the launch owner. mode: Initial mode. """ from toad.render_processes import RenderProcessPool @@ -189,7 +192,7 @@ def __init__( db=DB(), ) self._session_tracker = SessionTracker(self.tab_order) - self.coordination_access = CoordinationAccess(self) + self.coordination_access = CoordinationAccess(self, launch_root) from toad.file_kind import FileKind FileKind.prepare_terminal() diff --git a/src/toad/cli.py b/src/toad/cli.py index d7fb196a1..7c45f6c39 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -6,6 +6,7 @@ import click from agent_comms.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 @@ -75,10 +76,9 @@ def parse_args(self, ctx, args): if "--version" in args or "-v" in args: return super().parse_args(ctx, args) from agent_comms.active_route import ActiveRoute - from agent_comms.launch_root import LaunchRoot - # One selection, as in every Comms process: --comms-root if given, else the published route. - LaunchRoot.startup(args, ActiveRoute.published_launch) + # Select once at the process boundary; apps receive this captured value. + ctx.obj = LaunchRoot.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 @@ -123,7 +123,8 @@ def main(ctx, version): @main.command("run") @click.argument("project_dir", metavar="PATH", required=True) @click.option("-a", "--agent", metavar="AGENT", default="") -def run(project_dir: str, agent: str = "1"): +@click.pass_obj +def run(launch_root: LaunchRoot, project_dir: str, agent: str = "1"): """Run an installed agent.""" project = FieldCodec.decode(Checkout, project_dir).resolved(strict=True) @@ -137,6 +138,7 @@ def run(project_dir: str, agent: str = "1"): mode=binding.startup_mode, agent_binding=binding, project_dir=project, + launch_root=launch_root, ) run_terminal(app) @@ -158,7 +160,9 @@ def run(project_dir: str, agent: str = "1"): default=None, ) @click.option("-d", "--project-dir", "project_option", metavar="PATH", default=None) +@click.pass_obj def acp( + launch_root: LaunchRoot, command: str, title: str | None, project_dir: str | None, @@ -195,6 +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, agent_session_id=session_id, ) run_terminal(app) @@ -269,12 +274,13 @@ def serve(port: int, host: str, toad_args: tuple[str, ...]) -> None: @main.command("about") -def about() -> None: +@click.pass_obj +def about(launch_root: LaunchRoot) -> None: """Show about information.""" from toad import about - print(about.render()) + print(about.render(launch_root.path)) if __name__ == "__main__": diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index a78222d1b..4efacaace 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -24,6 +24,7 @@ from agent_comms.comms import Comms from agent_comms.display_references import PendingReferences, UnavailableReferences from agent_comms.errors import RouteChangedError +from agent_comms.launch_root import LaunchRoot from agent_comms.launch_runtime import LaunchRuntime from metaclass_registry.mro_dispatch import MroDispatch, handles from agent_comms.presentation import MessageNotification, NoRevision, ReadState @@ -329,11 +330,9 @@ def observe_commander(self, wanted: ObserveCommanderHud | StopCommanderHud): class CoordinationAccess(MroDispatch): """A validated Core route owns cached access, observations and UI write checks.""" - def __init__(self, app: ToadApp) -> None: - from agent_comms.launch_root import LaunchRoot - + def __init__(self, app: ToadApp, launch_root: LaunchRoot) -> None: self.app = app - self.launch_root = LaunchRoot.current() + self.launch_root = launch_root from toad.acp.transcript_reader import CoordinationTranscriptReader self.transcripts = CoordinationTranscriptReader(self) @@ -361,18 +360,31 @@ def __init__(self, app: ToadApp) -> None: self.commander_pages: WeakSet[CommanderPage] = WeakSet() self.commander_interest: ObserveCommanderHud | StopCommanderHud = StopCommanderHud() + @property + def selection(self) -> RouteSelection: + """The current route resolved from this app's held launch root.""" + return RouteSelection.of(self.launch_root) + + def selected(self, source: str | Path) -> RouteSelection: + """Select this app's route and refuse a foreign source.""" + return self.selection.require_source(source) + + def current(self, source: str | Path) -> bool: + """Whether a source still belongs to this app's current route.""" + 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.""" with self.opening: selected: RouteSelection | UnselectedRoute = UnselectedRoute() try: - selected = RouteSelection.capture(self.launch_root.path) + selected = self.selection return self.require(selected) except (OSError, ValueError, RuntimeError) as error: return self.observation.failed(error, selected) def confirm(self, selected: RouteSelection) -> None: - if RouteSelection.capture() != selected: + if self.selection != selected: raise RouteChangedError("Comms route changed before the operation") def require(self, selected: RouteSelection) -> Observing: @@ -649,5 +661,5 @@ async def observe_route(self) -> None: def write(self, selected: RouteSelection, operation: Callable[..., T], *args: object, **kwargs: object) -> T: # This method runs in the same worker as the actual sink. No route lock: the # store refuses a write an install holds, inside its own write transaction. - RouteSelection.capture(selected.root) + self.confirm(selected) return operation(*args, **kwargs) diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index 04c2a3cec..a33fda8b3 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -119,7 +119,6 @@ async def refresh_composer(self, read_only: bool) -> None: 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 - from agent_comms.route_selection import root_is_current if not body.strip(): return @@ -135,7 +134,9 @@ async def submit_comms_message(self, root, conversation, body, current, present) self.feedback.flash(self.status, style="error") return self._enter_send_state(SendPending()) - route_current = await self.app.preparation.run_thread(root_is_current, root) + 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) diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index 1dd174caa..16dc00bfc 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -209,7 +209,6 @@ def key(self) -> tuple[str, str, str]: return self.owner_mode, self.request.root, self.request.target async def run(self) -> str: - from agent_comms.route_selection import root_is_current navigator = self.navigator try: @@ -224,7 +223,7 @@ async def run(self) -> str: ): return navigator.app.selected_mode if not await navigator.app.preparation.run_thread( - root_is_current, self.request.root + lambda: navigator.app.coordination_access.current(self.request.root) ): raise RuntimeError("The thread's bus route changed while opening") return await prepared.open(self) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 398bb77fb..c0aa182e2 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -12,7 +12,6 @@ from agent_comms.coordination_errors import DMScopeChanged, Refused from agent_comms.declared_family import DeclaredFamily from metaclass_registry.mro_dispatch import handles -from agent_comms.route_selection import RouteSelection from agent_comms.repo import Checkout from textual import containers, getters, on from textual.app import ComposeResult @@ -238,7 +237,7 @@ def read_visible(self, screen: MainScreen) -> PaintedRead: worktree = app.project_dir def acknowledge(cursor) -> None: - selected = RouteSelection.capture(source_root) + selected = access.selected(source_root) comms = access.require(selected).service def mark() -> None: diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index e6dafa812..370e7e108 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -18,7 +18,6 @@ ) from agent_comms.presentation import NoRevision, ReadState from agent_comms.revision_views import RegistrationsView -from agent_comms.route_selection import root_is_current from agent_comms.thread_identity import ThreadIncarnation from toad.core import events as core_events @@ -402,7 +401,9 @@ async def history( return app.selected_mode if not origin.current(app.thread_navigation, owner_mode): return app.selected_mode - if not await app.preparation.run_thread(root_is_current, requested_root): + if not await app.preparation.run_thread( + lambda: app.coordination_access.current(requested_root) + ): return app.selected_mode if not origin.current(app.thread_navigation, owner_mode): return app.selected_mode @@ -544,7 +545,6 @@ async def _retire_requested_views(self) -> None: await self.retire_missing() async def retire_missing(self) -> None: - from agent_comms.route_selection import RouteSelection # Borrow each open entry, including hidden views. Core decides # which of their sources no longer exist; Toad only closes the views. @@ -569,11 +569,11 @@ def read(): answers = {} for root, (threads, channels) in asked.items(): try: - selected = RouteSelection.capture(root) - service = self.app.coordination_access.require(selected).service + access = self.app.coordination_access + selected = access.selected(root) + service = access.require(selected).service retired = service.views.retired_views(threads, channels) - if RouteSelection.capture(root) != selected: - continue # Replaced while reading; the next observation reads it. + access.confirm(selected) except RouteChangedError: continue # The next observation reads the replacement route. answers[root] = service, retired diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 5137fa9c5..3783818e3 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -13,7 +13,6 @@ from agent_comms.repo import Checkout from agent_comms.coordination_errors import CoordinationReadUnavailable, StaleRevision from agent_comms.errors import RelationViolationError -from agent_comms.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation from agent_comms.ui_model.observation import ( ReadTranscript, @@ -130,7 +129,7 @@ def original_threads( return ((self.root, identity.thread_incarnation()),) def matches_session(self, access: CoordinationAccess, session_id: str) -> bool: - service = access.require(RouteSelection.capture(self.root)).service + service = access.require(access.selected(self.root)).service return ( service.registry.canonical_name(session_id) == service.registry.require(self.name).name @@ -407,7 +406,7 @@ def require(self) -> ProjectSessionOrigin: def resolve_thread(self) -> str: identity = self.thread_identity - selected = RouteSelection.capture(identity.wire_root()) + selected = self.app.coordination_access.selected(identity.wire_root()) service = self.app.coordination_access.require(selected).service return identity.resolve_thread(service.registry, self.project_root) diff --git a/src/toad/slash_command.py b/src/toad/slash_command.py index 047c22b46..34764f53a 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() + markdown = about.render(conversation.app.coordination_access.selection.root) await conversation.post(MarkdownNote(markdown, classes="about")) conversation.app.copy_to_clipboard(markdown) conversation.notify( diff --git a/src/toad/target_commands.py b/src/toad/target_commands.py index 1663d0c2a..64342b0a7 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -2,7 +2,6 @@ from __future__ import annotations -from agent_comms.errors import RouteChangedError from toad.core import session_requests from abc import ABC, abstractmethod @@ -14,7 +13,6 @@ from agent_comms.comms import Comms from agent_comms.repo import Checkout from agent_comms.channel_targets import ChannelSelection, CommandTarget, NoChannel -from agent_comms.route_selection import root_is_current from toad.slash_command import CommandPresentation, LocalCommand, SlashCommand from agent_comms.cli_command import CliCommand, TargetAction from toad.thread_actions import ThreadAction @@ -53,19 +51,16 @@ def title(self) -> str: return ", ".join(names) if names else str(self.project) def current(self): - if not root_is_current(self.comms.root): - raise RouteChangedError(RouteChangedError.reader_sentence) + self.app.coordination_access.selected(self.comms.root) return self def available_actions(self): - from agent_comms.route_selection import RouteSelection - selected = RouteSelection.capture(self.comms.root) - self.current() + access = self.app.coordination_access + selected = access.selected(self.comms.root) actions = CliCommand.target_catalog(self.comms, CliCommand.selected_targets(self.selection), self.channel, project=self.project) - if RouteSelection.capture(self.comms.root) != selected: - raise ValueError('Comms route changed during command discovery') + access.confirm(selected) return actions def show_menu(self, sidebar, offset): diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index 428015ede..6de1b2890 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -12,7 +12,6 @@ from agent_comms.cli_result import CliResult from agent_comms.errors import RouteChangedError from agent_comms.native_pi import NativePiUnavailable -from agent_comms.route_selection import RouteSelection from toad.core import events as core_events @@ -34,7 +33,7 @@ def pending(self) -> dict[str | Path, str]: def invoke(self, action: ThreadAction, subject: str, *, source_root: Path) -> None: app = self.app - selected = RouteSelection.capture(source_root) + selected = app.coordination_access.selected(source_root) if any(target in self.requests for target in action.resources): app.notify(f"An action for {subject} is already in progress", title="Session action") return diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 1caaeca1c..5942c65a9 100644 --- a/src/toad/widgets/comms_chat.py +++ b/src/toad/widgets/comms_chat.py @@ -117,7 +117,7 @@ async def initialize_view(self) -> None: await self.app.coordination_access.current_sidebar() self.message_history.initialize_source(WireSource( comms, self.conversation, self.project_path, self._me, - IrcMessageStyle(), + IrcMessageStyle(), access=self.app.coordination_access, )) self.refresh_identity(comms.root) self.prepare_prompt() diff --git a/src/toad/widgets/comms_transfer.py b/src/toad/widgets/comms_transfer.py index 439d1308f..bb295317d 100644 --- a/src/toad/widgets/comms_transfer.py +++ b/src/toad/widgets/comms_transfer.py @@ -103,7 +103,8 @@ def __init__(self, app) -> None: self.app = app def open(self, kind: type[TransferRequest]) -> None: - selected = RouteSelection.capture(self.app.coordination_access.observation.observed().service.root) + access = self.app.coordination_access + selected = access.selected(access.observation.observed().service.root) self.app.push_screen(kind.dialog(selected), callback=partial(self.submitted, selected)) def submitted(self, selected: RouteSelection, request: TransferRequest | None) -> None: diff --git a/src/toad/widgets/context_explorer.py b/src/toad/widgets/context_explorer.py index 5c7362d11..2a5c5daf7 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -11,7 +11,6 @@ from agent_comms.context_store import LATEST_CONTEXT, ContextManifestCursor from agent_comms.coordinator import Coordination from metaclass_registry.mro_dispatch import handles -from agent_comms.route_selection import RouteSelection from textual import on, work from textual.binding import ModeBinding from textual.document._editing import EditingMode @@ -360,7 +359,7 @@ async def _read_owner(self, identity: ThreadIdentity, *, force: bool = False): self.state = previous.observe(access.revision) try: service = await self.app.preparation.run_thread( - lambda: access.require(RouteSelection.capture(identity.root_path())).service + lambda: access.require(access.selected(identity.root_path())).service ) if not self._reading(identity): return diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index a2f2747bb..f1404be99 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -13,7 +13,6 @@ from dataclasses import dataclass, field from agent_comms.display_order import ThreadSort -from agent_comms.route_selection import RouteSelection from agent_comms.ui_model.sidebar import ThreadRowModel from agent_comms.ui_model.unread import UnobservedUnread from textual import on @@ -282,7 +281,7 @@ async def relationship_source(self, identity: ThreadIdentity) -> RelationshipSou access = self.app.coordination_access return await self.app.preparation.run_thread( lambda: access.require( - RouteSelection.capture(identity.root_path()) + access.selected(identity.root_path()) ).service.relationships ) diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 93e9d610c..34e7ee642 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -32,6 +32,7 @@ ) from agent_comms.transcript_events import IncomingTranscript +from toad.comms_root import CoordinationAccess from toad.conversation_kind import ConversationKind from toad.line_blocks import Body, Gap, LineBlock, MutedRole, Summary from toad.message_rows import MessageRow, WireMessageStyle @@ -294,6 +295,7 @@ class WireSource: project: Checkout me: str style: WireMessageStyle + access: CoordinationAccess = field(kw_only=True) tail_limit: int = 8 page_limit: int = 40 @@ -416,9 +418,9 @@ def uses_observation(self, observation: Observation) -> bool: return observation.uses(self.loader.comms) async def route_is_current(self) -> bool: - from agent_comms.route_selection import root_is_current - - return not self.closed and await self.runtime.run_thread(root_is_current, self.loader.comms.root) + return not self.closed and await self.runtime.run_thread( + lambda: self.loader.access.current(self.loader.comms.root) + ) def status_thread(self, access): return self.loader.conversation.status_thread(self.loader.comms.root, access) diff --git a/tests/conftest.py b/tests/conftest.py index 68309e523..eead05fcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,7 +52,7 @@ def launch_root(tmp_path): def app_agents(tmp_path, launch_root) -> Iterator[AppAgents]: """Agents of an app built on the test's own directory and never run.""" with launch_root.selected(): - yield AppAgents.unrun(tmp_path) + yield AppAgents.unrun(tmp_path, launch_root) @pytest.fixture diff --git a/tests/model/test_agent_prompt_route.py b/tests/model/test_agent_prompt_route.py index 81071106d..02748639c 100644 --- a/tests/model/test_agent_prompt_route.py +++ b/tests/model/test_agent_prompt_route.py @@ -9,9 +9,7 @@ async def test_the_real_child_receives_the_selected_root_as_arguments_and_drops_the_environment_override( - app_agents, tmp_path, monkeypatch): - from agent_comms.launch_root import LaunchRoot - + app_agents, launch_root, tmp_path, monkeypatch): output = tmp_path / "child-launch.json" program = ( "import json,os,sys; from pathlib import Path; " @@ -20,7 +18,7 @@ async def test_the_real_child_receives_the_selected_root_as_arguments_and_drops_ ) definition = replace(command(sys.executable, "-c", program, str(output)), accepts_comms_root=True) agent = app_agents.agent(Agent, definition) - selected = LaunchRoot.current() + selected = launch_root monkeypatch.setenv("AGENT_COMMS_ROOT", str(tmp_path / "wrong-wire")) try: await agent.start() diff --git a/tests/native/test_native_catalog_launch.py b/tests/native/test_native_catalog_launch.py index 91c89b7c2..906fb1403 100644 --- a/tests/native/test_native_catalog_launch.py +++ b/tests/native/test_native_catalog_launch.py @@ -15,8 +15,9 @@ 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(**arguments): - return ToadApp(**{**arguments, "agent_binding": AgentAttached(definition)}) + def with_catalog_agent(*, launch_root, **arguments): + return ToadApp(launch_root=launch_root, + **{**arguments, "agent_binding": AgentAttached(definition)}) async with native_session(workspace, application=with_catalog_agent) as native: ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider diff --git a/tests/support/agents.py b/tests/support/agents.py index b71dd246c..6512cce11 100644 --- a/tests/support/agents.py +++ b/tests/support/agents.py @@ -4,6 +4,7 @@ from pathlib import Path from agent_comms.repo import Checkout +from agent_comms.launch_root import LaunchRoot import shlex import sys from typing import TYPE_CHECKING @@ -53,11 +54,11 @@ def __init__(self, app: ToadApp) -> None: self.app = app @classmethod - def unrun(cls, project: Path) -> AppAgents: + 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 - return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project))) + return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project), launch_root=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 f98a59d3d..3981329f9 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -17,6 +17,7 @@ import sys from agent_comms.field_codec import FieldCodec +from agent_comms.launch_root import LaunchRoot from agent_comms.acp_extension import QueuePromptRequest from agent_comms.input_disposition import InputDispositions from agent_comms.native_entries import NativeEntry @@ -179,7 +180,7 @@ def phase(name): 'accepts_comms_root': True, }) app = ToadApp(agent_binding=AgentAttached(definition), project_dir=subject.worktree, - agent_session_id=subject.name) + agent_session_id=subject.name, launch_root=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 8d4044674..6bcc9658d 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -11,6 +11,7 @@ import os from pathlib import Path from agent_comms.repo import Checkout +from agent_comms.launch_root import LaunchRoot from typing import Any, AsyncIterator from mcp_turn_boundaries import AGENT_DATA, exercise_boundaries, notes @@ -155,7 +156,8 @@ async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer previous = {key: os.environ.get(key) for key in env} os.environ.update(env) try: - app = ToadApp(agent_binding=NoAgent(), project_dir=Checkout(root / "project")) + app = ToadApp(agent_binding=NoAgent(), project_dir=Checkout(root / "project"), + launch_root=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 020afe15d..f5f00ff23 100644 --- a/tests/support/workspace.py +++ b/tests/support/workspace.py @@ -3,6 +3,7 @@ from __future__ import annotations from agent_comms.repo import Checkout, Repo +from agent_comms.launch_root import LaunchRoot from contextlib import asynccontextmanager from functools import cached_property import os @@ -24,13 +25,12 @@ class Workspace: """Everything a test of the running app starts from.""" def __init__(self, roots: Roots) -> None: - from agent_comms.launch_root import LaunchRoot - self.roots = roots self.project = roots.project self.project.mkdir(parents=True, exist_ok=True) Repo.git(self.project, "init", "-q") - LaunchRoot.from_path(roots.wire).install() + self.launch_root = LaunchRoot.from_path(roots.wire) + self.launch_root.install() self.comms = installed_wire(roots.wire) @property @@ -90,7 +90,7 @@ def prompts_received(self) -> list[str]: @cached_property def agents(self) -> AppAgents: - return AppAgents.unrun(self.project) + return AppAgents.unrun(self.project, self.launch_root) @asynccontextmanager async def app(self, *, size: tuple[int, int] = (120, 40), binding: AgentBinding = NoAgent(), @@ -103,7 +103,8 @@ async def app(self, *, size: tuple[int, int] = (120, 40), binding: AgentBinding from toad.app import ToadApp launch = {} if resume is None else {"agent_session_id": resume} - app = (application or ToadApp)(agent_binding=binding, project_dir=Checkout(self.project), **launch) + app = (application or ToadApp)(agent_binding=binding, project_dir=Checkout(self.project), + launch_root=self.launch_root, **launch) async with app.run_test(size=size) as pilot: await app.application.start_worker.wait() ui = TextualUi(app, pilot) diff --git a/tests/textual/test_conversation_kind.py b/tests/textual/test_conversation_kind.py index 80df2a19b..cdc0d63d6 100644 --- a/tests/textual/test_conversation_kind.py +++ b/tests/textual/test_conversation_kind.py @@ -1,6 +1,7 @@ """Textual-facing: stable conversation identities resolve current names through Core references.""" from agent_comms.repo import Checkout +from agent_comms.launch_root import LaunchRoot from dataclasses import FrozenInstanceError from functools import partial @@ -9,6 +10,7 @@ from agent_comms.comms import Comms from agent_comms.active_route import resolve_comms_route from support.roots import installed_wire +from support.agents import AppAgents from agent_comms.field_codec import FieldCodec from agent_comms.installer.backup import StoreBackup from agent_comms.message_page import PageBounds @@ -53,7 +55,8 @@ def test_conversation_family_resolves_copied_pages_and_current_labels( assert conversation.label(references) == label assert HistoryTarget.for_conversation(conversation, references) == route page = conversation.page( - WireSource(comms, conversation, comms.registry.require(viewer).worktree, viewer, IrcMessageStyle()), + WireSource(comms, conversation, comms.registry.require(viewer).worktree, viewer, IrcMessageStyle(), + access=AppAgents.unrun(tmp_path, LaunchRoot.from_path(comms.root)).app.coordination_access), PageBounds(limit=8), ) assert body in [message.body for message in page.messages] diff --git a/tests/textual/test_hidden_channel_refresh.py b/tests/textual/test_hidden_channel_refresh.py index 63a9a6290..d9c383c5e 100644 --- a/tests/textual/test_hidden_channel_refresh.py +++ b/tests/textual/test_hidden_channel_refresh.py @@ -1,7 +1,8 @@ -"""Textual-facing: a hidden channel view's refresh callback (a widget lifecycle) reads nothing; the shown one fetches current data.""" +"""A hidden channel does no route check; returning fetches current data.""" from unittest.mock import patch +from agent_comms.route_selection import RouteSelection from support.textual_steps import refresh_chat @@ -19,12 +20,12 @@ async def test_hidden_channel_refresh_does_no_root_check_and_resumed_window_fetc history = chat.message_history await ui.until(lambda: not history.undrawn and history.state.accepts_source_work, why="the first channel's history read") await ui.open_channel("#two", actor="peer") - # Mark the current page loaded before instrumenting the hidden reader. - await ui.until(lambda: history.state.accepts_source_work and not history.marking, + # Let the hidden reader settle before the next message arrives. + await ui.until(lambda: history.state.accepts_source_work, why="the hidden channel to settle") + await history.read_worker.wait() comms.messaging.send("peer", "#one", "ARRIVED-WHILE-HIDDEN") - with patch("agent_comms.route_selection.root_is_current", side_effect=AssertionError("hidden route check")): - # Exercise the real hidden callback, not a mocked visibility test. + with patch.object(RouteSelection, "root_is_current", side_effect=AssertionError("hidden route check")): for _ in range(40): await refresh_chat(chat) assert not any(m.body == "ARRIVED-WHILE-HIDDEN" for m in history.messages) diff --git a/tests/textual/test_scroll_down_bounce.py b/tests/textual/test_scroll_down_bounce.py index 944bf124b..ffacc088d 100644 --- a/tests/textual/test_scroll_down_bounce.py +++ b/tests/textual/test_scroll_down_bounce.py @@ -56,8 +56,8 @@ def _on_frame_published(self): self.frames.append((y, reader, page.prepared.start, page.prepared.stop)) -def recorded_app(**kwargs): - app = ToadApp(**kwargs) +def recorded_app(*, launch_root, **kwargs): + app = ToadApp(launch_root=launch_root, **kwargs) app.remove_mode("workspace") app.add_mode("workspace", partial(RecordedHistoryScreen, id="workspace")) return app diff --git a/tests/textual/test_session_observation_reads.py b/tests/textual/test_session_observation_reads.py index b7a77f54c..b3555bc79 100644 --- a/tests/textual/test_session_observation_reads.py +++ b/tests/textual/test_session_observation_reads.py @@ -43,7 +43,7 @@ async def next_read(observation): async def test_invalidation_during_a_read_supersedes_its_result(workspace): - agents = AppAgents.unrun(workspace.project) + agents = AppAgents.unrun(workspace.project, workspace.launch_root) view, app = ProbeView(agents.detached_agent()), App() observation = ProbeObservation(view) async with app.run_test(): @@ -61,7 +61,7 @@ async def test_invalidation_during_a_read_supersedes_its_result(workspace): async def test_a_read_for_a_previous_agent_is_not_published(workspace): - agents = AppAgents.unrun(workspace.project) + agents = AppAgents.unrun(workspace.project, workspace.launch_root) view, app = ProbeView(agents.detached_agent()), App() observation = ProbeObservation(view) async with app.run_test(): @@ -81,7 +81,7 @@ async def test_a_read_for_a_previous_agent_is_not_published(workspace): async def test_a_closed_observation_starts_no_read_and_delivery_actions_refuse(workspace): - agents = AppAgents.unrun(workspace.project) + agents = AppAgents.unrun(workspace.project, workspace.launch_root) view, app = ProbeView(agents.detached_agent()), App() observation = ProbeObservation(view) async with app.run_test() as pilot: diff --git a/tests/textual/test_stopped_thread_attachment.py b/tests/textual/test_stopped_thread_attachment.py index 4c2e564b3..160ff9b78 100644 --- a/tests/textual/test_stopped_thread_attachment.py +++ b/tests/textual/test_stopped_thread_attachment.py @@ -22,7 +22,6 @@ async def test_opening_stopped_thread_reads_history_without_loading_an_agent(wor async def test_started_owner_attaches_original_saved_session_in_place(workspace): from agent_comms.child_process import ProcessIdentity - from agent_comms.route_selection import RouteSelection from support.agents import peer import os @@ -36,7 +35,7 @@ async def test_started_owner_attaches_original_saved_session_in_place(workspace) workspace.comms.registry.declare(replace(thread, process_identity=ProcessIdentity.capture(os.getpid()))) await app.session_navigation.reconnect( - RouteSelection.capture(workspace.comms.root), ('stopped',)) + app.coordination_access.selected(workspace.comms.root), ('stopped',)) await ui.until(lambda: {'loaded': 'stopped'} in workspace.received(), why='ACP to load the original saved session') assert ui.conversation is view diff --git a/tests/textual/test_store_agent_actions.py b/tests/textual/test_store_agent_actions.py index 8f56ac225..e71d326f9 100644 --- a/tests/textual/test_store_agent_actions.py +++ b/tests/textual/test_store_agent_actions.py @@ -38,7 +38,8 @@ 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)) # starts on the Store: no session to wait for + 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 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 f6c19bcda..c6bbd4646 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() + assert ConfiguredBinary().configured() in about.render(workspace.launch_root.path) diff --git a/tests/textual/test_wire_source.py b/tests/textual/test_wire_source.py index 59635665e..8d96d8159 100644 --- a/tests/textual/test_wire_source.py +++ b/tests/textual/test_wire_source.py @@ -3,10 +3,12 @@ import os from agent_comms.repo import Checkout +from agent_comms.launch_root import LaunchRoot from agent_comms.channel_targets import ChannelTarget, ThreadTarget from agent_comms.child_process import ProcessIdentity from agent_comms.field_codec import TurnId from support.roots import installed_wire +from support.agents import AppAgents from agent_comms.historical_views import ConversationObservation from agent_comms.page_traversal import LiveCursor from agent_comms.threads import Thread @@ -21,7 +23,8 @@ def channel_source(comms, target, project): project = Checkout(project) viewer = comms.messaging.user_identity(project).name - return WireSource(comms, ChannelConversation(ChannelTarget(target)), project, viewer, IrcMessageStyle()) + return WireSource(comms, ChannelConversation(ChannelTarget(target)), project, viewer, IrcMessageStyle(), + access=AppAgents.unrun(project.path, LaunchRoot.from_path(comms.root)).app.coordination_access) def observed(source):