From 3758f5076ac6faacf7c9349d122a80dd0ddc01f4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:55:04 -0400 Subject: [PATCH 1/6] Reuse the managed native session for goal workflow qualification --- tests/native/test_native_goal_bar.py | 92 +++++++++++++------ .../native/test_native_goal_objective_edit.py | 15 +-- tests/native/test_native_goal_owner.py | 60 +++--------- tests/native/test_native_journey.py | 5 +- tests/support/native.py | 33 +++---- tests/support/native_steps.py | 46 +++------- tests/support/peers/typed_turn_events.py | 16 ++-- tests/textual/test_compaction_turn_events.py | 16 +++- tests/textual/test_goal_owner_views.py | 24 ++--- 9 files changed, 146 insertions(+), 161 deletions(-) diff --git a/tests/native/test_native_goal_bar.py b/tests/native/test_native_goal_bar.py index fd7e48bad..1072ef8f0 100644 --- a/tests/native/test_native_goal_bar.py +++ b/tests/native/test_native_goal_bar.py @@ -1,21 +1,27 @@ """The goal bar follows the real owner: standby, a long goal that scrolls, edits made elsewhere, pause/resume and clear.""" +import asyncio +import json import os -from contextlib import AsyncExitStack -from agent_comms import agent_events as events -from agent_comms.owned_turn import OwnedTurn from agent_comms.child_process import ProcessIdentity from agent_comms.goal_actions import ( - ActiveGoalAction, ClearGoalAction, EditGoalAction, GoalPrecondition, OwnerInvocable, PausedGoalAction, + ActiveGoalAction, + ClearGoalAction, + EditGoalAction, + GoalPrecondition, + OwnerInvocable, + PausedGoalAction, + SetGoalAction, StandbyGoalAction, ) from agent_comms.goal_presentation import IdleGoalExecution from agent_comms.goals import NoGoal +from support.native import ControlledProvider, NativeSession, native_session +from support.native_steps import goal_on_real_owner from textual.containers import VerticalScroll from textual.widgets import Static -from support.native_steps import goal_on_real_owner from toad.screens.goal_details import GoalDetails from toad.screens.goal_edit import GoalEdit from toad.widgets.goal_bar import GoalBar, GoalControl, StandbyPulse @@ -44,7 +50,8 @@ def owner_edit(comms, session, text): async def test_standby_goal_pulses_and_its_long_text_scrolls_at_every_size(workspace, native_package): async with goal_on_real_owner(workspace, OBJECTIVE, size=(90, 35), - arrange=standing_by_for_a_peer(workspace)) as (ui, _agent, _owner, _session, _goal): + arrange=standing_by_for_a_peer(workspace)) as native: + ui = native.ui conversation = ui.conversation bar = conversation.query_one(GoalBar) await ui.until(lambda: "Standby" in str(bar.query_one(".goal-header", Static).render()), @@ -76,9 +83,8 @@ async def test_standby_goal_pulses_and_its_long_text_scrolls_at_every_size(works async def test_edits_made_on_the_owner_reach_open_goal_screens_without_touching_a_draft(workspace, native_package): - async with goal_on_real_owner(workspace, OBJECTIVE, size=(90, 35)) as ( - ui, _agent, owner, session, _goal): - comms = owner.turns.comms + async with goal_on_real_owner(workspace, OBJECTIVE, size=(90, 35)) as native: + ui, comms, session = native.ui, native.comms, native.thread app, pilot = ui.textual conversation = ui.conversation await pilot.click("#goal-history") @@ -101,7 +107,8 @@ async def test_edits_made_on_the_owner_reach_open_goal_screens_without_touching_ async def test_pause_resume_and_clear(workspace, native_package): async with goal_on_real_owner(workspace, OBJECTIVE, size=(90, 35), - arrange=standing_by_for_a_peer(workspace)) as (ui, _agent, _owner, _session, _goal): + arrange=standing_by_for_a_peer(workspace)) as native: + ui = native.ui conversation = ui.conversation bar = conversation.query_one(GoalBar) pulse = bar.query_one(StandbyPulse) @@ -117,35 +124,66 @@ async def test_pause_resume_and_clear(workspace, native_package): async def test_failed_ledger_repaints_retry_then_active_without_rewriting_goal(workspace, native_package): - async with goal_on_real_owner(workspace, OBJECTIVE) as ( - ui, _agent, owner, session, first): + (workspace.project / "proof.txt").write_text("A real tool reads this file.\n") + + def reply(_request, number): + if number == 1: + return {"role": "assistant", "content": "No verified work"}, "stop" + if number == 2: + return {"role": "assistant", "tool_calls": [{ + "index": 0, "id": "p448-read", "type": "function", + "function": {"name": "read", "arguments": json.dumps({"path": "proof.txt"})}}]}, "tool_calls" + return {"role": "assistant", "content": "PRODUCTIVE_SUCCESSOR"}, "stop" + + async with native_session(workspace, provider=ControlledProvider(reply=reply, request_budget=3)) as native: + ui, agent, comms, session = native.ui, native.agent, native.comms, native.thread app, pilot = ui.textual view = ui.conversation view.prompt.text = "Keep the draft across the failed turn" bar = view.query_one(GoalBar) - failed = OwnedTurn(owner.turns, session, session, "Original work", original_goal_id=first.id) - async with AsyncExitStack() as resources, AsyncExitStack() as permits: - assert await failed.acquire(resources, permits) - await failed.progress.done(events.Done("Controlled backend failure", False)) - assert owner.turns.comms.registry.require(session).goal == first - await view.goal_observation.refresh() - await ui.until(lambda: "Blocked" in str(bar.header.render()), why="the failed ledger in the heading") + first = await agent.update_goal(SetGoalAction, OBJECTIVE) + await ui.until(lambda: "Blocked" in str(bar.header.render()), seconds=30, + why="the unproductive native goal turn settles as failed") + assert comms.registry.require(session).goal == first await ui.until(lambda: str(bar.query_one("#goal-toggle").render()) == "Retry", why="Retry from execution while the stored goal remains active") await pilot.click("#goal-history") await ui.until(lambda: isinstance(app.screen, GoalDetails), why="the open goal details") details = app.screen - successor = OwnedTurn(owner.turns, session, session, "Fresh productive input", - original_goal_id=first.id, original_owner_input=True) - async with AsyncExitStack() as resources, AsyncExitStack() as permits: - assert await successor.acquire(resources, permits) - await successor.progress.tool_ended(events.ToolEnd("read", "read", True, "Source read")) - await successor.progress.done(events.Done("Productive successor", True)) - assert owner.turns.comms.registry.require(session).goal == first - await view.goal_observation.refresh() + sending = asyncio.create_task(agent.send_prompt("Read proof.txt and report completion")) + try: + await ui.until(sending.done, seconds=30, why="the fresh productive native input finishes") + await sending + finally: + if not sending.done(): + sending.cancel() + await asyncio.gather(sending, return_exceptions=True) + assert len(native.provider.requests) == 3 + assert comms.registry.require(session).goal == first await ui.until(lambda: "Active" in str(details.query_one("#goal-current-heading", Static).render()), why="the already-open details following productive settlement") await ui.press("escape") await ui.until(lambda: str(bar.query_one("#goal-toggle").render()) == "Pause", why="the goal control following the ledger-only change") assert view.prompt.text == "Keep the draft across the failed turn" + + +async def test_standby_goal_mention_opens_its_peer_and_returns_to_the_draft(workspace, native_package): + async def prepare(comms, project, _provider): + comms.registry.declare(comms.registry.new_thread("peer", frozenset(), str(project))) + goal = comms.goals.update_goal(NativeSession.thread, SetGoalAction(text="Coordinate with @peer")) + comms.goals.update_goal(NativeSession.thread, StandbyGoalAction( + wait_for=("peer",), expect=GoalPrecondition(goal_id=goal.id))) + + async with native_session(workspace, prepare=prepare) as native: + ui = native.ui + original = ui.selected_tab + await ui.write_draft("Composer draft stays") + await ui.until_shows("Coordinate with @peer") + await ui.until(lambda: ui.link_at("@peer", occurrence=1) is not None, + why="the bound goal mention becomes a navigation link") + await ui.click("@peer", occurrence=1) + await ui.until(lambda: ui.app.selected_session._comms_thread == "peer", + why="the goal mention opens its peer") + await ui.select_tab(original) + assert ui.draft() == "Composer draft stays" diff --git a/tests/native/test_native_goal_objective_edit.py b/tests/native/test_native_goal_objective_edit.py index 83d6627ac..a7f01248c 100644 --- a/tests/native/test_native_goal_objective_edit.py +++ b/tests/native/test_native_goal_objective_edit.py @@ -2,15 +2,14 @@ from agent_comms.acp_extension import GoalChangedUpdate from agent_comms.goal_actions import ActiveGoalAction, GoalPrecondition +from support.native_steps import goal_on_real_owner from textual.widgets import Static -from support.native_steps import goal_on_real_owner from toad.core.events import CommsUpdated from toad.screens.goal_details import GoalDetails from toad.screens.goal_edit import GoalEdit from toad.widgets.goal_text import GoalText - OBJECTIVE = "Work on draft PR #17 and report remaining gaps. " + "Detailed acceptance criteria. " * 30 @@ -25,8 +24,9 @@ def goal_in_toad(workspace): async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspace, native_package): - async with goal_in_toad(workspace) as (ui, agent, owner, session, first): - comms = owner.turns.comms + async with goal_in_toad(workspace) as native: + ui, agent, comms, session = native.ui, native.agent, native.comms, native.thread + first = comms.registry.require(session).goal.require() app, pilot = ui.textual conversation = ui.conversation current = first @@ -48,10 +48,10 @@ async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspa else: await pilot.click("#goal-save") await ui.until(lambda: not isinstance(app.screen, GoalEdit), why="the editor to close") - await ui.until(lambda: comms.registry.require(session).goal.text == objective, why="the saved objective") + await ui.until(lambda expected=objective: comms.registry.require(session).goal.text == expected, why="the saved objective") current = comms.registry.require(session).goal assert current.id == first.id - await ui.until(lambda: conversation.goal_display.snapshot == current, why="the saved goal in the bar") + await ui.until(lambda expected=current: conversation.goal_display.snapshot == expected, why="the saved goal in the bar") summary = conversation.query_one(".goal-summary", GoalText) assert objective in str(summary.render()), str(summary.render()) assert ui.shows(" ".join(objective.split()[:3])) and ui.shows(objective.split()[-1]) @@ -71,7 +71,8 @@ async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspa async def test_a_late_notice_of_an_older_goal_does_not_replace_the_current_one(workspace, native_package): - async with goal_in_toad(workspace) as (ui, agent, _owner, session, first): + async with goal_in_toad(workspace) as native: + ui, agent = native.ui, native.agent conversation = ui.conversation before = await agent.get_goal_snapshot() older = before.goal.require() diff --git a/tests/native/test_native_goal_owner.py b/tests/native/test_native_goal_owner.py index 322e7bd2f..f9f66ea37 100644 --- a/tests/native/test_native_goal_owner.py +++ b/tests/native/test_native_goal_owner.py @@ -1,57 +1,29 @@ -"""Toad's goal requests reach the real owner: setting a goal makes it launchable; an edit keeps its identity.""" +"""Goal requests reach the managed native owner through Toad's real controller.""" import pytest from agent_comms.goal_actions import SetGoalAction -from agent_comms.goal_attempts import GoalAttemptStore +from support.native import native_session -from support.native import owner_server, start_private_native_wire -from toad.acp.transcript_reader import CoordinationTranscriptReader +async def test_setting_a_goal_reaches_the_native_provider(workspace, native_package): + async with native_session(workspace) as native: + native.provider.hold_next_request() + goal = await native.agent.update_goal(SetGoalAction, "Finish the task") + assert goal.state.declared_name == "active" + await native.ui.until(native.provider.entered.is_set, seconds=30, + why="the managed owner begins the goal") + assert native.comms.registry.require(native.thread).goal == goal + native.provider.release() -class InProcessReader(CoordinationTranscriptReader): - """Asks the owner directly, as Core's observation service would.""" - def __init__(self, comms): - super().__init__() - self.comms = comms - - async def read(self, request): - return await request.ask(self.comms) - - -async def test_setting_a_goal_makes_it_ready_to_launch(workspace, native_package, monkeypatch): - monkeypatch.setenv("AGENT_COMMS_AGENT_MODELS", "openrouter/fake") - comms = start_private_native_wire(workspace.roots.wire) - owner = owner_server(comms) - try: - session = (await owner.new_session(cwd=str(workspace.project))).session_id - agent = workspace.agents.detached_agent() - agent.coordination = comms.configuration.answer(session) - goal = await agent.update_goal(SetGoalAction, "Finish the task") - assert goal is not None and goal.state.declared_name == "active" - assert goal.text == "Finish the task" - generation = GoalAttemptStore(comms.root / "goal-private").snapshot(goal.id) - assert generation is not None and generation.lifecycle.ready - assert owner.turns.goals.open_goal_store().ready_grant(goal.id, generation.number) - assert comms.registry.require(session).goal == goal - finally: - await owner.shutdown() - - -async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(workspace, native_package, monkeypatch): - monkeypatch.setenv("AGENT_COMMS_AGENT_MODELS", "openrouter/fake") - comms = start_private_native_wire(workspace.roots.wire) - owner = owner_server(comms) - try: - session = (await owner.new_session(cwd=str(workspace.project))).session_id - agent = workspace.agents.detached_agent() - agent.coordination = comms.configuration.answer(session) - agent.controller.transcripts = InProcessReader(comms) +async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(workspace, native_package): + async with native_session(workspace) as native: + agent, comms, session = native.agent, native.comms, native.thread first = await agent.update_goal(SetGoalAction, "First objective") changed = await agent.edit_goal(first, "Revised objective") assert changed.id == first.id assert changed.revision == first.revision + 1 - assert changed.state.declared_name == first.state.declared_name + assert changed.state == first.state assert comms.registry.require(session).goal == changed with pytest.raises(ValueError): await agent.edit_goal(first, "Stale overwrite") @@ -62,5 +34,3 @@ async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(worksp assert not any(entry["after"] and entry["after"]["text"] == "Stale overwrite" for entry in history) snapshot = await agent.get_goal_snapshot() assert snapshot.goal == changed and snapshot.execution.goal_id == changed.id - finally: - await owner.shutdown() diff --git a/tests/native/test_native_journey.py b/tests/native/test_native_journey.py index 51e1c5fb9..60c05378a 100644 --- a/tests/native/test_native_journey.py +++ b/tests/native/test_native_journey.py @@ -11,10 +11,11 @@ from agent_comms.acp_extension import QueuePromptRequest from agent_comms.cli_commands import RestartCliCommand, StartCliCommand from agent_comms.input_disposition import InputDispositions - +from agent_comms.pi_vocabulary import OffThinkingLevel from support.native import native_session from support.native_steps import channel_message_feedback, direct_reply_feedback from support.textual_steps import request_target_command + from toad.widgets.agent_response import AgentResponse from toad.widgets.comms_sidebar import CommsRow, CommsSidebar from toad.widgets.prompt import QueueSummary @@ -250,7 +251,7 @@ async def test_a_second_thread_opened_from_the_app_answers_its_first_message(wor from support.native import MODEL async def declare_second(comms, project, _provider): - comms.registry.declare(comms.registry.new_thread("gamma", frozenset({"team"}), str(project), model=MODEL, thinking_level="off")) + comms.registry.declare(comms.registry.new_thread("gamma", frozenset({"team"}), str(project), model=MODEL, thinking_level=OffThinkingLevel)) async with native_session(workspace, prepare=declare_second) as native: ui, provider = native.ui, native.provider diff --git a/tests/support/native.py b/tests/support/native.py index 82bbd2eed..180285e5d 100644 --- a/tests/support/native.py +++ b/tests/support/native.py @@ -8,20 +8,23 @@ from __future__ import annotations import asyncio +import json +import os +import shlex +import sys +import threading from collections.abc import Callable from contextlib import asynccontextmanager, contextmanager from dataclasses import dataclass, field from functools import partial from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import json -import os from pathlib import Path -import shlex -import sys -import threading + +from agent_comms.pi_vocabulary import OffThinkingLevel +from agent_comms.thread_model import DeclaredModel PROVIDER = "selected-offline" -MODEL = f"{PROVIDER}/fixture" +MODEL = DeclaredModel(f"{PROVIDER}/fixture") KEY = "offline-only-fixture" @@ -197,7 +200,7 @@ def configure_provider(base_url: str, *, settings: dict | None = None) -> None: os.environ.update( AGENT_COMMS_AGENT_BIN="pi", AGENT_COMMS_AGENT_ARGS=f"--provider {PROVIDER} --model fixture --no-extensions --no-skills --no-context-files", - AGENT_COMMS_AGENT_MODELS=MODEL, + AGENT_COMMS_AGENT_MODELS=MODEL.text, ) @@ -273,12 +276,12 @@ async def native_session(workspace, *, provider: ControlledProvider | None = Non with provider.serving() as base_url: comms = start_private_native_wire(workspace.roots.wire) configure_provider(base_url, settings=settings) - comms.registry.declare(comms.registry.new_thread("beta", frozenset({"team"}), str(workspace.project), - model=MODEL, thinking_level="off")) + comms.registry.declare(comms.registry.new_thread(NativeSession.thread, frozenset({"team"}), str(workspace.project), + model=MODEL, thinking_level=OffThinkingLevel)) try: if prepare is not None: await prepare(comms, workspace.project, provider) - async with attached_app(workspace, comms, "beta", size=size, application=application, + async with attached_app(workspace, comms, NativeSession.thread, size=size, application=application, attached=attached) as (ui, agent): yield NativeSession(ui, agent, comms, provider, workspace.project) assert not provider.failures, provider.failures @@ -293,13 +296,3 @@ async def native_session(workspace, *, provider: ControlledProvider | None = Non def without_provider(models: str = "test/model") -> None: """Configure native owners with a model nothing serves, for tests that must never ask one.""" os.environ.update(AGENT_COMMS_AGENT_BIN="pi", AGENT_COMMS_AGENT_ARGS="", AGENT_COMMS_AGENT_MODELS=models) - - -def owner_server(comms, *, provider: str = "openrouter", model: str = "fake"): - """Core's ACP server object in this process, able to start native owners on the private wire.""" - from agent_comms.acp import CommsAgent - from agent_comms.native_arguments import GivenArguments - - return CommsAgent( - comms, agent_args=GivenArguments(("--provider", provider, "--model", model)), - runtime_enabled=True, auto_wake=False) diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index 135a43923..04f5ee954 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -7,13 +7,13 @@ from agent_comms.channel_targets import ChannelTarget, ThreadTarget from agent_comms.input_disposition import InputDispositions + +from support.textual_steps import refresh_chat from toad.conversation_kind import ChannelConversation, DmConversation from toad.core import input_events from toad.navigation_target import DirectTarget, NavigationContext, channel_target -from toad.widgets.comms_chat import CommsChatView from toad.notification_status import NotificationStatus - -from support.textual_steps import refresh_chat +from toad.widgets.comms_chat import CommsChatView def rows_with(chat, predicate): @@ -141,35 +141,19 @@ async def channel_message_feedback(native, owner_tab: str) -> None: @asynccontextmanager async def goal_on_real_owner(workspace, objective: str, *, size=(110, 35), arrange=None): - """A real owner (Core's ACP server in this process, no model) with an active goal, shown in Toad - through an agent object bound to the owner's session. + """Arrange a goal before the existing native workflow starts its managed owner. - `arrange(comms, session, goal)` may change the goal on the owner before the view binds the agent. - Yields (ui, agent, Core owner, session, goal as first set). + Yields the existing NativeSession. The model uses ControlledProvider. """ - import os - from agent_comms.goal_actions import SetGoalAction - from support.native import owner_server, start_private_native_wire - - os.environ["AGENT_COMMS_AGENT_MODELS"] = "openrouter/fake" - comms = start_private_native_wire(workspace.roots.wire) - owner = owner_server(comms) - try: - session = (await owner.new_session(cwd=str(workspace.project))).session_id - async with workspace.app(size=size) as ui: - agent = ui.detached_agent() - agent.coordination = comms.configuration.answer(session) - goal = await agent.update_goal(SetGoalAction, objective) - if arrange is not None: - arrange(comms, session, goal) - conversation = ui.conversation - conversation.set_reactive(type(conversation).agent, agent) - conversation.bind_agent(agent) - conversation.agent_ready = True - await conversation.goal_observation.refresh() - await ui.step() - yield ui, agent, owner, session, goal - finally: - await owner.shutdown() + from support.native import NativeSession, native_session + + async def prepare(comms, _project, _provider): + session = NativeSession.thread + goal = comms.goals.update_goal(session, SetGoalAction(text=objective)) + if arrange is not None: + arrange(comms, session, goal) + + async with native_session(workspace, prepare=prepare, size=size) as native: + yield native diff --git a/tests/support/peers/typed_turn_events.py b/tests/support/peers/typed_turn_events.py index 7e79d9f45..1c41af07b 100644 --- a/tests/support/peers/typed_turn_events.py +++ b/tests/support/peers/typed_turn_events.py @@ -6,15 +6,16 @@ """ import asyncio -from contextlib import AsyncExitStack -from functools import partial import json import os -from pathlib import Path import sys +from contextlib import AsyncExitStack +from functools import partial +from pathlib import Path -import agent_comms.agent_events as agent_events +from agent_comms import agent_events from agent_comms.acp import CommsAgent +from agent_comms.agent_event_updates import AcpEventConsumer from agent_comms.comms import wire from agent_comms.coordinator import Coordination @@ -37,6 +38,7 @@ async def produce(wire_root: Path, project: Path): project.mkdir(parents=True, exist_ok=True) comms = await Coordination.run_worker(partial(wire, wire_root)) producer = CommsAgent(comms, runtime_enabled=False, auto_wake=False) + producer.on_connect(Pipe()) resources.push_async_callback(producer.shutdown) def declare(): @@ -51,16 +53,16 @@ def declare(): retired_owner = await Coordination.run_worker(partial( producer.turns.acquire_turn, turns, SESSION, name, "retired-producer", "Authored protocol; no input")) retired = await Coordination.run_worker(partial(producer.turns.current_turn_update, SESSION)) - producer.on_connect(Pipe()) async with AsyncExitStack() as turns: await Coordination.run_worker(partial( producer.turns.acquire_turn, turns, SESSION, name, "actual-producer", "Authored protocol; no input")) - await producer.turns.replay_turn_state(SESSION) + await producer.turns.publish_turn_state( + AcpEventConsumer(producer, SESSION, producer.sessions.client)) await producer._emit_event(SESSION, agent_events.CompactionStart()) await producer._emit_event(SESSION, agent_events.CompactionEnd( summary="Authored phase observation; not a native committed summary")) assert await Coordination.run_worker(partial( - producer._comms.agents.finish_turn, retired_owner.turn_lease)) is None + comms.agents.finish_turn, retired_owner.turn_lease)) is None # An earlier finished cut: the current binding must refuse it. await producer._emit_event(SESSION, retired) await producer.turns.goals.sync_goal_execution(SESSION, name) diff --git a/tests/textual/test_compaction_turn_events.py b/tests/textual/test_compaction_turn_events.py index b9d118361..29d406131 100644 --- a/tests/textual/test_compaction_turn_events.py +++ b/tests/textual/test_compaction_turn_events.py @@ -1,23 +1,27 @@ """Textual-facing: turn and compaction facts from Core's owners, delivered as ACP updates, drive the conversation's activity line.""" -from pathlib import Path import subprocess import sys +from pathlib import Path -from agent_comms.acp_extension import CompactionChangedUpdate, GoalChangedUpdate, TurnChangedUpdate, decode_updates +from agent_comms.acp_extension import ( + CompactionChangedUpdate, + GoalChangedUpdate, + TurnChangedUpdate, + decode_updates, +) from agent_comms.child_process import SpawnedProcess from agent_comms.coordinator import Coordination from agent_comms.goal_presentation import IdleGoalExecution from agent_comms.goals import NoGoal - PRODUCER = Path(__file__).resolve().parents[1] / "support/peers/typed_turn_events.py" async def test_phase_events_from_core_owners_reach_the_turn_activity_and_a_finished_cut_is_refused(workspace): import json - from toad.widgets.conversation import TurnActivity + from toad.widgets.throbber import Throbber command = (sys.executable, str(PRODUCER), str(workspace.roots.attempt / "producer-wire"), str(workspace.project)) with SpawnedProcess.launch(command, output=subprocess.PIPE) as child: @@ -47,7 +51,8 @@ async def consume_until(member): assert agent.current_turn.managed_id == view.turns.managed_id == turn_id assert agent.current_turn.busy and view.turns.owner.busy await consume_until(CompactionChangedUpdate) - assert "Compacting context" in str(view.query_one(TurnActivity).render()) + assert "Compacting context" in view.turns.owner.activity + assert view.query_one(Throbber).busy # Pi runs the compaction and reports its start and its end; there is no progress in between. await consume_until(CompactionChangedUpdate) assert "Compacting context" not in view.turns.owner.activity @@ -58,6 +63,7 @@ async def consume_until(member): assert not terminal.state.busy and terminal.state.matches(turn_id) assert agent.current_turn.managed_id is view.turns.managed_id is None assert not agent.current_turn.busy and not view.turns.owner.busy + assert not view.query_one(Throbber).busy await consume_until(GoalChangedUpdate) assert view.goal_display.snapshot == NoGoal() and view.goal_execution == IdleGoalExecution() assert not list(requests) diff --git a/tests/textual/test_goal_owner_views.py b/tests/textual/test_goal_owner_views.py index ff497cc49..66fd8ce82 100644 --- a/tests/textual/test_goal_owner_views.py +++ b/tests/textual/test_goal_owner_views.py @@ -4,15 +4,19 @@ from agent_comms.acp_extension import GoalChangedUpdate from agent_comms.goal_presentation import ( - GoalWaitTarget, IdleGoalExecution, PausedExecution, RunnableExecution, StandbyExecution, + GoalWaitTarget, + IdleGoalExecution, + PausedExecution, + RunnableExecution, + StandbyExecution, ) from agent_comms.goal_states import PausedGoal from agent_comms.goals import NoGoal from agent_comms.thread_identity import RecordedThreadIncarnation -from textual.widgets import Button, Static - from support.goals import ScriptedGoalOwner, bound_mentions, published_goal from support.textual_steps import channel_roster +from textual.widgets import Button, Static + from toad.core.events import CommsUpdated from toad.screens.goal_details import GoalDetails from toad.screens.goal_edit import GoalEdit @@ -56,20 +60,6 @@ async def refresh_goal(ui, view): await ui.step() -async def test_standby_row_names_the_waited_peer_and_mentions_navigate(workspace): - async with workspace.app(size=(100, 35)) as ui: - owner, view, bar, status = await shown_goal(workspace, ui) - assert "Standby" in str(status.render()) and "@peer" in str(status.render()) - original = ui.selected_tab - await ui.until(lambda: ui.link_at("@peer", occurrence=1) is not None, - why="the observed goal mention becomes a navigation link") - await ui.click("@peer", occurrence=1) - await ui.until(lambda: ui.app.selected_session._comms_thread == "peer", - why="the goal mention opens the named peer") - await ui.select_tab(original) - assert view.prompt.text == "Composer draft stays" - - async def test_standby_stays_visible_while_the_previous_turn_settles(workspace): async with workspace.app(size=(100, 35)) as ui: owner, view, bar, status = await shown_goal(workspace, ui) From d82b6943131f7dede67c0b0e3eaeb23181ed627f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:15:04 -0400 Subject: [PATCH 2/6] Publish compaction through its owning turn and notification boundary --- tests/support/peers/typed_turn_events.py | 17 ++++++++++------- tests/textual/test_compaction_turn_events.py | 17 +++++++++++------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/support/peers/typed_turn_events.py b/tests/support/peers/typed_turn_events.py index 1c41af07b..b74cf9a2c 100644 --- a/tests/support/peers/typed_turn_events.py +++ b/tests/support/peers/typed_turn_events.py @@ -18,6 +18,7 @@ from agent_comms.agent_event_updates import AcpEventConsumer from agent_comms.comms import wire from agent_comms.coordinator import Coordination +from agent_comms.turn_lease import NoFinishedTurn SESSION = "pilot" @@ -52,19 +53,21 @@ def declare(): async with AsyncExitStack() as turns: retired_owner = await Coordination.run_worker(partial( producer.turns.acquire_turn, turns, SESSION, name, "retired-producer", "Authored protocol; no input")) + retired_lease = retired_owner.turn_lease retired = await Coordination.run_worker(partial(producer.turns.current_turn_update, SESSION)) async with AsyncExitStack() as turns: await Coordination.run_worker(partial( producer.turns.acquire_turn, turns, SESSION, name, "actual-producer", "Authored protocol; no input")) - await producer.turns.publish_turn_state( - AcpEventConsumer(producer, SESSION, producer.sessions.client)) - await producer._emit_event(SESSION, agent_events.CompactionStart()) - await producer._emit_event(SESSION, agent_events.CompactionEnd( + consumer = AcpEventConsumer(producer, SESSION, producer.sessions.client) + await producer.turns.publish_turn_state(consumer) + await consumer.dispatch(agent_events.CompactionStart()) + await consumer.dispatch(agent_events.CompactionEnd( summary="Authored phase observation; not a native committed summary")) - assert await Coordination.run_worker(partial( - comms.agents.finish_turn, retired_owner.turn_lease)) is None + assert isinstance(await Coordination.run_worker(partial( + comms.agents.finish_turn, retired_lease)), NoFinishedTurn) # An earlier finished cut: the current binding must refuse it. - await producer._emit_event(SESSION, retired) + await consumer.dispatch(retired) + await producer.turns.publish_turn_state(consumer) await producer.turns.goals.sync_goal_execution(SESSION, name) diff --git a/tests/textual/test_compaction_turn_events.py b/tests/textual/test_compaction_turn_events.py index 29d406131..96b9f94fc 100644 --- a/tests/textual/test_compaction_turn_events.py +++ b/tests/textual/test_compaction_turn_events.py @@ -14,6 +14,8 @@ from agent_comms.coordinator import Coordination from agent_comms.goal_presentation import IdleGoalExecution from agent_comms.goals import NoGoal +from agent_comms.lifecycle_capability import Executing +from tests.support.agents import deliver PRODUCER = Path(__file__).resolve().parents[1] / "support/peers/typed_turn_events.py" @@ -23,7 +25,7 @@ async def test_phase_events_from_core_owners_reach_the_turn_activity_and_a_finis from toad.widgets.throbber import Throbber - command = (sys.executable, str(PRODUCER), str(workspace.roots.attempt / "producer-wire"), str(workspace.project)) + command = (sys.executable, str(PRODUCER), str(workspace.roots.wire), str(workspace.project)) with SpawnedProcess.launch(command, output=subprocess.PIPE) as child: stdout, stderr = await Coordination.run_worker(child.process.communicate) outcome = child.reap() @@ -39,7 +41,8 @@ async def test_phase_events_from_core_owners_reach_the_turn_activity_and_a_finis async def consume_until(member): for request in requests: - assert await agent.server.call(request) is None + await deliver(agent, request["params"]["update"], + session_id=request["params"]["sessionId"]) await ui.step() for fact in decode_updates(request["params"]["update"].get("_meta")): if isinstance(fact, member): @@ -47,7 +50,9 @@ async def consume_until(member): raise AssertionError(f"the producer did not publish {member.__name__}") active = await consume_until(TurnChangedUpdate) - turn_id = active.state.managed_id + while not isinstance(active.state.active, Executing): + active = await consume_until(TurnChangedUpdate) + turn_id = active.state.active.id assert agent.current_turn.managed_id == view.turns.managed_id == turn_id assert agent.current_turn.busy and view.turns.owner.busy await consume_until(CompactionChangedUpdate) @@ -57,11 +62,11 @@ async def consume_until(member): await consume_until(CompactionChangedUpdate) assert "Compacting context" not in view.turns.owner.activity stale = await consume_until(TurnChangedUpdate) - assert not stale.state.busy and not stale.state.matches(turn_id) + assert not stale.state.active.phase.busy and not stale.state.matches(turn_id) assert agent.current_turn.managed_id == view.turns.managed_id == turn_id terminal = await consume_until(TurnChangedUpdate) - assert not terminal.state.busy and terminal.state.matches(turn_id) - assert agent.current_turn.managed_id is view.turns.managed_id is None + assert not terminal.state.active.phase.busy and terminal.state.matches(turn_id) + assert agent.current_turn == view.turns.owner assert not agent.current_turn.busy and not view.turns.owner.busy assert not view.query_one(Throbber).busy await consume_until(GoalChangedUpdate) From ade9a9dc97c142a0cab5985cf3422254e1ac6b8b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:35:48 -0400 Subject: [PATCH 3/6] Use the existing agent binding across test workflows and delete its shadow --- tests/native/test_native_journey.py | 13 +++---- .../core_adapters/human_input_observer.py | 13 +++---- .../core_adapters/mcp_permission_observer.py | 2 +- tests/support/native.py | 16 +++++---- tests/support/ui.py | 1 - tests/textual/test_acp_client_requests.py | 2 +- tests/textual/test_initial_prompt_handoff.py | 7 ++-- ...est_key_bursts_move_focus_to_the_editor.py | 4 ++- tests/textual/test_permission_requests.py | 2 +- tests/textual/test_plan_display.py | 7 ++-- tests/textual/test_prompt_completion.py | 4 +-- .../textual/test_session_observation_reads.py | 35 ++++++++++++------- tests/textual/test_session_tool_calls.py | 5 +-- .../test_transcript_presentation_fencing.py | 2 +- 14 files changed, 65 insertions(+), 48 deletions(-) diff --git a/tests/native/test_native_journey.py b/tests/native/test_native_journey.py index 60c05378a..17357746a 100644 --- a/tests/native/test_native_journey.py +++ b/tests/native/test_native_journey.py @@ -14,6 +14,7 @@ from agent_comms.pi_vocabulary import OffThinkingLevel from support.native import native_session from support.native_steps import channel_message_feedback, direct_reply_feedback +from toad.acp.agent import Agent from support.textual_steps import request_target_command from toad.widgets.agent_response import AgentResponse @@ -149,12 +150,12 @@ async def test_input_queued_while_the_tab_is_hidden_is_shown_on_return(workspace await ui.until(lambda: bool(agent.queue_attachment.projection.items), seconds=20, why="the queued input") await ui.select_tab(owner_tab) view = ui.conversation - assert view.agent is agent + assert view.agent_binding is agent await ui.until(lambda: view.turns.managed_id == agent.current_turn.managed_id, seconds=20, why="the running turn on return") - await ui.until(lambda: view.agent.configuration.model.selected is not None, seconds=20, + await ui.until(lambda: view.agent_binding.configuration.model.selected is not None, seconds=20, why="the model selection") - assert view.agent.configuration.model.current == "selected-offline/fixture" + assert view.agent_binding.configuration.model.current == "selected-offline/fixture" assert view.turns.owner.busy # what the activity indicator draws from; busy_count now counts only chat history reads assert agent.queue_attachment is queue assert agent.process.process is process and process.returncode is None @@ -201,7 +202,7 @@ async def test_stopped_owner_restarts_without_replaying_and_then_idles(workspace asked = len(provider.requests) await ui.select_tab(owner_tab) - assert ui.conversation.agent is agent, "Returning to a session replaced its agent" + assert ui.conversation.agent_binding is agent, "Returning to a session replaced its agent" await agent.session.reconnect() await ui.until(agent.session.settled.is_set, seconds=30, why="the reconnect to settle") assert agent.session.connected, "The restarted owner did not accept the session" @@ -262,7 +263,7 @@ async def declare_second(comms, project, _provider): assert opened != owner_tab and ui.selected_tab == opened assert [tab.identity for tab in ui.tabs()] == [owner_tab, opened] view = ui.conversation - await ui.until(lambda: view.agent is not None and view.agent is not native.agent, seconds=20, + await ui.until(lambda: isinstance(view.agent_binding, Agent) and view.agent_binding is not native.agent, seconds=20, why="the second thread's agent") await ui.until_agent_ready() await ui.write_draft("FIRST_MESSAGE_TO_GAMMA") @@ -272,4 +273,4 @@ async def declare_second(comms, project, _provider): assert len(provider.requests) == 1 assert ui.painted(view.window).count("NATIVE_RESPONSE_1") == 1 assert [tab.identity for tab in ui.tabs()] == [owner_tab, opened] - await view.agent.stop() + await view.agent_binding.stop() diff --git a/tests/support/core_adapters/human_input_observer.py b/tests/support/core_adapters/human_input_observer.py index 29b7b010a..bbcad62e4 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -21,6 +21,7 @@ from agent_comms.input_disposition import InputDispositions from agent_comms.native_entries import NativeEntry from agent_comms.retained_task_facts import HumanInputTaskFact +from toad.acp.agent import Agent from toad.agent_schema import AgentDefinition from toad.app import ToadApp @@ -52,7 +53,7 @@ async def queue_followup(self, text): inputs = InputDispositions(self.comms.root / InputDispositions.filename) before = inputs.read() command = QueuePromptRequest(text, True) - await self.app.selected_session.conversation.agent.send_prompt(text, request=command) + await self.app.selected_session.conversation.agent_binding.send_prompt(text, request=command) await until(self.pilot, lambda: len(inputs.read().rows) == len(before.rows) + 1) row = inputs.read().lookup('acp:' + command.input_id) row.origin.require_human().author.require_registered(self.comms.registry.snapshot()) @@ -64,7 +65,7 @@ async def queue_followup(self, text): async def submit(self, text, *, image=None, while_running=None, observation_seconds=30): view = self.app.selected_session.conversation - agent = view.agent + agent = view.agent_binding inputs = InputDispositions(self.comms.root / InputDispositions.filename) before = inputs.read() wire_sequence = self.comms.bus.log.latest_sequence() @@ -183,11 +184,11 @@ def phase(name): await until(pilot, lambda: app.selected_session is not None) await app.selected_session.wait_content_ready() view = app.selected_session.conversation - await until(pilot, lambda: view.agent is not None) + await until(pilot, lambda: isinstance(view.agent_binding, Agent)) phase('actual_original_view_before_acp_attachment_settlement') - await until(pilot, view.agent.session.settled.is_set) - assert view.agent.session.connected - await until(pilot, lambda: view.agent.queue_attachment.scope.matches_session(view.agent.session_id)) + await until(pilot, view.agent_binding.session.settled.is_set) + assert view.agent_binding.session.connected + await until(pilot, lambda: view.agent_binding.queue_attachment.scope.matches_session(view.agent_binding.session_id)) observer = ActualS2Ingress(app, pilot, owner._comms, subject, output) phase('actual_acp_attached_original_queue_scope') observer.persist('same_original_owner_actual_toad_acp_attached') diff --git a/tests/support/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index a3defddd7..69fc606fd 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -165,7 +165,7 @@ async def open_observer(case: str, artifact_dir: Path) -> AsyncIterator[Observer agent = Agent(root / "project", AgentDefinition.decode(AGENT_DATA), SESSION_ID) observer = Observer(case, app, pilot, agent, session, root) try: - view.agent = agent + view.bind_agent(agent) await exercise_boundaries(agent, view, pilot) yield observer assert len(observer.receipts) == 1 diff --git a/tests/support/native.py b/tests/support/native.py index 180285e5d..1a2df1f59 100644 --- a/tests/support/native.py +++ b/tests/support/native.py @@ -14,7 +14,7 @@ import sys import threading from collections.abc import Callable -from contextlib import asynccontextmanager, contextmanager +from contextlib import AsyncExitStack, asynccontextmanager, contextmanager from dataclasses import dataclass, field from functools import partial from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -235,22 +235,24 @@ async def attached_app(workspace, comms, thread: str, *, size: tuple[int, int] = application: type | None = None, attached: bool = True, project: Path | None = None): """Start Toad on declared thread `thread` through Core's ACP server; yields (ui, agent) once the session has settled. Stops the agent and every owner on the wire afterwards.""" + from toad.acp.agent import Agent from toad.app import ToadApp - agent = None started = partial(application or ToadApp, agent_session_id=thread) if project is not None: started = partial(started, project_dir=str(project)) try: - async with workspace.app(size=size, agent=comms_agent(), application=_first_wins(started)) as ui: - await ui.until(lambda: ui.conversation.agent is not None, seconds=20, why="the agent object") - agent = ui.conversation.agent + async with AsyncExitStack() as resources, workspace.app( + size=size, agent=comms_agent(), application=_first_wins(started) + ) as ui: + await ui.until(lambda: isinstance(ui.conversation.agent_binding, Agent), + seconds=20, why="the bound ACP agent") + agent = ui.conversation.agent_binding + resources.push_async_callback(agent.stop) await ui.until(agent.session.settled.is_set, seconds=30, why="the ACP session to settle") assert agent.session.connected is attached, "Unexpected ACP attachment outcome" yield ui, agent finally: - if agent is not None: - await agent.stop() for name in comms.registry.all_threads(): await asyncio.to_thread(comms.owners.stop, name) diff --git a/tests/support/ui.py b/tests/support/ui.py index 097b2115b..d560ceddd 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -241,7 +241,6 @@ async def write_draft(self, text: str) -> None: await self.step() def attach_agent(self, agent) -> None: - self.conversation.agent = agent self.conversation.bind_agent(agent) def status(self) -> str: diff --git a/tests/textual/test_acp_client_requests.py b/tests/textual/test_acp_client_requests.py index 0ec7c26c8..7889e4e72 100644 --- a/tests/textual/test_acp_client_requests.py +++ b/tests/textual/test_acp_client_requests.py @@ -12,7 +12,7 @@ async def test_agent_requests_are_served_and_the_reply_is_drawn(workspace): app, _pilot = ui.textual await ui.until_agent_ready() view = ui.conversation - agent = view.agent + agent = view.agent_binding try: await ui.until(lambda: agent.current_mode is not None, why="the advertised modes") assert agent.current_mode is agent.available_modes[0] diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 35f8f9649..4202f9a6a 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -4,6 +4,7 @@ import asyncio from collections import Counter +from toad.agent_binding import NoAgentBound from toad.core.input_events import UserInputSubmitted from toad.screens.main import MainScreen from toad.widgets.conversation import ConversationSessionBinding @@ -60,7 +61,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() view = owner.conversation - assert view.agent is None and not view.submissions.active + assert isinstance(view.agent_binding, NoAgentBound) and not view.submissions.active assert owner._initial_prompt is None and view._initial_prompt is None assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1} @@ -83,7 +84,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() restored = owner.conversation - assert restored is not view and restored.agent is None + assert restored is not view and isinstance(restored.agent_binding, NoAgentBound) assert restored._initial_prompt is None and owner.presentation.state is None assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} editor = restored.prompt.prompt_text_area @@ -101,7 +102,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await ui.select_tab(owner.id) await ui.step() assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} - assert owner.conversation.agent is None and not owner.conversation.submissions.active + assert isinstance(owner.conversation.agent_binding, NoAgentBound) and not owner.conversation.submissions.active await app.session_navigation.close(owner.id) assert owner.presentation.widget is None and owner.presentation.state is None assert owner.id not in app.workspace_sessions.views diff --git a/tests/textual/test_key_bursts_move_focus_to_the_editor.py b/tests/textual/test_key_bursts_move_focus_to_the_editor.py index deb802399..4e3be547c 100644 --- a/tests/textual/test_key_bursts_move_focus_to_the_editor.py +++ b/tests/textual/test_key_bursts_move_focus_to_the_editor.py @@ -6,6 +6,8 @@ from textual import events +from toad.agent_binding import NoAgentBound + async def send_burst(ui, keys): app, _pilot = ui.textual @@ -23,7 +25,7 @@ async def send_burst(ui, keys): async def test_a_character_and_an_immediate_backspace_in_one_burst_leave_nothing(workspace): async with workspace.app(size=(100, 35)) as ui: - assert ui.conversation.agent is None + assert isinstance(ui.conversation.agent_binding, NoAgentBound) editor = await send_burst(ui, (("x", "x"), ("backspace", None))) await ui.until(lambda: ui.shows("❯"), why="the editor drawn") await ui.step(.2) diff --git a/tests/textual/test_permission_requests.py b/tests/textual/test_permission_requests.py index c3f3bd194..c7d2eea1e 100644 --- a/tests/textual/test_permission_requests.py +++ b/tests/textual/test_permission_requests.py @@ -46,7 +46,7 @@ async def test_a_request_changed_while_its_screen_push_is_pending_is_cancelled_o app, _pilot = ui.textual view = ui.conversation agent = ui.detached_agent("session") - view.agent = agent # binds the view once, as opening a session does + view.bind_agent(agent) await ui.step() errors = [] loop = asyncio.get_running_loop() diff --git a/tests/textual/test_plan_display.py b/tests/textual/test_plan_display.py index 49cc1ce36..6aeee90f2 100644 --- a/tests/textual/test_plan_display.py +++ b/tests/textual/test_plan_display.py @@ -9,6 +9,7 @@ from support.agents import peer from support.textual_steps import painted_text, viewport_text +from toad.acp.agent import Agent from toad.plan import PlanStatus from toad.screens.main import MainScreen from toad.widgets.note import Note @@ -35,8 +36,8 @@ async def planning_session(workspace, *plans): """The app with the plan peer connected; the prompt of `plans` is sent but the peer waits at each step.""" async with workspace.app(size=(130, 44), agent=peer("plan", name="Local ACP plan acceptance", identity="plan-acceptance")) as ui: view = ui.conversation - await ui.until(lambda: view.agent is not None, why="the agent") - agent = view.agent + await ui.until(lambda: isinstance(view.agent_binding, Agent), why="the agent") + agent = view.agent_binding await ui.until(agent.session.settled.is_set, why="the agent session") assert agent.session.connected, "Real ACP startup/initialize/newSession failed" assert agent.process.process.returncode is None @@ -169,7 +170,7 @@ async def test_a_plan_sent_while_no_view_shows_the_agent_is_drawn_when_its_view_ await ui.until(lambda: painted_text(sidebar.query_one(Plan), "DETACHED_PLAN_ITEM"), why="the sidebar plan painted") assert restored_plan.entries is agent.controller.plan_entries assert sidebar.query_one(Plan).entries is restored_plan.entries - assert view.agent is agent and agent.process.process is original_process + assert view.agent_binding is agent and agent.process.process is original_process assert view.prompt.prompt_text_area.document is original_document assert view.prompt.prompt_text_area.history is original_history assert view.prompt.text == "Detached plan draft" diff --git a/tests/textual/test_prompt_completion.py b/tests/textual/test_prompt_completion.py index 7d5607d92..600766f10 100644 --- a/tests/textual/test_prompt_completion.py +++ b/tests/textual/test_prompt_completion.py @@ -27,7 +27,7 @@ def wire_records(workspace): async def ready_prompt(workspace, ui): await ui.until_agent_ready() view = ui.conversation - return view, view.prompt, view.agent + return view, view.prompt, view.agent_binding async def test_slash_command_discovered_from_the_agent_is_completed_by_keyboard_and_executed(workspace): @@ -199,7 +199,7 @@ async def test_model_picker_selects_through_the_agent_and_keeps_the_draft(worksp await ui.press(*"second") await ui.until(lambda: painted_text(picker, "Local second"), why="the model in the picker") await ui.press("enter") - await ui.until(lambda: view.agent.configuration.model.current == "local/second", why="the selected model") + await ui.until(lambda: view.agent_binding.configuration.model.current == "local/second", why="the selected model") assert {"config_id": "model", "value": "local/second"} in wire_records(workspace) await ui.until(lambda: not picker.is_open and prompt.prompt_text_area.has_focus, why="the picker closed") assert prompt.text == "Unfinished draft" diff --git a/tests/textual/test_session_observation_reads.py b/tests/textual/test_session_observation_reads.py index 9b0799257..1dd9cf4fc 100644 --- a/tests/textual/test_session_observation_reads.py +++ b/tests/textual/test_session_observation_reads.py @@ -1,4 +1,4 @@ -"""Textual-facing: a SessionObservation reads for a mounted view, and Textual's mount and removal bound its lifetime.""" +"""A mounted view observes its actual agent binding and discards replaced reads.""" import asyncio @@ -29,7 +29,15 @@ def failed(self, view, error): class ProbeView(Widget): result = None error = None - agent = object() + + def __init__(self, agent_binding): + super().__init__() + self.agent_binding = agent_binding + + +@pytest.fixture +def probe_view(workspace): + return ProbeView(workspace.agents.detached_agent()) async def next_read(observation): @@ -37,16 +45,16 @@ async def next_read(observation): return await observation.entered.get() -async def test_invalidation_during_a_read_supersedes_its_result(): - view, app = ProbeView(), App() +async def test_invalidation_during_a_read_supersedes_its_result(probe_view): + view, app = probe_view, App() observation = ProbeObservation(view) async with app.run_test(): await app.screen.mount(view) observation.invalidate() - assert await next_read(observation) is view.agent + assert await next_read(observation) is view.agent_binding observation.invalidate() observation.responses.put_nowait("superseded") - assert await next_read(observation) is view.agent + assert await next_read(observation) is view.agent_binding assert view.result is None observation.responses.put_nowait("current") await observation.task @@ -54,26 +62,27 @@ async def test_invalidation_during_a_read_supersedes_its_result(): assert app._exception is None -async def test_a_read_for_a_previous_agent_is_not_published(): - view, app = ProbeView(), App() +async def test_a_read_for_a_previous_agent_is_not_published(probe_view, workspace): + view, app = probe_view, App() observation = ProbeObservation(view) async with app.run_test(): await app.screen.mount(view) observation.invalidate() - assert await next_read(observation) is view.agent + assert await next_read(observation) is view.agent_binding observation.responses.put_nowait("current") await observation.task observation.invalidate() previous = await next_read(observation) - view.agent = object() + view.agent_binding = workspace.agents.detached_agent() observation.responses.put_nowait("previous owner") - assert await next_read(observation) is view.agent and view.agent is not previous + assert await next_read(observation) is view.agent_binding + assert view.agent_binding is not previous assert view.result == "current" assert app._exception is None -async def test_a_closed_observation_starts_no_read_and_delivery_actions_refuse(): - view, app = ProbeView(), App() +async def test_a_closed_observation_starts_no_read_and_delivery_actions_refuse(probe_view): + view, app = probe_view, App() observation = ProbeObservation(view) async with app.run_test() as pilot: await app.screen.mount(view) diff --git a/tests/textual/test_session_tool_calls.py b/tests/textual/test_session_tool_calls.py index 1e99c5738..f7e59c595 100644 --- a/tests/textual/test_session_tool_calls.py +++ b/tests/textual/test_session_tool_calls.py @@ -6,6 +6,7 @@ from acp.schema import ToolCall as SDKToolCall from support.agents import peer +from toad.acp.agent import Agent from toad.acp.tool_calls import SessionToolCalls from toad.tool_output import ReadToolOutputPart from toad.widgets.tool_call import ToolCall @@ -23,8 +24,8 @@ def profile(frame, event, arg): async with workspace.app(size=(110, 35), agent=peer("echo")) as ui: view = ui.conversation - await ui.until(lambda: view.agent is not None, seconds=20, why="the agent to be created") - agent = view.agent + await ui.until(lambda: isinstance(view.agent_binding, Agent), seconds=20, why="the agent to be created") + agent = view.agent_binding await agent.session.settled.wait() pid = agent.process.process.pid previous = sys.getprofile() diff --git a/tests/textual/test_transcript_presentation_fencing.py b/tests/textual/test_transcript_presentation_fencing.py index 9806dbebd..23bed56be 100644 --- a/tests/textual/test_transcript_presentation_fencing.py +++ b/tests/textual/test_transcript_presentation_fencing.py @@ -144,7 +144,7 @@ async def test_a_certified_read_that_expired_acquires_no_application(workspace): view = ui.conversation agent = ui.detached_agent("journal-source") agent.coordination = registered_coordination_fact(workspace.comms, "journal-source", worktree=str(workspace.project)) - view.agent = agent + view.bind_agent(agent) entered, release = asyncio.Event(), asyncio.Event() pending = asyncio.create_task(view.transcript.publish(HeldSourceCase, entered, release)) await ui.until(entered.is_set, why="the certified read to be held") From f7f7448c4ebdffd245331573d2ecaa4d181a838a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:59:26 -0400 Subject: [PATCH 4/6] Keep native fixture roots explicit and initialize captured observations --- src/toad/session_observation.py | 2 ++ tests/native/test_native_goal_bar.py | 24 +++++++++++-------- tests/support/catalog.py | 1 + .../core_adapters/human_input_observer.py | 1 + tests/support/native.py | 17 +++++++------ .../textual/test_session_observation_reads.py | 1 + 6 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/toad/session_observation.py b/src/toad/session_observation.py index c0a56ede8..47b9c068c 100644 --- a/src/toad/session_observation.py +++ b/src/toad/session_observation.py @@ -6,6 +6,7 @@ from agent_comms.goal_presentation import GoalSnapshot from agent_comms.ui_model.delivery import InputDelivery from agent_comms.ui_model.goal import GoalUnavailable +from toad.agent_binding import AgentBinding, NoAgentBound class SessionObservation(ABC): @@ -13,6 +14,7 @@ class SessionObservation(ABC): def __init__(self, view): self._view = ref(view) + self.agent: AgentBinding = NoAgentBound() self.revision = 0 self.task = None diff --git a/tests/native/test_native_goal_bar.py b/tests/native/test_native_goal_bar.py index 1072ef8f0..5bb8cab8e 100644 --- a/tests/native/test_native_goal_bar.py +++ b/tests/native/test_native_goal_bar.py @@ -128,14 +128,16 @@ async def test_failed_ledger_repaints_retry_then_active_without_rewriting_goal(w def reply(_request, number): if number == 1: - return {"role": "assistant", "content": "No verified work"}, "stop" + return {"role": "assistant", "content": ""}, "stop" if number == 2: return {"role": "assistant", "tool_calls": [{ "index": 0, "id": "p448-read", "type": "function", "function": {"name": "read", "arguments": json.dumps({"path": "proof.txt"})}}]}, "tool_calls" + provider.hold_next_request() return {"role": "assistant", "content": "PRODUCTIVE_SUCCESSOR"}, "stop" - async with native_session(workspace, provider=ControlledProvider(reply=reply, request_budget=3)) as native: + provider = ControlledProvider(reply=reply, request_budget=4) + async with native_session(workspace, provider=provider) as native: ui, agent, comms, session = native.ui, native.agent, native.comms, native.thread app, pilot = ui.textual view = ui.conversation @@ -158,7 +160,6 @@ def reply(_request, number): if not sending.done(): sending.cancel() await asyncio.gather(sending, return_exceptions=True) - assert len(native.provider.requests) == 3 assert comms.registry.require(session).goal == first await ui.until(lambda: "Active" in str(details.query_one("#goal-current-heading", Static).render()), why="the already-open details following productive settlement") @@ -169,14 +170,17 @@ def reply(_request, number): async def test_standby_goal_mention_opens_its_peer_and_returns_to_the_draft(workspace, native_package): - async def prepare(comms, project, _provider): - comms.registry.declare(comms.registry.new_thread("peer", frozenset(), str(project))) - goal = comms.goals.update_goal(NativeSession.thread, SetGoalAction(text="Coordinate with @peer")) - comms.goals.update_goal(NativeSession.thread, StandbyGoalAction( + async with native_session(workspace) as native: + ui, comms = native.ui, native.comms + owner = comms.registry.require(native.thread) + comms.registry.declare(comms.registry.new_thread( + "peer", frozenset(), str(native.project), model=owner.model, + thinking_level=owner.thinking_level, + )) + await asyncio.to_thread(comms.owners.start, "peer") + goal = comms.goals.update_goal(native.thread, SetGoalAction(text="Coordinate with @peer")) + comms.goals.update_goal(native.thread, StandbyGoalAction( wait_for=("peer",), expect=GoalPrecondition(goal_id=goal.id))) - - async with native_session(workspace, prepare=prepare) as native: - ui = native.ui original = ui.selected_tab await ui.write_draft("Composer draft stays") await ui.until_shows("Coordinate with @peer") diff --git a/tests/support/catalog.py b/tests/support/catalog.py index f9758f0f8..07abfa1a9 100644 --- a/tests/support/catalog.py +++ b/tests/support/catalog.py @@ -38,6 +38,7 @@ def catalog_text() -> str: definition = { "identity": IDENTITY, "name": "A native catalog control", "short_name": "native-control", "protocol": "acp", "type": "coding", "author_name": "Private controlled test", + "accepts_comms_root": True, "description": "Local commands only", "help": "# Native catalog control\nPrivate controlled command data.", } diff --git a/tests/support/core_adapters/human_input_observer.py b/tests/support/core_adapters/human_input_observer.py index bbcad62e4..3135f02a7 100644 --- a/tests/support/core_adapters/human_input_observer.py +++ b/tests/support/core_adapters/human_input_observer.py @@ -175,6 +175,7 @@ def phase(name): 'name': 'Original S2/S5 fixture', 'identity': 'original-s2-s5', 'short_name': 's2-s5', 'protocol': 'acp', 'run_command': {'*': shlex.join([sys.executable, '-m', 'agent_comms.acp'])}, + 'accepts_comms_root': True, }) app = ToadApp(agent_data=definition, project_dir=subject.worktree, agent_session_id=subject.name) diff --git a/tests/support/native.py b/tests/support/native.py index 1a2df1f59..6d49537f2 100644 --- a/tests/support/native.py +++ b/tests/support/native.py @@ -176,10 +176,11 @@ def log_message(self, *_args): def start_private_native_wire(root: Path): """Create a private root; Core derives its root identity and reviewed package.""" from agent_comms.comms import Comms + from agent_comms.launch_root import LaunchRoot comms = Comms(root) comms.messaging.initialize_private_initial_protocol() - os.environ["AGENT_COMMS_ROOT"] = str(comms.root) + LaunchRoot.from_path(comms.root).install() for key in ("PI_PROMPT", "PI_PARENT_ID", "PI_AGENT_ID"): os.environ.pop(key, None) return comms @@ -208,10 +209,11 @@ def comms_agent(): """The agent definition for Core's own ACP server (``python -m agent_comms.acp``).""" from toad.agent_schema import AgentDefinition - return AgentDefinition.decode({ - "name": "Native fixture", "identity": "native-fixture", "short_name": "native", "protocol": "acp", - "run_command": {"*": shlex.join([sys.executable, "-m", "agent_comms.acp"])}, - }) + return AgentDefinition( + identity="native-fixture", name="Native fixture", short_name="native", + run_command={"*": shlex.join([sys.executable, "-m", "agent_comms.acp"])}, + accepts_comms_root=True, + ) @dataclass @@ -253,8 +255,9 @@ async def attached_app(workspace, comms, thread: str, *, size: tuple[int, int] = assert agent.session.connected is attached, "Unexpected ACP attachment outcome" yield ui, agent finally: - for name in comms.registry.all_threads(): - await asyncio.to_thread(comms.owners.stop, name) + for thread in comms.registry.all_threads().values(): + for name in thread.role.executor(thread.name): + await asyncio.to_thread(comms.owners.stop, name) def _first_wins(started): diff --git a/tests/textual/test_session_observation_reads.py b/tests/textual/test_session_observation_reads.py index 1dd9cf4fc..26465ba0c 100644 --- a/tests/textual/test_session_observation_reads.py +++ b/tests/textual/test_session_observation_reads.py @@ -74,6 +74,7 @@ async def test_a_read_for_a_previous_agent_is_not_published(probe_view, workspac observation.invalidate() previous = await next_read(observation) view.agent_binding = workspace.agents.detached_agent() + observation.invalidate() observation.responses.put_nowait("previous owner") assert await next_read(observation) is view.agent_binding assert view.agent_binding is not previous From 0849f82f33169482ce94d060594550ea8116ead0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:19:03 -0400 Subject: [PATCH 5/6] Target the goal mention by its owning text widget --- tests/native/test_native_goal_bar.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/native/test_native_goal_bar.py b/tests/native/test_native_goal_bar.py index 5bb8cab8e..3ddb5100d 100644 --- a/tests/native/test_native_goal_bar.py +++ b/tests/native/test_native_goal_bar.py @@ -17,7 +17,8 @@ ) from agent_comms.goal_presentation import IdleGoalExecution from agent_comms.goals import NoGoal -from support.native import ControlledProvider, NativeSession, native_session +from rich.cells import cell_len +from support.native import ControlledProvider, native_session from support.native_steps import goal_on_real_owner from textual.containers import VerticalScroll from textual.widgets import Static @@ -184,9 +185,12 @@ async def test_standby_goal_mention_opens_its_peer_and_returns_to_the_draft(work original = ui.selected_tab await ui.write_draft("Composer draft stays") await ui.until_shows("Coordinate with @peer") - await ui.until(lambda: ui.link_at("@peer", occurrence=1) is not None, - why="the bound goal mention becomes a navigation link") - await ui.click("@peer", occurrence=1) + summary = ui.conversation.goal_bar.summary + offset = (cell_len(summary.goal_text.split("@peer")[0]), 0) + await ui.until(lambda: ui.app.screen.get_style_at( + summary.region.x + offset[0], summary.region.y, + ).meta.get("@click") is not None, why="the objective's bound goal mention") + assert await ui.pilot.click(summary, offset=offset) await ui.until(lambda: ui.app.selected_session._comms_thread == "peer", why="the goal mention opens its peer") await ui.select_tab(original) From c17204c8b5da6d88f6fcea6ee4030a87f8038a4b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:56:12 -0400 Subject: [PATCH 6/6] Check native message progress through its typed notification owners --- tests/support/native_steps.py | 37 ++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index 04f5ee954..39949dd16 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -7,6 +7,8 @@ from agent_comms.channel_targets import ChannelTarget, ThreadTarget from agent_comms.input_disposition import InputDispositions +from agent_comms.notification_progress import IgnoredProgress +from agent_comms.presentation import MessageNotification from support.textual_steps import refresh_chat from toad.conversation_kind import ChannelConversation, DmConversation @@ -58,8 +60,8 @@ async def direct_reply_feedback(native, owner_tab: str, *, hold: bool, target: s assert len(sent_rows) == 1 sent = sent_rows[0] assert sent.sender == user and sent.target == target - await ui.until(lambda: "Responding" in str(feedback_of(chat, sent).title), seconds=20, - why="'Responding' under the sent message") + await ui.until(lambda: feedback_of(chat, sent).working, seconds=20, + why="active reply progress under the sent message") assert comms.registry.require(target).executing await ui.select_tab(owner_tab) @@ -79,11 +81,15 @@ def replies(): assert len(replies()) == 1 reply = replies()[0] assert reply.target == user and reply.reference != sent.reference - await ui.until(lambda: "Responded" in str(feedback_of(chat, sent).title), seconds=20, - why="'Responded' under the sent message") - outcomes = await asyncio.to_thread(comms.views.message_notifications, (sent,)) - assert any(item.recipient == target and item.state == "Responded" - for item in outcomes[sent.seq, sent.message_id]) + outcomes = await asyncio.to_thread(comms.views.message_notifications_for_references, + (sent.reference,)) + notifications = outcomes[sent.seq, sent.message_id] + assert any(item.recipient == target and item.answer == reply.reference and item.state.read + for item in notifications) + summary = MessageNotification.summary(notifications) + await ui.until(lambda: feedback_of(chat, sent).title == summary + and not feedback_of(chat, sent).working, seconds=20, + why="the recorded answer and settled progress under the sent message") if not hold: from agent_comms.input_origin import WireInputOrigin @@ -106,8 +112,9 @@ def replies(): for fragment in fresh.message_history.fragments_in_range) == 1 for reference in (sent.reference, reply.reference)), seconds=20, why="the message and its reply once each after reopening") - await ui.until(lambda: "Responded" in str(feedback_of(fresh, sent).title), seconds=20, - why="'Responded' after reopening") + await ui.until(lambda: feedback_of(fresh, sent).title == summary + and not feedback_of(fresh, sent).working, seconds=20, + why="the same recorded answer after reopening") await ui.select_tab(owner_tab) @@ -135,8 +142,16 @@ async def channel_message_feedback(native, owner_tab: str) -> None: provider.release() await ui.until(lambda: not comms.registry.require("beta").executing, seconds=20, why="the decision to finish") await refresh_chat(chat) - await ui.until(lambda: "Checked" in feedback_of(chat, sent).title, seconds=10, why="'Checked' under the message") - assert "no response" in feedback_of(chat, sent).title.lower(), feedback_of(chat, sent).title + outcomes = await asyncio.to_thread(comms.views.message_notifications_for_references, + (sent.reference,)) + notifications = outcomes[sent.seq, sent.message_id] + assert any(item.recipient == "beta" and isinstance(item.state, IgnoredProgress) + and item.state.read for item in notifications) + assert all(item.answer is None for item in notifications) + summary = MessageNotification.summary(notifications) + await ui.until(lambda: feedback_of(chat, sent).title == summary + and not feedback_of(chat, sent).working, seconds=10, + why="the recorded read-without-reply decision under the message") @asynccontextmanager