From cab4cccff521249ccebb61a1672494ab53a27565 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:06:35 -0400 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 f2b22ebf0be218d7f1496b99283e64650e9feee3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 11:24:56 -0400 Subject: [PATCH 07/19] 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 fe5b3ae0581d43fc64fc8d05e07abb3134e475ca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 10:06:35 -0400 Subject: [PATCH 08/19] 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 09/19] 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 10/19] 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 11/19] 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 12/19] 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 13/19] 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 14/19] 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 15/19] 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") From 2da735a8a0455aff8687abac98147fdca6e63b60 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 16:37:04 -0400 Subject: [PATCH 16/19] Fix Toad suite drift: typed links, coordination facts and Core id types - markdown_links: linkify owns written schemes and e-mail only (fuzzy_link off), so schemeless spellings such as file.md and declarations.py resolve as project paths again instead of http://file.md; inline code links only when the whole span is a reference (regressed in 1325acc35). - Tests build coordination facts through support.coordination's registered_coordination_fact instead of four hand-built copies passing a str worktree. - Link tests assert typed LineLink actions (link.click) instead of our own href spelling; the hyperlink_at accessor that pinned that spelling is deleted. - Checkout, AcpSessionId and RootId typed where Core now requires them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- src/toad/markdown_links.py | 16 +++++++++++-- tests/app/test_markdown_file_links.py | 5 ++-- tests/support/ui.py | 7 ------ tests/textual/test_acp_log_link.py | 2 +- tests/textual/test_app_close_order.py | 12 ++++------ tests/textual/test_arrival_fade.py | 13 ++++------- tests/textual/test_channel_scope_rows.py | 8 +++---- tests/textual/test_checkpoint_first_source.py | 10 +++----- .../test_command_pane_and_resume_modal.py | 2 +- tests/textual/test_cursor_request_results.py | 6 +++-- .../textual/test_file_links_in_agent_text.py | 23 ++++++++++++------- tests/textual/test_snapshot_cancellation.py | 11 +++------ 12 files changed, 58 insertions(+), 57 deletions(-) diff --git a/src/toad/markdown_links.py b/src/toad/markdown_links.py index 9f34d2478..1cc35ab16 100644 --- a/src/toad/markdown_links.py +++ b/src/toad/markdown_links.py @@ -178,7 +178,7 @@ def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _Project if context.linked: return [token] result, position = [], 0 - for start, end, tokens in context.links.matches(token.content): + for start, end, tokens in cls.matches(token.content, context.links): if start > position: result.append(Token(token.type, token.tag, 0, content=token.content[position:start])) # Retain inline-code formatting on both linked and unlinked pieces. @@ -192,8 +192,17 @@ def resolve(cls, renderer: ProjectTokenRenderer, token: Token, context: _Project return result + @staticmethod + def matches(text: str, links: LinkContext): + return links.matches(text) + + class CodeInlineTokenRule(TextTokenRule): - pass + """A code span links only when the whole span is a reference: `run file.md` is a command.""" + + @staticmethod + def matches(text: str, links: LinkContext): + return [(start, end, tokens) for start, end, tokens in links.matches(text) if (start, end) == (0, len(text))] class ProjectTokenRenderer: @@ -211,6 +220,9 @@ def resolve(self, tokens: list[Token], context: _ProjectTokens): class _MarkdownParserState(local): def __init__(self): self.parser = MarkdownIt("gfm-like") + # Linkify owns written schemes and e-mail addresses. A schemeless spelling such as + # file.md or declarations.py is a project path first, which TextTokenRule resolves. + self.parser.linkify.set({"fuzzy_link": False}) self.parser.validateLink = ProjectLink.opens self.resolver = ProjectTokenRenderer() self.syntax_rules = tuple(self.parser.core.ruler.getRules("")) diff --git a/tests/app/test_markdown_file_links.py b/tests/app/test_markdown_file_links.py index d8189c16c..b3a0436e2 100644 --- a/tests/app/test_markdown_file_links.py +++ b/tests/app/test_markdown_file_links.py @@ -2,6 +2,7 @@ from toad.session_scope import AgentAttached from support.agents import peer +from toad.project_link import DirectProjectFileLink, ExternalProjectLink # One reference per paragraph: ui.link_at reaches the first match on a drawn row. ANSWER_SOURCE = "see file.md\n\nsee `file.md`\n\nbut `run file.md` is a command\n\nsee [website](https://example.com)" @@ -24,8 +25,8 @@ async def test_file_link_in_answer_opens_preview_and_return_keeps_draft(workspac await ui.until_shows("PEER_ANSWER") await ui.write_draft("KEEP_MARKDOWN_DRAFT") links = file_links(ui, "file.md") - assert sum(f"toad-file:{target}" in str(link) for link in links) == 2, links - assert any("https://example.com" in str(link) for link in file_links(ui, "website")), ui.visible_text() + assert links.count(DirectProjectFileLink(target.resolve(), original_root=workspace.project.resolve()).click) == 2, links + assert file_links(ui, "website") == [ExternalProjectLink("https://example.com").click], ui.visible_text() drawn = [i for i, line in enumerate(line for line in ui.visible_lines() if "file.md" in line)] await ui.click("file.md", occurrence=next(i for i in drawn if ui.link_at("file.md", occurrence=i) is not None)) await ui.until_shows("REAL_MARKDOWN_FILE_PREVIEW") diff --git a/tests/support/ui.py b/tests/support/ui.py index 2bc5f99cc..57bff6a5d 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -103,10 +103,6 @@ async def click(self, text: str, *, occurrence: int = 0) -> None: def link_at(self, text: str, *, occurrence: int = 0) -> tuple[str, tuple] | None: """The action a click on `text` would trigger, as (name, arguments), if it is a link.""" - @abstractmethod - def hyperlink_at(self, text: str, *, occurrence: int = 0) -> str | None: - """The address a click on `text` would open, if the drawn text is a hyperlink.""" - @abstractmethod async def resize(self, width: int, height: int) -> None: ... @@ -333,9 +329,6 @@ def link_at(self, text: str, *, occurrence: int = 0) -> tuple[str, tuple] | None "@click" ) - def hyperlink_at(self, text: str, *, occurrence: int = 0) -> str | None: - return self.app.screen.get_style_at(*self._cell_of(text, occurrence)).link - async def resize(self, width: int, height: int) -> None: await self.pilot.resize_terminal(width, height) await self.step() diff --git a/tests/textual/test_acp_log_link.py b/tests/textual/test_acp_log_link.py index 10623d299..d264cd6c8 100644 --- a/tests/textual/test_acp_log_link.py +++ b/tests/textual/test_acp_log_link.py @@ -63,7 +63,7 @@ async def test_a_link_to_a_log_that_was_never_written_opens_the_tab_and_says_the missing = workspace.roots.attempt / "state/toad/logs/never-written.txt" async with workspace.app(size=(139, 30)) as ui: owner = ui.selected_tab - await ui.conversation.post(AgentResponse(AcpLogLink(missing).markdown)) + await ui.conversation.post(AgentResponse(AcpLogLink.markdown(missing))) await ui.until(lambda: ui.shows(LINK) and ui.link_at(LINK) is not None, why="the log link drawn as a link") await ui.click(LINK) await ui.until(lambda: ui.selected_tab != owner, why="the log tab selected") diff --git a/tests/textual/test_app_close_order.py b/tests/textual/test_app_close_order.py index 34d1544b5..eaac02f14 100644 --- a/tests/textual/test_app_close_order.py +++ b/tests/textual/test_app_close_order.py @@ -6,13 +6,13 @@ import os import pytest -from agent_comms.acp_extension import CoordinationChangedUpdate, encode_updates +from agent_comms.acp_extension import encode_updates from agent_comms.child_process import ProcessIdentity -from agent_comms.context_reading import UnmeasuredContext from agent_comms.session_file import RegistrySessionLocation from agent_comms.fresh_private_session import create_fresh_private_session from support.agents import deliver +from support.coordination import registered_coordination_fact from toad.widgets.transcript_history import TranscriptHistory @@ -36,17 +36,15 @@ async def test_closing_the_app_after_the_observation_process_exits_raises_nothin stream.write(json.dumps({"type": "message", "id": "ask", "timestamp": "2026-10-22T00:00:00Z", "message": { "role": "user", "content": [{"type": "text", "text": "ASK_ROW"}]}}) + "\n") - thread = comms.registry.declare(replace(workspace.thread("source"), session_file=RegistrySessionLocation.from_path(str(saved), workspace.comms.root))) + comms.registry.declare(replace(workspace.thread("source"), session_file=RegistrySessionLocation.from_path(str(saved), workspace.comms.root))) workspace.thread("peer") comms.messaging.send("peer", "source", "SAVED_NOTICE") async with workspace.app(size=(120, 35)) as ui: view = ui.conversation agent = ui.detached_agent("closing") view.bind_agent(agent) - agent.coordination = CoordinationChangedUpdate( - thread=thread.incarnation, wire_root=str(workspace.roots.wire), owner=ProcessIdentity.capture(os.getpid()), - worktree=str(project), model="test-model", thinking_level=None, title=thread.name, - context_usage=UnmeasuredContext()) + agent.coordination = registered_coordination_fact( + comms, "source", owner=ProcessIdentity.capture(os.getpid()), model="test-model", worktree=project) await deliver(agent, { "sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": ""}, "_meta": encode_updates(agent.coordination)}) diff --git a/tests/textual/test_arrival_fade.py b/tests/textual/test_arrival_fade.py index 2dfea5aca..ded24fbda 100644 --- a/tests/textual/test_arrival_fade.py +++ b/tests/textual/test_arrival_fade.py @@ -5,9 +5,8 @@ import json import os -from agent_comms.acp_extension import CoordinationChangedUpdate, TurnChangedUpdate +from agent_comms.acp_extension import TurnChangedUpdate from agent_comms.child_process import ProcessIdentity -from agent_comms.context_reading import UnmeasuredContext from agent_comms.field_codec import TurnId from agent_comms.turn_lease import ActiveTurn, FinishedTurn, TurnState from agent_comms.turn_phase import PublishingPhase @@ -16,7 +15,7 @@ from ui_theme.effects import NEW_TEXT from support.agents import deliver -from support.coordination import carrying +from support.coordination import carrying, registered_coordination_fact from support.textual_steps import tool_status from toad.frame_presentation import EFFECT_UNSTARTED from toad.widgets.agent_response import AgentResponse @@ -48,17 +47,15 @@ async def test_a_commit_retiring_a_live_block_still_fades_another_senders_row(wo "timestamp": "2026-10-22T00:00:00Z", "message": { "role": "user", "content": [{"type": "text", "text": "ASK_ROW"}]}}) + "\n" saved.write_text(prompt) - thread = comms.registry.declare(replace(workspace.thread("source"), session_file=RegistrySessionLocation.from_path(str(saved), workspace.comms.root))) + comms.registry.declare(replace(workspace.thread("source"), session_file=RegistrySessionLocation.from_path(str(saved), workspace.comms.root))) workspace.thread("peer") comms.messaging.send("peer", "source", "EARLIER_NOTICE") async with workspace.app(size=(120, 35)) as ui: view = ui.conversation agent = ui.detached_agent("arrival-fade") view.bind_agent(agent) - coordination = CoordinationChangedUpdate( - thread=thread.incarnation, wire_root=str(workspace.roots.wire), owner=ProcessIdentity.capture(os.getpid()), - worktree=str(project), model="test-model", thinking_level=None, title=thread.name, - context_usage=UnmeasuredContext()) + coordination = registered_coordination_fact( + comms, "source", owner=ProcessIdentity.capture(os.getpid()), model="test-model", worktree=project) agent.coordination = coordination await deliver(agent, carrying(coordination)) agent.presentation.managed_turns().receive( diff --git a/tests/textual/test_channel_scope_rows.py b/tests/textual/test_channel_scope_rows.py index b8e10e58e..c899e3cb1 100644 --- a/tests/textual/test_channel_scope_rows.py +++ b/tests/textual/test_channel_scope_rows.py @@ -11,11 +11,11 @@ async def test_any_mode_channel_rows_include_older_messages_after_a_refresh(work for name, tags in (("alice", ("team",)), ("bob", ()), ("carol", ()), ("dave", ())): workspace.thread(name, *tags, running=True) comms = workspace.comms - project = str(workspace.project) + viewer = comms.views.display_viewer(Checkout(workspace.project)) 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", viewer=comms.views.display_viewer(Checkout(project))).messages] == [ + assert [m.body for m in comms.views.channel_display_page("#team", viewer=viewer).messages] == [ "current channel message"] async with workspace.app(size=(100, 30)) as ui: @@ -30,9 +30,9 @@ async def test_any_mode_channel_rows_include_older_messages_after_a_refresh(work await ui.app.coordination_access.stop_observing() history = chat.message_history source, snapshot = history.page_source, history.source_snapshot() - marked = comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))) + marked = comms.views.channel_display_page("#team", viewer=viewer) comms.channels.update_tags("carol", add=frozenset({"team"})) - expanded = comms.views.channel_display_page("#team", viewer=comms.views.display_viewer(Checkout(project))) + expanded = comms.views.channel_display_page("#team", viewer=viewer) assert [m.body for m in expanded.messages][:2] == ["older newly visible DM", "current channel message"] await history._mark_page(marked, (), snapshot) await ui.until(lambda: history.page_source is not source, diff --git a/tests/textual/test_checkpoint_first_source.py b/tests/textual/test_checkpoint_first_source.py index ea37657d5..4d7e1bbd2 100644 --- a/tests/textual/test_checkpoint_first_source.py +++ b/tests/textual/test_checkpoint_first_source.py @@ -8,13 +8,11 @@ from dataclasses import replace import os -from agent_comms.acp_extension import CoordinationChangedUpdate from agent_comms.child_process import ProcessIdentity -from agent_comms.context_reading import UnmeasuredContext from support import journals from support.agents import deliver -from support.coordination import carrying +from support.coordination import carrying, registered_coordination_fact from toad.live_output import ResponseStream from toad.transcript_publication import CommitPublication from toad.widgets.agent_response import AgentResponse @@ -31,10 +29,8 @@ async def test_first_native_file_replaces_the_wire_history_and_the_covered_live_ view = ui.conversation agent = ui.detached_agent("checkpoint-first") ui.attach_agent(agent) - agent.coordination = coordination = CoordinationChangedUpdate( - thread=thread.incarnation, wire_root=str(workspace.roots.wire), owner=ProcessIdentity.capture(os.getpid()), - worktree=str(workspace.project), model="test-model", thinking_level=None, title=thread.name, - context_usage=UnmeasuredContext()) + agent.coordination = coordination = registered_coordination_fact( + comms, "source", owner=ProcessIdentity.capture(os.getpid()), model="test-model", worktree=workspace.project) await deliver(agent, carrying(coordination)) agent.session.settled.set() # The bound view mounts the wire history itself; the test reads that one, never a copy. diff --git a/tests/textual/test_command_pane_and_resume_modal.py b/tests/textual/test_command_pane_and_resume_modal.py index 64bdeb0f4..4bedca07c 100644 --- a/tests/textual/test_command_pane_and_resume_modal.py +++ b/tests/textual/test_command_pane_and_resume_modal.py @@ -58,5 +58,5 @@ async def test_the_resume_modal_lists_a_saved_session_and_returns_it_typed(tmp_p assert modal.session_table.get_row(str(pk))[:2] == ["Agent", "Saved session"] await modal.dissmiss_with_session(str(pk)) await pilot.pause() - assert resumed[0].meta_json.cwd == tmp_path + assert resumed[0].meta_json.cwd == Checkout(tmp_path) assert resumed[0].agent_session_id == "saved-session" diff --git a/tests/textual/test_cursor_request_results.py b/tests/textual/test_cursor_request_results.py index 52024ee4c..413f4ec14 100644 --- a/tests/textual/test_cursor_request_results.py +++ b/tests/textual/test_cursor_request_results.py @@ -9,6 +9,7 @@ CursorAdvancedUpdate, CursorEnvelope, LiveCursorScope, VerifiedCursorObservation, encode_updates, ) from agent_comms.child_process import ProcessIdentity +from agent_comms.field_codec import AcpSessionId, RootId from agent_comms.native_input_record import FullNativeExecution, NumberNativeColumn, TextNativeColumn from agent_comms.native_runtime_input import CurrentNativeCursor from agent_comms.thread_identity import ClaimedIdentity, RecordedThreadIncarnation @@ -22,13 +23,14 @@ def response(session): owner = ClaimedIdentity(RecordedThreadIncarnation("owner", 1000.0), 1) + root = RootId("a" * 32) cursor = CurrentNativeCursor( - wire_root_id="a" * 32, recipient_lookup=owner.incarnation.lookup, owner_generation=1, + wire_root_id=root, recipient_lookup=owner.incarnation.lookup, owner_generation=1, owner_claimed_generation=1, covered_seq=2, injected_seq=2, input_id=TextNativeColumn("b" * 32), stage=TextNativeColumn(FullNativeExecution.declared_name), session_id=TextNativeColumn("native-session"), request_generation=NumberNativeColumn(1)) fact = CursorAdvancedUpdate(CursorEnvelope( - LiveCursorScope(session_id=session, wire_root_id="a" * 32, claimed_identity=owner, + LiveCursorScope(session_id=AcpSessionId(session), wire_root_id=root, claimed_identity=owner, owner=ProcessIdentity(1234, 1)), 1, VerifiedCursorObservation(cursor))) return NewSessionResponse(sessionId=session, _meta=encode_updates(fact)) diff --git a/tests/textual/test_file_links_in_agent_text.py b/tests/textual/test_file_links_in_agent_text.py index 3c8620188..10ea12c1f 100644 --- a/tests/textual/test_file_links_in_agent_text.py +++ b/tests/textual/test_file_links_in_agent_text.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from toad.project_link import DirectProjectFileLink, ExternalProjectLink, SearchProjectFileLink from toad.widgets.comms_menu import ContextMenu, ContextMenuItem from toad.widgets.agent_response import AgentResponse from toad.widgets.project_panel import FilePreview @@ -25,10 +26,13 @@ async def test_existing_project_paths_in_agent_text_are_links_and_other_text_is_ async with workspace.app(size=(130, 35)) as ui: await say(ui, "See plans/tag-channel-view-plan.md:\n\n`plans/tag-channel-view-plan.md`\n\n" "but not plans/missing.md\n\nor https://example.com/plans/tag-channel-view-plan.md.") - assert ui.hyperlink_at("plans/tag-channel-view-plan.md:", occurrence=0) == f"toad-file:{path}" - assert ui.hyperlink_at("plans/tag-channel-view-plan.md", occurrence=1) == f"toad-file:{path}" - assert ui.hyperlink_at("plans/missing.md") is None - assert ui.hyperlink_at("https://example.com/plans/tag-channel-view-plan.md.") is None + plan = DirectProjectFileLink(path, original_root=workspace.project.resolve()).click + assert ui.link_at("plans/tag-channel-view-plan.md:", occurrence=0) == plan + assert ui.link_at("plans/tag-channel-view-plan.md", occurrence=1) == plan + assert ui.link_at("plans/missing.md") is None + # Linkify's URLs are resolved syntax; the sentence's full stop is not part of the address. + assert ui.link_at("https://example.com/plans/tag-channel-view-plan.md") == ExternalProjectLink( + "https://example.com/plans/tag-channel-view-plan.md").click async def test_explicit_links_name_the_file_search_or_the_website(workspace): @@ -36,9 +40,11 @@ async def test_explicit_links_name_the_file_search_or_the_website(workspace): async with workspace.app(size=(130, 35)) as ui: await say(ui, f"[Absolute]({path}) [Relative](plans/tag-channel-view-plan.md) " "[Website](https://example.com/preview.py) [Missing](missing.py)") - assert [ui.hyperlink_at(name) for name in ("Absolute", "Relative", "Website", "Missing")] == [ - f"toad-file:{path}", f"toad-file:{path}", - "https://example.com/preview.py", "toad-file-search:missing.py"] + root = workspace.project.resolve() + assert [ui.link_at(name) for name in ("Absolute", "Relative", "Website", "Missing")] == [ + DirectProjectFileLink(path, original_root=root).click, DirectProjectFileLink(path, original_root=root).click, + ExternalProjectLink("https://example.com/preview.py").click, + SearchProjectFileLink("missing.py", original_root=root).click] async def test_clicking_a_file_link_opens_its_preview_tab(workspace): @@ -82,7 +88,8 @@ async def test_a_bare_file_name_links_to_a_search_that_opens_the_one_matching_fi app, _ = ui.textual owner = ui.selected_tab await say(ui, "See declarations.py") - assert ui.hyperlink_at("declarations.py") == "toad-file-search:declarations.py" + assert ui.link_at("declarations.py") == SearchProjectFileLink( + "declarations.py", original_root=workspace.project.resolve()).click await ui.click("declarations.py") await ui.until(lambda: ui.selected_tab != owner, why="the preview of the nested file") preview = app.selected_session.query_one(FilePreview) diff --git a/tests/textual/test_snapshot_cancellation.py b/tests/textual/test_snapshot_cancellation.py index c859d7b2c..b6430c7f4 100644 --- a/tests/textual/test_snapshot_cancellation.py +++ b/tests/textual/test_snapshot_cancellation.py @@ -9,15 +9,13 @@ import os from unittest.mock import patch -from agent_comms.acp_extension import CoordinationChangedUpdate from agent_comms.child_process import ProcessIdentity -from agent_comms.context_reading import UnmeasuredContext import pytest from textual.worker import WorkerCancelled from support import journals from support.agents import deliver -from support.coordination import carrying +from support.coordination import carrying, registered_coordination_fact from toad.live_output import ResponseStream from support.textual_steps import saved_history @@ -42,11 +40,8 @@ async def test_cancelled_commit_with_the_output_owner_busy_retires_the_old_histo old = await saved_history(view, old_page, view.transcript.get_transcript_page) await view.contents.mount(old) await ui.until(lambda: old in view.window.histories, why="the old history") - thread = workspace.comms.registry.require("replacement") - agent.coordination = coordination = CoordinationChangedUpdate( - thread=thread.incarnation, wire_root=str(workspace.roots.wire), owner=ProcessIdentity.capture(os.getpid()), - worktree=str(workspace.project), model="test-model", thinking_level=None, title=thread.name, - context_usage=UnmeasuredContext()) + agent.coordination = coordination = registered_coordination_fact( + workspace.comms, "replacement", owner=ProcessIdentity.capture(os.getpid()), model="test-model", worktree=workspace.project) await deliver(agent, carrying(coordination)) agent.session.settled.set() # A live stream block that the saved page already covers. From 836cb644e3dad1a4e8f745f29a43e04a960e584b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 17:05:07 -0400 Subject: [PATCH 17/19] Stop a failed test's helpers in its own process; fix the hidden-session mount The "2-minute hang at conftest.py:93" was BoundedRun.run reading the child test's pipes to EOF while helpers its app left running (after a failed close) still held them, so a test that failed in 14 s was killed at --test-timeout 165 s. The child now stops what its test started in pytest_sessionfinish; the runner's finally stays for a killed child. Measured on the unfixed test: 166.62 s -> 20.68 s. test_hidden_session_retirement passed Path(owner.worktree) to MainScreen.from_thread, which takes a Checkout: on_mount's update_title raised on .path, Toad's _handle_exception swallowed it, Textual's _pre_process ended the screen's loop and pruned its children, and sidebars() then raised NoMatches. Pass the Checkout. Registration.remove_originals is gone: retire threads through comms.threads.delete, as test_conversation_kind already does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/conftest.py | 14 ++++++++++++++ tests/textual/test_conversation_value.py | 2 +- tests/textual/test_hidden_session_retirement.py | 5 ++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index eead05fcc..00816bbad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,6 +121,20 @@ def pytest_collection_modifyitems(config, items): item.add_report_section("call", "Recorded defect", note) +def pytest_sessionfinish(session): + """The test's own process stops what its app left running before it exits. + + Those helpers inherit this process's output pipes, which the runner reads to their end + (conftest's BoundedRun.run): left running after a failed close, they held a finished + test open until --test-timeout killed it. + """ + if (attempt := os.environ.get(ATTEMPT)) is None: + return # The runner; the processes it was given stop their own. + from support.processes import stop_test_children + + asyncio.run(stop_test_children(attempt)) + + def pytest_runtest_logreport(report): if (path := os.environ.get(RESULT)) is None: return diff --git a/tests/textual/test_conversation_value.py b/tests/textual/test_conversation_value.py index d13e1f935..1964d34d6 100644 --- a/tests/textual/test_conversation_value.py +++ b/tests/textual/test_conversation_value.py @@ -190,7 +190,7 @@ async def test_recreated_dm_peer_retires_the_old_view_and_opens_a_new_incarnatio def replace_peer(): registry = workspace.comms.registry registry.unregister(peer.name) - registry.remove_originals((registry.require(peer.name),)) + workspace.comms.threads.delete(peer.name) return registry.declare(Thread( peer.name, peer.tags, peer.worktree, created_at=peer.created_at + 1, )) diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index fdf1135ff..6441608fd 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -2,7 +2,6 @@ from agent_comms.repo import Checkout from collections import Counter -from pathlib import Path import sys from agent_comms.comms import Comms @@ -34,7 +33,7 @@ async def open_hidden_sessions(workspace, ui, owners): first = ui.selected_tab def source(owner): - return MainScreen.from_thread(Path(owner.worktree), str(workspace.comms.root), owner.incarnation) + return MainScreen.from_thread(owner.worktree, str(workspace.comms.root), owner.incarnation) modes = {} for owner in owners[1:]: @@ -121,7 +120,7 @@ async def test_a_same_name_successor_does_not_keep_the_deleted_threads_hidden_se _, gone = app.session_navigation.get(modes["agent-3"]).original_threads(app.session_navigation)[0] old = registry.require(gone.name) registry.unregister(old.name) - registry.remove_originals((registry.require(old.name),)) + workspace.comms.threads.delete(old.name) registry.declare(Thread(old.name, old.tags, old.worktree, created_at=old.created_at + 1)) await app.session_navigation.retire_missing() await ui.until(lambda: app.session_navigation.get(modes["agent-3"]) is None, seconds=15, From 55e080ac7beb305bb7acf6fec81cd665a51d5c4f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 17:18:38 -0400 Subject: [PATCH 18/19] Sync the DM peer test on its mark; move read-ahead onto the current history surface test_dm_peer_replacement waited on read_worker, which leaving the tab legitimately cancels, and the paused mark keeps running in its thread after that cancel. It now waits on the mark itself, so its assertions run after the mark has really happened. test_channel_read_ahead used names deleted on main: CoordinationAccess.service (now the observation's service), WireHistory.messages (fragments since 12b1f4062), history.source (page_source.loader) and channel_display_page's before/limit keywords (PageBounds now). Its read-ahead threshold is unchanged and still fails: only the tail and one page are read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/textual/test_channel_read_ahead.py | 22 ++++++++++++---------- tests/textual/test_dm_peer_replacement.py | 14 +++++++++----- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/textual/test_channel_read_ahead.py b/tests/textual/test_channel_read_ahead.py index 0e29e002f..996753c6e 100644 --- a/tests/textual/test_channel_read_ahead.py +++ b/tests/textual/test_channel_read_ahead.py @@ -2,6 +2,8 @@ from unittest.mock import patch +from agent_comms.page_traversal import OlderTraversal + from toad.widgets.comms_chat import CommsChatView @@ -11,30 +13,30 @@ async def test_a_channel_reads_the_next_older_page_before_scrolling_reaches_its_ workspace.comms.messaging.send("sender", "#scroll", f"Message {index}: " + "wrapped body " * 6) async with workspace.app(size=(145, 50)) as ui: app, _pilot = ui.textual - views = app.coordination_access.service.views + views = app.coordination_access.observation.service.views earlier_reads = [] original = views.channel_display_page - def counted(target, **bounds): - if bounds.get("before") is not None: - earlier_reads.append(bounds["limit"]) - return original(target, **bounds) + def counted(target, *, bounds, **request): + if isinstance(bounds.traversal, OlderTraversal): + earlier_reads.append(bounds.limit) + return original(target, bounds=bounds, **request) with patch.object(views, "channel_display_page", side_effect=counted): await ui.open_channel("#scroll", actor="sender") chat = app.screen.query_one(CommsChatView) history = chat.message_history - await ui.until(lambda: history.messages, why="channel messages") - source = history.source + await ui.until(lambda: history.fragments, why="channel messages") + source = history.page_source.loader assert source.comms.views is views # The newest messages come as a short page drawn at once; every earlier read is a whole page. - await ui.until(lambda: len(history.messages) > source.tail_limit and len(earlier_reads) >= 3 + await ui.until(lambda: len(history.fragments) > source.tail_limit and len(earlier_reads) >= 3 and history.state.accepts_source_work, why="earlier messages and a page read ahead") assert earlier_reads[0] == source.tail_limit and set(earlier_reads[1:]) == {source.page_limit}, earlier_reads - oldest, reads = history.messages[0].seq, len(earlier_reads) + oldest, reads = history.fragments[0].message.seq, len(earlier_reads) chat.window.focus() for _ in range(6): await ui.press("pageup") - await ui.until(lambda: history.messages[0].seq <= oldest - 24, why="earlier messages drawn while paging up") + await ui.until(lambda: history.fragments[0].message.seq <= oldest - 24, why="earlier messages drawn while paging up") # Six pages of scrolling did not cost six Core reads. assert len(earlier_reads) - reads <= 3 and set(earlier_reads[1:]) == {source.page_limit}, earlier_reads diff --git a/tests/textual/test_dm_peer_replacement.py b/tests/textual/test_dm_peer_replacement.py index be5fec815..89dd57652 100644 --- a/tests/textual/test_dm_peer_replacement.py +++ b/tests/textual/test_dm_peer_replacement.py @@ -16,14 +16,17 @@ async def test_a_page_painted_for_the_old_peer_does_not_mark_the_replacement_pee viewer = workspace.viewer target = CommandTarget.named("peer") comms.messaging.send("peer", viewer, "old peer painted") - entered, release = Event(), Event() + entered, release, marked = Event(), Event(), Event() original_mark = HistoryViews.mark_page_read 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, page, **kwargs) + try: + if not release.wait(8): + raise TimeoutError("Old painted marker was not released") + return original_mark(self, page, **kwargs) + finally: + marked.set() try: with patch.object(HistoryViews, "mark_page_read", pause_old_mark): @@ -41,7 +44,8 @@ def pause_old_mark(self, page, **kwargs): comms.messaging.send("peer", viewer, "new peer never painted") await ui.select_tab(owner) release.set() - await chat.message_history.read_worker.wait() + # The old page's mark runs in its thread even when leaving the tab cancels its worker. + assert await asyncio.to_thread(marked.wait, 8) 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) From 31e3ca17e0c61cd2b827afe0c66082e0da55756b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 30 Oct 2026 17:26:37 -0400 Subject: [PATCH 19/19] Probe tab history by the buttons' disabled state; leave "!" to shell mode TabHistoryButton.sync (src/toad/widgets/side_bar.py:283) shows availability as disabled; the -unavailable class the test probe read is set by nothing, so a fresh app always looked navigable. test_prompt_routing still sent "!literal" as text and pinned "!" as text, which cfe1bb492 deliberately made the shell-mode switch on an empty prompt; tests/textual/test_prompt_shell_mode.py owns that behavior. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSoWgC3ZwKzjT4SwMDu --- tests/app/test_prompt_routing.py | 12 ++---------- tests/support/ui.py | 4 ++-- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/app/test_prompt_routing.py b/tests/app/test_prompt_routing.py index 66920ddef..9531bfe0e 100644 --- a/tests/app/test_prompt_routing.py +++ b/tests/app/test_prompt_routing.py @@ -7,7 +7,8 @@ async def test_command_looking_text_is_sent_to_the_agent_not_the_shell(workspace): async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: await ui.until_agent_ready() - sent = ["openhcs", "open the project", "git status", "python", "$100 budget", "@peer", "!literal"] + # "!" on an empty prompt enters shell mode (cfe1bb492); that is tests/textual/test_prompt_shell_mode.py's. + sent = ["openhcs", "open the project", "git status", "python", "$100 budget", "@peer"] for text in sent: await ui.type(text) assert ui.draft() == text @@ -32,12 +33,3 @@ async def test_enter_sends_the_whole_multiline_draft_and_ctrl_j_adds_a_line(work assert len(workspace.prompts_received()) == sent await ui.press("enter") await ui.until(lambda: workspace.prompts_received()[-1:] == ["line one\nline two\nline three"]) - - -async def test_an_exclamation_mark_is_text_not_a_shell_switch(workspace): - async with workspace.app(binding=AgentAttached(peer("echo"))) as ui: - await ui.until_agent_ready() - await ui.press("!") - assert ui.draft() == "!" - await ui.press("enter") - await ui.until(lambda: workspace.prompts_received() == ["!"], why="'!' at the agent") diff --git a/tests/support/ui.py b/tests/support/ui.py index 57bff6a5d..fcaa3dd7d 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -388,10 +388,10 @@ async def go_forward(self) -> None: await self.step() def can_go_back(self) -> bool: - return not self.app.screen.query_one("#tab-back").has_class("-unavailable") + return not self.app.screen.query_one("#tab-back").disabled def can_go_forward(self) -> bool: - return not self.app.screen.query_one("#tab-forward").has_class("-unavailable") + return not self.app.screen.query_one("#tab-forward").disabled async def open_preview(self, path) -> str: identity = await self.app.session_navigation.preview(path)