fix(acp): emit native plan updates for todo

This commit is contained in:
HenkDz 2026-05-15 15:26:08 +01:00 committed by Teknium
parent 6fc0fa6e50
commit 4444d5fe4f
2 changed files with 82 additions and 1 deletions

View file

@ -14,6 +14,7 @@ from collections import deque
from typing import Any, Callable, Deque, Dict from typing import Any, Callable, Deque, Dict
import acp import acp
from acp.schema import AgentPlanUpdate, PlanEntry
from .tools import ( from .tools import (
build_tool_complete, build_tool_complete,
@ -24,6 +25,52 @@ from .tools import (
logger = logging.getLogger(__name__) 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( def _send_update(
conn: acp.Client, conn: acp.Client,
session_id: str, session_id: str,
@ -175,6 +222,10 @@ def make_step_cb(
snapshot=meta.get("snapshot"), snapshot=meta.get("snapshot"),
) )
_send_update(conn, session_id, loop, update) _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: if not queue:
tool_call_ids.pop(tool_name, None) tool_call_ids.pop(tool_name, None)

View file

@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import acp import acp
from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk from acp.schema import AgentPlanUpdate, ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk
from acp_adapter.events import ( from acp_adapter.events import (
_send_update, _send_update,
@ -296,6 +296,36 @@ class TestStepCallback:
} }
mock_send.assert_called_once() 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 # Message callback