Add Claude fast mode toggle
This commit is contained in:
@@ -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", () => {
|
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||||
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./models.js";
|
import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||||
import { parsePartialJsonObject } from "./partial-json.js";
|
import { parsePartialJsonObject } from "./partial-json.js";
|
||||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||||
|
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||||
import {
|
import {
|
||||||
buildBinaryDiagnosticRows,
|
buildBinaryDiagnosticRows,
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentCreateSessionOptions,
|
type AgentCreateSessionOptions,
|
||||||
|
type AgentFeature,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -361,6 +363,7 @@ interface ClaudeOptionsLogSummary {
|
|||||||
hasStderrHandler: boolean;
|
hasStderrHandler: boolean;
|
||||||
pathToClaudeCodeExecutable: string | null;
|
pathToClaudeCodeExecutable: string | null;
|
||||||
persistSession: boolean | null;
|
persistSession: boolean | null;
|
||||||
|
fastMode: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_RECENT_STDERR_CHARS = 4000;
|
const MAX_RECENT_STDERR_CHARS = 4000;
|
||||||
@@ -409,9 +412,27 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS
|
|||||||
? options.pathToClaudeCodeExecutable
|
? options.pathToClaudeCodeExecutable
|
||||||
: null,
|
: null,
|
||||||
persistSession: typeof options.persistSession === "boolean" ? options.persistSession : 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 } {
|
function isToolResultTextBlock(value: unknown): value is { type: "text"; text: string } {
|
||||||
return (
|
return (
|
||||||
!!value &&
|
!!value &&
|
||||||
@@ -1319,6 +1340,14 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
return await getClaudeModelsWithSettings(this.logger);
|
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(
|
async listPersistedAgents(
|
||||||
options?: ListPersistedAgentsOptions,
|
options?: ListPersistedAgentsOptions,
|
||||||
): Promise<PersistedAgentDescriptor[]> {
|
): Promise<PersistedAgentDescriptor[]> {
|
||||||
@@ -1629,6 +1658,13 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return this.claudeSessionId;
|
return this.claudeSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get features(): AgentFeature[] {
|
||||||
|
return buildClaudeFeatures({
|
||||||
|
modelId: this.config.model,
|
||||||
|
fastModeEnabled: this.config.featureValues?.fast_mode === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||||
if (this.cachedRuntimeInfo) {
|
if (this.cachedRuntimeInfo) {
|
||||||
return { ...this.cachedRuntimeInfo };
|
return { ...this.cachedRuntimeInfo };
|
||||||
@@ -1829,6 +1865,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const activeQuery = await this.ensureQuery();
|
const activeQuery = await this.ensureQuery();
|
||||||
await activeQuery.setModel(normalizedModelId ?? undefined);
|
await activeQuery.setModel(normalizedModelId ?? undefined);
|
||||||
this.config.model = 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.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
||||||
this.lastRuntimeModel = null;
|
this.lastRuntimeModel = null;
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
@@ -1852,6 +1891,33 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.queryRestartNeeded = true;
|
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[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
|
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
|
||||||
}
|
}
|
||||||
@@ -2389,6 +2455,10 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
queryFactory: this.queryFactory,
|
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
|
// Do not kick off background control-plane queries here. Methods like
|
||||||
// supportedCommands()/setPermissionMode() may execute immediately after
|
// supportedCommands()/setPermissionMode() may execute immediately after
|
||||||
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
||||||
@@ -2482,6 +2552,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const { thinking, effort } = this.resolveThinkingConfig();
|
const { thinking, effort } = this.resolveThinkingConfig();
|
||||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||||
const extraClaudeOptions = this.config.extra?.claude;
|
const extraClaudeOptions = this.config.extra?.claude;
|
||||||
|
const fastModeOptions = this.buildFastModeOptions(extraClaudeOptions);
|
||||||
const sdkEnv = this.buildSdkEnv(extraClaudeOptions);
|
const sdkEnv = this.buildSdkEnv(extraClaudeOptions);
|
||||||
assertClaudeAutoModeEligible(this.currentMode, sdkEnv);
|
assertClaudeAutoModeEligible(this.currentMode, sdkEnv);
|
||||||
|
|
||||||
@@ -2534,6 +2605,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
...(thinking ? { thinking } : {}),
|
...(thinking ? { thinking } : {}),
|
||||||
...(effort ? { effort } : {}),
|
...(effort ? { effort } : {}),
|
||||||
...extraClaudeOptions,
|
...extraClaudeOptions,
|
||||||
|
...fastModeOptions,
|
||||||
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
|
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
|
||||||
env: sdkEnv,
|
env: sdkEnv,
|
||||||
};
|
};
|
||||||
@@ -2558,6 +2630,23 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return base;
|
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(
|
private normalizeMcpServers(
|
||||||
servers: Record<string, McpServerConfig>,
|
servers: Record<string, McpServerConfig>,
|
||||||
): Record<string, ClaudeSdkMcpServerConfig> {
|
): Record<string, ClaudeSdkMcpServerConfig> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user