fix: honour the client's settings option in idle compaction (#1555)

## Problem

A client can pass Claude Code settings in
`_meta.claudeCode.options.settings`, and the CLI gets them as
`--settings`. Idle compaction ignored them: it read only
`settingsManager.getSettings()`, the settings files. A client that sent
`{ idleCompaction: false }` still got `/compact` after about 54 idle
minutes, though the CLI had the setting off. The same went for
`autoCompactEnabled` and the `env` block (`DISABLE_COMPACT`,
`DISABLE_AUTO_COMPACT`, `CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS`). This
affected both ACP v1 and v2.

## Change

- `SettingsManager.getCompactionSettings(override)` resolves
`idleCompaction`, `autoCompactEnabled` and `env` in the CLI's order:
managed settings, then `--settings`, then the user/project/local files.
`env` merges entry by entry. An `undefined` value in the override does
not hide the files' value. This sits next to `getApiKeyHelper` and
`getModelSelectionSettings`, which already take the override the same
way.
- v1 (`src/v1/acp-agent.ts`) and v2 (`src/v2/reports.ts`, via
`ReportsSetup.configuredSettings`) pass the client's settings to it.
`IdleCompaction` itself is unchanged.

## Tests

- `settings-api-key-helper.test.ts`: managed > override > files for each
key and each `env` entry; an `undefined` override key keeps the files'
value.
- `v1/idle-compaction-agent.test.ts` and `v2/acp-v2.test.ts`: a session
started with `settings: { idleCompaction: false }` gets no `/compact`.
Both fail without the wiring change.

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Erdős Péter <6430206+sodre90@users.noreply.github.com>
This commit is contained in:
Erdős Péter
2026-11-10 22:24:24 +01:00
committed by GitHub
parent 626015bf81
commit b73ee491c8
8 changed files with 198 additions and 43 deletions

View File

@@ -5,7 +5,8 @@ export type IdleCompactionHost = {
isBusy(): boolean;
/** Context size after the last main-thread request, or undefined if unknown. */
contextTokens(): number | undefined;
/** Current merged Claude Code settings. */
/** Current Claude Code settings, the client's `settings` option included
* (`SettingsManager.getCompactionSettings`). */
settings(): {
idleCompaction?: boolean;
autoCompactEnabled?: boolean;

View File

@@ -39,6 +39,13 @@ export interface SettingsManagerOptions {
type ModelSelectionSettings = Pick<Settings, "availableModels" | "modelOverrides">;
/** The settings that decide whether Claude Code compacts (see `idle-compaction.ts`). */
export type CompactionSettings = {
idleCompaction?: boolean;
autoCompactEnabled?: boolean;
env?: Record<string, string>;
};
/**
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
* so the values we see match what `query()` would observe.
@@ -54,6 +61,7 @@ export class SettingsManager {
private managedDeniedModels: string[] = [];
private managedModelSettings: ModelSelectionSettings = {};
private managedApiKeyHelper?: string;
private managedCompactionSettings: CompactionSettings = {};
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
@@ -128,12 +136,23 @@ export class SettingsManager {
resolved.provenance.apiKeyHelper?.source === "managed"
? resolved.effective.apiKeyHelper
: undefined;
const managed = resolved.sources
.filter((entry) => entry.source === "managed")
.map((entry) => entry.settings);
this.managedCompactionSettings = {
idleCompaction: managed.findLast((tier) => tier.idleCompaction !== undefined)
?.idleCompaction,
autoCompactEnabled: managed.findLast((tier) => tier.autoCompactEnabled !== undefined)
?.autoCompactEnabled,
env: Object.assign({}, ...managed.map((tier) => tier.env)),
};
} catch (error) {
this.logger.error("Failed to resolve settings:", error);
this.effective = {};
this.managedDeniedModels = [];
this.managedModelSettings = {};
this.managedApiKeyHelper = undefined;
this.managedCompactionSettings = {};
}
}
@@ -226,6 +245,31 @@ export class SettingsManager {
};
}
/**
* `idleCompaction`, `autoCompactEnabled` and `env` as the CLI resolves them:
* managed settings outrank the `--settings` override, which outranks the
* user/project/local files. `env` merges entry by entry in the same order. A
* key whose value is `undefined` in the override does not hide the files' value.
*/
getCompactionSettings(settingsOverride?: Settings): CompactionSettings {
const managed = this.managedCompactionSettings;
const env: Record<string, string> = {};
for (const tier of [this.effective.env, settingsOverride?.env, managed.env]) {
for (const [name, value] of Object.entries(tier ?? {})) {
if (value !== undefined) env[name] = value;
}
}
return {
idleCompaction:
managed.idleCompaction ?? settingsOverride?.idleCompaction ?? this.effective.idleCompaction,
autoCompactEnabled:
managed.autoCompactEnabled ??
settingsOverride?.autoCompactEnabled ??
this.effective.autoCompactEnabled,
env,
};
}
/**
* `deniedModels` entries from the managed tier, the only tier the CLI
* honors them from.

View File

@@ -38,3 +38,62 @@ describe("SettingsManager.getApiKeyHelper", () => {
manager.dispose();
});
});
describe("SettingsManager.getCompactionSettings", () => {
it("ranks managed settings over the --settings override over the files, env entry by entry", async () => {
const manager = await managerResolving({
effective: {
idleCompaction: true,
autoCompactEnabled: false,
env: {
DISABLE_COMPACT: "1",
DISABLE_AUTO_COMPACT: "1",
CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: "1",
},
},
provenance: {},
sources: [
{ source: "userSettings", settings: { autoCompactEnabled: false } },
{
source: "managed",
settings: { idleCompaction: true, env: { DISABLE_AUTO_COMPACT: "1" } },
},
],
});
expect(
manager.getCompactionSettings({
idleCompaction: false,
autoCompactEnabled: true,
env: { DISABLE_COMPACT: "0", DISABLE_AUTO_COMPACT: "0" },
}),
).toEqual({
idleCompaction: true,
autoCompactEnabled: true,
env: {
DISABLE_COMPACT: "0",
DISABLE_AUTO_COMPACT: "1",
CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: "1",
},
});
manager.dispose();
});
it("keeps the files' value for a key the override sets to undefined", async () => {
const manager = await managerResolving({
effective: { idleCompaction: false, env: { DISABLE_COMPACT: "1" } },
provenance: {},
sources: [],
});
expect(
manager.getCompactionSettings({
idleCompaction: undefined,
env: { DISABLE_COMPACT: undefined as unknown as string },
}),
).toEqual({
idleCompaction: false,
autoCompactEnabled: undefined,
env: { DISABLE_COMPACT: "1" },
});
manager.dispose();
});
});

View File

@@ -226,7 +226,10 @@ function scriptClaudeCode({
};
}
async function connect(clientCapabilities: Record<string, unknown> = {}) {
async function connect(
clientCapabilities: Record<string, unknown> = {},
sessionMeta?: Record<string, unknown>,
) {
vi.resetModules();
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const updates: Array<{ update: Record<string, any> }> = [];
@@ -241,7 +244,7 @@ async function connect(clientCapabilities: Record<string, unknown> = {}) {
error: () => {},
});
await initializeClient(agent, clientCapabilities);
const session = await agent.newSession({ cwd, mcpServers: [] });
const session = await agent.newSession({ cwd, mcpServers: [], _meta: sessionMeta });
return { agent, sessionId: session.sessionId, updates, logs };
}
@@ -322,6 +325,22 @@ describe("idle compaction in the agent", () => {
await agent.dispose();
});
/** The client's `settings` option reaches the CLI as `--settings`, and idle compaction reads it too. */
it("does not compact a session whose client turned idle compaction off in the settings option", async () => {
const claude = scriptClaudeCode();
const { agent, sessionId } = await connect(
{},
{ claudeCode: { options: { settings: { idleCompaction: false } } } },
);
await promptHi(agent, sessionId);
await claude.untilIdle();
await vi.advanceTimersByTimeAsync(2 * 60 * MINUTE_MS);
expect(claude.received).toEqual(["hi"]);
await agent.dispose();
});
/** Closing the session during the wait drops the timer. */
it("does not compact once the session is closed", async () => {
const claude = scriptClaudeCode();

View File

@@ -4211,47 +4211,60 @@ describe("ACP v2 idle compaction", () => {
},
});
/**
* Prompts "hi" in a session created with `meta`, answers it with a large
* one-hour request, and waits 54 minutes. Returns the prompts Claude Code
* received and the client.
*/
async function promptAndIdle(meta?: Record<string, unknown>) {
const received: string[] = [];
sdk.run = async function* (prompts, options) {
const input = prompts[Symbol.asyncIterator]();
for (;;) {
const { value, done } = await input.next();
if (done) return;
received.push((value.message.content as Array<{ text: string }>)[0].text);
if (received.length === 1) {
yield {
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: options.sessionId,
isReplay: true,
};
yield largeOneHourRequest(options);
} else {
yield { type: "system", subtype: "status", status: "compacting", uuid: randomUUID() };
}
yield result(options);
yield idleState(options);
}
};
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
cwd,
_meta: meta,
});
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(54 * 60_000);
for (let i = 0; i < 20 && received.length < 2; i++) await vi.advanceTimersByTimeAsync(0);
for (let i = 0; i < 20 && turnTrace(client.sessionUpdates).length < 6; i++) {
await vi.advanceTimersByTimeAsync(0);
}
await client.authUpdate(1);
});
return { received, client };
}
/** The session is idle for 54 minutes after a large one-hour request, and the adapter's `/compact` runs. */
it("reports the compaction that the adapter starts as running, then idle, with no user_message", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
try {
const received: string[] = [];
sdk.run = async function* (prompts, options) {
const input = prompts[Symbol.asyncIterator]();
for (;;) {
const { value, done } = await input.next();
if (done) return;
received.push((value.message.content as Array<{ text: string }>)[0].text);
if (received.length === 1) {
yield {
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: options.sessionId,
isReplay: true,
};
yield largeOneHourRequest(options);
} else {
yield { type: "system", subtype: "status", status: "compacting", uuid: randomUUID() };
}
yield result(options);
yield idleState(options);
}
};
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(54 * 60_000);
for (let i = 0; i < 20 && received.length < 2; i++) await vi.advanceTimersByTimeAsync(0);
for (let i = 0; i < 20 && turnTrace(client.sessionUpdates).length < 6; i++) {
await vi.advanceTimersByTimeAsync(0);
}
await client.authUpdate(1);
});
const { received, client } = await promptAndIdle();
expect(received).toEqual(["hi", "/compact"]);
expect(turnTrace(client.sessionUpdates)).toEqual([
@@ -4266,6 +4279,20 @@ describe("ACP v2 idle compaction", () => {
vi.useRealTimers();
}
});
/** The client's `settings` option reaches the CLI as `--settings`, and idle compaction reads it too. */
it("does not compact a session whose client turned idle compaction off in the settings option", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
try {
const { received } = await promptAndIdle({
claudeCode: { options: { settings: { idleCompaction: false } } },
});
expect(received).toEqual(["hi"]);
} finally {
vi.useRealTimers();
}
});
});
// Claude Code runs model cycles that no prompt started, for example its

View File

@@ -10599,7 +10599,8 @@ export class ClaudeAcpAgent {
this.claudeIsWorking(sessionId, registered) ||
(registered.openUserInputs?.size ?? 0) > 0,
contextTokens: () => registered.contextUsedTokens,
settings: () => registered.settingsManager.getSettings(),
settings: () =>
registered.settingsManager.getCompactionSettings(configuredSettingsObject),
compact: () =>
this.prompt(
{ sessionId, prompt: [{ type: "text", text: "/compact" }] },

View File

@@ -969,6 +969,7 @@ class V2Agent implements AgentHandle, SessionConnection {
}
session.reports.configure({
settings,
configuredSettings: configured,
env: options.env ?? {},
mcpServers: request.mcpServers.map((server) => server.name),
mcpOAuth: this.elicitation.url && supportsMcpOAuth(q),

View File

@@ -21,6 +21,7 @@ import type {
SDKMessage,
SDKPartialAssistantMessage,
SDKResultMessage,
Settings,
} from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../logger.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
@@ -78,7 +79,9 @@ export interface ReportsHost {
/** What the session's query was started with (see {@link SessionReports.configure}). */
export interface ReportsSetup {
/** The session's live Claude Code settings, disposed with the reports. */
settings: Pick<SettingsManager, "getSettings" | "dispose">;
settings: Pick<SettingsManager, "getCompactionSettings" | "dispose">;
/** The client's `settings` option, which the query got as `--settings`. */
configuredSettings: Settings | undefined;
/** The query's environment. */
env: Record<string, string | undefined>;
/** The names of the client's MCP servers. */
@@ -172,7 +175,7 @@ export class SessionReports {
{
isBusy: () => this.host.isBusy(),
contextTokens: () => this.contextUsed,
settings: () => setup.settings.getSettings(),
settings: () => setup.settings.getCompactionSettings(setup.configuredSettings),
compact: () => this.host.compact(),
// The logger has no debug level.
log: (message) => this.host.logger.log(message),