diff --git a/src/agent_comms/goal_presentation.py b/src/agent_comms/goal_presentation.py index 55430a3640..82ec73fc88 100644 --- a/src/agent_comms/goal_presentation.py +++ b/src/agent_comms/goal_presentation.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable from dataclasses import dataclass, fields from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Self @@ -74,6 +75,18 @@ def execution(self) -> GoalExecution: class GoalExecution(DeclaredFamily, affix="Execution"): """The complete execution family consumed by commands, views and the commander.""" + toggle_label: ClassVar[str] = "Pause" + + def can_toggle(self) -> bool: + return False + + async def apply_toggle( + self, + change: Callable[[type[GoalAction]], Awaitable[None]], + unavailable: Callable[[], None], + ) -> None: + unavailable() + def details_heading(self, heading: str) -> str: return heading @@ -138,7 +151,15 @@ def waiting_presentation(self, ordinary: ThreadPresentation) -> ThreadPresentati class ControlledExecution(BoundGoalExecution, declared_name=None): """An execution the owner moves on with one control; the others offer none.""" - toggle_label: ClassVar[str] = "Pause" + def can_toggle(self) -> bool: + return True + + async def apply_toggle( + self, + change: Callable[[type[GoalAction]], Awaitable[None]], + unavailable: Callable[[], None], + ) -> None: + await change(self.toggle) @property @abstractmethod diff --git a/tests/test_goal_execution.py b/tests/test_goal_execution.py index efdeb07945..bddc84649f 100644 --- a/tests/test_goal_execution.py +++ b/tests/test_goal_execution.py @@ -18,6 +18,8 @@ from agent_comms.comms import wire from agent_comms.nominal.field_codec import AcpSessionId, FieldCodec, GoalId from agent_comms.goal_actions import ( + ActiveGoalAction, + GoalAction, PausedGoalAction, RetryGoalAction, SetGoalAction, @@ -26,13 +28,19 @@ from agent_comms.goal_history import RecordedGoalHistory from agent_comms.goal_presentation import ( AttemptFailedExecution, + AttemptUnresolvedExecution, + CompletedExecution, GoalExecution, GoalSnapshot, + IdleGoalExecution, + PausedExecution, + RetryableBlockExecution, RunnableExecution, RunningExecution, + StandbyExecution, ) from agent_comms.goal_scope import OneGoal -from agent_comms.goal_states import ActiveGoal +from agent_comms.goal_states import ActiveGoal, ModelPause from agent_comms.maintenance_barrier import MaintenanceOperator from agent_comms.owned_turn import OwnedTurn from agent_comms.threads import Thread @@ -68,6 +76,32 @@ def test_execution_identity_survives_wire_and_change_detection(execution_bus, ex assert current.changed_by(GoalSnapshot(goal, execution)) == (current.execution != execution) +@pytest.mark.asyncio +@pytest.mark.parametrize("execution, expected_actions", ( + (IdleGoalExecution(), ()), + (CompletedExecution(GoalId.mint()), ()), + (AttemptUnresolvedExecution(GoalId.mint()), ()), + (RunnableExecution(GoalId.mint()), (PausedGoalAction,)), + (RunningExecution(GoalId.mint()), (PausedGoalAction,)), + (StandbyExecution(GoalId.mint(), (), ()), (PausedGoalAction,)), + (PausedExecution(GoalId.mint(), ModelPause()), (ActiveGoalAction,)), + (RetryableBlockExecution(GoalId.mint(), "source fixture"), (RetryGoalAction,)), +)) +async def test_execution_owns_toggle_dispatch_and_unavailability( + execution: GoalExecution, expected_actions: tuple[type[GoalAction], ...], +) -> None: + actions: list[type[GoalAction]] = [] + unavailable: list[None] = [] + + async def change(action: type[GoalAction]) -> None: + actions.append(action) + + await execution.apply_toggle(change, lambda: unavailable.append(None)) + assert tuple(actions) == expected_actions + assert execution.can_toggle() == bool(expected_actions) + assert len(unavailable) == int(not expected_actions) + + @asynccontextmanager async def turn_on(comms, *, original_owner_input=False): agent = CommsAgent(comms, auto_wake=False)