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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user