feat(claude): add effort setting parity with model across stack (#353)

Co-authored-by: Xiaoyi <xiaoyizhang@microsoft.com>
This commit is contained in:
xyzhang626
2026-03-24 21:15:48 +08:00
committed by GitHub
co-authored by Xiaoyi
parent 30265fdc25
commit a200fe9628
50 changed files with 715 additions and 52 deletions
+18 -4
View File
@@ -1,6 +1,6 @@
import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import type { Metadata, SessionCollaborationMode, SessionModel, SessionPermissionMode } from '@/api/types';
import type { Metadata, SessionCollaborationMode, SessionEffort, SessionModel, SessionPermissionMode } from '@/api/types';
import { logger } from '@/ui/logger';
export type AgentSessionBaseOptions<Mode> = {
@@ -17,6 +17,7 @@ export type AgentSessionBaseOptions<Mode> = {
applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata;
permissionMode?: SessionPermissionMode;
model?: SessionModel;
effort?: SessionEffort;
collaborationMode?: SessionCollaborationMode;
};
@@ -39,6 +40,7 @@ export class AgentSessionBase<Mode> {
private keepAliveInterval: NodeJS.Timeout | null = null;
protected permissionMode?: SessionPermissionMode;
protected model?: SessionModel;
protected effort?: SessionEffort;
protected collaborationMode?: SessionCollaborationMode;
constructor(opts: AgentSessionBaseOptions<Mode>) {
@@ -55,6 +57,7 @@ export class AgentSessionBase<Mode> {
this.mode = opts.mode ?? 'local';
this.permissionMode = opts.permissionMode;
this.model = opts.model;
this.effort = opts.effort;
this.collaborationMode = opts.collaborationMode;
this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime());
@@ -74,10 +77,11 @@ export class AgentSessionBase<Mode> {
this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime());
const permissionLabel = this.permissionMode ?? 'unset';
const modelLabel = this.model === undefined ? 'unset' : (this.model ?? 'auto');
const effortLabel = this.effort === undefined ? 'unset' : (this.effort ?? 'auto');
const collaborationLabel = this.collaborationMode ?? 'unset';
logger.debug(
`[${this.sessionLabel}] Mode switched to ${mode} ` +
`(permissionMode=${permissionLabel}, model=${modelLabel}, collaborationMode=${collaborationLabel})`
`(permissionMode=${permissionLabel}, model=${modelLabel}, effort=${effortLabel}, collaborationMode=${collaborationLabel})`
);
this._onModeChange(mode);
};
@@ -111,13 +115,19 @@ export class AgentSessionBase<Mode> {
};
protected getKeepAliveRuntime():
{ permissionMode?: SessionPermissionMode; model?: SessionModel; collaborationMode?: SessionCollaborationMode } | undefined {
if (this.permissionMode === undefined && this.model === undefined && this.collaborationMode === undefined) {
{
permissionMode?: SessionPermissionMode
model?: SessionModel
effort?: SessionEffort
collaborationMode?: SessionCollaborationMode
} | undefined {
if (this.permissionMode === undefined && this.model === undefined && this.effort === undefined && this.collaborationMode === undefined) {
return undefined;
}
return {
permissionMode: this.permissionMode,
model: this.model,
effort: this.effort,
collaborationMode: this.collaborationMode
};
}
@@ -130,6 +140,10 @@ export class AgentSessionBase<Mode> {
return this.model;
}
getEffort(): SessionEffort | undefined {
return this.effort;
}
getCollaborationMode(): SessionCollaborationMode | undefined {
return this.collaborationMode;
}
+3 -1
View File
@@ -23,6 +23,7 @@ export type SessionBootstrapOptions = {
tag?: string
agentState?: AgentState | null
model?: string
effort?: string
metadataOverrides?: Partial<Metadata>
}
@@ -131,7 +132,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis
tag: sessionTag,
metadata,
state: agentState,
model: options.model
model: options.model,
effort: options.effort
})
const session = api.sessionSyncClient(sessionInfo)
+6 -2
View File
@@ -19,6 +19,7 @@ export class ApiClient {
metadata: Metadata
state: AgentState | null
model?: string
effort?: string
}): Promise<Session> {
const response = await axios.post<CreateSessionResponse>(
`${configuration.apiUrl}/cli/sessions`,
@@ -26,7 +27,8 @@ export class ApiClient {
tag: opts.tag,
metadata: opts.metadata,
agentState: opts.state,
model: opts.model
model: opts.model,
effort: opts.effort
},
{
headers: {
@@ -72,7 +74,9 @@ export class ApiClient {
thinkingAt: raw.thinkingAt,
todos: raw.todos,
model: raw.model,
permissionMode: raw.permissionMode
effort: raw.effort,
permissionMode: raw.permissionMode,
collaborationMode: raw.collaborationMode
}
}
+2 -1
View File
@@ -102,7 +102,7 @@ export class ApiMachineClient {
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => {
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {}
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {}
if (!directory) {
throw new Error('Directory is required')
@@ -116,6 +116,7 @@ export class ApiMachineClient {
approvedNewDirectoryCreation,
agent,
model,
effort,
modelReasoningEffort,
yolo,
token,
+1
View File
@@ -443,6 +443,7 @@ export class ApiSessionClient extends EventEmitter {
runtime?: {
permissionMode?: SessionPermissionMode
model?: SessionModel
effort?: string | null
collaborationMode?: SessionCollaborationMode
}
): void {
+2
View File
@@ -24,6 +24,7 @@ export type {
export type SessionPermissionMode = PermissionMode
export type SessionCollaborationMode = CodexCollaborationMode
export type SessionModel = string | null
export type SessionEffort = string | null
export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema }
@@ -99,6 +100,7 @@ export const CreateSessionResponseSchema = z.object({
thinkingAt: z.number(),
todos: TodosSchema.optional(),
model: z.string().nullable(),
effort: z.string().nullable(),
permissionMode: PermissionModeSchema.optional(),
collaborationMode: CodexCollaborationModeSchema.optional()
})
+1
View File
@@ -125,6 +125,7 @@ export async function claudeRemote(opts: {
mcpServers: opts.mcpServers,
permissionMode: initial.mode.permissionMode,
model: initial.mode.model,
effort: initial.mode.effort,
fallbackModel: initial.mode.fallbackModel,
customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + systemPrompt : undefined,
appendSystemPrompt: initial.mode.appendSystemPrompt ? initial.mode.appendSystemPrompt + '\n\n' + systemPrompt : systemPrompt,
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { normalizeClaudeSessionEffort } from './effort'
describe('normalizeClaudeSessionEffort', () => {
it('returns null when effort is missing', () => {
expect(normalizeClaudeSessionEffort()).toBeNull()
})
it('returns null for auto-like values', () => {
expect(normalizeClaudeSessionEffort('')).toBeNull()
expect(normalizeClaudeSessionEffort('auto')).toBeNull()
expect(normalizeClaudeSessionEffort('default')).toBeNull()
expect(normalizeClaudeSessionEffort(' AUTO ')).toBeNull()
})
it('normalizes supported effort values', () => {
expect(normalizeClaudeSessionEffort('medium')).toBe('medium')
expect(normalizeClaudeSessionEffort('high')).toBe('high')
expect(normalizeClaudeSessionEffort('max')).toBe('max')
expect(normalizeClaudeSessionEffort(' High ')).toBe('high')
})
})
+10
View File
@@ -0,0 +1,10 @@
import type { SessionEffort } from '@/api/types'
export function normalizeClaudeSessionEffort(effort?: string | null): SessionEffort {
const trimmedEffort = effort?.trim().toLowerCase()
if (!trimmedEffort || trimmedEffort === 'auto' || trimmedEffort === 'default') {
return null
}
return trimmedEffort
}
+5 -2
View File
@@ -6,7 +6,7 @@ import { Session } from "./session"
import { claudeLocalLauncher } from "./claudeLocalLauncher"
import { claudeRemoteLauncher } from "./claudeRemoteLauncher"
import { ApiClient } from "@/lib"
import type { SessionModel } from "@/api/types"
import type { SessionEffort, SessionModel } from "@/api/types"
import type { ClaudePermissionMode } from "@hapi/protocol/types"
export type PermissionMode = ClaudePermissionMode;
@@ -14,6 +14,7 @@ export type PermissionMode = ClaudePermissionMode;
export interface EnhancedMode {
permissionMode: PermissionMode;
model?: string;
effort?: string;
fallbackModel?: string;
customSystemPrompt?: string;
appendSystemPrompt?: string;
@@ -24,6 +25,7 @@ export interface EnhancedMode {
interface LoopOptions {
path: string
model?: SessionModel
effort?: SessionEffort
permissionMode?: PermissionMode
startingMode?: 'local' | 'remote'
startedBy?: 'runner' | 'terminal'
@@ -62,7 +64,8 @@ export async function loop(opts: LoopOptions) {
startingMode,
hookSettingsPath: opts.hookSettingsPath,
permissionMode: opts.permissionMode ?? 'default',
model: opts.model
model: opts.model,
effort: opts.effort
});
await runLocalRemoteSession({
+38 -6
View File
@@ -1,6 +1,6 @@
import { logger } from '@/ui/logger';
import { loop } from '@/claude/loop';
import { AgentState, SessionModel } from '@/api/types';
import { AgentState, SessionEffort, SessionModel } from '@/api/types';
import { EnhancedMode, PermissionMode } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
@@ -18,10 +18,12 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { PermissionModeSchema } from '@hapi/protocol/schemas';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { normalizeClaudeSessionModel } from './model';
import { normalizeClaudeSessionEffort } from './effort';
import { getInvokedCwd } from '@/utils/invokedCwd';
export interface StartOptions {
model?: string
effort?: string
permissionMode?: PermissionMode
startingMode?: 'local' | 'remote'
shouldStartRunner?: boolean
@@ -48,12 +50,14 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
const initialState: AgentState = {};
const initialModel = normalizeClaudeSessionModel(options.model);
const initialEffort = normalizeClaudeSessionEffort(options.effort);
const { api, session, sessionInfo } = await bootstrapSession({
flavor: 'claude',
startedBy,
workingDirectory,
agentState: initialState,
model: initialModel ?? undefined
model: initialModel ?? undefined,
effort: initialEffort ?? undefined
});
logger.debug(`Session created: ${sessionInfo.id}`);
@@ -138,6 +142,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
const messageQueue = new MessageQueue2<EnhancedMode>(mode => hashObject({
isPlan: mode.permissionMode === 'plan',
model: mode.model,
effort: mode.effort,
fallbackModel: mode.fallbackModel,
customSystemPrompt: mode.customSystemPrompt,
appendSystemPrompt: mode.appendSystemPrompt,
@@ -148,6 +153,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
// Forward messages to the queue
let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default';
let currentModel: SessionModel = initialModel;
let currentEffort: SessionEffort = initialEffort;
let currentFallbackModel: string | undefined = undefined; // Track current fallback model
let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt
let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt
@@ -161,7 +167,8 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
}
sessionInstance.setPermissionMode(currentPermissionMode);
sessionInstance.setModel(currentModel);
logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}`);
sessionInstance.setEffort(currentEffort);
logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, effort=${currentEffort ?? 'auto'}`);
};
session.onUserMessage((message) => {
const sessionPermissionMode = currentSessionRef.current?.getPermissionMode();
@@ -172,9 +179,14 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
if (sessionModel !== undefined) {
currentModel = sessionModel;
}
const sessionEffort = currentSessionRef.current?.getEffort();
if (sessionEffort !== undefined) {
currentEffort = sessionEffort;
}
const messagePermissionMode = currentPermissionMode;
const messageModel = currentModel ?? undefined;
logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, model: ${currentModel ?? 'auto'}`);
const messageEffort = currentEffort ?? undefined;
logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, model: ${currentModel ?? 'auto'}, effort: ${currentEffort ?? 'auto'}`);
// Resolve custom system prompt - use message.meta.customSystemPrompt if provided, otherwise use current
let messageCustomSystemPrompt = currentCustomSystemPrompt;
@@ -237,6 +249,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: messageModel,
effort: messageEffort,
fallbackModel: messageFallbackModel,
customSystemPrompt: messageCustomSystemPrompt,
appendSystemPrompt: messageAppendSystemPrompt,
@@ -255,6 +268,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: messageModel,
effort: messageEffort,
fallbackModel: messageFallbackModel,
customSystemPrompt: messageCustomSystemPrompt,
appendSystemPrompt: messageAppendSystemPrompt,
@@ -272,6 +286,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: messageModel,
effort: messageEffort,
fallbackModel: messageFallbackModel,
customSystemPrompt: messageCustomSystemPrompt,
appendSystemPrompt: messageAppendSystemPrompt,
@@ -302,11 +317,23 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
return normalizeClaudeSessionModel(value);
};
const resolveEffort = (value: unknown): SessionEffort => {
if (value === null) {
return null;
}
if (typeof value !== 'string') {
throw new Error('Invalid effort');
}
return normalizeClaudeSessionEffort(value);
};
session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => {
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid session config payload');
}
const config = payload as { permissionMode?: unknown; model?: unknown };
const config = payload as { permissionMode?: unknown; model?: unknown; effort?: unknown };
if (config.permissionMode !== undefined) {
currentPermissionMode = resolvePermissionMode(config.permissionMode);
@@ -316,8 +343,12 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
currentModel = resolveModel(config.model);
}
if (config.effort !== undefined) {
currentEffort = resolveEffort(config.effort);
}
syncSessionModes();
return { applied: { permissionMode: currentPermissionMode, model: currentModel } };
return { applied: { permissionMode: currentPermissionMode, model: currentModel, effort: currentEffort } };
});
let loopError: unknown = null;
@@ -326,6 +357,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
await loop({
path: workingDirectory,
model: currentModel,
effort: currentEffort,
permissionMode: options.permissionMode,
startingMode,
messageQueue,
+2
View File
@@ -280,6 +280,7 @@ export function query(config: {
continue: continueConversation,
resume,
model,
effort,
fallbackModel,
settingsPath,
strictMcpConfig,
@@ -300,6 +301,7 @@ export function query(config: {
if (appendSystemPrompt) args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(appendSystemPrompt))
if (maxTurns) args.push('--max-turns', maxTurns.toString())
if (model) args.push('--model', model)
if (effort) args.push('--effort', effort)
if (canCallTool) {
if (typeof prompt === 'string') {
throw new Error('canCallTool callback requires --input-format stream-json. Please set prompt as an AsyncIterable.')
+1
View File
@@ -170,6 +170,7 @@ export interface QueryOptions {
continue?: boolean
resume?: string
model?: string
effort?: string
fallbackModel?: string
settingsPath?: string
strictMcpConfig?: boolean
+9 -2
View File
@@ -2,7 +2,7 @@ import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger';
import { AgentSessionBase } from '@/agent/sessionBase';
import type { SessionModel } from '@/api/types';
import type { SessionEffort, SessionModel } from '@/api/types';
import type { EnhancedMode } from './loop';
import type { PermissionMode } from './loop';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
@@ -40,6 +40,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
hookSettingsPath: string;
permissionMode?: PermissionMode;
model?: SessionModel;
effort?: SessionEffort;
}) {
super({
api: opts.api,
@@ -57,7 +58,8 @@ export class Session extends AgentSessionBase<EnhancedMode> {
claudeSessionId: sessionId
}),
permissionMode: opts.permissionMode,
model: opts.model
model: opts.model,
effort: opts.effort
});
this.claudeEnvVars = opts.claudeEnvVars;
@@ -69,6 +71,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
this.startingMode = opts.startingMode;
this.permissionMode = opts.permissionMode;
this.model = opts.model;
this.effort = opts.effort;
}
setPermissionMode = (mode: PermissionMode): void => {
@@ -79,6 +82,10 @@ export class Session extends AgentSessionBase<EnhancedMode> {
this.model = model;
};
setEffort = (effort: SessionEffort): void => {
this.effort = effort;
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
+7
View File
@@ -49,6 +49,13 @@ export const claudeCommand: CommandDefinition = {
}
options.model = model
unknownArgs.push('--model', model)
} else if (arg === '--effort') {
const effort = args[++i]
if (!effort) {
throw new Error('Missing --effort value')
}
options.effort = effort
unknownArgs.push('--effort', effort)
} else if (arg === '--started-by') {
options.startedBy = args[++i] as 'runner' | 'terminal'
} else {
+1
View File
@@ -6,6 +6,7 @@ export interface SpawnSessionOptions {
approvedNewDirectoryCreation?: boolean
agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
model?: string
effort?: string
modelReasoningEffort?: string
yolo?: boolean
token?: string
+3
View File
@@ -363,6 +363,9 @@ export async function startRunner(): Promise<void> {
if (options.model && agent !== 'opencode') {
args.push('--model', options.model);
}
if (options.effort && agent === 'claude') {
args.push('--effort', options.effort);
}
if (options.modelReasoningEffort && agent === 'codex') {
args.push('--model-reasoning-effort', options.modelReasoningEffort);
}