diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04e39ce84..1a782d3d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ name: Local-equivalent verification on: + push: + branches: [main] pull_request: branches: [main] workflow_dispatch: @@ -14,7 +16,7 @@ permissions: contents: read concurrency: group: toad-verification-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name != 'push' }} jobs: nra-gate: name: NRA gate @@ -24,7 +26,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || inputs.head }} + ref: ${{ github.event.pull_request.head.sha || inputs.head || github.sha }} - uses: actions/setup-python@v5 with: python-version: '3.14' @@ -44,8 +46,8 @@ jobs: # pull_request.base.sha is not refreshed when the base branch moves, so it can name a # revision many commits behind and charge the PR for debt main introduced. The PR's own # change starts at the merge-base with the base branch's current tip. - BASE_TIP: ${{ github.event.pull_request.base.ref && format('origin/{0}', github.event.pull_request.base.ref) || inputs.base }} - PR_HEAD: ${{ github.event.pull_request.head.sha || inputs.head }} + BASE_TIP: ${{ github.event.pull_request.base.ref && format('origin/{0}', github.event.pull_request.base.ref) || inputs.base || github.event.before }} + PR_HEAD: ${{ github.event.pull_request.head.sha || inputs.head || github.sha }} run: PYTHONPATH="$PWD/.core-gate/src" PATH="$PWD/.venv/bin:$PATH" .venv/bin/python .core-gate/tools/nra_client_gate.py --root src/toad --base "$(git merge-base "$BASE_TIP" "$PR_HEAD")" --head "$PR_HEAD" - name: Run surface guards run: .venv/bin/pytest tests/guards @@ -56,18 +58,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha || inputs.head }} + ref: ${{ github.event.pull_request.head.sha || inputs.head || github.sha }} - uses: actions/setup-python@v5 with: python-version: '3.14' - - name: Install Neovim - run: | - curl --fail --location --silent --show-error \ - https://github.com/neovim/neovim/releases/download/v0.12.4/nvim-linux-x86_64.tar.gz \ - --output "$RUNNER_TEMP/neovim.tar.gz" - tar --extract --gzip --file "$RUNNER_TEMP/neovim.tar.gz" --directory "$RUNNER_TEMP" - echo "$RUNNER_TEMP/nvim-linux-x86_64/bin" >> "$GITHUB_PATH" - "$RUNNER_TEMP/nvim-linux-x86_64/bin/nvim" --version - uses: ./.github/actions/core-from-main with: path: .artifacts/core @@ -88,6 +82,8 @@ jobs: with open(os.environ['GITHUB_ENV'], 'a') as env: env.write(f'AC_NATIVE_COPIED_PACKAGE={package.resolve()}\n') PYCORE + - name: Prepare the declared Neovim requirement + run: .venv/bin/python -m toad.neovim_runtime # xdist reports a collection error as a worker crash; collect alone first so it reads as one. - run: .venv/bin/pytest --collect-only -q # A test stuck past 120 s dumps every thread, so the red names where it hung. diff --git a/pyproject.toml b/pyproject.toml index f08445c1a..20bf5d2a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ textual-diff-view = { git = "https://github.com/OpenHCSDev/textual-diff-view.git [project.scripts] toad = "toad.cli:main" +[project.entry-points."agent_comms.runtime_preparation"] +neovim = "toad.neovim_runtime:Neovim" + [project.entry-points."agent_comms.writer_owned_stores"] toad = "toad.db" preferences = "toad.preferences" 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_controller.py b/src/toad/acp/agent_controller.py index 91057609d..bee74363d 100644 --- a/src/toad/acp/agent_controller.py +++ b/src/toad/acp/agent_controller.py @@ -21,6 +21,7 @@ ) from agent_comms.acp_failure import ACPFailure, RequestFailure from agent_comms.coordination_errors import Refused +from agent_comms.runtime import RuntimeConnection, socket_path from agent_comms.transcripts import TranscriptReadIdentity from agent_comms.ui_model.delivery import DeliveryView from agent_comms.ui_model.observation import ReadDeliveryOverview @@ -390,10 +391,6 @@ async def _cancel_prompt(self, authority: ClientSessionRequest): async def request_owner(self, method: str, **params: JsonValue): if self.coordination is None: raise ValueError("This action requires an agent-comms thread.") - from agent_comms.runtime import RuntimeProxy, socket_path - - from toad.owner_preparation import OwnerRequestContext - coordination = self.coordination authority = ClientSessionRequest(self.agent, self.agent.session_id) self.require_owner(coordination, authority) @@ -403,8 +400,8 @@ 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 RuntimeProxy( - OwnerRequestContext(comms), owner.name, socket_path(comms.root, owner.require_process().pid) + return RuntimeConnection( + comms, owner.incarnation, socket_path(comms.root, owner.require_process().pid) ) proxy = await asyncio.to_thread(resolve) 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/core/context_inspection.py b/src/toad/core/context_inspection.py index bda453d71..bdbcee116 100644 --- a/src/toad/core/context_inspection.py +++ b/src/toad/core/context_inspection.py @@ -15,7 +15,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.importing import ImportedSessionMetadata from metaclass_registry.mro_dispatch import handles -from agent_comms.native_turn_context import NativeContextData +from agent_comms.pi_payloads import NativeContextData from agent_comms.pi_payloads import PiMessage from agent_comms.presentation import ReadState from agent_comms.registry_document import RegistryEntry @@ -539,7 +539,7 @@ def read( page = comms.contexts.page(owner, comms.registry, cursor=cursor, previous=previous) annotations = WorkingMemoryAnnotations.for_context( - comms.root / "coordination.sqlite3", + comms.registry.store.coordinator_reader, tuple(resource.value for resource in page.resources), JevClassifier.version()) imported = ImportedSessionMetadata.sources_for_owner(comms.registry, thread) inspection_type = ThreadInspectionSource().project_value(entry.status) @@ -622,7 +622,7 @@ async def _request(self, action: str, **parameters: JsonValue | tuple[int, ...]) if owner.incarnation != self.owner.incarnation: raise ValueError("Selected context thread incarnation changed") process = owner.require_process() - connection = RuntimeConnection(self.service, owner.name, socket_path(self.service.root, process.pid)) + connection = RuntimeConnection(self.service, owner.incarnation, socket_path(self.service.root, process.pid)) try: return await connection.request(action, **parameters) finally: 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/neovim_editing.py b/src/toad/neovim_editing.py index f51cbc259..6e33ce90c 100644 --- a/src/toad/neovim_editing.py +++ b/src/toad/neovim_editing.py @@ -27,6 +27,7 @@ from textual.keys import key_to_character from textual.message import Message from textual.widgets import TextArea +from toad.neovim_runtime import Neovim class NeovimKey(DeclaredFamily, affix="Key"): @@ -873,7 +874,7 @@ async def _run(self) -> None: try: async with AsyncExitStack() as cleanup: async def acquire_child() -> AttachedChild: - child = await AttachedChild.start(("nvim", "--embed", "--clean", "-n")) + child = await AttachedChild.start(Neovim().command()) cleanup.push_async_callback(child.stop) return child diff --git a/src/toad/neovim_runtime.py b/src/toad/neovim_runtime.py new file mode 100644 index 000000000..47ef01664 --- /dev/null +++ b/src/toad/neovim_runtime.py @@ -0,0 +1,219 @@ +"""The pinned Neovim artifact, prepared before publication rather than on input.""" + +from __future__ import annotations + +import errno +import hashlib +import os +import platform +import stat +import sys +import tarfile +from dataclasses import dataclass, field +from functools import cached_property +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory +from typing import ClassVar +from urllib.request import urlopen + +from agent_comms.declared_family import DeclaredFamily +from agent_comms.native_build_inputs import Executable +from agent_comms.runtime_preparation import RuntimePreparation +from toad.platform import LinuxPlatform, MacPlatform, Platform, host + + +class NeovimUnavailable(OSError): + """The declared artifact is missing, unsupported or fails verification.""" + + +class Architecture(DeclaredFamily, affix="Architecture"): + """Decode external machine spellings once into release architecture identity.""" + + tag: ClassVar[str] + aliases: ClassVar[tuple[str, ...]] = () + + @classmethod + def decode(cls, machine: str) -> type[Architecture]: + spelling = machine.casefold() + for member in cls.members_with(Architecture): + if spelling in (member.tag, *member.aliases): + return member + raise NeovimUnavailable(f"No declared Neovim architecture for {machine!r}") + + +class X64Architecture(Architecture): + tag = "x86_64" + aliases = ("amd64",) + + +class Arm64Architecture(Architecture): + tag = "arm64" + aliases = ("aarch64",) + + +@dataclass(frozen=True) +class ReleaseArchive: + """Trusted release metadata, not a checksum fetched beside an untrusted download.""" + + platform: type[Platform] + architecture: type[Architecture] + size: int + sha256: bytes + + @property + def name(self) -> str: + return f"{Neovim.program}-{self.platform.key}-{self.architecture.tag}.tar.gz" + + @property + def directory(self) -> str: + return self.name.removesuffix(".tar.gz") + + def verify(self, path: Path) -> None: + if path.stat().st_size != self.size: + raise NeovimUnavailable(f"Neovim archive size mismatch: {path}") + with path.open("rb") as stream: + actual = hashlib.file_digest(stream, "sha256").digest() + if actual != self.sha256: + raise NeovimUnavailable(f"Neovim archive SHA-256 mismatch: {path}") + + def download(self, url: str, destination: Path) -> None: + remaining = self.size + with urlopen(url, timeout=30) as response, destination.open("xb") as output: + while block := response.read(min(1024 * 1024, remaining + 1)): + remaining -= len(block) + if remaining < 0: + raise NeovimUnavailable(f"Neovim archive exceeds declared size: {url}") + output.write(block) + self.verify(destination) + + +@dataclass(frozen=True) +class Neovim(RuntimePreparation, Executable): + """One release declaration supplies preparation, runtime launch and CI.""" + + program: ClassVar[str] = "nvim" + version: ClassVar[str] = "0.12.4" + executable_mode: ClassVar[int] = 0o755 + # Official neovim/neovim v0.12.4 release metadata, retained by P928. + # These are external artifact records, not a roster of editing-mode members. + archives: ClassVar[tuple[ReleaseArchive, ...]] = ( + ReleaseArchive(LinuxPlatform, Arm64Architecture, 11353219, bytes.fromhex( + "ceb7e88c6b681f0515d135dcdfad54f5eb4373b25ce6172197cd9a69c758063f")), + ReleaseArchive(LinuxPlatform, X64Architecture, 11404178, bytes.fromhex( + "012bf3fcac5ade43914df3f174668bf64d05e049a4f032a388c027b1ebd78628")), + ReleaseArchive(MacPlatform, Arm64Architecture, 9630250, bytes.fromhex( + "51ab83afa66d663627c2ab1be43209b0f4e81360d4598b53efaa4d8195f24c89")), + ReleaseArchive(MacPlatform, X64Architecture, 9896697, bytes.fromhex( + "03fe16f8dd9f1e9eaf52d5e294913a39917b9e2faea30d7fb0fb385fbd36fe59")), + ) + + prefix: Path = Path(sys.prefix) + system: type[Platform] = host + architecture: type[Architecture] = field( + default_factory=lambda: Architecture.decode(platform.machine()) + ) + + @cached_property + def archive(self) -> ReleaseArchive: + for archive in self.archives: + if (archive.platform, archive.architecture) == (self.system, self.architecture): + return archive + raise NeovimUnavailable( + f"Neovim {self.version} has no declared asset for " + f"{self.system.system}/{self.architecture.tag}" + ) + + @property + def root(self) -> Path: + return self.prefix / "share" / "toad" / "neovim" / self.version / self.archive.directory + + @property + def payload(self) -> Path: + return self.root / self.archive.name + + @property + def executable(self) -> Path: + path = self.root / self.archive.directory / "bin" / self.program + try: + info = path.lstat() + except FileNotFoundError as error: + raise NeovimUnavailable( + f"Neovim {self.version} is not prepared at {path}; " + "run target-runtime preparation before selecting the native editor" + ) from error + if (not stat.S_ISREG(info.st_mode) + or stat.S_IMODE(info.st_mode) != self.executable_mode): + raise NeovimUnavailable(f"Neovim executable has unsafe type or permissions: {path}") + return path + + def command(self) -> tuple[str, ...]: + """No PATH discovery, download or alternate editor on the runtime path.""" + return str(self.executable), "--embed", "--clean", "-n" + + def verify(self) -> None: + """The trusted archive supplies every extracted file and link's expected answer.""" + self.archive.verify(self.payload) + expected_paths = {self.payload} + with tarfile.open(self.payload, "r:gz") as archive: + for member in archive: + parts = PurePosixPath(member.name).parts + if not parts or parts[0] != self.archive.directory: + raise NeovimUnavailable(f"Unexpected Neovim archive root: {member.name}") + tarfile.data_filter(member, str(self.root)) + relative = Path(member.name) + path = self.root / relative + expected_paths.add(path) + expected_paths.update( + self.root / parent for parent in relative.parents if parent != Path(".") + ) + if member.issym(): + if not path.is_symlink() or os.readlink(path) != member.linkname: + raise NeovimUnavailable(f"Neovim link differs from archive: {path}") + elif member.isfile() or member.islnk(): + if path.is_symlink() or not path.is_file(): + raise NeovimUnavailable(f"Neovim file missing or replaced: {path}") + expected = archive.extractfile(member) + if expected is None: + raise NeovimUnavailable(f"Neovim archive file has no payload: {member.name}") + with expected, path.open("rb") as actual: + if (hashlib.file_digest(expected, "sha256").digest() + != hashlib.file_digest(actual, "sha256").digest()): + raise NeovimUnavailable(f"Neovim file differs from archive: {path}") + elif member.isdir(): + if path.is_symlink() or not path.is_dir(): + raise NeovimUnavailable(f"Neovim directory missing or replaced: {path}") + else: + raise NeovimUnavailable(f"Unsupported Neovim archive member: {member.name}") + if set(self.root.rglob("*")) != expected_paths: + raise NeovimUnavailable(f"Neovim tree contains undeclared or missing paths: {self.root}") + self.executable + + def prepare(self) -> None: + """Filesystem admission is separate from use; only a completed tree is published.""" + if self.root.exists(): + self.verify() + return + self.root.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with TemporaryDirectory(prefix=".neovim-", dir=self.root.parent) as temporary: + staged = Neovim(Path(temporary), self.system, self.architecture) + staged.root.mkdir(mode=0o700, parents=True) + url = (f"https://github.com/neovim/neovim/releases/download/" + f"v{self.version}/{self.archive.name}") + self.archive.download(url, staged.payload) + with tarfile.open(staged.payload, "r:gz") as archive: + archive.extractall(staged.root, filter="data") + staged.executable.chmod(self.executable_mode) + staged.verify() + try: + os.rename(staged.root, self.root) + except OSError as error: + if error.errno not in (errno.EEXIST, errno.ENOTEMPTY): + raise + # A concurrent preparer may publish the same complete artifact first. + self.verify() + + +if __name__ == "__main__": + requirement = Neovim() + requirement.prepare() + print(requirement.executable) diff --git a/src/toad/owner_preparation.py b/src/toad/owner_preparation.py index 559d689f2..6e1e41886 100644 --- a/src/toad/owner_preparation.py +++ b/src/toad/owner_preparation.py @@ -62,18 +62,6 @@ def from_binding(cls, binding: ThreadOwnerBinding) -> OwnerPlan: return cls().project_value(binding) -@dataclass(frozen=True, slots=True) -class OwnerRequestContext: - """Supply the core proxy's service contract without capturing a UI widget. - - The proxy still resolves and validates the live owner before sending. This - object supplies only the revision-aware reader, never cached goal/delivery - state or an alternative authority for request routing. - """ - - _comms: Comms - - def read_thread_presentation(comms: Comms, name: str) -> ThreadPresentation: """Read the existing core presentation on a worker, without UI dependencies.""" return comms.views.thread_presentations((name,)).read(name) 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/widget_actions.py b/src/toad/widget_actions.py index aaefaa9f5..15c0bf34a 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -10,13 +10,13 @@ from typing import ClassVar, Generic, Literal, Self, TypeVar from agent_comms.command import Command -from agent_comms.declared_family import DeclaredFamily +from agent_comms.declared_family import DeclaredFamily, FamilyMeta from agent_comms.field_codec import FieldCodec, FieldPlan from textual.binding import Binding, BindingsMap, CommandBinding, ModeBinding from textual import events from textual._activation import PrimaryActivation from textual._callback import invoke -from textual.keys import KeyDeclaration +from textual.keys import KeyDeclaration, KeyDeclarationType from textual.widgets.text_area import EditingMode from textual.dom import DOMNode from toad.app_node import ToadNode @@ -26,7 +26,12 @@ Context = TypeVar("Context", bound=DOMNode) -class Action(KeyDeclaration[Context], Command, DeclaredFamily, Generic[Context], affix="Action"): +class KeyFamilyMeta(FamilyMeta, KeyDeclarationType): + """A named command family whose classes declare native key overlap.""" + + +class Action(KeyDeclaration[Context], Command, DeclaredFamily, Generic[Context], + metaclass=KeyFamilyMeta, affix="Action"): """Only the Textual boundary decodes an external action name. A member declares its keys, and its title and docstring are the only text any view shows for it.""" 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_sidebar.py b/src/toad/widgets/comms_sidebar.py index bdcda4ff5..c06d93786 100644 --- a/src/toad/widgets/comms_sidebar.py +++ b/src/toad/widgets/comms_sidebar.py @@ -97,8 +97,8 @@ def __init__(self, row: CommsRow, *, expanded: bool): def sidebar(self) -> CommsSidebar: return self.query_ancestor(CommsSidebar) - def present(self, channel: ChannelRowModel) -> None: - """Paint the channel header from its model row.""" + def present(self, channel: ChannelRowModel) -> frozenset[str]: + """Paint the header and return members whose channel pin changed.""" previous, self.channel = self.channel, channel row = self.row if previous.label != channel.label: @@ -113,6 +113,7 @@ def present(self, channel: ChannelRowModel) -> None: self.unread_badge.update(label, layout=width_changed) self.unread_badge.display = bool(label) self.sort_control.update_order(channel.order) + return previous.pinned_members ^ channel.pinned_members def toggle_members(self) -> None: super().toggle_members() 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/history_anchor.py b/src/toad/widgets/history_anchor.py index 0ddd42cd7..3cfc803cb 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -641,8 +641,6 @@ def jump_to_latest(self) -> None: history.request_latest(latest) MODE_BINDINGS = ( - ModeBinding("j", action_scroll_down, "Scroll down", mode=VimMode, show=False), - ModeBinding("k", action_scroll_up, "Scroll up", mode=VimMode, show=False), ModeBinding("ctrl+d", action_page_down, "Page down", mode=VimMode, show=False), ModeBinding("ctrl+u", action_page_up, "Page up", mode=VimMode, show=False), ModeBinding("G", jump_to_latest, "Latest", mode=VimMode, show=False), diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index d5a2fe5d5..c78658cda 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -10,6 +10,7 @@ from textual.app import ComposeResult from textual.content import Content from textual.widgets import Collapsible +from textual.worker import Worker from agent_comms.ui_model.observation import ReadNotifications @@ -81,6 +82,11 @@ def start(self, marker: VisibleReadMarking) -> None: class NothingToRead(PaintedRead): + is_finished = True + + async def wait(self) -> None: + pass + def start(self, marker: VisibleReadMarking) -> None: pass @@ -90,18 +96,18 @@ class ReadWork(PaintedRead): work: Awaitable[None] def start(self, marker: VisibleReadMarking) -> None: - marker._read_worker = marker.run_worker(self.work, group="painted-read") + marker.read_worker = marker.run_worker(self.work, group="painted-read") class VisibleReadMarking: """One painted-read operation at a time, for pagers and native/CLI sessions.""" def __init__(self, *args, **kwargs): - self._read_worker = None + self.read_worker: Worker[None] | NothingToRead = NothingToRead() super().__init__(*args, **kwargs) def mark_visible(self) -> None: - if self._read_worker is not None and not self._read_worker.is_finished: + if not self.read_worker.is_finished: return self.read_visible().start(self) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 7342ad0ba..ab8cecc49 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -221,6 +221,22 @@ def reconcile_keys(self, keys: Iterable[KeyT], *, changed: frozenset[KeyT]): return ordered +class NoWorker: + """No work was scheduled; queries need no absence branch or native task.""" + + @property + def is_finished(self) -> bool: + return True + + @property + def is_running(self) -> bool: + return False + + @property + def error(self) -> None: + return None + + class SidebarGroup[RowT: "ListedRow"](KeyedRows[str], VerticalGroup): """A common header/disclosure and optional bounded member viewport.""" @@ -239,8 +255,8 @@ class SidebarGroup[RowT: "ListedRow"](KeyedRows[str], VerticalGroup): _rows_pending = False """A reconciliation ran out of its step and left rows unmounted.""" - _row_mounting: Worker[None] | None = None - """The worker mounting pending rows after the following frames.""" + _row_mounting: Worker[None] | NoWorker = NoWorker() + """The pending-row worker, or unscheduled work answering the same queries.""" def __init__( self, @@ -390,9 +406,7 @@ def reconcile_rows(self, *, changed: frozenset[str]): self._rows_pending = False self._row_deadline = perf_counter_ns() + self.screen.LAYOUT_STEP_NS ordered = self.reconcile_keys(keys, changed=changed) - if self._rows_pending and ( - self._row_mounting is None or self._row_mounting.is_finished - ): + if self._rows_pending and self._row_mounting.is_finished: self._row_mounting = self.run_worker( self._mount_pending_rows(), group="sidebar-rows" ) @@ -459,8 +473,7 @@ def apply_comms(self, sidebar: CommsSidebar, rows: Mapping[str, ChannelRowModel] for name in self.names: group = sidebar.groups.get(name) if group is not None: - group.present(rows[name]) - group.sync_members(frozenset(group.member_keys)) + group.sync_members(group.present(rows[name])) def apply_welcome(self, channel_list: SidebarGroup, thread_list: SidebarGroup) -> None: channel_list.sync_members(self.names) diff --git a/src/toad/widgets/terminal.py b/src/toad/widgets/terminal.py index 37d489e49..537fa941e 100644 --- a/src/toad/widgets/terminal.py +++ b/src/toad/widgets/terminal.py @@ -17,6 +17,7 @@ from textual.style import Style from textual.visual import Visual from textual.geometry import Size +from textual.document._editing import NativeTextKeys from textual.scroll_view import ScrollView from textual._paint_state import PaintState from textual.strip import Strip @@ -29,7 +30,7 @@ ESCAPE_TAP_DURATION = 400 / 1000 -class Terminal(ConversationBlock, TerminalStateProjection, ScrollView, can_focus=True): +class Terminal(NativeTextKeys, ConversationBlock, TerminalStateProjection, ScrollView, can_focus=True): BINDING_GROUP_TITLE = "Terminal" HELP = """\ ## Terminal @@ -369,11 +370,6 @@ async def _reset_escaping(self) -> None: await self.write_process_stdin(self.state.key_escape()) self._escaping = False - @property - def allows_application_commands(self) -> bool: - # Focused terminal input belongs to the child, not workspace shortcuts. - return False - def check_consume_key(self, key: str, character: str | None = None) -> bool: # Escape belongs to the child process / double-tap blur, not to an # ancestor's priority jump, cancellation or unmaximize binding. 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..04505e2ea 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 @@ -323,8 +325,8 @@ def read(self, request: PageRequest[WireCut], through: AfterSequence) -> Prepare page = request.end.wire_page(self, request.cursor, through) shown = MessageShown(style=self.style) fragments = tuple( - WireFragment.of(message, shown=shown, evidence=page.evidence_for(message), reader=self.me) - for message in page.messages + WireFragment.of(message, shown=shown, evidence=evidence, reader=self.me) + for message, evidence in page.message_evidence() ) wire = WirePage( page.messages, @@ -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/app/test_channel_display_basis.py b/tests/app/test_channel_display_basis.py index 925a25dc8..91ba1b4ca 100644 --- a/tests/app/test_channel_display_basis.py +++ b/tests/app/test_channel_display_basis.py @@ -1,5 +1,7 @@ """A channel marks read exactly the messages whose text it showed, never the history it left out.""" +from agent_comms.read_basis import ChannelConversation from agent_comms.repo import Checkout +from agent_comms.ui_model.unread import ExactUnread async def test_shown_messages_are_read_exactly_and_hidden_expansion_stays_unread(workspace): @@ -13,8 +15,9 @@ async def test_shown_messages_are_read_exactly_and_hidden_expansion_stays_unread messages = [comms.messaging.send("alice", "#team", "\n".join(f"channel {i:02d} line" for _ in range(12))).message for i in range(12)] - def seen(): - return comms.bus.reads.seen_sequences(viewer, comms.registry.snapshot()) + ledger = comms.bus.reads + snapshot = comms.registry.snapshot() + channel = ChannelConversation("#team") async with workspace.app(size=(120, 40)) as ui: owner = ui.selected_tab @@ -24,14 +27,21 @@ def seen(): def shown(): return {message.seq for i, message in enumerate(messages) if ui.shows(f"channel {i:02d} line")} - await ui.until(lambda: seen() == shown(), why="the shown messages to be marked read") + await ui.until(lambda: all( + (message.seq in ledger.cursor(viewer, snapshot).for_conversation(channel)) + == (message.seq in shown()) for message in messages + ), why="the shown messages to be marked read") # Earlier messages were read from Core for scrolling, but never shown. assert 0 < len(shown()) < len(messages) and not ui.shows("channel 00 line") - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == len(messages) - len(shown()) - assert hidden.seq not in seen() + assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == ExactUnread( + count=len(messages) - len(shown())) + assert hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) await ui.select_tab(owner) comms.channels.set_channel_any_mode("#team", True) comms.channels.update_tags("bob", add=frozenset({"team"})) # Expanding a hidden tab's view never manufactures read facts. - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] >= len(messages) - len(shown()) + 1 - assert hidden.seq not in seen() + assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == ExactUnread( + count=len(messages) - len(shown()) + 1) + assert hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) diff --git a/tests/app/test_channel_scope_expansion.py b/tests/app/test_channel_scope_expansion.py index b5adb5890..ce7417d8a 100644 --- a/tests/app/test_channel_scope_expansion.py +++ b/tests/app/test_channel_scope_expansion.py @@ -1,5 +1,7 @@ """A mounted any-mode channel reveals older messages entering its scope.""" +from agent_comms.repo import Checkout + async def test_mounted_any_mode_channel_reveals_older_messages_entering_its_scope(workspace): for name, tags in (("alice", ("team",)), ("bob", ()), ("carol", ()), ("dave", ())): @@ -8,7 +10,7 @@ async def test_mounted_any_mode_channel_reveals_older_messages_entering_its_scop comms.messaging.send("carol", "dave", "older newly visible DM") comms.messaging.send("alice", "#team", "current channel message") comms.channels.set_channel_any_mode("#team", True) - assert [m.body for m in comms.views.channel_display_page("#team", worktree=str(workspace.project)).messages] == [ + assert [m.body for m in comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(workspace.project))).messages] == [ "current channel message"] async with workspace.app(size=(100, 30)) as ui: diff --git a/tests/app/test_unread_clears_on_opening.py b/tests/app/test_unread_clears_on_opening.py index b457b6c0d..d327e926e 100644 --- a/tests/app/test_unread_clears_on_opening.py +++ b/tests/app/test_unread_clears_on_opening.py @@ -1,5 +1,6 @@ """A displayed channel page clears its own unread rows after every navigation.""" from agent_comms.repo import Checkout +from agent_comms.ui_model.unread import ExactUnread def unread_badge_gone(ui, identity): @@ -11,23 +12,24 @@ async def test_channel_unread_clears_on_opening_and_on_return_to_the_open_tab(wo comms = workspace.comms comms.messaging.send("sender", "#all", "first-pending") - def unread() -> int: - return comms.views.viewer_snapshot(Checkout(workspace.project)).channel_unread["#all"] - - assert unread() == 1 + assert comms.views.viewer_snapshot(Checkout(workspace.project)).channel_unread["#all"] == ExactUnread(count=1) async with workspace.app(size=(110, 36)) as ui: owner = ui.selected_tab channel = await ui.open_channel("#all", actor=workspace.viewer) await ui.until_shows("first-pending", seconds=5) - await ui.until(lambda: unread() == 0, seconds=5, why="the channel to be read") + await ui.until(lambda: comms.views.viewer_snapshot( + Checkout(workspace.project)).channel_unread["#all"] == ExactUnread(), + seconds=5, why="the channel to be read") await ui.until(lambda: unread_badge_gone(ui, channel), seconds=5, why="the unread badge to clear") await ui.select_tab(owner) comms.messaging.send("sender", "#all", "second-pending") - assert unread() == 1 + assert comms.views.viewer_snapshot(Checkout(workspace.project)).channel_unread["#all"] == ExactUnread(count=1) await ui.select_tab(channel) await ui.until_shows("second-pending", seconds=5) - await ui.until(lambda: unread() == 0, seconds=5, why="the returned channel to be read") + await ui.until(lambda: comms.views.viewer_snapshot( + Checkout(workspace.project)).channel_unread["#all"] == ExactUnread(), + seconds=5, why="the returned channel to be read") await ui.until(lambda: unread_badge_gone(ui, channel), seconds=5, why="the unread badge to clear on return") @@ -35,16 +37,15 @@ async def test_direct_message_unread_clears_on_opening(workspace): workspace.thread("sender") comms = workspace.comms - def unread() -> int: - return comms.views.viewer_snapshot(Checkout(workspace.project)).unread.get("sender", 0) - async with workspace.app(size=(110, 36)) as ui: owner = ui.selected_tab comms.messaging.send("sender", workspace.viewer, "dm-pending") - assert unread() == 1 + assert comms.views.viewer_snapshot(Checkout(workspace.project)).unread["sender"] == ExactUnread(count=1) direct = await ui.open_direct("sender", actor=workspace.viewer, owner=owner) await ui.until_shows("dm-pending", seconds=5) - await ui.until(lambda: unread() == 0, seconds=5, why="the direct messages to be read") + await ui.until(lambda: comms.views.viewer_snapshot( + Checkout(workspace.project)).unread["sender"] == ExactUnread(), + seconds=5, why="the direct messages to be read") await ui.until(lambda: unread_badge_gone(ui, direct), seconds=5, why="the unread badge to clear") @@ -52,14 +53,13 @@ async def test_aggregate_feed_unread_clears_on_opening(workspace): workspace.thread("sender") comms = workspace.comms - def unread() -> int: - return comms.views.viewer_snapshot(Checkout(workspace.project)).channel_unread.get("#any", 0) - async with workspace.app(size=(110, 36)) as ui: owner = ui.selected_tab comms.messaging.send("sender", "#all", "aggregate-pending") - assert unread() >= 1 + assert comms.views.viewer_snapshot(Checkout(workspace.project)).channel_unread["#any"] == ExactUnread(count=1) feed = await ui.open_channel("#any", actor=workspace.viewer, owner=owner) await ui.until_shows("aggregate-pending", seconds=5) - await ui.until(lambda: unread() == 0, seconds=5, why="the feed to be read") + await ui.until(lambda: comms.views.viewer_snapshot( + Checkout(workspace.project)).channel_unread["#any"] == ExactUnread(), + seconds=5, why="the feed to be read") await ui.until(lambda: unread_badge_gone(ui, feed), seconds=5, why="the unread badge to clear") 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/model/test_context_inspection_source.py b/tests/model/test_context_inspection_source.py index 8a236c27c..7be1ebe88 100644 --- a/tests/model/test_context_inspection_source.py +++ b/tests/model/test_context_inspection_source.py @@ -2,14 +2,14 @@ import json -from agent_comms.field_codec import FieldCodec -from agent_comms.pi_payloads import AssistantMessage, TextContent, ToolCallContent +from agent_comms.field_codec import FieldCodec, ToolCallId +from agent_comms.pi_payloads import AssistantMessage, ReportedParts, TextContent, ToolCallContent from toad.core.context_inspection import NativeMessageNode def test_native_message_text_includes_its_tool_calls(): - call = ToolCallContent(id="call-1", name="read", arguments={"path": "notes.md"}) - message = AssistantMessage(content=(TextContent(text="Reading the notes."), call)) + call = ToolCallContent(id=ToolCallId("call-1"), name="read", arguments={"path": "notes.md"}) + message = AssistantMessage(content=ReportedParts((TextContent(text="Reading the notes."), call))) node = NativeMessageNode("native/0", None, None, message, 0) said, called = node.public_text().split("\n", 1) diff --git a/tests/model/test_neovim_preparation.py b/tests/model/test_neovim_preparation.py new file mode 100644 index 000000000..40f2dd176 --- /dev/null +++ b/tests/model/test_neovim_preparation.py @@ -0,0 +1,94 @@ +"""Real release controls; only the integration owner executes this selection.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest +from toad.neovim_runtime import ( + Neovim, NeovimUnavailable, ReleaseArchive, X64Architecture, +) +from toad.platform import WindowsPlatform + + +@pytest.mark.parametrize("archive", Neovim.archives) +def test_release_selection_uses_the_declared_pair(archive: ReleaseArchive, tmp_path: Path): + requirement = Neovim(tmp_path, archive.platform, archive.architecture) + assert requirement.archive is archive + assert not requirement.root.exists() + + +def test_unsupported_pair_refuses_without_preparation(tmp_path: Path): + requirement = Neovim(tmp_path, WindowsPlatform, X64Architecture) + with pytest.raises(NeovimUnavailable): + requirement.command() + assert not (tmp_path / "share").exists() + + +@pytest.fixture(scope="module") +def prepared_neovim(tmp_path_factory: pytest.TempPathFactory) -> Neovim: + requirement = Neovim(tmp_path_factory.mktemp("neovim-release")) + assert not requirement.root.exists() + requirement.prepare() + return requirement + + +def test_first_preparation_runs_the_declared_release(prepared_neovim: Neovim): + requirement = prepared_neovim + output = subprocess.run( + [str(requirement.executable), "--version"], + check=True, capture_output=True, text=True, timeout=30, + ).stdout + assert output.splitlines()[0] == f"NVIM v{requirement.version}" + assert requirement.command() == ( + str(requirement.executable), "--embed", "--clean", "-n" + ) + assert requirement.executable.is_relative_to(requirement.prefix) + + +def test_reusable_artifact_is_verified_without_republication(prepared_neovim: Neovim): + paths = (prepared_neovim.payload, prepared_neovim.executable) + before = tuple((path.stat().st_ino, path.stat().st_mtime_ns) for path in paths) + prepared_neovim.prepare() + assert tuple((path.stat().st_ino, path.stat().st_mtime_ns) for path in paths) == before + + +def test_corrupt_archive_is_refused_without_replacement(tmp_path: Path): + requirement = Neovim(tmp_path) + requirement.root.mkdir(parents=True) + requirement.payload.write_bytes(b"unfinished or corrupted release") + with pytest.raises(NeovimUnavailable): + requirement.prepare() + assert requirement.payload.read_bytes() == b"unfinished or corrupted release" + + +def test_corrupt_extracted_executable_is_refused( + prepared_neovim: Neovim, tmp_path: Path, +): + requirement = Neovim(tmp_path) + shutil.copytree(prepared_neovim.root, requirement.root, symlinks=True) + requirement.executable.write_bytes(b"not the declared Neovim executable") + with pytest.raises(NeovimUnavailable): + requirement.prepare() + assert requirement.executable.read_bytes() == b"not the declared Neovim executable" + + +def test_same_size_archive_corruption_is_refused( + prepared_neovim: Neovim, tmp_path: Path, +): + requirement = Neovim(tmp_path) + requirement.root.mkdir(parents=True) + shutil.copyfile(prepared_neovim.payload, requirement.payload) + with requirement.payload.open("r+b") as stream: + original = stream.read(1) + stream.seek(0) + stream.write(bytes((original[0] ^ 1,))) + with pytest.raises(NeovimUnavailable): + requirement.prepare() + + +def test_runtime_does_not_prepare_or_search_path(tmp_path: Path): + requirement = Neovim(tmp_path) + with pytest.raises(NeovimUnavailable): + requirement.command() + assert not requirement.root.exists() diff --git a/tests/model/test_permission_presentation_selection.py b/tests/model/test_permission_presentation_selection.py index b26245b19..9d403f16c 100644 --- a/tests/model/test_permission_presentation_selection.py +++ b/tests/model/test_permission_presentation_selection.py @@ -9,7 +9,7 @@ class ProbePermissionPresentation(PermissionPresentation): priority = 1 @classmethod - def admit(cls, kind, title, content): + def for_request(cls, kind, title, content): return cls(title) if kind == "read" else None diff --git a/tests/model/test_project_links.py b/tests/model/test_project_links.py index 45f6cd6c7..f53405946 100644 --- a/tests/model/test_project_links.py +++ b/tests/model/test_project_links.py @@ -23,9 +23,9 @@ def decode(root, href): def test_absolute_and_relative_spellings_name_the_same_existing_file(project): root, plan = project - assert decode(root, str(plan)) == DirectProjectFileLink(plan) - assert decode(root, "plans/tag-channel-view-plan.md") == DirectProjectFileLink(plan) - assert decode(root, plan.as_uri()) == DirectProjectFileLink(plan) + assert decode(root, str(plan)) == DirectProjectFileLink(plan, original_root=root) + assert decode(root, "plans/tag-channel-view-plan.md") == DirectProjectFileLink(plan, original_root=root) + assert decode(root, plan.as_uri()) == DirectProjectFileLink(plan, original_root=root) def test_web_addresses_stay_external(project): @@ -36,8 +36,8 @@ def test_web_addresses_stay_external(project): def test_a_bare_file_name_that_is_not_at_the_root_is_searched_for(project): root, _ = project - assert decode(root, "missing.py") == SearchProjectFileLink("missing.py") - assert decode(root, "plans/missing.md") == MissingProjectFileLink("plans/missing.md") + assert decode(root, "missing.py") == SearchProjectFileLink("missing.py", original_root=root) + assert decode(root, "plans/missing.md") == MissingProjectFileLink("plans/missing.md", original_root=root) async def test_a_unique_file_name_resolves_to_its_nested_path(project): @@ -46,9 +46,9 @@ async def test_a_unique_file_name_resolves_to_its_nested_path(project): nested.parent.mkdir(parents=True) nested.write_text("# NESTED-DECLARATIONS-MARKER\n") link = decode(root, "declarations.py") - assert link == SearchProjectFileLink("declarations.py") + assert link == SearchProjectFileLink("declarations.py", original_root=root) assert await link.resolve(root) == nested - assert decode(root, f"{nested}") == DirectProjectFileLink(nested) + assert decode(root, f"{nested}") == DirectProjectFileLink(nested, original_root=root) async def test_a_file_name_found_twice_is_ambiguous_and_a_file_name_found_nowhere_is_missing(project): @@ -57,7 +57,7 @@ async def test_a_file_name_found_twice_is_ambiguous_and_a_file_name_found_nowher (root / place).mkdir(parents=True, exist_ok=True) (root / place / "declarations.py").write_text("# declaration\n") with pytest.raises(AmbiguousProjectFile) as ambiguous: - await SearchProjectFileLink("declarations.py").resolve(root) + await SearchProjectFileLink("declarations.py", original_root=root).resolve(root) assert "Several files named declarations.py" in ambiguous.value.notice with pytest.raises(ProjectLinkProblem): - await SearchProjectFileLink("absent.py").resolve(root) + await SearchProjectFileLink("absent.py", original_root=root).resolve(root) diff --git a/tests/model/test_render_processes.py b/tests/model/test_render_processes.py index 9cc8a04df..015f938c2 100644 --- a/tests/model/test_render_processes.py +++ b/tests/model/test_render_processes.py @@ -12,6 +12,7 @@ import pytest from agent_comms.launch_runtime import ThreadLaunch +from agent_comms.repo import Checkout from toad.render_processes import RenderProcessPool @@ -77,7 +78,7 @@ async def test_workers_are_spawned_apart_from_the_parent_and_errors_propagate(mo async with pools() as make: pool = make() assert pool._executor is None - launch = ThreadLaunch("pilot-only", "/pilot-only", frozenset({"pilot"}), managed=True) + launch = ThreadLaunch("pilot-only", Checkout(Path("/pilot-only")), frozenset({"pilot"}), managed=True) for name, value in launch.encode().items(): monkeypatch.setenv(name, value) assert ThreadLaunch.inherited(os.environ) == launch.encode() diff --git a/tests/model/test_session_observation_cancellation.py b/tests/model/test_session_observation_cancellation.py index 07f1890f6..891f5505d 100644 --- a/tests/model/test_session_observation_cancellation.py +++ b/tests/model/test_session_observation_cancellation.py @@ -1,9 +1,11 @@ """A retired observation read cancels neither its caller nor the callback that awaits it.""" import asyncio +from pathlib import Path import pytest +from toad.agent import AgentBase from toad.session_observation import SessionObservation @@ -12,9 +14,18 @@ async def reached(event): await event.wait() +class ReadAgent(AgentBase): + """A bound agent that only serves observation reads; it is never prompted.""" + + async def send_prompt(self, prompt): + raise AssertionError("observation reads never prompt the agent") + + class ReadSource: is_attached = True - agent = object() + + def __init__(self): + self.agent_binding = ReadAgent(Path(__file__).parent) class WaitingObservation(SessionObservation): diff --git a/tests/model/test_usage_reports.py b/tests/model/test_usage_reports.py index b81365c2a..40dcd09dc 100644 --- a/tests/model/test_usage_reports.py +++ b/tests/model/test_usage_reports.py @@ -7,15 +7,17 @@ import toad from toad.application_lifetime import UsageReports +from toad.terminal_attention import TerminalAttention -async def test_the_tls_context_is_built_once(): +async def test_the_tls_context_is_built_once(monkeypatch): + monkeypatch.setenv("TERM_PROGRAM", "st") async def run_thread(function, *args): return function(*args) app = SimpleNamespace( preparation=SimpleNamespace(run_thread=run_thread), size=(80, 24), - settings=SimpleNamespace(anon_id="anon"), terminal_attention=SimpleNamespace(program="st"), + settings=SimpleNamespace(anon_id="anon"), terminal_attention=TerminalAttention, ) reports = UsageReports(app) clients = [] 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/overlapping_annotations.py b/tests/support/overlapping_annotations.py index 3b276ed18..738983549 100644 --- a/tests/support/overlapping_annotations.py +++ b/tests/support/overlapping_annotations.py @@ -3,15 +3,13 @@ from __future__ import annotations from agent_comms.repo import Checkout -from agent_comms.active_route import resolve_comms_route from dataclasses import dataclass, replace from pathlib import Path -from agent_comms.comms import Comms -from agent_comms.session_revision import SessionRevision +from agent_comms.context_store import AbsentContext, ContextManifestPage +from agent_comms.session_revision import NoSessionObservation from agent_comms.thread_identity import RecordedThreadIncarnation, TurnId, TurnIdentity -from agent_comms.registration import Registration from agent_comms.turn_context import ( ContextManifest, ContextSpan, ContributionCoordinates, RecordedContextTurn, UnattributedProvenance, UserInputSegment, @@ -22,6 +20,7 @@ from agent_comms.working_memory_questions import CommitmentSpan, KindQuestion, OtherSpan, RuleSpan from agent_comms.threads import Thread from toad.core.context_inspection import ContextInspection +from support.roots import installed_wire @dataclass(frozen=True) @@ -38,8 +37,9 @@ class OverlappingAnnotations: def overlapping_annotations(directory: Path) -> OverlappingAnnotations: """A short and a long annotation over one segment, as an inspection shows them, and the same after correcting the short one.""" - owner = Thread( - "authored-navigation", frozenset(), Checkout(directory), created_at=1.0) + service = installed_wire(directory / "unused-wire") + owner = service.registry.declare(Thread( + "authored-navigation", frozenset(), Checkout(directory), created_at=1.0)) segment = UserInputSegment(content="Keep the original instruction.", provenance=(UnattributedProvenance(),)) observed = segment.manifest(0) @@ -51,13 +51,14 @@ def overlapping_annotations(directory: Path) -> OverlappingAnnotations: classifier, (AnswerProbability(RuleSpan, .7), AnswerProbability(CommitmentSpan, .2), AnswerProbability(OtherSpan, .1)), .8, f"authored-answer-{index}", classifier.pin) for index, span in enumerate(spans)) - # This unsealed model only exercises navigation: no request, no captured - # public text, no wire message, no native owner and no saved session. + # A locally captured context exercises navigation without a native request, + # wire message or saved session. manifest = ContextManifest(owner.incarnation, - RecordedContextTurn(TurnId("authored-unsealed-navigation"), TurnIdentity(owner.incarnation, 1)), + RecordedContextTurn(TurnId.mint(), TurnIdentity(owner.incarnation, 1)), (observed,), "authored-navigation") - inspection = ContextInspection(owner, (manifest,), SessionRevision.observe(None), - Comms(resolve_comms_route(directory / "unused-wire")), labels) + captured = service.contexts.record_after(AbsentContext(), manifest) + inspection = ContextInspection(service.registry.entry(owner.name).entry, + ContextManifestPage((captured,)), NoSessionObservation(), service, labels) ((_, (source,), _),) = inspection.working_memory() short, long = source.children() corrected = HumanLabel.correct(labels[0], RuleSpan, RecordedThreadIncarnation("authored-user", 2.0)) diff --git a/tests/support/shared_stdio.py b/tests/support/shared_stdio.py index a51af95ed..c2b70b759 100644 --- a/tests/support/shared_stdio.py +++ b/tests/support/shared_stdio.py @@ -21,11 +21,12 @@ async def threads_owned_here(comms, project, names=("alpha", "beta")): """Declare `names` as threads of `project`, owned by this process through Core's ACP agent.""" from agent_comms.acp import CommsAgent from agent_comms.field_codec import AcpSessionId + from agent_comms.repo import Checkout owner = CommsAgent(comms, runtime_enabled=True, auto_wake=False) try: for name in names: - comms.threads.claim_thread(name, tags=frozenset({"acp"}), worktree=str(project)) + comms.threads.claim_thread(name, tags=frozenset({"acp"}), worktree=Checkout(project)) comms.registry.restore_stopped(comms.registry.snapshot(), (name,)) thread = comms.owners.acquire_thread(name) await owner.sessions.bind_owned(thread, AcpSessionId(name)) diff --git a/tests/support/textual_steps.py b/tests/support/textual_steps.py index e0f2d2c56..b9b073f12 100644 --- a/tests/support/textual_steps.py +++ b/tests/support/textual_steps.py @@ -96,7 +96,7 @@ def text_rows_of(chat, message) -> range | None: the window is not on them until that layout ends. """ window = chat.window - if window.reader_position is not None or window.screen._stepped_layout is not None: + if not window.reader_position.laid_out(window) or window.screen._stepped_layout is not None: return None for page in chat.message_history.pages: for fragment, first, lines in page._layout: 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_channel_member_mount_lifetime.py b/tests/textual/test_channel_member_mount_lifetime.py index b0c84bb33..f4075416b 100644 --- a/tests/textual/test_channel_member_mount_lifetime.py +++ b/tests/textual/test_channel_member_mount_lifetime.py @@ -23,10 +23,11 @@ async def test_pending_channel_members_stop_when_sidebar_hides_and_resume_when_s # for the next frame; do not fabricate a model or a worker result. screen.LAYOUT_STEP_NS = 0 group.toggle_members() - await ui.until(lambda: group._row_mounting is not None - and group._row_mounting.is_running, + await ui.until(lambda: group._row_mounting.is_running, why='member mounting waiting for its next frame') worker = group._row_mounting + group.sync_members(frozenset()) + assert group._row_mounting is worker sidebar.collapsed = True assert not tree.model_state.shows_model() finally: diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 4886825f5..fd9f75b17 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,6 +1,6 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" -from support.textual_steps import text_rows_of +from agent_comms.read_basis import ChannelConversation async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): @@ -12,40 +12,59 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ viewer = workspace.viewer rows = [comms.messaging.send("peer", "#team", f"row {i}\n" + "body\n" * 10).message for i in range(8)] - def seen(): - return comms.bus.reads.seen_sequences(viewer, comms.registry.snapshot()) + ledger = comms.bus.reads + snapshot = comms.registry.snapshot() + channel = ChannelConversation("#team") async with workspace.app(size=(90, 24)) as ui: app = ui.textual[0] await ui.open_channel("#team", actor=viewer) chat = app.screen.query_one(CommsChatView) history, window = chat.message_history, chat.window - await ui.until(lambda: seen() and not history.marking, why="the first viewport to be marked") + await ui.until(lambda: any(row.seq in ledger.cursor( + viewer, snapshot).for_conversation(channel) for row in rows), + why="the first viewport to be marked") + await history.read_worker.wait() in_view = {fragment.message.seq for fragment in history.read_fragments()} - assert 0 < len(seen()) < len(rows) - assert seen() == in_view - # Messages beyond the first viewport stay unread until their text is scrolled to. + assert 0 < len(in_view) < len(rows) + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all((message.seq in cursor) == (message.seq in in_view) for message in rows) + # Address each original message through the history's own source + # fragments; revealing its head prepares virtualized rows on demand. for message in reversed(rows): - if message.seq in seen(): + if message.seq in ledger.cursor(viewer, snapshot).for_conversation(channel): continue - if text_rows_of(chat, message) is None: - # Earlier messages are laid out as the reader nears them. - window.scroll_to(y=0, animate=False, immediate=True) - await ui.until(lambda: text_rows_of(chat, message) is not None, why=f"row {message.seq} laid out") - window.scroll_to(y=text_rows_of(chat, message).start, animate=False, immediate=True) - await ui.until(lambda: message.seq in seen(), why=f"row {message.seq} to be marked once scrolled to") - assert seen() == {row.seq for row in rows} + fragment = next(part for part in history.fragments + if part.message.view_key == message.view_key) + for _ in rows: + if history.draws_fragment(fragment): + break + window.scroll_page_up(animate=False) + await ui.step() + await ui.until(lambda: window.reader_position.laid_out(window), + why="the scrolled viewport to finish layout") + await history.reveal_message_head(fragment, lambda: True) + history.show_fragment(fragment) + await ui.until(lambda: message.seq in ledger.cursor( + viewer, snapshot).for_conversation(channel), + why=f"row {message.seq} to be marked once scrolled to") + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) # A wholly read history has nothing left to mark: no further worker or route read. - await ui.until(lambda: not history.marking, why="marking to settle") - assert all(fragment.evidence is None for fragment in history.wire_fragments) + await history.read_worker.wait() + assert all(fragment.evidence is None for fragment in history.fragments) + completed = history.read_worker for _ in range(50): history.mark_visible() - assert not history.marking - recorded = tuple(message.view_key for message in history.messages) + assert history.read_worker is completed + window.scroll_end(animate=False, immediate=True) + await ui.until(lambda: ui.shows("row 7"), why="the newest row after returning to the bottom") + recorded = tuple(fragment.message.view_key for fragment in history.fragments) await ui.press("ctrl+t") - await ui.until(lambda: isinstance(history.style, MarkdownMessageStyle), why="the Markdown style") + await ui.until(lambda: isinstance(history.page_source.loader.style, MarkdownMessageStyle), why="the Markdown style") assert ui.shows("row 7") await ui.press("ctrl+t") - await ui.until(lambda: isinstance(history.style, IrcMessageStyle), why="the IRC style") - assert tuple(message.view_key for message in history.messages) == recorded - assert seen() == {row.seq for row in rows} + await ui.until(lambda: isinstance(history.page_source.loader.style, IrcMessageStyle), why="the IRC style") + assert tuple(fragment.message.view_key for fragment in history.fragments) == recorded + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) diff --git a/tests/textual/test_context_tree_keeps_overlapping_annotations.py b/tests/textual/test_context_tree_keeps_overlapping_annotations.py index adb6b4e2e..e7245d7e6 100644 --- a/tests/textual/test_context_tree_keeps_overlapping_annotations.py +++ b/tests/textual/test_context_tree_keeps_overlapping_annotations.py @@ -1,70 +1,70 @@ -"""Textual-facing: the context tree (a Textual Tree widget) selects, scrolls and remounts overlapping annotations without losing the reader's row or position.""" +"""The real context panel keeps mounted navigation and rebinds detail after tree retirement.""" -from textual.app import App -from toad.textual_colours import ThemedApp -from toad.widgets.context_explorer import ContextTree, ContextTreeIntent +from toad.widgets.context_explorer import ContextExplorer, ContextTree +from toad.widgets.side_bar import SideBar, SideBarCollapsible from support.overlapping_annotations import overlapping_annotations -class TreeApp(ThemedApp, App): - def __init__(self, tree): - super().__init__() - self.subject = tree - - def compose(self): - yield self.subject - - -async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_new_selection(tmp_path): - found = overlapping_annotations(tmp_path) +async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_new_selection(workspace): + found = overlapping_annotations(workspace.project) spans, labels, inspection, short, long = found.spans, found.labels, found.inspection, found.short, found.long groups = found.refined_groups - intent = ContextTreeIntent(selected=short) - detail = [] - tree = ContextTree(intent, detail.append, lambda text: None) - tree.styles.height = 3 - app = TreeApp(tree) - async with app.run_test() as pilot: + async with workspace.app(size=(120, 45)) as ui: + app = ui.textual[0] + sidebar = app.screen.query_one("#thread-sidebar", SideBar) + sidebar.reveal() + await sidebar.wait_content_ready() + next(panel for panel in sidebar.query(SideBarCollapsible) if panel.title == "Context").collapsed = False + explorer = sidebar.query_one(ContextExplorer) + await ui.step() + tree = explorer.query_one(ContextTree) + tree.styles.height = 3 + tree.styles.min_height = 0 + intent = explorer.intent + intent.selected = short tree.present(inspection.working_memory()) - await pilot.pause() - short_node, long_node = tree.reveal(short.key), tree.reveal(long.key) + short_node, long_node = tree.resolve_reader(short.key), tree.resolve_reader(long.key) + tree.root.expand_all() + await ui.step() + tree.move_cursor(short_node, animate=False) + await ui.step() assert short_node is not long_node assert tree.owns_node(short_node) and tree.owns_node(long_node) assert tree.cursor_node is short_node and intent.selected is short_node.data assert short_node.data.annotation.span == spans[0] assert long_node.data.annotation.span == spans[1] - # A wheel scroll can leave the selected native row outside the - # viewport. Rebinding its source is not a new navigation request. + # Rebinding selected detail is not another native navigation request. tree.scroll_home(animate=False) - await pilot.pause() + await ui.step() assert short_node.line >= tree.scrollable_content_region.height reader_offset = tree.scroll_offset - detail.clear() tree.present(inspection.working_memory()) - await pilot.pause() + await ui.step() assert tree.cursor_node is short_node assert tree.scroll_offset == reader_offset - assert detail == [short_node.data] + assert intent.selected is short_node.data tree.present(groups) - await pilot.pause() - short_node, long_node = tree.reveal(short.key), tree.reveal(long.key) + await ui.step() + short_node, long_node = tree.resolve_reader(short.key), tree.resolve_reader(long.key) assert tree.owns_node(short_node) and tree.owns_node(long_node) - assert tree.cursor_node is short_node and intent.selected is short_node.data + # A human correction moves the answer to another section. The old + # native row retires; detail rebinds without manufacturing navigation. + assert intent.selected is short_node.data assert short_node.data.annotation == found.corrected assert long_node.data.annotation == labels[1] - assert detail[-1] is short_node.data await tree.remove() - restored = ContextTree(intent, detail.append, lambda text: None) - await app.mount(restored) + restored = ContextTree(explorer._show_detail, explorer._show_placeholder) + await explorer.mount(restored) restored.present(groups) - await pilot.pause() - short_node, long_node = restored.reveal(short.key), restored.reveal(long.key) + await ui.step() + short_node, long_node = restored.resolve_reader(short.key), restored.resolve_reader(long.key) assert restored.owns_node(short_node) and restored.owns_node(long_node) - assert restored.cursor_node is short_node and intent.selected is short_node.data + # Detail intent survives. Retired native nodes do not own a new cursor. + assert intent.selected is short_node.data + assert restored.cursor_node is not short_node assert short_node.data.annotation == found.corrected and long_node.data.annotation == labels[1] - assert detail[-1] is short_node.data assert app._exception is None 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_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index f12b88a91..b0e8f86c5 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -1,6 +1,7 @@ """Textual-facing: whether a channel message counts as read depends on which rows Textual painted, and a divider painted without its text is not enough.""" from agent_comms.repo import Checkout +from agent_comms.ui_model.unread import ExactUnread from unittest.mock import patch from support.textual_steps import text_rows_of @@ -11,7 +12,7 @@ async def test_divider_without_body_leaves_the_message_unread(workspace): workspace.thread("peer", "team") comms = workspace.comms - project = str(workspace.project) + project = workspace.project for index in range(7): comms.messaging.send("peer", "#team", f"earlier {index} " + "body " * 20) comms.views.mark_user_view_read("#team", worktree=Checkout(project)) @@ -49,6 +50,8 @@ def caption_is_last_row(): assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) mark_visible(history) - await ui.until(lambda: not history.marking, why="marking to settle") - await ui.step(.2) - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == 1 + # Earlier visible messages may have work; completing that work + # must not acknowledge the unread message whose text was hidden. + await history.read_worker.wait() + await ui.step() + assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == ExactUnread(count=1) diff --git a/tests/textual/test_dm_peer_replacement.py b/tests/textual/test_dm_peer_replacement.py index 7dce38b89..be5fec815 100644 --- a/tests/textual/test_dm_peer_replacement.py +++ b/tests/textual/test_dm_peer_replacement.py @@ -4,6 +4,7 @@ from threading import Event from unittest.mock import patch +from agent_comms.channel_targets import CommandTarget from agent_comms.history_views import HistoryViews @@ -13,25 +14,26 @@ async def test_a_page_painted_for_the_old_peer_does_not_mark_the_replacement_pee workspace.thread("peer", running=True) comms = workspace.comms viewer = workspace.viewer + target = CommandTarget.named("peer") comms.messaging.send("peer", viewer, "old peer painted") entered, release = Event(), Event() - original_mark = HistoryViews.mark_dm_view_read + original_mark = HistoryViews.mark_page_read - def pause_old_mark(self, peer, **kwargs): + def pause_old_mark(self, page, **kwargs): entered.set() if not release.wait(8): raise TimeoutError("Old painted marker was not released") - return original_mark(self, peer, **kwargs) + return original_mark(self, page, **kwargs) try: - with patch.object(HistoryViews, "mark_dm_view_read", pause_old_mark): + with patch.object(HistoryViews, "mark_page_read", pause_old_mark): async with workspace.app(size=(120, 40)) as ui: owner = ui.selected_tab await ui.open_direct("peer", actor=viewer) chat = ui.textual[0].screen.query_one(CommsChatView) assert await asyncio.to_thread(entered.wait, 4) - assert [message.body for message in chat.message_history.messages] == ["old peer painted"] - assert comms.bus.pending_count(viewer, "peer") == 1 + assert [fragment.message.body for fragment in chat.message_history.fragments] == ["old peer painted"] + assert comms.bus.pending_count(viewer, target) == 1 comms.registry.unregister("peer") comms.registry.remove("peer") @@ -39,8 +41,9 @@ def pause_old_mark(self, peer, **kwargs): comms.messaging.send("peer", viewer, "new peer never painted") await ui.select_tab(owner) release.set() - await ui.until(lambda: not chat.message_history.marking, why="the held mark to finish") - assert comms.bus.pending_count(viewer, "peer") == 1 - assert not any(message.body == "new peer never painted" for message in chat.message_history.messages) + await chat.message_history.read_worker.wait() + assert comms.bus.pending_count(viewer, target) == 1 + assert not any(fragment.message.body == "new peer never painted" + for fragment in chat.message_history.fragments) finally: release.set() diff --git a/tests/textual/test_hidden_channel_refresh.py b/tests/textual/test_hidden_channel_refresh.py index 63a9a6290..24e7a295a 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,17 +20,18 @@ 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) + assert not any(fragment.message.body == "ARRIVED-WHILE-HIDDEN" for fragment in history.fragments) await ui.select_tab(first) - await ui.until(lambda: any(m.body == "ARRIVED-WHILE-HIDDEN" for m in history.messages), + await ui.until(lambda: any(fragment.message.body == "ARRIVED-WHILE-HIDDEN" + for fragment in history.fragments), why="the resumed channel to fetch the current message") # The history holds the message before layout has measured its rows; the next layout draws it. await ui.until(lambda: ui.shows("ARRIVED-WHILE-HIDDEN"), why="the fetched message drawn") diff --git a/tests/textual/test_prompt_enter_insert.py b/tests/textual/test_prompt_enter_insert.py index 39b5d23fb..6ee2ec614 100644 --- a/tests/textual/test_prompt_enter_insert.py +++ b/tests/textual/test_prompt_enter_insert.py @@ -99,6 +99,10 @@ async def test_standard_chooser_retires_neovim_and_saved_selection_reopens(works await ui.until(lambda: ui.conversation.prompt.prompt_text_area.key_mode is Standard, why="the actual saved Standard preference after reopen") area = ui.conversation.prompt.prompt_text_area + assert type(area.editing_mode) is Standard area.focus() await ui.until(lambda: str(ui.conversation.prompt.editing_mode_label.render()) == Standard.label, why="the saved Standard label, without starting the engine") + await ui.type("B o stays native") + assert "B o stays native" in area.text + assert type(area.editing_mode) is Standard diff --git a/tests/textual/test_saved_view_read_only.py b/tests/textual/test_saved_view_read_only.py index b03cc3939..c1fb1fe47 100644 --- a/tests/textual/test_saved_view_read_only.py +++ b/tests/textual/test_saved_view_read_only.py @@ -25,18 +25,19 @@ async def test_saved_view_shows_union_history_keeps_its_pin_and_refuses_to_send( "engineering", ViewPredicate(AnyOfMatch, frozenset({"api", "ui"})), 17, frozenset({"#engineering"}))}, )) - before = comms.bus.log.path.read_bytes() + before = comms.views.latest_sequence() async with workspace.app(size=(110, 35)) as ui: sidebar = await channel_roster(ui, "#engineering") next(group for group in sidebar.query(ChannelGroup) if group.row.target_name == "#engineering").row.focus() await ui.press("enter") chat = ui.textual[0].screen.query_one(CommsChatView) - await ui.until(lambda: [m.body for m in chat.message_history.messages] == [m.body for m in rows], + await ui.until(lambda: [fragment.message.body for fragment in chat.message_history.fragments] + == [message.body for message in rows], why="the saved view's history") assert chat.prompt.prompt_text_area.disabled - await ui.until(lambda: "Read-only" in chat.status, why="the read-only status") + await ui.until(lambda: "Read-only" in chat.thread_status, why="the read-only status") await chat.submit_input(input_events.UserInputSubmitted("must not publish")) assert chat.prompt.text == "must not publish" - assert comms.bus.log.path.read_bytes() == before + assert comms.views.latest_sequence() == before assert next(view for view in comms.views.channel_views() if view.channel.name == "#engineering").channel.pinned 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_sidebar_rows_in_steps.py b/tests/textual/test_sidebar_rows_in_steps.py index eb6a94a27..c2f72eca7 100644 --- a/tests/textual/test_sidebar_rows_in_steps.py +++ b/tests/textual/test_sidebar_rows_in_steps.py @@ -7,10 +7,11 @@ from textual.widgets import Static from toad.textual_colours import ThemedApp -from toad.widgets.sidebar_tree import SidebarGroup +from toad.tone_paint import AlternatingTone +from toad.widgets.sidebar_tree import SidebarGroup, TargetTree -class Row(Static): +class Row(AlternatingTone, Static): pass @@ -19,20 +20,28 @@ class Group(SidebarGroup): def __init__(self, count: int) -> None: self.keys = tuple(f"row-{index}" for index in range(count)) - self.members: dict[str, Row] = {} self.painted: list[str] = [] super().__init__(Static("group"), expanded=True) - def on_mount(self) -> None: - self.sync_members() + @property + def member_keys(self) -> tuple[str, ...]: + return self.keys - def sync_members(self, changed=None) -> None: - def update(key, row): - if changed is None or key in changed or not row.has_class("-painted"): - row.add_class("-painted") - self.painted.append(key) + def new_row(self, key: str) -> Row: + return Row(key) - self.reconcile_rows(self.keys, self.members, lambda key: Row(key), update) + def show_member(self, key: str, row: Row) -> None: + row.add_class("-painted") + self.painted.append(key) + + def sync_members(self, changed: frozenset[str]) -> None: + self.reconcile_rows(changed=changed) + + +class Tree(TargetTree): + @property + def navigation_groups(self): + return self.query(Group) class Rows(ThemedApp, App): @@ -43,6 +52,7 @@ def __init__(self, count: int) -> None: def compose(self) -> ComposeResult: yield Static("before") + yield Tree() def _display(self, screen: Screen, renderable) -> None: if renderable is not None: @@ -57,12 +67,12 @@ async def test_long_list_mounts_a_step_per_frame_in_order(self): async with app.run_test(size=(60, 30)) as pilot: app.screen.LAYOUT_STEP_NS = 1_000_000 group = Group(app.count) - await app.screen.mount(group) + await app.screen.query_one(Tree).mount(group) first = len(group.member_container.children) self.assertGreater(first, 0) self.assertLess(first, app.count, "the first step mounts only part of the list") await pilot.pause() - while group._row_mounting is not None and not group._row_mounting.is_finished: + while not group._row_mounting.is_finished: await pilot.pause() await pilot.pause() children = list(group.member_container.children) @@ -82,8 +92,11 @@ async def test_short_list_mounts_at_once(self): # so the budget is fixed here the way the long-list test fixes it. app.screen.LAYOUT_STEP_NS = 1_000_000_000 group = Group(app.count) - await app.screen.mount(group) + unscheduled = group._row_mounting + self.assertTrue(unscheduled.is_finished) + self.assertFalse(unscheduled.is_running) + await app.screen.query_one(Tree).mount(group) self.assertEqual(len(group.member_container.children), app.count) - self.assertIsNone(group._row_mounting) + self.assertIs(group._row_mounting, unscheduled) await pilot.pause() 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): diff --git a/uv.lock b/uv.lock index 17d3c4d58..31cb59c88 100644 --- a/uv.lock +++ b/uv.lock @@ -1338,7 +1338,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#bea0d377b9bc414dc377f1ebcbb51405fc7b0e03" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#832b0f6d93500c8f250f41c8ceee2337a3839ba4" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" },