diff --git a/src/acp-agent.ts b/src/acp-agent.ts index b60a1e2b..de211b39 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -62,6 +62,7 @@ import { SDKPartialAssistantMessage, SDKUserMessage, SlashCommand, + ThinkingConfig, } from "@anthropic-ai/claude-agent-sdk"; import { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs"; @@ -941,28 +942,35 @@ export class ClaudeAcpAgent implements Agent { break; } case "compact_boundary": { - // Send used:0 immediately so the client doesn't keep showing - // the stale pre-compaction context size until the next turn. + // Refresh the displayed usage immediately so the client doesn't + // keep showing the stale pre-compaction size (e.g. "944k/1m") + // right after the user sees "Compacting completed", which is + // confusing and wrong. // - // This is a deliberate approximation: we don't know the exact - // post-compaction token count (only the SDK's next API call - // reveals that). But used:0 is directionally correct — context - // just dropped dramatically — and the real value replaces it - // within seconds when the next result message arrives. - // The alternative (no update) leaves the client showing e.g. - // "944k/1m" right after the user sees "Compacting completed", - // which is confusing and wrong. + // Prefer the SDK's authoritative post-compaction `used` via + // getContextUsage — it reflects the real retained context + // (system prompt + tools + surviving messages), which the + // per-message API usage numbers can't give us until the next + // turn's result. If the control request fails, fall back to the + // used:0 approximation: directionally correct (context just + // dropped dramatically) and replaced within seconds by the next + // result message. + // + // `size` keeps coming from session.contextWindowSize (learned + // from modelUsage / the model heuristic) — getContextUsage's + // window field under-reports extended 1M windows. // // The "Compacting completed." text is emitted from the `status` // handler (keyed on `compact_result`), not here, so the failure // path gets a message too. - lastAssistantTotalUsage = 0; + const usedTokens = await fetchContextUsedTokens(session.query, this.logger); lastAssistantUsage = null; + lastAssistantTotalUsage = usedTokens ?? 0; await this.client.sessionUpdate({ sessionId: message.session_id, update: { sessionUpdate: "usage_update", - used: 0, + used: lastAssistantTotalUsage, size: session.contextWindowSize, }, }); @@ -1043,6 +1051,49 @@ export class ClaudeAcpAgent implements Agent { }); break; } + case "mirror_error": { + // The SDK failed to persist session history (SessionStore + // append rejected/timed out after retry) — potential data loss + // the user should know about rather than a silent gap on + // resume. Log it and surface a warning in the conversation. + this.logger.error( + `Session ${message.session_id}: failed to persist history: ${message.error}`, + ); + break; + } + case "permission_denied": { + // A tool call was auto-denied (by a rule, the classifier, + // dontAsk mode, etc.) before running. The tool_use block was + // already emitted as a `tool_call`, so mark it failed with the + // rejection reason — otherwise the client shows a tool call + // that silently never resolves. + const reason = message.decision_reason ?? message.message; + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "tool_call_update", + toolCallId: message.tool_use_id, + status: "failed", + content: [ + { + type: "content", + content: { type: "text", text: `Permission denied: ${reason}` }, + }, + ], + _meta: { + claudeCode: { + toolName: message.tool_name, + toolResponse: { + decisionReasonType: message.decision_reason_type, + decisionReason: message.decision_reason, + message: message.message, + }, + }, + } satisfies ToolUpdateMeta, + }, + }); + break; + } case "hook_started": case "hook_progress": case "hook_response": @@ -1055,8 +1106,6 @@ export class ClaudeAcpAgent implements Agent { case "plugin_install": case "notification": case "api_retry": - case "mirror_error": - case "permission_denied": case "thinking_tokens": // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output break; @@ -1412,11 +1461,40 @@ export class ClaudeAcpAgent implements Agent { } break; } - case "tool_progress": + case "tool_progress": { + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "tool_call_update", + toolCallId: message.tool_use_id, + status: "in_progress", + _meta: { + claudeCode: { + toolName: message.tool_name, + toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds }, + }, + } satisfies ToolUpdateMeta, + }, + }); + break; + } + case "rate_limit_event": { + if (lastAssistantTotalUsage !== null) { + await this.client.sessionUpdate({ + sessionId: message.session_id, + update: { + sessionUpdate: "usage_update", + used: lastAssistantTotalUsage, + size: session.contextWindowSize, + _meta: { "_claude/rateLimit": message.rate_limit_info }, + }, + }); + } + break; + } case "tool_use_summary": case "auth_status": case "prompt_suggestion": - case "rate_limit_event": break; default: unreachable(message); @@ -2165,10 +2243,8 @@ export class ClaudeAcpAgent implements Agent { const sessionMeta = params._meta as NewSessionMeta | undefined; const userProvidedOptions = sessionMeta?.claudeCode?.options; - // Configure thinking tokens from environment variable - const maxThinkingTokens = process.env.MAX_THINKING_TOKENS - ? parseInt(process.env.MAX_THINKING_TOKENS, 10) - : undefined; + // Configure thinking behavior from environment variable + const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger); // Parse model configuration from environment (e.g. Bedrock model overrides) const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG); @@ -2194,7 +2270,7 @@ export class ClaudeAcpAgent implements Agent { const options: Options = { systemPrompt, settingSources: ["user", "project", "local"], - ...(maxThinkingTokens !== undefined && { maxThinkingTokens }), + ...(thinking !== undefined && { thinking }), ...userProvidedOptions, // CLAUDE_MODEL_CONFIG env var is a fallback for model // configuration (e.g. Bedrock model ID overrides). When the caller @@ -2422,7 +2498,6 @@ export class ClaudeAcpAgent implements Agent { effortLevel: initialEffort.currentValue as Settings["effortLevel"], }); } - this.sessions[sessionId] = { query: q, input: input, @@ -3439,7 +3514,8 @@ function commonPrefixLength(a: string, b: string) { } /** Best-effort first guess of a model's context window from its ID, used only - * until a `result` message arrives with the authoritative `modelUsage` value. + * as a fallback when the SDK's authoritative `getContextUsage` is unavailable + * (and until a `result` message arrives with the `modelUsage` value). * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also * matching things like "10m" or embedded substrings. */ @@ -3448,6 +3524,45 @@ function inferContextWindowFromModel(model: string): number | null { return null; } +/** Fetch the SDK's authoritative context-window occupancy via the + * `getContextUsage` control request. Unlike the per-message API usage numbers + * (which only count message tokens), this `totalTokens` includes the system + * prompt, tool schemas, MCP tools, and memory-file overhead — the real + * occupancy the user sees. Returns `null` on any control-request failure. + * + * Note: we deliberately do NOT use this response's window fields for `size`. + * They have been observed to under-report extended (1M) context windows, so + * the window keeps coming from `modelUsage` / `inferContextWindowFromModel`, + * which handle the 1M variants correctly. */ +async function fetchContextUsedTokens(query: Query, logger: Logger): Promise { + try { + const usage = await query.getContextUsage(); + return usage.totalTokens; + } catch (error) { + logger.error("Failed to fetch context usage from SDK:", error); + return null; + } +} + +/** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking` + * option. The `maxThinkingTokens` option it used to feed is deprecated and + * reduced to on/off on current models, so map the value to explicit thinking + * config instead: unset → `undefined` (SDK default, adaptive on models that + * support it); `0` → disabled; a positive integer → a fixed token budget. + * Anything else is ignored with a warning. */ +function resolveThinkingConfig( + raw: string | undefined, + logger: Logger, +): ThinkingConfig | undefined { + if (raw === undefined) return undefined; + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed) || parsed < 0) { + logger.error(`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${raw}'.`); + return undefined; + } + return parsed === 0 ? { type: "disabled" } : { type: "enabled", budgetTokens: parsed }; +} + function parseModelConfig( raw: string | undefined, ): { modelOverrides?: Record; availableModels?: string[] } | undefined { diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 2dfa09a9..2e9d7115 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -3108,6 +3108,49 @@ describe("usage_update computation", () => { // size should be 1000000 (Opus), not 200000 (the fallback if overrode the model) expect(usageUpdate.update.size).toBe(1000000); }); + + it("compact_boundary uses authoritative getContextUsage for used, keeps session window for size", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + const session = agent.sessions["test-session"]; + // A 1M window learned earlier (e.g. from modelUsage) must survive compaction + // — getContextUsage's window field under-reports it, so we don't use it. + session.contextWindowSize = 1000000; + (session.query as any).getContextUsage = vi + .fn() + .mockResolvedValue({ totalTokens: 12345, rawMaxTokens: 200000 }); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + expect(usageUpdate.update.used).toBe(12345); + // size stays at the session's learned window, NOT getContextUsage's value. + expect(usageUpdate.update.size).toBe(1000000); + expect(session.contextWindowSize).toBe(1000000); + }); + + it("compact_boundary falls back to used:0 when getContextUsage fails", async () => { + const { agent, updates } = createMockAgentWithCapture(); + injectSession(agent, [ + { type: "system", subtype: "compact_boundary", session_id: "test-session" }, + { type: "system", subtype: "session_state_changed", state: "idle" }, + ]); + const session = agent.sessions["test-session"]; + session.contextWindowSize = 200000; + (session.query as any).getContextUsage = vi.fn().mockRejectedValue(new Error("boom")); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }); + + const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update"); + expect(usageUpdate).toBeDefined(); + expect(usageUpdate.update.used).toBe(0); + expect(usageUpdate.update.size).toBe(200000); + expect(session.contextWindowSize).toBe(200000); + }); }); describe("emitRawSDKMessages", () => { diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index da0add5b..dcb75729 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -473,4 +473,62 @@ describe("createSession options merging", () => { // ACP-provided MCP server should also be present expect(capturedOptions!.mcpServers).toHaveProperty("acp-server"); }); + + describe("thinking config from MAX_THINKING_TOKENS", () => { + let originalMaxThinking: string | undefined; + + beforeEach(() => { + originalMaxThinking = process.env.MAX_THINKING_TOKENS; + delete process.env.MAX_THINKING_TOKENS; + }); + + afterEach(() => { + if (originalMaxThinking !== undefined) { + process.env.MAX_THINKING_TOKENS = originalMaxThinking; + } else { + delete process.env.MAX_THINKING_TOKENS; + } + }); + + it("leaves thinking unset (SDK default) when env var is absent", async () => { + await agent.newSession({ cwd: "/test", mcpServers: [] }); + expect(capturedOptions!.thinking).toBeUndefined(); + // The deprecated option must not be set either. + expect(capturedOptions!.maxThinkingTokens).toBeUndefined(); + }); + + it("maps 0 to disabled thinking", async () => { + process.env.MAX_THINKING_TOKENS = "0"; + await agent.newSession({ cwd: "/test", mcpServers: [] }); + expect(capturedOptions!.thinking).toEqual({ type: "disabled" }); + }); + + it("maps a positive value to a fixed thinking budget", async () => { + process.env.MAX_THINKING_TOKENS = "12000"; + await agent.newSession({ cwd: "/test", mcpServers: [] }); + expect(capturedOptions!.thinking).toEqual({ type: "enabled", budgetTokens: 12000 }); + }); + + it("ignores a non-numeric value", async () => { + process.env.MAX_THINKING_TOKENS = "lots"; + await agent.newSession({ cwd: "/test", mcpServers: [] }); + expect(capturedOptions!.thinking).toBeUndefined(); + }); + + it("lets a user-provided thinking option override the env default", async () => { + process.env.MAX_THINKING_TOKENS = "12000"; + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + thinking: { type: "adaptive" }, + }, + }, + }, + }); + expect(capturedOptions!.thinking).toEqual({ type: "adaptive" }); + }); + }); });