From 3f9ffd6e3b82234801d2921020c4c92db43526f1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 12:16:54 -0500 Subject: [PATCH 1/2] Share goal refusal reporting across button and slash controls --- src/toad/goal_interaction.py | 20 +++++++++++++++----- tests/textual/test_goal_owner_views.py | 23 +++++++++++++++-------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index 37cfa8418..5b11844b4 100644 --- a/src/toad/goal_interaction.py +++ b/src/toad/goal_interaction.py @@ -2,6 +2,8 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Iterator +from contextlib import contextmanager from typing import ClassVar, TYPE_CHECKING from weakref import ref @@ -13,6 +15,7 @@ from agent_comms.goal_history import RecordedGoalHistory if TYPE_CHECKING: + from toad.acp.agent import Agent from toad.widgets.conversation import Conversation @@ -161,12 +164,18 @@ async def activate(self, action: type[GoalInteraction]) -> None: if not action.enabled(self): view.feedback.flash(view.goal_display.heading(view.goal_execution)) return - try: + with self.reporting_refusals(agent): await action.apply(self) + + @contextmanager + def reporting_refusals(self, agent: Agent) -> Iterator[None]: + """Buttons and slash controls report once, only to their original source.""" + try: + yield except Refused as error: if not self.owns(agent): raise - view.app.show_error(error, "Goal action failed") + self.view.app.show_error(error, "Goal action failed") async def change(self, action: type[GoalAction], text: str = "") -> None: view = self.view @@ -175,9 +184,10 @@ async def change(self, action: type[GoalAction], text: str = "") -> None: raise GoalRefused("The goal presentation changed; reopen its controls.") # The existing Agent boundary encodes the declared goal operation. # Goal state governs continuation, never interruption of an active turn. - await self.write(agent, agent.update_goal(action, text)) - if self.owns(agent): - view.prompt.focus() + with self.reporting_refusals(agent): + await self.write(agent, agent.update_goal(action, text)) + if self.owns(agent): + view.prompt.focus() async def write(self, agent, operation): """Every write settles by reading the owner, including rejected writes.""" diff --git a/tests/textual/test_goal_owner_views.py b/tests/textual/test_goal_owner_views.py index 06d7595b3..2db788dea 100644 --- a/tests/textual/test_goal_owner_views.py +++ b/tests/textual/test_goal_owner_views.py @@ -6,7 +6,7 @@ import pytest from agent_comms.acp_extension import GoalChangedUpdate -from agent_comms.goal_actions import ClearGoalAction +from agent_comms.goal_actions import ClearGoalAction, RetryGoalAction from agent_comms.nominal.field_codec import FieldCodec, MalformedRecord, TurnId from agent_comms.goal_failure_observation import FailedTurnEvidence from agent_comms.goal_history import ( @@ -29,6 +29,7 @@ from toad.navigation_target import SelectTarget from toad.screens.goal_details import GoalDetails from toad.screens.goal_edit import GoalEdit +from toad.command_catalog import CommandCatalog from toad.widgets.goal_bar import GoalBar, GoalControl @@ -186,17 +187,23 @@ async def test_goal_history_lists_earlier_revisions(workspace): assert view.prompt.text == "Composer draft stays" -async def test_refused_goal_control_preserves_owner_state_and_composer(workspace): +@pytest.mark.parametrize("command, expected_action", ( + ("", ClearGoalAction), + ("/goal retry", RetryGoalAction), +)) +async def test_refused_goal_control_preserves_owner_state_and_composer(workspace, command, expected_action): async with workspace.app(size=(100, 35), notifications=True) as ui: owner, view, bar, _status = await shown_goal(workspace, ui) original = owner.goal - clear = bar.query_one("#goal-clear", GoalControl) - hit = ui.app.screen.get_widget_at(*clear.region.offset)[0] - print(f"Goal clear region={clear.region!r}; hit={hit!r}; disabled={clear.disabled}") - assert await ui.pilot.click(clear), "the real clear control must receive the click" - await ui.until(lambda: owner.requests == [("update", ClearGoalAction)], - why="one goal-clear request at the scripted owner") + if command: + assert await CommandCatalog.execute(command, view) + else: + clear = bar.query_one("#goal-clear", GoalControl) + assert await ui.pilot.click(clear), "the real clear control must receive the click" + await ui.until(lambda: owner.requests == [("update", expected_action)], + why="one goal-control request at the scripted owner") await ui.until_shows("Goal action failed") + assert sum(note.title == "Goal action failed" for note in ui.app._notifications) == 1 assert owner.goal == original assert view.goal_display.snapshot == original assert view.prompt.text == "Composer draft stays" From a5a8e354efab685ee93f86c0edaae5495fdc0f0f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 12:53:51 -0500 Subject: [PATCH 2/2] Use the declared agent connection for goal refusal reporting --- src/toad/goal_interaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index 5b11844b4..21307f419 100644 --- a/src/toad/goal_interaction.py +++ b/src/toad/goal_interaction.py @@ -13,9 +13,9 @@ from agent_comms.goal_actions import ClearGoalAction, GoalAction from agent_comms.goal_presentation import ControlledExecution, GoalExecution from agent_comms.goal_history import RecordedGoalHistory +from toad.agent_binding import AgentConnection if TYPE_CHECKING: - from toad.acp.agent import Agent from toad.widgets.conversation import Conversation @@ -168,7 +168,7 @@ async def activate(self, action: type[GoalInteraction]) -> None: await action.apply(self) @contextmanager - def reporting_refusals(self, agent: Agent) -> Iterator[None]: + def reporting_refusals(self, agent: AgentConnection) -> Iterator[None]: """Buttons and slash controls report once, only to their original source.""" try: yield