Skip to content

Forward dropped SDK events to ACP clients#508

Open
m13v wants to merge 3 commits intoagentclientprotocol:mainfrom
m13v:forward-dropped-sdk-events
Open

Forward dropped SDK events to ACP clients#508
m13v wants to merge 3 commits intoagentclientprotocol:mainfrom
m13v:forward-dropped-sdk-events

Conversation

@m13v
Copy link
Copy Markdown

@m13v m13v commented Apr 5, 2026

Summary

The claude-agent-acp runtime currently drops several Claude Agent SDK messages on the floor: api_retry, tool_progress, tool_use_summary, and rate_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/-namespaced extNotification calls, 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 }):

  1. _claude/api-retry — fired when the SDK retries a transient API error (overload, rate limit response, etc.)

    • httpStatus, errorType, attempt, maxRetries, retryDelayMs
  2. _claude/tool-progress — periodic heartbeat from a long-running tool

    • toolUseId, toolName, elapsedTimeSeconds
  3. _claude/tool-use-summary — model-generated summary of a group of prior tool uses

    • summary, precedingToolUseIds
  4. _claude/rate-limit — Claude.ai web-session rate limit state change

    • status, resetsAt, rateLimitType, utilization, overageStatus, overageDisabledReason, isUsingOverage, surpassedThreshold

Why extNotification

Per reviewer feedback, these aren't first-class ACP session updates (the spec doesn't define them), so forwarding them as sessionUpdate entries would pollute the standard update stream. extNotification with 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

  • Fall-through bug fix: the previous switch had hook_progress, hook_response, files_persisted, task_started, task_notification, task_progress, and elicitation_complete falling through into the newly-added api_retry handler. Each now has its own explicit break.
  • Dropped sessionMeta() / _meta additions from earlier revisions — no longer needed once events are forwarded directly to the client as they happen.
  • Logs api_retry at 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 — clean
  • npm test — all existing tests pass; mock Session objects updated to drop the removed totalCostUsd / lastTerminalReason fields

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
Comment on lines +635 to +638
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "" },
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend to send this as a custom notification instead, e.g. this.client.extNotification("_claude/api-retry", ...)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already include total_cost_usd in the usage_update message.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)...`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

SteffenDE added a commit to SteffenDE/claude-code-acp that referenced this pull request Apr 8, 2026
SteffenDE added a commit to SteffenDE/claude-code-acp that referenced this pull request Apr 8, 2026
m13v added 2 commits April 10, 2026 12:16
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
@m13v
Copy link
Copy Markdown
Author

m13v commented Apr 10, 2026

On the includeRawSDKMessages suggestion (and your draft in #527) — that's a cleaner long-term shape than forwarding one event at a time, and I'd happily use it if it lands. The reason I kept this PR narrow to the four specific events is that they're the ones I've hit in practice where the current behaviour is strictly lossy: the SDK has information the client needs, the runtime sees it, and then drops it. Forwarding them as typed _claude/ notifications means clients that already care about these cases can consume them without having to opt into the full raw message firehose or parse SDK internals.

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 includeRawSDKMessages, keep them as typed convenience notifications alongside it, or close this in favor of #527 entirely. Let me know which direction you'd prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants