mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 03:41:17 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }] },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user