diff --git a/apps/backend/agents/base.py b/apps/backend/agents/base.py index d4912311e7..badcb22efb 100644 --- a/apps/backend/agents/base.py +++ b/apps/backend/agents/base.py @@ -13,3 +13,10 @@ # 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 diff --git a/apps/backend/agents/coder.py b/apps/backend/agents/coder.py index b498ff86f3..b544ded6d8 100644 --- a/apps/backend/agents/coder.py +++ b/apps/backend/agents/coder.py @@ -56,7 +56,13 @@ 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 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: # 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 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: 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 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: # 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 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: 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 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: 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 @@ def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: 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: diff --git a/apps/backend/agents/planner.py b/apps/backend/agents/planner.py index c8d15fb8f4..23be81f621 100644 --- a/apps/backend/agents/planner.py +++ b/apps/backend/agents/planner.py @@ -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 ) diff --git a/apps/backend/agents/session.py b/apps/backend/agents/session.py index 263bf17efb..b9f6e50ca3 100644 --- a/apps/backend/agents/session.py +++ b/apps/backend/agents/session.py @@ -46,6 +46,28 @@ 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 diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 29d144744f..a254308d16 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -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: diff --git a/apps/backend/core/simple_client.py b/apps/backend/core/simple_client.py index 0b7db09740..29680db0d7 100644 --- a/apps/backend/core/simple_client.py +++ b/apps/backend/core/simple_client.py @@ -22,6 +22,7 @@ """ 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) diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index c94abc971f..ef125b6590 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -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 diff --git a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx index 502d139cfd..8123eadec9 100644 --- a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx +++ b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx @@ -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() { )} + + {/* Account details for OAuth profiles */} + {isOAuth && authStatus.name && authStatus.name !== 'OAuth' && ( + <> +
+ {/* Account name/email with icon */} +
+
+ + {t('common:usage.account')} +
+ {authStatus.name} +
+
+ + )} diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index f0d505f96f..101ae84c4d 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -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)", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index 00221c50df..f136ed72a0 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -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)", diff --git a/tests/test_auth.py b/tests/test_auth.py index f3b9edbaf0..1f5cee4884 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -465,7 +465,7 @@ def test_does_nothing_when_no_token_available(self, monkeypatch): """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 @@ def test_get_auth_token_decrypts_encrypted_env_token(self, monkeypatch): 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 @@ def test_get_auth_token_returns_decrypted_token_on_success(self, monkeypatch): 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 @@ def test_backward_compatibility_plaintext_tokens(self, monkeypatch): """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 @@ def test_keychain_encrypted_token_decryption_attempted(self, monkeypatch): 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 @@ def test_keychain_encrypted_token_decryption_success(self, monkeypatch): 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 @@ def test_plaintext_keychain_token_not_decrypted(self, monkeypatch): 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 @@ def test_env_var_takes_precedence_over_keychain(self, monkeypatch): 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 @@ def test_encrypted_env_var_precedence_over_plaintext_keychain(self, monkeypatch) 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: diff --git a/tests/test_issue_884_plan_schema.py b/tests/test_issue_884_plan_schema.py index 3c25790de8..3d8cead9b2 100644 --- a/tests/test_issue_884_plan_schema.py +++ b/tests/test_issue_884_plan_schema.py @@ -311,9 +311,9 @@ async def fake_run_agent_session( _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 fake_run_agent_session( 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 fake_run_agent_session( 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 fake_run_agent_session( ) 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)