From 9d28773f1e04f025a34cee5b54f302ef4cdbc6af Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 23 Oct 2026 18:15:27 -0400 Subject: [PATCH] Decode goal history once and consume Core's typed revisions --- src/toad/acp/agent.py | 5 +++-- src/toad/agent_binding.py | 5 +++-- src/toad/goal_interaction.py | 5 +++-- src/toad/screens/goal_details.py | 14 ++++++-------- tests/native/test_native_goal_owner.py | 13 ++++++++----- tests/support/goals.py | 9 +++++---- tests/textual/test_goal_owner_views.py | 16 +++++++++------- 7 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/toad/acp/agent.py b/src/toad/acp/agent.py index 8facae879..a5f7c3125 100644 --- a/src/toad/acp/agent.py +++ b/src/toad/acp/agent.py @@ -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 @@ -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( diff --git a/src/toad/agent_binding.py b/src/toad/agent_binding.py index 312744e11..373be62cf 100644 --- a/src/toad/agent_binding.py +++ b/src/toad/agent_binding.py @@ -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 @@ -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") diff --git a/src/toad/goal_interaction.py b/src/toad/goal_interaction.py index 1212e8d60..f2434c978 100644 --- a/src/toad/goal_interaction.py +++ b/src/toad/goal_interaction.py @@ -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 @@ -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) diff --git a/src/toad/screens/goal_details.py b/src/toad/screens/goal_details.py index d9430a17c..39d3b6144 100644 --- a/src/toad/screens/goal_details.py +++ b/src/toad/screens/goal_details.py @@ -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 @@ -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): @@ -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, diff --git a/tests/native/test_native_goal_owner.py b/tests/native/test_native_goal_owner.py index f9f66ea37..122974c2d 100644 --- a/tests/native/test_native_goal_owner.py +++ b/tests/native/test_native_goal_owner.py @@ -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 @@ -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 diff --git a/tests/support/goals.py b/tests/support/goals.py index 5c6b59f19..edee04581 100644 --- a/tests/support/goals.py +++ b/tests/support/goals.py @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/tests/textual/test_goal_owner_views.py b/tests/textual/test_goal_owner_views.py index b4769cc6a..78bbcb157 100644 --- a/tests/textual/test_goal_owner_views.py +++ b/tests/textual/test_goal_owner_views.py @@ -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 @@ -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