Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/toad/goal_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
23 changes: 15 additions & 8 deletions tests/textual/test_goal_owner_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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


Expand Down Expand Up @@ -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"
Expand Down
Loading