diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 707cfcb5..6466e750 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -1822,7 +1822,7 @@ export class ClaudeAcpAgent implements Agent { typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined; if (newEffort !== currentEffort) { await session.query.applyFlagSettings({ - effortLevel: newEffort as Settings["effortLevel"], + effortLevel: toSdkEffortLevel(newEffort), }); } @@ -1847,7 +1847,7 @@ export class ClaudeAcpAgent implements Agent { ); if (configId === "effort") { await session.query.applyFlagSettings({ - effortLevel: value as Settings["effortLevel"], + effortLevel: toSdkEffortLevel(value), }); } } @@ -2223,7 +2223,11 @@ export class ClaudeAcpAgent implements Agent { // 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") { + if ( + initialEffort && + typeof initialEffort.currentValue === "string" && + initialEffort.currentValue !== "default" + ) { await q.applyFlagSettings({ effortLevel: initialEffort.currentValue as Settings["effortLevel"], }); @@ -2419,6 +2423,16 @@ function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState return modes; } +// Translate a UI effort value into the flag-layer payload. The SDK +// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport, +// and only clears a key when an explicit `null` is sent — see +// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the +// `"default"` sentinel and `undefined` (effort option absent for the model) to +// `null` ensures any previously-applied flag is actually cleared. +function toSdkEffortLevel(value: string | undefined): Settings["effortLevel"] | null { + return value === undefined || value === "default" ? null : (value as Settings["effortLevel"]); +} + function buildConfigOptions( modes: SessionModeState, models: SessionModelState, @@ -2461,25 +2475,20 @@ function buildConfigOptions( : []; if (supportedLevels.length > 0) { - const effortOptions = supportedLevels.map((level) => ({ - value: level, - name: level - .split(/[_-]/) - .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) - .join(" "), - })); + const effortOptions = [ + { value: "default", name: "Default" }, + ...supportedLevels.map((level) => ({ + value: level, + name: level + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(" "), + })), + ]; - // Keep the current level if valid, otherwise prefer xhigh (Claude Code's - // recommended default for capable models), then high (the API default). - const includes = (l: string) => (supportedLevels as string[]).includes(l); + const includes = (l: string) => l === "default" || (supportedLevels as string[]).includes(l); const validEffort = - currentEffortLevel && includes(currentEffortLevel) - ? currentEffortLevel - : includes("xhigh") - ? "xhigh" - : includes("high") - ? "high" - : supportedLevels[0]; + currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default"; options.push({ id: "effort", diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 78b2ad95..cbad1fd9 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -65,8 +65,9 @@ const MOCK_CONFIG_OPTIONS = [ description: "Available effort levels for this model", type: "select", category: "effort", - currentValue: "high", + currentValue: "default", options: [ + { value: "default", name: "Default" }, { value: "low", name: "Low" }, { value: "medium", name: "Medium" }, { value: "high", name: "High" }, @@ -445,7 +446,7 @@ describe("session config options", () => { (o: any) => o.id === "effort", ); expect(effortOption).toBeUndefined(); - expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined }); + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); }); it("clamps effort in config_option_update when new model has different supported levels", async () => { @@ -483,8 +484,8 @@ describe("session config options", () => { (o: any) => o.id === "effort", ); expect(effortOption).toBeDefined(); - expect(effortOption.currentValue).toBe("high"); - expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" }); + expect(effortOption.currentValue).toBe("default"); + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); }); it("preserves effort in config_option_update when new model supports same level", async () => { @@ -557,6 +558,31 @@ describe("session config options", () => { expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" }); }); + it("calls applyFlagSettings with null effortLevel for 'default'", async () => { + // Set effort to a non-default value 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 = "high"; + + await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: "effort", + value: "default", + }); + + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); + + // The SDK's applyFlagSettings travels over a JSON pipe and only clears a + // flag-layer key when an explicit `null` is sent — `undefined` is + // dropped during JSON.stringify, which would leave the previous effort + // override in place. Round-trip the call args through JSON to make sure + // the key actually reaches the SDK. + const calls = applyFlagSettingsSpy.mock.calls; + const lastCallArgs = calls[calls.length - 1]?.[0]; + const serialized = JSON.parse(JSON.stringify(lastCallArgs)); + expect(serialized).toHaveProperty("effortLevel", null); + }); + it("updates effort currentValue in returned configOptions", async () => { const response = await agent.setSessionConfigOption({ sessionId: SESSION_ID, @@ -663,7 +689,7 @@ describe("session config options", () => { value: "claude-sonnet-4-6", }); - expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined }); + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); }); it("adds effort option when switching to a model that supports effort", async () => { @@ -696,8 +722,8 @@ describe("session config options", () => { const effortOption = response.configOptions.find((o) => o.id === "effort"); expect(effortOption).toBeDefined(); - // No previous effort, so defaults to "high" (the API default) - expect(effortOption?.currentValue).toBe("high"); + // No previous effort, so defaults to "default" (no effort override) + expect(effortOption?.currentValue).toBe("default"); }); it("clamps effort to valid value when new model has different supported levels", async () => { @@ -731,10 +757,10 @@ describe("session config options", () => { const effortOption = response.configOptions.find((o) => o.id === "effort"); expect(effortOption).toBeDefined(); - // "max" is not in sonnet's levels, so should fall back to "high" (the API default) - expect(effortOption?.currentValue).toBe("high"); - // SDK should be told about the clamped value - expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" }); + // "max" is not in sonnet's levels, so should fall back to "default" (no effort override) + expect(effortOption?.currentValue).toBe("default"); + // SDK should be told to clear the effort override + expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null }); }); it("preserves effort value when new model supports the same level", async () => {