Add Claude fast mode toggle

This commit is contained in:
Mohamed Boudra
2026-05-29 18:01:56 +07:00
parent c47f3190d8
commit c1453b25a7
3 changed files with 229 additions and 0 deletions

View File

@@ -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<unknown>;
}
).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<unknown>;
}
).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(

View File

@@ -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<Exclude<ClaudeOptions["settings"], string>>,
): 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<AgentFeature[]> {
const claudeConfig = this.assertConfig(config);
return buildClaudeFeatures({
modelId: claudeConfig.model,
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
});
}
async listPersistedAgents(
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
@@ -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<AgentRuntimeInfo> {
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<void> {
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<void> {
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<ClaudeOptions> | undefined,
): Pick<ClaudeOptions, "settings"> | Record<string, never> {
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<string, McpServerConfig>,
): Record<string, ClaudeSdkMcpServerConfig> {

View File

@@ -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<AgentFeatureToggle, "value"> = {
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,
},
];
}