diff --git a/src/agentMode/backends/claude/descriptor.ts b/src/agentMode/backends/claude/descriptor.ts index 3795f5d4..3b919efa 100644 --- a/src/agentMode/backends/claude/descriptor.ts +++ b/src/agentMode/backends/claude/descriptor.ts @@ -52,7 +52,7 @@ const claudeWire: ModelWireCodec = { provider: "anthropic", }), effortConfigFor: (baseModelId: string): BackendConfigOption | null => { - const catalog = getCachedSdkCatalog(); + const catalog = getCachedSdkCatalog(getSettings().agentMode?.backends?.claude?.envOverrides); if (!catalog) return null; const modelInfo = catalog.find((m) => m.value === baseModelId); if (!modelInfo) return null; diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 35a10b96..7ca046d6 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -2,7 +2,7 @@ import { type App, Platform } from "obsidian"; import type CopilotPlugin from "@/main"; import { logError } from "@/logger"; import { DEFAULT_SKILLS_FOLDER } from "@/constants"; -import { getSettings, subscribeToSettingsChange } from "@/settings/model"; +import { getSettings, subscribeToSettingsChange, type CopilotSettings } from "@/settings/model"; import { subscribeToSystemPromptChange } from "@/system-prompts/state"; import { buildAgentSystemPrompt } from "./backends/shared/agentSystemPrompt"; import { backendRegistry, listBackendDescriptors } from "./backends/registry"; @@ -109,6 +109,19 @@ function backendSystemPromptKey(_backendId: BackendId): string { return buildAgentSystemPrompt(); } +/** + * Order-independent dedup key for a backend's env overrides, so the restart + * subscription fires iff the effective record actually changes (not on key + * reordering or unrelated settings writes). + */ +function backendEnvOverridesKey(settings: CopilotSettings, backendId: BackendId): string { + const backends = settings.agentMode?.backends as + | Partial }>> + | undefined; + const env = backends?.[backendId]?.envOverrides ?? {}; + return JSON.stringify(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))); +} + /** * Single seam between the plugin host (`main.ts`) and Agent Mode. Initialises * the SkillManager singleton, wires the default permission prompter into a @@ -220,6 +233,27 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen } }; subscribeToSystemPromptChange(restartSystemPromptAffected); + // Env overrides are baked into subprocess spawn env, and the Claude SDK + // folds them into its model catalog (a custom `ANTHROPIC_MODEL` becomes a + // picker entry). Either way an edit only reaches the live or warm process on + // a fresh spawn/probe, so restart the backend whose record actually changed. + // The editor debounces commits and the manager/preloader coalesce rapid + // restarts, so a burst of edits folds into one re-probe (same rationale as + // the provider-config subscription above). + subscribeToSettingsChange((prev, next) => { + for (const descriptor of listBackendDescriptors()) { + if ( + backendEnvOverridesKey(prev, descriptor.id) === backendEnvOverridesKey(next, descriptor.id) + ) { + continue; + } + void manager + .restartBackend(descriptor.id, "env overrides changed") + .catch((error) => + logError(`[AgentMode] restart after env overrides change failed: ${descriptor.id}`, error) + ); + } + }); // Seed the plugin-shipped builtin skills into the canonical folder, then run // discovery so the pass picks them up and fans them out to the agent dirs. // The Plus relay skills are always seeded; the Miyo vault-search skill is diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts index 9def039e..498b1936 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts @@ -548,6 +548,43 @@ describe("ClaudeSdkBackendProcess.newSession dynamic catalog", () => { }); }); +describe("ANTHROPIC_MODEL env override reaches the catalog probe", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); + }); + + it("threads the backend's env overrides into the probe on a cold cache", async () => { + // Cold module cache forces a real probe; the SDK reflects ANTHROPIC_MODEL + // into init.models itself, so the env just has to reach the probe. + (getCachedSdkCatalog as jest.Mock).mockReturnValue(undefined); + const initializationResult = jest.fn().mockResolvedValue({ models: FAKE_CATALOG }); + queryMock.mockReturnValue({ + initializationResult, + interrupt: jest.fn().mockResolvedValue(undefined), + }); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getEnvOverrides: () => ({ ANTHROPIC_MODEL: "claude-fable-5" }), + }); + + await proc.newSession({ cwd: "/vault", mcpServers: [] }); + + const probeCall = queryMock.mock.calls[0][0] as { + options: { pathToClaudeCodeExecutable: string; env?: Record }; + }; + expect(probeCall.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude"); + expect(probeCall.options.env?.ANTHROPIC_MODEL).toBe("claude-fable-5"); + // Child env is process.env plus the overrides, not a bare override map. + expect(probeCall.options.env?.PATH).toBe(process.env.PATH); + }); +}); + function errorResultMessage(errors: string[]): SDKMessage { return { type: "result", diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts index 66cc8a79..c022ac3a 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts @@ -600,19 +600,21 @@ export class ClaudeSdkBackendProcess implements BackendProcess { */ private ensureModelCatalog(): Promise { if (this.cachedModels) return Promise.resolve(this.cachedModels); - const fromCache = getCachedSdkCatalog(); + const envOverrides = this.opts.getEnvOverrides?.(); + const fromCache = getCachedSdkCatalog(envOverrides); if (fromCache && fromCache.length > 0) { this.cachedModels = fromCache; return Promise.resolve(fromCache); } if (this.cachedModelsProbe) return this.cachedModelsProbe; - const probePromise = probeClaudeSdkCatalog(this.opts.pathToClaudeCodeExecutable).then( - (models) => { - if (models.length > 0) this.cachedModels = models; - else this.cachedModelsProbe = null; - return models; - } - ); + const probePromise = probeClaudeSdkCatalog( + this.opts.pathToClaudeCodeExecutable, + envOverrides + ).then((models) => { + if (models.length > 0) this.cachedModels = models; + else this.cachedModelsProbe = null; + return models; + }); this.cachedModelsProbe = probePromise; return probePromise; } diff --git a/src/agentMode/sdk/effortOption.test.ts b/src/agentMode/sdk/effortOption.test.ts new file mode 100644 index 00000000..7d117c57 --- /dev/null +++ b/src/agentMode/sdk/effortOption.test.ts @@ -0,0 +1,71 @@ +const queryMock = jest.fn(); +jest.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: (...args: unknown[]) => queryMock(...args), +})); + +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import { getCachedSdkCatalog, probeClaudeSdkCatalog } from "./effortOption"; + +const CATALOG: ModelInfo[] = [{ value: "claude-x", displayName: "Claude X", description: "test" }]; + +function fakeProbe(models: ModelInfo[]) { + return { + initializationResult: jest.fn().mockResolvedValue({ models }), + interrupt: jest.fn().mockResolvedValue(undefined), + }; +} + +beforeEach(() => queryMock.mockReset()); + +describe("probeClaudeSdkCatalog env passing", () => { + it("merges env overrides onto process.env so the CLI can reflect ANTHROPIC_MODEL", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + const models = await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "m-custom" }); + expect(models).toBe(CATALOG); + const opts = ( + queryMock.mock.calls[0][0] as { + options: { pathToClaudeCodeExecutable: string; env?: Record }; + } + ).options; + expect(opts.pathToClaudeCodeExecutable).toBe("/bin/claude"); + expect(opts.env?.ANTHROPIC_MODEL).toBe("m-custom"); + // process.env is preserved (Options.env replaces the child env wholesale). + expect(opts.env?.PATH).toBe(process.env.PATH); + }); + + it("omits options.env entirely when there are no overrides", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + await probeClaudeSdkCatalog("/bin/claude", undefined); + const opts = (queryMock.mock.calls[0][0] as { options: { env?: unknown } }).options; + expect(opts.env).toBeUndefined(); + }); +}); + +describe("getCachedSdkCatalog is scoped to the probe's env overrides", () => { + it("serves the cache only for a matching env key, order-independent", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + await probeClaudeSdkCatalog("/bin/claude", { A: "1", B: "2" }); + expect(getCachedSdkCatalog({ B: "2", A: "1" })).toBe(CATALOG); + expect(getCachedSdkCatalog({ A: "1", B: "9" })).toBeNull(); + expect(getCachedSdkCatalog(undefined)).toBeNull(); + }); + + it("a fresh probe under a new env replaces the cached entry", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "first" }); + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "first" })).toBe(CATALOG); + + const CATALOG2: ModelInfo[] = [{ value: "y", displayName: "Y", description: "t" }]; + queryMock.mockReturnValue(fakeProbe(CATALOG2)); + await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "second" }); + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "second" })).toBe(CATALOG2); + // Single-slot, env-scoped: the prior env no longer hits. + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "first" })).toBeNull(); + }); + + it("leaves the cache unwritten when the probe returns no models", async () => { + queryMock.mockReturnValue(fakeProbe([])); + await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "empty-case" }); + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "empty-case" })).toBeNull(); + }); +}); diff --git a/src/agentMode/sdk/effortOption.ts b/src/agentMode/sdk/effortOption.ts index c7803b11..ec4f5b2a 100644 --- a/src/agentMode/sdk/effortOption.ts +++ b/src/agentMode/sdk/effortOption.ts @@ -3,6 +3,7 @@ import { query, type EffortLevel, type ModelInfo, + type Options, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { logWarn } from "@/logger"; @@ -54,14 +55,31 @@ export function resolveSeedModelId( } /** - * Plugin-lifetime cache of the SDK's model catalog, shared across every - * `ClaudeSdkBackendProcess` instance so opening a chat doesn't re-spawn - * the `claude` CLI to read the model list. + * Order-independent key for the env overrides that influence which models the + * `claude` CLI advertises — chiefly `ANTHROPIC_MODEL`, which the CLI reflects + * into its init handshake. Used to scope the catalog cache so editing the + * override invalidates a stale entry. Mirrors `backendEnvOverridesKey` in + * `agentMode/index.ts`. */ -let cachedSdkCatalog: ModelInfo[] | null = null; +function catalogEnvKey(envOverrides: Record | undefined): string { + const entries = Object.entries(envOverrides ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); +} -export function getCachedSdkCatalog(): ModelInfo[] | null { - return cachedSdkCatalog; +/** + * Plugin-lifetime cache of the SDK's model catalog, shared across every + * `ClaudeSdkBackendProcess` instance so opening a chat doesn't re-spawn the + * `claude` CLI to read the model list. Keyed by the env overrides passed to + * the probe: editing `ANTHROPIC_MODEL` yields a different key, so the stale + * entry is ignored and the next `ensureModelCatalog()` re-probes with the new + * env (the backend is also restarted on the same settings change). + */ +let cachedSdkCatalog: { key: string; models: ModelInfo[] } | null = null; + +export function getCachedSdkCatalog( + envOverrides: Record | undefined +): ModelInfo[] | null { + return cachedSdkCatalog?.key === catalogEnvKey(envOverrides) ? cachedSdkCatalog.models : null; } /** @@ -69,28 +87,41 @@ export function getCachedSdkCatalog(): ModelInfo[] | null { * handshake — which carries the catalog of models the bundled `claude` * CLI advertises (per-model `supportsEffort` + `supportedEffortLevels`). * + * `envOverrides` (the user's Claude env-override box) is merged onto + * `process.env` for the probe. This is what surfaces a custom + * `ANTHROPIC_MODEL`: the CLI reflects the override into `init.models` as a + * first-class entry with full effort metadata, so no synthetic entry is + * needed and there's a single source of truth. + * * The SDK requires streaming-input mode to expose `initializationResult()`, * so we feed it a generator that never yields and tear the query down * via `interrupt()` once the handshake completes. Failures resolve to * an empty array (logged) so callers can degrade gracefully. * - * Successful, non-empty probes update the module-level cache so a later - * `getCachedSdkCatalog()` returns hot data without re-probing. + * Successful, non-empty probes update the module-level cache (keyed by + * `envOverrides`) so a later `getCachedSdkCatalog()` returns hot data without + * re-probing. */ export async function probeClaudeSdkCatalog( - pathToClaudeCodeExecutable: string + pathToClaudeCodeExecutable: string, + envOverrides?: Record ): Promise { // eslint-disable-next-line require-yield const noopPrompt = (async function* (): AsyncIterable { await new Promise(() => {}); })(); - const probe = query({ - prompt: noopPrompt, - options: { pathToClaudeCodeExecutable }, - }); + const options: Options = { pathToClaudeCodeExecutable }; + if (envOverrides && Object.keys(envOverrides).length > 0) { + // Options.env replaces (not merges with) the child env, so include + // process.env to preserve PATH and friends — same as the prompt path. + options.env = { ...process.env, ...envOverrides }; + } + const probe = query({ prompt: noopPrompt, options }); try { const init = await probe.initializationResult(); - if (init.models.length > 0) cachedSdkCatalog = init.models; + if (init.models.length > 0) { + cachedSdkCatalog = { key: catalogEnvKey(envOverrides), models: init.models }; + } return init.models; } catch (e) { logWarn("[AgentMode] Claude SDK init probe failed", e);