fix: Update to better utilize existing SDK data (#747)

- **feat: Better surface permission, progress, and rate updates**
- **Use SDK context usage and thinking config**
- **Keep learned context size after compaction**
This commit is contained in:
Ben Brandt
2026-06-05 10:59:06 +02:00
committed by GitHub
parent 23cbe6f58f
commit 398f763f71
3 changed files with 239 additions and 23 deletions

View File

@@ -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<number | null> {
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<string, string>; availableModels?: string[] } | undefined {

View File

@@ -3108,6 +3108,49 @@ describe("usage_update computation", () => {
// size should be 1000000 (Opus), not 200000 (the fallback if <synthetic> 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", () => {

View File

@@ -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" });
});
});
});