diff --git a/src/toad/agent.py b/src/toad/agent.py index cfbd50723..1d98b2412 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -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 diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 12fa3b9e8..ede6da689 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -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 () diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index 821ebfb72..257e55473 100644 --- a/src/toad/goal_interaction.py +++ b/src/toad/goal_interaction.py @@ -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 @@ -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 @@ -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 @@ -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): @@ -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): @@ -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: @@ -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) diff --git a/src/toad/screens/goal_edit.py b/src/toad/screens/goal_edit.py index 319abb1b4..966173d29 100644 --- a/src/toad/screens/goal_edit.py +++ b/src/toad/screens/goal_edit.py @@ -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()) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index bd89351c6..fe6f00798 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -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 @@ -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("") diff --git a/src/toad/widgets/goal_bar.py b/src/toad/widgets/goal_bar.py index 6a44d0bd9..a8fef603e 100644 --- a/src/toad/widgets/goal_bar.py +++ b/src/toad/widgets/goal_bar.py @@ -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) diff --git a/tests/native/test_native_catalog_launch.py b/tests/native/test_native_catalog_launch.py index 5f8342487..b46825bc6 100644 --- a/tests/native/test_native_catalog_launch.py +++ b/tests/native/test_native_catalog_launch.py @@ -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 diff --git a/tests/native/test_native_client_requests.py b/tests/native/test_native_client_requests.py index bc32bb15f..57f930226 100644 --- a/tests/native/test_native_client_requests.py +++ b/tests/native/test_native_client_requests.py @@ -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 diff --git a/tests/native/test_native_configuration.py b/tests/native/test_native_configuration.py index ddd074555..983eb8a03 100644 --- a/tests/native/test_native_configuration.py +++ b/tests/native/test_native_configuration.py @@ -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 diff --git a/tests/native/test_native_context_measurement.py b/tests/native/test_native_context_measurement.py index 96fb3fe26..62b043a5d 100644 --- a/tests/native/test_native_context_measurement.py +++ b/tests/native/test_native_context_measurement.py @@ -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() diff --git a/tests/native/test_native_context_resume.py b/tests/native/test_native_context_resume.py index d9a5cf5e6..79e0df5df 100644 --- a/tests/native/test_native_context_resume.py +++ b/tests/native/test_native_context_resume.py @@ -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): diff --git a/tests/native/test_native_editor_queue.py b/tests/native/test_native_editor_queue.py index 7f40db5d2..7bfb7f768 100644 --- a/tests/native/test_native_editor_queue.py +++ b/tests/native/test_native_editor_queue.py @@ -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 diff --git a/tests/native/test_native_goal_bar.py b/tests/native/test_native_goal_bar.py index 358c5f0bf..fd7e48bad 100644 --- a/tests/native/test_native_goal_bar.py +++ b/tests/native/test_native_goal_bar.py @@ -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 @@ -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 @@ -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") @@ -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" diff --git a/tests/native/test_native_goal_objective_edit.py b/tests/native/test_native_goal_objective_edit.py index 481aba0b2..83d6627ac 100644 --- a/tests/native/test_native_goal_objective_edit.py +++ b/tests/native/test_native_goal_objective_edit.py @@ -1,9 +1,7 @@ """Editing a goal's objective in Toad saves through the real owner and redraws the goal bar; progress stays separate.""" -import asyncio - from agent_comms.acp_extension import GoalChangedUpdate -from agent_comms.goal_actions import ActiveGoalAction, ClearGoalAction, GoalPrecondition, PausedGoalAction +from agent_comms.goal_actions import ActiveGoalAction, GoalPrecondition from textual.widgets import Static from support.native_steps import goal_on_real_owner @@ -22,12 +20,13 @@ def with_progress(comms, session, goal): expect=GoalPrecondition(goal_id=goal.id))) -def goal_in_toad(workspace, native_package): - return goal_on_real_owner(workspace, native_package, OBJECTIVE, arrange=with_progress) +def goal_in_toad(workspace): + return goal_on_real_owner(workspace, OBJECTIVE, arrange=with_progress) async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspace, native_package): - async with goal_in_toad(workspace, native_package) as (ui, agent, comms, session, first): + async with goal_in_toad(workspace) as (ui, agent, owner, session, first): + comms = owner.turns.comms app, pilot = ui.textual conversation = ui.conversation current = first @@ -71,46 +70,17 @@ async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspa await ui.until_shows("Objective: Work on draft PR #17.") -async def test_a_slow_goal_read_does_not_bring_back_a_goal_paused_then_cleared(workspace, native_package): - async with goal_in_toad(workspace, native_package) as (ui, agent, comms, session, _first): - conversation = ui.conversation - started, release = asyncio.Event(), asyncio.Event() - read_snapshot = agent.get_goal_snapshot - reads = 0 - - async def delayed_snapshot(): - nonlocal reads - reads += 1 - snapshot = await read_snapshot() - if reads == 1: - started.set() - await release.wait() - return snapshot - - agent.get_goal_snapshot = delayed_snapshot - slow = asyncio.create_task(conversation.goal_observation.refresh()) - await asyncio.wait_for(started.wait(), 2) - pause = asyncio.create_task(conversation.goal_controls.change(PausedGoalAction)) - await ui.until(lambda: comms.registry.require(session).goal.state.declared_name == "paused", - seconds=5, why="the goal paused on the owner") - clear = asyncio.create_task(conversation.goal_controls.change(ClearGoalAction)) - await ui.until(lambda: comms.registry.require(session).goal is None, seconds=5, why="the goal cleared") - release.set() - await asyncio.gather(slow, pause, clear) - assert reads >= 2, reads - assert conversation.goal_display.snapshot is None and conversation.goal_execution is None - - async def test_a_late_notice_of_an_older_goal_does_not_replace_the_current_one(workspace, native_package): - async with goal_in_toad(workspace, native_package) as (ui, agent, comms, session, first): + async with goal_in_toad(workspace) as (ui, agent, _owner, session, first): conversation = ui.conversation - older = comms.registry.require(session).goal + before = await agent.get_goal_snapshot() + older = before.goal.require() current = await agent.edit_goal(older, "Short updated goal") await conversation.goal_observation.refresh() assert conversation.goal_display.snapshot == current shown = [] conversation.watch(conversation, "goal_display", lambda display: shown.append(display.snapshot), init=False) - agent.events.publish(CommsUpdated(GoalChangedUpdate(older, None), session_id=agent.session_id)) + agent.events.publish(CommsUpdated(GoalChangedUpdate(older, before.execution), session_id=agent.session_id)) await ui.step(1) assert older not in shown, ( f"A notice of revision {older.revision} was shown over revision {current.revision}") diff --git a/tests/native/test_native_goal_owner.py b/tests/native/test_native_goal_owner.py index 827b28281..322e7bd2f 100644 --- a/tests/native/test_native_goal_owner.py +++ b/tests/native/test_native_goal_owner.py @@ -21,8 +21,8 @@ async def read(self, request): async def test_setting_a_goal_makes_it_ready_to_launch(workspace, native_package, monkeypatch): monkeypatch.setenv("AGENT_COMMS_AGENT_MODELS", "openrouter/fake") - comms = start_private_native_wire(workspace.roots.wire, native_package) - owner = owner_server(comms, native_package) + comms = start_private_native_wire(workspace.roots.wire) + owner = owner_server(comms) try: session = (await owner.new_session(cwd=str(workspace.project))).session_id agent = workspace.agents.detached_agent() @@ -40,8 +40,8 @@ async def test_setting_a_goal_makes_it_ready_to_launch(workspace, native_package async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(workspace, native_package, monkeypatch): monkeypatch.setenv("AGENT_COMMS_AGENT_MODELS", "openrouter/fake") - comms = start_private_native_wire(workspace.roots.wire, native_package) - owner = owner_server(comms, native_package) + comms = start_private_native_wire(workspace.roots.wire) + owner = owner_server(comms) try: session = (await owner.new_session(cwd=str(workspace.project))).session_id agent = workspace.agents.detached_agent() diff --git a/tests/native/test_native_human_input_origin.py b/tests/native/test_native_human_input_origin.py index dc3bf1fee..6f5522ffe 100644 --- a/tests/native/test_native_human_input_origin.py +++ b/tests/native/test_native_human_input_origin.py @@ -14,7 +14,7 @@ async def test_typed_and_queued_inputs_carry_the_same_registered_human_origin(wo provider = ControlledProvider( reply=lambda request, number: ({"role": "assistant", "content": f"ORIGIN_ANSWER_{number}"}, "stop"), request_budget=2) - async with native_session(workspace, native_package, provider=provider) as native: + async with native_session(workspace, provider=provider) as native: ui, agent, comms = native.ui, native.agent, native.comms await ui.until(lambda: agent.queue_attachment.scope.matches_session(agent.session_id), seconds=20, why="the queue attachment") provider.hold_next_request() diff --git a/tests/native/test_native_journey.py b/tests/native/test_native_journey.py index 65328a2ae..51e1c5fb9 100644 --- a/tests/native/test_native_journey.py +++ b/tests/native/test_native_journey.py @@ -60,7 +60,7 @@ async def finish_both_turns(native): async def test_owner_is_attached_and_navigable(workspace, native_package): from toad.navigation_preparation import ThreadNavigationRequest - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: owner = native.comms.registry.require("beta") assert owner.process_identity is not None and owner.process_alive navigation = await asyncio.to_thread( @@ -69,7 +69,7 @@ async def test_owner_is_attached_and_navigable(workspace, native_package): async def test_busy_owner_is_shown_busy_in_the_sidebar_at_any_width(workspace, native_package): - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: ui = native.ui app, _pilot = ui.textual await send_first_input_and_hold_its_reply(native) @@ -92,7 +92,7 @@ def beta_row(): async def test_input_queued_behind_a_running_turn_is_consumed_and_saved(workspace, native_package): - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: ui, agent, comms = native.ui, native.agent, native.comms view = ui.conversation await send_first_input_and_hold_its_reply(native) @@ -135,7 +135,7 @@ async def test_input_queued_behind_a_running_turn_is_consumed_and_saved(workspac async def test_input_queued_while_the_tab_is_hidden_is_shown_on_return(workspace, native_package): - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: ui, agent = native.ui, native.agent owner_tab = ui.selected_tab await send_first_input_and_hold_its_reply(native) @@ -166,18 +166,18 @@ async def test_input_queued_while_the_tab_is_hidden_is_shown_on_return(workspace async def test_direct_message_is_answered_once_and_marked_responded(workspace, native_package): - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: await direct_reply_feedback(native, native.ui.selected_tab, hold=True) async def test_channel_message_the_thread_declines_is_marked_checked(workspace, native_package): - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: await channel_message_feedback(native, native.ui.selected_tab) assert len(native.provider.requests) == 1, native.provider.requests async def test_stopped_owner_restarts_without_replaying_and_then_idles(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 owner_tab = ui.selected_tab @@ -221,7 +221,7 @@ async def test_stopped_owner_restarts_without_replaying_and_then_idles(workspace async def test_queue_line_follows_the_owner_after_restart_process(workspace, native_package): - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: ui, agent, comms = native.ui, native.agent, native.comms app, _pilot = ui.textual view = ui.conversation @@ -252,7 +252,7 @@ async def test_a_second_thread_opened_from_the_app_answers_its_first_message(wor async def declare_second(comms, project, _provider): comms.registry.declare(comms.registry.new_thread("gamma", frozenset({"team"}), str(project), model=MODEL, thinking_level="off")) - async with native_session(workspace, native_package, prepare=declare_second) as native: + async with native_session(workspace, prepare=declare_second) as native: ui, provider = native.ui, native.provider app, _pilot = ui.textual owner_tab = ui.selected_tab diff --git a/tests/native/test_native_mcp_inventory.py b/tests/native/test_native_mcp_inventory.py index eb83e2aae..80ff7a544 100644 --- a/tests/native/test_native_mcp_inventory.py +++ b/tests/native/test_native_mcp_inventory.py @@ -37,7 +37,7 @@ def package_inventory(project) -> bytes: def test_inventory_from_the_package_is_decoded_and_malformed_inventory_refused(workspace, native_package): - start_private_native_wire(workspace.roots.wire, native_package) + start_private_native_wire(workspace.roots.wire) agent_dir = workspace.roots.attempt / "pi" agent_dir.mkdir(mode=0o700, exist_ok=True) declare_fixture_server(native_package, workspace.project, agent_dir, trusted=False) @@ -70,7 +70,7 @@ def test_inventory_from_the_package_is_decoded_and_malformed_inventory_refused(w async def test_trust_decisions_are_applied_through_the_terminal_dialog(workspace, native_package): - start_private_native_wire(workspace.roots.wire, native_package) + start_private_native_wire(workspace.roots.wire) project = workspace.project agent_dir = workspace.roots.attempt / "pi" agent_dir.mkdir(mode=0o700, exist_ok=True) diff --git a/tests/native/test_native_project_resume.py b/tests/native/test_native_project_resume.py index 18944303d..d7785e8dc 100644 --- a/tests/native/test_native_project_resume.py +++ b/tests/native/test_native_project_resume.py @@ -22,7 +22,7 @@ async def test_saved_project_and_later_changes_replace_the_launch_directory(work launched, saved = workspace.project, workspace.roots.attempt / "saved-project" saved.mkdir() 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(launched))) comms.threads.set_project("saved", str(saved)) async with attached_app(workspace, comms, "saved", size=(100, 35)) as (ui, agent): @@ -61,7 +61,7 @@ async def test_a_project_change_reported_by_the_agent_moves_the_session(workspac moved = workspace.roots.attempt / "moved-project" moved.mkdir() 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))) async with attached_app(workspace, comms, "saved", size=(100, 35)) as (ui, agent): app, _pilot = ui.textual diff --git a/tests/native/test_native_reconnect_history.py b/tests/native/test_native_reconnect_history.py index 82a10009c..b584873ef 100644 --- a/tests/native/test_native_reconnect_history.py +++ b/tests/native/test_native_reconnect_history.py @@ -10,7 +10,7 @@ async def test_equal_answers_are_each_drawn_once_before_and_after_reconnect(workspace, native_package): provider = ControlledProvider(reply=lambda request, number: ({"role": "assistant", "content": BODY}, "stop"), request_budget=2) - async with native_session(workspace, native_package, provider=provider) as native: + async with native_session(workspace, provider=provider) as native: ui, agent, comms = native.ui, native.agent, native.comms view = ui.conversation await ui.until_agent_ready() diff --git a/tests/native/test_native_tool_output.py b/tests/native/test_native_tool_output.py index cd3b2a28e..23e642a4b 100644 --- a/tests/native/test_native_tool_output.py +++ b/tests/native/test_native_tool_output.py @@ -33,7 +33,7 @@ def reply(request, number): return {"role": "assistant", "content": "NATIVE_TOOL_OUTPUT_COMPLETE"}, "stop" (workspace.project / "example.py").write_text(SOURCE) - async with native_session(workspace, native_package, provider=ControlledProvider(reply=reply)) as native: + async with native_session(workspace, provider=ControlledProvider(reply=reply)) as native: ui, agent, provider = native.ui, native.agent, native.provider view = ui.conversation diff --git a/tests/native/test_native_widget_actions.py b/tests/native/test_native_widget_actions.py index e658e8f6e..5bd01734b 100644 --- a/tests/native/test_native_widget_actions.py +++ b/tests/native/test_native_widget_actions.py @@ -33,7 +33,7 @@ async def answered_once_and_reattached(native): async def test_question_and_permission_actions_leave_the_draft_alone(workspace, native_package): - async with native_session(workspace, native_package) as native: + async with native_session(workspace) as native: ui, agent = native.ui, native.agent app, pilot = ui.textual view = ui.conversation @@ -123,7 +123,7 @@ class ReviewQuestion(Question): async def test_double_escape_cancels_a_running_native_turn(workspace, native_package): provider = ControlledProvider(request_budget=1, expected_disconnects=frozenset({1})) - async with native_session(workspace, native_package, provider=provider) as native: + async with native_session(workspace, provider=provider) as native: ui = native.ui view = ui.conversation await ui.until_agent_ready() diff --git a/tests/native/test_thread_open_during_slow_load.py b/tests/native/test_thread_open_during_slow_load.py index 49bb5c30c..e13ed6a99 100644 --- a/tests/native/test_thread_open_during_slow_load.py +++ b/tests/native/test_thread_open_during_slow_load.py @@ -21,7 +21,7 @@ async def test_a_slow_thread_open_uses_one_tab_and_ends_with_saved_history(workspace, native_package): project = workspace.project - comms = start_private_native_wire(workspace.roots.wire, native_package) + comms = start_private_native_wire(workspace.roots.wire) for name in ("owner", "fresh-peer"): comms.registry.declare(comms.registry.new_thread(name, frozenset({"journey"}), str(project), process_identity=ProcessIdentity.capture(os.getpid()))) diff --git a/tests/support/goals.py b/tests/support/goals.py index d152ea739..5c6b59f19 100644 --- a/tests/support/goals.py +++ b/tests/support/goals.py @@ -6,7 +6,7 @@ from agent_comms.goal_actions import GoalAction from agent_comms.goal_mentions import bind_goal_mentions -from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution, ThreadGoalExecution +from agent_comms.goal_presentation import GoalExecution, GoalSnapshot, RunnableExecution from agent_comms.goal_states import ActiveGoal from agent_comms.goals import Goal, GoalDeclaration from agent_comms.registry_document import RegistrySnapshot @@ -34,10 +34,10 @@ class ScriptedGoalOwner(Agent): Every goal request is recorded in ``requests`` as (kind, details). A test sets ``goal``, ``execution`` and ``reject`` to change what the owner answers next. A control action - (Retry, Pause, ...) leaves the goal active. + receives a canned active/runnable reply; this fixture does not implement Core's transitions. """ - def __init__(self, *args, goal: GoalDeclaration, execution: ThreadGoalExecution, history: list[dict], + def __init__(self, *args, goal: GoalDeclaration, execution: GoalExecution, history: list[dict], **kwargs): super().__init__(*args, **kwargs) self.goal, self.execution, self.history = goal, execution, history @@ -46,7 +46,7 @@ def __init__(self, *args, goal: GoalDeclaration, execution: ThreadGoalExecution, @classmethod def owning(cls, app: ToadApp, goal: GoalDeclaration, - execution: ThreadGoalExecution = IdleGoalExecution(), *, + execution: GoalExecution, *, history: tuple[dict, ...] = ()) -> ScriptedGoalOwner: """The goal owner of ``app``, built through the app's own Agent constructor.""" return AppAgents(app).agent(cls, AgentDefinition("goal-owner", "goal-owner", {"*": "true"}), "goal-owner", @@ -58,6 +58,7 @@ async def get_goal_snapshot(self): async def update_goal(self, action: type[GoalAction], text: str = ""): self.requests.append(("update", action)) self.goal = replace(self.goal, state=ActiveGoal()) + self.execution = RunnableExecution(self.goal.id) return self.goal async def edit_goal(self, expected, text): diff --git a/tests/support/native.py b/tests/support/native.py index a47e07ed9..82bbd2eed 100644 --- a/tests/support/native.py +++ b/tests/support/native.py @@ -170,18 +170,13 @@ def log_message(self, *_args): server.server_close() -def start_private_native_wire(root: Path, package: Path): - """Create the test's wire and make it the route native owners launch on, with `package`.""" +def start_private_native_wire(root: Path): + """Create a private root; Core derives its root identity and reviewed package.""" from agent_comms.comms import Comms comms = Comms(root) - root_id = comms.messaging.initialize_private_initial_protocol() - comms.owners.pin_private_nk_launch(comms.root, root_id, package) - os.environ.update( - AGENT_COMMS_ROOT=str(comms.root), - AGENT_COMMS_PRIVATE_NK_WIRE_ROOT_ID=root_id, - AGENT_COMMS_PRIVATE_NK_NATIVE_PACKAGE=str(package), - ) + comms.messaging.initialize_private_initial_protocol() + os.environ["AGENT_COMMS_ROOT"] = str(comms.root) for key in ("PI_PROMPT", "PI_PARENT_ID", "PI_AGENT_ID"): os.environ.pop(key, None) return comms @@ -265,7 +260,7 @@ def construct(**given): @asynccontextmanager -async def native_session(workspace, package: Path, *, provider: ControlledProvider | None = None, +async def native_session(workspace, *, provider: ControlledProvider | None = None, settings: dict | None = None, prepare=None, size: tuple[int, int] = (160, 44), application: type | None = None, attached: bool = True): """Start Toad on a declared native thread ``beta`` whose model is the controlled provider. @@ -276,7 +271,7 @@ async def native_session(workspace, package: Path, *, provider: ControlledProvid provider = provider or ControlledProvider() with provider.serving() as base_url: - comms = start_private_native_wire(workspace.roots.wire, package) + comms = start_private_native_wire(workspace.roots.wire) configure_provider(base_url, settings=settings) comms.registry.declare(comms.registry.new_thread("beta", frozenset({"team"}), str(workspace.project), model=MODEL, thinking_level="off")) @@ -300,11 +295,11 @@ def without_provider(models: str = "test/model") -> None: os.environ.update(AGENT_COMMS_AGENT_BIN="pi", AGENT_COMMS_AGENT_ARGS="", AGENT_COMMS_AGENT_MODELS=models) -def owner_server(comms, package: Path, *, provider: str = "openrouter", model: str = "fake"): +def owner_server(comms, *, provider: str = "openrouter", model: str = "fake"): """Core's ACP server object in this process, able to start native owners on the private wire.""" from agent_comms.acp import CommsAgent + from agent_comms.native_arguments import GivenArguments return CommsAgent( - comms, agent_bin="pi", agent_args=["--provider", provider, "--model", model], - runtime_enabled=True, auto_wake=False, private_nk_native_package=package, - private_nk_wire_root_id=os.environ["AGENT_COMMS_PRIVATE_NK_WIRE_ROOT_ID"]) + comms, agent_args=GivenArguments(("--provider", provider, "--model", model)), + runtime_enabled=True, auto_wake=False) diff --git a/tests/support/native_steps.py b/tests/support/native_steps.py index 5f933cebc..135a43923 100644 --- a/tests/support/native_steps.py +++ b/tests/support/native_steps.py @@ -140,12 +140,12 @@ async def channel_message_feedback(native, owner_tab: str) -> None: @asynccontextmanager -async def goal_on_real_owner(workspace, package, objective: str, *, size=(110, 35), arrange=None): +async def goal_on_real_owner(workspace, objective: str, *, size=(110, 35), arrange=None): """A real owner (Core's ACP server in this process, no model) with an active goal, shown in Toad through an agent object bound to the owner's session. `arrange(comms, session, goal)` may change the goal on the owner before the view binds the agent. - Yields (ui, agent, comms, session, goal as first set). + Yields (ui, agent, Core owner, session, goal as first set). """ import os @@ -154,8 +154,8 @@ async def goal_on_real_owner(workspace, package, objective: str, *, size=(110, 3 from support.native import owner_server, start_private_native_wire os.environ["AGENT_COMMS_AGENT_MODELS"] = "openrouter/fake" - comms = start_private_native_wire(workspace.roots.wire, package) - owner = owner_server(comms, package) + comms = start_private_native_wire(workspace.roots.wire) + owner = owner_server(comms) try: session = (await owner.new_session(cwd=str(workspace.project))).session_id async with workspace.app(size=size) as ui: @@ -170,6 +170,6 @@ async def goal_on_real_owner(workspace, package, objective: str, *, size=(110, 3 conversation.agent_ready = True await conversation.goal_observation.refresh() await ui.step() - yield ui, agent, comms, session, goal + yield ui, agent, owner, session, goal finally: await owner.shutdown() diff --git a/tests/textual/test_compaction_turn_events.py b/tests/textual/test_compaction_turn_events.py index ed0db1336..b9d118361 100644 --- a/tests/textual/test_compaction_turn_events.py +++ b/tests/textual/test_compaction_turn_events.py @@ -7,6 +7,8 @@ from agent_comms.acp_extension import CompactionChangedUpdate, GoalChangedUpdate, TurnChangedUpdate, decode_updates from agent_comms.child_process import SpawnedProcess from agent_comms.coordinator import Coordination +from agent_comms.goal_presentation import IdleGoalExecution +from agent_comms.goals import NoGoal PRODUCER = Path(__file__).resolve().parents[1] / "support/peers/typed_turn_events.py" @@ -57,7 +59,7 @@ async def consume_until(member): assert agent.current_turn.managed_id is view.turns.managed_id is None assert not agent.current_turn.busy and not view.turns.owner.busy await consume_until(GoalChangedUpdate) - assert view.goal_display.snapshot is None and view.goal_execution is None + assert view.goal_display.snapshot == NoGoal() and view.goal_execution == IdleGoalExecution() assert not list(requests) finally: await agent.stop() diff --git a/tests/textual/test_goal_bar_collapse.py b/tests/textual/test_goal_bar_collapse.py index 940f3df26..f64950bc6 100644 --- a/tests/textual/test_goal_bar_collapse.py +++ b/tests/textual/test_goal_bar_collapse.py @@ -2,7 +2,7 @@ from dataclasses import replace -from agent_comms.goal_presentation import GoalExecution, GoalExecutionState, GoalWaitTarget +from agent_comms.goal_presentation import GoalWaitTarget, RunnableExecution, StandbyExecution from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.ui_model.goal import GoalUnavailable from textual.widgets import Static @@ -17,6 +17,7 @@ async def shown_bar(ui): view = ui.conversation view.goal_display = GOAL + view.goal_execution = RunnableExecution(GOAL.id) view.prompt.text = "Keep this draft" await ui.step() bar = view.query_one(GoalBar) @@ -80,7 +81,7 @@ async def test_expanding_shows_standby_and_the_latest_progress(workspace): assert await pilot.click(toggle) await ui.step() view.goal_display = replace(GOAL, revision=30, progress="UPDATED_PROGRESS") - view.goal_execution = GoalExecution(GoalExecutionState.STANDBY, GOAL.id, (GoalWaitTarget(RecordedThreadIncarnation("peer", 1.0)),)) + view.goal_execution = StandbyExecution(GOAL.id, (GoalWaitTarget(RecordedThreadIncarnation("peer", 1.0)),), ()) await ui.until(lambda: "Standby" in str(header.render()), why="Standby in the collapsed header") assert not bar.query_one(StandbyPulse).active assert await pilot.click(toggle) diff --git a/tests/textual/test_goal_controls.py b/tests/textual/test_goal_controls.py index 7ea4c4c8c..60ade5e9d 100644 --- a/tests/textual/test_goal_controls.py +++ b/tests/textual/test_goal_controls.py @@ -1,6 +1,7 @@ """Textual-facing: the goal bar mounts one control per declared interaction, paints them, and collapses.""" from agent_comms.goal_actions import PausedGoalAction +from agent_comms.goal_presentation import RunnableExecution from toad.goal_interaction import GoalInteraction, GoalSession from toad.widgets.goal_bar import GoalBar, GoalControl @@ -17,7 +18,9 @@ async def apply(cls, session): async def show_goal(ui): view = ui.conversation - view.goal_display = published_goal("Visible goal for control discovery", "new-control") + goal = published_goal("Visible goal for control discovery", "new-control") + view.goal_display = goal + view.goal_execution = RunnableExecution(goal.id) await ui.until(lambda: view.query(GoalBar), why="the goal bar") return view, view.query_one(GoalBar) diff --git a/tests/textual/test_goal_owner_views.py b/tests/textual/test_goal_owner_views.py index 40ed27cef..ff497cc49 100644 --- a/tests/textual/test_goal_owner_views.py +++ b/tests/textual/test_goal_owner_views.py @@ -3,14 +3,15 @@ from dataclasses import replace from agent_comms.acp_extension import GoalChangedUpdate -from agent_comms.goal_presentation import GoalExecution, GoalExecutionState, GoalWaitTarget, IdleGoalExecution +from agent_comms.goal_presentation import ( + GoalWaitTarget, IdleGoalExecution, PausedExecution, RunnableExecution, StandbyExecution, +) from agent_comms.goal_states import PausedGoal from agent_comms.goals import NoGoal from agent_comms.thread_identity import RecordedThreadIncarnation from textual.widgets import Button, Static from support.goals import ScriptedGoalOwner, bound_mentions, published_goal -from toad.line_blocks import OpenTarget from support.textual_steps import channel_roster from toad.core.events import CommsUpdated from toad.screens.goal_details import GoalDetails @@ -22,7 +23,7 @@ def goal_owner(workspace, app): goal = bound_mentions(published_goal("Coordinate with @peer", "stable-goal", progress="Keep evidence", revision=4), workspace.thread("goal-owner"), workspace.comms.registry.snapshot()) - execution = GoalExecution(GoalExecutionState.STANDBY, goal.id, (GoalWaitTarget(RecordedThreadIncarnation("peer", 1.0)),)) + execution = StandbyExecution(goal.id, (GoalWaitTarget(RecordedThreadIncarnation("peer", 1.0)),), ()) history = [ {"sequence": 1, "kind": "baseline", "observed_at": 1000.0, "before": None, "after": replace(goal, text="HISTORICAL_OBJECTIVE", revision=4).to_wire()}, @@ -58,11 +59,15 @@ async def refresh_goal(ui, view): async def test_standby_row_names_the_waited_peer_and_mentions_navigate(workspace): async with workspace.app(size=(100, 35)) as ui: owner, view, bar, status = await shown_goal(workspace, ui) - rendered = bar.query_one(".goal-summary", Static).render() - assert any(not isinstance(span.style, str) - and (span.style.meta or {}).get("@click") == OpenTarget("peer").click - for span in rendered.spans), "Goal mentions must use existing thread navigation" assert "Standby" in str(status.render()) and "@peer" in str(status.render()) + original = ui.selected_tab + await ui.until(lambda: ui.link_at("@peer", occurrence=1) is not None, + why="the observed goal mention becomes a navigation link") + await ui.click("@peer", occurrence=1) + await ui.until(lambda: ui.app.selected_session._comms_thread == "peer", + why="the goal mention opens the named peer") + await ui.select_tab(original) + assert view.prompt.text == "Composer draft stays" async def test_standby_stays_visible_while_the_previous_turn_settles(workspace): @@ -81,16 +86,17 @@ async def test_standby_is_never_inferred_from_the_goal_text_or_a_paused_goal(wor async with workspace.app(size=(100, 35)) as ui: owner, view, bar, status = await shown_goal(workspace, ui) standby_execution = owner.execution - owner.execution = GoalExecution(GoalExecutionState.RUNNABLE, owner.goal.id) + owner.execution = RunnableExecution(owner.goal.id) await refresh_goal(ui, view) await ui.until(lambda: not status.display, why="the standby row hidden when the owner says runnable") - owner.execution = standby_execution active_goal = owner.goal - owner.goal = replace(owner.goal, state=PausedGoal()) + pause = PausedGoal() + owner.goal = replace(owner.goal, state=pause) + owner.execution = PausedExecution(owner.goal.id, pause.source) await refresh_goal(ui, view) - await ui.until(lambda: not status.display, - why="a previous standby projection cannot override paused goal state") + await ui.until(lambda: not status.display, why="the owner's paused execution") owner.goal = active_goal + owner.execution = standby_execution await refresh_goal(ui, view) await ui.until(lambda: status.display, why="the standby row back") diff --git a/tests/textual/test_goal_retry.py b/tests/textual/test_goal_retry.py index fbe0cb4d9..0a4190cba 100644 --- a/tests/textual/test_goal_retry.py +++ b/tests/textual/test_goal_retry.py @@ -1,6 +1,7 @@ """Textual-facing: the goal bar's Retry control is a mounted button that reaches the goal owner.""" from agent_comms.goal_actions import RetryGoalAction +from agent_comms.goal_presentation import DecidedBlockExecution from agent_comms.goal_states import BlockedGoal from support.goals import ScriptedGoalOwner, published_goal @@ -12,15 +13,14 @@ async def test_blocked_goal_offers_retry_and_retry_reaches_the_owner(workspace): view = ui.conversation goal = published_goal("Learn architectural factoring", "goal-control", state=BlockedGoal(block_reason="Original turn failed")) - owner = ScriptedGoalOwner.owning(ui.app, goal) + owner = ScriptedGoalOwner.owning(ui.app, goal, DecidedBlockExecution(goal.id, goal.state.block_reason)) ui.attach_agent(owner) view.agent_ready = True - view.goal_display = goal + await view.goal_observation.refresh() bar = view.query_one(GoalBar) - assert str(bar.query_one("#goal-toggle").render()) == "Retry" await ui.until_shows("Retry") await ui.click("Retry") await ui.until(lambda: owner.requests, why="the retry request at the owner") assert owner.requests == [("update", RetryGoalAction)] - await ui.until(lambda: view.goal_display.snapshot.state.declared_name == "active", - why="the goal shown as active") + await ui.until(lambda: str(bar.query_one("#goal-toggle").render()) == "Pause", + why="the owner's runnable reply repaints the control")