mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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", () => {
|
||||
test("maps frontend header-keyed answers to Claude question text keys", () => {
|
||||
expect(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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