fix: Report tool_progress heartbeats against the tool call they describe (#916)

Heartbeat beats reach the bridge under a derived
`<tool_use_id>-heartbeat-<n>` id that no `tool_call` was ever emitted
for, and the handler forwarded it verbatim. Clients that expect a
`tool_call_update` to reference a tool call they have been told about
saw a reference they could not resolve, and since the counter makes
every beat a fresh id, nothing deduplicated them: a ten-minute Bash call
produced roughly twenty phantom entries. Zed renders each as a failed
"Tool call not found" card; compozy/compozy#257 and
agent-of-empires/agent-of-empires#3084 report the same shape.

Strip the derived suffix so the beat is reported against the call it
describes, and drop it when that call is not in `emittedToolCalls`. Ids
enter that set at `tool_use` and leave it at `tool_result`, so the guard
covers both an id that was never announced and a straggling beat that
races past completion, which would otherwise flip a call the client has
seen finish back to `in_progress`.

The SDK sets `heartbeat: true` on these messages, but that flag alone
cannot recover the original id, and `parent_tool_use_id` is the subagent
parent rather than the call being reported on, so the suffix still has
to be stripped.

`tool_progress` had no test coverage. Add cases for the retarget across
a climbing counter, the not-in-flight drop, meta pass-through, and an
unsuffixed id being left alone.

Fixes #913

---------

Signed-off-by: Daan De Meyer <daan@amutable.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daan De Meyer
2026-07-27 12:16:20 +02:00
committed by GitHub
parent d0604140f9
commit 5559ba890c
2 changed files with 182 additions and 1 deletions

View File

@@ -3762,11 +3762,30 @@ export class ClaudeAcpAgent {
break;
}
case "tool_progress": {
// Not every beat reports under the id of a tool call the client has
// seen: heartbeats derive `<tool_use_id>-heartbeat-<n>`, and the
// `agent_api_retry` beats behind `subagentRetry` report under
// `agent_<assistant_message_id>`. Forwarding those verbatim leaves the
// client resolving an id it has never been told about (the same trap
// `ensureToolCallEmitted` documents for #851). The SDK stamps
// `parent_tool_use_id` with the executing tool's real id whenever the
// beat doesn't carry one of its own, so fall back to it rather than
// pattern-matching each synthetic id shape. Beats that do report a real
// id (a subagent's `bash_progress`, whose parent is the spawning Agent
// call) keep resolving to that id.
const toolCallId = session.emittedToolCalls.has(message.tool_use_id)
? message.tool_use_id
: message.parent_tool_use_id;
// Ids leave `emittedToolCalls` at `tool_result`, so this also stops a
// beat that races past completion from reopening a finished call.
if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
break;
}
await sendUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: message.tool_use_id,
toolCallId,
status: "in_progress",
_meta: {
claudeCode: {