Forward dropped SDK events to ACP clients#508
Forward dropped SDK events to ACP clients#508m13v wants to merge 3 commits intoagentclientprotocol:mainfrom
Conversation
The Claude Agent SDK emits several useful events that the ACP agent currently ignores (no-op break statements). This change forwards them to ACP clients so they can observe rate limits, API retries, tool progress, and cost data without needing to monkey-patch the agent. Changes: - Forward rate_limit_event via _meta on usage_update notifications (status, resetsAt, utilization, overage info) - Forward api_retry system messages with HTTP status code and typed error category (billing_error, rate_limit, server_error, etc.) - Forward tool_progress as tool_call_update with elapsed time - Forward tool_use_summary as agent_message_chunk - Include costUsd and terminalReason in PromptResponse _meta - Track totalCostUsd and lastTerminalReason on Session This addresses the "Todo" comment on api_retry and gives clients structured error data instead of forcing them to regex-match error message strings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
src/acp-agent.ts
Outdated
| update: { | ||
| sessionUpdate: "agent_message_chunk", | ||
| content: { type: "text", text: "" }, | ||
| }, |
There was a problem hiding this comment.
I'd recommend to send this as a custom notification instead, e.g. this.client.extNotification("_claude/api-retry", ...)
There was a problem hiding this comment.
Good call. Revised to use extNotification("_claude/api-retry", ...) and similarly _claude/rate-limit, _claude/tool-progress, _claude/tool-use-summary for all four dropped events.
src/acp-agent.ts
Outdated
| session.accumulatedUsage.outputTokens += message.usage.output_tokens; | ||
| session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens; | ||
| session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens; | ||
| session.totalCostUsd = message.total_cost_usd; |
There was a problem hiding this comment.
We already include total_cost_usd in the usage_update message.
There was a problem hiding this comment.
You're right, removed. The sessionMeta() helper and the _meta additions to return values are gone. total_cost_usd is already carried by usage_update.
src/acp-agent.ts
Outdated
| status: "running", | ||
| content: [{ | ||
| type: "text", | ||
| text: `Tool running (${Math.round((message as any).elapsed_time_seconds ?? 0)}s)...`, |
There was a problem hiding this comment.
I wouldn't do this. Content for a tool call should be based on the actual tool output. If you want progress, I'd recommend to send it as extNotification("_claude/tool-progress", ...) or similar.
Now, I'm thinking that instead of adding custom mappings for many more SDK messages, what if we'd allow you to pass a custom flag includeRawSDKMessages in session/new etc. and we'd then forward all SDK messages as custom session notifications, so you'd be able to handle them however you want in your client?
Then, in the future we might have "proper" ACP messages for errors / rate-limits and you could switch to those at some point.
There was a problem hiding this comment.
Agreed. Switched to extNotification("_claude/tool-progress", ...) — passes toolCallId, toolName, and elapsedTimeSeconds as notification params rather than injecting text into the tool output content.
src/acp-agent.ts
Outdated
| meta.costUsd = session.totalCostUsd; | ||
| } | ||
| if (session.lastTerminalReason) { | ||
| meta.terminalReason = session.lastTerminalReason; |
There was a problem hiding this comment.
In other places, we nest _meta as _meta: { claudeCode: ... }, so I'd recommend to do the same here.
That said, I'm not aware of a terminal_reason field in the SDK.
There was a problem hiding this comment.
Since we switched fully to extNotification, the _meta nesting question is moot for these events — the method name (_claude/rate-limit etc.) provides the namespace. The _meta: { claudeCode: ... } pattern is preserved for the existing sessionUpdate calls that already use it.
Address reviewer feedback from SteffenDE:
- Use extNotification("_claude/api-retry", ...) instead of a
sessionUpdate with _meta on an empty agent_message_chunk
- Use extNotification("_claude/tool-progress", ...) instead of
injecting elapsed time as tool output content via tool_call_update
- Use extNotification("_claude/tool-use-summary", ...) instead of
emitting a synthetic agent_message_chunk
- Use extNotification("_claude/rate-limit", ...) instead of piggybacking
_meta on a usage_update (total_cost_usd is already in usage_update)
- Fix fall-through bug: hook_progress/hook_response/files_persisted/
task_started/task_notification/task_progress/elicitation_complete now
have their own break before the api_retry case
- Remove sessionMeta() helper and totalCostUsd/lastTerminalReason session
fields: cost is already in usage_update, terminal_reason is not a real
SDK field
- tool_progress: rename toolCallId back to toolUseId to match the underlying SDK field name (tool_use_id) - tool_use_summary: include precedingToolUseIds from the SDK message so clients can reconstruct which tools preceded the summary - rate_limit: include surpassedThreshold alongside the other overage fields so clients see all the threshold-state info the SDK provides
|
On the If #527 moves forward, this PR's handlers should be easy to either drop or rebase on top of it (they're small and self-contained). Happy to do whatever's cleanest — delete these in favor of |
Summary
The claude-agent-acp runtime currently drops several Claude Agent SDK messages on the floor:
api_retry,tool_progress,tool_use_summary, andrate_limit_event. ACP clients can't see that the SDK is retrying a 529, that a long-running tool is making progress, that the model summarized prior tool uses, or that a Claude.ai web-session rate limit fired.This PR forwards those four events to clients via typed
_claude/-namespacedextNotificationcalls, so clients that care can surface them without touching the transport.What's forwarded
All four events are forwarded via
this.client.extNotification("_claude/<name>", { sessionId, ...fields }):_claude/api-retry— fired when the SDK retries a transient API error (overload, rate limit response, etc.)httpStatus,errorType,attempt,maxRetries,retryDelayMs_claude/tool-progress— periodic heartbeat from a long-running tooltoolUseId,toolName,elapsedTimeSeconds_claude/tool-use-summary— model-generated summary of a group of prior tool usessummary,precedingToolUseIds_claude/rate-limit— Claude.ai web-session rate limit state changestatus,resetsAt,rateLimitType,utilization,overageStatus,overageDisabledReason,isUsingOverage,surpassedThresholdWhy
extNotificationPer reviewer feedback, these aren't first-class ACP session updates (the spec doesn't define them), so forwarding them as
sessionUpdateentries would pollute the standard update stream.extNotificationwith a_claude/prefix is the documented escape hatch for vendor-specific events, keeps the core ACP surface clean, and lets clients opt in by listening on the namespaced method.Other changes
hook_progress,hook_response,files_persisted,task_started,task_notification,task_progress, andelicitation_completefalling through into the newly-addedapi_retryhandler. Each now has its own explicitbreak.sessionMeta()/_metaadditions from earlier revisions — no longer needed once events are forwarded directly to the client as they happen.api_retryat error level (same as before) so the CLI still shows retries in the terminal.Field naming
Field names match the fazm ACP client's existing shape (
toolUseId,httpStatus,retryDelayMs,precedingToolUseIds, etc.) so a client already consuming these from a monkey-patched bridge can swap over without changing its handler code.Testing
npm run build— cleannpm test— all existing tests pass; mock Session objects updated to drop the removedtotalCostUsd/lastTerminalReasonfields