From 22441dfb097d1b6916339a006123bdc61871e458 Mon Sep 17 00:00:00 2001 From: matthew-heartful Date: Sun, 5 Apr 2026 15:04:40 -0700 Subject: [PATCH 1/3] Forward dropped SDK events to ACP clients 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 --- src/acp-agent.ts | 124 ++++++++++++++++++++++++++++++++++-- src/tests/acp-agent.test.ts | 8 +++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 1ad57477..ede7017e 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -116,6 +116,10 @@ type Session = { cwd: string; settingsManager: SettingsManager; accumulatedUsage: AccumulatedUsage; + /** Cumulative cost in USD for this session (from SDK result messages) */ + totalCostUsd: number; + /** Terminal reason from the last SDK result (e.g. "blocking_limit", "completed") */ + lastTerminalReason: string | null; modes: SessionModeState; models: SessionModelState; configOptions: SessionConfigOption[]; @@ -486,6 +490,8 @@ export class ClaudeAcpAgent implements Agent { cachedReadTokens: 0, cachedWriteTokens: 0, }; + session.totalCostUsd = 0; + session.lastTerminalReason = null; let lastAssistantTotalUsage: number | null = null; let lastAssistantModel: string | null = null; @@ -590,7 +596,11 @@ export class ClaudeAcpAgent implements Agent { } case "session_state_changed": { if (message.state === "idle") { - return { stopReason, usage: sessionUsage(session) }; + return { + stopReason, + usage: sessionUsage(session), + _meta: sessionMeta(session), + }; } break; } @@ -602,9 +612,33 @@ export class ClaudeAcpAgent implements Agent { case "task_notification": case "task_progress": case "elicitation_complete": - case "api_retry": - // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output + case "api_retry": { + // Forward API retry events so clients can observe transient failures, + // including the HTTP status code and typed error category. + const retryMsg = message as any; + this.logger.error( + `API retry: attempt=${retryMsg.attempt}/${retryMsg.max_retries}, ` + + `httpStatus=${retryMsg.error_status}, error=${retryMsg.error}, ` + + `delay=${retryMsg.retry_delay_ms}ms`, + ); + await this.client.sessionUpdate({ + sessionId: retryMsg.session_id, + _meta: { + apiRetry: { + httpStatus: retryMsg.error_status ?? null, + errorType: retryMsg.error ?? "unknown", + attempt: retryMsg.attempt ?? 0, + maxRetries: retryMsg.max_retries ?? 0, + retryDelayMs: retryMsg.retry_delay_ms ?? 0, + }, + }, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "" }, + }, + }); break; + } default: unreachable(message, this.logger); break; @@ -616,6 +650,10 @@ export class ClaudeAcpAgent implements Agent { 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; + if ("terminal_reason" in message && message.terminal_reason) { + session.lastTerminalReason = message.terminal_reason as string; + } const matchingModelUsage = lastAssistantModel ? getMatchingModelUsage(message.modelUsage, lastAssistantModel) @@ -738,7 +776,7 @@ export class ClaudeAcpAgent implements Agent { handedOff = true; // the current loop stops with end_turn, // the loop of the next prompt continues running - return { stopReason: "end_turn", usage: sessionUsage(session) }; + return { stopReason: "end_turn", usage: sessionUsage(session), _meta: sessionMeta(session) }; } if ("isReplay" in message && message.isReplay) { // not pending or unrelated replay message @@ -835,11 +873,69 @@ export class ClaudeAcpAgent implements Agent { } break; } - case "tool_progress": - case "tool_use_summary": + case "tool_progress": { + // Forward tool execution progress as a tool_call_update so clients + // can show elapsed time for long-running tools. + const toolCallId = "tool_use_id" in message ? (message as any).tool_use_id : undefined; + if (toolCallId) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "running", + content: [{ + type: "text", + text: `Tool running (${Math.round((message as any).elapsed_time_seconds ?? 0)}s)...`, + }], + }, + }); + } + break; + } + case "tool_use_summary": { + // Forward collapsed tool-use summaries as agent message chunks + // so clients can display a high-level overview of tool activity. + const summary = "summary" in message ? (message as any).summary : undefined; + if (summary) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: summary }, + }, + }); + } + break; + } + case "rate_limit_event": { + // Forward rate limit info via _meta on a usage_update notification. + // This allows clients to detect approaching limits, rejections, + // and overage status without parsing error message strings. + const info = "rate_limit_info" in message ? (message as any).rate_limit_info : undefined; + if (info) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + _meta: { + rateLimitStatus: info.status ?? "unknown", + rateLimitResetsAt: info.resetsAt ?? null, + rateLimitType: info.rateLimitType ?? null, + rateLimitUtilization: info.utilization ?? null, + overageStatus: info.overageStatus ?? null, + overageDisabledReason: info.overageDisabledReason ?? null, + isUsingOverage: info.isUsingOverage ?? false, + }, + update: { + sessionUpdate: "usage_update", + used: lastAssistantTotalUsage ?? 0, + size: lastContextWindowSize, + }, + }); + } + break; + } case "auth_status": case "prompt_suggestion": - case "rate_limit_event": break; default: unreachable(message); @@ -1537,6 +1633,8 @@ export class ClaudeAcpAgent implements Agent { cachedReadTokens: 0, cachedWriteTokens: 0, }, + totalCostUsd: 0, + lastTerminalReason: null, modes, models, configOptions, @@ -1569,6 +1667,18 @@ function sessionUsage(session: Session) { }; } +/** Build _meta for PromptResponse with cost and terminal reason from SDK results */ +function sessionMeta(session: Session): Record { + const meta: Record = {}; + if (session.totalCostUsd > 0) { + meta.costUsd = session.totalCostUsd; + } + if (session.lastTerminalReason) { + meta.terminalReason = session.lastTerminalReason; + } + return Object.keys(meta).length > 0 ? meta : {}; +} + function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) { if (!gatewayMeta) { return {}; diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 6d77aad9..857593bb 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1337,6 +1337,8 @@ describe("stop reason propagation", () => { availableModels: [], }, settingsManager: { dispose: vi.fn() } as any, + totalCostUsd: 0, + lastTerminalReason: null, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -1476,6 +1478,8 @@ describe("stop reason propagation", () => { availableModels: [], }, settingsManager: { dispose: vi.fn() } as any, + totalCostUsd: 0, + lastTerminalReason: null, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -1549,6 +1553,8 @@ describe("session/close", () => { availableModels: [], }, settingsManager: { dispose: vi.fn() } as any, + totalCostUsd: 0, + lastTerminalReason: null, accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -1719,6 +1725,8 @@ describe("usage_update computation", () => { availableModels: [], }, settingsManager: {} as any, + totalCostUsd: 0, + lastTerminalReason: null, accumulatedUsage: { inputTokens: 0, outputTokens: 0, From 5ddb27cfe12b59f63625f6f07b125badc39c4a90 Mon Sep 17 00:00:00 2001 From: matthew-heartful Date: Fri, 10 Apr 2026 12:16:54 -0700 Subject: [PATCH 2/3] Revise dropped SDK event forwarding to use extNotification 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 --- src/acp-agent.ts | 117 ++++++++++-------------------------- src/tests/acp-agent.test.ts | 12 ++-- 2 files changed, 36 insertions(+), 93 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index ede7017e..82abe7d4 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -116,10 +116,6 @@ type Session = { cwd: string; settingsManager: SettingsManager; accumulatedUsage: AccumulatedUsage; - /** Cumulative cost in USD for this session (from SDK result messages) */ - totalCostUsd: number; - /** Terminal reason from the last SDK result (e.g. "blocking_limit", "completed") */ - lastTerminalReason: string | null; modes: SessionModeState; models: SessionModelState; configOptions: SessionConfigOption[]; @@ -490,9 +486,6 @@ export class ClaudeAcpAgent implements Agent { cachedReadTokens: 0, cachedWriteTokens: 0, }; - session.totalCostUsd = 0; - session.lastTerminalReason = null; - let lastAssistantTotalUsage: number | null = null; let lastAssistantModel: string | null = null; let lastContextWindowSize: number = 200000; @@ -596,11 +589,7 @@ export class ClaudeAcpAgent implements Agent { } case "session_state_changed": { if (message.state === "idle") { - return { - stopReason, - usage: sessionUsage(session), - _meta: sessionMeta(session), - }; + return { stopReason, usage: sessionUsage(session) }; } break; } @@ -612,30 +601,23 @@ export class ClaudeAcpAgent implements Agent { case "task_notification": case "task_progress": case "elicitation_complete": + break; case "api_retry": { - // Forward API retry events so clients can observe transient failures, - // including the HTTP status code and typed error category. + // Forward API retry events via extNotification so clients can observe + // transient failures including HTTP status code and typed error category. const retryMsg = message as any; this.logger.error( `API retry: attempt=${retryMsg.attempt}/${retryMsg.max_retries}, ` + `httpStatus=${retryMsg.error_status}, error=${retryMsg.error}, ` + `delay=${retryMsg.retry_delay_ms}ms`, ); - await this.client.sessionUpdate({ - sessionId: retryMsg.session_id, - _meta: { - apiRetry: { - httpStatus: retryMsg.error_status ?? null, - errorType: retryMsg.error ?? "unknown", - attempt: retryMsg.attempt ?? 0, - maxRetries: retryMsg.max_retries ?? 0, - retryDelayMs: retryMsg.retry_delay_ms ?? 0, - }, - }, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "" }, - }, + await this.client.extNotification("_claude/api-retry", { + sessionId: params.sessionId, + httpStatus: retryMsg.error_status ?? null, + errorType: retryMsg.error ?? "unknown", + attempt: retryMsg.attempt ?? 0, + maxRetries: retryMsg.max_retries ?? 0, + retryDelayMs: retryMsg.retry_delay_ms ?? 0, }); break; } @@ -650,10 +632,6 @@ export class ClaudeAcpAgent implements Agent { 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; - if ("terminal_reason" in message && message.terminal_reason) { - session.lastTerminalReason = message.terminal_reason as string; - } const matchingModelUsage = lastAssistantModel ? getMatchingModelUsage(message.modelUsage, lastAssistantModel) @@ -776,7 +754,7 @@ export class ClaudeAcpAgent implements Agent { handedOff = true; // the current loop stops with end_turn, // the loop of the next prompt continues running - return { stopReason: "end_turn", usage: sessionUsage(session), _meta: sessionMeta(session) }; + return { stopReason: "end_turn", usage: sessionUsage(session) }; } if ("isReplay" in message && message.isReplay) { // not pending or unrelated replay message @@ -874,62 +852,45 @@ export class ClaudeAcpAgent implements Agent { break; } case "tool_progress": { - // Forward tool execution progress as a tool_call_update so clients - // can show elapsed time for long-running tools. + // Forward tool execution progress via extNotification so clients can + // show elapsed time for long-running tools without polluting tool output. const toolCallId = "tool_use_id" in message ? (message as any).tool_use_id : undefined; if (toolCallId) { - await this.client.sessionUpdate({ + await this.client.extNotification("_claude/tool-progress", { sessionId: params.sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId, - status: "running", - content: [{ - type: "text", - text: `Tool running (${Math.round((message as any).elapsed_time_seconds ?? 0)}s)...`, - }], - }, + toolCallId, + toolName: (message as any).tool_name ?? null, + elapsedTimeSeconds: (message as any).elapsed_time_seconds ?? 0, }); } break; } case "tool_use_summary": { - // Forward collapsed tool-use summaries as agent message chunks - // so clients can display a high-level overview of tool activity. + // Forward collapsed tool-use summaries via extNotification so clients + // can display a high-level overview of tool activity. const summary = "summary" in message ? (message as any).summary : undefined; if (summary) { - await this.client.sessionUpdate({ + await this.client.extNotification("_claude/tool-use-summary", { sessionId: params.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: summary }, - }, + summary, }); } break; } case "rate_limit_event": { - // Forward rate limit info via _meta on a usage_update notification. - // This allows clients to detect approaching limits, rejections, - // and overage status without parsing error message strings. + // Forward rate limit info via extNotification so clients can detect + // approaching limits and rejections without parsing error message strings. const info = "rate_limit_info" in message ? (message as any).rate_limit_info : undefined; if (info) { - await this.client.sessionUpdate({ + await this.client.extNotification("_claude/rate-limit", { sessionId: params.sessionId, - _meta: { - rateLimitStatus: info.status ?? "unknown", - rateLimitResetsAt: info.resetsAt ?? null, - rateLimitType: info.rateLimitType ?? null, - rateLimitUtilization: info.utilization ?? null, - overageStatus: info.overageStatus ?? null, - overageDisabledReason: info.overageDisabledReason ?? null, - isUsingOverage: info.isUsingOverage ?? false, - }, - update: { - sessionUpdate: "usage_update", - used: lastAssistantTotalUsage ?? 0, - size: lastContextWindowSize, - }, + status: info.status ?? "unknown", + resetsAt: info.resetsAt ?? null, + rateLimitType: info.rateLimitType ?? null, + utilization: info.utilization ?? null, + overageStatus: info.overageStatus ?? null, + overageDisabledReason: info.overageDisabledReason ?? null, + isUsingOverage: info.isUsingOverage ?? false, }); } break; @@ -1633,8 +1594,6 @@ export class ClaudeAcpAgent implements Agent { cachedReadTokens: 0, cachedWriteTokens: 0, }, - totalCostUsd: 0, - lastTerminalReason: null, modes, models, configOptions, @@ -1667,18 +1626,6 @@ function sessionUsage(session: Session) { }; } -/** Build _meta for PromptResponse with cost and terminal reason from SDK results */ -function sessionMeta(session: Session): Record { - const meta: Record = {}; - if (session.totalCostUsd > 0) { - meta.costUsd = session.totalCostUsd; - } - if (session.lastTerminalReason) { - meta.terminalReason = session.lastTerminalReason; - } - return Object.keys(meta).length > 0 ? meta : {}; -} - function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) { if (!gatewayMeta) { return {}; diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 857593bb..4e12b4d8 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1337,8 +1337,7 @@ describe("stop reason propagation", () => { availableModels: [], }, settingsManager: { dispose: vi.fn() } as any, - totalCostUsd: 0, - lastTerminalReason: null, + accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -1478,8 +1477,7 @@ describe("stop reason propagation", () => { availableModels: [], }, settingsManager: { dispose: vi.fn() } as any, - totalCostUsd: 0, - lastTerminalReason: null, + accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -1553,8 +1551,7 @@ describe("session/close", () => { availableModels: [], }, settingsManager: { dispose: vi.fn() } as any, - totalCostUsd: 0, - lastTerminalReason: null, + accumulatedUsage: { inputTokens: 0, outputTokens: 0, @@ -1725,8 +1722,7 @@ describe("usage_update computation", () => { availableModels: [], }, settingsManager: {} as any, - totalCostUsd: 0, - lastTerminalReason: null, + accumulatedUsage: { inputTokens: 0, outputTokens: 0, From 2cabd33b79483f28bd0d14a2ef58cfbe796cb34d Mon Sep 17 00:00:00 2001 From: matthew-heartful Date: Fri, 10 Apr 2026 12:28:16 -0700 Subject: [PATCH 3/3] Preserve additional event fields dropped in previous revision - 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 --- src/acp-agent.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 82abe7d4..24f85cd5 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -854,11 +854,11 @@ export class ClaudeAcpAgent implements Agent { case "tool_progress": { // Forward tool execution progress via extNotification so clients can // show elapsed time for long-running tools without polluting tool output. - const toolCallId = "tool_use_id" in message ? (message as any).tool_use_id : undefined; - if (toolCallId) { + const toolUseId = "tool_use_id" in message ? (message as any).tool_use_id : undefined; + if (toolUseId) { await this.client.extNotification("_claude/tool-progress", { sessionId: params.sessionId, - toolCallId, + toolUseId, toolName: (message as any).tool_name ?? null, elapsedTimeSeconds: (message as any).elapsed_time_seconds ?? 0, }); @@ -873,6 +873,10 @@ export class ClaudeAcpAgent implements Agent { await this.client.extNotification("_claude/tool-use-summary", { sessionId: params.sessionId, summary, + precedingToolUseIds: + "preceding_tool_use_ids" in message + ? (message as any).preceding_tool_use_ids ?? [] + : [], }); } break; @@ -891,6 +895,7 @@ export class ClaudeAcpAgent implements Agent { overageStatus: info.overageStatus ?? null, overageDisabledReason: info.overageDisabledReason ?? null, isUsingOverage: info.isUsingOverage ?? false, + surpassedThreshold: info.surpassedThreshold ?? null, }); } break;