Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/toad/session_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
from agent_comms.goal_presentation import GoalSnapshot
from agent_comms.ui_model.delivery import InputDelivery
from agent_comms.ui_model.goal import GoalUnavailable
from toad.agent_binding import AgentBinding, NoAgentBound


class SessionObservation(ABC):
errors = (OSError, ValueError)

def __init__(self, view):
self._view = ref(view)
self.agent: AgentBinding = NoAgentBound()
self.revision = 0
self.task = None

Expand Down
100 changes: 73 additions & 27 deletions tests/native/test_native_goal_bar.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
"""The goal bar follows the real owner: standby, a long goal that scrolls, edits made elsewhere, pause/resume and clear."""

import asyncio
import json
import os
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,
ActiveGoalAction,
ClearGoalAction,
EditGoalAction,
GoalPrecondition,
OwnerInvocable,
PausedGoalAction,
SetGoalAction,
StandbyGoalAction,
)
from agent_comms.goal_presentation import IdleGoalExecution
from agent_comms.goals import NoGoal
from rich.cells import cell_len
from support.native import ControlledProvider, native_session
from support.native_steps import goal_on_real_owner
from textual.containers import VerticalScroll
from textual.widgets import Static

from support.native_steps import goal_on_real_owner
from toad.screens.goal_details import GoalDetails
from toad.screens.goal_edit import GoalEdit
from toad.widgets.goal_bar import GoalBar, GoalControl, StandbyPulse
Expand Down Expand Up @@ -44,7 +51,8 @@ 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, OBJECTIVE, size=(90, 35),
arrange=standing_by_for_a_peer(workspace)) as (ui, _agent, _owner, _session, _goal):
arrange=standing_by_for_a_peer(workspace)) as native:
ui = native.ui
conversation = ui.conversation
bar = conversation.query_one(GoalBar)
await ui.until(lambda: "Standby" in str(bar.query_one(".goal-header", Static).render()),
Expand Down Expand Up @@ -76,9 +84,8 @@ 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, OBJECTIVE, size=(90, 35)) as (
ui, _agent, owner, session, _goal):
comms = owner.turns.comms
async with goal_on_real_owner(workspace, OBJECTIVE, size=(90, 35)) as native:
ui, comms, session = native.ui, native.comms, native.thread
app, pilot = ui.textual
conversation = ui.conversation
await pilot.click("#goal-history")
Expand All @@ -101,7 +108,8 @@ async def test_edits_made_on_the_owner_reach_open_goal_screens_without_touching_

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):
arrange=standing_by_for_a_peer(workspace)) as native:
ui = native.ui
conversation = ui.conversation
bar = conversation.query_one(GoalBar)
pulse = bar.query_one(StandbyPulse)
Expand All @@ -117,35 +125,73 @@ async def test_pause_resume_and_clear(workspace, native_package):


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):
(workspace.project / "proof.txt").write_text("A real tool reads this file.\n")

def reply(_request, number):
if number == 1:
return {"role": "assistant", "content": ""}, "stop"
if number == 2:
return {"role": "assistant", "tool_calls": [{
"index": 0, "id": "p448-read", "type": "function",
"function": {"name": "read", "arguments": json.dumps({"path": "proof.txt"})}}]}, "tool_calls"
provider.hold_next_request()
return {"role": "assistant", "content": "PRODUCTIVE_SUCCESSOR"}, "stop"

provider = ControlledProvider(reply=reply, request_budget=4)
async with native_session(workspace, provider=provider) as native:
ui, agent, comms, session = native.ui, native.agent, native.comms, native.thread
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")
first = await agent.update_goal(SetGoalAction, OBJECTIVE)
await ui.until(lambda: "Blocked" in str(bar.header.render()), seconds=30,
why="the unproductive native goal turn settles as failed")
assert comms.registry.require(session).goal == first
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()
sending = asyncio.create_task(agent.send_prompt("Read proof.txt and report completion"))
try:
await ui.until(sending.done, seconds=30, why="the fresh productive native input finishes")
await sending
finally:
if not sending.done():
sending.cancel()
await asyncio.gather(sending, return_exceptions=True)
assert comms.registry.require(session).goal == first
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"


async def test_standby_goal_mention_opens_its_peer_and_returns_to_the_draft(workspace, native_package):
async with native_session(workspace) as native:
ui, comms = native.ui, native.comms
owner = comms.registry.require(native.thread)
comms.registry.declare(comms.registry.new_thread(
"peer", frozenset(), str(native.project), model=owner.model,
thinking_level=owner.thinking_level,
))
await asyncio.to_thread(comms.owners.start, "peer")
goal = comms.goals.update_goal(native.thread, SetGoalAction(text="Coordinate with @peer"))
comms.goals.update_goal(native.thread, StandbyGoalAction(
wait_for=("peer",), expect=GoalPrecondition(goal_id=goal.id)))
original = ui.selected_tab
await ui.write_draft("Composer draft stays")
await ui.until_shows("Coordinate with @peer")
summary = ui.conversation.goal_bar.summary
offset = (cell_len(summary.goal_text.split("@peer")[0]), 0)
await ui.until(lambda: ui.app.screen.get_style_at(
summary.region.x + offset[0], summary.region.y,
).meta.get("@click") is not None, why="the objective's bound goal mention")
assert await ui.pilot.click(summary, offset=offset)
await ui.until(lambda: ui.app.selected_session._comms_thread == "peer",
why="the goal mention opens its peer")
await ui.select_tab(original)
assert ui.draft() == "Composer draft stays"
15 changes: 8 additions & 7 deletions tests/native/test_native_goal_objective_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

from agent_comms.acp_extension import GoalChangedUpdate
from agent_comms.goal_actions import ActiveGoalAction, GoalPrecondition
from support.native_steps import goal_on_real_owner
from textual.widgets import Static

from support.native_steps import goal_on_real_owner
from toad.core.events import CommsUpdated
from toad.screens.goal_details import GoalDetails
from toad.screens.goal_edit import GoalEdit
from toad.widgets.goal_text import GoalText


OBJECTIVE = "Work on draft PR #17 and report remaining gaps. " + "Detailed acceptance criteria. " * 30


Expand All @@ -25,8 +24,9 @@ def goal_in_toad(workspace):


async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspace, native_package):
async with goal_in_toad(workspace) as (ui, agent, owner, session, first):
comms = owner.turns.comms
async with goal_in_toad(workspace) as native:
ui, agent, comms, session = native.ui, native.agent, native.comms, native.thread
first = comms.registry.require(session).goal.require()
app, pilot = ui.textual
conversation = ui.conversation
current = first
Expand All @@ -48,10 +48,10 @@ async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspa
else:
await pilot.click("#goal-save")
await ui.until(lambda: not isinstance(app.screen, GoalEdit), why="the editor to close")
await ui.until(lambda: comms.registry.require(session).goal.text == objective, why="the saved objective")
await ui.until(lambda expected=objective: comms.registry.require(session).goal.text == expected, why="the saved objective")
current = comms.registry.require(session).goal
assert current.id == first.id
await ui.until(lambda: conversation.goal_display.snapshot == current, why="the saved goal in the bar")
await ui.until(lambda expected=current: conversation.goal_display.snapshot == expected, why="the saved goal in the bar")
summary = conversation.query_one(".goal-summary", GoalText)
assert objective in str(summary.render()), str(summary.render())
assert ui.shows(" ".join(objective.split()[:3])) and ui.shows(objective.split()[-1])
Expand All @@ -71,7 +71,8 @@ async def test_objective_edits_save_to_the_owner_and_redraw_the_goal_bar(workspa


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) as (ui, agent, _owner, session, first):
async with goal_in_toad(workspace) as native:
ui, agent = native.ui, native.agent
conversation = ui.conversation
before = await agent.get_goal_snapshot()
older = before.goal.require()
Expand Down
60 changes: 15 additions & 45 deletions tests/native/test_native_goal_owner.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,29 @@
"""Toad's goal requests reach the real owner: setting a goal makes it launchable; an edit keeps its identity."""
"""Goal requests reach the managed native owner through Toad's real controller."""

import pytest
from agent_comms.goal_actions import SetGoalAction
from agent_comms.goal_attempts import GoalAttemptStore
from support.native import native_session

from support.native import owner_server, start_private_native_wire
from toad.acp.transcript_reader import CoordinationTranscriptReader

async def test_setting_a_goal_reaches_the_native_provider(workspace, native_package):
async with native_session(workspace) as native:
native.provider.hold_next_request()
goal = await native.agent.update_goal(SetGoalAction, "Finish the task")
assert goal.state.declared_name == "active"
await native.ui.until(native.provider.entered.is_set, seconds=30,
why="the managed owner begins the goal")
assert native.comms.registry.require(native.thread).goal == goal
native.provider.release()

class InProcessReader(CoordinationTranscriptReader):
"""Asks the owner directly, as Core's observation service would."""

def __init__(self, comms):
super().__init__()
self.comms = comms

async def read(self, request):
return await request.ask(self.comms)


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)
owner = owner_server(comms)
try:
session = (await owner.new_session(cwd=str(workspace.project))).session_id
agent = workspace.agents.detached_agent()
agent.coordination = comms.configuration.answer(session)
goal = await agent.update_goal(SetGoalAction, "Finish the task")
assert goal is not None and goal.state.declared_name == "active"
assert goal.text == "Finish the task"
generation = GoalAttemptStore(comms.root / "goal-private").snapshot(goal.id)
assert generation is not None and generation.lifecycle.ready
assert owner.turns.goals.open_goal_store().ready_grant(goal.id, generation.number)
assert comms.registry.require(session).goal == goal
finally:
await owner.shutdown()


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)
owner = owner_server(comms)
try:
session = (await owner.new_session(cwd=str(workspace.project))).session_id
agent = workspace.agents.detached_agent()
agent.coordination = comms.configuration.answer(session)
agent.controller.transcripts = InProcessReader(comms)
async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(workspace, native_package):
async with native_session(workspace) as native:
agent, comms, session = native.agent, native.comms, native.thread
first = await agent.update_goal(SetGoalAction, "First objective")
changed = await agent.edit_goal(first, "Revised objective")
assert changed.id == first.id
assert changed.revision == first.revision + 1
assert changed.state.declared_name == first.state.declared_name
assert changed.state == first.state
assert comms.registry.require(session).goal == changed
with pytest.raises(ValueError):
await agent.edit_goal(first, "Stale overwrite")
Expand All @@ -62,5 +34,3 @@ async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(worksp
assert not any(entry["after"] and entry["after"]["text"] == "Stale overwrite" for entry in history)
snapshot = await agent.get_goal_snapshot()
assert snapshot.goal == changed and snapshot.execution.goal_id == changed.id
finally:
await owner.shutdown()
18 changes: 10 additions & 8 deletions tests/native/test_native_journey.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
from agent_comms.acp_extension import QueuePromptRequest
from agent_comms.cli_commands import RestartCliCommand, StartCliCommand
from agent_comms.input_disposition import InputDispositions

from agent_comms.pi_vocabulary import OffThinkingLevel
from support.native import native_session
from support.native_steps import channel_message_feedback, direct_reply_feedback
from toad.acp.agent import Agent
from support.textual_steps import request_target_command

from toad.widgets.agent_response import AgentResponse
from toad.widgets.comms_sidebar import CommsRow, CommsSidebar
from toad.widgets.prompt import QueueSummary
Expand Down Expand Up @@ -148,12 +150,12 @@ async def test_input_queued_while_the_tab_is_hidden_is_shown_on_return(workspace
await ui.until(lambda: bool(agent.queue_attachment.projection.items), seconds=20, why="the queued input")
await ui.select_tab(owner_tab)
view = ui.conversation
assert view.agent is agent
assert view.agent_binding is agent
await ui.until(lambda: view.turns.managed_id == agent.current_turn.managed_id, seconds=20,
why="the running turn on return")
await ui.until(lambda: view.agent.configuration.model.selected is not None, seconds=20,
await ui.until(lambda: view.agent_binding.configuration.model.selected is not None, seconds=20,
why="the model selection")
assert view.agent.configuration.model.current == "selected-offline/fixture"
assert view.agent_binding.configuration.model.current == "selected-offline/fixture"
assert view.turns.owner.busy # what the activity indicator draws from; busy_count now counts only chat history reads
assert agent.queue_attachment is queue
assert agent.process.process is process and process.returncode is None
Expand Down Expand Up @@ -200,7 +202,7 @@ async def test_stopped_owner_restarts_without_replaying_and_then_idles(workspace

asked = len(provider.requests)
await ui.select_tab(owner_tab)
assert ui.conversation.agent is agent, "Returning to a session replaced its agent"
assert ui.conversation.agent_binding is agent, "Returning to a session replaced its agent"
await agent.session.reconnect()
await ui.until(agent.session.settled.is_set, seconds=30, why="the reconnect to settle")
assert agent.session.connected, "The restarted owner did not accept the session"
Expand Down Expand Up @@ -250,7 +252,7 @@ async def test_a_second_thread_opened_from_the_app_answers_its_first_message(wor
from support.native import MODEL

async def declare_second(comms, project, _provider):
comms.registry.declare(comms.registry.new_thread("gamma", frozenset({"team"}), str(project), model=MODEL, thinking_level="off"))
comms.registry.declare(comms.registry.new_thread("gamma", frozenset({"team"}), str(project), model=MODEL, thinking_level=OffThinkingLevel))

async with native_session(workspace, prepare=declare_second) as native:
ui, provider = native.ui, native.provider
Expand All @@ -261,7 +263,7 @@ async def declare_second(comms, project, _provider):
assert opened != owner_tab and ui.selected_tab == opened
assert [tab.identity for tab in ui.tabs()] == [owner_tab, opened]
view = ui.conversation
await ui.until(lambda: view.agent is not None and view.agent is not native.agent, seconds=20,
await ui.until(lambda: isinstance(view.agent_binding, Agent) and view.agent_binding is not native.agent, seconds=20,
why="the second thread's agent")
await ui.until_agent_ready()
await ui.write_draft("FIRST_MESSAGE_TO_GAMMA")
Expand All @@ -271,4 +273,4 @@ async def declare_second(comms, project, _provider):
assert len(provider.requests) == 1
assert ui.painted(view.window).count("NATIVE_RESPONSE_1") == 1
assert [tab.identity for tab in ui.tabs()] == [owner_tab, opened]
await view.agent.stop()
await view.agent_binding.stop()
Loading
Loading