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
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)

View file

@ -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