* fix: implement agent retry loop for tool concurrency errors (#1546) - Add exponential backoff retry logic (2s, 4s, 8s, 16s, 32s) for 400 tool concurrency errors - Add is_tool_concurrency_error() detection in session.py - Update run_agent_session to return 3-tuple (status, response, error_info) - Track consecutive concurrency errors (max 5 retries before marking subtask as stuck) - Add error context to agent prompt instructing it to use one tool at a time - Fix: Reset first_run=True on planning concurrency errors to retry planning - Update test mocks for run_agent_session 3-tuple return type - Update test mocks for get_token_from_keychain config_dir parameter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix ruff format (line length) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use exception_type string instead of raw exception object Avoids JSON serialization issues and potential internal leaks when error_info is logged or sent via IPC. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review feedback for agent retry loop (#1546) - Extract duplicated concurrency error reset logic into _reset_concurrency_state() helper - Fix stale docstring referencing "exception" key (now "exception_type") - Move `import os` to module level in simple_client.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: fix ruff format (nonlocal line length) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,3 +13,10 @@ logger = logging.getLogger(__name__)
|
||||
# Configuration constants
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# Retry configuration for 400 tool concurrency errors
|
||||
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||
INITIAL_RETRY_DELAY_SECONDS = (
|
||||
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
|
||||
)
|
||||
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
|
||||
|
||||
@@ -56,7 +56,13 @@ from ui import (
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
from .base import (
|
||||
AUTO_CONTINUE_DELAY_SECONDS,
|
||||
HUMAN_INTERVENTION_FILE,
|
||||
INITIAL_RETRY_DELAY_SECONDS,
|
||||
MAX_CONCURRENCY_RETRIES,
|
||||
MAX_RETRY_DELAY_SECONDS,
|
||||
)
|
||||
from .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||
from .session import post_session_processing, run_agent_session
|
||||
from .utils import (
|
||||
@@ -215,6 +221,21 @@ async def run_autonomous_agent(
|
||||
|
||||
# Main loop
|
||||
iteration = 0
|
||||
consecutive_concurrency_errors = 0 # Track consecutive 400 tool concurrency errors
|
||||
current_retry_delay = INITIAL_RETRY_DELAY_SECONDS # Exponential backoff delay
|
||||
concurrency_error_context: str | None = (
|
||||
None # Context to pass to agent after concurrency error
|
||||
)
|
||||
|
||||
def _reset_concurrency_state() -> None:
|
||||
"""Reset concurrency error tracking state after a successful session or non-concurrency error."""
|
||||
nonlocal \
|
||||
consecutive_concurrency_errors, \
|
||||
current_retry_delay, \
|
||||
concurrency_error_context
|
||||
consecutive_concurrency_errors = 0
|
||||
current_retry_delay = INITIAL_RETRY_DELAY_SECONDS
|
||||
concurrency_error_context = None
|
||||
|
||||
while True:
|
||||
iteration += 1
|
||||
@@ -405,6 +426,14 @@ async def run_autonomous_agent(
|
||||
prompt += "\n\n" + graphiti_context
|
||||
print_status("Graphiti memory context loaded", "success")
|
||||
|
||||
# Add concurrency error context if recovering from 400 error
|
||||
if concurrency_error_context:
|
||||
prompt += "\n\n" + concurrency_error_context
|
||||
print_status(
|
||||
f"Added tool concurrency error context (retry {consecutive_concurrency_errors}/{MAX_CONCURRENCY_RETRIES})",
|
||||
"warning",
|
||||
)
|
||||
|
||||
# Show what we're working on
|
||||
print(f"Working on: {highlight(subtask_id)}")
|
||||
print(f"Description: {next_subtask.get('description', 'No description')}")
|
||||
@@ -419,7 +448,7 @@ async def run_autonomous_agent(
|
||||
|
||||
# Run session with async context manager
|
||||
async with client:
|
||||
status, response = await run_agent_session(
|
||||
status, response, error_info = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=current_log_phase
|
||||
)
|
||||
|
||||
@@ -512,6 +541,9 @@ async def run_autonomous_agent(
|
||||
print_build_complete_banner(spec_dir)
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
|
||||
# Reset error tracking on success
|
||||
_reset_concurrency_state()
|
||||
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
@@ -526,6 +558,9 @@ async def run_autonomous_agent(
|
||||
break
|
||||
|
||||
elif status == "continue":
|
||||
# Reset error tracking on successful session
|
||||
_reset_concurrency_state()
|
||||
|
||||
print(
|
||||
muted(
|
||||
f"\nAgent will auto-continue in {AUTO_CONTINUE_DELAY_SECONDS}s..."
|
||||
@@ -556,10 +591,106 @@ async def run_autonomous_agent(
|
||||
|
||||
elif status == "error":
|
||||
emit_phase(ExecutionPhase.FAILED, "Session encountered an error")
|
||||
print_status("Session encountered an error", "error")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
|
||||
# Check if this is a tool concurrency error (400)
|
||||
is_concurrency_error = (
|
||||
error_info and error_info.get("type") == "tool_concurrency"
|
||||
)
|
||||
|
||||
if is_concurrency_error:
|
||||
consecutive_concurrency_errors += 1
|
||||
|
||||
# Check if we've exceeded max retries (allow 5 retries with delays: 2s, 4s, 8s, 16s, 32s)
|
||||
if consecutive_concurrency_errors > MAX_CONCURRENCY_RETRIES:
|
||||
print_status(
|
||||
f"Tool concurrency limit hit {consecutive_concurrency_errors} times consecutively",
|
||||
"error",
|
||||
)
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" CRITICAL: Agent stuck in retry loop")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(
|
||||
"The agent is repeatedly hitting Claude API's tool concurrency limit."
|
||||
)
|
||||
print(
|
||||
"This usually means the agent is trying to use too many tools at once."
|
||||
)
|
||||
print()
|
||||
print("Possible solutions:")
|
||||
print(" 1. The agent needs to reduce tool usage per request")
|
||||
print(" 2. Break down the current subtask into smaller steps")
|
||||
print(" 3. Manual intervention may be required")
|
||||
print()
|
||||
print(f"Error: {error_info.get('message', 'Unknown error')[:200]}")
|
||||
print()
|
||||
|
||||
# Mark current subtask as stuck if we have one
|
||||
if subtask_id:
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id,
|
||||
f"Tool concurrency errors after {consecutive_concurrency_errors} retries",
|
||||
)
|
||||
print_status(f"Subtask {subtask_id} marked as STUCK", "error")
|
||||
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
break # Exit the loop
|
||||
|
||||
# Exponential backoff: 2s, 4s, 8s, 16s, 32s
|
||||
print_status(
|
||||
f"Tool concurrency error (retry {consecutive_concurrency_errors}/{MAX_CONCURRENCY_RETRIES})",
|
||||
"warning",
|
||||
)
|
||||
print(
|
||||
muted(
|
||||
f"Waiting {current_retry_delay}s before retry (exponential backoff)..."
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
# Set context for next retry so agent knows to adjust behavior
|
||||
error_context_message = (
|
||||
"## CRITICAL: TOOL CONCURRENCY ERROR\n\n"
|
||||
f"Your previous session hit Claude API's tool concurrency limit (HTTP 400).\n"
|
||||
f"This is retry {consecutive_concurrency_errors}/{MAX_CONCURRENCY_RETRIES}.\n\n"
|
||||
"**IMPORTANT: You MUST adjust your approach:**\n"
|
||||
"1. Use ONE tool at a time - do NOT call multiple tools in parallel\n"
|
||||
"2. Wait for each tool result before calling the next tool\n"
|
||||
"3. Avoid starting with `pwd` or multiple Read calls at once\n"
|
||||
"4. If you need to read multiple files, read them one by one\n"
|
||||
"5. Take a more incremental, step-by-step approach\n\n"
|
||||
"Start by focusing on ONE specific action for this subtask."
|
||||
)
|
||||
|
||||
# If we're in planning phase, reset first_run to True so next iteration
|
||||
# re-enters the planning branch (fix for issue #1565)
|
||||
if current_log_phase == LogPhase.PLANNING:
|
||||
first_run = True
|
||||
planning_retry_context = error_context_message
|
||||
print_status(
|
||||
"Planning session failed - will retry planning", "warning"
|
||||
)
|
||||
else:
|
||||
concurrency_error_context = error_context_message
|
||||
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
await asyncio.sleep(current_retry_delay)
|
||||
|
||||
# Double the retry delay for next time (cap at MAX_RETRY_DELAY_SECONDS)
|
||||
current_retry_delay = min(
|
||||
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
|
||||
)
|
||||
|
||||
else:
|
||||
# Other errors - use standard retry logic
|
||||
print_status("Session encountered an error", "error")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
|
||||
# Reset concurrency error tracking on non-concurrency errors
|
||||
_reset_concurrency_state()
|
||||
|
||||
# Small delay between sessions
|
||||
if max_iterations is None or iteration < max_iterations:
|
||||
|
||||
@@ -111,7 +111,7 @@ async def run_followup_planner(
|
||||
try:
|
||||
# Run single planning session
|
||||
async with client:
|
||||
status, response = await run_agent_session(
|
||||
status, response, error_info = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
|
||||
)
|
||||
|
||||
|
||||
@@ -46,6 +46,28 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a 400 tool concurrency error from Claude API.
|
||||
|
||||
Tool concurrency errors occur when too many tools are used simultaneously
|
||||
in a single API request, hitting Claude's concurrent tool use limit.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a tool concurrency error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
# Check for 400 status AND tool concurrency keywords
|
||||
return "400" in error_str and (
|
||||
("tool" in error_str and "concurrency" in error_str)
|
||||
or "too many tools" in error_str
|
||||
or "concurrent tool" in error_str
|
||||
)
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
@@ -317,7 +339,7 @@ async def run_agent_session(
|
||||
spec_dir: Path,
|
||||
verbose: bool = False,
|
||||
phase: LogPhase = LogPhase.CODING,
|
||||
) -> tuple[str, str]:
|
||||
) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Run a single agent session using Claude Agent SDK.
|
||||
|
||||
@@ -329,10 +351,13 @@ async def run_agent_session(
|
||||
phase: Current execution phase for logging
|
||||
|
||||
Returns:
|
||||
(status, response_text) where status is:
|
||||
- "continue" if agent should continue working
|
||||
- "complete" if all subtasks complete
|
||||
- "error" if an error occurred
|
||||
(status, response_text, error_info) where:
|
||||
- status: "continue", "complete", or "error"
|
||||
- response_text: Agent's response text
|
||||
- error_info: Dict with error details (empty if no error):
|
||||
- "type": "tool_concurrency" or "other"
|
||||
- "message": Error message string
|
||||
- "exception_type": Exception class name string
|
||||
"""
|
||||
debug_section("session", f"Agent Session - {phase.value}")
|
||||
debug(
|
||||
@@ -529,7 +554,7 @@ async def run_agent_session(
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "complete", response_text
|
||||
return "complete", response_text, {}
|
||||
|
||||
debug_success(
|
||||
"session",
|
||||
@@ -538,17 +563,36 @@ async def run_agent_session(
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "continue", response_text
|
||||
return "continue", response_text, {}
|
||||
|
||||
except Exception as e:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
error_type = "tool_concurrency" if is_concurrency else "other"
|
||||
|
||||
debug_error(
|
||||
"session",
|
||||
f"Session error: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
error_category=error_type,
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
)
|
||||
print(f"Error during agent session: {e}")
|
||||
|
||||
# Log concurrency errors prominently
|
||||
if is_concurrency:
|
||||
print("\n⚠️ Tool concurrency limit reached (400 error)")
|
||||
print(" Claude API limits concurrent tool use in a single request")
|
||||
print(f" Error: {str(e)[:200]}\n")
|
||||
else:
|
||||
print(f"Error during agent session: {e}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
return "error", str(e)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": str(e),
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", str(e), error_info
|
||||
|
||||
@@ -490,8 +490,16 @@ def create_client(
|
||||
(see security.py for ALLOWED_COMMANDS)
|
||||
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
|
||||
"""
|
||||
# Get OAuth token - Claude CLI handles token lifecycle internally
|
||||
oauth_token = require_auth_token()
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, CLAUDE_CONFIG_DIR, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get the config dir for profile-specific credential lookup
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
@@ -499,10 +507,11 @@ def create_client(
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
|
||||
@@ -22,6 +22,7 @@ Example usage:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
@@ -72,21 +73,26 @@ def create_simple_client(
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token()
|
||||
# Get environment variables for SDK (including CLAUDE_CONFIG_DIR if set)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get the config dir for profile-specific credential lookup
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
import os
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Get environment variables for SDK
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ import { GitHubSetupModal } from './components/GitHubSetupModal';
|
||||
import { useProjectStore, loadProjects, addProject, initializeProject, removeProject } from './stores/project-store';
|
||||
import { useTaskStore, loadTasks } from './stores/task-store';
|
||||
import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store';
|
||||
import { useClaudeProfileStore } from './stores/claude-profile-store';
|
||||
import { useClaudeProfileStore, loadClaudeProfiles } from './stores/claude-profile-store';
|
||||
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
|
||||
import { initializeGitHubListeners } from './stores/github';
|
||||
import { initDownloadProgressListener } from './stores/download-store';
|
||||
@@ -184,6 +184,7 @@ export function App() {
|
||||
loadProjects();
|
||||
loadSettings();
|
||||
loadProfiles();
|
||||
loadClaudeProfiles();
|
||||
// Initialize global GitHub listeners (PR reviews, etc.) so they persist across navigation
|
||||
initializeGitHubListeners();
|
||||
// Initialize global download progress listener for Ollama model downloads
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './ui/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useClaudeProfileStore } from '../stores/claude-profile-store';
|
||||
import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection';
|
||||
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
|
||||
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
|
||||
@@ -49,8 +50,13 @@ const OAUTH_FALLBACK = {
|
||||
} as const;
|
||||
|
||||
export function AuthStatusIndicator() {
|
||||
// Subscribe to profile state from settings store
|
||||
// Subscribe to profile state from settings store (API profiles)
|
||||
const { profiles, activeProfileId } = useSettingsStore();
|
||||
|
||||
// Subscribe to Claude OAuth profile state
|
||||
const claudeProfiles = useClaudeProfileStore((state) => state.profiles);
|
||||
const activeClaudeProfileId = useClaudeProfileStore((state) => state.activeProfileId);
|
||||
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Track usage data for warning badge
|
||||
@@ -102,6 +108,7 @@ export function AuthStatusIndicator() {
|
||||
|
||||
// Compute auth status and provider detection using useMemo to avoid unnecessary re-renders
|
||||
const authStatus = useMemo(() => {
|
||||
// First check if user is using API profile auth (has active API profile)
|
||||
if (activeProfileId) {
|
||||
const activeProfile = profiles.find(p => p.id === activeProfileId);
|
||||
if (activeProfile) {
|
||||
@@ -119,12 +126,36 @@ export function AuthStatusIndicator() {
|
||||
badgeColor: getProviderBadgeColor(provider)
|
||||
};
|
||||
}
|
||||
// Profile ID set but profile not found - fallback to OAuth
|
||||
return OAUTH_FALLBACK;
|
||||
}
|
||||
// No active profile - using OAuth
|
||||
|
||||
// No active API profile - check Claude OAuth profiles directly
|
||||
if (activeClaudeProfileId && claudeProfiles.length > 0) {
|
||||
const activeClaudeProfile = claudeProfiles.find(p => p.id === activeClaudeProfileId);
|
||||
if (activeClaudeProfile) {
|
||||
return {
|
||||
type: 'oauth' as const,
|
||||
name: activeClaudeProfile.email || activeClaudeProfile.name,
|
||||
provider: 'anthropic' as const,
|
||||
providerLabel: 'Anthropic',
|
||||
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to usage data if Claude profiles aren't loaded yet
|
||||
if (usage && (usage.profileName || usage.profileEmail)) {
|
||||
return {
|
||||
type: 'oauth' as const,
|
||||
name: usage.profileEmail || usage.profileName,
|
||||
provider: 'anthropic' as const,
|
||||
providerLabel: 'Anthropic',
|
||||
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
|
||||
};
|
||||
}
|
||||
|
||||
// No auth info available - fallback to generic OAuth
|
||||
return OAUTH_FALLBACK;
|
||||
}, [activeProfileId, profiles]);
|
||||
}, [activeProfileId, profiles, activeClaudeProfileId, claudeProfiles, usage]);
|
||||
|
||||
// Helper function to truncate ID for display
|
||||
const truncateId = (id: string): string => {
|
||||
@@ -274,6 +305,22 @@ export function AuthStatusIndicator() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Account details for OAuth profiles */}
|
||||
{isOAuth && authStatus.name && authStatus.name !== 'OAuth' && (
|
||||
<>
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
{/* Account name/email with icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
<span className="text-[10px]">{t('common:usage.account')}</span>
|
||||
</div>
|
||||
<span className="font-medium text-[10px]">{authStatus.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"clickToOpenSettings": "Click to open Settings →",
|
||||
"sessionShort": "5-hour session usage",
|
||||
"weeklyShort": "7-day weekly usage",
|
||||
"swap": "Swap"
|
||||
"swap": "Swap",
|
||||
"account": "Account"
|
||||
},
|
||||
"oauth": {
|
||||
"enterCode": "Manual Code Entry (Fallback)",
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"clickToOpenSettings": "Cliquez pour ouvrir les Paramètres →",
|
||||
"sessionShort": "Utilisation session 5 heures",
|
||||
"weeklyShort": "Utilisation hebdomadaire 7 jours",
|
||||
"swap": "Changer"
|
||||
"swap": "Changer",
|
||||
"account": "Compte"
|
||||
},
|
||||
"oauth": {
|
||||
"enterCode": "Saisie manuelle du code (secours)",
|
||||
|
||||
+9
-9
@@ -465,7 +465,7 @@ class TestEnsureClaudeCodeOAuthToken:
|
||||
"""Doesn't set env var when no auth token is available."""
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
# Ensure keychain returns None
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
@@ -646,7 +646,7 @@ class TestTokenDecryption:
|
||||
from unittest.mock import patch
|
||||
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:testtoken123456789")
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
|
||||
|
||||
with patch("core.auth.decrypt_token") as mock_decrypt:
|
||||
# Simulate decryption failure
|
||||
@@ -669,7 +669,7 @@ class TestTokenDecryption:
|
||||
decrypted_token = "sk-ant-oat01-decrypted-token"
|
||||
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
|
||||
|
||||
with patch("core.auth.decrypt_token") as mock_decrypt:
|
||||
mock_decrypt.return_value = decrypted_token
|
||||
@@ -687,7 +687,7 @@ class TestTokenDecryption:
|
||||
"""Verify plaintext tokens continue to work unchanged."""
|
||||
token = "sk-ant-oat01-test"
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", token)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
|
||||
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
|
||||
|
||||
from core.auth import get_auth_token
|
||||
|
||||
@@ -990,7 +990,7 @@ class TestTokenDecryptionKeychain:
|
||||
|
||||
encrypted_token = "enc:keychaintoken1234"
|
||||
monkeypatch.setattr(
|
||||
"core.auth.get_token_from_keychain", lambda: encrypted_token
|
||||
"core.auth.get_token_from_keychain", lambda config_dir=None: encrypted_token
|
||||
)
|
||||
|
||||
with patch("core.auth.decrypt_token") as mock_decrypt:
|
||||
@@ -1012,7 +1012,7 @@ class TestTokenDecryptionKeychain:
|
||||
decrypted_token = "sk-ant-oat01-from-keychain"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.auth.get_token_from_keychain", lambda: encrypted_token
|
||||
"core.auth.get_token_from_keychain", lambda config_dir=None: encrypted_token
|
||||
)
|
||||
|
||||
with patch("core.auth.decrypt_token") as mock_decrypt:
|
||||
@@ -1031,7 +1031,7 @@ class TestTokenDecryptionKeychain:
|
||||
|
||||
plaintext_token = "sk-ant-oat01-keychain-plaintext"
|
||||
monkeypatch.setattr(
|
||||
"core.auth.get_token_from_keychain", lambda: plaintext_token
|
||||
"core.auth.get_token_from_keychain", lambda config_dir=None: plaintext_token
|
||||
)
|
||||
|
||||
with patch("core.auth.decrypt_token") as mock_decrypt:
|
||||
@@ -1049,7 +1049,7 @@ class TestTokenDecryptionKeychain:
|
||||
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", env_token)
|
||||
monkeypatch.setattr(
|
||||
"core.auth.get_token_from_keychain", lambda: keychain_token
|
||||
"core.auth.get_token_from_keychain", lambda config_dir=None: keychain_token
|
||||
)
|
||||
|
||||
from core.auth import get_auth_token
|
||||
@@ -1067,7 +1067,7 @@ class TestTokenDecryptionKeychain:
|
||||
|
||||
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_env)
|
||||
monkeypatch.setattr(
|
||||
"core.auth.get_token_from_keychain", lambda: keychain_token
|
||||
"core.auth.get_token_from_keychain", lambda config_dir=None: keychain_token
|
||||
)
|
||||
|
||||
with patch("core.auth.decrypt_token") as mock_decrypt:
|
||||
|
||||
@@ -311,9 +311,9 @@ async def test_planner_session_does_not_trigger_post_session_processing_on_retry
|
||||
_spec_dir: Path,
|
||||
_verbose: bool = False,
|
||||
phase: LogPhase = LogPhase.CODING,
|
||||
) -> tuple[str, str]:
|
||||
) -> tuple[str, str, dict]:
|
||||
assert phase == LogPhase.PLANNING
|
||||
return "error", "planner failed"
|
||||
return "error", "planner failed", {}
|
||||
|
||||
monkeypatch.setattr("agents.coder.create_client", fake_create_client)
|
||||
monkeypatch.setattr("agents.coder.get_graphiti_context", fake_get_graphiti_context)
|
||||
@@ -374,7 +374,7 @@ async def test_worktree_planning_to_coding_sync_updates_source_phase_status(
|
||||
spec_dir: Path,
|
||||
_verbose: bool = False,
|
||||
phase: LogPhase = LogPhase.CODING,
|
||||
) -> tuple[str, str]:
|
||||
) -> tuple[str, str, dict]:
|
||||
if phase == LogPhase.PLANNING:
|
||||
plan = {
|
||||
"feature": "Test feature",
|
||||
@@ -397,7 +397,7 @@ async def test_worktree_planning_to_coding_sync_updates_source_phase_status(
|
||||
json.dumps(plan, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return "continue", "planned"
|
||||
return "continue", "planned", {}
|
||||
|
||||
# First coding session should see planning already completed in source spec logs
|
||||
# Note: task_logs.json is created/synced by run_autonomous_agent; absence indicates a bug.
|
||||
@@ -406,7 +406,7 @@ async def test_worktree_planning_to_coding_sync_updates_source_phase_status(
|
||||
)
|
||||
assert logs["phases"]["planning"]["status"] == "completed"
|
||||
assert logs["phases"]["coding"]["status"] == "active"
|
||||
return "complete", "done"
|
||||
return "complete", "done", {}
|
||||
|
||||
monkeypatch.setattr("agents.coder.create_client", fake_create_client)
|
||||
monkeypatch.setattr("agents.coder.get_graphiti_context", fake_get_graphiti_context)
|
||||
|
||||
Reference in New Issue
Block a user