From c229a0d9c17554f117c8ebf61f1cbd396dc5cc9d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 03:02:21 -0400 Subject: [PATCH 01/33] Prepare and launch one verified Neovim release artifact --- .github/workflows/ci.yml | 10 +- pyproject.toml | 3 + src/toad/neovim_editing.py | 3 +- src/toad/neovim_runtime.py | 215 ++++++++++++++++++++++ tests/model/test_neovim_preparation.py | 77 ++++++++ tests/textual/test_prompt_enter_insert.py | 4 + 6 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 src/toad/neovim_runtime.py create mode 100644 tests/model/test_neovim_preparation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be8a9bded..9c487af3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,14 +60,6 @@ jobs: - 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 +80,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 0c7948e4b..6a6748796 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/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..5377f7915 --- /dev/null +++ b/src/toad/neovim_runtime.py @@ -0,0 +1,215 @@ +"""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 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.""" + + name: str + size: int + sha256: bytes + + @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("nvim-linux-arm64.tar.gz", 11353219, bytes.fromhex( + "ceb7e88c6b681f0515d135dcdfad54f5eb4373b25ce6172197cd9a69c758063f")), + ReleaseArchive("nvim-linux-x86_64.tar.gz", 11404178, bytes.fromhex( + "012bf3fcac5ade43914df3f174668bf64d05e049a4f032a388c027b1ebd78628")), + ReleaseArchive("nvim-macos-arm64.tar.gz", 9630250, bytes.fromhex( + "51ab83afa66d663627c2ab1be43209b0f4e81360d4598b53efaa4d8195f24c89")), + ReleaseArchive("nvim-macos-x86_64.tar.gz", 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: + name = f"{self.program}-{self.system.key}-{self.architecture.tag}.tar.gz" + for archive in self.archives: + if archive.name == name: + 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/tests/model/test_neovim_preparation.py b/tests/model/test_neovim_preparation.py new file mode 100644 index 000000000..86508c7af --- /dev/null +++ b/tests/model/test_neovim_preparation.py @@ -0,0 +1,77 @@ +"""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 + + +@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/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 From 6222aaa6eca5e21bf77a420a424b76ffe5a4c64b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 05:04:24 -0400 Subject: [PATCH 02/33] Declare Neovim release assets by platform and architecture --- src/toad/neovim_runtime.py | 20 ++++++++++++-------- tests/model/test_neovim_preparation.py | 19 ++++++++++++++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/toad/neovim_runtime.py b/src/toad/neovim_runtime.py index 5377f7915..47ef01664 100644 --- a/src/toad/neovim_runtime.py +++ b/src/toad/neovim_runtime.py @@ -19,7 +19,7 @@ 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 Platform, host +from toad.platform import LinuxPlatform, MacPlatform, Platform, host class NeovimUnavailable(OSError): @@ -55,10 +55,15 @@ class Arm64Architecture(Architecture): class ReleaseArchive: """Trusted release metadata, not a checksum fetched beside an untrusted download.""" - name: str + 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") @@ -92,13 +97,13 @@ class Neovim(RuntimePreparation, Executable): # 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("nvim-linux-arm64.tar.gz", 11353219, bytes.fromhex( + ReleaseArchive(LinuxPlatform, Arm64Architecture, 11353219, bytes.fromhex( "ceb7e88c6b681f0515d135dcdfad54f5eb4373b25ce6172197cd9a69c758063f")), - ReleaseArchive("nvim-linux-x86_64.tar.gz", 11404178, bytes.fromhex( + ReleaseArchive(LinuxPlatform, X64Architecture, 11404178, bytes.fromhex( "012bf3fcac5ade43914df3f174668bf64d05e049a4f032a388c027b1ebd78628")), - ReleaseArchive("nvim-macos-arm64.tar.gz", 9630250, bytes.fromhex( + ReleaseArchive(MacPlatform, Arm64Architecture, 9630250, bytes.fromhex( "51ab83afa66d663627c2ab1be43209b0f4e81360d4598b53efaa4d8195f24c89")), - ReleaseArchive("nvim-macos-x86_64.tar.gz", 9896697, bytes.fromhex( + ReleaseArchive(MacPlatform, X64Architecture, 9896697, bytes.fromhex( "03fe16f8dd9f1e9eaf52d5e294913a39917b9e2faea30d7fb0fb385fbd36fe59")), ) @@ -110,9 +115,8 @@ class Neovim(RuntimePreparation, Executable): @cached_property def archive(self) -> ReleaseArchive: - name = f"{self.program}-{self.system.key}-{self.architecture.tag}.tar.gz" for archive in self.archives: - if archive.name == name: + if (archive.platform, archive.architecture) == (self.system, self.architecture): return archive raise NeovimUnavailable( f"Neovim {self.version} has no declared asset for " diff --git a/tests/model/test_neovim_preparation.py b/tests/model/test_neovim_preparation.py index 86508c7af..40f2dd176 100644 --- a/tests/model/test_neovim_preparation.py +++ b/tests/model/test_neovim_preparation.py @@ -5,7 +5,24 @@ from pathlib import Path import pytest -from toad.neovim_runtime import Neovim, NeovimUnavailable +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") From 85d3543b75ded5343b2db68797c1f564f33509e7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 02:55:49 -0400 Subject: [PATCH 03/33] Give unscheduled sidebar work its own queryable value --- src/toad/widgets/sidebar_tree.py | 24 ++++++++--- .../test_channel_member_mount_lifetime.py | 5 ++- tests/textual/test_sidebar_rows_in_steps.py | 43 ++++++++++++------- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/toad/widgets/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 7342ad0ba..305ae6fd8 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" ) 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_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() From 999edc483a9f034a546080cdc4e93c6dad2fc22d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 05:10:23 -0400 Subject: [PATCH 04/33] Consume page-owned message evidence in wire fragments --- src/toad/wire_history.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 93e9d610c..7b65b2846 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -323,8 +323,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, From e26db264f32e677733d19107d2d89a73a9e0a378 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 06:51:15 -0400 Subject: [PATCH 05/33] Borrow the retained coordinator reader for context annotations --- src/toad/core/context_inspection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/core/context_inspection.py b/src/toad/core/context_inspection.py index bda453d71..da50ed3bd 100644 --- a/src/toad/core/context_inspection.py +++ b/src/toad/core/context_inspection.py @@ -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) From 43c1e267b75ca6acd89c42c301177a135153504c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 06:57:12 -0400 Subject: [PATCH 06/33] Follow declared types in nine stale model tests Core and Toad typed these values weeks ago; the tests kept the old shapes and have failed on main since: - ProjectFileLink requires original_root (bb041c33a, 10-15) - terminal_attention.program is a method - Checkout replaced str worktrees (3af6ec8f42, 10-26) - PermissionPresentation.admit became for_request (e8099ab66, 10-17) - observations bind through agent_binding - AssistantMessage.content is ReportedParts; tool call ids are ToolCallId - 'no saved session' is NoSessionObservation, not observe(None) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/model/test_context_inspection_source.py | 8 ++++---- .../test_permission_presentation_selection.py | 2 +- tests/model/test_project_links.py | 18 +++++++++--------- tests/model/test_render_processes.py | 3 ++- .../test_session_observation_cancellation.py | 14 +++++++++++++- tests/model/test_usage_reports.py | 2 +- tests/support/overlapping_annotations.py | 4 ++-- tests/support/shared_stdio.py | 3 ++- 8 files changed, 34 insertions(+), 20 deletions(-) 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_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..a869f5c6a 100644 --- a/tests/model/test_session_observation_cancellation.py +++ b/tests/model/test_session_observation_cancellation.py @@ -12,9 +12,21 @@ async def reached(event): await event.wait() +class ReadBinding: + """The bound agent schedules reads on itself, as toad.agent.Agent does.""" + + def invalidate_observation(self, observation): + observation.schedule(self) + + def matches_agent(self, agent): + return self is agent + + class ReadSource: is_attached = True - agent = object() + + def __init__(self): + self.agent_binding = ReadBinding() class WaitingObservation(SessionObservation): diff --git a/tests/model/test_usage_reports.py b/tests/model/test_usage_reports.py index b81365c2a..a8e8d2cd1 100644 --- a/tests/model/test_usage_reports.py +++ b/tests/model/test_usage_reports.py @@ -15,7 +15,7 @@ async def run_thread(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=SimpleNamespace(program=lambda: "st"), ) reports = UsageReports(app) clients = [] diff --git a/tests/support/overlapping_annotations.py b/tests/support/overlapping_annotations.py index 3b276ed18..3ac607dab 100644 --- a/tests/support/overlapping_annotations.py +++ b/tests/support/overlapping_annotations.py @@ -9,7 +9,7 @@ from pathlib import Path from agent_comms.comms import Comms -from agent_comms.session_revision import SessionRevision +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 ( @@ -56,7 +56,7 @@ def overlapping_annotations(directory: Path) -> OverlappingAnnotations: manifest = ContextManifest(owner.incarnation, RecordedContextTurn(TurnId("authored-unsealed-navigation"), TurnIdentity(owner.incarnation, 1)), (observed,), "authored-navigation") - inspection = ContextInspection(owner, (manifest,), SessionRevision.observe(None), + inspection = ContextInspection(owner, (manifest,), NoSessionObservation(), Comms(resolve_comms_route(directory / "unused-wire")), labels) ((_, (source,), _),) = inspection.working_memory() short, long = source.children() 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)) From e638d02a1f77ec0e4d5cc4d91f37da00a0ff0aef Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 07:07:42 -0400 Subject: [PATCH 07/33] Bind observation and usage tests to their declared owners The cancellation test's fake binding copied AgentBase.matches_agent and invalidate_observation (toad/agent.py:45,84); it is now an AgentBase member that implements only the abstract send_prompt. The usage test passes TerminalAttention, whose static program() reads TERM_PROGRAM, instead of re-spelling it as a lambda. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- .../test_session_observation_cancellation.py | 15 +++++++-------- tests/model/test_usage_reports.py | 6 ++++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/model/test_session_observation_cancellation.py b/tests/model/test_session_observation_cancellation.py index a869f5c6a..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,21 +14,18 @@ async def reached(event): await event.wait() -class ReadBinding: - """The bound agent schedules reads on itself, as toad.agent.Agent does.""" +class ReadAgent(AgentBase): + """A bound agent that only serves observation reads; it is never prompted.""" - def invalidate_observation(self, observation): - observation.schedule(self) - - def matches_agent(self, agent): - return self is agent + async def send_prompt(self, prompt): + raise AssertionError("observation reads never prompt the agent") class ReadSource: is_attached = True def __init__(self): - self.agent_binding = ReadBinding() + 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 a8e8d2cd1..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=lambda: "st"), + settings=SimpleNamespace(anon_id="anon"), terminal_attention=TerminalAttention, ) reports = UsageReports(app) clients = [] From ff2f19494f594af283fd43c1eb2c90dd9f5ac6c5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 07:49:54 -0400 Subject: [PATCH 08/33] Pass retained owner incarnation to context requests --- src/toad/core/context_inspection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toad/core/context_inspection.py b/src/toad/core/context_inspection.py index da50ed3bd..910e13636 100644 --- a/src/toad/core/context_inspection.py +++ b/src/toad/core/context_inspection.py @@ -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: From 9ed84e419c6bbd94fad43fc893b9a30ce78c097c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 07:51:02 -0400 Subject: [PATCH 09/33] Pass the launch root from the CLI into each Toad app --- src/toad/about.py | 4 ++-- src/toad/app.py | 5 ++++- src/toad/cli.py | 17 ++++++++++++----- src/toad/comms_root.py | 9 ++++----- src/toad/slash_command.py | 2 +- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/toad/about.py b/src/toad/about.py index 2c2309edb..c5b437d2c 100644 --- a/src/toad/about.py +++ b/src/toad/about.py @@ -78,7 +78,7 @@ ) -def render() -> str: +def render(launch_root: LaunchRoot) -> 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(launch_root.path), "CONFIG": config, "DATA_PATH": paths.get_data(), "LOG_PATH": paths.get_log(), diff --git a/src/toad/app.py b/src/toad/app.py index 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..fca371e15 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,10 @@ 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. + # Select once at the process boundary; apps receive this captured value. LaunchRoot.startup(args, ActiveRoute.published_launch) + ctx.obj = LaunchRoot.current() # 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 +124,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 +139,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 +161,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 +200,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 +275,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)) if __name__ == "__main__": diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index a78222d1b..ffa1f0339 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) @@ -372,7 +371,7 @@ def open_current(self) -> Observing | FailedObservation: return self.observation.failed(error, selected) def confirm(self, selected: RouteSelection) -> None: - if RouteSelection.capture() != selected: + if RouteSelection.capture(selected.root) != selected: raise RouteChangedError("Comms route changed before the operation") def require(self, selected: RouteSelection) -> Observing: diff --git a/src/toad/slash_command.py b/src/toad/slash_command.py index 047c22b46..ba19b8592 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.launch_root) await conversation.post(MarkdownNote(markdown, classes="about")) conversation.app.copy_to_clipboard(markdown) conversation.notify( From c7a778e931844402d9a21f97809186a439bf67a2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 08:26:33 -0400 Subject: [PATCH 10/33] Terminal owns printable keys through NativeTextKeys Under the Vim profile Terminal's own ScrollView arrow bindings gained hjkl spellings, and NavigationKey preemption scrolled the view instead of writing j/k/h/l to the child. Terminal now composes textual's NativeTextKeys (OpenHCSDev/textual#214), so its bindings project in EditingMode; the hand-written allows_application_commands override is deleted because the capability derives it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/toad/widgets/terminal.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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. From f26ab9559c38c9e18fd76c529e023d004c6be115 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 08:36:49 -0400 Subject: [PATCH 11/33] Relock textual to 832b0f6d so NativeTextKeys exists Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From bcc8ce70b9cf78f64d0c9136e9ff0e4288d0caa8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 09:23:12 -0400 Subject: [PATCH 12/33] Use incarnation-bound owner requests and native response declarations --- src/toad/acp/agent_controller.py | 9 +++------ src/toad/core/context_inspection.py | 2 +- src/toad/owner_preparation.py | 12 ------------ 3 files changed, 4 insertions(+), 19 deletions(-) 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/core/context_inspection.py b/src/toad/core/context_inspection.py index 910e13636..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 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) From 519382bf8b9193c6bf202ce27bff4c4b516d5dd0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 09:59:50 -0400 Subject: [PATCH 13/33] Derive history Vim scrolling from inherited arrow identities --- src/toad/widgets/history_anchor.py | 2 -- uv.lock | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) 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/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" }, From 3fbd6a0490fab8a4f2b45385b9c269949bb19810 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:04:46 -0400 Subject: [PATCH 14/33] Resolve app routes from held launch roots --- src/toad/acp/agent_process.py | 2 +- src/toad/acp/agent_session.py | 2 +- src/toad/acp/transcript_reader.py | 2 +- src/toad/cli.py | 3 +-- src/toad/comms_root.py | 6 +++--- src/toad/delivery_failure_view.py | 6 ++++-- src/toad/navigation_preparation.py | 4 ++-- src/toad/screens/main.py | 2 +- src/toad/session_navigation.py | 11 ++++++----- src/toad/session_scope.py | 4 ++-- src/toad/target_commands.py | 9 ++++----- src/toad/thread_actions.py | 2 +- src/toad/widgets/comms_chat.py | 2 +- src/toad/widgets/comms_transfer.py | 3 ++- src/toad/widgets/context_explorer.py | 2 +- src/toad/widgets/thread_comms.py | 2 +- src/toad/wire_history.py | 9 ++++++--- tests/conftest.py | 2 +- tests/native/test_native_catalog_launch.py | 5 +++-- tests/support/agents.py | 5 +++-- .../core_adapters/human_input_observer.py | 3 ++- .../core_adapters/mcp_permission_observer.py | 4 +++- tests/support/workspace.py | 11 ++++++----- tests/textual/test_conversation_kind.py | 4 +++- tests/textual/test_hidden_channel_refresh.py | 16 ++++++---------- tests/textual/test_scroll_down_bounce.py | 4 ++-- tests/textual/test_session_observation_reads.py | 6 +++--- tests/textual/test_stopped_thread_attachment.py | 2 +- tests/textual/test_store_agent_actions.py | 3 ++- tests/textual/test_target_command_context.py | 2 +- tests/textual/test_wire_source.py | 4 +++- 31 files changed, 77 insertions(+), 65 deletions(-) diff --git a/src/toad/acp/agent_process.py b/src/toad/acp/agent_process.py index 452b871c0..20b3ed9aa 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 = RouteSelection.of(agent.controller.transcripts.access.launch_root) 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..c28de24fa 100644 --- a/src/toad/acp/agent_session.py +++ b/src/toad/acp/agent_session.py @@ -362,7 +362,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) + RouteSelection.of, self.agent.controller.transcripts.access.launch_root) except RouteChangedError as error: self.startup_failed(f"Reconnect blocked before restart: {error}") return diff --git a/src/toad/acp/transcript_reader.py b/src/toad/acp/transcript_reader.py index aa5f6fac8..124a95b50 100644 --- a/src/toad/acp/transcript_reader.py +++ b/src/toad/acp/transcript_reader.py @@ -44,7 +44,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(RouteSelection.of(self.access.launch_root).require_source(root)).service ) async def thread_presentation( diff --git a/src/toad/cli.py b/src/toad/cli.py index fca371e15..cf6796c97 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -78,8 +78,7 @@ def parse_args(self, ctx, args): from agent_comms.active_route import ActiveRoute # Select once at the process boundary; apps receive this captured value. - LaunchRoot.startup(args, ActiveRoute.published_launch) - ctx.obj = LaunchRoot.current() + 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 diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index ffa1f0339..be62aa2a7 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -365,13 +365,13 @@ def open_current(self) -> Observing | FailedObservation: with self.opening: selected: RouteSelection | UnselectedRoute = UnselectedRoute() try: - selected = RouteSelection.capture(self.launch_root.path) + selected = RouteSelection.of(self.launch_root) 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.root) != selected: + if RouteSelection.of(self.launch_root) != selected: raise RouteChangedError("Comms route changed before the operation") def require(self, selected: RouteSelection) -> Observing: @@ -648,5 +648,5 @@ async def observe_route(self) -> None: def write(self, selected: RouteSelection, operation: Callable[..., T], *args: object, **kwargs: object) -> T: # This method runs in the same worker as the actual sink. No route lock: the # store refuses a write an install holds, inside its own write transaction. - RouteSelection.capture(selected.root) + self.confirm(selected) return operation(*args, **kwargs) diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index 04c2a3cec..59e82a749 100644 --- a/src/toad/delivery_failure_view.py +++ b/src/toad/delivery_failure_view.py @@ -119,7 +119,7 @@ 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 + from agent_comms.route_selection import RouteSelection if not body.strip(): return @@ -135,7 +135,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: RouteSelection.of(self.app.coordination_access.launch_root).root_is_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..3cfc8664e 100644 --- a/src/toad/navigation_preparation.py +++ b/src/toad/navigation_preparation.py @@ -209,7 +209,7 @@ 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 + from agent_comms.route_selection import RouteSelection navigator = self.navigator try: @@ -224,7 +224,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: RouteSelection.of(navigator.app.coordination_access.launch_root).root_is_current(self.request.root) ): raise RuntimeError("The thread's bus route changed while opening") return await prepared.open(self) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 398bb77fb..f18409db8 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -238,7 +238,7 @@ def read_visible(self, screen: MainScreen) -> PaintedRead: worktree = app.project_dir def acknowledge(cursor) -> None: - selected = RouteSelection.capture(source_root) + selected = RouteSelection.of(access.launch_root).require_source(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..ad7a4e775 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -18,7 +18,7 @@ ) 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.route_selection import RouteSelection from agent_comms.thread_identity import ThreadIncarnation from toad.core import events as core_events @@ -402,7 +402,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: RouteSelection.of(app.coordination_access.launch_root).root_is_current(requested_root) + ): return app.selected_mode if not origin.current(app.thread_navigation, owner_mode): return app.selected_mode @@ -544,7 +546,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,10 +570,10 @@ def read(): answers = {} for root, (threads, channels) in asked.items(): try: - selected = RouteSelection.capture(root) + selected = RouteSelection.of(self.app.coordination_access.launch_root).require_source(root) service = self.app.coordination_access.require(selected).service retired = service.views.retired_views(threads, channels) - if RouteSelection.capture(root) != selected: + if RouteSelection.of(self.app.coordination_access.launch_root).require_source(root) != selected: continue # Replaced while reading; the next observation reads it. except RouteChangedError: continue # The next observation reads the replacement route. diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index 5137fa9c5..da08dd0c5 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -130,7 +130,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(RouteSelection.of(access.launch_root).require_source(self.root)).service return ( service.registry.canonical_name(session_id) == service.registry.require(self.name).name @@ -407,7 +407,7 @@ def require(self) -> ProjectSessionOrigin: def resolve_thread(self) -> str: identity = self.thread_identity - selected = RouteSelection.capture(identity.wire_root()) + selected = RouteSelection.of(self.app.coordination_access.launch_root).require_source(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/target_commands.py b/src/toad/target_commands.py index 1663d0c2a..4b3bcfe46 100644 --- a/src/toad/target_commands.py +++ b/src/toad/target_commands.py @@ -14,7 +14,7 @@ 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 agent_comms.route_selection import RouteSelection from toad.slash_command import CommandPresentation, LocalCommand, SlashCommand from agent_comms.cli_command import CliCommand, TargetAction from toad.thread_actions import ThreadAction @@ -53,18 +53,17 @@ def title(self) -> str: return ", ".join(names) if names else str(self.project) def current(self): - if not root_is_current(self.comms.root): + if not RouteSelection.of(self.app.coordination_access.launch_root).root_is_current(self.comms.root): raise RouteChangedError(RouteChangedError.reader_sentence) return self def available_actions(self): - from agent_comms.route_selection import RouteSelection - selected = RouteSelection.capture(self.comms.root) + selected = RouteSelection.of(self.app.coordination_access.launch_root).require_source(self.comms.root) self.current() actions = CliCommand.target_catalog(self.comms, CliCommand.selected_targets(self.selection), self.channel, project=self.project) - if RouteSelection.capture(self.comms.root) != selected: + if RouteSelection.of(self.app.coordination_access.launch_root).require_source(self.comms.root) != selected: raise ValueError('Comms route changed during command discovery') return actions diff --git a/src/toad/thread_actions.py b/src/toad/thread_actions.py index 428015ede..a266bd5bf 100644 --- a/src/toad/thread_actions.py +++ b/src/toad/thread_actions.py @@ -34,7 +34,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 = RouteSelection.of(app.coordination_access.launch_root).require_source(source_root) if any(target in self.requests for target in action.resources): app.notify(f"An action for {subject} is already in progress", title="Session action") return diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 1caaeca1c..1484fd595 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(), launch_root=self.app.coordination_access.launch_root, )) self.refresh_identity(comms.root) self.prepare_prompt() diff --git a/src/toad/widgets/comms_transfer.py b/src/toad/widgets/comms_transfer.py index 439d1308f..f546ec20d 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 = RouteSelection.of(access.launch_root).require_source(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..3c0a53d56 100644 --- a/src/toad/widgets/context_explorer.py +++ b/src/toad/widgets/context_explorer.py @@ -360,7 +360,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(RouteSelection.of(access.launch_root).require_source(identity.root_path())).service ) if not self._reading(identity): return diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index a2f2747bb..b4d064d0e 100644 --- a/src/toad/widgets/thread_comms.py +++ b/src/toad/widgets/thread_comms.py @@ -282,7 +282,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()) + RouteSelection.of(access.launch_root).require_source(identity.root_path()) ).service.relationships ) diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 93e9d610c..c373e6d70 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -15,6 +15,8 @@ from typing import TYPE_CHECKING, ClassVar from agent_comms.comms import Comms +from agent_comms.launch_root import LaunchRoot +from agent_comms.route_selection import RouteSelection from agent_comms.repo import Checkout from agent_comms.display_references import CurrentDisplayReferences from agent_comms.historical_views import ( @@ -294,6 +296,7 @@ class WireSource: project: Checkout me: str style: WireMessageStyle + launch_root: LaunchRoot = field(kw_only=True) tail_limit: int = 8 page_limit: int = 40 @@ -416,9 +419,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: RouteSelection.of(self.loader.launch_root).root_is_current(self.loader.comms.root) + ) def status_thread(self, access): return self.loader.conversation.status_thread(self.loader.comms.root, access) diff --git a/tests/conftest.py b/tests/conftest.py index 68309e523..eead05fcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,7 +52,7 @@ def launch_root(tmp_path): def app_agents(tmp_path, launch_root) -> Iterator[AppAgents]: """Agents of an app built on the test's own directory and never run.""" with launch_root.selected(): - yield AppAgents.unrun(tmp_path) + yield AppAgents.unrun(tmp_path, launch_root) @pytest.fixture diff --git a/tests/native/test_native_catalog_launch.py b/tests/native/test_native_catalog_launch.py index 91c89b7c2..906fb1403 100644 --- a/tests/native/test_native_catalog_launch.py +++ b/tests/native/test_native_catalog_launch.py @@ -15,8 +15,9 @@ async def test_store_launch_starts_a_session_and_leaves_the_open_one_alone(works with private_catalog_entry() as definition: from toad.app import ToadApp - def with_catalog_agent(**arguments): - return ToadApp(**{**arguments, "agent_binding": AgentAttached(definition)}) + def with_catalog_agent(*, launch_root, **arguments): + return ToadApp(launch_root=launch_root, + **{**arguments, "agent_binding": AgentAttached(definition)}) async with native_session(workspace, application=with_catalog_agent) as native: ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider diff --git a/tests/support/agents.py b/tests/support/agents.py index b71dd246c..6512cce11 100644 --- a/tests/support/agents.py +++ b/tests/support/agents.py @@ -4,6 +4,7 @@ from pathlib import Path from agent_comms.repo import Checkout +from agent_comms.launch_root import LaunchRoot import shlex import sys from typing import TYPE_CHECKING @@ -53,11 +54,11 @@ def __init__(self, app: ToadApp) -> None: self.app = app @classmethod - def unrun(cls, project: Path) -> AppAgents: + def unrun(cls, project: Path, launch_root: LaunchRoot) -> AppAgents: """For a test with no app running: agents of an app built on ``project`` and never run.""" from toad.app import ToadApp - return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project))) + return cls(ToadApp(agent_binding=NoAgent(), project_dir=Checkout(project), launch_root=launch_root)) def agent[A: Agent](self, kind: type[A], definition: AgentDefinition, session_id: str | None = None, **fields: object) -> A: diff --git a/tests/support/core_adapters/human_input_observer.py b/tests/support/core_adapters/human_input_observer.py index f98a59d3d..3981329f9 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -17,6 +17,7 @@ import sys from agent_comms.field_codec import FieldCodec +from agent_comms.launch_root import LaunchRoot from agent_comms.acp_extension import QueuePromptRequest from agent_comms.input_disposition import InputDispositions from agent_comms.native_entries import NativeEntry @@ -179,7 +180,7 @@ def phase(name): 'accepts_comms_root': True, }) app = ToadApp(agent_binding=AgentAttached(definition), project_dir=subject.worktree, - agent_session_id=subject.name) + agent_session_id=subject.name, launch_root=LaunchRoot.from_path(owner._comms.root)) phase('before_actual_toad_run_test') async with app.run_test(headless=True, size=(160, 44)) as pilot: phase('actual_toad_run_test_yielded_before_saved_view_ready') diff --git a/tests/support/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index 8d4044674..6bcc9658d 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -11,6 +11,7 @@ import os from pathlib import Path from agent_comms.repo import Checkout +from agent_comms.launch_root import LaunchRoot from typing import Any, AsyncIterator from mcp_turn_boundaries import AGENT_DATA, exercise_boundaries, notes @@ -155,7 +156,8 @@ async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer previous = {key: os.environ.get(key) for key in env} os.environ.update(env) try: - app = ToadApp(agent_binding=NoAgent(), project_dir=Checkout(root / "project")) + app = ToadApp(agent_binding=NoAgent(), project_dir=Checkout(root / "project"), + launch_root=LaunchRoot.from_path(root / "wire")) async with app.run_test(size=(120, 40)) as pilot: # Toad starts with no tab open; the observed session is opened here. definition = AgentDefinition.decode(AGENT_DATA) diff --git a/tests/support/workspace.py b/tests/support/workspace.py index 020afe15d..f5f00ff23 100644 --- a/tests/support/workspace.py +++ b/tests/support/workspace.py @@ -3,6 +3,7 @@ from __future__ import annotations from agent_comms.repo import Checkout, Repo +from agent_comms.launch_root import LaunchRoot from contextlib import asynccontextmanager from functools import cached_property import os @@ -24,13 +25,12 @@ class Workspace: """Everything a test of the running app starts from.""" def __init__(self, roots: Roots) -> None: - from agent_comms.launch_root import LaunchRoot - self.roots = roots self.project = roots.project self.project.mkdir(parents=True, exist_ok=True) Repo.git(self.project, "init", "-q") - LaunchRoot.from_path(roots.wire).install() + self.launch_root = LaunchRoot.from_path(roots.wire) + self.launch_root.install() self.comms = installed_wire(roots.wire) @property @@ -90,7 +90,7 @@ def prompts_received(self) -> list[str]: @cached_property def agents(self) -> AppAgents: - return AppAgents.unrun(self.project) + return AppAgents.unrun(self.project, self.launch_root) @asynccontextmanager async def app(self, *, size: tuple[int, int] = (120, 40), binding: AgentBinding = NoAgent(), @@ -103,7 +103,8 @@ async def app(self, *, size: tuple[int, int] = (120, 40), binding: AgentBinding from toad.app import ToadApp launch = {} if resume is None else {"agent_session_id": resume} - app = (application or ToadApp)(agent_binding=binding, project_dir=Checkout(self.project), **launch) + app = (application or ToadApp)(agent_binding=binding, project_dir=Checkout(self.project), + launch_root=self.launch_root, **launch) async with app.run_test(size=size) as pilot: await app.application.start_worker.wait() ui = TextualUi(app, pilot) diff --git a/tests/textual/test_conversation_kind.py b/tests/textual/test_conversation_kind.py index 80df2a19b..b335b2c15 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 @@ -53,7 +54,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(), + launch_root=LaunchRoot.from_path(comms.root)), PageBounds(limit=8), ) assert body in [message.body for message in page.messages] diff --git a/tests/textual/test_hidden_channel_refresh.py b/tests/textual/test_hidden_channel_refresh.py index 63a9a6290..e895678de 100644 --- a/tests/textual/test_hidden_channel_refresh.py +++ b/tests/textual/test_hidden_channel_refresh.py @@ -1,11 +1,9 @@ -"""Textual-facing: a hidden channel view's refresh callback (a widget lifecycle) reads nothing; the shown one fetches current data.""" - -from unittest.mock import patch +"""A hidden channel leaves its history unchanged; returning fetches current data.""" from support.textual_steps import refresh_chat -async def test_hidden_channel_refresh_does_no_root_check_and_resumed_window_fetches_current_message(workspace): +async def test_hidden_channel_refresh_leaves_history_unchanged_and_resumed_window_fetches_current_message(workspace): from toad.widgets.comms_chat import CommsChatView workspace.thread("peer", "one", "two") @@ -19,15 +17,13 @@ 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. + # Let the hidden reader settle before the next message arrives. await ui.until(lambda: history.state.accepts_source_work and not history.marking, why="the hidden channel to settle") 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. - for _ in range(40): - await refresh_chat(chat) - assert not any(m.body == "ARRIVED-WHILE-HIDDEN" for m in history.messages) + for _ in range(40): + await refresh_chat(chat) + assert not any(m.body == "ARRIVED-WHILE-HIDDEN" for m in history.messages) await ui.select_tab(first) await ui.until(lambda: any(m.body == "ARRIVED-WHILE-HIDDEN" for m in history.messages), why="the resumed channel to fetch the current message") diff --git a/tests/textual/test_scroll_down_bounce.py b/tests/textual/test_scroll_down_bounce.py index 944bf124b..ffacc088d 100644 --- a/tests/textual/test_scroll_down_bounce.py +++ b/tests/textual/test_scroll_down_bounce.py @@ -56,8 +56,8 @@ def _on_frame_published(self): self.frames.append((y, reader, page.prepared.start, page.prepared.stop)) -def recorded_app(**kwargs): - app = ToadApp(**kwargs) +def recorded_app(*, launch_root, **kwargs): + app = ToadApp(launch_root=launch_root, **kwargs) app.remove_mode("workspace") app.add_mode("workspace", partial(RecordedHistoryScreen, id="workspace")) return app diff --git a/tests/textual/test_session_observation_reads.py b/tests/textual/test_session_observation_reads.py index b7a77f54c..b3555bc79 100644 --- a/tests/textual/test_session_observation_reads.py +++ b/tests/textual/test_session_observation_reads.py @@ -43,7 +43,7 @@ async def next_read(observation): async def test_invalidation_during_a_read_supersedes_its_result(workspace): - agents = AppAgents.unrun(workspace.project) + agents = AppAgents.unrun(workspace.project, workspace.launch_root) view, app = ProbeView(agents.detached_agent()), App() observation = ProbeObservation(view) async with app.run_test(): @@ -61,7 +61,7 @@ async def test_invalidation_during_a_read_supersedes_its_result(workspace): async def test_a_read_for_a_previous_agent_is_not_published(workspace): - agents = AppAgents.unrun(workspace.project) + agents = AppAgents.unrun(workspace.project, workspace.launch_root) view, app = ProbeView(agents.detached_agent()), App() observation = ProbeObservation(view) async with app.run_test(): @@ -81,7 +81,7 @@ async def test_a_read_for_a_previous_agent_is_not_published(workspace): async def test_a_closed_observation_starts_no_read_and_delivery_actions_refuse(workspace): - agents = AppAgents.unrun(workspace.project) + agents = AppAgents.unrun(workspace.project, workspace.launch_root) view, app = ProbeView(agents.detached_agent()), App() observation = ProbeObservation(view) async with app.run_test() as pilot: diff --git a/tests/textual/test_stopped_thread_attachment.py b/tests/textual/test_stopped_thread_attachment.py index 4c2e564b3..6bb38baa6 100644 --- a/tests/textual/test_stopped_thread_attachment.py +++ b/tests/textual/test_stopped_thread_attachment.py @@ -36,7 +36,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',)) + RouteSelection.of(workspace.launch_root).require_source(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..646499d28 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) diff --git a/tests/textual/test_wire_source.py b/tests/textual/test_wire_source.py index 59635665e..88fccee21 100644 --- a/tests/textual/test_wire_source.py +++ b/tests/textual/test_wire_source.py @@ -3,6 +3,7 @@ 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 @@ -21,7 +22,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(), + launch_root=LaunchRoot.from_path(comms.root)) def observed(source): From cab4cccff521249ccebb61a1672494ab53a27565 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:06:35 -0400 Subject: [PATCH 15/33] P1022: follow Core read and session owners in Toad tests Carry NoSessionObservation and Checkout call migrations from #1364; delete both seen_sequences test forwarders and inspect conversation cursors directly. Restore main-push verification and its push-specific base/head selection. --- .github/workflows/ci.yml | 10 +++++--- tests/app/test_channel_display_basis.py | 17 +++++++++---- tests/support/overlapping_annotations.py | 4 +-- tests/support/shared_stdio.py | 3 ++- tests/textual/test_channel_partial_paint.py | 27 ++++++++++++++------- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04e39ce84..109b9b49e 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: @@ -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,7 +58,7 @@ 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' diff --git a/tests/app/test_channel_display_basis.py b/tests/app/test_channel_display_basis.py index 925a25dc8..323866d30 100644 --- a/tests/app/test_channel_display_basis.py +++ b/tests/app/test_channel_display_basis.py @@ -1,4 +1,5 @@ """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 @@ -13,8 +14,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 +26,19 @@ 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 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 hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) diff --git a/tests/support/overlapping_annotations.py b/tests/support/overlapping_annotations.py index 3b276ed18..3ac607dab 100644 --- a/tests/support/overlapping_annotations.py +++ b/tests/support/overlapping_annotations.py @@ -9,7 +9,7 @@ from pathlib import Path from agent_comms.comms import Comms -from agent_comms.session_revision import SessionRevision +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 ( @@ -56,7 +56,7 @@ def overlapping_annotations(directory: Path) -> OverlappingAnnotations: manifest = ContextManifest(owner.incarnation, RecordedContextTurn(TurnId("authored-unsealed-navigation"), TurnIdentity(owner.incarnation, 1)), (observed,), "authored-navigation") - inspection = ContextInspection(owner, (manifest,), SessionRevision.observe(None), + inspection = ContextInspection(owner, (manifest,), NoSessionObservation(), Comms(resolve_comms_route(directory / "unused-wire")), labels) ((_, (source,), _),) = inspection.working_memory() short, long = source.children() diff --git a/tests/support/shared_stdio.py b/tests/support/shared_stdio.py index a51af95ed..e2a2c7211 100644 --- a/tests/support/shared_stdio.py +++ b/tests/support/shared_stdio.py @@ -2,6 +2,7 @@ from __future__ import annotations +from agent_comms.repo import Checkout from contextlib import asynccontextmanager import shlex import sys @@ -25,7 +26,7 @@ async def threads_owned_here(comms, project, names=("alpha", "beta")): 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/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 4886825f5..2801e7c9c 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,5 +1,6 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" +from agent_comms.read_basis import ChannelConversation from support.textual_steps import text_rows_of @@ -12,29 +13,36 @@ 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: rows[-1].seq in ledger.cursor( + viewer, snapshot).for_conversation(channel) and not history.marking, + why="the first viewport to be marked") in_view = {fragment.message.seq for fragment in history.read_fragments()} - assert 0 < len(seen()) < len(rows) - assert seen() == in_view + 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) # Messages beyond the first viewport stay unread until their text is scrolled to. 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} + 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) @@ -48,4 +56,5 @@ def seen(): 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} + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) From 5db45987449711d259955a7e99b061aede1beb8e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:19:18 -0400 Subject: [PATCH 16/33] P1022: migrate exposed unread, painted-read and context-page callers --- tests/app/test_channel_display_basis.py | 7 +++++-- tests/support/overlapping_annotations.py | 15 ++++++++------- tests/textual/test_channel_partial_paint.py | 10 ++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/app/test_channel_display_basis.py b/tests/app/test_channel_display_basis.py index 323866d30..91ba1b4ca 100644 --- a/tests/app/test_channel_display_basis.py +++ b/tests/app/test_channel_display_basis.py @@ -1,6 +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): @@ -32,13 +33,15 @@ def shown(): ), 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 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 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/support/overlapping_annotations.py b/tests/support/overlapping_annotations.py index 3ac607dab..74d9d1e6d 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.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) @@ -56,8 +56,9 @@ def overlapping_annotations(directory: Path) -> OverlappingAnnotations: manifest = ContextManifest(owner.incarnation, RecordedContextTurn(TurnId("authored-unsealed-navigation"), TurnIdentity(owner.incarnation, 1)), (observed,), "authored-navigation") - inspection = ContextInspection(owner, (manifest,), NoSessionObservation(), - 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/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 2801e7c9c..25a66b49a 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -22,9 +22,10 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ await ui.open_channel("#team", actor=viewer) chat = app.screen.query_one(CommsChatView) history, window = chat.message_history, chat.window - await ui.until(lambda: rows[-1].seq in ledger.cursor( - viewer, snapshot).for_conversation(channel) and not history.marking, + 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(in_view) < len(rows) cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) @@ -44,11 +45,12 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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") + await history._read_worker.wait() assert all(fragment.evidence is None for fragment in history.wire_fragments) + completed = history._read_worker for _ in range(50): history.mark_visible() - assert not history.marking + assert history._read_worker is completed recorded = tuple(message.view_key for message in history.messages) await ui.press("ctrl+t") await ui.until(lambda: isinstance(history.style, MarkdownMessageStyle), why="the Markdown style") From 54142ff33f99384d07b84c760baacf6212455921 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:24:57 -0400 Subject: [PATCH 17/33] P1022: complete unread and divider test caller migration --- tests/app/test_unread_clears_on_opening.py | 34 +++++++++---------- ...test_divider_only_viewport_stays_unread.py | 5 +-- 2 files changed, 20 insertions(+), 19 deletions(-) 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/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index f12b88a91..032348009 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 @@ -49,6 +50,6 @@ 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") + assert history._read_worker is None await ui.step(.2) - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == 1 + assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == ExactUnread(count=1) From 3001ecc752e5cc8c0acf56ae3d8290c90a3f3234 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:48:43 -0400 Subject: [PATCH 18/33] P1022: use declared fixture identities and scroll the paged viewport --- tests/support/overlapping_annotations.py | 6 ++--- tests/textual/test_channel_partial_paint.py | 24 +++++++++---------- ...test_divider_only_viewport_stays_unread.py | 2 +- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/support/overlapping_annotations.py b/tests/support/overlapping_annotations.py index 74d9d1e6d..738983549 100644 --- a/tests/support/overlapping_annotations.py +++ b/tests/support/overlapping_annotations.py @@ -51,10 +51,10 @@ 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") captured = service.contexts.record_after(AbsentContext(), manifest) inspection = ContextInspection(service.registry.entry(owner.name).entry, diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 25a66b49a..7909424cf 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,7 +1,6 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" from agent_comms.read_basis import ChannelConversation -from support.textual_steps import text_rows_of async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): @@ -30,18 +29,15 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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) - # Messages beyond the first viewport stay unread until their text is scrolled to. - for message in reversed(rows): - 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 ledger.cursor( - viewer, snapshot).for_conversation(channel), - why=f"row {message.seq} to be marked once scrolled to") + # Walk the actual viewport, including pages prepared on demand. A + # virtualized history need not keep every authored row mounted. + window.scroll_end(animate=False, immediate=True) + for _ in range(len(rows) * 4): + await ui.step(.2) + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + if all(row.seq in cursor for row in rows): + break + window.scroll_page_up(animate=False) 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. @@ -51,6 +47,8 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ for _ in range(50): history.mark_visible() 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(message.view_key for message in history.messages) await ui.press("ctrl+t") await ui.until(lambda: isinstance(history.style, MarkdownMessageStyle), why="the Markdown style") diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index 032348009..96f914700 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -12,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)) From 3e7dac51497416c2e2ed1867f5e046c8fa4d75fe Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:07:49 -0400 Subject: [PATCH 19/33] P1022: expose owned read work and migrate complete caller family --- .github/workflows/ci.yml | 2 +- src/toad/widgets/message_notifications.py | 6 +- tests/app/test_channel_scope_expansion.py | 4 +- tests/support/textual_steps.py | 2 +- tests/textual/test_channel_partial_paint.py | 31 ++++---- ...text_tree_keeps_overlapping_annotations.py | 71 +++++++++---------- ...test_divider_only_viewport_stays_unread.py | 2 +- tests/textual/test_dm_peer_replacement.py | 10 +-- tests/textual/test_hidden_channel_refresh.py | 3 +- 9 files changed, 66 insertions(+), 65 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 109b9b49e..c56772a60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,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 diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index d5a2fe5d5..0866fc1e4 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -90,18 +90,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 = None super().__init__(*args, **kwargs) def mark_visible(self) -> None: - if self._read_worker is not None and not self._read_worker.is_finished: + if self.read_worker is not None and not self.read_worker.is_finished: return self.read_visible().start(self) 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/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/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 7909424cf..264cd7285 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -24,29 +24,32 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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() + await history.read_worker.wait() in_view = {fragment.message.seq for fragment in history.read_fragments()} 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) - # Walk the actual viewport, including pages prepared on demand. A - # virtualized history need not keep every authored row mounted. - window.scroll_end(animate=False, immediate=True) - for _ in range(len(rows) * 4): - await ui.step(.2) - cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) - if all(row.seq in cursor for row in rows): - break - window.scroll_page_up(animate=False) + # 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 ledger.cursor(viewer, snapshot).for_conversation(channel): + continue + fragment = next(part for part in history.fragments + if part.message.view_key == message.view_key) + 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 history._read_worker.wait() - assert all(fragment.evidence is None for fragment in history.wire_fragments) - completed = history._read_worker + 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 history._read_worker is completed + 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(message.view_key for message in history.messages) diff --git a/tests/textual/test_context_tree_keeps_overlapping_annotations.py b/tests/textual/test_context_tree_keeps_overlapping_annotations.py index adb6b4e2e..1a7a2462f 100644 --- a/tests/textual/test_context_tree_keeps_overlapping_annotations.py +++ b/tests/textual/test_context_tree_keeps_overlapping_annotations.py @@ -1,70 +1,65 @@ -"""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) + 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.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 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_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index 96f914700..7052968e8 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -50,6 +50,6 @@ def caption_is_last_row(): assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) mark_visible(history) - assert history._read_worker is None + assert history.read_worker is None await ui.step(.2) 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..cfb28781a 100644 --- a/tests/textual/test_dm_peer_replacement.py +++ b/tests/textual/test_dm_peer_replacement.py @@ -15,16 +15,16 @@ async def test_a_page_painted_for_the_old_peer_does_not_mark_the_replacement_pee viewer = workspace.viewer 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) @@ -39,7 +39,7 @@ 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") + await chat.message_history.read_worker.wait() assert comms.bus.pending_count(viewer, "peer") == 1 assert not any(message.body == "new peer never painted" for message in chat.message_history.messages) finally: diff --git a/tests/textual/test_hidden_channel_refresh.py b/tests/textual/test_hidden_channel_refresh.py index 63a9a6290..34b36bf9b 100644 --- a/tests/textual/test_hidden_channel_refresh.py +++ b/tests/textual/test_hidden_channel_refresh.py @@ -20,8 +20,9 @@ async def test_hidden_channel_refresh_does_no_root_check_and_resumed_window_fetc 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, + 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. From fe378600ce371e9adeb3dc0edc020dc02ecbd810 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:17:37 -0400 Subject: [PATCH 20/33] P1022: address retained source rows and finish retired history callers --- tests/textual/test_channel_partial_paint.py | 15 +++++++++------ ..._context_tree_keeps_overlapping_annotations.py | 3 +++ .../test_divider_only_viewport_stays_unread.py | 6 ++++-- tests/textual/test_dm_peer_replacement.py | 5 +++-- tests/textual/test_hidden_channel_refresh.py | 5 +++-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 264cd7285..b211d59f5 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -5,6 +5,7 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): from toad.widgets.comms_chat import CommsChatView + from toad.widgets.history_anchor import FragmentPosition from toad.message_rows import IrcMessageStyle, MarkdownMessageStyle workspace.thread("peer", "team", running=True) @@ -36,8 +37,10 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ continue fragment = next(part for part in history.fragments if part.message.view_key == message.view_key) - await history.reveal_message_head(fragment, lambda: True) - history.show_fragment(fragment) + async with window.preserve_history(root=history): + window.reader_position = FragmentPosition(fragment.key, 0, 1, block=0) + await history.prepare_rows(lambda: True, window=window, target=history, + selected=history.selected_categories, rows_needed=window.outer_size.height) 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") @@ -52,12 +55,12 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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(message.view_key for message in history.messages) + 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 + 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 1a7a2462f..a63c68361 100644 --- a/tests/textual/test_context_tree_keeps_overlapping_annotations.py +++ b/tests/textual/test_context_tree_keeps_overlapping_annotations.py @@ -17,6 +17,7 @@ async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_n 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 @@ -24,6 +25,8 @@ async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_n intent.selected = short tree.present(inspection.working_memory()) 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 diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index 7052968e8..b0e8f86c5 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -50,6 +50,8 @@ def caption_is_last_row(): assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) mark_visible(history) - assert history.read_worker is None - await ui.step(.2) + # 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 cfb28781a..8104780a3 100644 --- a/tests/textual/test_dm_peer_replacement.py +++ b/tests/textual/test_dm_peer_replacement.py @@ -30,7 +30,7 @@ def pause_old_mark(self, page, **kwargs): 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 [fragment.message.body for fragment in chat.message_history.fragments] == ["old peer painted"] assert comms.bus.pending_count(viewer, "peer") == 1 comms.registry.unregister("peer") @@ -41,6 +41,7 @@ def pause_old_mark(self, page, **kwargs): release.set() await chat.message_history.read_worker.wait() assert comms.bus.pending_count(viewer, "peer") == 1 - assert not any(message.body == "new peer never painted" for message in chat.message_history.messages) + 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 34b36bf9b..9efef73f7 100644 --- a/tests/textual/test_hidden_channel_refresh.py +++ b/tests/textual/test_hidden_channel_refresh.py @@ -28,9 +28,10 @@ async def test_hidden_channel_refresh_does_no_root_check_and_resumed_window_fetc # Exercise the real hidden callback, not a mocked visibility test. 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") From 54db4ff84ddf70ccb13ce7d20d5f28339fb2a195 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:23:31 -0400 Subject: [PATCH 21/33] Own app route selection in CoordinationAccess --- src/toad/about.py | 6 +++--- src/toad/acp/agent_process.py | 2 +- src/toad/acp/agent_session.py | 3 +-- src/toad/acp/comms_updates.py | 6 +----- src/toad/acp/transcript_reader.py | 3 +-- src/toad/cli.py | 2 +- src/toad/comms_root.py | 17 +++++++++++++++-- src/toad/delivery_failure_view.py | 3 +-- src/toad/navigation_preparation.py | 3 +-- src/toad/screens/main.py | 3 +-- src/toad/session_navigation.py | 11 +++++------ src/toad/session_scope.py | 5 ++--- src/toad/slash_command.py | 2 +- src/toad/target_commands.py | 12 ++++-------- src/toad/thread_actions.py | 3 +-- src/toad/widgets/comms_chat.py | 2 +- src/toad/widgets/comms_transfer.py | 2 +- src/toad/widgets/context_explorer.py | 3 +-- src/toad/widgets/thread_comms.py | 3 +-- src/toad/wire_history.py | 8 +++----- tests/model/test_agent_prompt_route.py | 6 ++---- tests/textual/test_conversation_kind.py | 3 ++- tests/textual/test_hidden_channel_refresh.py | 17 +++++++++++------ tests/textual/test_stopped_thread_attachment.py | 3 +-- tests/textual/test_target_command_context.py | 2 +- tests/textual/test_wire_source.py | 3 ++- 26 files changed, 65 insertions(+), 68 deletions(-) diff --git a/src/toad/about.py b/src/toad/about.py index c5b437d2c..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(launch_root: LaunchRoot) -> str: +def render(root: Path) -> str: """Render about markdown. Returns: @@ -95,7 +95,7 @@ def render(launch_root: LaunchRoot) -> str: "COLORTERM": os.environ.get("COLORTERM", ""), "COMMS_AGENT_ARGS": shlex.join(InheritedArguments().configured().argv), "COMMS_AGENT_BIN": ConfiguredBinary().configured(), - "COMMS_ROOT": str(launch_root.path), + "COMMS_ROOT": str(root), "CONFIG": config, "DATA_PATH": paths.get_data(), "LOG_PATH": paths.get_log(), diff --git a/src/toad/acp/agent_process.py b/src/toad/acp/agent_process.py index 20b3ed9aa..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.of(agent.controller.transcripts.access.launch_root) + 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 c28de24fa..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.of, self.agent.controller.transcripts.access.launch_root) + 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 124a95b50..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.of(self.access.launch_root).require_source(root)).service + lambda: self.access.require(self.access.selected(root)).service ) async def thread_presentation( diff --git a/src/toad/cli.py b/src/toad/cli.py index cf6796c97..7c45f6c39 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -280,7 +280,7 @@ def about(launch_root: LaunchRoot) -> None: from toad import about - print(about.render(launch_root)) + print(about.render(launch_root.path)) if __name__ == "__main__": diff --git a/src/toad/comms_root.py b/src/toad/comms_root.py index be62aa2a7..4efacaace 100644 --- a/src/toad/comms_root.py +++ b/src/toad/comms_root.py @@ -360,18 +360,31 @@ def __init__(self, app: ToadApp, launch_root: LaunchRoot) -> 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.of(self.launch_root) + 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.of(self.launch_root) != selected: + if self.selection != selected: raise RouteChangedError("Comms route changed before the operation") def require(self, selected: RouteSelection) -> Observing: diff --git a/src/toad/delivery_failure_view.py b/src/toad/delivery_failure_view.py index 59e82a749..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 RouteSelection if not body.strip(): return @@ -136,7 +135,7 @@ async def submit_comms_message(self, root, conversation, body, current, present) return self._enter_send_state(SendPending()) route_current = await self.app.preparation.run_thread( - lambda: RouteSelection.of(self.app.coordination_access.launch_root).root_is_current(root) + lambda: self.app.coordination_access.current(root) ) if not route_current or not current(): raise HumanSendRefusedError(RelationViolationError("Message route changed; reopen this view")) diff --git a/src/toad/navigation_preparation.py b/src/toad/navigation_preparation.py index 3cfc8664e..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 RouteSelection 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( - lambda: RouteSelection.of(navigator.app.coordination_access.launch_root).root_is_current(self.request.root) + lambda: navigator.app.coordination_access.current(self.request.root) ): raise RuntimeError("The thread's bus route changed while opening") return await prepared.open(self) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index f18409db8..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.of(access.launch_root).require_source(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 ad7a4e775..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 RouteSelection from agent_comms.thread_identity import ThreadIncarnation from toad.core import events as core_events @@ -403,7 +402,7 @@ async def history( if not origin.current(app.thread_navigation, owner_mode): return app.selected_mode if not await app.preparation.run_thread( - lambda: RouteSelection.of(app.coordination_access.launch_root).root_is_current(requested_root) + lambda: app.coordination_access.current(requested_root) ): return app.selected_mode if not origin.current(app.thread_navigation, owner_mode): @@ -570,11 +569,11 @@ def read(): answers = {} for root, (threads, channels) in asked.items(): try: - selected = RouteSelection.of(self.app.coordination_access.launch_root).require_source(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.of(self.app.coordination_access.launch_root).require_source(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 da08dd0c5..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.of(access.launch_root).require_source(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.of(self.app.coordination_access.launch_root).require_source(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 ba19b8592..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(conversation.app.coordination_access.launch_root) + 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 4b3bcfe46..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 RouteSelection from toad.slash_command import CommandPresentation, LocalCommand, SlashCommand from agent_comms.cli_command import CliCommand, TargetAction from toad.thread_actions import ThreadAction @@ -53,18 +51,16 @@ def title(self) -> str: return ", ".join(names) if names else str(self.project) def current(self): - if not RouteSelection.of(self.app.coordination_access.launch_root).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): - selected = RouteSelection.of(self.app.coordination_access.launch_root).require_source(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.of(self.app.coordination_access.launch_root).require_source(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 a266bd5bf..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.of(app.coordination_access.launch_root).require_source(source_root) + selected = app.coordination_access.selected(source_root) if any(target in self.requests for target in action.resources): app.notify(f"An action for {subject} is already in progress", title="Session action") return diff --git a/src/toad/widgets/comms_chat.py b/src/toad/widgets/comms_chat.py index 1484fd595..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(), launch_root=self.app.coordination_access.launch_root, + IrcMessageStyle(), access=self.app.coordination_access, )) self.refresh_identity(comms.root) self.prepare_prompt() diff --git a/src/toad/widgets/comms_transfer.py b/src/toad/widgets/comms_transfer.py index f546ec20d..bb295317d 100644 --- a/src/toad/widgets/comms_transfer.py +++ b/src/toad/widgets/comms_transfer.py @@ -104,7 +104,7 @@ def __init__(self, app) -> None: def open(self, kind: type[TransferRequest]) -> None: access = self.app.coordination_access - selected = RouteSelection.of(access.launch_root).require_source(access.observation.observed().service.root) + 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 3c0a53d56..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.of(access.launch_root).require_source(identity.root_path())).service + lambda: access.require(access.selected(identity.root_path())).service ) if not self._reading(identity): return diff --git a/src/toad/widgets/thread_comms.py b/src/toad/widgets/thread_comms.py index b4d064d0e..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.of(access.launch_root).require_source(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 c373e6d70..0f0e576e2 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -15,8 +15,6 @@ from typing import TYPE_CHECKING, ClassVar from agent_comms.comms import Comms -from agent_comms.launch_root import LaunchRoot -from agent_comms.route_selection import RouteSelection from agent_comms.repo import Checkout from agent_comms.display_references import CurrentDisplayReferences from agent_comms.historical_views import ( @@ -61,7 +59,7 @@ if TYPE_CHECKING: from agent_comms.read_basis import NamedViewer - from toad.comms_root import Observation + from toad.comms_root import CoordinationAccess, Observation from toad.core.input_events import UserInputSubmitted from toad.widgets.comms_chat import CommsChatView from toad.widgets.transcript_lines import Lines @@ -296,7 +294,7 @@ class WireSource: project: Checkout me: str style: WireMessageStyle - launch_root: LaunchRoot = field(kw_only=True) + access: CoordinationAccess = field(kw_only=True) tail_limit: int = 8 page_limit: int = 40 @@ -420,7 +418,7 @@ def uses_observation(self, observation: Observation) -> bool: async def route_is_current(self) -> bool: return not self.closed and await self.runtime.run_thread( - lambda: RouteSelection.of(self.loader.launch_root).root_is_current(self.loader.comms.root) + lambda: self.loader.access.current(self.loader.comms.root) ) def status_thread(self, access): 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/textual/test_conversation_kind.py b/tests/textual/test_conversation_kind.py index b335b2c15..cdc0d63d6 100644 --- a/tests/textual/test_conversation_kind.py +++ b/tests/textual/test_conversation_kind.py @@ -10,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 @@ -55,7 +56,7 @@ def test_conversation_family_resolves_copied_pages_and_current_labels( assert HistoryTarget.for_conversation(conversation, references) == route page = conversation.page( WireSource(comms, conversation, comms.registry.require(viewer).worktree, viewer, IrcMessageStyle(), - launch_root=LaunchRoot.from_path(comms.root)), + access=AppAgents.unrun(tmp_path, LaunchRoot.from_path(comms.root)).app.coordination_access), PageBounds(limit=8), ) assert body in [message.body for message in page.messages] diff --git a/tests/textual/test_hidden_channel_refresh.py b/tests/textual/test_hidden_channel_refresh.py index e895678de..d9c383c5e 100644 --- a/tests/textual/test_hidden_channel_refresh.py +++ b/tests/textual/test_hidden_channel_refresh.py @@ -1,9 +1,12 @@ -"""A hidden channel leaves its history unchanged; returning 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 -async def test_hidden_channel_refresh_leaves_history_unchanged_and_resumed_window_fetches_current_message(workspace): +async def test_hidden_channel_refresh_does_no_root_check_and_resumed_window_fetches_current_message(workspace): from toad.widgets.comms_chat import CommsChatView workspace.thread("peer", "one", "two") @@ -18,12 +21,14 @@ async def test_hidden_channel_refresh_leaves_history_unchanged_and_resumed_windo 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") # Let the hidden reader settle before the next message arrives. - await ui.until(lambda: history.state.accepts_source_work and not history.marking, + 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") - for _ in range(40): - await refresh_chat(chat) - assert not any(m.body == "ARRIVED-WHILE-HIDDEN" for m in history.messages) + 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) await ui.select_tab(first) await ui.until(lambda: any(m.body == "ARRIVED-WHILE-HIDDEN" for m in history.messages), why="the resumed channel to fetch the current message") diff --git a/tests/textual/test_stopped_thread_attachment.py b/tests/textual/test_stopped_thread_attachment.py index 6bb38baa6..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.of(workspace.launch_root).require_source(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_target_command_context.py b/tests/textual/test_target_command_context.py index 646499d28..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(workspace.launch_root) + 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 88fccee21..8d96d8159 100644 --- a/tests/textual/test_wire_source.py +++ b/tests/textual/test_wire_source.py @@ -8,6 +8,7 @@ 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 @@ -23,7 +24,7 @@ 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(), - launch_root=LaunchRoot.from_path(comms.root)) + access=AppAgents.unrun(project.path, LaunchRoot.from_path(comms.root)).app.coordination_access) def observed(source): From 4a7a415d0a60e5624fd5c3c3b26bc7f9e4a74bc3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:23:46 -0400 Subject: [PATCH 22/33] P1032: repaint channel members only for changed pins --- src/toad/widgets/comms_sidebar.py | 5 +++-- src/toad/widgets/sidebar_tree.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/sidebar_tree.py b/src/toad/widgets/sidebar_tree.py index 7342ad0ba..91340c513 100644 --- a/src/toad/widgets/sidebar_tree.py +++ b/src/toad/widgets/sidebar_tree.py @@ -459,8 +459,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) From f2b22ebf0be218d7f1496b99283e64650e9feee3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:24:56 -0400 Subject: [PATCH 23/33] P1022: restore mounted source ranges and migrate saved-view consumers --- tests/textual/test_channel_partial_paint.py | 15 ++++++++++----- ..._context_tree_keeps_overlapping_annotations.py | 4 +++- tests/textual/test_dm_peer_replacement.py | 6 ++++-- tests/textual/test_saved_view_read_only.py | 9 +++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index b211d59f5..24fab5862 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,11 +1,12 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" +from dataclasses import replace + from agent_comms.read_basis import ChannelConversation async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): from toad.widgets.comms_chat import CommsChatView - from toad.widgets.history_anchor import FragmentPosition from toad.message_rows import IrcMessageStyle, MarkdownMessageStyle workspace.thread("peer", "team", running=True) @@ -37,10 +38,14 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ continue fragment = next(part for part in history.fragments if part.message.view_key == message.view_key) - async with window.preserve_history(root=history): - window.reader_position = FragmentPosition(fragment.key, 0, 1, block=0) - await history.prepare_rows(lambda: True, window=window, target=history, - selected=history.selected_categories, rows_needed=window.outer_size.height) + page, index = next((page, index) for page in history.pages + for index, member in enumerate(page.prepared.fragments) + if member is fragment) + destination = replace(page.prepared.capture_range(), start=index, stop=index + 1, + members=(fragment,)) + await history.restore_reader_ranges((destination,), lambda: True) + history.show_fragment(fragment) + window.request_preparation() 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") diff --git a/tests/textual/test_context_tree_keeps_overlapping_annotations.py b/tests/textual/test_context_tree_keeps_overlapping_annotations.py index a63c68361..e7245d7e6 100644 --- a/tests/textual/test_context_tree_keeps_overlapping_annotations.py +++ b/tests/textual/test_context_tree_keeps_overlapping_annotations.py @@ -50,7 +50,9 @@ async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_n 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] diff --git a/tests/textual/test_dm_peer_replacement.py b/tests/textual/test_dm_peer_replacement.py index 8104780a3..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,6 +14,7 @@ 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_page_read @@ -31,7 +33,7 @@ def pause_old_mark(self, page, **kwargs): chat = ui.textual[0].screen.query_one(CommsChatView) assert await asyncio.to_thread(entered.wait, 4) assert [fragment.message.body for fragment in chat.message_history.fragments] == ["old peer painted"] - assert comms.bus.pending_count(viewer, "peer") == 1 + assert comms.bus.pending_count(viewer, target) == 1 comms.registry.unregister("peer") comms.registry.remove("peer") @@ -40,7 +42,7 @@ def pause_old_mark(self, page, **kwargs): await ui.select_tab(owner) release.set() await chat.message_history.read_worker.wait() - assert comms.bus.pending_count(viewer, "peer") == 1 + 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: 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 From 9df4c247dac547433fcb80a1313e50524364ffbd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:45:06 -0400 Subject: [PATCH 24/33] Import the route access owner at module scope --- src/toad/wire_history.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/wire_history.py b/src/toad/wire_history.py index 0f0e576e2..34e7ee642 100644 --- a/src/toad/wire_history.py +++ b/src/toad/wire_history.py @@ -32,6 +32,7 @@ ) from agent_comms.transcript_events import IncomingTranscript +from toad.comms_root import CoordinationAccess from toad.conversation_kind import ConversationKind from toad.line_blocks import Body, Gap, LineBlock, MutedRole, Summary from toad.message_rows import MessageRow, WireMessageStyle @@ -59,7 +60,7 @@ if TYPE_CHECKING: from agent_comms.read_basis import NamedViewer - from toad.comms_root import CoordinationAccess, Observation + from toad.comms_root import Observation from toad.core.input_events import UserInputSubmitted from toad.widgets.comms_chat import CommsChatView from toad.widgets.transcript_lines import Lines From cbcc720c938db1facd0a8ab89d19d8e4e9c0967a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 14:00:40 -0400 Subject: [PATCH 25/33] Give Action one metaclass with both parents so Toad imports on Textual main textual#215 gave KeyDeclaration the KeyDeclarationType metaclass, while Action also inherits DeclaredFamily's FamilyMeta; neither metaclass subclasses the other, so `import toad.widget_actions` raises a metaclass conflict and agent-comms' debt-src/debt-tools die importing the Toad cutover tools on every head. KeyFamilyMeta(FamilyMeta, KeyDeclarationType) is the one class both bases require; same lines as toad#1338's fix, so its merge of main resolves to them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/toad/widget_actions.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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.""" From fe5b3ae0581d43fc64fc8d05e07abb3134e475ca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:06:35 -0400 Subject: [PATCH 26/33] P1022: follow Core read and session owners in Toad tests Carry NoSessionObservation and Checkout call migrations from #1364; delete both seen_sequences test forwarders and inspect conversation cursors directly. Restore main-push verification and its push-specific base/head selection. --- .github/workflows/ci.yml | 10 +++++--- tests/app/test_channel_display_basis.py | 17 +++++++++---- tests/support/shared_stdio.py | 1 + tests/textual/test_channel_partial_paint.py | 27 ++++++++++++++------- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a2b25f30..6584534d8 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: @@ -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,7 +58,7 @@ 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' diff --git a/tests/app/test_channel_display_basis.py b/tests/app/test_channel_display_basis.py index 925a25dc8..323866d30 100644 --- a/tests/app/test_channel_display_basis.py +++ b/tests/app/test_channel_display_basis.py @@ -1,4 +1,5 @@ """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 @@ -13,8 +14,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 +26,19 @@ 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 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 hidden.seq not in ledger.cursor(viewer, snapshot).for_conversation( + ledger.conversation(hidden, snapshot)) diff --git a/tests/support/shared_stdio.py b/tests/support/shared_stdio.py index c2b70b759..b3d2443ab 100644 --- a/tests/support/shared_stdio.py +++ b/tests/support/shared_stdio.py @@ -2,6 +2,7 @@ from __future__ import annotations +from agent_comms.repo import Checkout from contextlib import asynccontextmanager import shlex import sys diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 4886825f5..2801e7c9c 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,5 +1,6 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" +from agent_comms.read_basis import ChannelConversation from support.textual_steps import text_rows_of @@ -12,29 +13,36 @@ 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: rows[-1].seq in ledger.cursor( + viewer, snapshot).for_conversation(channel) and not history.marking, + why="the first viewport to be marked") in_view = {fragment.message.seq for fragment in history.read_fragments()} - assert 0 < len(seen()) < len(rows) - assert seen() == in_view + 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) # Messages beyond the first viewport stay unread until their text is scrolled to. 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} + 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) @@ -48,4 +56,5 @@ def seen(): 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} + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + assert all(row.seq in cursor for row in rows) From 1fd68a0b8608818bfad52d27d1bee2292b490e4a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:19:18 -0400 Subject: [PATCH 27/33] P1022: migrate exposed unread, painted-read and context-page callers --- tests/app/test_channel_display_basis.py | 7 +++++-- tests/support/overlapping_annotations.py | 15 ++++++++------- tests/textual/test_channel_partial_paint.py | 10 ++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/app/test_channel_display_basis.py b/tests/app/test_channel_display_basis.py index 323866d30..91ba1b4ca 100644 --- a/tests/app/test_channel_display_basis.py +++ b/tests/app/test_channel_display_basis.py @@ -1,6 +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): @@ -32,13 +33,15 @@ def shown(): ), 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 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 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/support/overlapping_annotations.py b/tests/support/overlapping_annotations.py index 3ac607dab..74d9d1e6d 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.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) @@ -56,8 +56,9 @@ def overlapping_annotations(directory: Path) -> OverlappingAnnotations: manifest = ContextManifest(owner.incarnation, RecordedContextTurn(TurnId("authored-unsealed-navigation"), TurnIdentity(owner.incarnation, 1)), (observed,), "authored-navigation") - inspection = ContextInspection(owner, (manifest,), NoSessionObservation(), - 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/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 2801e7c9c..25a66b49a 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -22,9 +22,10 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ await ui.open_channel("#team", actor=viewer) chat = app.screen.query_one(CommsChatView) history, window = chat.message_history, chat.window - await ui.until(lambda: rows[-1].seq in ledger.cursor( - viewer, snapshot).for_conversation(channel) and not history.marking, + 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(in_view) < len(rows) cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) @@ -44,11 +45,12 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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") + await history._read_worker.wait() assert all(fragment.evidence is None for fragment in history.wire_fragments) + completed = history._read_worker for _ in range(50): history.mark_visible() - assert not history.marking + assert history._read_worker is completed recorded = tuple(message.view_key for message in history.messages) await ui.press("ctrl+t") await ui.until(lambda: isinstance(history.style, MarkdownMessageStyle), why="the Markdown style") From f0524cc1c2d38df8f8b9f1cb071f4390aa82df23 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:24:57 -0400 Subject: [PATCH 28/33] P1022: complete unread and divider test caller migration --- tests/app/test_unread_clears_on_opening.py | 34 +++++++++---------- ...test_divider_only_viewport_stays_unread.py | 5 +-- 2 files changed, 20 insertions(+), 19 deletions(-) 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/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index f12b88a91..032348009 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 @@ -49,6 +50,6 @@ 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") + assert history._read_worker is None await ui.step(.2) - assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == 1 + assert comms.views.viewer_snapshot(Checkout(project)).channel_unread["#team"] == ExactUnread(count=1) From 8cce6c5868c609e286408eaebd5938865a442eca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:48:43 -0400 Subject: [PATCH 29/33] P1022: use declared fixture identities and scroll the paged viewport --- tests/support/overlapping_annotations.py | 6 ++--- tests/textual/test_channel_partial_paint.py | 24 +++++++++---------- ...test_divider_only_viewport_stays_unread.py | 2 +- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/support/overlapping_annotations.py b/tests/support/overlapping_annotations.py index 74d9d1e6d..738983549 100644 --- a/tests/support/overlapping_annotations.py +++ b/tests/support/overlapping_annotations.py @@ -51,10 +51,10 @@ 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") captured = service.contexts.record_after(AbsentContext(), manifest) inspection = ContextInspection(service.registry.entry(owner.name).entry, diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 25a66b49a..7909424cf 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,7 +1,6 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" from agent_comms.read_basis import ChannelConversation -from support.textual_steps import text_rows_of async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): @@ -30,18 +29,15 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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) - # Messages beyond the first viewport stay unread until their text is scrolled to. - for message in reversed(rows): - 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 ledger.cursor( - viewer, snapshot).for_conversation(channel), - why=f"row {message.seq} to be marked once scrolled to") + # Walk the actual viewport, including pages prepared on demand. A + # virtualized history need not keep every authored row mounted. + window.scroll_end(animate=False, immediate=True) + for _ in range(len(rows) * 4): + await ui.step(.2) + cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) + if all(row.seq in cursor for row in rows): + break + window.scroll_page_up(animate=False) 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. @@ -51,6 +47,8 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ for _ in range(50): history.mark_visible() 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(message.view_key for message in history.messages) await ui.press("ctrl+t") await ui.until(lambda: isinstance(history.style, MarkdownMessageStyle), why="the Markdown style") diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index 032348009..96f914700 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -12,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)) From 1d7ae68d3dcb821b61f0bdbe280a182e90868ffc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:07:49 -0400 Subject: [PATCH 30/33] P1022: expose owned read work and migrate complete caller family --- .github/workflows/ci.yml | 2 +- src/toad/widgets/message_notifications.py | 6 +- tests/app/test_channel_scope_expansion.py | 4 +- tests/support/textual_steps.py | 2 +- tests/textual/test_channel_partial_paint.py | 31 ++++---- ...text_tree_keeps_overlapping_annotations.py | 71 +++++++++---------- ...test_divider_only_viewport_stays_unread.py | 2 +- tests/textual/test_dm_peer_replacement.py | 10 +-- 8 files changed, 64 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6584534d8..1a782d3d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,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 diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index d5a2fe5d5..0866fc1e4 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -90,18 +90,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 = None super().__init__(*args, **kwargs) def mark_visible(self) -> None: - if self._read_worker is not None and not self._read_worker.is_finished: + if self.read_worker is not None and not self.read_worker.is_finished: return self.read_visible().start(self) 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/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/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 7909424cf..264cd7285 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -24,29 +24,32 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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() + await history.read_worker.wait() in_view = {fragment.message.seq for fragment in history.read_fragments()} 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) - # Walk the actual viewport, including pages prepared on demand. A - # virtualized history need not keep every authored row mounted. - window.scroll_end(animate=False, immediate=True) - for _ in range(len(rows) * 4): - await ui.step(.2) - cursor = ledger.cursor(viewer, snapshot).for_conversation(channel) - if all(row.seq in cursor for row in rows): - break - window.scroll_page_up(animate=False) + # 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 ledger.cursor(viewer, snapshot).for_conversation(channel): + continue + fragment = next(part for part in history.fragments + if part.message.view_key == message.view_key) + 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 history._read_worker.wait() - assert all(fragment.evidence is None for fragment in history.wire_fragments) - completed = history._read_worker + 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 history._read_worker is completed + 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(message.view_key for message in history.messages) diff --git a/tests/textual/test_context_tree_keeps_overlapping_annotations.py b/tests/textual/test_context_tree_keeps_overlapping_annotations.py index adb6b4e2e..1a7a2462f 100644 --- a/tests/textual/test_context_tree_keeps_overlapping_annotations.py +++ b/tests/textual/test_context_tree_keeps_overlapping_annotations.py @@ -1,70 +1,65 @@ -"""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) + 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.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 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_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index 96f914700..7052968e8 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -50,6 +50,6 @@ def caption_is_last_row(): assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) mark_visible(history) - assert history._read_worker is None + assert history.read_worker is None await ui.step(.2) 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..cfb28781a 100644 --- a/tests/textual/test_dm_peer_replacement.py +++ b/tests/textual/test_dm_peer_replacement.py @@ -15,16 +15,16 @@ async def test_a_page_painted_for_the_old_peer_does_not_mark_the_replacement_pee viewer = workspace.viewer 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) @@ -39,7 +39,7 @@ 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") + await chat.message_history.read_worker.wait() assert comms.bus.pending_count(viewer, "peer") == 1 assert not any(message.body == "new peer never painted" for message in chat.message_history.messages) finally: From cfea9c6c3b9e7fe837f78a7c28573de2c5aaa52b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:17:37 -0400 Subject: [PATCH 31/33] P1022: address retained source rows and finish retired history callers --- tests/textual/test_channel_partial_paint.py | 15 +++++++++------ ..._context_tree_keeps_overlapping_annotations.py | 3 +++ .../test_divider_only_viewport_stays_unread.py | 6 ++++-- tests/textual/test_dm_peer_replacement.py | 5 +++-- tests/textual/test_hidden_channel_refresh.py | 5 +++-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 264cd7285..b211d59f5 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -5,6 +5,7 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): from toad.widgets.comms_chat import CommsChatView + from toad.widgets.history_anchor import FragmentPosition from toad.message_rows import IrcMessageStyle, MarkdownMessageStyle workspace.thread("peer", "team", running=True) @@ -36,8 +37,10 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ continue fragment = next(part for part in history.fragments if part.message.view_key == message.view_key) - await history.reveal_message_head(fragment, lambda: True) - history.show_fragment(fragment) + async with window.preserve_history(root=history): + window.reader_position = FragmentPosition(fragment.key, 0, 1, block=0) + await history.prepare_rows(lambda: True, window=window, target=history, + selected=history.selected_categories, rows_needed=window.outer_size.height) 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") @@ -52,12 +55,12 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ 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(message.view_key for message in history.messages) + 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 + 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 1a7a2462f..a63c68361 100644 --- a/tests/textual/test_context_tree_keeps_overlapping_annotations.py +++ b/tests/textual/test_context_tree_keeps_overlapping_annotations.py @@ -17,6 +17,7 @@ async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_n 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 @@ -24,6 +25,8 @@ async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_n intent.selected = short tree.present(inspection.working_memory()) 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 diff --git a/tests/textual/test_divider_only_viewport_stays_unread.py b/tests/textual/test_divider_only_viewport_stays_unread.py index 7052968e8..b0e8f86c5 100644 --- a/tests/textual/test_divider_only_viewport_stays_unread.py +++ b/tests/textual/test_divider_only_viewport_stays_unread.py @@ -50,6 +50,8 @@ def caption_is_last_row(): assert all(fragment.message.seq != last.seq for fragment in history.read_fragments()) mark_visible(history) - assert history.read_worker is None - await ui.step(.2) + # 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 cfb28781a..8104780a3 100644 --- a/tests/textual/test_dm_peer_replacement.py +++ b/tests/textual/test_dm_peer_replacement.py @@ -30,7 +30,7 @@ def pause_old_mark(self, page, **kwargs): 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 [fragment.message.body for fragment in chat.message_history.fragments] == ["old peer painted"] assert comms.bus.pending_count(viewer, "peer") == 1 comms.registry.unregister("peer") @@ -41,6 +41,7 @@ def pause_old_mark(self, page, **kwargs): release.set() await chat.message_history.read_worker.wait() assert comms.bus.pending_count(viewer, "peer") == 1 - assert not any(message.body == "new peer never painted" for message in chat.message_history.messages) + 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 d9c383c5e..24e7a295a 100644 --- a/tests/textual/test_hidden_channel_refresh.py +++ b/tests/textual/test_hidden_channel_refresh.py @@ -28,9 +28,10 @@ async def test_hidden_channel_refresh_does_no_root_check_and_resumed_window_fetc 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") From df54a5dcfea4fd1358ef56667fcd135b45212575 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:24:56 -0400 Subject: [PATCH 32/33] P1022: restore mounted source ranges and migrate saved-view consumers --- tests/textual/test_channel_partial_paint.py | 15 ++++++++++----- ..._context_tree_keeps_overlapping_annotations.py | 4 +++- tests/textual/test_dm_peer_replacement.py | 6 ++++-- tests/textual/test_saved_view_read_only.py | 9 +++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index b211d59f5..24fab5862 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,11 +1,12 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" +from dataclasses import replace + from agent_comms.read_basis import ChannelConversation async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_remaining_messages(workspace): from toad.widgets.comms_chat import CommsChatView - from toad.widgets.history_anchor import FragmentPosition from toad.message_rows import IrcMessageStyle, MarkdownMessageStyle workspace.thread("peer", "team", running=True) @@ -37,10 +38,14 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ continue fragment = next(part for part in history.fragments if part.message.view_key == message.view_key) - async with window.preserve_history(root=history): - window.reader_position = FragmentPosition(fragment.key, 0, 1, block=0) - await history.prepare_rows(lambda: True, window=window, target=history, - selected=history.selected_categories, rows_needed=window.outer_size.height) + page, index = next((page, index) for page in history.pages + for index, member in enumerate(page.prepared.fragments) + if member is fragment) + destination = replace(page.prepared.capture_range(), start=index, stop=index + 1, + members=(fragment,)) + await history.restore_reader_ranges((destination,), lambda: True) + history.show_fragment(fragment) + window.request_preparation() 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") diff --git a/tests/textual/test_context_tree_keeps_overlapping_annotations.py b/tests/textual/test_context_tree_keeps_overlapping_annotations.py index a63c68361..e7245d7e6 100644 --- a/tests/textual/test_context_tree_keeps_overlapping_annotations.py +++ b/tests/textual/test_context_tree_keeps_overlapping_annotations.py @@ -50,7 +50,9 @@ async def test_tree_rebinds_corrections_and_remounts_on_the_same_row_without_a_n 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] diff --git a/tests/textual/test_dm_peer_replacement.py b/tests/textual/test_dm_peer_replacement.py index 8104780a3..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,6 +14,7 @@ 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_page_read @@ -31,7 +33,7 @@ def pause_old_mark(self, page, **kwargs): chat = ui.textual[0].screen.query_one(CommsChatView) assert await asyncio.to_thread(entered.wait, 4) assert [fragment.message.body for fragment in chat.message_history.fragments] == ["old peer painted"] - assert comms.bus.pending_count(viewer, "peer") == 1 + assert comms.bus.pending_count(viewer, target) == 1 comms.registry.unregister("peer") comms.registry.remove("peer") @@ -40,7 +42,7 @@ def pause_old_mark(self, page, **kwargs): await ui.select_tab(owner) release.set() await chat.message_history.read_worker.wait() - assert comms.bus.pending_count(viewer, "peer") == 1 + 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: 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 From eaf09cc06470ca46dc6e7ec829244b0b9ed9a42d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 14:47:12 -0400 Subject: [PATCH 33/33] P1022: own finished read state and exercise native message navigation --- src/toad/widgets/message_notifications.py | 10 ++++++++-- tests/support/shared_stdio.py | 1 - tests/textual/test_channel_partial_paint.py | 17 ++++++++--------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index 0866fc1e4..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 @@ -97,11 +103,11 @@ 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/tests/support/shared_stdio.py b/tests/support/shared_stdio.py index b3d2443ab..c2b70b759 100644 --- a/tests/support/shared_stdio.py +++ b/tests/support/shared_stdio.py @@ -2,7 +2,6 @@ from __future__ import annotations -from agent_comms.repo import Checkout from contextlib import asynccontextmanager import shlex import sys diff --git a/tests/textual/test_channel_partial_paint.py b/tests/textual/test_channel_partial_paint.py index 24fab5862..fd9f75b17 100644 --- a/tests/textual/test_channel_partial_paint.py +++ b/tests/textual/test_channel_partial_paint.py @@ -1,7 +1,5 @@ """Textual-facing: read marks follow the message text the viewport has painted; scrolling the window reveals the rest.""" -from dataclasses import replace - from agent_comms.read_basis import ChannelConversation @@ -38,14 +36,15 @@ async def test_partial_viewport_marks_only_painted_text_and_scrolling_marks_the_ continue fragment = next(part for part in history.fragments if part.message.view_key == message.view_key) - page, index = next((page, index) for page in history.pages - for index, member in enumerate(page.prepared.fragments) - if member is fragment) - destination = replace(page.prepared.capture_range(), start=index, stop=index + 1, - members=(fragment,)) - await history.restore_reader_ranges((destination,), lambda: True) + 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) - window.request_preparation() 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")