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
5 changes: 3 additions & 2 deletions src/toad/acp/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from agent_comms.goal_actions import GoalAction
from agent_comms.goal_presentation import GoalSnapshot, IdleGoalExecution
from agent_comms.goals import Goal, GoalDeclaration, NoGoal
from agent_comms.runtime_requests import GoalHistoryReply
from agent_comms.ui_model.observation import ReadOwnerGoal

import toad
Expand Down Expand Up @@ -243,9 +244,9 @@ async def get_goal_snapshot(self) -> GoalSnapshot:
consumer.dispatch_sync(fact)
return snapshot

async def get_goal_history(self, goal_id: str):
async def get_goal_history(self, goal_id: str) -> GoalHistoryReply:
result = await self.controller.request_owner("goal_history", goal_id=goal_id)
return result["history"]
return FieldCodec.decode(GoalHistoryReply, result)

async def edit_goal(self, goal: Goal, text: str) -> Goal:
result = await self.controller.request_owner(
Expand Down
5 changes: 3 additions & 2 deletions src/toad/agent_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import TYPE_CHECKING

from agent_comms.thread_presentation import ThreadPresentation
from agent_comms.runtime_requests import GoalHistoryReply
from textual.content import Content

from toad.acp.agent_configuration import AgentConfiguration
Expand Down Expand Up @@ -164,8 +165,8 @@ def accepts_failed_input(self, message: CoreEventMessage) -> bool:
def send_now(self, owner: ConversationSubmissions) -> None:
"""No remote queue is bound."""

async def get_goal_history(self, goal_id: str):
return ()
async def get_goal_history(self, goal_id: str) -> GoalHistoryReply:
return GoalHistoryReply(())

async def update_goal(self, action: type[GoalAction], text: str):
raise ValueError("Persistent goals require an agent-comms session")
Expand Down
5 changes: 3 additions & 2 deletions src/toad/goal_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from agent_comms.declared_family import DeclaredFamily
from agent_comms.goal_actions import ClearGoalAction, GoalAction
from agent_comms.goal_presentation import ControlledExecution, GoalExecution
from agent_comms.runtime_requests import GoalHistoryReply

if TYPE_CHECKING:
from toad.widgets.conversation import Conversation
Expand Down Expand Up @@ -41,11 +42,11 @@ class HistoryInteraction(GoalInteraction):
label = "History"

@classmethod
async def apply(cls, session):
async def apply(cls, session: GoalSession) -> None:
from toad.screens.goal_details import GoalDetails
view = session.view
goal, agent = view.goal_display.snapshot.require(), view.agent_binding
history = await agent.get_goal_history(goal.id)
history: GoalHistoryReply = await agent.get_goal_history(goal.id)
if not session.owns(agent):
return
details = GoalDetails(session, history=history)
Expand Down
14 changes: 6 additions & 8 deletions src/toad/screens/goal_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@

from datetime import datetime

from agent_comms.field_codec import FieldCodec
from agent_comms.goals import Goal
from agent_comms.runtime_requests import GoalHistoryReply
from textual import on
from textual.app import ComposeResult
from textual.containers import Vertical, VerticalScroll
Expand All @@ -32,10 +31,10 @@ class GoalDetails(CoreEventReceiver, ProjectPathOwner, ModalScreen[None]):
GoalDetails Button { dock: bottom; margin-top: 1; }
"""

def __init__(self, source: GoalSession, *, history=()):
def __init__(self, source: GoalSession, *, history: GoalHistoryReply):
super().__init__()
self.source = source
self.history = history
self.history = history.history

@property
def project_root(self):
Expand Down Expand Up @@ -66,14 +65,13 @@ def compose(self) -> ComposeResult:
if self.history:
yield Static("Revision history", markup=False)
for entry in reversed(self.history):
goal_data = entry["after"] or entry["before"]
revision_goal = FieldCodec.decode(Goal, goal_data)
revision_goal = (entry.after if entry.after.shows_document else entry.before).require()
when = (
datetime.fromtimestamp(entry["observed_at"])
datetime.fromtimestamp(entry.observed_at)
.astimezone()
.strftime("%Y-%m-%d %H:%M:%S")
)
kind = entry["kind"].replace("_", " ")
kind = entry.kind.replace("_", " ")
yield Static(
f"Revision {revision_goal.revision} · {kind} · observed {when}",
markup=False,
Expand Down
13 changes: 8 additions & 5 deletions tests/native/test_native_goal_owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
from agent_comms.goal_actions import SetGoalAction
from agent_comms.runtime_requests import GoalHistoryReply
from support.native import native_session


Expand All @@ -27,10 +28,12 @@ async def test_editing_a_goal_keeps_its_identity_and_refuses_a_stale_edit(worksp
assert comms.registry.require(session).goal == changed
with pytest.raises(ValueError):
await agent.edit_goal(first, "Stale overwrite")
history = await agent.get_goal_history(changed.id)
assert any(entry["after"] and entry["after"]["text"] == "First objective" for entry in history)
assert any(entry["before"] and entry["before"]["text"] == "First objective"
and entry["after"]["text"] == "Revised objective" for entry in history)
assert not any(entry["after"] and entry["after"]["text"] == "Stale overwrite" for entry in history)
history: GoalHistoryReply = await agent.get_goal_history(changed.id)
assert any(entry.after.shows_document and entry.after.require().text == "First objective"
for entry in history.history)
assert any(entry.before.shows_document and entry.before.require().text == "First objective"
and entry.after.require().text == "Revised objective" for entry in history.history)
assert not any(entry.after.shows_document and entry.after.require().text == "Stale overwrite"
for entry in history.history)
snapshot = await agent.get_goal_snapshot()
assert snapshot.goal == changed and snapshot.execution.goal_id == changed.id
9 changes: 5 additions & 4 deletions tests/support/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from agent_comms.goal_states import ActiveGoal
from agent_comms.goals import Goal, GoalDeclaration
from agent_comms.registry_document import RegistrySnapshot
from agent_comms.runtime_requests import GoalHistoryReply
from agent_comms.threads import Thread

from toad.acp.agent import Agent
Expand Down Expand Up @@ -37,7 +38,7 @@ class ScriptedGoalOwner(Agent):
receives a canned active/runnable reply; this fixture does not implement Core's transitions.
"""

def __init__(self, *args, goal: GoalDeclaration, execution: GoalExecution, history: list[dict],
def __init__(self, *args, goal: GoalDeclaration, execution: GoalExecution, history: GoalHistoryReply,
**kwargs):
super().__init__(*args, **kwargs)
self.goal, self.execution, self.history = goal, execution, history
Expand All @@ -47,10 +48,10 @@ def __init__(self, *args, goal: GoalDeclaration, execution: GoalExecution, histo
@classmethod
def owning(cls, app: ToadApp, goal: GoalDeclaration,
execution: GoalExecution, *,
history: tuple[dict, ...] = ()) -> ScriptedGoalOwner:
history: GoalHistoryReply = GoalHistoryReply(())) -> 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",
goal=goal, execution=execution, history=list(history))
goal=goal, execution=execution, history=history)

async def get_goal_snapshot(self):
return GoalSnapshot(self.goal, self.execution)
Expand All @@ -68,6 +69,6 @@ async def edit_goal(self, expected, text):
self.goal = replace(self.goal, text=text, revision=self.goal.revision + 1)
return self.goal

async def get_goal_history(self, goal_id):
async def get_goal_history(self, goal_id: str) -> GoalHistoryReply:
assert goal_id == self.goal.id
return self.history
16 changes: 9 additions & 7 deletions tests/textual/test_goal_owner_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from dataclasses import replace

from agent_comms.acp_extension import GoalChangedUpdate
from agent_comms.goal_history import RecordedGoalRevision
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.runtime_requests import GoalHistoryReply
from agent_comms.thread_identity import RecordedThreadIncarnation
from textual.widgets import Button, Static

Expand All @@ -26,13 +28,13 @@ 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 = 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()},
{"sequence": 2, "kind": "transition", "observed_at": 1001.0,
"before": replace(goal, revision=4).to_wire(), "after": goal.to_wire()},
]
owner = ScriptedGoalOwner.owning(app, goal, execution, history=tuple(history))
history = GoalHistoryReply((
RecordedGoalRevision(sequence=1, kind="baseline", observed_at=1000.0, before=NoGoal(),
after=replace(goal, text="HISTORICAL_OBJECTIVE", revision=4)),
RecordedGoalRevision(sequence=2, kind="transition", observed_at=1001.0,
before=replace(goal, revision=4), after=goal),
))
owner = ScriptedGoalOwner.owning(app, goal, execution, history=history)
owner.reject = True
return owner

Expand Down
Loading