feat: Support effort levels (#464)

This commit is contained in:
Ben Brandt
2026-04-20 15:21:41 +02:00
committed by GitHub
parent 3d2fd36de2
commit 9e1185bfd5
4 changed files with 465 additions and 21 deletions

View File

@@ -53,6 +53,7 @@ import {
PermissionMode,
Query,
query,
Settings,
SDKPartialAssistantMessage,
SDKUserMessage,
SlashCommand,
@@ -135,6 +136,7 @@ type Session = {
accumulatedUsage: AccumulatedUsage;
modes: SessionModeState;
models: SessionModelState;
modelInfos: ModelInfo[];
configOptions: SessionConfigOption[];
promptRunning: boolean;
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
@@ -1088,11 +1090,17 @@ export class ClaudeAcpAgent implements Agent {
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
if (!this.sessions[params.sessionId]) {
const session = this.sessions[params.sessionId];
if (!session) {
throw new Error("Session not found");
}
await this.sessions[params.sessionId].query.setModel(params.modelId);
await this.updateConfigOption(params.sessionId, "model", params.modelId);
// Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so
// downstream lookups in modelInfos succeed and the effort option isn't
// silently dropped.
const resolved = resolveModelPreference(session.modelInfos, params.modelId);
const modelId = resolved?.value ?? params.modelId;
await session.query.setModel(modelId);
await this.updateConfigOption(params.sessionId, "model", modelId);
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
@@ -1162,14 +1170,11 @@ export class ClaudeAcpAgent implements Agent {
} else if (params.configId === "model") {
await this.sessions[params.sessionId].query.setModel(resolvedValue);
}
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
// effort changes and effort changes induced by a model switch go through
// the same path.
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 };
}
@@ -1401,11 +1406,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,
@@ -1416,7 +1417,12 @@ export class ClaudeAcpAgent implements Agent {
});
}
private syncSessionConfigState(session: Session, configId: string, value: string): void {
private async applyConfigOptionValue(
session: Session,
configId: string,
value: string,
): Promise<void> {
// Sync top-level session state
if (configId === "mode") {
session.modes = { ...session.modes, currentModeId: value };
} else if (configId === "model") {
@@ -1429,6 +1435,39 @@ export class ClaudeAcpAgent implements Agent {
}
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,
);
if (configId === "effort") {
await session.query.applyFlagSettings({
effortLevel: value as Settings["effortLevel"],
});
}
}
}
private async getOrCreateSession(params: {
@@ -1705,7 +1744,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,
@@ -1722,6 +1774,7 @@ export class ClaudeAcpAgent implements Agent {
},
modes,
models,
modelInfos: initializationResult.models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
@@ -1817,8 +1870,10 @@ function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
function buildConfigOptions(
modes: SessionModeState,
models: SessionModelState,
modelInfos: ModelInfo[],
currentEffortLevel?: string,
): SessionConfigOption[] {
return [
const options: SessionConfigOption[] = [
{
id: "mode",
name: "Mode",
@@ -1846,6 +1901,46 @@ 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
.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 validEffort =
currentEffortLevel && includes(currentEffortLevel)
? currentEffortLevel
: includes("xhigh")
? "xhigh"
: includes("high")
? "high"
: 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,

View File

@@ -22,6 +22,7 @@ export interface ClaudeCodeSettings {
permissions?: PermissionSettings;
env?: Record<string, string>;
model?: string;
effortLevel?: string;
}
/**
@@ -188,6 +189,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,

View File

@@ -1336,6 +1336,7 @@ describe("stop reason propagation", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1478,6 +1479,7 @@ describe("stop reason propagation", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1554,6 +1556,7 @@ describe("session/close", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1649,6 +1652,7 @@ describe("getOrCreateSession param change detection", () => {
}),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1882,6 +1886,7 @@ describe("usage_update computation", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
@@ -2235,7 +2240,7 @@ describe("usage_update computation", () => {
const session = agent.sessions["test-session"];
expect(session.contextWindowSize).toBe(200000);
(agent as any).syncSessionConfigState(session, "model", "claude-opus-4-6-1m");
await (agent as any).applyConfigOptionValue(session, "model", "claude-opus-4-6-1m");
expect(session.contextWindowSize).toBe(1000000);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
@@ -2286,7 +2291,7 @@ describe("usage_update computation", () => {
it("switching the session's model invalidates the learned context window", async () => {
// When the user switches models mid-session, the window learned for the
// previous model would otherwise persist into the next prompt's first
// mid-stream update. syncSessionConfigState should reset it so the next
// mid-stream update. applyConfigOptionValue should reset it so the next
// turn's first update falls back to the heuristic (here: 200k default).
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
@@ -2320,7 +2325,7 @@ describe("usage_update computation", () => {
session.models = { ...session.models, currentModelId: "claude-opus-4-6-1m" };
// User flips the selector to a 200k model.
(agent as any).syncSessionConfigState(session, "model", "claude-sonnet-4-6");
await (agent as any).applyConfigOptionValue(session, "model", "claude-sonnet-4-6");
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
@@ -2770,6 +2775,7 @@ describe("emitRawSDKMessages", () => {
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,

View File

@@ -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<typeof vi.fn>;
let setPermissionModeSpy: ReturnType<typeof vi.fn>;
let setModelSpy: ReturnType<typeof vi.fn>;
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
function createMockClient(): AgentSideConnection {
return {
@@ -82,11 +97,13 @@ describe("session config options", () => {
function populateSession() {
setPermissionModeSpy = vi.fn();
setModelSpy = vi.fn();
applyFlagSettingsSpy = vi.fn();
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
query: {
setPermissionMode: setPermissionModeSpy,
setModel: setModelSpy,
applyFlagSettings: applyFlagSettingsSpy,
supportedCommands: async () => [],
},
input: null,
@@ -95,6 +112,15 @@ describe("session config options", () => {
settingsManager: {},
modes: structuredClone(MOCK_MODES),
models: structuredClone(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),
contextWindowSize: 200000,
};
@@ -387,6 +413,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<string, any> }).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<string, any> }).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("high");
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
});
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<string, any> }).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", () => {
@@ -421,6 +542,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<string, any> }).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<string, any> }).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<string, any> }).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 "high" (the API default)
expect(effortOption?.currentValue).toBe("high");
});
it("clamps effort to valid value when new model has different supported levels", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).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 "high" (the API default)
expect(effortOption?.currentValue).toBe("high");
// SDK should be told about the clamped value
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
});
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();