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
3 changes: 0 additions & 3 deletions src/toad/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ def attach_view(self, view) -> None:
def matches_agent(self, agent) -> bool:
return self is agent

def goal_editor(self):
return self

def cancel_allowed(self, turn):
return turn.can_cancel

Expand Down
3 changes: 0 additions & 3 deletions src/toad/agent_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,6 @@ def accepts_failed_input(self, message):
def send_now(self, owner):
"""No remote queue is bound."""

def goal_editor(self):
raise ValueError("Editing requires an agent-comms goal")

async def get_goal_history(self, goal_id):
return ()

Expand Down
38 changes: 18 additions & 20 deletions src/toad/goal_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@

from agent_comms.declared_family import DeclaredFamily
from agent_comms.goal_actions import ClearGoalAction, GoalAction
from agent_comms.goal_presentation import ThreadGoalExecution
from agent_comms.goal_presentation import ControlledExecution, GoalExecution

if TYPE_CHECKING:
from agent_comms.ui_model.goal import GoalDisplay
from toad.widgets.conversation import Conversation


Expand All @@ -22,15 +21,15 @@ def control_id(cls) -> str:
return f"goal-{cls.declared_name}"

@classmethod
def enabled(cls, display: GoalDisplay) -> bool:
return display.can_control
def enabled(cls, session: GoalSession) -> bool:
return session.display.can_control

@classmethod
def visible(cls, display: GoalDisplay) -> bool:
def visible(cls, session: GoalSession) -> bool:
return True

@classmethod
def caption(cls, display: GoalDisplay, collapsed: bool) -> str:
def caption(cls, session: GoalSession, collapsed: bool) -> str:
return cls.label

@classmethod
Expand All @@ -57,11 +56,11 @@ class CollapseInteraction(GoalInteraction):
label = "Collapse"

@classmethod
def enabled(cls, display):
def enabled(cls, session):
return True

@classmethod
def caption(cls, display, collapsed):
def caption(cls, session, collapsed):
return "Expand" if collapsed else cls.label

@classmethod
Expand All @@ -74,21 +73,20 @@ class ToggleInteraction(GoalInteraction):
label = "Pause"

@classmethod
def visible(cls, display):
return display.can_control and not display.snapshot.require().state.terminal
def visible(cls, session):
return session.display.can_control and isinstance(session.execution, ControlledExecution)

@classmethod
def caption(cls, display, collapsed):
return display.snapshot.require().state.toggle_label if display.can_control else cls.label
def caption(cls, session, collapsed):
return session.execution.toggle_label if cls.visible(session) else cls.label

@classmethod
async def apply(cls, session):
view = session.view
action = view.goal_display.snapshot.require().state.toggle
if action is None:
view.flash("This goal is completed; set a new goal to continue.")
execution = session.execution
if isinstance(execution, ControlledExecution):
await session.change(execution.toggle)
else:
await session.change(action)
session.view.flash(session.display.heading(execution))


class EditInteraction(GoalInteraction):
Expand All @@ -100,7 +98,7 @@ async def apply(cls, session):
from toad.widgets.goal_text import goal_mention_candidates
view = session.view
goal = view.goal_display.snapshot.require()
agent = view.agent_binding.goal_editor()
agent = view.agent_binding

async def save(text: str) -> None:
if not session.owns(agent):
Expand Down Expand Up @@ -135,7 +133,7 @@ def display(self):
return self.view.goal_display

@property
def execution(self) -> ThreadGoalExecution:
def execution(self) -> GoalExecution:
return self.view.goal_execution

def owns(self, agent) -> bool:
Expand All @@ -156,7 +154,7 @@ async def activate(self, action: type[GoalInteraction]) -> None:
await view.goal_observation.refresh()
if not self.owns(agent):
return
if not action.enabled(view.goal_display):
if not action.enabled(self):
view.flash(view.goal_display.heading(view.goal_execution))
return
await action.apply(self)
Expand Down
2 changes: 1 addition & 1 deletion src/toad/screens/goal_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def save(self, text: str) -> None:
save_button.disabled = True
try:
await self.on_save(text.strip())
except RequestError as error:
except (RequestError, ValueError) as error:
self.query_one("#goal-edit-error", Static).update(str(error))
else:
self.dismiss(text.strip())
Expand Down
4 changes: 2 additions & 2 deletions src/toad/widgets/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from time import monotonic, time_ns
from typing import TYPE_CHECKING, Any, Callable, Literal

from agent_comms.goal_presentation import ThreadGoalExecution, IdleGoalExecution
from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution
from agent_comms.mro_dispatch import handles
from toad.widgets.message_notifications import HistoryNotifications, SourceNotifications
from rich.segment import Segment
Expand Down Expand Up @@ -480,7 +480,7 @@ def mode_selection_available(self) -> bool:
goal_display: var[GoalDisplay] = var(NoGoal())


goal_execution: var[ThreadGoalExecution] = var(IdleGoalExecution())
goal_execution: var[GoalExecution] = var(IdleGoalExecution())


status: var[str | Content | RichText] = var("")
Expand Down
7 changes: 4 additions & 3 deletions src/toad/widgets/goal_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,12 @@ def _update_goal_text(self) -> None:
objective = goal.objective
execution = self.execution
self.header.update(state.heading(execution))
session = self._source().goal_controls
for control in self.actions:
action = control.interaction
control.disabled = not action.enabled(state)
control.display = action.visible(state)
control.update(action.caption(state, self.collapsed))
control.disabled = not action.enabled(session)
control.display = action.visible(session)
control.update(action.caption(session, self.collapsed))
self.document.display = goal.shows_document and not self.collapsed
self.summary.update_goal_text(f"Objective: {objective}")
standby = state.standby(execution)
Expand Down
2 changes: 1 addition & 1 deletion tests/native/test_native_catalog_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async def test_store_launch_starts_a_session_and_leaves_the_open_one_alone(works
def with_catalog_agent(**arguments):
return ToadApp(**{**arguments, "agent_data": definition})

async with native_session(workspace, native_package, application=with_catalog_agent) as native:
async with native_session(workspace, application=with_catalog_agent) as native:
ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider
app, _pilot = ui.textual
view = ui.conversation
Expand Down
2 changes: 1 addition & 1 deletion tests/native/test_native_client_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@


async def test_client_requests_follow_the_session_they_belong_to(workspace, native_package):
async with native_session(workspace, native_package) as native:
async with native_session(workspace) as native:
ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider
app, _pilot = ui.textual
view = ui.conversation
Expand Down
2 changes: 1 addition & 1 deletion tests/native/test_native_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@


async def test_thinking_level_changes_during_a_turn_and_survives_reattaching(workspace, native_package):
async with native_session(workspace, native_package) as native:
async with native_session(workspace) as native:
ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider
app, pilot = ui.textual
view = ui.conversation
Expand Down
2 changes: 1 addition & 1 deletion tests/native/test_native_context_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@


async def test_measured_usage_is_shown_and_restored_without_asking_the_model(workspace, native_package):
async with native_session(workspace, native_package) as native:
async with native_session(workspace) as native:
ui, agent, provider = native.ui, native.agent, native.provider
view = ui.conversation
await ui.until_agent_ready()
Expand Down
2 changes: 1 addition & 1 deletion tests/native/test_native_context_resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

async def test_saved_context_use_is_shown_on_attach_and_replaced_by_live_reports(workspace, native_package):
without_provider()
comms = start_private_native_wire(workspace.roots.wire, native_package)
comms = start_private_native_wire(workspace.roots.wire)
comms.registry.declare(comms.registry.new_thread("saved", frozenset({"acp"}), str(workspace.project)))
comms.agents.set_agent_info("saved", model="test/model", context_used=38723, context_size=272000)
async with attached_app(workspace, comms, "saved", size=(120, 40)) as (ui, agent):
Expand Down
2 changes: 1 addition & 1 deletion tests/native/test_native_editor_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


async def test_enter_queues_during_a_turn_and_a_tab_click_returns_to_the_same_editor(workspace, native_package):
async with native_session(workspace, native_package) as native:
async with native_session(workspace) as native:
ui, agent, comms, provider = native.ui, native.agent, native.comms, native.provider
app, pilot = ui.textual
view = ui.conversation
Expand Down
107 changes: 54 additions & 53 deletions tests/native/test_native_goal_bar.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""The goal bar follows the real owner: standby, a long goal that scrolls, edits made elsewhere, an owner outage."""
"""The goal bar follows the real owner: standby, a long goal that scrolls, edits made elsewhere, pause/resume and clear."""

import asyncio
import os
import threading
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,
StandbyGoalAction,
)
from agent_comms.goal_presentation import IdleGoalExecution
from agent_comms.goals import NoGoal
from textual.containers import VerticalScroll
from textual.widgets import Static

Expand Down Expand Up @@ -40,12 +43,12 @@ 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, native_package, OBJECTIVE, size=(90, 35),
arrange=standing_by_for_a_peer(workspace)) as (ui, _agent, _comms, _session, _goal):
async with goal_on_real_owner(workspace, OBJECTIVE, size=(90, 35),
arrange=standing_by_for_a_peer(workspace)) as (ui, _agent, _owner, _session, _goal):
conversation = ui.conversation
await ui.until(lambda: conversation.goal_execution is not None, why="the goal's execution state")
bar = conversation.query_one(GoalBar)
assert "Standby" in str(bar.query_one(".goal-header", Static).render())
await ui.until(lambda: "Standby" in str(bar.query_one(".goal-header", Static).render()),
why="the owner's standby execution")
assert conversation.goal_display.snapshot.state.declared_name == "active"
pulse = bar.query_one(StandbyPulse)
assert pulse.active
Expand Down Expand Up @@ -73,8 +76,9 @@ 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, native_package, OBJECTIVE, size=(90, 35)) as (
ui, _agent, comms, session, _goal):
async with goal_on_real_owner(workspace, OBJECTIVE, size=(90, 35)) as (
ui, _agent, owner, session, _goal):
comms = owner.turns.comms
app, pilot = ui.textual
conversation = ui.conversation
await pilot.click("#goal-history")
Expand All @@ -95,56 +99,53 @@ async def test_edits_made_on_the_owner_reach_open_goal_screens_without_touching_
assert editor.editor.text == "UNSAVED DRAFT"


async def test_pause_resume_owner_outage_and_clear(workspace, native_package):
async with goal_on_real_owner(workspace, native_package, OBJECTIVE, size=(90, 35),
arrange=standing_by_for_a_peer(workspace)) as (ui, _agent, comms, _session, _goal):
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):
conversation = ui.conversation
await ui.until(lambda: conversation.goal_execution is not None, why="the goal's execution state")
bar = conversation.query_one(GoalBar)
pulse = bar.query_one(StandbyPulse)
await ui.until(lambda: pulse.active, why="the owner's standby execution")
await conversation.goal_controls.change(PausedGoalAction)
assert conversation.goal_display.snapshot.state.declared_name == "paused" and not pulse.active
await conversation.goal_controls.change(ActiveGoalAction)
assert conversation.goal_display.snapshot.state.declared_name == "active"

# An owner that cannot be read marks the kept goal unavailable; it is never shown as standby.
read = comms.goals.goal_snapshot

def unavailable(_name):
raise OSError("Owner temporarily unavailable")

comms.goals.goal_snapshot = unavailable
try:
await ui.until(lambda: not conversation.goal_display.can_control, seconds=6, why="the goal marked unavailable")
assert "unavailable" in str(bar.query_one(".goal-header", Static).render())
assert not pulse.active
assert all(control.disabled for control in bar.query(GoalControl) if control.id != "goal-collapse")
assert not bar.query_one("#goal-collapse", GoalControl).disabled
finally:
comms.goals.goal_snapshot = read
await ui.until(lambda: conversation.goal_display.can_control, seconds=6, why="the goal controllable again")
await conversation.goal_controls.change(ClearGoalAction)
assert conversation.goal_display.snapshot is None and not bar.display


async def test_a_goal_read_from_a_stalled_owner_gives_up(workspace, native_package):
async with goal_on_real_owner(workspace, native_package, OBJECTIVE, size=(90, 35)) as (
_ui, agent, comms, _session, _goal):
read = comms.goals.goal_snapshot
release = threading.Event()

def stalled(name):
release.wait(10)
return read(name)

comms.goals.goal_snapshot = stalled
try:
try:
await asyncio.wait_for(agent.get_goal_snapshot(), 3.5)
except TimeoutError:
pass
else:
raise AssertionError("The goal read did not time out")
finally:
release.set()
comms.goals.goal_snapshot = read
assert conversation.goal_display.snapshot == NoGoal()
assert conversation.goal_execution == IdleGoalExecution() and not bar.display


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):
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")
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()
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"
Loading
Loading