mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: accept IDE session hooks without dropping model settings (#1265)
IDEs can pass their own Claude hooks through `_meta.claudeCode.options.settings.hooks` on `session/new` and `session/load`. Before this change, any session settings replaced `CLAUDE_MODEL_CONFIG` entirely, so adding a single hook dropped the deployment's `modelOverrides` / `availableModels`. The two are now merged, and the session settings win per key. The wrapper does not merge hooks itself: Claude Code already runs the hooks of every settings tier, so user and project hooks keep running next to the session ones. Hooks stay scoped to their session, and a `session/load` with different settings recreates the query, which replaces or removes them. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
b3bed2f763
commit
fe8b69edd4
@@ -17,6 +17,7 @@ It describes only this adapter.
|
||||
- [Claude tools and ACP fields](#claude-tools-and-acp-fields)
|
||||
- [Diff patch](#diff-patch)
|
||||
- [Plan file](#plan-file)
|
||||
- [Session hooks](#session-hooks)
|
||||
- [Permission presentation](#permission-presentation)
|
||||
- [Goal](#goal)
|
||||
- [Recommended config values](#recommended-config-values)
|
||||
@@ -496,6 +497,21 @@ The plan text goes out as before in these cases:
|
||||
AIR then shows the in-memory `plan` text.
|
||||
A client that is not AIR always gets the whole SDK input, like upstream.
|
||||
|
||||
## Session hooks
|
||||
|
||||
The adapter accepts session hooks from any client, without a capability.
|
||||
AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/new` and `session/load`:
|
||||
|
||||
```json
|
||||
{ "claudeCode": { "options": { "settings": { "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "..." }] }] } } } }
|
||||
```
|
||||
|
||||
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
|
||||
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
|
||||
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
|
||||
- A `session/load` with different settings recreates the query, so it applies the new hooks.
|
||||
- Claude asks for no hook trust step.
|
||||
|
||||
## Permission presentation
|
||||
|
||||
The adapter uses standard ACP permission requests and responses.
|
||||
|
||||
@@ -42,12 +42,12 @@ node dist/index.js
|
||||
|
||||
## Precedence
|
||||
|
||||
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
|
||||
An ACP caller can provide `settings` via `_meta.claudeCode.options.settings` in `session/new` or `session/load`, as an object or a path to a settings file. The adapter merges `CLAUDE_MODEL_CONFIG` into those settings, so a caller that only adds hooks keeps the model settings. When both set the same key, the caller wins; the merge is shallow, so a caller's `modelOverrides` replaces the env var's map.
|
||||
|
||||
| Source | Priority |
|
||||
| -------------------------------------------- | ----------------------------------------------------- |
|
||||
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
|
||||
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
|
||||
| Source | Priority |
|
||||
| -------------------------------------------- | ---------------------------------------------- |
|
||||
| `_meta.claudeCode.options.settings` (caller) | Highest — wins for each key it sets |
|
||||
| `CLAUDE_MODEL_CONFIG` (env var) | Used for the keys that the caller does not set |
|
||||
|
||||
## Format details
|
||||
|
||||
|
||||
@@ -9714,32 +9714,26 @@ export class ClaudeAcpAgent {
|
||||
// into the query.
|
||||
const resolvedProvider = this.resolveProviderConfig();
|
||||
const providerEnv = createEnvForProvider(resolvedProvider);
|
||||
const configuredSettings =
|
||||
userProvidedOptions?.settings ??
|
||||
(modelConfig
|
||||
? {
|
||||
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
||||
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
||||
}
|
||||
: undefined);
|
||||
const configuredSettingsObject =
|
||||
typeof configuredSettings === "string"
|
||||
const providedSettings = userProvidedOptions?.settings;
|
||||
const providedSettingsObject =
|
||||
typeof providedSettings === "string"
|
||||
? (JSON.parse(
|
||||
await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"),
|
||||
await fs.readFile(path.resolve(params.cwd, providedSettings), "utf8"),
|
||||
) as Settings)
|
||||
: configuredSettings;
|
||||
: providedSettings;
|
||||
const configuredSettingsObject = modelConfig
|
||||
? { ...modelConfig, ...providedSettingsObject }
|
||||
: providedSettingsObject;
|
||||
// Claude Code applies env from settings.json after the subprocess env. Put
|
||||
// an active ACP route in the programmatic settings tier too so user/project
|
||||
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
|
||||
let settings = configuredSettings;
|
||||
if (resolvedProvider) {
|
||||
const baseSettings = configuredSettingsObject;
|
||||
settings = {
|
||||
...baseSettings,
|
||||
apiKeyHelper: "",
|
||||
env: { ...baseSettings?.env, ...providerEnv },
|
||||
};
|
||||
}
|
||||
const settings = resolvedProvider
|
||||
? {
|
||||
...configuredSettingsObject,
|
||||
apiKeyHelper: "",
|
||||
env: { ...configuredSettingsObject?.env, ...providerEnv },
|
||||
}
|
||||
: configuredSettingsObject;
|
||||
const env = {
|
||||
...process.env,
|
||||
...userProvidedOptions?.env,
|
||||
|
||||
@@ -656,9 +656,71 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores env var when _meta provides settings", async () => {
|
||||
it("keeps model settings when session settings add hooks", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
const hooks = { PostToolUse: [{ hooks: [{ type: "command", command: "echo ready" }] }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings: { hooks } } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
availableModels: ["opus"],
|
||||
hooks,
|
||||
});
|
||||
// The CLI adds these hooks to the ones in the user and project settings.
|
||||
expect(capturedOptions!.settingSources).toEqual(["user", "project", "local"]);
|
||||
});
|
||||
|
||||
it("applies session hooks on session/load with model settings", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
|
||||
const cwd = process.cwd();
|
||||
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
|
||||
const hooks = { Stop: [{ hooks: [{ type: "command", command: "echo done" }] }] };
|
||||
|
||||
await agent.loadSession({
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings: { hooks } } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.resume).toBe(sessionId);
|
||||
expect(capturedOptions!.settings).toEqual({ availableModels: ["opus"], hooks });
|
||||
});
|
||||
|
||||
it("merges model settings with a session settings file", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
|
||||
const settingsDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-settings-"));
|
||||
const settingsFile = path.join(settingsDir, "settings.json");
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { Stop: [] } }));
|
||||
|
||||
try {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings: settingsFile } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
availableModels: ["opus"],
|
||||
hooks: { Stop: [] },
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(settingsDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers session settings when both sources set the same field", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
@@ -676,10 +738,10 @@ describe("createSession options merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// _meta settings take precedence; env var is ignored entirely
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
model: "claude-sonnet-4-6",
|
||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
202
src/tests/session-hooks.test.ts
Normal file
202
src/tests/session-hooks.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Session hooks against the real Claude Code binary. A local stub of the
|
||||
* Messages API answers every turn, so the test needs no network or account.
|
||||
* The CLI merges the settings tiers, so a mock SDK cannot show that the user
|
||||
* and project hooks still run next to the hooks of `_meta.claudeCode.options`.
|
||||
*/
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { NewSessionRequest } from "@agentclientprotocol/sdk";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
|
||||
function sse(type: string, data: object): string {
|
||||
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
|
||||
}
|
||||
|
||||
const REPLY =
|
||||
sse("message_start", {
|
||||
message: {
|
||||
id: "msg_stub",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-5",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
}) +
|
||||
sse("content_block_start", { index: 0, content_block: { type: "text", text: "" } }) +
|
||||
sse("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ok" } }) +
|
||||
sse("content_block_stop", { index: 0 }) +
|
||||
sse("message_delta", {
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
}) +
|
||||
sse("message_stop", {});
|
||||
|
||||
describe("session hooks with the Claude Code binary", () => {
|
||||
let root: string;
|
||||
let cwd: string;
|
||||
let home: string;
|
||||
let log: string;
|
||||
let server: http.Server;
|
||||
let agent: ClaudeAcpAgent;
|
||||
let env: Record<string, string | undefined>;
|
||||
let settingsFiles: Record<string, string>;
|
||||
let logCursor = 0;
|
||||
|
||||
/** A SessionStart hook that writes `tag` to the log. */
|
||||
function hooks(tag: string) {
|
||||
return {
|
||||
SessionStart: [{ hooks: [{ type: "command", command: `echo ${tag} >> "${log}"` }] }],
|
||||
};
|
||||
}
|
||||
|
||||
function meta(tag?: string): NewSessionRequest["_meta"] {
|
||||
return { claudeCode: { options: { env, ...(tag && { settings: { hooks: hooks(tag) } }) } } };
|
||||
}
|
||||
|
||||
/** The hooks that ran since the previous call. Hooks of one event run in parallel. */
|
||||
async function hooksSinceLastCheck(): Promise<string[]> {
|
||||
const lines = (await readFile(log, "utf8").catch(() => "")).split("\n").filter(Boolean);
|
||||
const added = lines.slice(logCursor);
|
||||
logCursor = lines.length;
|
||||
return added.sort();
|
||||
}
|
||||
|
||||
/** A turn finishes after the SessionStart hooks, and it persists the session for a load. */
|
||||
async function finishTurn(sessionId: string): Promise<void> {
|
||||
const response = await agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
}
|
||||
|
||||
async function readSettingsFiles(): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
for (const dir of [path.join(home, ".claude"), path.join(cwd, ".claude")]) {
|
||||
for (const name of await readdir(dir)) {
|
||||
if (name.startsWith("settings")) {
|
||||
files[path.join(dir, name)] = await readFile(path.join(dir, name), "utf8");
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "claude-acp-session-hooks-"));
|
||||
cwd = path.join(root, "workspace");
|
||||
home = path.join(root, "home");
|
||||
log = path.join(root, "hooks.log");
|
||||
await mkdir(path.join(cwd, ".claude"), { recursive: true });
|
||||
await mkdir(path.join(home, ".claude"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(home, ".claude", "settings.json"),
|
||||
JSON.stringify({ hooks: hooks("user") }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(cwd, ".claude", "settings.json"),
|
||||
JSON.stringify({ hooks: hooks("project") }),
|
||||
);
|
||||
settingsFiles = await readSettingsFiles();
|
||||
// vitest.config.ts points every test at `/usr/bin/false` in place of the
|
||||
// CLI. This file opts in to the real binary, and isolates the agent process
|
||||
// too: it reads user settings and runs `claude auth status` itself.
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", undefined);
|
||||
vi.stubEnv("HOME", home);
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(home, ".claude"));
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
if (req.url?.startsWith("/v1/messages/count_tokens")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ input_tokens: 1 }));
|
||||
} else if (req.url?.startsWith("/v1/messages")) {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.end(REPLY);
|
||||
} else {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
env = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
CLAUDE_CONFIG_DIR: path.join(home, ".claude"),
|
||||
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
||||
};
|
||||
const client = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
} as unknown as AcpClient;
|
||||
agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await agent?.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
await new Promise((resolve) => server?.close(resolve));
|
||||
// The CLI may still write the transcript of the last session after dispose.
|
||||
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
// With CLAUDE_MODEL_CONFIG, the adapter passes the merged settings inline.
|
||||
for (const modelConfig of [
|
||||
undefined,
|
||||
'{"modelOverrides":{"claude-opus-4-6":"claude-opus-4-6"}}',
|
||||
]) {
|
||||
describe(modelConfig ? "with CLAUDE_MODEL_CONFIG" : "without CLAUDE_MODEL_CONFIG", () => {
|
||||
const original = process.env.CLAUDE_MODEL_CONFIG;
|
||||
beforeEach(() => {
|
||||
if (modelConfig) process.env.CLAUDE_MODEL_CONFIG = modelConfig;
|
||||
else delete process.env.CLAUDE_MODEL_CONFIG;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env.CLAUDE_MODEL_CONFIG;
|
||||
else process.env.CLAUDE_MODEL_CONFIG = original;
|
||||
});
|
||||
|
||||
it("runs the user, project and IDE hooks of one event, only in their session", async () => {
|
||||
const withHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta("ide") });
|
||||
await finishTurn(withHooks.sessionId);
|
||||
expect(await hooksSinceLastCheck()).toEqual(["ide", "project", "user"]);
|
||||
|
||||
const withoutHooks = await agent.newSession({ cwd, mcpServers: [], _meta: meta() });
|
||||
await finishTurn(withoutHooks.sessionId);
|
||||
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
|
||||
|
||||
expect(await readSettingsFiles()).toEqual(settingsFiles);
|
||||
}, 60_000);
|
||||
|
||||
it("replaces and removes IDE hooks on session/load", async () => {
|
||||
const { sessionId } = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
_meta: meta("ide-old"),
|
||||
});
|
||||
await finishTurn(sessionId);
|
||||
expect(await hooksSinceLastCheck()).toEqual(["ide-old", "project", "user"]);
|
||||
|
||||
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta("ide-new") });
|
||||
await finishTurn(sessionId);
|
||||
expect(await hooksSinceLastCheck()).toEqual(["ide-new", "project", "user"]);
|
||||
|
||||
await agent.loadSession({ sessionId, cwd, mcpServers: [], _meta: meta() });
|
||||
await finishTurn(sessionId);
|
||||
expect(await hooksSinceLastCheck()).toEqual(["project", "user"]);
|
||||
|
||||
expect(await readSettingsFiles()).toEqual(settingsFiles);
|
||||
}, 60_000);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user