From 5c1863d56ead2acd7f3d5b7ad8a8eeaabd1c43d8 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 3 Mar 2026 06:58:40 -0500 Subject: [PATCH] Fix: propagate max_tokens stop reason instead of throwing internal error (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When the Claude SDK reports `stop_reason: "max_tokens"`, the ACP adapter now returns `{ stopReason: "max_tokens" }` instead of throwing a `RequestError.internalError` (JSON-RPC `-32603`) - Scoped to `success` and `error_during_execution` result subtypes only — `error_max_turns` / `error_max_budget_usd` still correctly map to `max_turn_requests` ## Problem When tokens are exhausted, the SDK sends a `result` message with `stop_reason: "max_tokens"` and `is_error: true`. The adapter hit the `is_error` check first and threw an internal error, so ACP clients never received the `stopReason` field. Downstream tools like [toad](https://github.com/batrachianai/toad/discussions/176#discussioncomment-15445958) saw a JSON-RPC exception instead of a clean stop reason. ## Fix Check `stop_reason === "max_tokens"` before the `is_error` throw in the `success` and `error_during_execution` branches. Token exhaustion is a normal stop condition, not an internal error. ## Test plan - [x] Added 5 unit tests covering: - `success` + `max_tokens` + `is_error: false` → returns `max_tokens` - `success` + `max_tokens` + `is_error: true` → returns `max_tokens` (main bug) - `error_during_execution` + `max_tokens` + `is_error: true` → returns `max_tokens` - `success` + `null` stop_reason → still returns `end_turn` (no regression) - `success` + `is_error: true` + non-max_tokens → still throws (no regression) - [x] All 100 tests pass - [x] Build passes Fixes #235 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- src/acp-agent.ts | 6 ++ src/tests/acp-agent.test.ts | 153 +++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 81a785e1..8c53c20e 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -543,12 +543,18 @@ export class ClaudeAcpAgent implements Agent { if (message.result.includes("Please run /login")) { throw RequestError.authRequired(); } + if (message.stop_reason === "max_tokens") { + return { stopReason: "max_tokens", usage }; + } if (message.is_error) { throw RequestError.internalError(undefined, message.result); } return { stopReason: "end_turn", usage }; } case "error_during_execution": + if (message.stop_reason === "max_tokens") { + return { stopReason: "max_tokens", usage }; + } if (message.is_error) { throw RequestError.internalError( undefined, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 673c921f..b2e5eafa 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -23,7 +23,8 @@ import { toolUpdateFromToolResult, toolUpdateFromEditToolResponse, } from "../tools.js"; -import { toAcpNotifications, promptToClaude } from "../acp-agent.js"; +import { toAcpNotifications, promptToClaude, ClaudeAcpAgent } from "../acp-agent.js"; +import { Pushable } from "../utils.js"; import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import { randomUUID } from "crypto"; import type { @@ -1228,3 +1229,153 @@ describe("permission requests", () => { } }); }); + +describe("stop reason propagation", () => { + function createMockAgent() { + const mockClient = { + sessionUpdate: async () => {}, + } as unknown as AgentSideConnection; + return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + } + + function createResultMessage(overrides: { + subtype: "success" | "error_during_execution"; + stop_reason: string | null; + is_error: boolean; + result?: string; + errors?: string[]; + }) { + return { + type: "result" as const, + subtype: overrides.subtype, + stop_reason: overrides.stop_reason, + is_error: overrides.is_error, + result: overrides.result ?? "", + errors: overrides.errors ?? [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + } + + function* messageGenerator(messages: any[]) { + yield* messages; + } + + function injectSession(agent: ClaudeAcpAgent, messages: any[]) { + const gen = messageGenerator(messages); + agent.sessions["test-session"] = { + query: gen as any, + input: new Pushable(), + cancelled: false, + permissionMode: "default", + settingsManager: {} as any, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + configOptions: [], + promptRunning: false, + pendingMessages: new Map(), + nextPendingOrder: 0, + }; + } + + it("should return max_tokens when success result has stop_reason max_tokens", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ subtype: "success", stop_reason: "max_tokens", is_error: false }), + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("max_tokens"); + }); + + it("should return max_tokens when success result has stop_reason max_tokens and is_error true", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ + subtype: "success", + stop_reason: "max_tokens", + is_error: true, + result: "Token limit reached", + }), + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("max_tokens"); + }); + + it("should return max_tokens when error_during_execution has stop_reason max_tokens", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ + subtype: "error_during_execution", + stop_reason: "max_tokens", + is_error: true, + errors: ["some error"], + }), + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("max_tokens"); + }); + + it("should return end_turn for success with null stop_reason", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ subtype: "success", stop_reason: null, is_error: false }), + ]); + + const response = await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + expect(response.stopReason).toBe("end_turn"); + }); + + it("should throw internal error for success with is_error true and no max_tokens", async () => { + const agent = createMockAgent(); + injectSession(agent, [ + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "Something went wrong", + }), + ]); + + await expect( + agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }), + ).rejects.toThrow("Internal error"); + }); +});