fix(fast-mode): use setting_sources instead of env var for CLI fast mode (#1771)

* fix(fast-mode): use setting_sources instead of env var for CLI fast mode

The Claude Code CLI reads fastMode from user settings (~/.claude/settings.json),
not from environment variables. The previous CLAUDE_CODE_FAST_MODE env var approach
was non-functional. This fix writes fastMode=true to user settings before spawning
the CLI and enables the "user" setting source so the CLI reads it.

Key changes:
- Fast mode now writes to ~/.claude/settings.json with atomic file writes
- Extracted shared fast mode helpers into core/fast_mode.py (DRY)
- Moved fast mode toggle from global settings to per-task configuration
- Added opus-4.5 model option and adaptive thinking badges
- Sanitize legacy thinking levels (ultrathink→high, none→low) at all layers
- Shared LEGACY_THINKING_MAP, PHASE_KEYS, sanitizeThinkingLevel in frontend
- Moved diagnostic test script to scripts/ to prevent pytest collection
- SDK requirement bumped to >=0.1.33 for Opus 4.6 adaptive thinking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: persist fastMode toggle state in task edit and creation dialogs

When users toggled fast mode OFF, the change didn't persist because the code used
a conditional that only set fastMode when true. This meant the old fastMode: true
value was preserved during metadata merge. Now fastMode is always set explicitly,
matching the pattern used by requireReviewBeforeCoding.

Fixed in both:
- TaskEditDialog.tsx line 251
- TaskCreationWizard.tsx line 459

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings for fast mode implementation

- Fix fastMode toggle not persisting when disabled in TaskEditDialog
- Replace manual atomic write with write_json_atomic from core/file_utils
- Rename _ensure_fast_mode_in_user_settings to public (no underscore)
- Replace hardcoded validLevels with VALID_THINKING_LEVELS constant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix github issues

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-09 23:34:16 +01:00
committed by GitHub
parent aa7f56e5d0
commit 390ba6a588
41 changed files with 1007 additions and 140 deletions
+3
View File
@@ -591,6 +591,9 @@ async def run_autonomous_agent(
# Generate appropriate prompt
fast_mode = get_fast_mode(spec_dir)
logger.info(
f"[Coder] [Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for phase={current_phase}"
)
if first_run:
# Create client for planning phase
+3
View File
@@ -104,6 +104,9 @@ async def run_followup_planner(
spec_dir, "planning", planning_model
)
fast_mode = get_fast_mode(spec_dir)
logger.info(
f"[Planner] [Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for follow-up planning"
)
client = create_client(
project_dir,
spec_dir,
+23 -5
View File
@@ -21,6 +21,7 @@ import time
from pathlib import Path
from typing import Any
from core.fast_mode import ensure_fast_mode_in_user_settings
from core.platform import (
is_windows,
validate_cli_path,
@@ -483,9 +484,10 @@ def create_client(
"medium", "high"). When set, injected as CLAUDE_CODE_EFFORT_LEVEL
env var for the SDK subprocess. Only meaningful for models that
support adaptive thinking (e.g., Opus 4.6).
fast_mode: Enable Fast Mode for faster Opus 4.6 output. When True, injected
as CLAUDE_CODE_FAST_MODE=true env var. Requires extra usage enabled
on Claude subscription; falls back to standard speed automatically.
fast_mode: Enable Fast Mode for faster Opus 4.6 output. When True, enables
the "user" setting source so the CLI reads fastMode from
~/.claude/settings.json. Requires extra usage enabled on Claude
subscription; falls back to standard speed automatically.
Returns:
Configured ClaudeSDKClient
@@ -517,9 +519,19 @@ def create_client(
if effort_level:
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
# Inject fast mode for faster Opus 4.6 output
# Fast mode requires the CLI to read "fastMode" from user settings.
# The SDK default (setting_sources=None) passes --setting-sources "" which
# blocks ALL filesystem settings. We must explicitly enable "user" source
# so the CLI reads ~/.claude/settings.json where fastMode: true lives.
# See: https://code.claude.com/docs/en/fast-mode
if fast_mode:
sdk_env["CLAUDE_CODE_FAST_MODE"] = "true"
ensure_fast_mode_in_user_settings()
logger.info("[Fast Mode] ACTIVE — will enable user setting source for fastMode")
print(
"[Fast Mode] ACTIVE — enabling user settings source for CLI to read fastMode"
)
else:
logger.info("[Fast Mode] inactive — not requested for this client")
# Debug: Log git-bash path detection on Windows
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
@@ -841,6 +853,12 @@ def create_client(
"enable_file_checkpointing": True,
}
# Fast mode: enable user setting source so CLI reads fastMode from
# ~/.claude/settings.json. Without this, the SDK's default --setting-sources ""
# blocks all filesystem settings and the CLI never sees fastMode: true.
if fast_mode:
options_kwargs["setting_sources"] = ["user"]
# Optional: Allow CLI path override via environment variable
# The SDK bundles its own CLI, but users can override if needed
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
+76
View File
@@ -0,0 +1,76 @@
"""
Fast Mode Settings Helper
=========================
Manages the fastMode flag in ~/.claude/settings.json for temporary
per-task fast mode overrides. Shared by both client.py and simple_client.py.
"""
import json
import logging
from pathlib import Path
from core.file_utils import write_json_atomic
logger = logging.getLogger(__name__)
_fast_mode_atexit_registered = False
def _write_fast_mode_setting(enabled: bool) -> None:
"""Write fastMode value to ~/.claude/settings.json (atomic read-modify-write).
Uses write_json_atomic from core.file_utils to prevent corruption when
multiple concurrent task processes modify the file simultaneously.
"""
settings_file = Path.home() / ".claude" / "settings.json"
try:
settings: dict = {}
if settings_file.exists():
settings = json.loads(settings_file.read_text(encoding="utf-8"))
if settings.get("fastMode") != enabled:
settings["fastMode"] = enabled
settings_file.parent.mkdir(parents=True, exist_ok=True)
# Atomic write using shared utility
write_json_atomic(settings_file, settings)
state = "true" if enabled else "false"
logger.info(
f"[Fast Mode] Wrote fastMode={state} to ~/.claude/settings.json"
)
except Exception as e:
logger.warning(f"[Fast Mode] Could not update ~/.claude/settings.json: {e}")
def _disable_fast_mode_on_exit() -> None:
"""atexit handler: restore fastMode=false so interactive CLI sessions stay standard."""
_write_fast_mode_setting(False)
def ensure_fast_mode_in_user_settings() -> None:
"""
Enable fastMode in ~/.claude/settings.json and register cleanup.
The CLI reads fastMode from user settings (loaded via --setting-sources user).
This function:
1. Writes fastMode=true before spawning the CLI subprocess
2. Registers an atexit handler to restore fastMode=false when the process exits
This ensures fast mode is a temporary override per task process, not a permanent
setting change. The CLI subprocess reads settings at startup, so restoring false
after exit doesn't affect running tasks — only prevents fast mode from leaking
into subsequent interactive CLI sessions or non-fast-mode tasks.
"""
global _fast_mode_atexit_registered
_write_fast_mode_setting(True)
# Register cleanup once per process — idempotent on repeated calls
if not _fast_mode_atexit_registered:
import atexit
atexit.register(_disable_fast_mode_on_exit)
_fast_mode_atexit_registered = True
logger.info(
"[Fast Mode] Registered atexit cleanup (will restore fastMode=false)"
)
+13 -4
View File
@@ -31,6 +31,7 @@ from core.auth import (
configure_sdk_authentication,
get_sdk_env_vars,
)
from core.fast_mode import ensure_fast_mode_in_user_settings
from core.platform import validate_cli_path
from phase_config import get_thinking_budget
@@ -71,8 +72,8 @@ def create_simple_client(
betas: Optional list of SDK beta header strings (e.g., ["context-1m-2025-08-07"])
effort_level: Optional effort level for adaptive thinking models (e.g., "low",
"medium", "high"). Injected as CLAUDE_CODE_EFFORT_LEVEL env var.
fast_mode: Enable Fast Mode for faster Opus 4.6 output. Injected as
CLAUDE_CODE_FAST_MODE=true env var.
fast_mode: Enable Fast Mode for faster Opus 4.6 output. Enables the "user"
setting source so the CLI reads fastMode from ~/.claude/settings.json.
Returns:
Configured ClaudeSDKClient for single-turn operations
@@ -94,9 +95,12 @@ def create_simple_client(
if effort_level:
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
# Inject fast mode for faster Opus 4.6 output
# Fast mode: the CLI reads "fastMode" from user settings (~/.claude/settings.json).
# By default the SDK passes --setting-sources "" which blocks all filesystem settings.
# We enable "user" source so the CLI can read fastMode from user settings.
if fast_mode:
sdk_env["CLAUDE_CODE_FAST_MODE"] = "true"
ensure_fast_mode_in_user_settings()
logger.info("[Fast Mode] ACTIVE — will enable user setting source for fastMode")
# Get agent configuration (raises ValueError if unknown type)
config = get_agent_config(agent_type)
@@ -120,6 +124,11 @@ def create_simple_client(
"env": sdk_env,
}
# Fast mode: enable user setting source so CLI reads fastMode from
# ~/.claude/settings.json. Without this, --setting-sources "" blocks it.
if fast_mode:
options_kwargs["setting_sources"] = ["user"]
# Only add max_thinking_tokens if not None (Haiku doesn't support extended thinking)
if max_thinking_tokens is not None:
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
+50 -6
View File
@@ -7,15 +7,19 @@ Reads configuration from task_metadata.json and provides resolved model IDs.
"""
import json
import logging
import os
from pathlib import Path
from typing import Literal, TypedDict
logger = logging.getLogger(__name__)
# Model shorthand to full model ID mapping
# Values must match apps/frontend/src/shared/constants/models.ts MODEL_ID_MAP
MODEL_ID_MAP: dict[str, str] = {
"opus": "claude-opus-4-6",
"opus-1m": "claude-opus-4-6",
"opus-4.5": "claude-opus-4-5-20251101",
"sonnet": "claude-sonnet-4-5-20250929",
"haiku": "claude-haiku-4-5-20251001",
}
@@ -128,6 +132,8 @@ def resolve_model_id(model: str) -> str:
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
"opus-1m": "ANTHROPIC_DEFAULT_OPUS_MODEL",
# opus-4.5 intentionally omitted — always resolves to its hardcoded
# model ID (claude-opus-4-5-20251101) regardless of env var overrides.
}
env_var = env_var_map.get(model)
if env_var:
@@ -158,6 +164,37 @@ def get_model_betas(model_short: str) -> list[str]:
return MODEL_BETAS_MAP.get(model_short, [])
VALID_THINKING_LEVELS = {"low", "medium", "high"}
# Mapping from legacy/removed thinking levels to valid ones
LEGACY_THINKING_LEVEL_MAP: dict[str, str] = {
"ultrathink": "high",
"none": "low",
}
def sanitize_thinking_level(thinking_level: str) -> str:
"""
Validate and sanitize a thinking level string.
Maps legacy values (e.g., 'ultrathink') to valid equivalents and falls
back to 'medium' for completely unknown values. Used by CLI argparse
handlers to make the backend resilient to invalid values from the frontend.
Args:
thinking_level: Raw thinking level string from CLI or task_metadata.json
Returns:
A valid thinking level string (low, medium, high)
"""
if thinking_level in VALID_THINKING_LEVELS:
return thinking_level
mapped = LEGACY_THINKING_LEVEL_MAP.get(thinking_level, "medium")
logger.warning("Invalid thinking level '%s' mapped to '%s'", thinking_level, mapped)
return mapped
def get_thinking_budget(thinking_level: str) -> int:
"""
Get the thinking budget for a thinking level.
@@ -168,13 +205,12 @@ def get_thinking_budget(thinking_level: str) -> int:
Returns:
Token budget for extended thinking
"""
import logging
if thinking_level not in THINKING_BUDGET_MAP:
valid_levels = ", ".join(THINKING_BUDGET_MAP.keys())
logging.warning(
f"Invalid thinking_level '{thinking_level}'. Valid values: {valid_levels}. "
f"Defaulting to 'medium'."
logger.warning(
"Invalid thinking_level '%s'. Valid values: %s. Defaulting to 'medium'.",
thinking_level,
valid_levels,
)
return THINKING_BUDGET_MAP["medium"]
@@ -447,7 +483,15 @@ def get_fast_mode(spec_dir: Path) -> bool:
"""
metadata = load_task_metadata(spec_dir)
if metadata:
return bool(metadata.get("fastMode", False))
enabled = bool(metadata.get("fastMode", False))
if enabled:
logger.info(
"[Fast Mode] ENABLED — read fastMode=true from task_metadata.json"
)
else:
logger.info("[Fast Mode] disabled — fastMode not set in task_metadata.json")
return enabled
logger.info("[Fast Mode] disabled — no task_metadata.json found")
return False
+4
View File
@@ -131,6 +131,10 @@ async def run_qa_validation_loop(
)
fast_mode = get_fast_mode(spec_dir)
debug(
"qa_loop",
f"[Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for QA validation",
)
# Check if there's pending human feedback that needs to be processed
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
+3 -3
View File
@@ -1,7 +1,7 @@
# Auto-Build Framework Dependencies
# SDK 0.1.25+ required for improved tool use concurrency handling
# Earlier versions had 400 errors when tool_use blocks had partial failures
claude-agent-sdk>=0.1.25
# SDK 0.1.33+ required for Opus 4.6 adaptive thinking support
# Earlier versions lacked effort parameter and thinking type configuration
claude-agent-sdk>=0.1.33
python-dotenv>=1.0.0
# TOML parsing fallback for Python < 3.11
+5 -2
View File
@@ -77,6 +77,7 @@ from core.sentry import capture_exception, init_sentry, set_context
init_sentry(component="github-runner")
from debug import debug_error
from phase_config import sanitize_thinking_level
# Add github runner directory to path for direct imports
sys.path.insert(0, str(Path(__file__).parent))
@@ -689,8 +690,7 @@ def main():
"--thinking-level",
type=str,
default="medium",
choices=["low", "medium", "high"],
help="Thinking level for extended reasoning",
help="Thinking level for extended reasoning (low, medium, high)",
)
parser.add_argument(
"--fast-mode",
@@ -802,6 +802,9 @@ def main():
args = parser.parse_args()
# Validate and sanitize thinking level (handles legacy values like 'ultrathink')
args.thinking_level = sanitize_thinking_level(args.thinking_level)
if not args.command:
parser.print_help()
sys.exit(1)
+5 -2
View File
@@ -47,6 +47,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from core.io_utils import safe_print
from models import GitLabRunnerConfig
from orchestrator import GitLabOrchestrator, ProgressCallback
from phase_config import sanitize_thinking_level
def print_progress(callback: ProgressCallback) -> None:
@@ -286,8 +287,7 @@ def main():
"--thinking-level",
type=str,
default="medium",
choices=["none", "low", "medium", "high"],
help="Thinking level for extended reasoning",
help="Thinking level for extended reasoning (low, medium, high)",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
@@ -305,6 +305,9 @@ def main():
args = parser.parse_args()
# Validate and sanitize thinking level (handles legacy values like 'ultrathink')
args.thinking_level = sanitize_thinking_level(args.thinking_level)
if not args.command:
parser.print_help()
sys.exit(1)
+5 -2
View File
@@ -48,6 +48,7 @@ from ideation import (
IdeationPhaseResult,
)
from ideation.generator import IDEATION_TYPE_LABELS, IDEATION_TYPES
from phase_config import sanitize_thinking_level
# Re-export for backward compatibility
__all__ = [
@@ -109,8 +110,7 @@ def main():
"--thinking-level",
type=str,
default="medium",
choices=["low", "medium", "high"],
help="Thinking level for extended reasoning (default: medium)",
help="Thinking level for extended reasoning (low, medium, high)",
)
parser.add_argument(
"--refresh",
@@ -130,6 +130,9 @@ def main():
args = parser.parse_args()
# Validate and sanitize thinking level (handles legacy values like 'ultrathink')
args.thinking_level = sanitize_thinking_level(args.thinking_level)
# Validate project directory
project_dir = args.project.resolve()
if not project_dir.exists():
+5 -3
View File
@@ -47,7 +47,7 @@ from debug import (
debug_section,
debug_success,
)
from phase_config import get_thinking_budget, resolve_model_id
from phase_config import get_thinking_budget, resolve_model_id, sanitize_thinking_level
def load_project_context(project_dir: str) -> str:
@@ -353,11 +353,13 @@ def main():
parser.add_argument(
"--thinking-level",
default="medium",
choices=["low", "medium", "high"],
help="Thinking level for extended reasoning (default: medium)",
help="Thinking level for extended reasoning (low, medium, high)",
)
args = parser.parse_args()
# Validate and sanitize thinking level (handles legacy values like 'ultrathink')
args.thinking_level = sanitize_thinking_level(args.thinking_level)
debug_section("insights_runner", "Starting Insights Chat")
project_dir = args.project_dir
+5 -2
View File
@@ -37,6 +37,7 @@ if env_file.exists():
load_dotenv(env_file)
from debug import debug, debug_error, debug_warning
from phase_config import sanitize_thinking_level
# Import from refactored roadmap package (now a subpackage of runners)
from runners.roadmap import RoadmapOrchestrator
@@ -71,8 +72,7 @@ def main():
"--thinking-level",
type=str,
default="medium",
choices=["low", "medium", "high"],
help="Thinking level for extended reasoning (default: medium)",
help="Thinking level for extended reasoning (low, medium, high)",
)
parser.add_argument(
"--refresh",
@@ -94,6 +94,9 @@ def main():
args = parser.parse_args()
# Validate and sanitize thinking level (handles legacy values like 'ultrathink')
args.thinking_level = sanitize_thinking_level(args.thinking_level)
debug(
"roadmap_runner",
"CLI invoked",
+4 -2
View File
@@ -108,7 +108,7 @@ init_sentry(component="spec-runner")
from core.platform import is_windows
from debug import debug, debug_error, debug_section, debug_success
from phase_config import resolve_model_id
from phase_config import resolve_model_id, sanitize_thinking_level
from review import ReviewState
from spec import SpecOrchestrator
from ui import Icons, highlight, muted, print_section, print_status
@@ -185,7 +185,6 @@ Examples:
"--thinking-level",
type=str,
default="medium",
choices=["low", "medium", "high"],
help="Thinking level for extended thinking (low, medium, high)",
)
parser.add_argument(
@@ -222,6 +221,9 @@ Examples:
args = parser.parse_args()
# Validate and sanitize thinking level (handles legacy values like 'ultrathink')
args.thinking_level = sanitize_thinking_level(args.thinking_level)
# Warn user about direct mode risks
if args.direct:
print_status(
@@ -132,6 +132,10 @@ class AgentRunner:
betas = get_model_betas(self.model)
fast_mode = get_fast_mode(self.spec_dir)
debug(
"agent_runner",
f"[Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for spec pipeline agent",
)
resolved_model = resolve_model_id(self.model)
thinking_kwargs = get_thinking_kwargs_for_model(
resolved_model, thinking_level or "medium"
@@ -15,7 +15,7 @@ import {
} from './types';
import type { IdeationConfig } from '../../shared/types';
import { resetStuckSubtasks } from '../ipc-handlers/task/plan-file-utils';
import { AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants';
import { AUTO_BUILD_PATHS, getSpecsDir, sanitizeThinkingLevel } from '../../shared/constants';
import { projectStore } from '../project-store';
/**
@@ -304,15 +304,16 @@ export class AgentManager extends EventEmitter {
// Pass model and thinking level configuration
// For auto profile, use phase-specific config; otherwise use single model/thinking
// Validate thinking levels to prevent legacy values (e.g. 'ultrathink') from reaching the backend
if (metadata?.isAutoProfile && metadata.phaseModels && metadata.phaseThinking) {
// Pass the spec phase model and thinking level to spec_runner
args.push('--model', metadata.phaseModels.spec);
args.push('--thinking-level', metadata.phaseThinking.spec);
args.push('--thinking-level', sanitizeThinkingLevel(metadata.phaseThinking.spec));
} else if (metadata?.model) {
// Non-auto profile: use single model and thinking level
args.push('--model', metadata.model);
if (metadata.thinkingLevel) {
args.push('--thinking-level', metadata.thinkingLevel);
args.push('--thinking-level', sanitizeThinkingLevel(metadata.thinkingLevel));
}
}
@@ -19,7 +19,6 @@ import { transformIdeaFromSnakeCase, transformSessionFromSnakeCase } from '../ip
import { transformRoadmapFromSnakeCase } from '../ipc-handlers/roadmap/transformers';
import type { RawIdea } from '../ipc-handlers/ideation/types';
import { getPathDelimiter } from '../platform';
import { readSettingsFile } from '../settings-utils';
import { debounce } from '../utils/debounce';
import { writeFileWithRetry } from '../utils/atomic-file';
@@ -313,16 +312,6 @@ export class AgentQueueManager {
args.push('--thinking-level', config.thinkingLevel);
}
// Pass Fast Mode flag from app settings
try {
const settings = readSettingsFile();
if (settings?.fastMode) {
args.push('--fast-mode');
}
} catch {
// Settings read failure is non-fatal
}
debugLog('[Agent Queue] Spawning ideation process with args:', args);
// Use projectId as taskId for ideation operations
+5 -5
View File
@@ -57,10 +57,10 @@ export interface SpecCreationMetadata {
// Auto profile - phase-based model and thinking configuration
isAutoProfile?: boolean;
phaseModels?: {
spec: 'haiku' | 'sonnet' | 'opus' | 'opus-1m';
planning: 'haiku' | 'sonnet' | 'opus' | 'opus-1m';
coding: 'haiku' | 'sonnet' | 'opus' | 'opus-1m';
qa: 'haiku' | 'sonnet' | 'opus' | 'opus-1m';
spec: 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5';
planning: 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5';
coding: 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5';
qa: 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5';
};
phaseThinking?: {
spec: 'low' | 'medium' | 'high';
@@ -69,7 +69,7 @@ export interface SpecCreationMetadata {
qa: 'low' | 'medium' | 'high';
};
// Non-auto profile - single model and thinking level
model?: 'haiku' | 'sonnet' | 'opus' | 'opus-1m';
model?: 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5';
thinkingLevel?: 'low' | 'medium' | 'high';
// Workspace mode - whether to use worktree isolation
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
@@ -1628,6 +1628,7 @@ function updateMacOSKeychainCredentials(
['delete-generic-password', '-s', serviceName],
{
encoding: 'utf-8',
stdio: 'pipe',
timeout: MACOS_KEYCHAIN_TIMEOUT_MS,
windowsHide: true,
}
@@ -1452,17 +1452,15 @@ async function runPRReview(
);
const { model, thinkingLevel } = getGitHubPRSettings();
const settings = readSettingsFile();
const fastMode = !!settings?.fastMode;
const args = buildRunnerArgs(
getRunnerPath(backendPath),
project.path,
"review-pr",
[prNumber.toString()],
{ model, thinkingLevel, fastMode }
{ model, thinkingLevel }
);
debugLog("Spawning PR review process", { args, model, thinkingLevel, fastMode });
debugLog("Spawning PR review process", { args, model, thinkingLevel });
// Create log collector for this review
const config = getGitHubConfig(project);
@@ -2899,17 +2897,15 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
ciWaitAbortControllers.delete(reviewKey);
const { model, thinkingLevel } = getGitHubPRSettings();
const followupSettings = readSettingsFile();
const followupFastMode = !!followupSettings?.fastMode;
const args = buildRunnerArgs(
getRunnerPath(backendPath),
project.path,
"followup-review-pr",
[prNumber.toString()],
{ model, thinkingLevel, fastMode: followupFastMode }
{ model, thinkingLevel }
);
debugLog("Spawning follow-up review process", { args, model, thinkingLevel, fastMode: followupFastMode });
debugLog("Spawning follow-up review process", { args, model, thinkingLevel });
// Create log collector for this follow-up review (config already declared above)
const repo = config?.repo || project.name || "unknown";
@@ -271,7 +271,8 @@ export async function githubFetch(
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} - Request failed`);
const errorBody = await response.text().catch(() => 'Request failed');
throw new Error(`GitHub API error: ${response.status} - ${errorBody}`);
}
return response.json();
@@ -323,7 +324,8 @@ export async function githubFetchWithETag(
}
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} - Request failed`);
const errorBody = await response.text().catch(() => 'Request failed');
throw new Error(`GitHub API error: ${response.status} - ${errorBody}`);
}
const data = await response.json();
@@ -748,7 +748,6 @@ export function buildRunnerArgs(
options?: {
model?: string;
thinkingLevel?: string;
fastMode?: boolean;
}
): string[] {
const args = [runnerPath, '--project', projectPath];
@@ -761,10 +760,6 @@ export function buildRunnerArgs(
args.push('--thinking-level', options.thinkingLevel);
}
if (options?.fastMode) {
args.push('--fast-mode');
}
args.push(command);
args.push(...additionalArgs);
@@ -8,7 +8,7 @@ import { is } from '@electron-toolkit/utils';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE } from '../../shared/constants';
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE, sanitizeThinkingLevel, VALID_THINKING_LEVELS } from '../../shared/constants';
import { setAppLanguage } from '../app-language';
import type {
AppSettings,
@@ -135,6 +135,39 @@ export function registerSettingsHandlers(
needsSave = true;
}
// Migration: Replace legacy thinking levels with valid equivalents
// The 'ultrathink' value was removed but may persist in stored customPhaseThinking
if (!settings._migratedUltrathinkToHigh) {
if (settings.customPhaseThinking) {
let changed = false;
for (const phase of Object.keys(settings.customPhaseThinking) as Array<keyof typeof settings.customPhaseThinking>) {
if (!(VALID_THINKING_LEVELS as readonly string[]).includes(settings.customPhaseThinking[phase])) {
const mapped = sanitizeThinkingLevel(settings.customPhaseThinking[phase]);
settings.customPhaseThinking[phase] = mapped as import('../../shared/types/settings').ThinkingLevel;
changed = true;
}
}
if (changed) {
console.warn('[SETTINGS_GET] Migrated invalid thinking levels in customPhaseThinking');
}
}
if (settings.featureThinking) {
let changed = false;
for (const feature of Object.keys(settings.featureThinking) as Array<keyof typeof settings.featureThinking>) {
if (!(VALID_THINKING_LEVELS as readonly string[]).includes(settings.featureThinking[feature])) {
const mapped = sanitizeThinkingLevel(settings.featureThinking[feature]);
settings.featureThinking[feature] = mapped as import('../../shared/types/settings').ThinkingLevel;
changed = true;
}
}
if (changed) {
console.warn('[SETTINGS_GET] Migrated invalid thinking levels in featureThinking');
}
}
settings._migratedUltrathinkToHigh = true;
needsSave = true;
}
// Migration: Clear CLI tool paths that are from a different platform
// Fixes issue where Windows paths persisted on macOS (and vice versa)
// when settings were synced/transferred between platforms
@@ -1,5 +1,5 @@
import { ipcMain, nativeImage } from 'electron';
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir, VALID_THINKING_LEVELS, sanitizeThinkingLevel } from '../../../shared/constants';
import type { IPCResult, Task, TaskMetadata } from '../../../shared/types';
import path from 'path';
import { execFileSync } from 'child_process';
@@ -15,6 +15,30 @@ import { getToolPath } from '../../cli-tool-manager';
import { getIsolatedGitEnv } from '../../utils/git-isolation';
import { taskStateManager } from '../../task-state-manager';
/**
* Sanitize thinking levels in task metadata in-place.
* Maps legacy values (e.g. 'ultrathink' → 'high') and defaults unknown values to 'medium'.
*/
function sanitizeThinkingLevels(metadata: TaskMetadata): void {
const isValid = (val: string): boolean => VALID_THINKING_LEVELS.includes(val as typeof VALID_THINKING_LEVELS[number]);
if (metadata.thinkingLevel && !isValid(metadata.thinkingLevel)) {
const mapped = sanitizeThinkingLevel(metadata.thinkingLevel);
console.warn(`[TASK_CRUD] Sanitized invalid thinkingLevel "${metadata.thinkingLevel}" to "${mapped}"`);
metadata.thinkingLevel = mapped as TaskMetadata['thinkingLevel'];
}
if (metadata.phaseThinking) {
for (const phase of Object.keys(metadata.phaseThinking) as Array<keyof typeof metadata.phaseThinking>) {
if (!isValid(metadata.phaseThinking[phase])) {
const mapped = sanitizeThinkingLevel(metadata.phaseThinking[phase]);
console.warn(`[TASK_CRUD] Sanitized invalid phaseThinking.${phase} "${metadata.phaseThinking[phase]}" to "${mapped}"`);
metadata.phaseThinking[phase] = mapped as typeof metadata.phaseThinking[typeof phase];
}
}
}
}
/**
* Register task CRUD (Create, Read, Update, Delete) handlers
*/
@@ -199,10 +223,12 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2), 'utf-8');
// Save task metadata if provided
// Save task metadata if provided (sanitize thinking levels before writing)
if (taskMetadata) {
sanitizeThinkingLevels(taskMetadata);
const metadataPath = path.join(specDir, 'task_metadata.json');
writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2), 'utf-8');
console.log(`[TASK_CREATE] [Fast Mode] ${taskMetadata.fastMode ? 'ENABLED' : 'disabled'} — written to task_metadata.json for spec ${specId}`);
}
// Create requirements.json with attached images
@@ -506,7 +532,8 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
updatedMetadata.attachedImages = savedImages;
}
// Update task_metadata.json
// Sanitize thinking levels and update task_metadata.json
sanitizeThinkingLevels(updatedMetadata);
const metadataPath = path.join(specDir, 'task_metadata.json');
try {
writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2), 'utf-8');
@@ -99,6 +99,7 @@ function taskCardPropsAreEqual(prevProps: TaskCardProps, nextProps: TaskCardProp
prevTask.executionProgress?.phase === nextTask.executionProgress?.phase &&
prevTask.executionProgress?.phaseProgress === nextTask.executionProgress?.phaseProgress &&
prevTask.subtasks.length === nextTask.subtasks.length &&
prevTask.metadata?.fastMode === nextTask.metadata?.fastMode &&
prevTask.metadata?.category === nextTask.metadata?.category &&
prevTask.metadata?.complexity === nextTask.metadata?.complexity &&
prevTask.metadata?.archivedAt === nextTask.metadata?.archivedAt &&
@@ -428,6 +429,16 @@ export const TaskCard = memo(function TaskCard({
{reviewReasonInfo.label}
</Badge>
)}
{/* Fast Mode badge */}
{task.metadata?.fastMode && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0.5 flex items-center gap-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30"
>
<Zap className="h-2.5 w-2.5" />
{t('metadata.fastMode')}
</Badge>
)}
{/* Category badge with icon */}
{task.metadata?.category && (
<Badge
@@ -30,7 +30,9 @@ import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/s
import {
DEFAULT_AGENT_PROFILES,
DEFAULT_PHASE_MODELS,
DEFAULT_PHASE_THINKING
DEFAULT_PHASE_THINKING,
FAST_MODE_MODELS,
PHASE_KEYS
} from '../../shared/constants';
import { useSettingsStore } from '../stores/settings-store';
@@ -123,6 +125,15 @@ export function TaskCreationWizard({
// Review setting
const [requireReviewBeforeCoding, setRequireReviewBeforeCoding] = useState(false);
// Fast mode
const [fastMode, setFastMode] = useState(false);
// Show Fast Mode toggle when any phase uses an Opus model
const showFastModeToggle = useMemo(() => {
if (!phaseModels) return false;
return PHASE_KEYS.some(phase => FAST_MODE_MODELS.includes(phaseModels[phase]));
}, [phaseModels]);
// Draft state
const [isDraftRestored, setIsDraftRestored] = useState(false);
@@ -161,6 +172,7 @@ export function TaskCreationWizard({
setImages(draft.images);
setReferencedFiles(draft.referencedFiles ?? []);
setRequireReviewBeforeCoding(draft.requireReviewBeforeCoding ?? false);
setFastMode(draft.fastMode ?? false);
setIsDraftRestored(true);
if (draft.category || draft.priority || draft.complexity || draft.impact) {
@@ -183,6 +195,7 @@ export function TaskCreationWizard({
setImages([]);
setReferencedFiles([]);
setRequireReviewBeforeCoding(false);
setFastMode(false);
setBaseBranch(PROJECT_DEFAULT_BRANCH);
setUseWorktree(true);
setIsDraftRestored(false);
@@ -259,8 +272,9 @@ export function TaskCreationWizard({
images,
referencedFiles,
requireReviewBeforeCoding,
fastMode,
savedAt: new Date()
}), [projectId, title, description, category, priority, complexity, impact, profileId, model, thinkingLevel, phaseModels, phaseThinking, images, referencedFiles, requireReviewBeforeCoding]);
}), [projectId, title, description, category, priority, complexity, impact, profileId, model, thinkingLevel, phaseModels, phaseThinking, images, referencedFiles, requireReviewBeforeCoding, fastMode]);
/**
* Detect @ mention being typed and show autocomplete
@@ -442,7 +456,7 @@ export function TaskCreationWizard({
// Set useLocalBranch when user explicitly selects a local branch
// This preserves gitignored files (.env, configs) by not switching to origin
if (isSelectedBranchLocal) metadata.useLocalBranch = true;
if (settings.fastMode) metadata.fastMode = true;
metadata.fastMode = fastMode;
const task = await createTask(projectId, title.trim(), description.trim(), metadata);
if (task) {
@@ -474,6 +488,7 @@ export function TaskCreationWizard({
setImages([]);
setReferencedFiles([]);
setRequireReviewBeforeCoding(false);
setFastMode(false);
setBaseBranch(PROJECT_DEFAULT_BRANCH);
setUseWorktree(true);
setError(null);
@@ -656,6 +671,9 @@ export function TaskCreationWizard({
onImagesChange={setImages}
requireReviewBeforeCoding={requireReviewBeforeCoding}
onRequireReviewChange={setRequireReviewBeforeCoding}
fastMode={fastMode}
onFastModeChange={setFastMode}
showFastModeToggle={showFastModeToggle}
disabled={isCreating}
error={error}
onError={setError}
@@ -39,7 +39,9 @@ import type { Task, ImageAttachment, TaskCategory, TaskPriority, TaskComplexity,
import {
DEFAULT_AGENT_PROFILES,
DEFAULT_PHASE_MODELS,
DEFAULT_PHASE_THINKING
DEFAULT_PHASE_THINKING,
FAST_MODE_MODELS,
PHASE_KEYS
} from '../../shared/constants';
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
import { useSettingsStore } from '../stores/settings-store';
@@ -120,6 +122,18 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
task.metadata?.requireReviewBeforeCoding ?? false
);
// Fast mode
const [fastMode, setFastMode] = useState(task.metadata?.fastMode ?? false);
// Show Fast Mode toggle when any phase uses an Opus model
const showFastModeToggle = useMemo(() => {
if (!phaseModels) return false;
return PHASE_KEYS.some(phase => FAST_MODE_MODELS.includes(phaseModels[phase]));
}, [phaseModels]);
// Disable fast mode toggle for tasks that have moved past backlog
const isFastModeEditable = task.status === 'backlog';
// Reset form when task changes or dialog opens
useEffect(() => {
if (open) {
@@ -160,6 +174,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
setImages(task.metadata?.attachedImages || []);
setRequireReviewBeforeCoding(task.metadata?.requireReviewBeforeCoding ?? false);
setFastMode(task.metadata?.fastMode ?? false);
setError(null);
// Auto-expand classification if it has content
@@ -204,6 +219,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
model !== (task.metadata?.model || '') ||
thinkingLevel !== (task.metadata?.thinkingLevel || '') ||
requireReviewBeforeCoding !== (task.metadata?.requireReviewBeforeCoding ?? false) ||
fastMode !== (task.metadata?.fastMode ?? false) ||
JSON.stringify(images) !== JSON.stringify(task.metadata?.attachedImages || []) ||
JSON.stringify(phaseModels) !== JSON.stringify(task.metadata?.phaseModels || DEFAULT_PHASE_MODELS) ||
JSON.stringify(phaseThinking) !== JSON.stringify(task.metadata?.phaseThinking || DEFAULT_PHASE_THINKING);
@@ -232,8 +248,7 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
// Always set attachedImages to persist removal when all images are deleted
metadataUpdates.attachedImages = images.length > 0 ? images : [];
metadataUpdates.requireReviewBeforeCoding = requireReviewBeforeCoding;
// Preserve fastMode from original task metadata (set at creation from settings)
if (task.metadata?.fastMode) metadataUpdates.fastMode = true;
metadataUpdates.fastMode = fastMode;
const success = await persistUpdateTask(task.id, {
title: trimmedTitle,
@@ -313,6 +328,9 @@ export function TaskEditDialog({ task, open, onOpenChange, onSaved }: TaskEditDi
onImagesChange={setImages}
requireReviewBeforeCoding={requireReviewBeforeCoding}
onRequireReviewChange={setRequireReviewBeforeCoding}
fastMode={fastMode}
onFastModeChange={setFastMode}
showFastModeToggle={showFastModeToggle && isFastModeEditable}
disabled={isSaving}
error={error}
onError={setError}
@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw, Settings2, Info } from 'lucide-react';
import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw, Settings2 } from 'lucide-react';
import { cn } from '../../lib/utils';
import {
DEFAULT_AGENT_PROFILES,
@@ -8,13 +8,13 @@ import {
THINKING_LEVELS,
DEFAULT_PHASE_MODELS,
DEFAULT_PHASE_THINKING,
FAST_MODE_MODELS
ADAPTIVE_THINKING_MODELS,
PHASE_KEYS
} from '../../../shared/constants';
import { useSettingsStore, saveSettings } from '../../stores/settings-store';
import { SettingsSection } from './SettingsSection';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Switch } from '../ui/switch';
import {
Select,
SelectContent,
@@ -22,6 +22,7 @@ import {
SelectTrigger,
SelectValue
} from '../ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import type { AgentProfile, PhaseModelConfig, PhaseThinkingConfig, ModelTypeShort, ThinkingLevel } from '../../../shared/types/settings';
/**
@@ -35,8 +36,6 @@ const iconMap: Record<string, React.ElementType> = {
Settings2
};
const PHASE_KEYS: Array<keyof PhaseModelConfig> = ['spec', 'planning', 'coding', 'qa'];
/**
* Agent Profile Settings component
* Displays preset agent profiles for quick model/thinking level configuration
@@ -105,17 +104,6 @@ export function AgentProfileSettings() {
await saveSettings({ customPhaseThinking: newPhaseThinking });
};
// Show Fast Mode toggle when any phase uses an Opus model
const showFastModeToggle = useMemo(() => {
return PHASE_KEYS.some(phase =>
FAST_MODE_MODELS.includes(currentPhaseModels[phase])
);
}, [currentPhaseModels]);
const handleFastModeToggle = async (checked: boolean) => {
await saveSettings({ fastMode: checked });
};
const handleResetToProfileDefaults = async () => {
// Reset to the selected profile's defaults
await saveSettings({
@@ -302,7 +290,21 @@ export function AgentProfileSettings() {
</div>
{/* Thinking Level Select */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{t('agentProfile.thinkingLevel')}</Label>
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground">{t('agentProfile.thinkingLevel')}</Label>
{ADAPTIVE_THINKING_MODELS.includes(currentPhaseModels[phase]) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center rounded bg-primary/10 px-1.5 py-0.5 text-[9px] font-medium text-primary cursor-help">
{t('agentProfile.adaptiveThinking.badge')}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">{t('agentProfile.adaptiveThinking.tooltip')}</p>
</TooltipContent>
</Tooltip>
)}
</div>
<Select
value={currentPhaseThinking[phase]}
onValueChange={(value) => handlePhaseThinkingChange(phase, value as ThinkingLevel)}
@@ -332,37 +334,6 @@ export function AgentProfileSettings() {
)}
</div>
{/* Fast Mode Toggle - shown when any phase uses an Opus model */}
{showFastModeToggle && (
<div className="mt-4 rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 shrink-0">
<Zap className="h-5 w-5 text-amber-500" />
</div>
<div>
<Label className="text-sm font-medium text-foreground">
{t('agentProfile.fastMode.label')}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t('agentProfile.fastMode.description')}
</p>
</div>
</div>
<Switch
checked={settings.fastMode ?? false}
onCheckedChange={handleFastModeToggle}
/>
</div>
<div className="mt-3 flex items-start gap-2 rounded-md bg-amber-500/5 border border-amber-500/20 p-2.5">
<Info className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-[10px] text-amber-600 dark:text-amber-400">
{t('agentProfile.fastMode.extraUsageNotice')}
</p>
</div>
</div>
)}
</div>
</SettingsSection>
);
@@ -67,6 +67,7 @@ const LOG_PHASE_TO_CONFIG_PHASE: Record<TaskLogPhase, keyof PhaseModelConfig> =
const MODEL_SHORT_LABELS: Record<ModelTypeShort, string> = {
opus: 'Opus',
'opus-1m': 'Opus (1M)',
'opus-4.5': 'Opus 4.5',
sonnet: 'Sonnet',
haiku: 'Haiku'
};
@@ -11,11 +11,12 @@
*/
import { useRef, useState, useEffect, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronUp, Image as ImageIcon, X, Camera } from 'lucide-react';
import { ChevronDown, ChevronUp, Image as ImageIcon, X, Camera, Zap, Info } from 'lucide-react';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Checkbox } from '../ui/checkbox';
import { Switch } from '../ui/switch';
import { Button } from '../ui/button';
import { AgentProfileSelector } from '../AgentProfileSelector';
import { ClassificationFields } from './ClassificationFields';
@@ -86,6 +87,11 @@ interface TaskFormFieldsProps {
requireReviewBeforeCoding: boolean;
onRequireReviewChange: (require: boolean) => void;
// Fast mode
fastMode?: boolean;
onFastModeChange?: (value: boolean) => void;
showFastModeToggle?: boolean;
// Form state
disabled?: boolean;
error?: string | null;
@@ -135,6 +141,9 @@ export function TaskFormFields({
onImagesChange,
requireReviewBeforeCoding,
onRequireReviewChange,
fastMode = false,
onFastModeChange,
showFastModeToggle = false,
disabled = false,
error,
onError,
@@ -533,6 +542,38 @@ export function TaskFormFields({
</div>
</div>
{/* Fast Mode Toggle - shown when any phase uses an Opus model */}
{showFastModeToggle && onFastModeChange && (
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 shrink-0">
<Zap className="h-5 w-5 text-amber-500" />
</div>
<div>
<Label className="text-sm font-medium text-foreground">
{t('tasks:form.fastModeLabel')}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t('tasks:form.fastModeDescription')}
</p>
</div>
</div>
<Switch
checked={fastMode}
onCheckedChange={onFastModeChange}
disabled={disabled}
/>
</div>
<div className="mt-3 flex items-start gap-2 rounded-md bg-amber-500/5 border border-amber-500/20 p-2.5">
<Info className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-[10px] text-amber-600 dark:text-amber-400">
{t('tasks:form.fastModeNotice')}
</p>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive" role="alert">
+4 -2
View File
@@ -148,8 +148,10 @@ function queueUpdate(taskId: string, update: BatchedUpdate): void {
function isTaskForCurrentProject(eventProjectId?: string): boolean {
// If no projectId provided (backward compatibility), accept the event
if (!eventProjectId) return true;
const currentProjectId = useProjectStore.getState().selectedProjectId;
// If no project selected, accept the event
const { activeProjectId, selectedProjectId } = useProjectStore.getState();
// Keep filtering aligned with App task loading logic (active first, selected fallback)
const currentProjectId = activeProjectId || selectedProjectId;
// If no project selected/active, accept the event
if (!currentProjectId) return true;
return currentProjectId === eventProjectId;
}
@@ -12,6 +12,7 @@ import type { AgentProfile, PhaseModelConfig, FeatureModelConfig, FeatureThinkin
export const AVAILABLE_MODELS = [
{ value: 'opus', label: 'Claude Opus 4.6' },
{ value: 'opus-1m', label: 'Claude Opus 4.6 (1M)' },
{ value: 'opus-4.5', label: 'Claude Opus 4.5' },
{ value: 'sonnet', label: 'Claude Sonnet 4.5' },
{ value: 'haiku', label: 'Claude Haiku 4.5' }
] as const;
@@ -21,6 +22,7 @@ export const AVAILABLE_MODELS = [
export const MODEL_ID_MAP: Record<string, string> = {
opus: 'claude-opus-4-6',
'opus-1m': 'claude-opus-4-6',
'opus-4.5': 'claude-opus-4-5-20251101',
sonnet: 'claude-sonnet-4-5-20250929',
haiku: 'claude-haiku-4-5-20251001'
} as const;
@@ -196,6 +198,24 @@ export const DEFAULT_AGENT_PROFILES: AgentProfile[] = [
// Models that support Fast Mode (same model, faster API routing, higher cost)
export const FAST_MODE_MODELS: readonly string[] = ['opus', 'opus-1m'] as const;
// Models that use adaptive thinking (Opus dynamically decides how much to think within the budget cap)
export const ADAPTIVE_THINKING_MODELS: readonly string[] = ['opus', 'opus-1m'] as const;
// Valid thinking levels for validation
export const VALID_THINKING_LEVELS = ['low', 'medium', 'high'] as const;
// Legacy thinking level mappings (must match backend phase_config.py LEGACY_THINKING_LEVEL_MAP)
export const LEGACY_THINKING_MAP: Record<string, string> = { ultrathink: 'high', none: 'low' } as const;
/** Sanitize a thinking level value, mapping legacy values to valid ones */
export function sanitizeThinkingLevel(val: string): string {
if (VALID_THINKING_LEVELS.includes(val as typeof VALID_THINKING_LEVELS[number])) return val;
return LEGACY_THINKING_MAP[val] ?? 'medium';
}
// Phase keys for iterating over phase model/thinking configuration
export const PHASE_KEYS: readonly (keyof PhaseModelConfig)[] = ['spec', 'planning', 'coding', 'qa'] as const;
// ============================================
// Memory Backends
// ============================================
@@ -400,10 +400,9 @@
"resetToProfileDefaults": "Reset to {{profile}} defaults",
"customized": "Customized",
"phaseConfigNote": "These settings will be used as defaults when creating new tasks with this profile. You can override them per-task in the task creation wizard.",
"fastMode": {
"label": "Fast Mode",
"description": "Same Opus 4.6 model with faster output. Higher cost per token.",
"extraUsageNotice": "Requires \"extra usage\" enabled on your Claude subscription. Falls back to standard speed automatically if unavailable."
"adaptiveThinking": {
"badge": "Adaptive",
"tooltip": "Opus uses adaptive thinking — it dynamically decides how much to think within the budget cap set by the thinking level."
},
"phases": {
"spec": {
@@ -175,6 +175,7 @@
"openInIDE": "Open in IDE"
},
"metadata": {
"fastMode": "Fast",
"severity": "severity",
"pullRequest": "Pull Request",
"showMore": "Show more",
@@ -253,6 +254,9 @@
"classificationOptional": "Classification (optional)",
"requireReviewLabel": "Require human review before coding",
"requireReviewDescription": "When enabled, you'll be prompted to review the spec and implementation plan before the coding phase begins. This allows you to approve, request changes, or provide feedback.",
"fastModeLabel": "Fast Mode",
"fastModeDescription": "Same Opus 4.6 model with faster output. Higher cost per token.",
"fastModeNotice": "Requires \"extra usage\" enabled on your Claude subscription.",
"errors": {
"descriptionRequired": "Please provide a description",
"maxImagesReached": "Maximum of 5 images allowed",
@@ -400,10 +400,9 @@
"resetToProfileDefaults": "Réinitialiser aux défauts de {{profile}}",
"customized": "Personnalisé",
"phaseConfigNote": "Ces paramètres seront utilisés par défaut lors de la création de nouvelles tâches avec ce profil. Vous pouvez les modifier par tâche dans l'assistant de création.",
"fastMode": {
"label": "Mode Rapide",
"description": "Même modèle Opus 4.6 avec une sortie plus rapide. Coût plus élevé par token.",
"extraUsageNotice": "Nécessite « utilisation supplémentaire » activée sur votre abonnement Claude. Revient automatiquement à la vitesse standard si indisponible."
"adaptiveThinking": {
"badge": "Adaptatif",
"tooltip": "Opus utilise la réflexion adaptative — il décide dynamiquement de la profondeur de réflexion dans la limite du budget défini par le niveau de réflexion."
},
"phases": {
"spec": {
@@ -174,6 +174,7 @@
"openInIDE": "Ouvrir dans l'IDE"
},
"metadata": {
"fastMode": "Rapide",
"severity": "sévérité",
"pullRequest": "Pull Request",
"showMore": "Afficher plus",
@@ -252,6 +253,9 @@
"classificationOptional": "Classification (optionnel)",
"requireReviewLabel": "Exiger une révision humaine avant le codage",
"requireReviewDescription": "Lorsque activé, vous serez invité à réviser la spécification et le plan d'implémentation avant le début de la phase de codage. Cela vous permet d'approuver, de demander des modifications ou de fournir des commentaires.",
"fastModeLabel": "Mode Rapide",
"fastModeDescription": "Même modèle Opus 4.6 avec une sortie plus rapide. Coût plus élevé par token.",
"fastModeNotice": "Nécessite « utilisation supplémentaire » activée sur votre abonnement Claude.",
"errors": {
"descriptionRequired": "Veuillez fournir une description",
"maxImagesReached": "Maximum de 5 images autorisé",
+2 -3
View File
@@ -161,7 +161,7 @@ export interface ColorThemeDefinition {
export type ThinkingLevel = 'low' | 'medium' | 'high';
// Model type shorthand
export type ModelTypeShort = 'haiku' | 'sonnet' | 'opus' | 'opus-1m';
export type ModelTypeShort = 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5';
// Phase-based model configuration for Auto profile
// Each phase can use a different model optimized for that task type
@@ -276,6 +276,7 @@ export interface AppSettings {
// Migration flags (internal use)
_migratedAgentProfileToAuto?: boolean;
_migratedDefaultModelSync?: boolean;
_migratedUltrathinkToHigh?: boolean;
// Language preference for UI (i18n)
language?: SupportedLanguage;
// Developer tools preferences
@@ -293,8 +294,6 @@ export interface AppSettings {
seenVersionWarnings?: string[];
// Sidebar collapsed state (icons only when true)
sidebarCollapsed?: boolean;
/** Fast Mode — faster Opus 4.6 output, requires extra usage on Claude subscription */
fastMode?: boolean;
}
// Auto-Claude Source Environment Configuration (for auto-claude repo .env)
+2 -1
View File
@@ -154,6 +154,7 @@ export interface TaskDraft {
images: ImageAttachment[];
referencedFiles: ReferencedFile[];
requireReviewBeforeCoding?: boolean;
fastMode?: boolean;
savedAt: Date;
}
@@ -163,7 +164,7 @@ export type TaskImpact = 'low' | 'medium' | 'high' | 'critical';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
// Re-export ThinkingLevel (defined in settings.ts) for convenience
export type { ThinkingLevel };
export type ModelType = 'haiku' | 'sonnet' | 'opus' | 'opus-1m';
export type ModelType = 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5';
export type TaskCategory =
| 'feature'
| 'bug_fix'
+1 -1
View File
@@ -25,7 +25,7 @@
},
"apps/frontend": {
"name": "auto-claude-ui",
"version": "2.7.4",
"version": "2.7.6-beta.2",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env python3
"""
Fast Mode Diagnostic Test
=========================
Tests different approaches to enable fast mode when using the Claude Agent SDK.
The Claude Code CLI supports fast mode via:
- `/fast` toggle in interactive mode
- `"fastMode": true` in user settings (~/.claude/settings.json)
The challenge: The Agent SDK passes `--setting-sources ""` by default,
which disables loading of user/project/local settings. This means even
if fastMode is in ~/.claude/settings.json, the CLI subprocess won't read it.
This script tests different invocation methods to find one that works:
1. --settings file with fastMode (current approach)
2. --setting-sources user (load user settings where fastMode lives)
3. --setting-sources user,project + project .claude/settings.json
4. CLAUDE_CONFIG_DIR/settings.json with setting-sources user
5. Direct CLI invocation with various flags
Usage:
cd apps/backend
.venv/bin/python ../../tests/test_fast_mode_invocations.py
Requirements:
- Claude Code CLI installed
- Active Claude subscription with extra usage enabled
- Opus 4.6 model access
"""
import asyncio
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_extra_usage(token: str | None = None) -> dict | None:
"""Fetch current extra_usage from the Anthropic OAuth usage API."""
try:
import urllib.request
import urllib.error
# If no token provided, try to get from Claude CLI credentials
if not token:
token = _get_oauth_token()
if not token:
print(" [SKIP] No OAuth token available")
return None
req = urllib.request.Request(
"https://api.anthropic.com/api/oauth/usage",
headers={"Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
return data.get("extra_usage")
except Exception as e:
print(f" [ERROR] Failed to fetch usage: {e}")
return None
def _get_oauth_token() -> str | None:
"""Try to get OAuth token from Claude CLI keychain."""
try:
# Use the claude CLI to check auth status
result = subprocess.run(
["claude", "--version"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return None
# Try reading from the default profile's credential store
# Check common profile directories
config_dir = os.environ.get("CLAUDE_CONFIG_DIR", str(Path.home() / ".claude"))
cred_file = Path(config_dir) / "credentials.json"
if cred_file.exists():
creds = json.loads(cred_file.read_text())
return creds.get("token") or creds.get("oauthToken")
return None
except Exception:
return None
def run_claude_cli(extra_args: list[str], env_overrides: dict | None = None,
label: str = "test") -> tuple[int, str, str]:
"""Run claude CLI with -p flag and capture output."""
cmd = [
"claude", "-p",
"Reply with exactly: HELLO_FAST_TEST",
"--model", "claude-opus-4-6",
"--max-budget-usd", "0.50",
*extra_args,
]
env = os.environ.copy()
if env_overrides:
env.update(env_overrides)
print(f" CMD: {' '.join(cmd)}")
if env_overrides:
for k, v in env_overrides.items():
print(f" ENV: {k}={v}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
env=env,
cwd=str(Path.home()), # Use home dir to avoid project settings
)
return result.returncode, result.stdout[:500], result.stderr[:1000]
except subprocess.TimeoutExpired:
return -1, "", "TIMEOUT"
except Exception as e:
return -1, "", str(e)
def check_usage_delta(before: dict | None, after: dict | None) -> str:
"""Compare extra_usage before and after a test."""
if not before or not after:
return "UNKNOWN (couldn't fetch usage)"
before_credits = before.get("used_credits") or 0
after_credits = after.get("used_credits") or 0
delta = after_credits - before_credits
if delta > 0:
return f"EXTRA USAGE INCREASED by ${delta:.2f} (${before_credits:.2f} -> ${after_credits:.2f}) — FAST MODE IS WORKING"
else:
return f"NO CHANGE in extra usage (${before_credits:.2f} -> ${after_credits:.2f}) — fast mode NOT active"
# ---------------------------------------------------------------------------
# Test cases
# ---------------------------------------------------------------------------
def test_1_settings_file_with_fast_mode():
"""Test: Pass fastMode via --settings JSON file."""
print("\n" + "=" * 70)
print("TEST 1: --settings file with fastMode=true")
print("=" * 70)
print(" Strategy: Write fastMode to a temp settings.json, pass via --settings")
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"fastMode": True}, f)
settings_path = f.name
try:
usage_before = get_extra_usage()
returncode, stdout, stderr = run_claude_cli(
["--settings", settings_path],
label="settings-file",
)
# Small delay for usage to propagate
time.sleep(3)
usage_after = get_extra_usage()
print(f" Exit code: {returncode}")
print(f" Output: {stdout[:200]}")
if "error" in stderr.lower() or "fast" in stderr.lower():
print(f" Stderr (relevant): {stderr[:300]}")
print(f" Result: {check_usage_delta(usage_before, usage_after)}")
finally:
os.unlink(settings_path)
def test_2_settings_json_inline():
"""Test: Pass fastMode via --settings inline JSON string."""
print("\n" + "=" * 70)
print("TEST 2: --settings inline JSON with fastMode=true")
print("=" * 70)
print(" Strategy: Pass JSON string directly to --settings")
usage_before = get_extra_usage()
returncode, stdout, stderr = run_claude_cli(
["--settings", '{"fastMode": true}'],
label="settings-inline",
)
time.sleep(3)
usage_after = get_extra_usage()
print(f" Exit code: {returncode}")
print(f" Output: {stdout[:200]}")
if "error" in stderr.lower() or "fast" in stderr.lower():
print(f" Stderr (relevant): {stderr[:300]}")
print(f" Result: {check_usage_delta(usage_before, usage_after)}")
def test_3_setting_sources_user():
"""Test: Enable user setting sources so CLI reads ~/.claude/settings.json."""
print("\n" + "=" * 70)
print("TEST 3: --setting-sources user (loads ~/.claude/settings.json)")
print("=" * 70)
print(" Strategy: Tell CLI to load user settings (where /fast toggle saves)")
print(" NOTE: Requires fastMode=true in ~/.claude/settings.json")
# Check if fastMode is in user settings
user_settings_path = Path.home() / ".claude" / "settings.json"
has_fast_mode = False
if user_settings_path.exists():
try:
settings = json.loads(user_settings_path.read_text())
has_fast_mode = settings.get("fastMode", False)
print(f" ~/.claude/settings.json fastMode: {has_fast_mode}")
except Exception:
print(f" Could not read {user_settings_path}")
if not has_fast_mode:
print(" [ACTION NEEDED] fastMode not in user settings.")
print(" Run `/fast` in Claude Code CLI first, then re-run this test.")
print(" Or manually add '\"fastMode\": true' to ~/.claude/settings.json")
print(" SKIPPING (won't produce meaningful result)")
return
usage_before = get_extra_usage()
returncode, stdout, stderr = run_claude_cli(
["--setting-sources", "user"],
label="setting-sources-user",
)
time.sleep(3)
usage_after = get_extra_usage()
print(f" Exit code: {returncode}")
print(f" Output: {stdout[:200]}")
if "error" in stderr.lower() or "fast" in stderr.lower():
print(f" Stderr (relevant): {stderr[:300]}")
print(f" Result: {check_usage_delta(usage_before, usage_after)}")
def test_4_settings_file_plus_setting_sources():
"""Test: --settings with fastMode + --setting-sources user."""
print("\n" + "=" * 70)
print("TEST 4: --settings fastMode + --setting-sources user")
print("=" * 70)
print(" Strategy: Both --settings with fastMode AND enable user sources")
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"fastMode": True}, f)
settings_path = f.name
try:
usage_before = get_extra_usage()
returncode, stdout, stderr = run_claude_cli(
["--settings", settings_path, "--setting-sources", "user"],
label="settings-plus-sources",
)
time.sleep(3)
usage_after = get_extra_usage()
print(f" Exit code: {returncode}")
print(f" Output: {stdout[:200]}")
if "error" in stderr.lower() or "fast" in stderr.lower():
print(f" Stderr (relevant): {stderr[:300]}")
print(f" Result: {check_usage_delta(usage_before, usage_after)}")
finally:
os.unlink(settings_path)
def test_5_project_settings():
"""Test: Write fastMode to project .claude/settings.json + setting-sources project."""
print("\n" + "=" * 70)
print("TEST 5: Project .claude/settings.json with fastMode=true")
print("=" * 70)
print(" Strategy: Create project settings in temp dir with fastMode")
# Create a temp project dir with .claude/settings.json
with tempfile.TemporaryDirectory() as tmpdir:
claude_dir = Path(tmpdir) / ".claude"
claude_dir.mkdir()
settings_file = claude_dir / "settings.json"
settings_file.write_text(json.dumps({"fastMode": True}))
print(f" Project dir: {tmpdir}")
print(f" Settings: {settings_file}")
cmd = [
"claude", "-p",
"Reply with exactly: HELLO_FAST_TEST",
"--model", "claude-opus-4-6",
"--max-budget-usd", "0.50",
"--setting-sources", "project",
"--dangerously-skip-permissions",
]
print(f" CMD: {' '.join(cmd)}")
usage_before = get_extra_usage()
try:
result = subprocess.run(
cmd,
capture_output=True, text=True, timeout=120,
cwd=tmpdir, # Run from the temp project dir
)
returncode, stdout, stderr = result.returncode, result.stdout[:500], result.stderr[:1000]
except Exception as e:
returncode, stdout, stderr = -1, "", str(e)
time.sleep(3)
usage_after = get_extra_usage()
print(f" Exit code: {returncode}")
print(f" Output: {stdout[:200]}")
if "error" in stderr.lower() or "fast" in stderr.lower():
print(f" Stderr (relevant): {stderr[:300]}")
print(f" Result: {check_usage_delta(usage_before, usage_after)}")
def test_6_config_dir_settings():
"""Test: Write fastMode to CLAUDE_CONFIG_DIR/settings.json."""
print("\n" + "=" * 70)
print("TEST 6: CLAUDE_CONFIG_DIR/settings.json with fastMode=true")
print("=" * 70)
print(" Strategy: Create temp config dir with fastMode in settings.json")
with tempfile.TemporaryDirectory() as config_dir:
settings_file = Path(config_dir) / "settings.json"
settings_file.write_text(json.dumps({"fastMode": True}))
print(f" Config dir: {config_dir}")
usage_before = get_extra_usage()
returncode, stdout, stderr = run_claude_cli(
["--setting-sources", "user"],
env_overrides={"CLAUDE_CONFIG_DIR": config_dir},
label="config-dir-settings",
)
time.sleep(3)
usage_after = get_extra_usage()
print(f" Exit code: {returncode}")
print(f" Output: {stdout[:200]}")
if "error" in stderr.lower() or "fast" in stderr.lower():
print(f" Stderr (relevant): {stderr[:300]}")
print(f" Result: {check_usage_delta(usage_before, usage_after)}")
def test_7_env_var():
"""Test: CLAUDE_CODE_FAST_MODE env var (known not to work, baseline)."""
print("\n" + "=" * 70)
print("TEST 7: CLAUDE_CODE_FAST_MODE=true env var (control/baseline)")
print("=" * 70)
print(" Strategy: Pass env var (expected NOT to work)")
usage_before = get_extra_usage()
returncode, stdout, stderr = run_claude_cli(
[],
env_overrides={"CLAUDE_CODE_FAST_MODE": "true"},
label="env-var",
)
time.sleep(3)
usage_after = get_extra_usage()
print(f" Exit code: {returncode}")
print(f" Output: {stdout[:200]}")
print(f" Result: {check_usage_delta(usage_before, usage_after)}")
def test_0_check_where_fast_saves():
"""Discovery: Check where /fast toggle saves its state."""
print("\n" + "=" * 70)
print("TEST 0: DISCOVERY — Where does /fast save its setting?")
print("=" * 70)
locations = [
Path.home() / ".claude" / "settings.json",
Path.home() / ".claude" / "settings.local.json",
Path.home() / ".claude" / "preferences.json",
Path.home() / ".claude" / "config.json",
Path.home() / ".claude" / "state.json",
]
# Also check CLAUDE_CONFIG_DIR if set
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
if config_dir:
config_path = Path(config_dir)
locations.extend([
config_path / "settings.json",
config_path / "settings.local.json",
config_path / "config.json",
])
# Check all profile dirs
profiles_dir = Path.home() / ".claude-profiles"
if profiles_dir.exists():
for profile_dir in profiles_dir.iterdir():
if profile_dir.is_dir():
locations.extend([
profile_dir / "settings.json",
profile_dir / "settings.local.json",
profile_dir / "config.json",
])
print("\n Scanning for 'fast' references in Claude config files:\n")
found_any = False
for loc in locations:
if loc.exists():
try:
content = loc.read_text()
if "fast" in content.lower():
found_any = True
print(f" FOUND in {loc}:")
# Parse and show just the relevant part
try:
data = json.loads(content)
for key, value in data.items():
if "fast" in key.lower():
print(f" {key}: {value}")
except json.JSONDecodeError:
# Show lines containing "fast"
for line in content.split("\n"):
if "fast" in line.lower():
print(f" {line.strip()}")
else:
print(f" {loc}: exists, no 'fast' references")
except Exception as e:
print(f" {loc}: error reading: {e}")
else:
pass # Skip non-existent files silently
if not found_any:
print("\n No 'fast' references found in any config files.")
print(" Try running `/fast` in the Claude Code CLI first,")
print(" then re-run this test to see where it saves.")
# Also scan ~/.claude/ for any files we missed
claude_dir = Path.home() / ".claude"
if claude_dir.exists():
print(f"\n All files in {claude_dir}:")
for item in sorted(claude_dir.iterdir()):
if item.is_file():
size = item.stat().st_size
print(f" {item.name} ({size} bytes)")
if item.suffix == ".json" and size < 50000:
try:
content = item.read_text()
if "fast" in content.lower():
print(f" ^ CONTAINS 'fast' reference!")
except Exception:
pass
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
print("=" * 70)
print("FAST MODE DIAGNOSTIC TEST")
print("=" * 70)
print(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Claude CLI: ", end="")
sys.stdout.flush()
try:
result = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=5)
print(result.stdout.strip())
except Exception as e:
print(f"ERROR: {e}")
print("Claude CLI not found! Install it first.")
sys.exit(1)
# Initial usage check
print("\nInitial extra_usage:")
usage = get_extra_usage()
if usage:
print(f" enabled: {usage.get('is_enabled')}")
print(f" used_credits: ${usage.get('used_credits', 0):.2f}")
print(f" monthly_limit: ${usage.get('monthly_limit', 0)}")
else:
print(" Could not fetch (tests will show UNKNOWN results)")
# Run discovery first
test_0_check_where_fast_saves()
# Ask user which tests to run
print("\n" + "=" * 70)
print("AVAILABLE TESTS:")
print("=" * 70)
print(" 1. --settings file with fastMode=true")
print(" 2. --settings inline JSON with fastMode=true")
print(" 3. --setting-sources user (requires fastMode in ~/.claude/settings.json)")
print(" 4. --settings fastMode + --setting-sources user")
print(" 5. Project .claude/settings.json with --setting-sources project")
print(" 6. CLAUDE_CONFIG_DIR/settings.json with --setting-sources user")
print(" 7. CLAUDE_CODE_FAST_MODE env var (control/baseline)")
print(" a. Run ALL tests")
print(" q. Quit")
tests = {
"1": test_1_settings_file_with_fast_mode,
"2": test_2_settings_json_inline,
"3": test_3_setting_sources_user,
"4": test_4_settings_file_plus_setting_sources,
"5": test_5_project_settings,
"6": test_6_config_dir_settings,
"7": test_7_env_var,
}
while True:
choice = input("\nRun which test(s)? [1-7, a=all, q=quit]: ").strip().lower()
if choice == "q":
break
elif choice == "a":
for test_fn in tests.values():
test_fn()
break
elif choice in tests:
tests[choice]()
else:
print(f"Invalid choice: {choice}")
print("\n" + "=" * 70)
print("DONE")
print("=" * 70)
if __name__ == "__main__":
main()
+30 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
from phase_config import THINKING_BUDGET_MAP, get_thinking_budget
from phase_config import THINKING_BUDGET_MAP, get_thinking_budget, sanitize_thinking_level
class TestThinkingLevelValidation:
@@ -95,3 +95,32 @@ class TestThinkingLevelValidation:
budget = get_thinking_budget("ultrathink")
assert budget == THINKING_BUDGET_MAP["medium"]
assert "Invalid thinking_level 'ultrathink'" in caplog.text
class TestSanitizeThinkingLevel:
"""Test sanitize_thinking_level for CLI argparse validation."""
def test_valid_levels_pass_through(self):
"""Test that valid thinking levels are returned unchanged."""
assert sanitize_thinking_level("low") == "low"
assert sanitize_thinking_level("medium") == "medium"
assert sanitize_thinking_level("high") == "high"
def test_ultrathink_maps_to_high(self):
"""Test that legacy 'ultrathink' is mapped to 'high'."""
assert sanitize_thinking_level("ultrathink") == "high"
def test_none_maps_to_low(self):
"""Test that legacy 'none' is mapped to 'low'."""
assert sanitize_thinking_level("none") == "low"
def test_unknown_value_defaults_to_medium(self):
"""Test that completely unknown values default to 'medium'."""
assert sanitize_thinking_level("garbage") == "medium"
assert sanitize_thinking_level("") == "medium"
assert sanitize_thinking_level("ULTRA") == "medium"
def test_case_sensitive(self):
"""Test that sanitize_thinking_level is case-sensitive."""
assert sanitize_thinking_level("HIGH") == "medium"
assert sanitize_thinking_level("Medium") == "medium"