diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index fef31ccc4..be905bf9e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -510,6 +510,98 @@ describe("ClaudeAgentClient binary resolution", () => { }); }); +describe("ClaudeAgentSession features", () => { + const logger = createTestLogger(); + + function createQueryMock() { + const queryReturn = vi.fn(async () => undefined); + const queryMock = { + close: vi.fn(), + return: queryReturn, + applyFlagSettings: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + }; + const queryFactory = vi.fn(() => queryMock); + return { queryFactory, queryMock }; + } + + test("lists fast mode only for supported Opus models", async () => { + const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); + + await expect( + client.listFeatures({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-4-8", + }), + ).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]); + + await expect( + client.listFeatures({ + provider: "claude", + cwd: process.cwd(), + model: "claude-sonnet-4-6", + }), + ).resolves.toEqual([]); + }); + + test("passes initial fast mode through Claude flag settings", async () => { + const { queryFactory, queryMock } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-4-8", + featureValues: { fast_mode: true }, + }); + + await expect( + ( + session as unknown as { + ensureQuery(): Promise; + } + ).ensureQuery(), + ).resolves.toBeDefined(); + + expect(queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({ fastMode: true }); + expect(queryMock.applyFlagSettings).toHaveBeenCalledWith({ fastMode: true }); + + await session.close(); + }); + + test("toggles fast mode on the active query without restarting it", async () => { + const { queryFactory, queryMock } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-4-8", + }); + + await ( + session as unknown as { + ensureQuery(): Promise; + } + ).ensureQuery(); + await session.setFeature?.("fast_mode", true); + + expect(queryFactory).toHaveBeenCalledTimes(1); + expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true }); + expect(queryMock.close).not.toHaveBeenCalled(); + expect(queryMock.return).not.toHaveBeenCalled(); + + await session.close(); + }); +}); + describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { test("maps frontend header-keyed answers to Claude question text keys", () => { expect( diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 4538af2a1..d35aa1bd1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -32,6 +32,7 @@ import { import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./models.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js"; import { buildBinaryDiagnosticRows, formatDiagnosticStatus, @@ -51,6 +52,7 @@ import { type AgentCapabilityFlags, type AgentClient, type AgentCreateSessionOptions, + type AgentFeature, type AgentLaunchContext, type AgentMetadata, type AgentMode, @@ -361,6 +363,7 @@ interface ClaudeOptionsLogSummary { hasStderrHandler: boolean; pathToClaudeCodeExecutable: string | null; persistSession: boolean | null; + fastMode: boolean | null; } const MAX_RECENT_STDERR_CHARS = 4000; @@ -409,9 +412,27 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS ? options.pathToClaudeCodeExecutable : null, persistSession: typeof options.persistSession === "boolean" ? options.persistSession : null, + fastMode: readClaudeFastModeSetting(options.settings), }; } +function readClaudeFastModeSetting(settings: ClaudeOptions["settings"]): boolean | null { + if (!settings || typeof settings === "string") { + return null; + } + return typeof settings.fastMode === "boolean" ? settings.fastMode : null; +} + +function mergeClaudeSettings( + settings: ClaudeOptions["settings"], + updates: NonNullable>, +): ClaudeOptions["settings"] { + if (!settings || typeof settings === "string") { + return settings ?? updates; + } + return { ...settings, ...updates }; +} + function isToolResultTextBlock(value: unknown): value is { type: "text"; text: string } { return ( !!value && @@ -1319,6 +1340,14 @@ export class ClaudeAgentClient implements AgentClient { return await getClaudeModelsWithSettings(this.logger); } + async listFeatures(config: AgentSessionConfig): Promise { + const claudeConfig = this.assertConfig(config); + return buildClaudeFeatures({ + modelId: claudeConfig.model, + fastModeEnabled: claudeConfig.featureValues?.fast_mode === true, + }); + } + async listPersistedAgents( options?: ListPersistedAgentsOptions, ): Promise { @@ -1629,6 +1658,13 @@ class ClaudeAgentSession implements AgentSession { return this.claudeSessionId; } + get features(): AgentFeature[] { + return buildClaudeFeatures({ + modelId: this.config.model, + fastModeEnabled: this.config.featureValues?.fast_mode === true, + }); + } + async getRuntimeInfo(): Promise { if (this.cachedRuntimeInfo) { return { ...this.cachedRuntimeInfo }; @@ -1829,6 +1865,9 @@ class ClaudeAgentSession implements AgentSession { const activeQuery = await this.ensureQuery(); await activeQuery.setModel(normalizedModelId ?? undefined); this.config.model = normalizedModelId ?? undefined; + if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) { + await this.applyFastModeFeature(false, activeQuery); + } this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel; this.lastRuntimeModel = null; this.cachedRuntimeInfo = null; @@ -1852,6 +1891,33 @@ class ClaudeAgentSession implements AgentSession { this.queryRestartNeeded = true; } + async setFeature(featureId: string, value: unknown): Promise { + if (featureId !== "fast_mode") { + throw new Error(`Unknown Claude feature: ${featureId}`); + } + + const enabled = Boolean(value); + if (enabled && !claudeModelSupportsFastMode(this.config.model)) { + throw new Error( + `Claude fast mode is not available for model '${this.config.model ?? "default"}'`, + ); + } + + await this.applyFastModeFeature(enabled); + } + + private async applyFastModeFeature(enabled: boolean, query?: Query): Promise { + this.config.featureValues = { + ...this.config.featureValues, + fast_mode: enabled, + }; + const activeQuery = query ?? this.query; + if (activeQuery) { + await activeQuery.applyFlagSettings({ fastMode: enabled }); + } + this.cachedRuntimeInfo = null; + } + getPendingPermissions(): AgentPermissionRequest[] { return Array.from(this.pendingPermissions.values()).map((entry) => entry.request); } @@ -2389,6 +2455,10 @@ class ClaudeAgentSession implements AgentSession { queryFactory: this.queryFactory, }, ); + const fastMode = this.resolveFastModeSetting(); + if (fastMode !== null) { + await this.query.applyFlagSettings({ fastMode }); + } // Do not kick off background control-plane queries here. Methods like // supportedCommands()/setPermissionMode() may execute immediately after // ensureQuery() (for listCommands()/setMode()), and sharing the same query @@ -2482,6 +2552,7 @@ class ClaudeAgentSession implements AgentSession { const { thinking, effort } = this.resolveThinkingConfig(); const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const extraClaudeOptions = this.config.extra?.claude; + const fastModeOptions = this.buildFastModeOptions(extraClaudeOptions); const sdkEnv = this.buildSdkEnv(extraClaudeOptions); assertClaudeAutoModeEligible(this.currentMode, sdkEnv); @@ -2534,6 +2605,7 @@ class ClaudeAgentSession implements AgentSession { ...(thinking ? { thinking } : {}), ...(effort ? { effort } : {}), ...extraClaudeOptions, + ...fastModeOptions, ...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }), env: sdkEnv, }; @@ -2558,6 +2630,23 @@ class ClaudeAgentSession implements AgentSession { return base; } + private buildFastModeOptions( + extraClaudeOptions: Partial | undefined, + ): Pick | Record { + const fastMode = this.resolveFastModeSetting(); + if (fastMode === null) { + return {}; + } + return { settings: mergeClaudeSettings(extraClaudeOptions?.settings, { fastMode }) }; + } + + private resolveFastModeSetting(): boolean | null { + if (!claudeModelSupportsFastMode(this.config.model)) { + return null; + } + return this.config.featureValues?.fast_mode === true; + } + private normalizeMcpServers( servers: Record, ): Record { diff --git a/packages/server/src/server/agent/providers/claude/feature-definitions.ts b/packages/server/src/server/agent/providers/claude/feature-definitions.ts new file mode 100644 index 000000000..39a795930 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/feature-definitions.ts @@ -0,0 +1,48 @@ +import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js"; + +const CLAUDE_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", +] as const; + +export const CLAUDE_FAST_MODE_FEATURE: Omit = { + type: "toggle", + id: "fast_mode", + label: "Fast", + description: "Lower latency Opus responses at higher token cost", + tooltip: "Toggle fast mode", + icon: "zap", +}; + +function normalizeClaudeModelId(modelId: string | null | undefined): string | null { + const normalized = typeof modelId === "string" ? modelId.trim() : ""; + return normalized.length > 0 ? normalized : null; +} + +export function claudeModelSupportsFastMode(modelId: string | null | undefined): boolean { + const normalizedModelId = normalizeClaudeModelId(modelId); + if (!normalizedModelId) { + return false; + } + + return CLAUDE_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some( + (prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(`${prefix}[`), + ); +} + +export function buildClaudeFeatures(input: { + modelId: string | null | undefined; + fastModeEnabled: boolean; +}): AgentFeature[] { + if (!claudeModelSupportsFastMode(input.modelId)) { + return []; + } + + return [ + { + ...CLAUDE_FAST_MODE_FEATURE, + value: input.fastModeEnabled, + }, + ]; +}