From 4444d5fe4f65dcbca939a1f39ae58438205e7dad Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 15:26:08 +0100 Subject: [PATCH] fix(acp): emit native plan updates for todo --- acp_adapter/events.py | 51 ++++++++++++++++++++++++++++++++++++++++ tests/acp/test_events.py | 32 ++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/acp_adapter/events.py b/acp_adapter/events.py index f0442ca2e8f..828807c3aef 100644 --- a/acp_adapter/events.py +++ b/acp_adapter/events.py @@ -14,6 +14,7 @@ from collections import deque from typing import Any, Callable, Deque, Dict import acp +from acp.schema import AgentPlanUpdate, PlanEntry from .tools import ( build_tool_complete, @@ -24,6 +25,52 @@ from .tools import ( logger = logging.getLogger(__name__) +def _build_plan_update_from_todo_result(result: Any) -> AgentPlanUpdate | None: + """Translate Hermes' todo tool result into ACP's native plan update. + + Zed renders ``sessionUpdate: plan`` as its first-class task/todo panel. The + Hermes agent already maintains task state through the ``todo`` tool, so the + ACP adapter should expose that state natively instead of only as a generic + tool-call transcript block. + """ + if not isinstance(result, str) or not result.strip(): + return None + + try: + data = json.loads(result) + except Exception: + return None + + if not isinstance(data, dict) or not isinstance(data.get("todos"), list): + return None + + status_map = { + "pending": "pending", + "in_progress": "in_progress", + "completed": "completed", + # ACP plans only support pending/in_progress/completed. Preserve + # cancelled tasks as terminal entries instead of dropping them and + # making the client's full-list replacement lose visible context. + "cancelled": "completed", + } + entries: list[PlanEntry] = [] + for item in data["todos"]: + if not isinstance(item, dict): + continue + content = str(item.get("content") or item.get("id") or "").strip() + if not content: + continue + raw_status = str(item.get("status") or "pending").strip() + status = status_map.get(raw_status, "pending") + if raw_status == "cancelled": + content = f"[cancelled] {content}" + entries.append(PlanEntry(content=content, priority="medium", status=status)) + + if not entries: + return None + return AgentPlanUpdate(session_update="plan", entries=entries) + + def _send_update( conn: acp.Client, session_id: str, @@ -175,6 +222,10 @@ def make_step_cb( snapshot=meta.get("snapshot"), ) _send_update(conn, session_id, loop, update) + if tool_name == "todo": + plan_update = _build_plan_update_from_todo_result(result) + if plan_update is not None: + _send_update(conn, session_id, loop, plan_update) if not queue: tool_call_ids.pop(tool_name, None) diff --git a/tests/acp/test_events.py b/tests/acp/test_events.py index 56a2687226c..ebddf076dbd 100644 --- a/tests/acp/test_events.py +++ b/tests/acp/test_events.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest import acp -from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk +from acp.schema import AgentPlanUpdate, ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk from acp_adapter.events import ( _send_update, @@ -296,6 +296,36 @@ class TestStepCallback: } mock_send.assert_called_once() + def test_todo_completion_emits_native_plan_update(self, mock_conn, event_loop_fixture): + from collections import deque + + tool_call_ids = {"todo": deque(["tc-todo"])} + loop = event_loop_fixture + cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids, {}) + todo_result = ( + '{"todos":[' + '{"id":"inspect","content":"Inspect ACP","status":"completed"},' + '{"id":"patch","content":"Patch renderer","status":"in_progress"},' + '{"id":"old","content":"Drop stale task","status":"cancelled"}' + '],"summary":{"total":3}}' + ) + + with patch("acp_adapter.events._send_update") as mock_send: + cb(1, [{"name": "todo", "result": todo_result}]) + + updates = [call.args[3] for call in mock_send.call_args_list] + plan_updates = [u for u in updates if getattr(u, "session_update", None) == "plan"] + assert len(plan_updates) == 1 + plan = plan_updates[0] + assert isinstance(plan, AgentPlanUpdate) + assert [entry.content for entry in plan.entries] == [ + "Inspect ACP", + "Patch renderer", + "[cancelled] Drop stale task", + ] + assert [entry.status for entry in plan.entries] == ["completed", "in_progress", "completed"] + assert [entry.priority for entry in plan.entries] == ["medium", "medium", "medium"] + # --------------------------------------------------------------------------- # Message callback