diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index 37cfa8418..21307f419 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 @@ -11,6 +13,7 @@ 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.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: AgentConnection) -> 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"