diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 259fbde..dffc9b5 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -51,6 +51,7 @@ import { PermissionMode, Query, query, + Settings, SDKPartialAssistantMessage, SDKResultMessage, SDKUserMessage, @@ -116,6 +117,7 @@ type Session = { accumulatedUsage: AccumulatedUsage; modes: SessionModeState; models: SessionModelState; + modelInfos: ModelInfo[]; configOptions: SessionConfigOption[]; promptRunning: boolean; pendingMessages: Map void; order: number }>; @@ -957,15 +959,13 @@ export class ClaudeAcpAgent implements Agent { }); } else if (params.configId === "model") { await this.sessions[params.sessionId].query.setModel(resolvedValue); + } else if (params.configId === "effort") { + await this.sessions[params.sessionId].query.applyFlagSettings({ + effortLevel: resolvedValue as Settings["effortLevel"], + }); } - this.syncSessionConfigState(session, params.configId, params.value); - - session.configOptions = session.configOptions.map((o) => - o.id === params.configId && typeof o.currentValue === "string" - ? { ...o, currentValue: resolvedValue } - : o, - ); + await this.applyConfigOptionValue(session, params.configId, resolvedValue); return { configOptions: session.configOptions }; } @@ -1194,11 +1194,7 @@ export class ClaudeAcpAgent implements Agent { const session = this.sessions[sessionId]; if (!session) return; - this.syncSessionConfigState(session, configId, value); - - session.configOptions = session.configOptions.map((o) => - o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, - ); + await this.applyConfigOptionValue(session, configId, value); await this.client.sessionUpdate({ sessionId, @@ -1209,12 +1205,45 @@ export class ClaudeAcpAgent implements Agent { }); } - private syncSessionConfigState(session: Session, configId: string, value: string): void { + private async applyConfigOptionValue( + session: Session, + configId: string, + value: string, + ): Promise { + // Sync top-level session state if (configId === "mode") { session.modes = { ...session.modes, currentModeId: value }; } else if (configId === "model") { session.models = { ...session.models, currentModelId: value }; } + + // Update configOptions + if (configId === "model") { + // Rebuild config options since effort levels depend on the selected model + const effortOpt = session.configOptions.find((o) => o.id === "effort"); + const currentEffort = + typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined; + session.configOptions = buildConfigOptions( + session.modes, + session.models, + session.modelInfos, + currentEffort, + ); + + // Sync effort with the SDK if it changed after the model switch + const newEffortOpt = session.configOptions.find((o) => o.id === "effort"); + const newEffort = + typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined; + if (newEffort !== currentEffort) { + await session.query.applyFlagSettings({ + effortLevel: newEffort as Settings["effortLevel"], + }); + } + } else { + session.configOptions = session.configOptions.map((o) => + o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, + ); + } } private async getOrCreateSession(params: { @@ -1482,7 +1511,20 @@ export class ClaudeAcpAgent implements Agent { availableModes, }; - const configOptions = buildConfigOptions(modes, models); + const configOptions = buildConfigOptions( + modes, + models, + initializationResult.models, + settingsManager.getSettings().effortLevel, + ); + + // Apply the initial effort level to the SDK so it matches the UI default + const initialEffort = configOptions.find((o) => o.id === "effort"); + if (initialEffort && typeof initialEffort.currentValue === "string") { + await q.applyFlagSettings({ + effortLevel: initialEffort.currentValue as Settings["effortLevel"], + }); + } this.sessions[sessionId] = { query: q, @@ -1498,6 +1540,7 @@ export class ClaudeAcpAgent implements Agent { }, modes, models, + modelInfos: initializationResult.models, configOptions, promptRunning: false, pendingMessages: new Map(), @@ -1544,8 +1587,10 @@ function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) { function buildConfigOptions( modes: SessionModeState, models: SessionModelState, + modelInfos: ModelInfo[], + currentEffortLevel?: string, ): SessionConfigOption[] { - return [ + const options: SessionConfigOption[] = [ { id: "mode", name: "Mode", @@ -1573,6 +1618,40 @@ function buildConfigOptions( })), }, ]; + + // Add effort level option based on the currently selected model + const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId); + const supportedLevels = currentModelInfo?.supportsEffort + ? (currentModelInfo.supportedEffortLevels ?? []) + : []; + + if (supportedLevels.length > 0) { + const effortOptions = supportedLevels.map((level) => ({ + value: level, + name: level.charAt(0).toUpperCase() + level.slice(1), + })); + + // Keep the current level if valid, otherwise prefer a sensible default + const includes = (l: string) => (supportedLevels as string[]).includes(l); + const validEffort = + currentEffortLevel && includes(currentEffortLevel) + ? currentEffortLevel + : includes("medium") + ? "medium" + : supportedLevels[0]; + + options.push({ + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + category: "effort", + type: "select", + currentValue: validEffort, + options: effortOptions, + }); + } + + return options; } // Claude Code CLI persists display strings like "opus[1m]" in settings, diff --git a/src/settings.ts b/src/settings.ts index 45200ae..b67fb4c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -22,6 +22,7 @@ export interface ClaudeCodeSettings { permissions?: PermissionSettings; env?: Record; model?: string; + effortLevel?: string; } /** @@ -168,6 +169,10 @@ export class SettingsManager { merged.model = settings.model; } + if (settings.effortLevel !== undefined) { + merged.effortLevel = settings.effortLevel; + } + if (settings.permissions?.defaultMode !== undefined) { merged.permissions = { ...merged.permissions, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index cc04183..6538866 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1336,6 +1336,7 @@ describe("stop reason propagation", () => { currentModelId: "default", availableModels: [], }, + modelInfos: [], settingsManager: { dispose: vi.fn() } as any, accumulatedUsage: { inputTokens: 0, @@ -1472,6 +1473,7 @@ describe("stop reason propagation", () => { currentModelId: "default", availableModels: [], }, + modelInfos: [], settingsManager: { dispose: vi.fn() } as any, accumulatedUsage: { inputTokens: 0, @@ -1545,6 +1547,7 @@ describe("session/close", () => { currentModelId: "default", availableModels: [], }, + modelInfos: [], settingsManager: { dispose: vi.fn() } as any, accumulatedUsage: { inputTokens: 0, diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 35f75f9..572f6bc 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; const { registerHookCallbackSpy } = vi.hoisted(() => ({ @@ -58,6 +59,19 @@ const MOCK_CONFIG_OPTIONS = [ description: m.description, })), }, + { + id: "effort", + name: "Effort", + description: "Available effort levels for this model", + type: "select", + category: "effort", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, ]; describe("session config options", () => { @@ -67,6 +81,7 @@ describe("session config options", () => { let createSessionSpy: ReturnType; let setPermissionModeSpy: ReturnType; let setModelSpy: ReturnType; + let applyFlagSettingsSpy: ReturnType; function createMockClient(): AgentSideConnection { return { @@ -82,17 +97,30 @@ describe("session config options", () => { function populateSession() { setPermissionModeSpy = vi.fn(); setModelSpy = vi.fn(); + applyFlagSettingsSpy = vi.fn(); (agent as unknown as { sessions: Record }).sessions[SESSION_ID] = { query: { setPermissionMode: setPermissionModeSpy, setModel: setModelSpy, + applyFlagSettings: applyFlagSettingsSpy, supportedCommands: async () => [], }, input: null, cancelled: false, permissionMode: "default", settingsManager: {}, + modes: MOCK_MODES, + models: MOCK_MODELS, + modelInfos: MOCK_MODELS.availableModels.map( + (m): ModelInfo => ({ + value: m.modelId, + displayName: m.name, + description: m.description, + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }), + ), configOptions: structuredClone(MOCK_CONFIG_OPTIONS), }; } @@ -384,6 +412,101 @@ describe("session config options", () => { const modelOption = session.configOptions.find((o) => o.id === "model"); expect(modelOption?.currentValue).toBe("claude-sonnet-4-5"); }); + + it("includes updated effort in config_option_update when model drops effort support", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-5", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-5", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdate).toBeDefined(); + const effortOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "effort", + ); + expect(effortOption).toBeUndefined(); + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined }); + }); + + it("clamps effort in config_option_update when new model has different supported levels", async () => { + // Set current effort to "max" which the new model won't support + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); + if (effortOpt) effortOpt.currentValue = "max"; + + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + }, + { + value: "claude-sonnet-4-5", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + ]; + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-5", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + const effortOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "effort", + ); + expect(effortOption).toBeDefined(); + expect(effortOption.currentValue).toBe("medium"); + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "medium" }); + }); + + it("preserves effort in config_option_update when new model supports same level", async () => { + // Set effort to "low" first + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); + if (effortOpt) effortOpt.currentValue = "low"; + + await agent.unstable_setSessionModel({ + sessionId: SESSION_ID, + modelId: "claude-sonnet-4-5", + }); + + const configUpdate = sessionUpdates.find( + (n) => n.update.sessionUpdate === "config_option_update", + ); + const effortOption = (configUpdate?.update as any).configOptions.find( + (o: any) => o.id === "effort", + ); + expect(effortOption?.currentValue).toBe("low"); + // Effort didn't change, so applyFlagSettings should NOT be called + expect(applyFlagSettingsSpy).not.toHaveBeenCalled(); + }); }); describe("no config_option_update notification when using setSessionConfigOption", () => { @@ -418,6 +541,223 @@ describe("session config options", () => { }); }); + describe("setSessionConfigOption for effort", () => { + beforeEach(() => { + populateSession(); + }); + + it("calls applyFlagSettings with effortLevel", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" }); + }); + + it("updates effort currentValue in returned configOptions", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "medium", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption?.currentValue).toBe("medium"); + }); + + it("throws for invalid effort value", async () => { + await expect( + agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "turbo", + }), + ).rejects.toThrow("Invalid value for config option effort: turbo"); + }); + + it("does not send config_option_update notification", async () => { + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + const configUpdates = sessionUpdates.filter( + (n) => n.update.sessionUpdate === "config_option_update", + ); + expect(configUpdates).toHaveLength(0); + }); + + it("other options are unchanged when effort is updated", async () => { + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + const modeOption = response.configOptions.find((o) => o.id === "mode"); + expect(modeOption?.currentValue).toBe("default"); + const modelOption = response.configOptions.find((o) => o.id === "model"); + expect(modelOption?.currentValue).toBe("claude-opus-4-5"); + }); + }); + + describe("effort level and model switch interactions", () => { + beforeEach(() => { + populateSession(); + }); + + it("drops effort option when switching to a model without effort support", async () => { + // Make sonnet not support effort + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-5", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-5", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption).toBeUndefined(); + }); + + it("clears effort via applyFlagSettings when switching to a model without effort", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-5", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-5", + }); + + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined }); + }); + + it("adds effort option when switching to a model that supports effort", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + // Start with sonnet (no effort) as current + session.models = { ...session.models, currentModelId: "claude-sonnet-4-5" }; + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-4-5", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: false, + }, + ]; + // Remove effort from current config options + session.configOptions = session.configOptions.filter((o: any) => o.id !== "effort"); + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-opus-4-5", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption).toBeDefined(); + // No previous effort, so defaults to "medium" + expect(effortOption?.currentValue).toBe("medium"); + }); + + it("clamps effort to valid value when new model has different supported levels", async () => { + const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; + // Set current effort to "max" (not supported by sonnet in our mock) + const effortOpt = session.configOptions.find((o: any) => o.id === "effort"); + if (effortOpt) effortOpt.currentValue = "max"; + + session.modelInfos = [ + { + value: "claude-opus-4-5", + displayName: "Claude Opus", + description: "Most capable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "max"], + }, + { + value: "claude-sonnet-4-5", + displayName: "Claude Sonnet", + description: "Balanced", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + ]; + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-5", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption).toBeDefined(); + // "max" is not in sonnet's levels, so should fall back to "medium" + expect(effortOption?.currentValue).toBe("medium"); + // SDK should be told about the clamped value + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "medium" }); + }); + + it("preserves effort value when new model supports the same level", async () => { + // Set effort to "low" + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "low", + }); + + // Switch model — both support "low" + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "model", + value: "claude-sonnet-4-5", + }); + + const effortOption = response.configOptions.find((o) => o.id === "effort"); + expect(effortOption?.currentValue).toBe("low"); + // applyFlagSettings was called once for the effort change, but not again for the model switch + expect(applyFlagSettingsSpy).toHaveBeenCalledTimes(1); + }); + }); + describe("bidirectional consistency", () => { beforeEach(() => { populateSession();