Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d64615211 | |||
| cd89147003 | |||
| ded6aad4f7 | |||
| f149a7fbd7 | |||
| c2245b8122 | |||
| 950da45e4a | |||
| 25acf2826c | |||
| 5ac40f57c1 | |||
| 39aa088725 | |||
| 8de8039db2 | |||
| 68e782df1f | |||
| 6f751e5e74 | |||
| f4788e4af8 | |||
| 3f95765cf2 | |||
| 923880f5b2 | |||
| 390ba6a588 | |||
| aa7f56e5d0 | |||
| a9b93e6dfb | |||
| cec8e65ee8 | |||
| 48d5f7a321 | |||
| bb7e189374 | |||
| 7589f8e4f4 | |||
| 57e38a692c | |||
| d09ebb8504 | |||
| 087091cef8 | |||
| f085c08bd0 | |||
| f121f9cdd2 | |||
| f41f15e592 | |||
| bdff9141af | |||
| 8c9a504df9 | |||
| 8a7443d24d | |||
| e0d53adb47 | |||
| 323b0d3be4 | |||
| d639f6ef84 | |||
| 4438c0b109 | |||
| 32bf353da3 | |||
| 2db36982fb | |||
| 09f059ca3b | |||
| b5de0d9ffa | |||
| 445da186c8 | |||
| f8499e965b | |||
| 826583b826 | |||
| ac4fe4f423 | |||
| 152e540933 | |||
| 2c2a8a7545 | |||
| 7e799ee578 | |||
| 7bebf62393 | |||
| 216b58bcf1 |
@@ -7,6 +7,7 @@
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
nul
|
||||
|
||||
# ===========================
|
||||
# Security - Environment & Secrets
|
||||
|
||||
+40
-54
@@ -1,52 +1,36 @@
|
||||
#!/bin/sh
|
||||
|
||||
# =============================================================================
|
||||
# GIT WORKTREE CONTEXT HANDLING
|
||||
# GIT WORKTREE ENVIRONMENT CLEANUP
|
||||
# =============================================================================
|
||||
# When running in a worktree, we need to preserve git context to prevent HEAD
|
||||
# corruption. However, we must also CLEAR these variables when NOT in a worktree
|
||||
# to prevent cross-worktree contamination (files leaking between worktrees).
|
||||
# Git automatically sets GIT_DIR (and CWD to the working tree root) before
|
||||
# running hooks -- even in worktrees. We do NOT need to manually parse .git
|
||||
# files or export GIT_DIR/GIT_WORK_TREE.
|
||||
#
|
||||
# The bug: If GIT_DIR/GIT_WORK_TREE are set from a previous worktree session
|
||||
# and this hook runs in the main repo (where .git is a directory, not a file),
|
||||
# git commands will target the wrong repository, causing files to appear as
|
||||
# untracked in the wrong location.
|
||||
#
|
||||
# Fix: Explicitly unset these variables when NOT in a worktree context.
|
||||
# However, external tools (IDEs, agents, parent shells) may leave stale
|
||||
# GIT_DIR/GIT_WORK_TREE values in the environment. If these point to a
|
||||
# different repo or worktree, git commands in this hook would target the
|
||||
# wrong repository. Unsetting them lets git re-resolve the correct values
|
||||
# from the working directory.
|
||||
# =============================================================================
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
# We're in a worktree (.git is a file pointing to the actual git dir)
|
||||
# Use -n with /p to only print lines that match the gitdir: prefix, head -1 for safety
|
||||
WORKTREE_GIT_DIR=$(sed -n 's/^gitdir: //p' .git | head -1)
|
||||
if [ -n "$WORKTREE_GIT_DIR" ] && [ -d "$WORKTREE_GIT_DIR" ]; then
|
||||
export GIT_DIR="$WORKTREE_GIT_DIR"
|
||||
export GIT_WORK_TREE="$(pwd)"
|
||||
else
|
||||
# .git file exists but is malformed or points to non-existent directory
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
else
|
||||
# We're in the main repo (.git is a directory)
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
|
||||
# =============================================================================
|
||||
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
|
||||
# =============================================================================
|
||||
# If core.worktree is set in the main repo's config (pointing to a worktree),
|
||||
# this indicates previous corruption. Fix it automatically.
|
||||
if [ ! -f ".git" ]; then
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting, removing it..."
|
||||
if ! git config --unset core.worktree 2>/dev/null; then
|
||||
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
|
||||
fi
|
||||
# core.worktree lives in the SHARED .git/config (not per-worktree). If any
|
||||
# process accidentally writes it (e.g., running `git init` with a leaked
|
||||
# GIT_WORK_TREE), ALL repos and worktrees see the wrong working tree root,
|
||||
# causing files from one worktree to "leak" into others.
|
||||
#
|
||||
# This check runs from both main repo and worktree contexts since the config
|
||||
# is shared and corruption can happen from either.
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting ('$CORE_WORKTREE'), removing it..."
|
||||
if ! git config --unset core.worktree 2>/dev/null; then
|
||||
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -178,37 +162,39 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
# Run from repo root (not apps/backend) so tests that use Path.resolve() get correct CWD.
|
||||
# PYTHONPATH includes apps/backend so imports resolve correctly.
|
||||
echo "Running Python tests..."
|
||||
(
|
||||
cd apps/backend
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# Also skip tests that require optional dependencies (pydantic structured outputs)
|
||||
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py --ignore=../../tests/test_finding_validation.py --ignore=../../tests/test_sdk_structured_output.py --ignore=../../tests/test_structured_outputs.py"
|
||||
|
||||
# Also skip gitlab_e2e (e2e test sensitive to test-ordering env contamination, validated by CI)
|
||||
IGNORE_TESTS="--ignore=tests/test_graphiti.py --ignore=tests/test_merge_file_tracker.py --ignore=tests/test_service_orchestrator.py --ignore=tests/test_worktree.py --ignore=tests/test_workspace.py --ignore=tests/test_finding_validation.py --ignore=tests/test_sdk_structured_output.py --ignore=tests/test_structured_outputs.py --ignore=tests/test_gitlab_e2e.py"
|
||||
# Determine Python executable from venv
|
||||
VENV_PYTHON=""
|
||||
if [ -f ".venv/bin/python" ]; then
|
||||
VENV_PYTHON=".venv/bin/python"
|
||||
elif [ -f ".venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON=".venv/Scripts/python.exe"
|
||||
if [ -f "apps/backend/.venv/bin/python" ]; then
|
||||
VENV_PYTHON="apps/backend/.venv/bin/python"
|
||||
elif [ -f "apps/backend/.venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON="apps/backend/.venv/Scripts/python.exe"
|
||||
fi
|
||||
|
||||
|
||||
# -k "not windows_path": skip tests using fake Windows paths that break
|
||||
# Path.resolve() on macOS/Linux. These are validated by CI on all platforms.
|
||||
if [ -n "$VENV_PYTHON" ]; then
|
||||
# Check if pytest is installed in venv
|
||||
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
PYTHONPATH=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: pytest not installed in venv. Installing test dependencies..."
|
||||
$VENV_PYTHON -m pip install -q -r ../../tests/requirements-test.txt
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
$VENV_PYTHON -m pip install -q -r tests/requirements-test.txt
|
||||
PYTHONPATH=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
fi
|
||||
elif [ -d ".venv" ]; then
|
||||
elif [ -d "apps/backend/.venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Fixed task logs disappearing after app restart in development mode (issue #1657)
|
||||
|
||||
- Fixed Kanban board status flip-flopping and multi-location task deletion
|
||||
|
||||
- Fixed Windows CLI detection and version selection UX issues
|
||||
|
||||
@@ -35,18 +35,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.3)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.3-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.3-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.3-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
# API_TIMEOUT_MS=600000
|
||||
|
||||
# Model override (OPTIONAL)
|
||||
# Default: claude-opus-4-5-20251101
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
|
||||
# Default: claude-opus-4-6
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-6
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.2"
|
||||
__version__ = "2.7.6-beta.3"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -15,6 +15,9 @@ logger = logging.getLogger(__name__)
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# Retry configuration for subtask execution
|
||||
MAX_SUBTASK_RETRIES = 5 # Maximum attempts before marking subtask as stuck
|
||||
|
||||
# Retry configuration for 400 tool concurrency errors
|
||||
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||
INITIAL_RETRY_DELAY_SECONDS = (
|
||||
|
||||
+145
-12
@@ -21,7 +21,12 @@ from linear_updater import (
|
||||
linear_task_started,
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
@@ -69,6 +74,7 @@ from .base import (
|
||||
MAX_CONCURRENCY_RETRIES,
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS,
|
||||
MAX_RETRY_DELAY_SECONDS,
|
||||
MAX_SUBTASK_RETRIES,
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS,
|
||||
RATE_LIMIT_PAUSE_FILE,
|
||||
RESUME_FILE,
|
||||
@@ -87,6 +93,60 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILE VALIDATION UTILITIES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def validate_subtask_files(subtask: dict, project_dir: Path) -> dict:
|
||||
"""
|
||||
Validate all files_to_modify exist before subtask execution.
|
||||
|
||||
Args:
|
||||
subtask: Subtask dictionary containing files_to_modify array
|
||||
project_dir: Root directory of the project
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
- success (bool): True if all files exist
|
||||
- error (str): Error message if validation fails
|
||||
- missing_files (list): List of missing file paths
|
||||
- invalid_paths (list): List of paths that resolve outside the project
|
||||
- suggestion (str): Actionable suggestion for resolution
|
||||
"""
|
||||
missing_files = []
|
||||
invalid_paths = []
|
||||
|
||||
resolved_project = Path(project_dir).resolve()
|
||||
for file_path in subtask.get("files_to_modify", []):
|
||||
full_path = (resolved_project / file_path).resolve()
|
||||
if not full_path.is_relative_to(resolved_project):
|
||||
invalid_paths.append(file_path)
|
||||
continue
|
||||
if not full_path.exists():
|
||||
missing_files.append(file_path)
|
||||
|
||||
if invalid_paths:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Paths resolve outside project boundary: {', '.join(invalid_paths)}",
|
||||
"missing_files": missing_files,
|
||||
"invalid_paths": invalid_paths,
|
||||
"suggestion": "Update implementation plan to use paths within the project directory",
|
||||
}
|
||||
|
||||
if missing_files:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Planned files do not exist: {', '.join(missing_files)}",
|
||||
"missing_files": missing_files,
|
||||
"invalid_paths": [],
|
||||
"suggestion": "Update implementation plan with correct filenames or create missing files",
|
||||
}
|
||||
|
||||
return {"success": True, "missing_files": [], "invalid_paths": []}
|
||||
|
||||
|
||||
def _check_and_clear_resume_file(
|
||||
resume_file: Path,
|
||||
pause_file: Path,
|
||||
@@ -524,20 +584,28 @@ async def run_autonomous_agent(
|
||||
# first_run means we're in planning phase, otherwise coding phase
|
||||
current_phase = "planning" if first_run else "coding"
|
||||
phase_model = get_phase_model(spec_dir, current_phase, model)
|
||||
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
|
||||
|
||||
# Create client (fresh context) with phase-specific model and thinking
|
||||
# Use appropriate agent_type for correct tool permissions and thinking budget
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner" if first_run else "coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
phase_betas = get_phase_model_betas(spec_dir, current_phase, model)
|
||||
thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
spec_dir, current_phase, phase_model
|
||||
)
|
||||
|
||||
# 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
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner",
|
||||
betas=phase_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
prompt = generate_planner_prompt(spec_dir, project_dir)
|
||||
if planning_retry_context:
|
||||
prompt += "\n\n" + planning_retry_context
|
||||
@@ -615,6 +683,70 @@ async def run_autonomous_agent(
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
|
||||
# Validate that all files_to_modify exist before attempting execution
|
||||
# This prevents infinite retry loops when implementation plan references non-existent files
|
||||
validation_result = validate_subtask_files(next_subtask, project_dir)
|
||||
if not validation_result["success"]:
|
||||
# File validation failed - record error and skip session
|
||||
error_msg = validation_result["error"]
|
||||
suggestion = validation_result.get("suggestion", "")
|
||||
|
||||
print()
|
||||
print_status(f"File validation failed: {error_msg}", "error")
|
||||
if suggestion:
|
||||
print(muted(f"Suggestion: {suggestion}"))
|
||||
print()
|
||||
|
||||
# Record the validation failure in recovery manager
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=iteration,
|
||||
success=False,
|
||||
approach="File validation failed before execution",
|
||||
error=error_msg,
|
||||
)
|
||||
|
||||
# Log the validation failure
|
||||
if task_logger:
|
||||
task_logger.log_error(
|
||||
f"File validation failed: {error_msg}", LogPhase.CODING
|
||||
)
|
||||
|
||||
# Check if subtask has exceeded max retries
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
if attempt_count >= MAX_SUBTASK_RETRIES:
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id,
|
||||
f"File validation failed after {attempt_count} attempts: {error_msg}",
|
||||
)
|
||||
print_status(
|
||||
f"Subtask {subtask_id} marked as STUCK after {attempt_count} failed validation attempts",
|
||||
"error",
|
||||
)
|
||||
print(
|
||||
muted(
|
||||
"Consider: update implementation plan with correct filenames"
|
||||
)
|
||||
)
|
||||
|
||||
# Update status
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
|
||||
# Small delay before retry
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
continue # Skip to next iteration
|
||||
|
||||
# Create client for coding phase (after file validation passes)
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="coder",
|
||||
betas=phase_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
# Get attempt count for recovery context
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
recovery_hints = (
|
||||
@@ -730,11 +862,12 @@ async def run_autonomous_agent(
|
||||
linear_enabled=linear_is_enabled,
|
||||
status_manager=status_manager,
|
||||
source_spec_dir=source_spec_dir,
|
||||
error_info=error_info,
|
||||
)
|
||||
|
||||
# Check for stuck subtasks
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
if not success and attempt_count >= 3:
|
||||
if not success and attempt_count >= MAX_SUBTASK_RETRIES:
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id, f"Failed after {attempt_count} attempts"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,12 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
@@ -94,12 +99,22 @@ async def run_followup_planner(
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
planning_betas = get_phase_model_betas(spec_dir, "planning", model)
|
||||
thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
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,
|
||||
planning_model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
agent_type="planner",
|
||||
betas=planning_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
# Generate follow-up planner prompt
|
||||
|
||||
+121
-105
@@ -7,10 +7,15 @@ memory updates, recovery tracking, and Linear integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from core.error_utils import (
|
||||
is_authentication_error,
|
||||
is_rate_limit_error,
|
||||
is_tool_concurrency_error,
|
||||
)
|
||||
from core.file_utils import write_json_atomic
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from insight_extractor import extract_session_insights
|
||||
from linear_updater import (
|
||||
@@ -21,7 +26,7 @@ from progress import (
|
||||
count_subtasks_detailed,
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager
|
||||
from recovery import RecoveryManager, check_and_recover, reset_subtask
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
@@ -48,113 +53,36 @@ 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.
|
||||
def _execute_recovery_action(
|
||||
recovery_action,
|
||||
recovery_manager: RecoveryManager,
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
) -> None:
|
||||
"""Execute a recovery action (rollback/retry/skip/escalate)."""
|
||||
if not recovery_action:
|
||||
return
|
||||
|
||||
Tool concurrency errors occur when too many tools are used simultaneously
|
||||
in a single API request, hitting Claude's concurrent tool use limit.
|
||||
print_status(f"Recovery action: {recovery_action.action}", "info")
|
||||
print_status(f"Reason: {recovery_action.reason}", "info")
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
if recovery_action.action == "rollback":
|
||||
print_status(f"Rolling back to {recovery_action.target[:8]}", "warning")
|
||||
if recovery_manager.rollback_to_commit(recovery_action.target):
|
||||
print_status("Rollback successful", "success")
|
||||
else:
|
||||
print_status("Rollback failed", "error")
|
||||
|
||||
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
|
||||
)
|
||||
elif recovery_action.action == "retry":
|
||||
print_status(f"Resetting subtask {subtask_id} for retry", "info")
|
||||
reset_subtask(spec_dir, project_dir, subtask_id)
|
||||
print_status("Subtask reset - will retry with different approach", "success")
|
||||
|
||||
|
||||
def is_rate_limit_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a rate limit error (429 or similar).
|
||||
|
||||
Rate limit errors occur when the API usage quota is exceeded,
|
||||
either for session limits or weekly limits.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 429 with word boundaries to avoid false positives
|
||||
if re.search(r"\b429\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other rate limit indicators
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"usage limit",
|
||||
"quota exceeded",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_authentication_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is an authentication error (401, token expired, etc.).
|
||||
|
||||
Authentication errors occur when OAuth tokens are invalid, expired,
|
||||
or have been revoked (e.g., after token refresh on another process).
|
||||
|
||||
Validation approach:
|
||||
- HTTP 401 status code is checked with word boundaries to minimize false positives
|
||||
- Additional string patterns are validated against lowercase error messages
|
||||
- Patterns are designed to match known Claude API and OAuth error formats
|
||||
|
||||
Known false positive risks:
|
||||
- Generic error messages containing "unauthorized" or "access denied" may match
|
||||
even if not related to authentication (e.g., file permission errors)
|
||||
- Error messages containing these keywords in user-provided content could match
|
||||
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
|
||||
|
||||
Real-world validation:
|
||||
- Pattern matching has been tested against actual Claude API error responses
|
||||
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
|
||||
- If false positive occurs, user can simply resume without re-authenticating
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is an authentication error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 401 with word boundaries to avoid false positives
|
||||
if re.search(r"\b401\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other authentication indicators
|
||||
# NOTE: "authentication failed" and "authentication error" are more specific patterns
|
||||
# to reduce false positives from generic "authentication" mentions
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"authentication failed",
|
||||
"authentication error",
|
||||
"unauthorized",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"authentication_error",
|
||||
"invalid_token",
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
]
|
||||
)
|
||||
elif recovery_action.action in ("skip", "escalate"):
|
||||
print_status(f"Marking subtask {subtask_id} as stuck", "warning")
|
||||
recovery_manager.mark_subtask_stuck(subtask_id, recovery_action.reason)
|
||||
print_status("Subtask marked for human intervention", "warning")
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
@@ -168,6 +96,7 @@ async def post_session_processing(
|
||||
linear_enabled: bool = False,
|
||||
status_manager: StatusManager | None = None,
|
||||
source_spec_dir: Path | None = None,
|
||||
error_info: dict | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Process session results and update memory automatically.
|
||||
@@ -185,6 +114,7 @@ async def post_session_processing(
|
||||
linear_enabled: Whether Linear integration is enabled
|
||||
status_manager: Optional status manager for ccstatusline
|
||||
source_spec_dir: Original spec directory (for syncing back from worktree)
|
||||
error_info: Error information from run_agent_session (for rate limit detection)
|
||||
|
||||
Returns:
|
||||
True if subtask was completed successfully
|
||||
@@ -316,6 +246,77 @@ async def post_session_processing(
|
||||
error="Subtask not marked as completed",
|
||||
)
|
||||
|
||||
# Check if this was a concurrency error - if so, reset subtask to pending for retry
|
||||
is_concurrency_error = (
|
||||
error_info and error_info.get("type") == "tool_concurrency"
|
||||
)
|
||||
|
||||
if is_concurrency_error:
|
||||
print_status(
|
||||
f"Rate limit detected - resetting subtask {subtask_id} to pending for retry",
|
||||
"info",
|
||||
)
|
||||
|
||||
# Use recovery system's reset_subtask for consistency
|
||||
reset_subtask(spec_dir, project_dir, subtask_id)
|
||||
|
||||
# Also reset in implementation plan
|
||||
plan = load_implementation_plan(spec_dir)
|
||||
if plan:
|
||||
# Find and reset the subtask
|
||||
subtask_found = False
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
# Reset subtask to pending state
|
||||
subtask["status"] = "pending"
|
||||
subtask["started_at"] = None
|
||||
subtask["completed_at"] = None
|
||||
subtask_found = True
|
||||
break
|
||||
if subtask_found:
|
||||
break
|
||||
|
||||
if subtask_found:
|
||||
# Save plan atomically to prevent corruption
|
||||
try:
|
||||
plan_path = spec_dir / "implementation_plan.json"
|
||||
write_json_atomic(plan_path, plan, indent=2)
|
||||
print_status(
|
||||
f"Subtask {subtask_id} reset to pending status", "success"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to save implementation plan after reset: {e}"
|
||||
)
|
||||
print_status("Failed to save plan after reset", "error")
|
||||
else:
|
||||
print_status(
|
||||
f"Warning: Could not find subtask {subtask_id} in plan",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
print_status(
|
||||
"Warning: Could not load implementation plan for reset", "warning"
|
||||
)
|
||||
else:
|
||||
# Non-rate-limit error - use automatic recovery flow
|
||||
error_message = (
|
||||
error_info.get("message", "Subtask not marked as completed")
|
||||
if error_info
|
||||
else "Subtask not marked as completed"
|
||||
)
|
||||
|
||||
recovery_action = check_and_recover(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
error=error_message,
|
||||
)
|
||||
_execute_recovery_action(
|
||||
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
|
||||
)
|
||||
|
||||
# Still record commit if one was made (partial progress)
|
||||
if commit_after and commit_after != commit_before:
|
||||
recovery_manager.record_good_commit(commit_after, subtask_id)
|
||||
@@ -379,6 +380,21 @@ async def post_session_processing(
|
||||
error=f"Subtask status is {subtask_status}",
|
||||
)
|
||||
|
||||
# Automatic recovery flow - determine and execute recovery action
|
||||
error_message = f"Subtask status is {subtask_status}"
|
||||
if error_info:
|
||||
error_message = error_info.get("message", error_message)
|
||||
|
||||
recovery_action = check_and_recover(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
error=error_message,
|
||||
)
|
||||
_execute_recovery_action(
|
||||
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
|
||||
)
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verification script for agent module refactoring.
|
||||
|
||||
This script verifies that:
|
||||
1. All modules can be imported
|
||||
2. All public API functions are accessible
|
||||
3. Backwards compatibility is maintained
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def test_imports():
|
||||
"""Test that all modules can be imported."""
|
||||
print("Testing module imports...")
|
||||
|
||||
# Test base module
|
||||
from agents import base
|
||||
|
||||
assert hasattr(base, "AUTO_CONTINUE_DELAY_SECONDS")
|
||||
assert hasattr(base, "HUMAN_INTERVENTION_FILE")
|
||||
print(" ✓ agents.base")
|
||||
|
||||
# Test utils module
|
||||
from agents import utils
|
||||
|
||||
assert hasattr(utils, "get_latest_commit")
|
||||
assert hasattr(utils, "load_implementation_plan")
|
||||
print(" ✓ agents.utils")
|
||||
|
||||
# Test memory module
|
||||
from agents import memory
|
||||
|
||||
assert hasattr(memory, "save_session_memory")
|
||||
assert hasattr(memory, "get_graphiti_context")
|
||||
print(" ✓ agents.memory")
|
||||
|
||||
# Test session module
|
||||
from agents import session
|
||||
|
||||
assert hasattr(session, "run_agent_session")
|
||||
assert hasattr(session, "post_session_processing")
|
||||
print(" ✓ agents.session")
|
||||
|
||||
# Test planner module
|
||||
from agents import planner
|
||||
|
||||
assert hasattr(planner, "run_followup_planner")
|
||||
print(" ✓ agents.planner")
|
||||
|
||||
# Test coder module
|
||||
from agents import coder
|
||||
|
||||
assert hasattr(coder, "run_autonomous_agent")
|
||||
print(" ✓ agents.coder")
|
||||
|
||||
print("\n✓ All module imports successful!\n")
|
||||
|
||||
|
||||
def test_public_api():
|
||||
"""Test that the public API is accessible."""
|
||||
print("Testing public API...")
|
||||
|
||||
# Test main agent module exports
|
||||
import agents
|
||||
|
||||
required_functions = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"save_session_memory",
|
||||
"get_graphiti_context",
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
]
|
||||
|
||||
for func_name in required_functions:
|
||||
assert hasattr(agents, func_name), f"Missing function: {func_name}"
|
||||
print(f" ✓ agents.{func_name}")
|
||||
|
||||
print("\n✓ All public API functions accessible!\n")
|
||||
|
||||
|
||||
def test_backwards_compatibility():
|
||||
"""Test that the old agent.py facade maintains backwards compatibility."""
|
||||
print("Testing backwards compatibility...")
|
||||
|
||||
# Test that agent.py can be imported
|
||||
import agent
|
||||
|
||||
required_functions = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
]
|
||||
|
||||
for func_name in required_functions:
|
||||
assert hasattr(agent, func_name), (
|
||||
f"Missing function in agent module: {func_name}"
|
||||
)
|
||||
print(f" ✓ agent.{func_name}")
|
||||
|
||||
print("\n✓ Backwards compatibility maintained!\n")
|
||||
|
||||
|
||||
def test_module_structure():
|
||||
"""Test that the module structure is correct."""
|
||||
print("Testing module structure...")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
agents_dir = Path(__file__).parent
|
||||
|
||||
required_files = [
|
||||
"__init__.py",
|
||||
"base.py",
|
||||
"utils.py",
|
||||
"memory.py",
|
||||
"session.py",
|
||||
"planner.py",
|
||||
"coder.py",
|
||||
]
|
||||
|
||||
for filename in required_files:
|
||||
filepath = agents_dir / filename
|
||||
assert filepath.exists(), f"Missing file: {filename}"
|
||||
print(f" ✓ agents/{filename}")
|
||||
|
||||
print("\n✓ Module structure correct!\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_module_structure()
|
||||
test_imports()
|
||||
test_public_api()
|
||||
test_backwards_compatibility()
|
||||
|
||||
print("=" * 60)
|
||||
print("✓ ALL TESTS PASSED - Refactoring verified!")
|
||||
print("=" * 60)
|
||||
|
||||
except AssertionError as e:
|
||||
print(f"\n✗ TEST FAILED: {e}")
|
||||
sys.exit(1)
|
||||
except ImportError as e:
|
||||
print(f"\n✗ IMPORT ERROR: {e}")
|
||||
print("Note: Some imports may fail due to missing dependencies.")
|
||||
print("This is expected in test environments.")
|
||||
sys.exit(0) # Don't fail on import errors (expected in test env)
|
||||
@@ -46,7 +46,7 @@ TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
"mcp__context7__query-docs",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
@@ -158,7 +158,7 @@ AGENT_CONFIGS = {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [], # Self-critique, no external tools
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "ultrathink",
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
@@ -210,7 +210,7 @@ AGENT_CONFIGS = {
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "none", # Coding doesn't use extended thinking
|
||||
"thinking_default": "low", # Coding uses minimal thinking (effort: low for Opus, 1024 tokens for Sonnet/Haiku)
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
@@ -247,9 +247,9 @@ AGENT_CONFIGS = {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
# Note: Default to "none" because insight_extractor uses Haiku which doesn't support thinking
|
||||
# If using Sonnet/Opus models, override max_thinking_tokens in create_simple_client()
|
||||
"thinking_default": "none",
|
||||
# Note: Default to "low" for minimal thinking overhead
|
||||
# Haiku doesn't support thinking; create_simple_client() handles this
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
@@ -524,7 +524,7 @@ def get_default_thinking_level(agent_type: str) -> str:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (none, low, medium, high, ultrathink)
|
||||
Thinking level string (low, medium, high)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
|
||||
@@ -23,7 +23,8 @@ from .ci_discovery import CIDiscovery
|
||||
from .project_analyzer import ProjectAnalyzer
|
||||
from .risk_classifier import RiskClassifier
|
||||
from .security_scanner import SecurityScanner
|
||||
from .test_discovery import TestDiscovery
|
||||
|
||||
# TestDiscovery was removed - tests are now co-located in their respective modules
|
||||
|
||||
# insight_extractor is a module with functions, not a class, so don't import it here
|
||||
# Import it directly when needed: from analysis import insight_extractor
|
||||
@@ -37,5 +38,5 @@ __all__ = [
|
||||
"RiskClassifier",
|
||||
"SecurityScanner",
|
||||
"CIDiscovery",
|
||||
"TestDiscovery",
|
||||
# "TestDiscovery", # Removed - tests now co-located in their modules
|
||||
]
|
||||
|
||||
@@ -235,10 +235,15 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
|
||||
# Scripts
|
||||
scripts = pkg.get("scripts", {})
|
||||
pkg_mgr = self.analysis.get("package_manager", "npm")
|
||||
if "dev" in scripts:
|
||||
self.analysis["dev_command"] = "npm run dev"
|
||||
self.analysis["dev_command"] = f"{pkg_mgr} run dev"
|
||||
elif "start" in scripts:
|
||||
self.analysis["dev_command"] = "npm run start"
|
||||
self.analysis["dev_command"] = f"{pkg_mgr} run start"
|
||||
|
||||
# Capture available scripts for downstream consumers (QA agents, init.sh)
|
||||
if scripts:
|
||||
self.analysis["scripts"] = dict(scripts)
|
||||
|
||||
def _detect_go_framework(self, content: str) -> None:
|
||||
"""Detect Go framework."""
|
||||
|
||||
@@ -1,690 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Discovery Module
|
||||
=====================
|
||||
|
||||
Detects test frameworks, test commands, and test directories in a project.
|
||||
This module analyzes project configuration files to discover how tests
|
||||
should be run.
|
||||
|
||||
The test discovery results are used by:
|
||||
- QA Agent: To determine what test commands to run
|
||||
- Test Creator: To know what framework to use when creating tests
|
||||
- Planner: To include correct test commands in verification strategy
|
||||
|
||||
Usage:
|
||||
from test_discovery import TestDiscovery
|
||||
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
|
||||
print(f"Test frameworks: {result['frameworks']}")
|
||||
print(f"Test command: {result['test_command']}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestFramework:
|
||||
"""
|
||||
Represents a detected test framework.
|
||||
|
||||
Attributes:
|
||||
name: Name of the framework (e.g., "pytest", "jest", "vitest")
|
||||
type: Type of testing (unit, integration, e2e, all)
|
||||
command: Command to run tests
|
||||
config_file: Configuration file if found
|
||||
version: Version if detected
|
||||
coverage_command: Command for coverage if available
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
name: str
|
||||
type: str # unit, integration, e2e, all
|
||||
command: str
|
||||
config_file: str | None = None
|
||||
version: str | None = None
|
||||
coverage_command: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestDiscoveryResult:
|
||||
"""
|
||||
Result of test framework discovery.
|
||||
|
||||
Attributes:
|
||||
frameworks: List of detected test frameworks
|
||||
test_command: Primary test command to run
|
||||
test_directories: Discovered test directories
|
||||
package_manager: Detected package manager
|
||||
has_tests: Whether any test files were found
|
||||
coverage_command: Command for coverage if available
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
frameworks: list[TestFramework] = field(default_factory=list)
|
||||
test_command: str = ""
|
||||
test_directories: list[str] = field(default_factory=list)
|
||||
package_manager: str = ""
|
||||
has_tests: bool = False
|
||||
coverage_command: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FRAMEWORK DETECTORS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# Pattern-based framework detection
|
||||
FRAMEWORK_PATTERNS = {
|
||||
# JavaScript/TypeScript
|
||||
"jest": {
|
||||
"config_files": [
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.mjs",
|
||||
"jest.config.cjs",
|
||||
],
|
||||
"package_key": "jest",
|
||||
"type": "unit",
|
||||
"command": "npx jest",
|
||||
"coverage_command": "npx jest --coverage",
|
||||
},
|
||||
"vitest": {
|
||||
"config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
|
||||
"package_key": "vitest",
|
||||
"type": "unit",
|
||||
"command": "npx vitest run",
|
||||
"coverage_command": "npx vitest run --coverage",
|
||||
},
|
||||
"mocha": {
|
||||
"config_files": [
|
||||
".mocharc.js",
|
||||
".mocharc.json",
|
||||
".mocharc.yaml",
|
||||
".mocharc.yml",
|
||||
],
|
||||
"package_key": "mocha",
|
||||
"type": "unit",
|
||||
"command": "npx mocha",
|
||||
"coverage_command": "npx nyc mocha",
|
||||
},
|
||||
"playwright": {
|
||||
"config_files": ["playwright.config.js", "playwright.config.ts"],
|
||||
"package_key": "@playwright/test",
|
||||
"type": "e2e",
|
||||
"command": "npx playwright test",
|
||||
"coverage_command": None,
|
||||
},
|
||||
"cypress": {
|
||||
"config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"],
|
||||
"package_key": "cypress",
|
||||
"type": "e2e",
|
||||
"command": "npx cypress run",
|
||||
"coverage_command": None,
|
||||
},
|
||||
# Python
|
||||
"pytest": {
|
||||
"config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"],
|
||||
"pyproject_key": "pytest",
|
||||
"requirements_key": "pytest",
|
||||
"type": "all",
|
||||
"command": "pytest",
|
||||
"coverage_command": "pytest --cov",
|
||||
},
|
||||
"unittest": {
|
||||
"config_files": [],
|
||||
"type": "unit",
|
||||
"command": "python -m unittest discover",
|
||||
"coverage_command": "coverage run -m unittest discover",
|
||||
},
|
||||
# Rust
|
||||
"cargo_test": {
|
||||
"config_files": ["Cargo.toml"],
|
||||
"type": "all",
|
||||
"command": "cargo test",
|
||||
"coverage_command": "cargo tarpaulin",
|
||||
},
|
||||
# Go
|
||||
"go_test": {
|
||||
"config_files": ["go.mod"],
|
||||
"type": "all",
|
||||
"command": "go test ./...",
|
||||
"coverage_command": "go test -cover ./...",
|
||||
},
|
||||
# Ruby
|
||||
"rspec": {
|
||||
"config_files": [".rspec", "spec/spec_helper.rb"],
|
||||
"gemfile_key": "rspec",
|
||||
"type": "all",
|
||||
"command": "bundle exec rspec",
|
||||
"coverage_command": "bundle exec rspec --format documentation",
|
||||
},
|
||||
"minitest": {
|
||||
"config_files": [],
|
||||
"gemfile_key": "minitest",
|
||||
"type": "unit",
|
||||
"command": "bundle exec rake test",
|
||||
"coverage_command": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
"""
|
||||
Discovers test frameworks and configurations in a project.
|
||||
|
||||
Analyzes:
|
||||
- Package files (package.json, pyproject.toml, Cargo.toml, etc.)
|
||||
- Configuration files (jest.config.js, pytest.ini, etc.)
|
||||
- Directory structure (tests/, spec/, __tests__/)
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the test discovery."""
|
||||
self._cache: dict[str, TestDiscoveryResult] = {}
|
||||
|
||||
def discover(self, project_dir: Path) -> TestDiscoveryResult:
|
||||
"""
|
||||
Discover test frameworks and configuration in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
TestDiscoveryResult with detected frameworks and commands
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
cache_key = str(project_dir.resolve())
|
||||
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
result = TestDiscoveryResult()
|
||||
|
||||
# Detect package manager
|
||||
result.package_manager = self._detect_package_manager(project_dir)
|
||||
|
||||
# Discover frameworks based on project type
|
||||
if (project_dir / "package.json").exists():
|
||||
self._discover_js_frameworks(project_dir, result)
|
||||
|
||||
# Check for Python project indicators
|
||||
python_indicators = [
|
||||
project_dir / "pyproject.toml",
|
||||
project_dir / "requirements.txt",
|
||||
project_dir / "setup.py",
|
||||
project_dir / "pytest.ini",
|
||||
project_dir / "conftest.py",
|
||||
project_dir / "tests" / "conftest.py",
|
||||
]
|
||||
if any(p.exists() for p in python_indicators):
|
||||
self._discover_python_frameworks(project_dir, result)
|
||||
|
||||
if (project_dir / "Cargo.toml").exists():
|
||||
self._discover_rust_frameworks(project_dir, result)
|
||||
if (project_dir / "go.mod").exists():
|
||||
self._discover_go_frameworks(project_dir, result)
|
||||
if (project_dir / "Gemfile").exists():
|
||||
self._discover_ruby_frameworks(project_dir, result)
|
||||
|
||||
# Find test directories
|
||||
result.test_directories = self._find_test_directories(project_dir)
|
||||
|
||||
# Check if tests exist
|
||||
result.has_tests = self._has_test_files(project_dir, result.test_directories)
|
||||
|
||||
# Set primary test command
|
||||
if result.frameworks:
|
||||
result.test_command = result.frameworks[0].command
|
||||
|
||||
# Set coverage command from first framework that has one
|
||||
if not result.coverage_command:
|
||||
for framework in result.frameworks:
|
||||
if framework.coverage_command:
|
||||
result.coverage_command = framework.coverage_command
|
||||
break
|
||||
|
||||
self._cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _detect_package_manager(self, project_dir: Path) -> str:
|
||||
"""Detect the package manager used by the project."""
|
||||
if (project_dir / "pnpm-lock.yaml").exists():
|
||||
return "pnpm"
|
||||
if (project_dir / "yarn.lock").exists():
|
||||
return "yarn"
|
||||
if (project_dir / "package-lock.json").exists():
|
||||
return "npm"
|
||||
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
|
||||
return "bun"
|
||||
if (project_dir / "uv.lock").exists():
|
||||
return "uv"
|
||||
if (project_dir / "poetry.lock").exists():
|
||||
return "poetry"
|
||||
if (project_dir / "Pipfile.lock").exists():
|
||||
return "pipenv"
|
||||
if (project_dir / "Cargo.lock").exists():
|
||||
return "cargo"
|
||||
if (project_dir / "go.sum").exists():
|
||||
return "go"
|
||||
if (project_dir / "Gemfile.lock").exists():
|
||||
return "bundler"
|
||||
return ""
|
||||
|
||||
def _discover_js_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover JavaScript/TypeScript test frameworks."""
|
||||
package_json = project_dir / "package.json"
|
||||
if not package_json.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(package_json, encoding="utf-8") as f:
|
||||
pkg = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
deps = pkg.get("dependencies", {})
|
||||
dev_deps = pkg.get("devDependencies", {})
|
||||
all_deps = {**deps, **dev_deps}
|
||||
scripts = pkg.get("scripts", {})
|
||||
|
||||
# Check for test frameworks in dependencies
|
||||
for name, pattern in FRAMEWORK_PATTERNS.items():
|
||||
if "package_key" not in pattern:
|
||||
continue
|
||||
|
||||
if pattern["package_key"] in all_deps:
|
||||
# Check for config file
|
||||
config_file = None
|
||||
for cf in pattern.get("config_files", []):
|
||||
if (project_dir / cf).exists():
|
||||
config_file = cf
|
||||
break
|
||||
|
||||
# Get version
|
||||
version = all_deps.get(pattern["package_key"], "")
|
||||
if version.startswith("^") or version.startswith("~"):
|
||||
version = version[1:]
|
||||
|
||||
# Determine command - prefer npm scripts if available
|
||||
command = pattern["command"]
|
||||
if "test" in scripts and pattern["package_key"] in scripts.get(
|
||||
"test", ""
|
||||
):
|
||||
command = f"{result.package_manager or 'npm'} test"
|
||||
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name=name,
|
||||
type=pattern["type"],
|
||||
command=command,
|
||||
config_file=config_file,
|
||||
version=version,
|
||||
coverage_command=pattern.get("coverage_command"),
|
||||
)
|
||||
)
|
||||
|
||||
# Check npm scripts for test commands
|
||||
if not result.frameworks and "test" in scripts:
|
||||
test_script = scripts["test"]
|
||||
if (
|
||||
test_script
|
||||
and test_script != 'echo "Error: no test specified" && exit 1'
|
||||
):
|
||||
# Try to infer framework from script
|
||||
framework_name = "npm_test"
|
||||
framework_type = "unit"
|
||||
|
||||
if "jest" in test_script:
|
||||
framework_name = "jest"
|
||||
elif "vitest" in test_script:
|
||||
framework_name = "vitest"
|
||||
elif "mocha" in test_script:
|
||||
framework_name = "mocha"
|
||||
elif "playwright" in test_script:
|
||||
framework_name = "playwright"
|
||||
framework_type = "e2e"
|
||||
elif "cypress" in test_script:
|
||||
framework_name = "cypress"
|
||||
framework_type = "e2e"
|
||||
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name=framework_name,
|
||||
type=framework_type,
|
||||
command=f"{result.package_manager or 'npm'} test",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_python_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Python test frameworks."""
|
||||
# Check for pytest.ini first (explicit pytest config)
|
||||
if (project_dir / "pytest.ini").exists():
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file="pytest.ini",
|
||||
)
|
||||
)
|
||||
|
||||
# Check pyproject.toml
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text(encoding="utf-8")
|
||||
|
||||
# Check for pytest
|
||||
if "pytest" in content:
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
config_file = (
|
||||
"pyproject.toml" if "[tool.pytest" in content else None
|
||||
)
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file=config_file,
|
||||
)
|
||||
)
|
||||
|
||||
# Check requirements.txt
|
||||
requirements = project_dir / "requirements.txt"
|
||||
if requirements.exists():
|
||||
content = requirements.read_text(encoding="utf-8").lower()
|
||||
if "pytest" in content and not any(
|
||||
f.name == "pytest" for f in result.frameworks
|
||||
):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for conftest.py (pytest marker)
|
||||
conftest_root = project_dir / "conftest.py"
|
||||
conftest_tests = project_dir / "tests" / "conftest.py"
|
||||
if conftest_root.exists() or conftest_tests.exists():
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file="conftest.py",
|
||||
)
|
||||
)
|
||||
|
||||
# Fall back to unittest if test files exist but no framework detected
|
||||
if not result.frameworks:
|
||||
test_dirs = self._find_test_directories(project_dir)
|
||||
if test_dirs:
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="unittest",
|
||||
type="unit",
|
||||
command="python -m unittest discover",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_rust_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Rust test frameworks."""
|
||||
cargo_toml = project_dir / "Cargo.toml"
|
||||
if cargo_toml.exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="cargo_test",
|
||||
type="all",
|
||||
command="cargo test",
|
||||
config_file="Cargo.toml",
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_go_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Go test frameworks."""
|
||||
go_mod = project_dir / "go.mod"
|
||||
if go_mod.exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="go_test",
|
||||
type="all",
|
||||
command="go test ./...",
|
||||
config_file="go.mod",
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_ruby_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Ruby test frameworks."""
|
||||
gemfile = project_dir / "Gemfile"
|
||||
if not gemfile.exists():
|
||||
return
|
||||
|
||||
content = gemfile.read_text(encoding="utf-8").lower()
|
||||
|
||||
if "rspec" in content or (project_dir / ".rspec").exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="rspec",
|
||||
type="all",
|
||||
command="bundle exec rspec",
|
||||
config_file=".rspec" if (project_dir / ".rspec").exists() else None,
|
||||
)
|
||||
)
|
||||
elif "minitest" in content:
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="minitest",
|
||||
type="unit",
|
||||
command="bundle exec rake test",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _find_test_directories(self, project_dir: Path) -> list[str]:
|
||||
"""Find test directories in the project."""
|
||||
test_dir_patterns = [
|
||||
"tests",
|
||||
"test",
|
||||
"spec",
|
||||
"__tests__",
|
||||
"specs",
|
||||
"test_*",
|
||||
]
|
||||
|
||||
found_dirs = []
|
||||
for pattern in test_dir_patterns:
|
||||
if pattern.endswith("*"):
|
||||
# Glob pattern
|
||||
for d in project_dir.glob(pattern):
|
||||
if d.is_dir():
|
||||
found_dirs.append(str(d.relative_to(project_dir)))
|
||||
else:
|
||||
# Exact name
|
||||
test_dir = project_dir / pattern
|
||||
if test_dir.is_dir():
|
||||
found_dirs.append(pattern)
|
||||
|
||||
return found_dirs
|
||||
|
||||
def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool:
|
||||
"""Check if any test files exist."""
|
||||
test_file_patterns = [
|
||||
"**/test_*.py",
|
||||
"**/*_test.py",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/test_*.go",
|
||||
"**/*_test.go",
|
||||
"**/*_test.rs",
|
||||
"**/spec/**/*_spec.rb",
|
||||
]
|
||||
|
||||
# Check in test directories
|
||||
for test_dir in test_directories:
|
||||
test_path = project_dir / test_dir
|
||||
if test_path.exists():
|
||||
for pattern in test_file_patterns:
|
||||
if list(test_path.glob(pattern.replace("**/", ""))):
|
||||
return True
|
||||
|
||||
# Check project-wide
|
||||
for pattern in test_file_patterns:
|
||||
if list(project_dir.glob(pattern)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"frameworks": [
|
||||
{
|
||||
"name": f.name,
|
||||
"type": f.type,
|
||||
"command": f.command,
|
||||
"config_file": f.config_file,
|
||||
"version": f.version,
|
||||
"coverage_command": f.coverage_command,
|
||||
}
|
||||
for f in result.frameworks
|
||||
],
|
||||
"test_command": result.test_command,
|
||||
"test_directories": result.test_directories,
|
||||
"package_manager": result.package_manager,
|
||||
"has_tests": result.has_tests,
|
||||
"coverage_command": result.coverage_command,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the internal cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def discover_tests(project_dir: Path) -> TestDiscoveryResult:
|
||||
"""
|
||||
Convenience function to discover tests in a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
TestDiscoveryResult with detected frameworks
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
return discovery.discover(project_dir)
|
||||
|
||||
|
||||
def get_test_command(project_dir: Path) -> str:
|
||||
"""
|
||||
Get the primary test command for a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
Test command string, or empty string if not found
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
return result.test_command
|
||||
|
||||
|
||||
def get_test_frameworks(project_dir: Path) -> list[str]:
|
||||
"""
|
||||
Get list of test framework names in a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
List of framework names
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
return [f.name for f in result.frameworks]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Discover test frameworks")
|
||||
parser.add_argument("project_dir", type=Path, help="Path to project root")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(args.project_dir)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(discovery.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"Package Manager: {result.package_manager or 'unknown'}")
|
||||
print(f"Has Tests: {result.has_tests}")
|
||||
print(f"Test Command: {result.test_command or 'none'}")
|
||||
print(f"Test Directories: {', '.join(result.test_directories) or 'none'}")
|
||||
print(f"Coverage Command: {result.coverage_command or 'none'}")
|
||||
print(f"\nFrameworks ({len(result.frameworks)}):")
|
||||
for f in result.frameworks:
|
||||
print(f" - {f.name} ({f.type})")
|
||||
print(f" Command: {f.command}")
|
||||
if f.config_file:
|
||||
print(f" Config: {f.config_file}")
|
||||
if f.version:
|
||||
print(f" Version: {f.version}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,6 +15,7 @@ import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.platform import (
|
||||
get_where_exe_path,
|
||||
is_linux,
|
||||
is_macos,
|
||||
is_windows,
|
||||
@@ -854,9 +855,9 @@ def _find_git_bash_path() -> str | None:
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use where.exe explicitly for reliability
|
||||
# Use full path to where.exe for reliability (works even when System32 isn't in PATH)
|
||||
result = subprocess.run(
|
||||
["where.exe", "git"],
|
||||
[get_where_exe_path(), "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
|
||||
@@ -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,
|
||||
@@ -449,6 +450,9 @@ def create_client(
|
||||
max_thinking_tokens: int | None = None,
|
||||
output_format: dict | None = None,
|
||||
agents: dict | None = None,
|
||||
betas: list[str] | None = None,
|
||||
effort_level: str | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
@@ -464,10 +468,9 @@ def create_client(
|
||||
agent_type: Agent type identifier from AGENT_CONFIGS
|
||||
(e.g., 'coder', 'planner', 'qa_reviewer', 'spec_gatherer')
|
||||
max_thinking_tokens: Token budget for extended thinking (None = disabled)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
- medium: 5000 (planning, validation)
|
||||
- None: disabled (coding)
|
||||
- high: 16384 (spec creation, QA review)
|
||||
- medium: 4096 (planning, validation)
|
||||
- low: 1024 (coding)
|
||||
output_format: Optional structured output format for validated JSON responses.
|
||||
Use {"type": "json_schema", "schema": Model.model_json_schema()}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
@@ -475,6 +478,16 @@ def create_client(
|
||||
Format: {"agent-name": {"description": "...", "prompt": "...",
|
||||
"tools": [...], "model": "inherit"}}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
betas: Optional list of SDK beta header strings (e.g., ["context-1m-2025-08-07"]
|
||||
for 1M context window). Use get_phase_model_betas() to compute from config.
|
||||
effort_level: Optional effort level for adaptive thinking models (e.g., "low",
|
||||
"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, 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
|
||||
@@ -502,6 +515,24 @@ def create_client(
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
# Inject effort level for adaptive thinking models (e.g., Opus 4.6)
|
||||
if effort_level:
|
||||
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
|
||||
|
||||
# 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:
|
||||
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:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
@@ -665,7 +696,12 @@ def create_client(
|
||||
print(" - Worktree permissions: granted for original project directories")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
thinking_info = f"{max_thinking_tokens:,} tokens"
|
||||
if effort_level:
|
||||
thinking_info += f" + effort={effort_level}"
|
||||
if fast_mode:
|
||||
thinking_info += " + fast mode"
|
||||
print(f" - Extended thinking: {thinking_info}")
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
@@ -817,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")
|
||||
@@ -834,4 +876,8 @@ def create_client(
|
||||
if agents:
|
||||
options_kwargs["agents"] = agents
|
||||
|
||||
# Add beta headers if specified (e.g., for 1M context window)
|
||||
if betas:
|
||||
options_kwargs["betas"] = betas
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Shared Error Utilities
|
||||
======================
|
||||
|
||||
Common error detection and classification functions used across
|
||||
agent sessions, QA, and other modules.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def is_rate_limit_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a rate limit error (429 or similar).
|
||||
|
||||
Rate limit errors occur when the API usage quota is exceeded,
|
||||
either for session limits or weekly limits.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 429 with word boundaries to avoid false positives
|
||||
if re.search(r"\b429\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other rate limit indicators
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"usage limit",
|
||||
"quota exceeded",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_authentication_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is an authentication error (401, token expired, etc.).
|
||||
|
||||
Authentication errors occur when OAuth tokens are invalid, expired,
|
||||
or have been revoked (e.g., after token refresh on another process).
|
||||
|
||||
Validation approach:
|
||||
- HTTP 401 status code is checked with word boundaries to minimize false positives
|
||||
- Additional string patterns are validated against lowercase error messages
|
||||
- Patterns are designed to match known Claude API and OAuth error formats
|
||||
|
||||
Known false positive risks:
|
||||
- Generic error messages containing "unauthorized" or "access denied" may match
|
||||
even if not related to authentication (e.g., file permission errors)
|
||||
- Error messages containing these keywords in user-provided content could match
|
||||
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
|
||||
|
||||
Real-world validation:
|
||||
- Pattern matching has been tested against actual Claude API error responses
|
||||
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
|
||||
- If false positive occurs, user can simply resume without re-authenticating
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is an authentication error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 401 with word boundaries to avoid false positives
|
||||
if re.search(r"\b401\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other authentication indicators
|
||||
# NOTE: "authentication failed" and "authentication error" are more specific patterns
|
||||
# to reduce false positives from generic "authentication" mentions
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"authentication failed",
|
||||
"authentication error",
|
||||
"unauthorized",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"authentication_error",
|
||||
"invalid_token",
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
"does not have access to claude",
|
||||
"please login again",
|
||||
]
|
||||
)
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -10,6 +10,8 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
_cached_gh_path: str | None = None
|
||||
|
||||
|
||||
@@ -53,12 +55,11 @@ def _run_where_command() -> str | None:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where gh",
|
||||
[get_where_exe_path(), "gh"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
@@ -133,7 +134,7 @@ def _find_gh_executable() -> str | None:
|
||||
if os.path.isfile(path) and _verify_gh_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
@@ -15,6 +15,8 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
# Git environment variables that can interfere with worktree operations
|
||||
# when set by pre-commit hooks or other git configurations.
|
||||
# These must be cleared to prevent cross-worktree contamination.
|
||||
@@ -124,14 +126,13 @@ def _find_git_executable() -> str:
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 4. Try 'where' command with shell=True (more reliable on Windows)
|
||||
# 4. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where git",
|
||||
[get_where_exe_path(), "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
|
||||
@@ -10,6 +10,8 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
_cached_glab_path: str | None = None
|
||||
|
||||
|
||||
@@ -53,12 +55,11 @@ def _run_where_command() -> str | None:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where glab",
|
||||
[get_where_exe_path(), "glab"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
@@ -134,7 +135,7 @@ def _find_glab_executable() -> str | None:
|
||||
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
@@ -430,6 +430,22 @@ def requires_shell(command: str) -> bool:
|
||||
return ext.lower() in {".cmd", ".bat", ".ps1"}
|
||||
|
||||
|
||||
def get_where_exe_path() -> str:
|
||||
"""Get full path to where.exe on Windows.
|
||||
|
||||
Using the full path ensures where.exe works even when System32 isn't in PATH,
|
||||
which can happen in restricted environments or when the app doesn't inherit
|
||||
the full system PATH.
|
||||
|
||||
Returns:
|
||||
Full path to where.exe (e.g., C:\\Windows\\System32\\where.exe)
|
||||
"""
|
||||
system_root = os.environ.get(
|
||||
"SystemRoot", os.environ.get("SYSTEMROOT", "C:\\Windows")
|
||||
)
|
||||
return os.path.join(system_root, "System32", "where.exe")
|
||||
|
||||
|
||||
def get_comspec_path() -> str:
|
||||
"""
|
||||
Get the path to cmd.exe on Windows.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +45,9 @@ def create_simple_client(
|
||||
cwd: Path | None = None,
|
||||
max_turns: int = 1,
|
||||
max_thinking_tokens: int | None = None,
|
||||
betas: list[str] | None = None,
|
||||
effort_level: str | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a minimal Claude SDK client for single-turn utility operations.
|
||||
@@ -65,6 +69,11 @@ def create_simple_client(
|
||||
max_turns: Maximum conversation turns (default: 1 for single-turn)
|
||||
max_thinking_tokens: Override thinking budget (None = use agent default from
|
||||
AGENT_CONFIGS, converted using phase_config.THINKING_BUDGET_MAP)
|
||||
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. Enables the "user"
|
||||
setting source so the CLI reads fastMode from ~/.claude/settings.json.
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient for single-turn operations
|
||||
@@ -82,6 +91,17 @@ def create_simple_client(
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
# Inject effort level for adaptive thinking models (e.g., Opus 4.6)
|
||||
if effort_level:
|
||||
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
|
||||
|
||||
# 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:
|
||||
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)
|
||||
|
||||
@@ -104,10 +124,19 @@ 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
|
||||
|
||||
# Add beta headers if specified (e.g., for 1M context window)
|
||||
if betas:
|
||||
options_kwargs["betas"] = betas
|
||||
|
||||
# Optional: Allow CLI path override via environment variable
|
||||
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_cli_path and validate_cli_path(env_cli_path):
|
||||
|
||||
@@ -17,7 +17,6 @@ Public API exported from sub-modules.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import merge functions from workspace.py (which coexists with this package)
|
||||
@@ -28,10 +27,17 @@ _workspace_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_workspace_module)
|
||||
merge_existing_build = _workspace_module.merge_existing_build
|
||||
_run_parallel_merges = _workspace_module._run_parallel_merges
|
||||
_resolve_git_conflicts_with_ai = _workspace_module._resolve_git_conflicts_with_ai
|
||||
AI_MERGE_SYSTEM_PROMPT = _workspace_module.AI_MERGE_SYSTEM_PROMPT
|
||||
_build_merge_prompt = _workspace_module._build_merge_prompt
|
||||
_check_git_conflicts = _workspace_module._check_git_conflicts
|
||||
_rebase_spec_branch = _workspace_module._rebase_spec_branch
|
||||
_create_merge_progress_callback = _workspace_module._create_merge_progress_callback
|
||||
_infer_language_from_path = _workspace_module._infer_language_from_path
|
||||
_strip_code_fences = _workspace_module._strip_code_fences
|
||||
_try_simple_3way_merge = _workspace_module._try_simple_3way_merge
|
||||
_attempt_ai_merge = _workspace_module._attempt_ai_merge
|
||||
_merge_file_with_ai_async = _workspace_module._merge_file_with_ai_async
|
||||
|
||||
# Models and Enums
|
||||
# Display Functions
|
||||
@@ -74,7 +80,9 @@ from .git_utils import (
|
||||
# Export private names for backward compatibility
|
||||
_is_process_running,
|
||||
_validate_merged_syntax,
|
||||
apply_path_mapping,
|
||||
create_conflict_file_with_git,
|
||||
detect_file_renames,
|
||||
get_binary_file_content_from_ref,
|
||||
get_changed_files_from_branch,
|
||||
get_current_branch,
|
||||
@@ -91,6 +99,8 @@ from .models import (
|
||||
MergeLockError,
|
||||
ParallelMergeResult,
|
||||
ParallelMergeTask,
|
||||
SpecNumberLock,
|
||||
SpecNumberLockError,
|
||||
WorkspaceChoice,
|
||||
WorkspaceMode,
|
||||
)
|
||||
@@ -110,11 +120,9 @@ from .setup import (
|
||||
__all__ = [
|
||||
# Merge Operations (from workspace.py)
|
||||
"merge_existing_build",
|
||||
"_run_parallel_merges", # Private but used internally
|
||||
"AI_MERGE_SYSTEM_PROMPT", # System prompt for AI merge (ACS-194)
|
||||
"_build_merge_prompt", # Internal prompt builder (ACS-194)
|
||||
"_check_git_conflicts", # Internal git conflict detection (ACS-224)
|
||||
"_rebase_spec_branch", # Internal rebase function (ACS-224)
|
||||
# Note: Private functions (_run_parallel_merges, _resolve_git_conflicts_with_ai, etc.)
|
||||
# are kept as module-level assignments for internal use but not exported in __all__
|
||||
# to maintain the underscore convention for private/internal APIs
|
||||
# Models
|
||||
"WorkspaceMode",
|
||||
"WorkspaceChoice",
|
||||
@@ -122,6 +130,8 @@ __all__ = [
|
||||
"ParallelMergeResult",
|
||||
"MergeLock",
|
||||
"MergeLockError",
|
||||
"SpecNumberLock",
|
||||
"SpecNumberLockError",
|
||||
# Git Utils
|
||||
"has_uncommitted_changes",
|
||||
"get_current_branch",
|
||||
@@ -131,8 +141,11 @@ __all__ = [
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_file",
|
||||
"is_lock_file",
|
||||
"validate_merged_syntax",
|
||||
"create_conflict_file_with_git",
|
||||
"detect_file_renames", # File rename detection
|
||||
"apply_path_mapping", # Path mapping for renamed files
|
||||
# Setup
|
||||
"choose_workspace",
|
||||
"copy_spec_to_worktree",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pytest Configuration and Shared Fixtures for Workspace Tests
|
||||
==============================================================
|
||||
|
||||
Provides test fixtures for the workspace module tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# MODULE MOCK CLEANUP - Prevents test isolation issues
|
||||
# =============================================================================
|
||||
|
||||
# List of modules that might be mocked by test files
|
||||
_POTENTIALLY_MOCKED_MODULES = [
|
||||
"claude_code_sdk",
|
||||
"claude_code_sdk.types",
|
||||
"claude_agent_sdk",
|
||||
"claude_agent_sdk.types",
|
||||
]
|
||||
|
||||
# Store original module references at import time (BEFORE pre-mocking)
|
||||
_original_module_state = {}
|
||||
for _name in _POTENTIALLY_MOCKED_MODULES:
|
||||
if _name in sys.modules:
|
||||
_original_module_state[_name] = sys.modules[_name]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRE-MOCK EXTERNAL SDK MODULES - Must happen BEFORE adding auto-claude to path
|
||||
# =============================================================================
|
||||
# These SDK modules may not be installed, so we mock them before any imports
|
||||
# that might trigger loading code that depends on them.
|
||||
|
||||
|
||||
def _create_sdk_mock():
|
||||
"""Create a comprehensive mock for SDK modules."""
|
||||
mock = MagicMock()
|
||||
mock.ClaudeAgentOptions = MagicMock
|
||||
mock.ClaudeSDKClient = MagicMock
|
||||
mock.HookMatcher = MagicMock
|
||||
return mock
|
||||
|
||||
|
||||
# Pre-mock claude_agent_sdk if not installed
|
||||
if "claude_agent_sdk" not in sys.modules:
|
||||
sys.modules["claude_agent_sdk"] = _create_sdk_mock()
|
||||
sys.modules["claude_agent_sdk.types"] = MagicMock()
|
||||
|
||||
# Pre-mock claude_code_sdk if not installed
|
||||
if "claude_code_sdk" not in sys.modules:
|
||||
sys.modules["claude_code_sdk"] = _create_sdk_mock()
|
||||
sys.modules["claude_code_sdk.types"] = MagicMock()
|
||||
|
||||
# Add backend directory to path for imports
|
||||
# When co-located at workspace/tests/, go up to backend directory
|
||||
# workspace/tests -> workspace -> core -> backend (4 levels up)
|
||||
_backend = Path(__file__).resolve().parent.parent.parent.parent
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
# Add repo root to sys.path for test_fixtures import fallback
|
||||
_repo_root = _backend.parent.parent
|
||||
sys.path.insert(0, str(_repo_root))
|
||||
|
||||
|
||||
def _cleanup_mocked_modules():
|
||||
"""Remove any MagicMock modules from sys.modules."""
|
||||
for name in _POTENTIALLY_MOCKED_MODULES:
|
||||
if name in sys.modules:
|
||||
module = sys.modules[name]
|
||||
if isinstance(module, MagicMock):
|
||||
if name in _original_module_state:
|
||||
sys.modules[name] = _original_module_state[name]
|
||||
else:
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
"""Clean up any mocked modules before the test session starts."""
|
||||
_cleanup_mocked_modules()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DIRECTORY FIXTURES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir() -> Generator[Path, None, None]:
|
||||
"""Create a temporary directory that's cleaned up after the test."""
|
||||
temp_path = Path(tempfile.mkdtemp())
|
||||
yield temp_path
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_git_repo(temp_dir: Path) -> Generator[Path, None, None]:
|
||||
"""Create a temporary git repository with initial commit.
|
||||
|
||||
IMPORTANT: This fixture properly isolates git operations by clearing
|
||||
git environment variables that may be set by pre-commit hooks. Without
|
||||
this isolation, git operations could affect the parent repository when
|
||||
tests run inside a git worktree (e.g., during pre-commit validation).
|
||||
|
||||
See: https://git-scm.com/docs/git#_environment_variables
|
||||
"""
|
||||
# Save original environment values to restore later
|
||||
orig_env = {}
|
||||
|
||||
# These git env vars may be set by pre-commit hooks and MUST be cleared
|
||||
# to avoid git operations affecting the parent repository instead of
|
||||
# our isolated test repo. This is critical when running inside worktrees.
|
||||
git_vars_to_clear = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
]
|
||||
|
||||
# Clear interfering git environment variables
|
||||
for key in git_vars_to_clear:
|
||||
orig_env[key] = os.environ.get(key)
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
# Set GIT_CEILING_DIRECTORIES to prevent git from discovering parent .git
|
||||
# directories. This is critical for test isolation when running inside
|
||||
# another git repo (like during pre-commit hooks in worktrees).
|
||||
orig_env["GIT_CEILING_DIRECTORIES"] = os.environ.get("GIT_CEILING_DIRECTORIES")
|
||||
os.environ["GIT_CEILING_DIRECTORIES"] = str(temp_dir.parent)
|
||||
|
||||
try:
|
||||
# Initialize git repo
|
||||
subprocess.run(["git", "init"], cwd=temp_dir, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=temp_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"],
|
||||
cwd=temp_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Create initial commit
|
||||
test_file = temp_dir / "README.md"
|
||||
test_file.write_text("# Test Project\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Initial commit"], cwd=temp_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Ensure branch is named 'main' (some git configs default to 'master')
|
||||
subprocess.run(
|
||||
["git", "branch", "-M", "main"], cwd=temp_dir, capture_output=True
|
||||
)
|
||||
|
||||
yield temp_dir
|
||||
finally:
|
||||
# Restore original environment variables
|
||||
for key, value in orig_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spec_dir(temp_dir: Path) -> Path:
|
||||
"""Create a spec directory inside temp_dir."""
|
||||
spec_path = temp_dir / "spec"
|
||||
spec_path.mkdir(parents=True)
|
||||
return spec_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir: Path) -> Path:
|
||||
"""Create a project directory inside temp_dir."""
|
||||
project_path = temp_dir / "project"
|
||||
project_path.mkdir(parents=True)
|
||||
return project_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_commit(temp_git_repo: Path):
|
||||
"""Fixture to make commits in the test git repo.
|
||||
|
||||
Usage:
|
||||
def test_something(make_commit):
|
||||
make_commit("message", files={"file.txt": "content"})
|
||||
"""
|
||||
|
||||
def _make_commit(message: str, files: dict[str, str] | None = None):
|
||||
"""Create a commit with the given message and files.
|
||||
|
||||
Args:
|
||||
message: Commit message
|
||||
files: Optional dict of {filepath: content} to create before committing
|
||||
"""
|
||||
if files:
|
||||
for file_path, content in files.items():
|
||||
full_path = temp_git_repo / file_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", message],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
return _make_commit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage_files(temp_git_repo: Path):
|
||||
"""Fixture to stage files in the test git repo.
|
||||
|
||||
Usage:
|
||||
def test_something(stage_files):
|
||||
stage_files({"file.txt": "content"})
|
||||
"""
|
||||
|
||||
def _stage_files(files: dict[str, str]):
|
||||
"""Stage files for commit.
|
||||
|
||||
Args:
|
||||
files: Dict of {filepath: content} to create and stage
|
||||
"""
|
||||
for file_path, content in files.items():
|
||||
full_path = temp_git_repo / file_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
|
||||
return _stage_files
|
||||
@@ -0,0 +1,10 @@
|
||||
[pytest]
|
||||
# Pytest configuration for workspace module tests
|
||||
|
||||
# Async test mode
|
||||
asyncio_mode = auto
|
||||
|
||||
# Register custom markers
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests (deselect with '-m "not integration"')
|
||||
@@ -0,0 +1,856 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Display Functions
|
||||
======================================
|
||||
|
||||
Tests the display.py module functionality including:
|
||||
- Build summary display
|
||||
- Changed files display
|
||||
- Merge success printing
|
||||
- Conflict info display
|
||||
- Environment file operations
|
||||
- Node modules symlink operations
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestShowBuildSummary:
|
||||
"""Tests for show_build_summary display function."""
|
||||
|
||||
def test_show_build_summary_no_changes(self, capsys):
|
||||
"""show_build_summary prints info message when no changes."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 0,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = []
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No changes were made" in captured.out
|
||||
|
||||
def test_show_build_summary_with_new_files(self, capsys):
|
||||
"""show_build_summary displays new files count correctly."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 3,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("A", "file1.py"),
|
||||
("A", "file2.py"),
|
||||
("A", "file3.py"),
|
||||
]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "What was built" in captured.out
|
||||
assert "+ 3 new files" in captured.out
|
||||
|
||||
def test_show_build_summary_singular_new_file(self, capsys):
|
||||
"""show_build_summary uses singular form for one new file."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 1,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [("A", "file1.py")]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 1 new file" in captured.out
|
||||
assert "files" not in captured.out.split("new file")[1].split("\n")[0]
|
||||
|
||||
def test_show_build_summary_with_modified_files(self, capsys):
|
||||
"""show_build_summary displays modified files count correctly."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 0,
|
||||
"modified_files": 2,
|
||||
"deleted_files": 0,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("M", "file1.py"),
|
||||
("M", "file2.py"),
|
||||
]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "~ 2 modified files" in captured.out
|
||||
|
||||
def test_show_build_summary_with_deleted_files(self, capsys):
|
||||
"""show_build_summary displays deleted files count correctly."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 0,
|
||||
"modified_files": 0,
|
||||
"deleted_files": 1,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [("D", "old.py")]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "- 1 deleted file" in captured.out
|
||||
|
||||
def test_show_build_summary_mixed_changes(self, capsys):
|
||||
"""show_build_summary displays all change types together."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_build_summary
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_change_summary.return_value = {
|
||||
"new_files": 2,
|
||||
"modified_files": 3,
|
||||
"deleted_files": 1,
|
||||
}
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("A", "new1.py"),
|
||||
("A", "new2.py"),
|
||||
("M", "mod1.py"),
|
||||
("M", "mod2.py"),
|
||||
("M", "mod3.py"),
|
||||
("D", "old.py"),
|
||||
]
|
||||
|
||||
show_build_summary(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 2 new files" in captured.out
|
||||
assert "~ 3 modified files" in captured.out
|
||||
assert "- 1 deleted file" in captured.out
|
||||
|
||||
|
||||
class TestShowChangedFiles:
|
||||
"""Tests for show_changed_files display function."""
|
||||
|
||||
def test_show_changed_files_empty_list(self, capsys):
|
||||
"""show_changed_files prints info message when no files changed."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = []
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No changes" in captured.out
|
||||
|
||||
def test_show_changed_files_with_added_file(self, capsys):
|
||||
"""show_changed_files displays added file with + prefix."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("A", "new_file.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Changed files" in captured.out
|
||||
assert "+ new_file.py" in captured.out
|
||||
|
||||
def test_show_changed_files_with_modified_file(self, capsys):
|
||||
"""show_changed_files displays modified file with ~ prefix."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("M", "changed.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "~ changed.py" in captured.out
|
||||
|
||||
def test_show_changed_files_with_deleted_file(self, capsys):
|
||||
"""show_changed_files displays deleted file with - prefix."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("D", "removed.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "- removed.py" in captured.out
|
||||
|
||||
def test_show_changed_files_with_unknown_status(self, capsys):
|
||||
"""show_changed_files displays unknown status code without decoration."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [("R", "renamed.py")]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "R renamed.py" in captured.out
|
||||
|
||||
def test_show_changed_files_multiple_files(self, capsys):
|
||||
"""show_changed_files displays all changed files."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.display import show_changed_files
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_changed_files.return_value = [
|
||||
("A", "new.py"),
|
||||
("M", "modified.py"),
|
||||
("D", "deleted.py"),
|
||||
("R", "renamed.py"),
|
||||
]
|
||||
|
||||
show_changed_files(mock_manager, "test-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ new.py" in captured.out
|
||||
assert "~ modified.py" in captured.out
|
||||
assert "- deleted.py" in captured.out
|
||||
assert "R renamed.py" in captured.out
|
||||
|
||||
|
||||
class TestPrintMergeSuccess:
|
||||
"""Tests for print_merge_success display function."""
|
||||
|
||||
def test_print_merge_success_no_commit_basic(self, capsys):
|
||||
"""print_merge_success with no_commit=True shows basic message."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "working directory" in captured.out
|
||||
assert "Review the changes" in captured.out
|
||||
assert "commit when ready" in captured.out
|
||||
|
||||
def test_print_merge_success_no_commit_with_lock_files(self, capsys):
|
||||
"""print_merge_success with lock_files_excluded shows lock file note."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {"lock_files_excluded": 2}
|
||||
print_merge_success(no_commit=True, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Lock files kept from main" in captured.out
|
||||
assert "npm install" in captured.out
|
||||
|
||||
def test_print_merge_success_no_commit_with_keep_worktree(self, capsys):
|
||||
"""print_merge_success with keep_worktree shows discard command."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=True, spec_name="spec-001", keep_worktree=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Worktree kept for testing" in captured.out
|
||||
assert "python auto-claude/run.py --spec spec-001 --discard" in captured.out
|
||||
|
||||
def test_print_merge_success_no_commit_full_scenario(self, capsys):
|
||||
"""print_merge_success with all optional parameters."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {"lock_files_excluded": 1}
|
||||
print_merge_success(
|
||||
no_commit=True,
|
||||
stats=stats,
|
||||
spec_name="test-spec",
|
||||
keep_worktree=True,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Lock files kept from main" in captured.out
|
||||
assert "Worktree kept for testing" in captured.out
|
||||
assert "--spec test-spec --discard" in captured.out
|
||||
|
||||
def test_print_merge_success_with_commit_basic(self, capsys):
|
||||
"""print_merge_success with no_commit=False shows commit message."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "separate workspace has been cleaned up" in captured.out
|
||||
|
||||
def test_print_merge_success_with_commit_and_stats(self, capsys):
|
||||
"""print_merge_success with stats shows file counts."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {
|
||||
"files_added": 5,
|
||||
"files_modified": 3,
|
||||
"files_deleted": 1,
|
||||
}
|
||||
print_merge_success(no_commit=False, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "What changed" in captured.out
|
||||
assert "+ 5 files added" in captured.out
|
||||
assert "~ 3 files modified" in captured.out
|
||||
assert "- 1 file deleted" in captured.out
|
||||
|
||||
def test_print_merge_success_singular_file_counts(self, capsys):
|
||||
"""print_merge_success uses singular form for single file counts."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {
|
||||
"files_added": 1,
|
||||
"files_modified": 1,
|
||||
"files_deleted": 1,
|
||||
}
|
||||
print_merge_success(no_commit=False, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 1 file added" in captured.out
|
||||
assert "~ 1 file modified" in captured.out
|
||||
assert "- 1 file deleted" in captured.out
|
||||
|
||||
def test_print_merge_success_with_keep_worktree(self, capsys):
|
||||
"""print_merge_success with keep_worktree shows discard command."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
print_merge_success(no_commit=False, keep_worktree=True, spec_name="my-spec")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
|
||||
assert "Worktree kept for testing" in captured.out
|
||||
assert "--spec my-spec --discard" in captured.out
|
||||
assert "separate workspace has been cleaned up" not in captured.out
|
||||
|
||||
def test_print_merge_success_zero_file_counts_not_shown(self, capsys):
|
||||
"""print_merge_success doesn't show file types with zero count."""
|
||||
from core.workspace.display import print_merge_success
|
||||
|
||||
stats = {
|
||||
"files_added": 2,
|
||||
"files_modified": 0,
|
||||
"files_deleted": 0,
|
||||
}
|
||||
print_merge_success(no_commit=False, stats=stats)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "+ 2 files added" in captured.out
|
||||
assert "files modified" not in captured.out
|
||||
assert "files deleted" not in captured.out
|
||||
|
||||
|
||||
class TestPrintConflictInfoExtended:
|
||||
"""Extended tests for print_conflict_info display function."""
|
||||
|
||||
def test_print_conflict_info_empty_conflicts(self, capsys):
|
||||
"""print_conflict_info returns early with empty conflicts list."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": []}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
def test_print_conflict_info_no_conflicts_key(self, capsys):
|
||||
"""print_conflict_info returns early when conflicts key missing."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
|
||||
def test_print_conflict_info_critical_severity(self, capsys):
|
||||
"""print_conflict_info shows critical severity icon."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{
|
||||
"file": "critical.py",
|
||||
"reason": "Breaking change",
|
||||
"severity": "critical",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "critical.py" in captured.out
|
||||
assert "⛔" in captured.out
|
||||
assert "Breaking change" in captured.out
|
||||
|
||||
def test_print_conflict_info_high_severity(self, capsys):
|
||||
"""print_conflict_info shows high severity icon."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "high.py", "reason": "Major conflict", "severity": "high"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "high.py" in captured.out
|
||||
assert "🔴" in captured.out
|
||||
assert "Major conflict" in captured.out
|
||||
|
||||
def test_print_conflict_info_medium_severity(self, capsys):
|
||||
"""print_conflict_info shows medium severity icon."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "medium.py", "reason": "Minor conflict", "severity": "medium"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "medium.py" in captured.out
|
||||
assert "🟡" in captured.out
|
||||
assert "Minor conflict" in captured.out
|
||||
|
||||
def test_print_conflict_info_low_severity_no_icon(self, capsys):
|
||||
"""print_conflict_info shows no icon for low severity."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "low.py", "reason": "Trivial issue", "severity": "low"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "low.py" in captured.out
|
||||
assert "Trivial issue" in captured.out
|
||||
assert "⛔" not in captured.out
|
||||
assert "🔴" not in captured.out
|
||||
assert "🟡" not in captured.out
|
||||
|
||||
def test_print_conflict_info_unknown_severity(self, capsys):
|
||||
"""print_conflict_info handles unknown severity gracefully."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "unknown.py", "reason": "Unknown", "severity": "unknown"}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "unknown.py" in captured.out
|
||||
assert "Unknown" in captured.out
|
||||
|
||||
def test_print_conflict_info_missing_file_key(self, capsys):
|
||||
"""print_conflict_info handles missing file key."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": [{"reason": "No file specified", "severity": "high"}]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "unknown" in captured.out
|
||||
assert "No file specified" in captured.out
|
||||
|
||||
def test_print_conflict_info_missing_reason_key(self, capsys):
|
||||
"""print_conflict_info handles missing reason key."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": [{"file": "noreason.py", "severity": "medium"}]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "noreason.py" in captured.out
|
||||
|
||||
def test_print_conflict_info_dict_no_reason(self, capsys):
|
||||
"""print_conflict_info with dict missing reason."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": [{"file": "test.py", "severity": "high"}]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "test.py" in captured.out
|
||||
assert "🔴" in captured.out
|
||||
|
||||
def test_print_conflict_info_multiple_conflicts(self, capsys):
|
||||
"""print_conflict_info handles multiple conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "critical.py", "reason": "Critical", "severity": "critical"},
|
||||
{"file": "high.py", "reason": "High", "severity": "high"},
|
||||
{"file": "medium.py", "reason": "Medium", "severity": "medium"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 file" in captured.out
|
||||
assert "⛔" in captured.out
|
||||
assert "🔴" in captured.out
|
||||
assert "🟡" in captured.out
|
||||
|
||||
def test_print_conflict_info_shows_marker_conflict_message(self, capsys):
|
||||
"""print_conflict_info shows marker conflict message for string conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": ["conflict.py"]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "conflict markers" in captured.out
|
||||
# Check that the conflict markers are mentioned in the message
|
||||
|
||||
def test_print_conflict_info_shows_ai_conflict_message(self, capsys):
|
||||
"""print_conflict_info shows AI conflict message for dict conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{
|
||||
"file": "ai-conflict.py",
|
||||
"reason": "AI merge failed",
|
||||
"severity": "high",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "could not be auto-merged" in captured.out
|
||||
|
||||
def test_print_conflict_info_shows_both_messages_mixed(self, capsys):
|
||||
"""print_conflict_info shows both messages for mixed conflicts."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"marker.py",
|
||||
{"file": "ai.py", "reason": "AI failed", "severity": "high"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "conflict markers" in captured.out
|
||||
assert "could not be auto-merged" in captured.out
|
||||
|
||||
def test_print_conflict_info_shows_git_commands(self, capsys):
|
||||
"""print_conflict_info shows git add and commit commands."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": ["file1.py", "file2.py"]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "git add" in captured.out
|
||||
assert "git commit" in captured.out
|
||||
|
||||
def test_print_conflict_info_quotes_special_paths(self, capsys):
|
||||
"""print_conflict_info properly quotes file paths with special characters."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {"conflicts": ["file with spaces.py", "file'with'quotes.py"]}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# shlex.quote should quote paths with spaces
|
||||
assert "git add" in captured.out
|
||||
assert "file with spaces.py" in captured.out
|
||||
|
||||
def test_print_conflict_info_deduplicates_files(self, capsys):
|
||||
"""print_conflict_info deduplicates file paths in git command."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"file1.py",
|
||||
{"file": "file1.py", "reason": "Also here", "severity": "medium"},
|
||||
"file2.py",
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Count occurrences of file1.py
|
||||
count = captured.out.count("file1.py")
|
||||
assert count == 3 # Display shows it twice (string + dict), once in git add
|
||||
|
||||
def test_print_conflict_info_preserves_order(self, capsys):
|
||||
"""print_conflict_info preserves file order while deduplicating."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"first.py",
|
||||
{"file": "second.py", "severity": "high"},
|
||||
"first.py", # Duplicate
|
||||
{"file": "third.py", "severity": "medium"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# First occurrence should be preserved
|
||||
lines = captured.out.split("\n")
|
||||
first_idx = None
|
||||
second_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if "first.py" in line:
|
||||
if first_idx is None:
|
||||
first_idx = i
|
||||
if "second.py" in line:
|
||||
if second_idx is None:
|
||||
second_idx = i
|
||||
assert first_idx is not None
|
||||
assert second_idx is not None
|
||||
|
||||
|
||||
class TestCopyEnvFilesToWorktree:
|
||||
"""Tests for copy_env_files_to_worktree function."""
|
||||
|
||||
def test_copies_all_env_files(self, temp_git_repo: Path):
|
||||
"""Copies all .env files when they exist in project dir."""
|
||||
from core.workspace.setup import copy_env_files_to_worktree
|
||||
|
||||
# Create .env files in project
|
||||
(temp_git_repo / ".env").write_text("FOO=bar", encoding="utf-8")
|
||||
(temp_git_repo / ".env.local").write_text("LOCAL=1", encoding="utf-8")
|
||||
(temp_git_repo / ".env.development").write_text("DEV=1", encoding="utf-8")
|
||||
|
||||
# Create worktree directory
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Copy env files
|
||||
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
# Check all files were copied
|
||||
assert ".env" in copied
|
||||
assert ".env.local" in copied
|
||||
assert ".env.development" in copied
|
||||
assert len(copied) == 3
|
||||
|
||||
# Verify files exist in worktree
|
||||
assert (worktree_path / ".env").exists()
|
||||
assert (worktree_path / ".env.local").exists()
|
||||
assert (worktree_path / ".env.development").exists()
|
||||
|
||||
def test_skips_nonexistent_env_files(self, temp_git_repo: Path):
|
||||
"""Only copies env files that exist."""
|
||||
from core.workspace.setup import copy_env_files_to_worktree
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
assert len(copied) == 0
|
||||
|
||||
def test_does_not_overwrite_existing_env_files(self, temp_git_repo: Path):
|
||||
"""Does not overwrite .env files that already exist in worktree."""
|
||||
from core.workspace.setup import copy_env_files_to_worktree
|
||||
|
||||
# Create .env in project
|
||||
(temp_git_repo / ".env").write_text("PROJECT=1", encoding="utf-8")
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Create existing .env in worktree with different content
|
||||
(worktree_path / ".env").write_text("WORKTREE=1", encoding="utf-8")
|
||||
|
||||
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
# .env should not be in copied list since it already existed
|
||||
assert ".env" not in copied
|
||||
|
||||
# Worktree .env should keep its original content
|
||||
assert (worktree_path / ".env").read_text(encoding="utf-8") == "WORKTREE=1"
|
||||
|
||||
|
||||
class TestSymlinkNodeModulesToWorktree:
|
||||
"""Tests for symlink_node_modules_to_worktree function."""
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="Unix-specific test")
|
||||
def test_symlinks_node_modules_on_unix(self, temp_git_repo: Path):
|
||||
"""Creates relative symlinks on Unix systems."""
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
# Create node_modules in project
|
||||
node_modules = temp_git_repo / "node_modules"
|
||||
node_modules.mkdir()
|
||||
(node_modules / "test.txt").write_text("test", encoding="utf-8")
|
||||
|
||||
# Create apps/frontend/node_modules
|
||||
frontend_node_modules = temp_git_repo / "apps" / "frontend" / "node_modules"
|
||||
frontend_node_modules.mkdir(parents=True)
|
||||
(frontend_node_modules / "test2.txt").write_text("test2", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
(worktree_path / "apps" / "frontend").mkdir(parents=True)
|
||||
|
||||
# Create symlinks
|
||||
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
assert len(symlinked) == 2
|
||||
assert "node_modules" in symlinked
|
||||
assert "apps/frontend/node_modules" in symlinked
|
||||
|
||||
# Verify symlinks exist and point to correct location
|
||||
assert (worktree_path / "node_modules").is_symlink()
|
||||
assert (worktree_path / "apps" / "frontend" / "node_modules").is_symlink()
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
|
||||
def test_creates_junctions_on_windows(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Creates junctions on Windows systems."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
# Create node_modules in project
|
||||
node_modules = temp_git_repo / "node_modules"
|
||||
node_modules.mkdir()
|
||||
(node_modules / "test.txt").write_text("test", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock subprocess.run to simulate mklink /J success
|
||||
def mock_subprocess_run(cmd, capture_output=False, text=False):
|
||||
result = type("obj", (object,), {"returncode": 0, "stderr": ""})()
|
||||
return result
|
||||
|
||||
with patch("subprocess.run", side_effect=mock_subprocess_run):
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr("sys.platform", "win32")
|
||||
symlinked = symlink_node_modules_to_worktree(
|
||||
temp_git_repo, worktree_path
|
||||
)
|
||||
|
||||
assert "node_modules" in symlinked
|
||||
|
||||
def test_skips_nonexistent_node_modules(self, temp_git_repo: Path):
|
||||
"""Skips node_modules that don't exist in project."""
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
assert len(symlinked) == 0
|
||||
|
||||
def test_skips_existing_symlinks(self, temp_git_repo: Path):
|
||||
"""Does not recreate symlinks that already exist."""
|
||||
from core.workspace.setup import symlink_node_modules_to_worktree
|
||||
|
||||
# Create node_modules in project
|
||||
node_modules = temp_git_repo / "node_modules"
|
||||
node_modules.mkdir()
|
||||
(node_modules / "test.txt").write_text("test", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Create existing symlink
|
||||
if sys.platform != "win32":
|
||||
os.symlink(temp_git_repo / "node_modules", worktree_path / "node_modules")
|
||||
|
||||
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
|
||||
|
||||
# Should skip existing symlink
|
||||
assert "node_modules" not in symlinked
|
||||
@@ -0,0 +1,805 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Selection and Management
|
||||
=============================================
|
||||
|
||||
Tests the workspace.py module functionality including:
|
||||
- Workspace mode selection (isolated vs direct)
|
||||
- Uncommitted changes detection
|
||||
- Workspace setup
|
||||
- Build finalization workflows
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path so we can import the workspace module
|
||||
# When co-located at workspace/tests/, we need to add backend to path
|
||||
# workspace/tests -> workspace -> core -> backend (4 levels up)
|
||||
_backend = Path(__file__).resolve().parent.parent.parent.parent
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
from core.workspace import (
|
||||
WorkspaceChoice,
|
||||
WorkspaceMode,
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
has_uncommitted_changes,
|
||||
setup_workspace,
|
||||
)
|
||||
from worktree import WorktreeError, WorktreeManager
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
# =============================================================================
|
||||
# TESTS FOR finalization.py
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestFinalizeWorkspace:
|
||||
"""Tests for finalize_workspace function."""
|
||||
|
||||
def test_direct_mode_returns_merge(self, temp_git_repo: Path, monkeypatch, capsys):
|
||||
"""Direct mode returns MERGE choice and shows completion message."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
# Mock the UI functions
|
||||
def mock_box(content, width=60, style="heavy"):
|
||||
return content
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.box", mock_box)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
manager=None,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.MERGE
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "BUILD COMPLETE" in captured.out
|
||||
assert "directly to your project" in captured.out
|
||||
|
||||
def test_auto_continue_mode_returns_later(self, temp_git_repo: Path):
|
||||
"""Auto-continue mode returns LATER choice."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree info
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=True,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.LATER
|
||||
|
||||
def test_isolated_mode_shows_menu(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode shows menu with test/review/merge/later options."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "test"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "test"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.TEST
|
||||
|
||||
|
||||
class TestHandleWorkspaceChoice:
|
||||
"""Tests for handle_workspace_choice function."""
|
||||
|
||||
def test_choice_test_shows_instructions(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""TEST choice shows testing instructions."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TEST YOUR FEATURE" in captured.out
|
||||
assert str(worktree_path) in captured.out
|
||||
|
||||
def test_choice_merge_calls_merge_worktree(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""MERGE choice calls manager.merge_worktree."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree and commit something
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
(worktree_path / "test.py").write_text("test", encoding="utf-8")
|
||||
|
||||
# Initialize git in worktree and commit
|
||||
subprocess.run(["git", "init"], cwd=worktree_path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=worktree_path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"], cwd=worktree_path, capture_output=True
|
||||
)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.MERGE, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Adding changes" in captured.out
|
||||
|
||||
def test_choice_review_shows_changed_files(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""REVIEW choice shows changed files."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock show_changed_files
|
||||
mock_shown = []
|
||||
|
||||
def mock_show_changed_files(manager, spec_name):
|
||||
mock_shown.append(spec_name)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.workspace.finalization.show_changed_files", mock_show_changed_files
|
||||
)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.REVIEW, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
assert len(mock_shown) == 1
|
||||
assert mock_shown[0] == spec_name
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "To see full details" in captured.out
|
||||
|
||||
def test_choice_later_shows_deferred_message(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""LATER choice shows deferral message."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No problem!" in captured.out
|
||||
assert "saved" in captured.out
|
||||
|
||||
|
||||
class TestReviewExistingBuild:
|
||||
"""Tests for review_existing_build function."""
|
||||
|
||||
def test_no_existing_build_shows_warning(self, temp_git_repo: Path, capsys):
|
||||
"""Shows warning when no existing build found."""
|
||||
from core.workspace.finalization import review_existing_build
|
||||
|
||||
result = review_existing_build(temp_git_repo, "nonexistent-spec")
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No existing build found" in captured.out
|
||||
|
||||
def test_shows_build_contents(self, temp_git_repo: Path, capsys):
|
||||
"""Shows build summary and changed files when build exists."""
|
||||
from core.workspace.finalization import review_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = review_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "BUILD CONTENTS" in captured.out
|
||||
|
||||
|
||||
class TestDiscardExistingBuild:
|
||||
"""Tests for discard_existing_build function."""
|
||||
|
||||
def test_no_existing_build_returns_false(self, temp_git_repo: Path, capsys):
|
||||
"""Returns False when no existing build found."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
result = discard_existing_build(temp_git_repo, "nonexistent-spec")
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No existing build found" in captured.out
|
||||
|
||||
def test_confirmation_deletes_build(self, temp_git_repo: Path, monkeypatch, capsys):
|
||||
"""Deletes build when user types 'delete' to confirm."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock input to return "delete"
|
||||
monkeypatch.setattr("builtins.input", lambda: "delete")
|
||||
|
||||
result = discard_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
captured = capsys.readouterr()
|
||||
assert "Build deleted" in captured.out
|
||||
|
||||
def test_cancelled_confirmation_returns_false(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Returns False when user doesn't confirm."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock input to return "no"
|
||||
monkeypatch.setattr("builtins.input", lambda: "no")
|
||||
|
||||
result = discard_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is False
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
|
||||
class TestCheckExistingBuild:
|
||||
"""Tests for check_existing_build function."""
|
||||
|
||||
def test_no_existing_build_returns_false(self, temp_git_repo: Path):
|
||||
"""Returns False when no existing build."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
result = check_existing_build(temp_git_repo, "nonexistent-spec")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_shows_menu_for_existing_build(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Shows menu when existing build found."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "continue"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "continue"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_review_choice_reviews_and_continues(
|
||||
self, temp_git_repo: Path, monkeypatch
|
||||
):
|
||||
"""Review choice reviews build then continues."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
review_called = []
|
||||
|
||||
def mock_review(project_dir, spec_name):
|
||||
review_called.append(spec_name)
|
||||
return True
|
||||
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "review"
|
||||
|
||||
def mock_input(prompt):
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.workspace.finalization.review_existing_build", mock_review
|
||||
)
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is True
|
||||
assert spec_name in review_called
|
||||
|
||||
|
||||
class TestListAllWorktrees:
|
||||
"""Tests for list_all_worktrees function."""
|
||||
|
||||
def test_returns_empty_list_when_no_worktrees(self, temp_git_repo: Path):
|
||||
"""Returns empty list when no worktrees exist."""
|
||||
from core.workspace.finalization import list_all_worktrees
|
||||
|
||||
result = list_all_worktrees(temp_git_repo)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_lists_existing_worktrees(self, temp_git_repo: Path):
|
||||
"""Returns list of existing worktrees."""
|
||||
from core.workspace.finalization import list_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
(worktrees_dir / "spec-001").mkdir()
|
||||
(worktrees_dir / "spec-002").mkdir()
|
||||
|
||||
result = list_all_worktrees(temp_git_repo)
|
||||
|
||||
assert len(result) == 2
|
||||
spec_names = {wt.spec_name for wt in result}
|
||||
assert "spec-001" in spec_names
|
||||
assert "spec-002" in spec_names
|
||||
|
||||
|
||||
class TestCleanupAllWorktrees:
|
||||
"""Tests for cleanup_all_worktrees function."""
|
||||
|
||||
def test_no_worktrees_returns_false(self, temp_git_repo: Path, capsys):
|
||||
"""Returns False when no worktrees found."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=False)
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No worktrees found" in captured.out
|
||||
|
||||
def test_cleanup_without_confirmation(self, temp_git_repo: Path):
|
||||
"""Cleans up worktrees when confirm=False."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
spec2_path = worktrees_dir / "spec-002"
|
||||
spec2_path.mkdir()
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=False)
|
||||
|
||||
assert result is True
|
||||
assert not spec1_path.exists()
|
||||
assert not spec2_path.exists()
|
||||
|
||||
def test_cleanup_with_confirmation_yes(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Cleans up worktrees when user confirms with 'yes'."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
|
||||
# Mock input to return "yes"
|
||||
monkeypatch.setattr("builtins.input", lambda: "yes")
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
|
||||
|
||||
assert result is True
|
||||
assert not spec1_path.exists()
|
||||
|
||||
def test_cleanup_with_confirmation_no(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Cancels cleanup when user doesn't confirm."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
|
||||
# Mock input to return "no"
|
||||
monkeypatch.setattr("builtins.input", lambda: "no")
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
|
||||
|
||||
assert result is False
|
||||
assert spec1_path.exists() # Should still exist
|
||||
|
||||
def test_cleanup_with_confirmation_keyboard_interrupt(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Cancels cleanup when user presses Ctrl+C (KeyboardInterrupt)."""
|
||||
from core.workspace.finalization import cleanup_all_worktrees
|
||||
|
||||
# Create worktrees
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
spec1_path = worktrees_dir / "spec-001"
|
||||
spec1_path.mkdir()
|
||||
|
||||
# Mock input to raise KeyboardInterrupt
|
||||
def mock_input(prompt=""):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
|
||||
|
||||
assert result is False
|
||||
assert spec1_path.exists() # Should still exist
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
|
||||
class TestFinalizeWorkspaceBranchCoverage:
|
||||
"""Additional tests for finalize_workspace to cover missing branches."""
|
||||
|
||||
def test_isolated_mode_merge_choice(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode returns MERGE when user selects merge."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "merge"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "merge"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.MERGE
|
||||
|
||||
def test_isolated_mode_review_choice(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode returns REVIEW when user selects review."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "review"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "review"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.REVIEW
|
||||
|
||||
def test_isolated_mode_later_choice(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Isolated mode returns LATER when user selects later."""
|
||||
from core.workspace.finalization import finalize_workspace
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return "later"
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "later"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
result = finalize_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
manager=manager,
|
||||
auto_continue=False,
|
||||
)
|
||||
|
||||
assert result == WorkspaceChoice.LATER
|
||||
|
||||
|
||||
class TestHandleWorkspaceChoiceBranchCoverage:
|
||||
"""Additional tests for handle_workspace_choice to cover missing branches."""
|
||||
|
||||
def test_choice_test_without_staging_path(self, temp_git_repo: Path, capsys):
|
||||
"""TEST choice shows fallback instructions when staging_path is None."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree directory (but not through manager, so no staging_path)
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "TEST YOUR FEATURE" in captured.out
|
||||
# Should show the fallback path
|
||||
assert (
|
||||
str(worktree_path) in captured.out
|
||||
or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
|
||||
)
|
||||
|
||||
def test_choice_merge_success(self, temp_git_repo: Path, capsys):
|
||||
"""MERGE choice shows success message when merge succeeds."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
from worktree import WorktreeManager
|
||||
|
||||
# Setup a proper isolated workspace with git worktree
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Make changes and commit
|
||||
(working_dir / "test.py").write_text("test content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add test"], cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.MERGE, temp_git_repo, "test-spec", manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Your feature has been added" in captured.out
|
||||
|
||||
def test_choice_later_without_staging_path(self, temp_git_repo: Path, capsys):
|
||||
"""LATER choice shows fallback path when staging_path is None."""
|
||||
from core.workspace.finalization import handle_workspace_choice
|
||||
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
spec_name = "test-spec"
|
||||
|
||||
# Create worktree directory (but not through manager, so no staging_path)
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
handle_workspace_choice(
|
||||
WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No problem!" in captured.out
|
||||
# Should show the fallback path
|
||||
assert (
|
||||
str(worktree_path) in captured.out
|
||||
or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
|
||||
)
|
||||
|
||||
|
||||
class TestDiscardExistingBuildBranchCoverage:
|
||||
"""Additional tests for discard_existing_build to cover missing branches."""
|
||||
|
||||
def test_keyboard_interrupt_cancels_discard(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""KeyboardInterrupt during confirmation returns False."""
|
||||
from core.workspace.finalization import discard_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock input to raise KeyboardInterrupt
|
||||
def mock_input(prompt=""):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
|
||||
result = discard_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert result is False
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Cancelled" in captured.out
|
||||
|
||||
|
||||
class TestCheckExistingBuildBranchCoverage:
|
||||
"""Additional tests for check_existing_build to cover missing branches."""
|
||||
|
||||
def test_none_choice_exits(self, temp_git_repo: Path, monkeypatch):
|
||||
"""None choice (quit) calls sys.exit(0)."""
|
||||
import sys
|
||||
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock select_menu to return None (quit)
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
# Should raise SystemExit
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
check_existing_build(temp_git_repo, spec_name)
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_merge_choice_merges_and_returns_false(
|
||||
self, temp_git_repo: Path, monkeypatch
|
||||
):
|
||||
"""Merge choice calls merge_existing_build and returns False."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
merge_called = []
|
||||
|
||||
def mock_merge_existing_build(project_dir, spec_name):
|
||||
merge_called.append(spec_name)
|
||||
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "merge"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
|
||||
# Mock the workspace module import
|
||||
import workspace as ws
|
||||
|
||||
original_merge = getattr(ws, "merge_existing_build", None)
|
||||
ws.merge_existing_build = mock_merge_existing_build
|
||||
|
||||
try:
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
assert result is False
|
||||
assert spec_name in merge_called
|
||||
finally:
|
||||
if original_merge:
|
||||
ws.merge_existing_build = original_merge
|
||||
|
||||
def test_fresh_choice_discards_and_returns_false(
|
||||
self, temp_git_repo: Path, monkeypatch
|
||||
):
|
||||
"""Fresh choice discards build and returns False (start fresh)."""
|
||||
from core.workspace.finalization import check_existing_build
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_path = worktrees_dir / spec_name
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
def mock_select_menu(title, options, allow_quit):
|
||||
return "fresh"
|
||||
|
||||
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
|
||||
# Mock input to return "delete" for confirmation
|
||||
monkeypatch.setattr("builtins.input", lambda: "delete")
|
||||
|
||||
result = check_existing_build(temp_git_repo, spec_name)
|
||||
assert result is False, "Fresh choice should return False"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,638 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Models
|
||||
==========================
|
||||
|
||||
Tests the workspace.py module models including:
|
||||
- WorkspaceMode enum
|
||||
- WorkspaceChoice enum
|
||||
- ParallelMergeTask
|
||||
- ParallelMergeResult
|
||||
- MergeLock and MergeLockError
|
||||
- SpecNumberLock and SpecNumberLockError
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path so we can import the workspace module
|
||||
# When co-located at workspace/tests/, we need to add backend to path
|
||||
# workspace/tests -> workspace -> core -> backend (4 levels up)
|
||||
_backend = Path(__file__).resolve().parent.parent.parent.parent
|
||||
sys.path.insert(0, str(_backend))
|
||||
|
||||
from core.workspace.models import (
|
||||
MergeLock,
|
||||
MergeLockError,
|
||||
ParallelMergeResult,
|
||||
ParallelMergeTask,
|
||||
SpecNumberLock,
|
||||
SpecNumberLockError,
|
||||
)
|
||||
from worktree import WorktreeError, WorktreeManager
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestWorkspaceMode:
|
||||
"""Tests for WorkspaceMode enum."""
|
||||
|
||||
def test_isolated_mode(self):
|
||||
"""ISOLATED mode value is correct."""
|
||||
from core.workspace.models import WorkspaceMode
|
||||
|
||||
assert WorkspaceMode.ISOLATED.value == "isolated"
|
||||
|
||||
def test_direct_mode(self):
|
||||
"""DIRECT mode value is correct."""
|
||||
from core.workspace.models import WorkspaceMode
|
||||
|
||||
assert WorkspaceMode.DIRECT.value == "direct"
|
||||
|
||||
|
||||
class TestWorkspaceChoice:
|
||||
"""Tests for WorkspaceChoice enum."""
|
||||
|
||||
def test_merge_choice(self):
|
||||
"""MERGE choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.MERGE.value == "merge"
|
||||
|
||||
def test_review_choice(self):
|
||||
"""REVIEW choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.REVIEW.value == "review"
|
||||
|
||||
def test_test_choice(self):
|
||||
"""TEST choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.TEST.value == "test"
|
||||
|
||||
def test_later_choice(self):
|
||||
"""LATER choice value is correct."""
|
||||
from core.workspace.models import WorkspaceChoice
|
||||
|
||||
assert WorkspaceChoice.LATER.value == "later"
|
||||
|
||||
|
||||
class TestParallelMergeTask:
|
||||
"""Tests for ParallelMergeTask dataclass."""
|
||||
|
||||
def test_create_merge_task(self):
|
||||
"""ParallelMergeTask can be instantiated with all fields."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/example.py",
|
||||
main_content="main content",
|
||||
worktree_content="worktree content",
|
||||
base_content="base content",
|
||||
spec_name="test-spec",
|
||||
project_dir=Path("/project"),
|
||||
)
|
||||
|
||||
assert task.file_path == "src/example.py"
|
||||
assert task.main_content == "main content"
|
||||
assert task.worktree_content == "worktree content"
|
||||
assert task.base_content == "base content"
|
||||
assert task.spec_name == "test-spec"
|
||||
assert task.project_dir == Path("/project")
|
||||
|
||||
def test_merge_task_with_none_base(self):
|
||||
"""ParallelMergeTask can have None for base_content."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/example.py",
|
||||
main_content="main content",
|
||||
worktree_content="worktree content",
|
||||
base_content=None,
|
||||
spec_name="test-spec",
|
||||
project_dir=Path("/project"),
|
||||
)
|
||||
|
||||
assert task.base_content is None
|
||||
|
||||
def test_merge_task_field_assignment(self):
|
||||
"""ParallelMergeTask fields can be reassigned."""
|
||||
task = ParallelMergeTask(
|
||||
file_path="src/example.py",
|
||||
main_content="main",
|
||||
worktree_content="worktree",
|
||||
base_content=None,
|
||||
spec_name="spec-1",
|
||||
project_dir=Path("/project"),
|
||||
)
|
||||
|
||||
task.file_path = "src/updated.py"
|
||||
task.main_content = "updated main"
|
||||
task.worktree_content = "updated worktree"
|
||||
task.base_content = "updated base"
|
||||
task.spec_name = "spec-2"
|
||||
task.project_dir = Path("/updated")
|
||||
|
||||
assert task.file_path == "src/updated.py"
|
||||
assert task.main_content == "updated main"
|
||||
assert task.worktree_content == "updated worktree"
|
||||
assert task.base_content == "updated base"
|
||||
assert task.spec_name == "spec-2"
|
||||
assert task.project_dir == Path("/updated")
|
||||
|
||||
|
||||
class TestParallelMergeResult:
|
||||
"""Tests for ParallelMergeResult dataclass."""
|
||||
|
||||
def test_create_successful_result(self):
|
||||
"""ParallelMergeResult can represent a successful merge."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content="merged content",
|
||||
success=True,
|
||||
error=None,
|
||||
was_auto_merged=True,
|
||||
)
|
||||
|
||||
assert result.file_path == "src/example.py"
|
||||
assert result.merged_content == "merged content"
|
||||
assert result.success is True
|
||||
assert result.error is None
|
||||
assert result.was_auto_merged is True
|
||||
|
||||
def test_create_failed_result(self):
|
||||
"""ParallelMergeResult can represent a failed merge."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="Merge conflict occurred",
|
||||
was_auto_merged=False,
|
||||
)
|
||||
|
||||
assert result.file_path == "src/example.py"
|
||||
assert result.merged_content is None
|
||||
assert result.success is False
|
||||
assert result.error == "Merge conflict occurred"
|
||||
assert result.was_auto_merged is False
|
||||
|
||||
def test_result_default_values(self):
|
||||
"""ParallelMergeResult has correct default values."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content="content",
|
||||
success=True,
|
||||
)
|
||||
|
||||
assert result.error is None
|
||||
assert result.was_auto_merged is False
|
||||
|
||||
def test_result_field_assignment(self):
|
||||
"""ParallelMergeResult fields can be reassigned."""
|
||||
result = ParallelMergeResult(
|
||||
file_path="src/example.py",
|
||||
merged_content="merged",
|
||||
success=True,
|
||||
error=None,
|
||||
was_auto_merged=False,
|
||||
)
|
||||
|
||||
result.file_path = "src/updated.py"
|
||||
result.merged_content = "updated merged"
|
||||
result.success = False
|
||||
result.error = "New error"
|
||||
result.was_auto_merged = True
|
||||
|
||||
assert result.file_path == "src/updated.py"
|
||||
assert result.merged_content == "updated merged"
|
||||
assert result.success is False
|
||||
assert result.error == "New error"
|
||||
assert result.was_auto_merged is True
|
||||
|
||||
|
||||
class TestMergeLockError:
|
||||
"""Tests for MergeLockError exception."""
|
||||
|
||||
def test_merge_lock_error_creation(self):
|
||||
"""MergeLockError can be instantiated with a message."""
|
||||
error = MergeLockError("Could not acquire lock")
|
||||
assert str(error) == "Could not acquire lock"
|
||||
|
||||
def test_merge_lock_error_is_exception(self):
|
||||
"""MergeLockError is an Exception subclass."""
|
||||
error = MergeLockError("test")
|
||||
assert isinstance(error, Exception)
|
||||
assert isinstance(error, MergeLockError)
|
||||
|
||||
def test_raise_merge_lock_error(self):
|
||||
"""MergeLockError can be raised and caught."""
|
||||
with pytest.raises(MergeLockError) as exc_info:
|
||||
raise MergeLockError("Lock timeout")
|
||||
assert str(exc_info.value) == "Lock timeout"
|
||||
|
||||
|
||||
class TestMergeLock:
|
||||
"""Tests for MergeLock context manager."""
|
||||
|
||||
def test_merge_lock_initialization(self, temp_git_repo: Path):
|
||||
"""MergeLock initializes with correct paths."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
assert lock.project_dir == temp_git_repo
|
||||
assert lock.spec_name == "test-spec"
|
||||
assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
|
||||
assert lock.lock_file == lock.lock_dir / "merge-test-spec.lock"
|
||||
assert lock.acquired is False
|
||||
|
||||
def test_merge_lock_acquire_and_release(self, temp_git_repo: Path):
|
||||
"""MergeLock can be acquired and released."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
assert lock.lock_file.exists()
|
||||
|
||||
# After context, lock should be released
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_merge_lock_creates_lock_dir(self, temp_git_repo: Path):
|
||||
"""MergeLock creates lock directory if it doesn't exist."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
# Remove lock dir if it exists
|
||||
if lock.lock_dir.exists():
|
||||
lock.lock_dir.rmdir()
|
||||
|
||||
with lock:
|
||||
assert lock.lock_dir.exists()
|
||||
|
||||
def test_merge_lock_writes_pid(self, temp_git_repo: Path):
|
||||
"""MergeLock writes current PID to lock file."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock:
|
||||
pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
|
||||
assert pid_content == str(os.getpid())
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_merge_lock_timeout_on_contention(self, temp_git_repo: Path):
|
||||
"""MergeLock raises MergeLockError when lock is held by another process."""
|
||||
lock1 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
# Acquire first lock
|
||||
lock1.__enter__()
|
||||
|
||||
try:
|
||||
# Create a second lock for the same spec
|
||||
lock2 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
# This should timeout because lock1 holds the lock
|
||||
with pytest.raises(MergeLockError) as exc_info:
|
||||
lock2.__enter__()
|
||||
|
||||
assert "Could not acquire merge lock" in str(exc_info.value)
|
||||
assert "test-spec" in str(exc_info.value)
|
||||
assert "after 30s" in str(exc_info.value)
|
||||
finally:
|
||||
lock1.__exit__(None, None, None)
|
||||
|
||||
def test_merge_lock_removes_stale_lock(self, temp_git_repo: Path):
|
||||
"""MergeLock removes stale lock from dead process."""
|
||||
lock1 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock1:
|
||||
# Write a fake PID that doesn't exist
|
||||
fake_pid = 999999
|
||||
lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the stale lock
|
||||
lock2 = MergeLock(temp_git_repo, "test-spec")
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_merge_lock_handles_invalid_pid(self, temp_git_repo: Path):
|
||||
"""MergeLock handles invalid PID in lock file."""
|
||||
lock1 = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock1:
|
||||
# Write invalid content to lock file
|
||||
lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the invalid lock
|
||||
lock2 = MergeLock(temp_git_repo, "test-spec")
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_merge_lock_cleanup_on_exception(self, temp_git_repo: Path):
|
||||
"""MergeLock releases lock even if exception occurs in context."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
try:
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
raise ValueError("Test exception")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Lock should be released despite exception
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_merge_lock_idempotent_release(self, temp_git_repo: Path):
|
||||
"""MergeLock __exit__ can be called multiple times safely."""
|
||||
lock = MergeLock(temp_git_repo, "test-spec")
|
||||
|
||||
with lock:
|
||||
pass
|
||||
|
||||
# Call __exit__ again - should not raise
|
||||
lock.__exit__(None, None, None)
|
||||
lock.__exit__(None, None, None)
|
||||
|
||||
def test_merge_lock_different_specs_dont_conflict(self, temp_git_repo: Path):
|
||||
"""MergeLock for different specs can be held simultaneously."""
|
||||
lock1 = MergeLock(temp_git_repo, "spec-1")
|
||||
lock2 = MergeLock(temp_git_repo, "spec-2")
|
||||
|
||||
with lock1:
|
||||
with lock2:
|
||||
assert lock1.acquired is True
|
||||
assert lock2.acquired is True
|
||||
assert lock1.lock_file != lock2.lock_file
|
||||
|
||||
|
||||
class TestSpecNumberLockError:
|
||||
"""Tests for SpecNumberLockError exception."""
|
||||
|
||||
def test_spec_number_lock_error_creation(self):
|
||||
"""SpecNumberLockError can be instantiated with a message."""
|
||||
error = SpecNumberLockError("Could not acquire spec numbering lock")
|
||||
assert str(error) == "Could not acquire spec numbering lock"
|
||||
|
||||
def test_spec_number_lock_error_is_exception(self):
|
||||
"""SpecNumberLockError is an Exception subclass."""
|
||||
error = SpecNumberLockError("test")
|
||||
assert isinstance(error, Exception)
|
||||
assert isinstance(error, SpecNumberLockError)
|
||||
|
||||
def test_raise_spec_number_lock_error(self):
|
||||
"""SpecNumberLockError can be raised and caught."""
|
||||
with pytest.raises(SpecNumberLockError) as exc_info:
|
||||
raise SpecNumberLockError("Lock timeout")
|
||||
assert str(exc_info.value) == "Lock timeout"
|
||||
|
||||
|
||||
class TestSpecNumberLock:
|
||||
"""Tests for SpecNumberLock context manager."""
|
||||
|
||||
def test_spec_number_lock_initialization(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock initializes with correct paths."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
assert lock.project_dir == temp_git_repo
|
||||
assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
|
||||
assert lock.lock_file == lock.lock_dir / "spec-numbering.lock"
|
||||
assert lock.acquired is False
|
||||
assert lock._global_max is None
|
||||
|
||||
def test_spec_number_lock_acquire_and_release(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock can be acquired and released."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
assert lock.lock_file.exists()
|
||||
|
||||
# After context, lock should be released
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_spec_number_lock_creates_lock_dir(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock creates lock directory if it doesn't exist."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
# Remove lock dir if it exists
|
||||
if lock.lock_dir.exists():
|
||||
lock.lock_dir.rmdir()
|
||||
|
||||
with lock:
|
||||
assert lock.lock_dir.exists()
|
||||
|
||||
def test_spec_number_lock_writes_pid(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock writes current PID to lock file."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
|
||||
assert pid_content == str(os.getpid())
|
||||
|
||||
def test_get_next_spec_number_no_existing_specs(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number returns 1 when no specs exist."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num = lock.get_next_spec_number()
|
||||
assert next_num == 1
|
||||
|
||||
def test_get_next_spec_number_with_existing_specs(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number returns max existing spec number + 1."""
|
||||
# Create spec directories
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "001-first").mkdir()
|
||||
(specs_dir / "003-third").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num = lock.get_next_spec_number()
|
||||
assert next_num == 4
|
||||
|
||||
def test_get_next_spec_number_caches_result(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number caches the global max."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "005-test").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num1 = lock.get_next_spec_number()
|
||||
next_num2 = lock.get_next_spec_number()
|
||||
|
||||
# Should return the same value (cached)
|
||||
assert next_num1 == next_num2 == 6
|
||||
assert lock._global_max == 5
|
||||
|
||||
def test_get_next_spec_number_requires_lock(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number raises SpecNumberLockError if lock not acquired."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with pytest.raises(SpecNumberLockError) as exc_info:
|
||||
lock.get_next_spec_number()
|
||||
|
||||
assert "Lock must be acquired" in str(exc_info.value)
|
||||
|
||||
def test_get_next_spec_number_scans_worktrees(self, temp_git_repo: Path):
|
||||
"""get_next_spec_number scans all worktree spec directories."""
|
||||
# Create main project specs
|
||||
main_specs = temp_git_repo / ".auto-claude" / "specs"
|
||||
main_specs.mkdir(parents=True)
|
||||
(main_specs / "002-main").mkdir()
|
||||
|
||||
# Create worktree with specs
|
||||
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir.mkdir(parents=True)
|
||||
worktree_spec_dir = worktrees_dir / "test-worktree" / ".auto-claude" / "specs"
|
||||
worktree_spec_dir.mkdir(parents=True)
|
||||
(worktree_spec_dir / "005-worktree").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
next_num = lock.get_next_spec_number()
|
||||
# Should find max of 2 and 5, return 6
|
||||
assert next_num == 6
|
||||
|
||||
def test_scan_specs_dir_nonexistent(self, temp_git_repo: Path):
|
||||
"""_scan_specs_dir returns 0 for nonexistent directory."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
# Use a path inside temp_dir that doesn't exist
|
||||
nonexistent = temp_git_repo / "this_does_not_exist_specs"
|
||||
result = lock._scan_specs_dir(nonexistent)
|
||||
assert result == 0
|
||||
|
||||
def test_scan_specs_dir_ignores_invalid_names(self, temp_git_repo: Path):
|
||||
"""_scan_specs_dir ignores directories with invalid spec names."""
|
||||
specs_dir = temp_git_repo / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True)
|
||||
(specs_dir / "001-valid").mkdir()
|
||||
(specs_dir / "invalid-name").mkdir()
|
||||
(specs_dir / "abc").mkdir()
|
||||
(specs_dir / "100-valid").mkdir()
|
||||
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
result = lock._scan_specs_dir(specs_dir)
|
||||
# Should only count 001 and 100
|
||||
assert result == 100
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_spec_number_lock_timeout_on_contention(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock raises SpecNumberLockError when lock is held."""
|
||||
lock1 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
# Acquire first lock
|
||||
lock1.__enter__()
|
||||
|
||||
try:
|
||||
# Create a second lock
|
||||
lock2 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
# This should timeout because lock1 holds the lock
|
||||
with pytest.raises(SpecNumberLockError) as exc_info:
|
||||
lock2.__enter__()
|
||||
|
||||
assert "Could not acquire spec numbering lock" in str(exc_info.value)
|
||||
assert "after 30s" in str(exc_info.value)
|
||||
finally:
|
||||
lock1.__exit__(None, None, None)
|
||||
|
||||
def test_spec_number_lock_removes_stale_lock(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock removes stale lock from dead process."""
|
||||
lock1 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock1:
|
||||
# Write a fake PID that doesn't exist
|
||||
fake_pid = 999999
|
||||
lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the stale lock
|
||||
lock2 = SpecNumberLock(temp_git_repo)
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_spec_number_lock_handles_invalid_pid(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock handles invalid PID in lock file."""
|
||||
lock1 = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock1:
|
||||
# Write invalid content to lock file
|
||||
lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
|
||||
|
||||
# Create a new lock - it should remove the invalid lock
|
||||
lock2 = SpecNumberLock(temp_git_repo)
|
||||
with lock2:
|
||||
assert lock2.acquired is True
|
||||
|
||||
def test_spec_number_lock_cleanup_on_exception(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock releases lock even if exception occurs in context."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
try:
|
||||
with lock:
|
||||
assert lock.acquired is True
|
||||
raise ValueError("Test exception")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Lock should be released despite exception
|
||||
assert lock.lock_file.exists() is False
|
||||
|
||||
def test_spec_number_lock_idempotent_release(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock __exit__ can be called multiple times safely."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock:
|
||||
pass
|
||||
|
||||
# Call __exit__ again - should not raise
|
||||
lock.__exit__(None, None, None)
|
||||
lock.__exit__(None, None, None)
|
||||
|
||||
def test_spec_number_lock_returns_self(self, temp_git_repo: Path):
|
||||
"""SpecNumberLock __enter__ returns self."""
|
||||
lock = SpecNumberLock(temp_git_repo)
|
||||
|
||||
with lock as entered_lock:
|
||||
assert entered_lock is lock
|
||||
|
||||
def test_merge_success_returns_true(self, temp_git_repo: Path):
|
||||
"""Successful merge returns True (ACS-163 verification)."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
# Create a worktree with non-conflicting changes
|
||||
worker_info = manager.create_worktree("worker-spec")
|
||||
(worker_info.path / "worker-file.txt").write_text(
|
||||
"worker content", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Worker commit"],
|
||||
cwd=worker_info.path,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Merge should succeed
|
||||
result = manager.merge_worktree("worker-spec", delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the file was merged into base branch
|
||||
subprocess.run(
|
||||
["git", "checkout", manager.base_branch],
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
assert (temp_git_repo / "worker-file.txt").exists(), (
|
||||
"Merged file should exist in base branch"
|
||||
)
|
||||
merged_content = (temp_git_repo / "worker-file.txt").read_text(encoding="utf-8")
|
||||
assert merged_content == "worker content", (
|
||||
"Merged file should have worktree content"
|
||||
)
|
||||
@@ -1,539 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Selection and Management
|
||||
=============================================
|
||||
Tests for Workspace Rebase Operations
|
||||
======================================
|
||||
|
||||
Tests the workspace.py module functionality including:
|
||||
- Workspace mode selection (isolated vs direct)
|
||||
- Uncommitted changes detection
|
||||
- Workspace setup
|
||||
- Build finalization workflows
|
||||
Tests the rebase functionality including:
|
||||
- Rebase detection (_check_git_conflicts)
|
||||
- Spec branch rebase operations
|
||||
- Rebase integration tests
|
||||
- Rebase error handling
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from workspace import (
|
||||
WorkspaceChoice,
|
||||
WorkspaceMode,
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
has_uncommitted_changes,
|
||||
setup_workspace,
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
from worktree import WorktreeError, WorktreeManager
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestWorkspaceMode:
|
||||
"""Tests for WorkspaceMode enum."""
|
||||
|
||||
def test_isolated_mode(self):
|
||||
"""ISOLATED mode value is correct."""
|
||||
assert WorkspaceMode.ISOLATED.value == "isolated"
|
||||
|
||||
def test_direct_mode(self):
|
||||
"""DIRECT mode value is correct."""
|
||||
assert WorkspaceMode.DIRECT.value == "direct"
|
||||
|
||||
|
||||
class TestWorkspaceChoice:
|
||||
"""Tests for WorkspaceChoice enum."""
|
||||
|
||||
def test_merge_choice(self):
|
||||
"""MERGE choice value is correct."""
|
||||
assert WorkspaceChoice.MERGE.value == "merge"
|
||||
|
||||
def test_review_choice(self):
|
||||
"""REVIEW choice value is correct."""
|
||||
assert WorkspaceChoice.REVIEW.value == "review"
|
||||
|
||||
def test_test_choice(self):
|
||||
"""TEST choice value is correct."""
|
||||
assert WorkspaceChoice.TEST.value == "test"
|
||||
|
||||
def test_later_choice(self):
|
||||
"""LATER choice value is correct."""
|
||||
assert WorkspaceChoice.LATER.value == "later"
|
||||
|
||||
|
||||
class TestHasUncommittedChanges:
|
||||
"""Tests for uncommitted changes detection."""
|
||||
|
||||
def test_clean_repo_no_changes(self, temp_git_repo: Path):
|
||||
"""Clean repo returns False."""
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is False
|
||||
|
||||
def test_untracked_file_has_changes(self, temp_git_repo: Path):
|
||||
"""Untracked file counts as changes."""
|
||||
(temp_git_repo / "new_file.txt").write_text("content")
|
||||
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is True
|
||||
|
||||
def test_modified_file_has_changes(self, temp_git_repo: Path):
|
||||
"""Modified tracked file counts as changes."""
|
||||
(temp_git_repo / "README.md").write_text("modified content")
|
||||
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is True
|
||||
|
||||
def test_staged_file_has_changes(self, temp_git_repo: Path):
|
||||
"""Staged file counts as changes."""
|
||||
(temp_git_repo / "README.md").write_text("modified")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=temp_git_repo, capture_output=True)
|
||||
|
||||
result = has_uncommitted_changes(temp_git_repo)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestGetCurrentBranch:
|
||||
"""Tests for current branch detection."""
|
||||
|
||||
def test_gets_main_branch(self, temp_git_repo: Path):
|
||||
"""Gets the main/master branch."""
|
||||
branch = get_current_branch(temp_git_repo)
|
||||
|
||||
# Could be main or master depending on git config
|
||||
assert branch in ["main", "master"]
|
||||
|
||||
def test_gets_feature_branch(self, temp_git_repo: Path):
|
||||
"""Gets feature branch name."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "feature/test-branch"],
|
||||
cwd=temp_git_repo, capture_output=True
|
||||
)
|
||||
|
||||
branch = get_current_branch(temp_git_repo)
|
||||
assert branch == "feature/test-branch"
|
||||
|
||||
|
||||
class TestGetExistingBuildWorktree:
|
||||
"""Tests for existing build worktree detection."""
|
||||
|
||||
def test_no_existing_worktree(self, temp_git_repo: Path):
|
||||
"""Returns None when no worktree exists."""
|
||||
result = get_existing_build_worktree(temp_git_repo, "test-spec")
|
||||
assert result is None
|
||||
|
||||
def test_existing_worktree(self, temp_git_repo: Path):
|
||||
"""Returns path when worktree exists."""
|
||||
# Create the worktree directory structure (per-spec architecture)
|
||||
worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME)
|
||||
assert result == worktree_path
|
||||
|
||||
|
||||
class TestSetupWorkspace:
|
||||
"""Tests for workspace setup."""
|
||||
|
||||
def test_setup_direct_mode(self, temp_git_repo: Path):
|
||||
"""Direct mode returns project dir and no manager."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.DIRECT,
|
||||
)
|
||||
|
||||
assert working_dir == temp_git_repo
|
||||
assert manager is None
|
||||
|
||||
def test_setup_isolated_mode(self, temp_git_repo: Path):
|
||||
"""Isolated mode creates worktree and returns manager."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
TEST_SPEC_NAME,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert working_dir != temp_git_repo
|
||||
assert manager is not None
|
||||
assert working_dir.exists()
|
||||
# Per-spec architecture: worktree is named after the spec
|
||||
assert working_dir.name == TEST_SPEC_NAME
|
||||
|
||||
def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Isolated mode creates worktrees directory."""
|
||||
setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists()
|
||||
|
||||
|
||||
class TestWorkspaceUtilities:
|
||||
"""Tests for workspace utility functions."""
|
||||
|
||||
def test_per_spec_worktree_naming(self, temp_git_repo: Path):
|
||||
"""Per-spec architecture uses spec name for worktree directory."""
|
||||
spec_name = "my-spec-001"
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Worktree should be named after the spec
|
||||
assert working_dir.name == spec_name
|
||||
# New path: .auto-claude/worktrees/tasks/{spec_name}
|
||||
assert working_dir.parent.name == "tasks"
|
||||
|
||||
|
||||
class TestWorkspaceIntegration:
|
||||
"""Integration tests for workspace management."""
|
||||
|
||||
def test_isolated_workflow(self, temp_git_repo: Path):
|
||||
"""Full isolated workflow: setup -> work -> finalize."""
|
||||
# Setup isolated workspace
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Make changes in workspace
|
||||
(working_dir / "feature.py").write_text("# New feature\n")
|
||||
|
||||
# Verify changes are in workspace
|
||||
assert (working_dir / "feature.py").exists()
|
||||
|
||||
# Verify changes are NOT in main project
|
||||
assert not (temp_git_repo / "feature.py").exists()
|
||||
|
||||
def test_direct_workflow(self, temp_git_repo: Path):
|
||||
"""Full direct workflow: setup -> work."""
|
||||
# Setup direct workspace
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.DIRECT,
|
||||
)
|
||||
|
||||
# Working dir is the project dir
|
||||
assert working_dir == temp_git_repo
|
||||
|
||||
# Make changes directly
|
||||
(working_dir / "feature.py").write_text("# New feature\n")
|
||||
|
||||
# Changes are in main project
|
||||
assert (temp_git_repo / "feature.py").exists()
|
||||
|
||||
def test_isolated_merge(self, temp_git_repo: Path):
|
||||
"""Can merge isolated workspace back to main."""
|
||||
# Setup
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Make changes and commit using git directly
|
||||
(working_dir / "feature.py").write_text("# New feature\n")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add feature"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge back using merge_worktree
|
||||
result = manager.merge_worktree("test-spec", delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Check changes are in main
|
||||
subprocess.run(
|
||||
["git", "checkout", manager.base_branch],
|
||||
cwd=temp_git_repo, capture_output=True
|
||||
)
|
||||
assert (temp_git_repo / "feature.py").exists()
|
||||
|
||||
|
||||
class TestWorkspaceCleanup:
|
||||
"""Tests for workspace cleanup."""
|
||||
|
||||
def test_cleanup_after_merge(self, temp_git_repo: Path):
|
||||
"""Workspace is cleaned up after merge with delete_after=True."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Commit changes using git directly
|
||||
(working_dir / "test.py").write_text("test")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge with cleanup
|
||||
manager.merge_worktree("test-spec", delete_after=True)
|
||||
|
||||
# Workspace should be removed
|
||||
assert not working_dir.exists()
|
||||
|
||||
def test_workspace_preserved_after_merge_no_delete(self, temp_git_repo: Path):
|
||||
"""Workspace preserved after merge with delete_after=False."""
|
||||
working_dir, manager, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Commit changes using git directly
|
||||
(working_dir / "test.py").write_text("test")
|
||||
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Test"],
|
||||
cwd=working_dir, capture_output=True
|
||||
)
|
||||
|
||||
# Merge without cleanup
|
||||
manager.merge_worktree("test-spec", delete_after=False)
|
||||
|
||||
# Workspace should still exist
|
||||
assert working_dir.exists()
|
||||
|
||||
|
||||
class TestWorkspaceReuse:
|
||||
"""Tests for reusing existing workspaces."""
|
||||
|
||||
def test_reuse_existing_workspace(self, temp_git_repo: Path):
|
||||
"""Can reuse existing workspace on second setup."""
|
||||
# First setup
|
||||
working_dir1, manager1, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Add a marker file
|
||||
(working_dir1 / "marker.txt").write_text("marker")
|
||||
|
||||
# Second setup (should reuse)
|
||||
working_dir2, manager2, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Should be the same directory
|
||||
assert working_dir1 == working_dir2
|
||||
|
||||
# Marker should still exist
|
||||
assert (working_dir2 / "marker.txt").exists()
|
||||
|
||||
|
||||
class TestWorkspaceErrors:
|
||||
"""Tests for workspace error handling."""
|
||||
|
||||
def test_setup_non_git_directory(self, temp_dir: Path):
|
||||
"""Handles non-git directories gracefully."""
|
||||
with pytest.raises(Exception):
|
||||
# This should fail because temp_dir is not a git repo
|
||||
setup_workspace(
|
||||
temp_dir,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
|
||||
class TestPerSpecWorktreeName:
|
||||
"""Tests for per-spec worktree naming (new architecture)."""
|
||||
|
||||
def test_worktree_named_after_spec(self, temp_git_repo: Path):
|
||||
"""Worktree is named after the spec."""
|
||||
spec_name = "spec-1"
|
||||
working_dir, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
spec_name,
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Per-spec architecture: worktree directory matches spec name
|
||||
assert working_dir.name == spec_name
|
||||
|
||||
def test_different_specs_get_different_worktrees(self, temp_git_repo: Path):
|
||||
"""Different specs create separate worktrees."""
|
||||
working_dir1, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"spec-1",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
working_dir2, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"spec-2",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# Each spec has its own worktree
|
||||
assert working_dir1.name == "spec-1"
|
||||
assert working_dir2.name == "spec-2"
|
||||
assert working_dir1 != working_dir2
|
||||
|
||||
def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path):
|
||||
"""Worktree is created in worktrees directory."""
|
||||
working_dir, _, _ = setup_workspace(
|
||||
temp_git_repo,
|
||||
"test-spec",
|
||||
WorkspaceMode.ISOLATED,
|
||||
)
|
||||
|
||||
# New path: .auto-claude/worktrees/tasks/{spec_name}
|
||||
assert "worktrees" in str(working_dir)
|
||||
assert working_dir.parent.name == "tasks"
|
||||
|
||||
|
||||
class TestConflictInfoDisplay:
|
||||
"""Tests for conflict info display function (ACS-179)."""
|
||||
|
||||
def test_print_conflict_info_with_string_list(self, capsys):
|
||||
"""print_conflict_info handles string list of file paths (ACS-179)."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": ["file1.txt", "file2.py", "file3.js"]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 file" in captured.out
|
||||
assert "file1.txt" in captured.out
|
||||
assert "file2.py" in captured.out
|
||||
assert "file3.js" in captured.out
|
||||
assert "git add" in captured.out
|
||||
|
||||
def test_print_conflict_info_with_dict_list(self, capsys):
|
||||
"""print_conflict_info handles dict list with file/reason/severity (ACS-179)."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
{"file": "file1.txt", "reason": "Syntax error", "severity": "high"},
|
||||
{"file": "file2.py", "reason": "Merge conflict", "severity": "medium"},
|
||||
{"file": "file3.js", "reason": "Unknown error", "severity": "low"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "3 file" in captured.out
|
||||
assert "file1.txt" in captured.out
|
||||
assert "file2.py" in captured.out
|
||||
assert "file3.js" in captured.out
|
||||
assert "Syntax error" in captured.out
|
||||
assert "Merge conflict" in captured.out
|
||||
# Verify severity emoji indicators
|
||||
assert "🔴" in captured.out # High severity
|
||||
assert "🟡" in captured.out # Medium severity
|
||||
|
||||
def test_print_conflict_info_mixed_formats(self, capsys):
|
||||
"""print_conflict_info handles mixed string and dict conflicts (ACS-179)."""
|
||||
from core.workspace.display import print_conflict_info
|
||||
|
||||
result = {
|
||||
"conflicts": [
|
||||
"simple-file.txt",
|
||||
{"file": "complex-file.py", "reason": "AI merge failed", "severity": "high"},
|
||||
]
|
||||
}
|
||||
|
||||
print_conflict_info(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "2 file" in captured.out
|
||||
assert "simple-file.txt" in captured.out
|
||||
assert "complex-file.py" in captured.out
|
||||
assert "AI merge failed" in captured.out
|
||||
|
||||
|
||||
class TestMergeErrorHandling:
|
||||
"""Tests for merge error handling (ACS-163)."""
|
||||
|
||||
def test_merge_failure_returns_false_immediately(self, temp_git_repo: Path):
|
||||
"""Failed merge returns False without falling through (ACS-163)."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
# Create a worktree with changes
|
||||
worker_info = manager.create_worktree("worker-spec")
|
||||
(worker_info.path / "worker-file.txt").write_text("worker content")
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Worker commit"],
|
||||
cwd=worker_info.path, capture_output=True
|
||||
)
|
||||
|
||||
# Create a conflicting change on main
|
||||
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
|
||||
(temp_git_repo / "worker-file.txt").write_text("main content")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main commit"],
|
||||
cwd=temp_git_repo, capture_output=True
|
||||
)
|
||||
|
||||
# Merge should fail (conflict) and return False
|
||||
# This tests the fix for ACS-163 where failed merge would fall through
|
||||
result = manager.merge_worktree("worker-spec", delete_after=False)
|
||||
|
||||
# Should return False on merge conflict
|
||||
assert result is False
|
||||
|
||||
# Verify side effects: base branch content is unchanged
|
||||
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
|
||||
base_content = (temp_git_repo / "worker-file.txt").read_text()
|
||||
assert base_content == "main content", "Base branch should be unchanged after failed merge"
|
||||
|
||||
# Verify worktree still exists (delete_after=False)
|
||||
assert worker_info.path.exists(), "Worktree should still exist after failed merge"
|
||||
|
||||
# Verify worktree content is unchanged
|
||||
worktree_content = (worker_info.path / "worker-file.txt").read_text()
|
||||
assert worktree_content == "worker content", "Worktree content should be unchanged"
|
||||
|
||||
def test_merge_success_returns_true(self, temp_git_repo: Path):
|
||||
"""Successful merge returns True (ACS-163 verification)."""
|
||||
manager = WorktreeManager(temp_git_repo)
|
||||
manager.setup()
|
||||
|
||||
# Create a worktree with non-conflicting changes
|
||||
worker_info = manager.create_worktree("worker-spec")
|
||||
(worker_info.path / "worker-file.txt").write_text("worker content")
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Worker commit"],
|
||||
cwd=worker_info.path, capture_output=True
|
||||
)
|
||||
|
||||
# Merge should succeed
|
||||
result = manager.merge_worktree("worker-spec", delete_after=False)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the file was merged into base branch
|
||||
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
|
||||
assert (temp_git_repo / "worker-file.txt").exists(), "Merged file should exist in base branch"
|
||||
merged_content = (temp_git_repo / "worker-file.txt").read_text()
|
||||
assert merged_content == "worker content", "Merged file should have worktree content"
|
||||
|
||||
|
||||
class TestRebaseDetection:
|
||||
"""Tests for automatic rebase detection (ACS-224)."""
|
||||
|
||||
def test_check_git_conflicts_detects_branch_behind(self, temp_git_repo: Path):
|
||||
"""_check_git_conflicts detects when spec branch is behind base branch (ACS-224)."""
|
||||
from core.workspace import _check_git_conflicts
|
||||
@@ -547,7 +39,7 @@ class TestRebaseDetection:
|
||||
)
|
||||
|
||||
# Add a commit to spec branch
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -561,7 +53,7 @@ class TestRebaseDetection:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "main-file.txt").write_text("main content")
|
||||
(temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main commit after spec"],
|
||||
@@ -574,7 +66,9 @@ class TestRebaseDetection:
|
||||
|
||||
assert result is not None
|
||||
assert result.get("needs_rebase") is True, "Should detect branch is behind"
|
||||
assert result.get("commits_behind") == 1, "Should count commits behind correctly"
|
||||
assert result.get("commits_behind") == 1, (
|
||||
"Should count commits behind correctly"
|
||||
)
|
||||
assert result.get("spec_branch") == spec_branch
|
||||
|
||||
def test_check_git_conflicts_no_commits_behind(self, temp_git_repo: Path):
|
||||
@@ -588,7 +82,7 @@ class TestRebaseDetection:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -624,7 +118,7 @@ class TestRebaseDetection:
|
||||
)
|
||||
|
||||
# Add a commit to spec branch
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -639,7 +133,9 @@ class TestRebaseDetection:
|
||||
capture_output=True,
|
||||
)
|
||||
for i in range(3):
|
||||
(temp_git_repo / f"main-file-{i}.txt").write_text(f"main content {i}")
|
||||
(temp_git_repo / f"main-file-{i}.txt").write_text(
|
||||
f"main content {i}", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"Main commit {i}"],
|
||||
@@ -671,7 +167,7 @@ class TestRebaseSpecBranch:
|
||||
)
|
||||
|
||||
# Add a commit to spec branch
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -685,7 +181,7 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "main-file.txt").write_text("main content")
|
||||
(temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main commit"],
|
||||
@@ -726,7 +222,9 @@ class TestRebaseSpecBranch:
|
||||
).stdout
|
||||
assert "Main commit" in log, "Spec branch should have main commit after rebase"
|
||||
|
||||
def test_rebase_spec_branch_with_conflicts_aborts_cleanly(self, temp_git_repo: Path):
|
||||
def test_rebase_spec_branch_with_conflicts_aborts_cleanly(
|
||||
self, temp_git_repo: Path
|
||||
):
|
||||
"""_rebase_spec_branch handles conflicts by aborting and returning False (ACS-224)."""
|
||||
from core.workspace import _rebase_spec_branch
|
||||
|
||||
@@ -739,7 +237,7 @@ class TestRebaseSpecBranch:
|
||||
)
|
||||
|
||||
# Create a file that will conflict
|
||||
(temp_git_repo / "conflict.txt").write_text("spec version")
|
||||
(temp_git_repo / "conflict.txt").write_text("spec version", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec conflict"],
|
||||
@@ -753,7 +251,7 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "conflict.txt").write_text("main version")
|
||||
(temp_git_repo / "conflict.txt").write_text("main version", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main conflict"],
|
||||
@@ -823,7 +321,7 @@ class TestRebaseSpecBranch:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -842,7 +340,9 @@ class TestRebaseSpecBranch:
|
||||
# (branch already up-to-date is a success condition)
|
||||
result = _rebase_spec_branch(temp_git_repo, "test-spec", "main")
|
||||
|
||||
assert result is True, "Rebase should return True when branch is already up-to-date"
|
||||
assert result is True, (
|
||||
"Rebase should return True when branch is already up-to-date"
|
||||
)
|
||||
|
||||
|
||||
class TestRebaseIntegration:
|
||||
@@ -859,7 +359,9 @@ class TestRebaseIntegration:
|
||||
worker_info = manager.create_worktree("test-spec")
|
||||
|
||||
# Add a file in spec worktree and commit
|
||||
(worker_info.path / "spec-file.txt").write_text("spec content")
|
||||
(worker_info.path / "spec-file.txt").write_text(
|
||||
"spec content", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -874,7 +376,7 @@ class TestRebaseIntegration:
|
||||
capture_output=True,
|
||||
)
|
||||
for i in range(2):
|
||||
(temp_git_repo / f"main-{i}.txt").write_text(f"main {i}")
|
||||
(temp_git_repo / f"main-{i}.txt").write_text(f"main {i}", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"Main {i}"],
|
||||
@@ -906,7 +408,7 @@ class TestRebaseIntegration:
|
||||
)
|
||||
|
||||
# Add a commit to spec
|
||||
(temp_git_repo / "spec.txt").write_text("spec")
|
||||
(temp_git_repo / "spec.txt").write_text("spec", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec"],
|
||||
@@ -920,7 +422,7 @@ class TestRebaseIntegration:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "main.txt").write_text("main")
|
||||
(temp_git_repo / "main.txt").write_text("main", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Main"],
|
||||
@@ -966,7 +468,7 @@ class TestRebaseErrorHandling:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -1007,7 +509,6 @@ class TestRebaseErrorHandling:
|
||||
|
||||
def test_check_git_conflicts_handles_corrupted_repo(self, temp_git_repo: Path):
|
||||
"""_check_git_conflicts handles corrupted repo metadata gracefully (ACS-224)."""
|
||||
import shutil
|
||||
|
||||
from core.workspace import _check_git_conflicts
|
||||
|
||||
@@ -1018,7 +519,7 @@ class TestRebaseErrorHandling:
|
||||
cwd=temp_git_repo,
|
||||
capture_output=True,
|
||||
)
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content")
|
||||
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Spec commit"],
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Workspace Setup Operations
|
||||
=====================================
|
||||
|
||||
Tests the setup functionality including:
|
||||
- Spec copy to workspace operations
|
||||
- Timeline hook installation
|
||||
- Timeline tracking initialization
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Test constant - in the new per-spec architecture, each spec has its own worktree
|
||||
# named after the spec itself. This constant is used for test assertions.
|
||||
TEST_SPEC_NAME = "test-spec"
|
||||
|
||||
|
||||
class TestCopySpecToWorktree:
|
||||
"""Tests for copy_spec_to_worktree function."""
|
||||
|
||||
def test_copies_spec_files_to_worktree(self, temp_git_repo: Path):
|
||||
"""Copies spec directory to worktree .auto-claude/specs/ location."""
|
||||
from core.workspace.setup import copy_spec_to_worktree
|
||||
|
||||
# Create source spec directory
|
||||
source_spec = temp_git_repo / "specs" / "test-spec"
|
||||
source_spec.mkdir(parents=True)
|
||||
(source_spec / "spec.md").write_text("# Test Spec", encoding="utf-8")
|
||||
(source_spec / "requirements.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Copy spec
|
||||
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
|
||||
|
||||
# Verify path is correct
|
||||
expected = worktree_path / ".auto-claude" / "specs" / "test-spec"
|
||||
assert result == expected
|
||||
|
||||
# Verify files were copied
|
||||
assert (expected / "spec.md").exists()
|
||||
assert (expected / "requirements.json").exists()
|
||||
assert (expected / "spec.md").read_text(encoding="utf-8") == "# Test Spec"
|
||||
|
||||
def test_overwrites_existing_spec_in_worktree(self, temp_git_repo: Path):
|
||||
"""Overwrites spec files if they already exist in worktree."""
|
||||
from core.workspace.setup import copy_spec_to_worktree
|
||||
|
||||
# Create source spec
|
||||
source_spec = temp_git_repo / "specs" / "test-spec"
|
||||
source_spec.mkdir(parents=True)
|
||||
(source_spec / "spec.md").write_text("# New Spec", encoding="utf-8")
|
||||
|
||||
# Create worktree with existing spec
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
existing_spec = worktree_path / ".auto-claude" / "specs" / "test-spec"
|
||||
existing_spec.mkdir(parents=True)
|
||||
(existing_spec / "spec.md").write_text("# Old Spec", encoding="utf-8")
|
||||
|
||||
# Copy spec
|
||||
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
|
||||
|
||||
# Verify new content was copied
|
||||
assert (result / "spec.md").read_text(encoding="utf-8") == "# New Spec"
|
||||
|
||||
def test_creates_parent_directories(self, temp_git_repo: Path):
|
||||
"""Creates .auto-claude/specs directory if it doesn't exist."""
|
||||
from core.workspace.setup import copy_spec_to_worktree
|
||||
|
||||
source_spec = temp_git_repo / "specs" / "test-spec"
|
||||
source_spec.mkdir(parents=True)
|
||||
(source_spec / "spec.md").write_text("# Test", encoding="utf-8")
|
||||
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
|
||||
|
||||
# Parent directories should be created
|
||||
assert result.exists()
|
||||
assert (result.parent).exists()
|
||||
|
||||
|
||||
class TestEnsureTimelineHookInstalled:
|
||||
"""Tests for ensure_timeline_hook_installed function."""
|
||||
|
||||
def test_skips_if_not_git_repo(self, temp_dir: Path):
|
||||
"""Skips hook installation if directory is not a git repo."""
|
||||
from core.workspace.setup import ensure_timeline_hook_installed
|
||||
|
||||
# Should not raise exception
|
||||
ensure_timeline_hook_installed(temp_dir)
|
||||
|
||||
def test_skips_if_hook_already_installed(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Skips if FileTimelineTracker hook is already installed."""
|
||||
from core.workspace.setup import ensure_timeline_hook_installed
|
||||
|
||||
# Create hooks directory
|
||||
hooks_dir = temp_git_repo / ".git" / "hooks"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create hook with FileTimelineTracker marker
|
||||
hook_file = hooks_dir / "post-commit"
|
||||
hook_file.write_text(
|
||||
"#!/bin/sh\n# FileTimelineTracker hook\necho 'tracked'", encoding="utf-8"
|
||||
)
|
||||
|
||||
# Mock install_hook to track if it was called
|
||||
install_called = []
|
||||
|
||||
def mock_install_hook(project_dir):
|
||||
install_called.append(True)
|
||||
|
||||
monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
|
||||
|
||||
ensure_timeline_hook_installed(temp_git_repo)
|
||||
|
||||
# install_hook should not be called
|
||||
assert len(install_called) == 0
|
||||
|
||||
def test_installs_hook_if_missing(self, temp_git_repo: Path):
|
||||
"""Installs hook if it doesn't exist."""
|
||||
from core.workspace.setup import ensure_timeline_hook_installed
|
||||
|
||||
# Create hooks directory but no hook file
|
||||
hooks_dir = temp_git_repo / ".git" / "hooks"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# This test verifies the function runs without error
|
||||
# The actual install_hook call is hard to mock because it's imported locally
|
||||
# In production, the real install_hook would be called
|
||||
ensure_timeline_hook_installed(temp_git_repo)
|
||||
|
||||
# Verify hooks directory exists (function ran)
|
||||
assert hooks_dir.exists()
|
||||
|
||||
|
||||
class TestInitializeTimelineTracking:
|
||||
"""Tests for initialize_timeline_tracking function."""
|
||||
|
||||
def test_with_implementation_plan(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Initializes tracking with files from implementation plan."""
|
||||
from core.workspace.setup import initialize_timeline_tracking
|
||||
|
||||
# Create source spec with implementation plan
|
||||
spec_name = "test-spec"
|
||||
source_spec = temp_git_repo / ".auto-claude" / "specs" / spec_name
|
||||
source_spec.mkdir(parents=True)
|
||||
|
||||
plan = {
|
||||
"title": "Test Feature",
|
||||
"description": "Test description",
|
||||
"phases": [
|
||||
{
|
||||
"subtasks": [
|
||||
{"files": ["app/main.py", "app/utils.py"]},
|
||||
{"files": ["tests/test_main.py"]},
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
(source_spec / "implementation_plan.json").write_text(
|
||||
json.dumps(plan), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Create worktree
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock FileTimelineTracker
|
||||
mock_tracker_calls = []
|
||||
|
||||
class MockTracker:
|
||||
def __init__(self, project_dir):
|
||||
pass
|
||||
|
||||
def on_task_start(
|
||||
self,
|
||||
task_id,
|
||||
files_to_modify,
|
||||
branch_point_commit,
|
||||
task_intent,
|
||||
task_title,
|
||||
):
|
||||
mock_tracker_calls.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"files": files_to_modify,
|
||||
"branch": branch_point_commit,
|
||||
"intent": task_intent,
|
||||
"title": task_title,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
|
||||
|
||||
initialize_timeline_tracking(
|
||||
temp_git_repo, spec_name, worktree_path, source_spec
|
||||
)
|
||||
|
||||
# Verify tracker was called with correct parameters
|
||||
assert len(mock_tracker_calls) == 1
|
||||
call = mock_tracker_calls[0]
|
||||
assert call["task_id"] == spec_name
|
||||
assert set(call["files"]) == {
|
||||
"app/main.py",
|
||||
"app/utils.py",
|
||||
"tests/test_main.py",
|
||||
}
|
||||
assert call["title"] == "Test Feature"
|
||||
assert call["intent"] == "Test description"
|
||||
|
||||
def test_without_implementation_plan(self, temp_git_repo: Path, monkeypatch):
|
||||
"""Initializes tracking retroactively from worktree if no plan."""
|
||||
from core.workspace.setup import initialize_timeline_tracking
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock FileTimelineTracker
|
||||
mock_calls = []
|
||||
|
||||
class MockTracker:
|
||||
def __init__(self, project_dir):
|
||||
pass
|
||||
|
||||
def initialize_from_worktree(
|
||||
self, task_id, worktree_path, task_intent, task_title
|
||||
):
|
||||
mock_calls.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"worktree": worktree_path,
|
||||
"intent": task_intent,
|
||||
"title": task_title,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
|
||||
|
||||
initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
|
||||
|
||||
# Should use retroactive initialization
|
||||
assert len(mock_calls) == 1
|
||||
assert mock_calls[0]["task_id"] == spec_name
|
||||
|
||||
def test_handles_exception_gracefully(
|
||||
self, temp_git_repo: Path, monkeypatch, capsys
|
||||
):
|
||||
"""Logs warning but doesn't raise exception on error."""
|
||||
from core.workspace.setup import initialize_timeline_tracking
|
||||
|
||||
spec_name = "test-spec"
|
||||
worktree_path = (
|
||||
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
)
|
||||
worktree_path.mkdir(parents=True)
|
||||
|
||||
# Mock FileTimelineTracker to raise exception
|
||||
class FailingTracker:
|
||||
def __init__(self, project_dir):
|
||||
raise Exception("Tracker init failed")
|
||||
|
||||
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", FailingTracker)
|
||||
|
||||
# Should not raise
|
||||
initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
|
||||
|
||||
# Should print warning
|
||||
captured = capsys.readouterr()
|
||||
assert "Timeline tracking" in captured.out or "Note:" in captured.out
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ class IdeationConfigManager:
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
"""Initialize configuration manager.
|
||||
|
||||
@@ -64,6 +65,7 @@ class IdeationConfigManager:
|
||||
self.model,
|
||||
self.thinking_level,
|
||||
self.max_ideas_per_type,
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
self.analyzer = ProjectAnalyzer(
|
||||
self.project_dir,
|
||||
|
||||
@@ -17,7 +17,12 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from client import create_client
|
||||
from phase_config import get_thinking_budget, resolve_model_id
|
||||
from phase_config import (
|
||||
get_model_betas,
|
||||
get_thinking_budget,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
from ui import print_status
|
||||
|
||||
# Ideation types
|
||||
@@ -59,6 +64,7 @@ class IdeationGenerator:
|
||||
model: str = "sonnet", # Changed from "opus" (fix #433)
|
||||
thinking_level: str = "medium",
|
||||
max_ideas_per_type: int = 5,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.output_dir = Path(output_dir)
|
||||
@@ -66,6 +72,7 @@ class IdeationGenerator:
|
||||
self.thinking_level = thinking_level
|
||||
self.thinking_budget = get_thinking_budget(thinking_level)
|
||||
self.max_ideas_per_type = max_ideas_per_type
|
||||
self.fast_mode = fast_mode
|
||||
self.prompts_dir = Path(__file__).parent.parent / "prompts"
|
||||
|
||||
async def run_agent(
|
||||
@@ -93,12 +100,19 @@ class IdeationGenerator:
|
||||
# Create client with thinking budget
|
||||
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
|
||||
# which can cause 60-second timeout delays
|
||||
resolved_model = resolve_model_id(self.model)
|
||||
betas = get_model_betas(self.model)
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(
|
||||
resolved_model, self.thinking_level
|
||||
)
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
resolved_model,
|
||||
agent_type="ideation",
|
||||
betas=betas,
|
||||
fast_mode=self.fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -188,12 +202,19 @@ Write the fixed JSON to the file now.
|
||||
"""
|
||||
|
||||
# Use agent_type="ideation" for recovery agent as well
|
||||
resolved_model = resolve_model_id(self.model)
|
||||
betas = get_model_betas(self.model)
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(
|
||||
resolved_model, self.thinking_level
|
||||
)
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
resolved_model,
|
||||
agent_type="ideation",
|
||||
betas=betas,
|
||||
fast_mode=self.fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -46,6 +46,7 @@ class IdeationOrchestrator:
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
"""Initialize the ideation orchestrator.
|
||||
|
||||
@@ -60,6 +61,7 @@ class IdeationOrchestrator:
|
||||
thinking_level: Thinking level for extended reasoning
|
||||
refresh: Force regeneration of existing files
|
||||
append: Preserve existing ideas when merging
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output
|
||||
"""
|
||||
# Initialize configuration manager
|
||||
self.config_manager = IdeationConfigManager(
|
||||
@@ -73,6 +75,7 @@ class IdeationOrchestrator:
|
||||
thinking_level=thinking_level,
|
||||
refresh=refresh,
|
||||
append=append,
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
|
||||
# Expose configuration for convenience
|
||||
|
||||
@@ -145,7 +145,7 @@ class GraphitiConfig:
|
||||
|
||||
# OpenRouter settings (multi-provider aggregator)
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
openrouter_base_url: str = "https://openrouter.ai/api"
|
||||
openrouter_llm_model: str = "anthropic/claude-sonnet-4"
|
||||
openrouter_embedding_model: str = "openai/text-embedding-3-small"
|
||||
|
||||
@@ -207,7 +207,7 @@ class GraphitiConfig:
|
||||
# OpenRouter settings
|
||||
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY", "")
|
||||
openrouter_base_url = os.environ.get(
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api"
|
||||
)
|
||||
openrouter_llm_model = os.environ.get(
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-sonnet-4"
|
||||
|
||||
@@ -1,720 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Script for Memory Integration with LadybugDB
|
||||
=================================================
|
||||
|
||||
This script tests the memory layer (graph + semantic search) to verify
|
||||
data is being saved and retrieved correctly from LadybugDB (embedded Kuzu).
|
||||
|
||||
LadybugDB is an embedded graph database - no Docker required!
|
||||
|
||||
Usage:
|
||||
# Set environment variables first (or in .env file):
|
||||
export GRAPHITI_ENABLED=true
|
||||
export GRAPHITI_EMBEDDER_PROVIDER=ollama # or: openai, voyage, azure_openai, google
|
||||
|
||||
# For Ollama (recommended - free, local):
|
||||
export OLLAMA_EMBEDDING_MODEL=embeddinggemma
|
||||
export OLLAMA_EMBEDDING_DIM=768
|
||||
|
||||
# For OpenAI:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
# Run the test:
|
||||
cd auto-claude
|
||||
python integrations/graphiti/test_graphiti_memory.py
|
||||
|
||||
# Or run specific tests:
|
||||
python integrations/graphiti/test_graphiti_memory.py --test connection
|
||||
python integrations/graphiti/test_graphiti_memory.py --test save
|
||||
python integrations/graphiti/test_graphiti_memory.py --test search
|
||||
python integrations/graphiti/test_graphiti_memory.py --test ollama
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
auto_claude_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(auto_claude_dir))
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = auto_claude_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
except ImportError:
|
||||
print("Note: python-dotenv not installed, using environment variables only")
|
||||
|
||||
|
||||
def apply_ladybug_monkeypatch():
|
||||
"""Apply LadybugDB monkeypatch for embedded database support."""
|
||||
try:
|
||||
import real_ladybug
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
import kuzu # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
"""Print a section header."""
|
||||
print("\n" + "=" * 60)
|
||||
print(f" {title}")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
|
||||
def print_result(label: str, value: str, success: bool = True):
|
||||
"""Print a result line."""
|
||||
status = "✅" if success else "❌"
|
||||
print(f" {status} {label}: {value}")
|
||||
|
||||
|
||||
def print_info(message: str):
|
||||
"""Print an info line."""
|
||||
print(f" ℹ️ {message}")
|
||||
|
||||
|
||||
async def test_ladybugdb_connection(db_path: str, database: str) -> bool:
|
||||
"""Test basic LadybugDB connection."""
|
||||
print_header("1. Testing LadybugDB Connection")
|
||||
|
||||
print(f" Database path: {db_path}")
|
||||
print(f" Database name: {database}")
|
||||
print()
|
||||
|
||||
if not apply_ladybug_monkeypatch():
|
||||
print_result("LadybugDB", "Not installed (pip install real-ladybug)", False)
|
||||
return False
|
||||
|
||||
print_result("LadybugDB", "Installed", True)
|
||||
|
||||
try:
|
||||
import kuzu # This is real_ladybug via monkeypatch
|
||||
|
||||
# Ensure parent directory exists (database will create its own structure)
|
||||
full_path = Path(db_path) / database
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create database and connection
|
||||
db = kuzu.Database(str(full_path))
|
||||
conn = kuzu.Connection(db)
|
||||
|
||||
# Test basic query
|
||||
result = conn.execute("RETURN 1 + 1 as test")
|
||||
df = result.get_as_df()
|
||||
test_value = df["test"].iloc[0] if len(df) > 0 else None
|
||||
|
||||
if test_value == 2:
|
||||
print_result("Connection", "SUCCESS - Database responds correctly", True)
|
||||
return True
|
||||
else:
|
||||
print_result("Connection", f"Unexpected result: {test_value}", False)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print_result("Connection", f"FAILED: {e}", False)
|
||||
return False
|
||||
|
||||
|
||||
async def test_save_episode(db_path: str, database: str) -> tuple[str, str]:
|
||||
"""Test saving an episode to the graph."""
|
||||
print_header("2. Testing Episode Save")
|
||||
|
||||
try:
|
||||
from integrations.graphiti.config import GraphitiConfig
|
||||
from integrations.graphiti.queries_pkg.client import GraphitiClient
|
||||
|
||||
# Create config
|
||||
config = GraphitiConfig.from_env()
|
||||
config.db_path = db_path
|
||||
config.database = database
|
||||
config.enabled = True
|
||||
|
||||
print(f" Embedder provider: {config.embedder_provider}")
|
||||
print()
|
||||
|
||||
# Initialize client
|
||||
client = GraphitiClient(config)
|
||||
initialized = await client.initialize()
|
||||
|
||||
if not initialized:
|
||||
print_result("Client Init", "Failed to initialize", False)
|
||||
return None, None
|
||||
|
||||
print_result("Client Init", "SUCCESS", True)
|
||||
|
||||
# Create test episode data
|
||||
test_data = {
|
||||
"type": "test_episode",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"test_field": "Hello from LadybugDB test!",
|
||||
"test_number": 42,
|
||||
"embedder": config.embedder_provider,
|
||||
}
|
||||
|
||||
episode_name = f"test_episode_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
group_id = "ladybug_test_group"
|
||||
|
||||
print(f" Episode name: {episode_name}")
|
||||
print(f" Group ID: {group_id}")
|
||||
print(f" Data: {json.dumps(test_data, indent=4)}")
|
||||
print()
|
||||
|
||||
# Save using Graphiti
|
||||
from graphiti_core.nodes import EpisodeType
|
||||
|
||||
print(" Saving episode...")
|
||||
await client.graphiti.add_episode(
|
||||
name=episode_name,
|
||||
episode_body=json.dumps(test_data),
|
||||
source=EpisodeType.text,
|
||||
source_description="Test episode from test_graphiti_memory.py",
|
||||
reference_time=datetime.now(timezone.utc),
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
print_result("Episode Save", "SUCCESS", True)
|
||||
|
||||
await client.close()
|
||||
return episode_name, group_id
|
||||
|
||||
except ImportError as e:
|
||||
print_result("Import", f"Missing dependency: {e}", False)
|
||||
return None, None
|
||||
except Exception as e:
|
||||
print_result("Episode Save", f"FAILED: {e}", False)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return None, None
|
||||
|
||||
|
||||
async def test_keyword_search(db_path: str, database: str) -> bool:
|
||||
"""Test keyword search (works without embeddings)."""
|
||||
print_header("3. Testing Keyword Search")
|
||||
|
||||
if not apply_ladybug_monkeypatch():
|
||||
print_result("LadybugDB", "Not installed", False)
|
||||
return False
|
||||
|
||||
try:
|
||||
import kuzu
|
||||
|
||||
full_path = Path(db_path) / database
|
||||
if not full_path.exists():
|
||||
print_info("Database doesn't exist yet - run save test first")
|
||||
return True
|
||||
|
||||
db = kuzu.Database(str(full_path))
|
||||
conn = kuzu.Connection(db)
|
||||
|
||||
# Search for test episodes
|
||||
search_query = "test"
|
||||
print(f" Search query: '{search_query}'")
|
||||
print()
|
||||
|
||||
query = f"""
|
||||
MATCH (e:Episodic)
|
||||
WHERE toLower(e.name) CONTAINS '{search_query}'
|
||||
OR toLower(e.content) CONTAINS '{search_query}'
|
||||
RETURN e.name as name, e.content as content
|
||||
LIMIT 5
|
||||
"""
|
||||
|
||||
try:
|
||||
result = conn.execute(query)
|
||||
df = result.get_as_df()
|
||||
|
||||
print(f" Found {len(df)} results:")
|
||||
for _, row in df.iterrows():
|
||||
name = row.get("name", "unknown")[:50]
|
||||
content = str(row.get("content", ""))[:60]
|
||||
print(f" - {name}: {content}...")
|
||||
|
||||
print_result("Keyword Search", f"Found {len(df)} results", True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
if "Episodic" in str(e) and "not exist" in str(e).lower():
|
||||
print_info("Episodic table doesn't exist yet - run save test first")
|
||||
return True
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
print_result("Keyword Search", f"FAILED: {e}", False)
|
||||
return False
|
||||
|
||||
|
||||
async def test_semantic_search(db_path: str, database: str, group_id: str) -> bool:
|
||||
"""Test semantic search using embeddings."""
|
||||
print_header("4. Testing Semantic Search")
|
||||
|
||||
if not group_id:
|
||||
print_info("Skipping - no group_id from save test")
|
||||
return True
|
||||
|
||||
try:
|
||||
from integrations.graphiti.config import GraphitiConfig
|
||||
from integrations.graphiti.queries_pkg.client import GraphitiClient
|
||||
|
||||
# Create config
|
||||
config = GraphitiConfig.from_env()
|
||||
config.db_path = db_path
|
||||
config.database = database
|
||||
config.enabled = True
|
||||
|
||||
if not config.embedder_provider:
|
||||
print_info("No embedder configured - semantic search requires embeddings")
|
||||
return True
|
||||
|
||||
print(f" Embedder: {config.embedder_provider}")
|
||||
print()
|
||||
|
||||
# Initialize client
|
||||
client = GraphitiClient(config)
|
||||
initialized = await client.initialize()
|
||||
|
||||
if not initialized:
|
||||
print_result("Client Init", "Failed", False)
|
||||
return False
|
||||
|
||||
# Search
|
||||
query = "test episode hello LadybugDB"
|
||||
print(f" Query: '{query}'")
|
||||
print(f" Group ID: {group_id}")
|
||||
print()
|
||||
|
||||
print(" Searching...")
|
||||
results = await client.graphiti.search(
|
||||
query=query,
|
||||
group_ids=[group_id],
|
||||
num_results=10,
|
||||
)
|
||||
|
||||
print(f" Found {len(results)} results:")
|
||||
for i, result in enumerate(results[:5]):
|
||||
# Print available attributes
|
||||
if hasattr(result, "fact") and result.fact:
|
||||
print(f" {i + 1}. [fact] {str(result.fact)[:80]}...")
|
||||
elif hasattr(result, "content") and result.content:
|
||||
print(f" {i + 1}. [content] {str(result.content)[:80]}...")
|
||||
elif hasattr(result, "name"):
|
||||
print(f" {i + 1}. [name] {str(result.name)[:80]}...")
|
||||
|
||||
await client.close()
|
||||
|
||||
if results:
|
||||
print_result(
|
||||
"Semantic Search", f"SUCCESS - Found {len(results)} results", True
|
||||
)
|
||||
else:
|
||||
print_result(
|
||||
"Semantic Search", "No results (may need time for embedding)", False
|
||||
)
|
||||
|
||||
return len(results) > 0
|
||||
|
||||
except Exception as e:
|
||||
print_result("Semantic Search", f"FAILED: {e}", False)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_ollama_embeddings() -> bool:
|
||||
"""Test Ollama embedding generation directly."""
|
||||
print_header("5. Testing Ollama Embeddings")
|
||||
|
||||
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
|
||||
print(f" Model: {ollama_model}")
|
||||
print(f" Base URL: {ollama_base_url}")
|
||||
print()
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Check Ollama status
|
||||
print(" Checking Ollama status...")
|
||||
try:
|
||||
resp = requests.get(f"{ollama_base_url}/api/tags", timeout=5)
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
"Ollama", f"Not responding (status {resp.status_code})", False
|
||||
)
|
||||
return False
|
||||
|
||||
models = [m["name"] for m in resp.json().get("models", [])]
|
||||
embedding_models = [
|
||||
m for m in models if "embed" in m.lower() or "gemma" in m.lower()
|
||||
]
|
||||
print_result("Ollama", f"Running with {len(models)} models", True)
|
||||
print(f" Embedding models: {embedding_models}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print_result("Ollama", "Not running - start with 'ollama serve'", False)
|
||||
return False
|
||||
|
||||
# Test embedding generation
|
||||
print()
|
||||
print(" Generating test embedding...")
|
||||
|
||||
test_text = (
|
||||
"This is a test embedding for Auto Claude memory system using LadybugDB."
|
||||
)
|
||||
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": test_text},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
embedding = data.get("embedding", [])
|
||||
print_result("Embedding", f"SUCCESS - {len(embedding)} dimensions", True)
|
||||
print(f" First 5 values: {embedding[:5]}")
|
||||
|
||||
# Verify dimension matches config
|
||||
expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", 768))
|
||||
if len(embedding) == expected_dim:
|
||||
print_result("Dimension", f"Matches expected ({expected_dim})", True)
|
||||
else:
|
||||
print_result(
|
||||
"Dimension",
|
||||
f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
|
||||
False,
|
||||
)
|
||||
print_info(
|
||||
f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config"
|
||||
)
|
||||
|
||||
return True
|
||||
else:
|
||||
print_result(
|
||||
"Embedding", f"FAILED: {resp.status_code} - {resp.text}", False
|
||||
)
|
||||
return False
|
||||
|
||||
except ImportError:
|
||||
print_result("requests", "Not installed (pip install requests)", False)
|
||||
return False
|
||||
except Exception as e:
|
||||
print_result("Ollama Embeddings", f"FAILED: {e}", False)
|
||||
return False
|
||||
|
||||
|
||||
async def test_graphiti_memory_class(db_path: str, database: str) -> bool:
|
||||
"""Test the GraphitiMemory wrapper class."""
|
||||
print_header("6. Testing GraphitiMemory Class")
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
|
||||
# Create temporary directories for testing
|
||||
test_spec_dir = Path("/tmp/graphiti_test_spec")
|
||||
test_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
test_project_dir = Path("/tmp/graphiti_test_project")
|
||||
test_project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" Spec dir: {test_spec_dir}")
|
||||
print(f" Project dir: {test_project_dir}")
|
||||
print()
|
||||
|
||||
# Override database path via environment
|
||||
os.environ["GRAPHITI_DB_PATH"] = db_path
|
||||
os.environ["GRAPHITI_DATABASE"] = database
|
||||
|
||||
# Create memory instance
|
||||
memory = GraphitiMemory(test_spec_dir, test_project_dir)
|
||||
|
||||
print(f" Is enabled: {memory.is_enabled}")
|
||||
print(f" Group ID: {memory.group_id}")
|
||||
print()
|
||||
|
||||
if not memory.is_enabled:
|
||||
print_info("GraphitiMemory not enabled - check GRAPHITI_ENABLED=true")
|
||||
return True
|
||||
|
||||
# Initialize
|
||||
print(" Initializing...")
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
# Test save_session_insights
|
||||
print()
|
||||
print(" Testing save_session_insights...")
|
||||
insights = {
|
||||
"subtasks_completed": ["test-subtask-1"],
|
||||
"discoveries": {
|
||||
"files_understood": {"test.py": "Test file"},
|
||||
"patterns_found": ["Pattern: LadybugDB works!"],
|
||||
"gotchas_encountered": [],
|
||||
},
|
||||
"what_worked": ["Using embedded database"],
|
||||
"what_failed": [],
|
||||
"recommendations_for_next_session": ["Continue testing"],
|
||||
}
|
||||
|
||||
save_result = await memory.save_session_insights(
|
||||
session_num=1, insights=insights
|
||||
)
|
||||
print_result(
|
||||
"save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
|
||||
)
|
||||
|
||||
# Test save_pattern
|
||||
print()
|
||||
print(" Testing save_pattern...")
|
||||
pattern_result = await memory.save_pattern(
|
||||
"LadybugDB pattern: Embedded graph database works without Docker"
|
||||
)
|
||||
print_result(
|
||||
"save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
|
||||
)
|
||||
|
||||
# Test get_relevant_context
|
||||
print()
|
||||
print(" Testing get_relevant_context...")
|
||||
await asyncio.sleep(1) # Brief wait for processing
|
||||
|
||||
context = await memory.get_relevant_context("LadybugDB embedded database")
|
||||
print(f" Found {len(context)} context items")
|
||||
|
||||
for item in context[:3]:
|
||||
item_type = item.get("type", "unknown")
|
||||
content = str(item.get("content", ""))[:60]
|
||||
print(f" - [{item_type}] {content}...")
|
||||
|
||||
print_result("get_relevant_context", f"Found {len(context)} items", True)
|
||||
|
||||
# Get status
|
||||
print()
|
||||
print(" Status summary:")
|
||||
status = memory.get_status_summary()
|
||||
for key, value in status.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
await memory.close()
|
||||
print_result("GraphitiMemory", "All tests passed", True)
|
||||
return True
|
||||
|
||||
except ImportError as e:
|
||||
print_result("Import", f"Missing: {e}", False)
|
||||
return False
|
||||
except Exception as e:
|
||||
print_result("GraphitiMemory", f"FAILED: {e}", False)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_database_contents(db_path: str, database: str) -> bool:
|
||||
"""Show what's in the database (debug)."""
|
||||
print_header("7. Database Contents (Debug)")
|
||||
|
||||
if not apply_ladybug_monkeypatch():
|
||||
print_result("LadybugDB", "Not installed", False)
|
||||
return False
|
||||
|
||||
try:
|
||||
import kuzu
|
||||
|
||||
full_path = Path(db_path) / database
|
||||
if not full_path.exists():
|
||||
print_info(f"Database doesn't exist at {full_path}")
|
||||
return True
|
||||
|
||||
db = kuzu.Database(str(full_path))
|
||||
conn = kuzu.Connection(db)
|
||||
|
||||
# Get table info
|
||||
print(" Checking tables...")
|
||||
|
||||
tables_to_check = ["Episodic", "Entity", "Community"]
|
||||
|
||||
for table in tables_to_check:
|
||||
try:
|
||||
result = conn.execute(f"MATCH (n:{table}) RETURN count(n) as count")
|
||||
df = result.get_as_df()
|
||||
count = df["count"].iloc[0] if len(df) > 0 else 0
|
||||
print(f" {table}: {count} nodes")
|
||||
except Exception as e:
|
||||
if "not exist" in str(e).lower() or "cannot" in str(e).lower():
|
||||
print(f" {table}: (table not created yet)")
|
||||
else:
|
||||
print(f" {table}: Error - {e}")
|
||||
|
||||
# Show sample episodic nodes
|
||||
print()
|
||||
print(" Sample Episodic nodes:")
|
||||
try:
|
||||
result = conn.execute("""
|
||||
MATCH (e:Episodic)
|
||||
RETURN e.name as name, e.created_at as created
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
df = result.get_as_df()
|
||||
|
||||
if len(df) == 0:
|
||||
print(" (none)")
|
||||
else:
|
||||
for _, row in df.iterrows():
|
||||
print(f" - {row.get('name', 'unknown')}")
|
||||
except Exception as e:
|
||||
if "Episodic" in str(e):
|
||||
print(" (table not created yet)")
|
||||
else:
|
||||
print(f" Error: {e}")
|
||||
|
||||
print_result("Database Contents", "Displayed", True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print_result("Database Contents", f"FAILED: {e}", False)
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all tests."""
|
||||
parser = argparse.ArgumentParser(description="Test Memory System with LadybugDB")
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
choices=[
|
||||
"all",
|
||||
"connection",
|
||||
"save",
|
||||
"keyword",
|
||||
"semantic",
|
||||
"ollama",
|
||||
"memory",
|
||||
"contents",
|
||||
],
|
||||
default="all",
|
||||
help="Which test to run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db-path",
|
||||
default=os.path.expanduser("~/.auto-claude/memories"),
|
||||
help="Database path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--database",
|
||||
default="test_memory",
|
||||
help="Database name (use 'test_memory' for testing)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" MEMORY SYSTEM TEST SUITE (LadybugDB)")
|
||||
print("=" * 60)
|
||||
|
||||
# Configuration check
|
||||
print_header("0. Configuration Check")
|
||||
|
||||
print(f" Database path: {args.db_path}")
|
||||
print(f" Database name: {args.database}")
|
||||
print()
|
||||
|
||||
# Check environment
|
||||
graphiti_enabled = os.environ.get("GRAPHITI_ENABLED", "").lower() == "true"
|
||||
embedder_provider = os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", "")
|
||||
|
||||
print_result("GRAPHITI_ENABLED", str(graphiti_enabled), graphiti_enabled)
|
||||
print_result(
|
||||
"GRAPHITI_EMBEDDER_PROVIDER",
|
||||
embedder_provider or "(not set)",
|
||||
bool(embedder_provider),
|
||||
)
|
||||
|
||||
if embedder_provider == "ollama":
|
||||
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "")
|
||||
ollama_dim = os.environ.get("OLLAMA_EMBEDDING_DIM", "")
|
||||
print_result(
|
||||
"OLLAMA_EMBEDDING_MODEL", ollama_model or "(not set)", bool(ollama_model)
|
||||
)
|
||||
print_result(
|
||||
"OLLAMA_EMBEDDING_DIM", ollama_dim or "(not set)", bool(ollama_dim)
|
||||
)
|
||||
elif embedder_provider == "openai":
|
||||
has_key = bool(os.environ.get("OPENAI_API_KEY"))
|
||||
print_result("OPENAI_API_KEY", "Set" if has_key else "Not set", has_key)
|
||||
|
||||
# Run tests based on selection
|
||||
test = args.test
|
||||
group_id = None
|
||||
|
||||
if test in ["all", "connection"]:
|
||||
await test_ladybugdb_connection(args.db_path, args.database)
|
||||
|
||||
if test in ["all", "ollama"]:
|
||||
await test_ollama_embeddings()
|
||||
|
||||
if test in ["all", "save"]:
|
||||
_, group_id = await test_save_episode(args.db_path, args.database)
|
||||
if group_id:
|
||||
print("\n Waiting 2 seconds for embedding processing...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if test in ["all", "keyword"]:
|
||||
await test_keyword_search(args.db_path, args.database)
|
||||
|
||||
if test in ["all", "semantic"]:
|
||||
await test_semantic_search(
|
||||
args.db_path, args.database, group_id or "ladybug_test_group"
|
||||
)
|
||||
|
||||
if test in ["all", "memory"]:
|
||||
await test_graphiti_memory_class(args.db_path, args.database)
|
||||
|
||||
if test in ["all", "contents"]:
|
||||
await test_database_contents(args.db_path, args.database)
|
||||
|
||||
print_header("TEST SUMMARY")
|
||||
print(" Tests completed. Check the results above for any failures.")
|
||||
print()
|
||||
print(" Quick commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/test_graphiti_memory.py")
|
||||
print()
|
||||
print(" # Test just Ollama embeddings:")
|
||||
print(" python integrations/graphiti/test_graphiti_memory.py --test ollama")
|
||||
print()
|
||||
print(" # Test with production database:")
|
||||
print(
|
||||
" python integrations/graphiti/test_graphiti_memory.py --database auto_claude_memory"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,862 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Script for Ollama Embedding Memory Integration
|
||||
====================================================
|
||||
|
||||
This test validates that the memory system works correctly with local Ollama
|
||||
embedding models (like embeddinggemma, nomic-embed-text) for creating and
|
||||
retrieving memories in the hybrid RAG system.
|
||||
|
||||
The test covers:
|
||||
1. Ollama embedding generation (direct API test)
|
||||
2. Creating memories with Ollama embeddings via GraphitiMemory
|
||||
3. Retrieving memories via semantic search
|
||||
4. Verifying the full create → store → retrieve cycle
|
||||
|
||||
Prerequisites:
|
||||
1. Install Ollama: https://ollama.ai/
|
||||
2. Pull an embedding model:
|
||||
ollama pull embeddinggemma # 768 dimensions (lightweight)
|
||||
ollama pull nomic-embed-text # 768 dimensions (good quality)
|
||||
3. Pull an LLM model (for knowledge graph construction):
|
||||
ollama pull deepseek-r1:7b # or llama3.2:3b, mistral:7b
|
||||
4. Start Ollama server: ollama serve
|
||||
5. Configure environment:
|
||||
export GRAPHITI_ENABLED=true
|
||||
export GRAPHITI_LLM_PROVIDER=ollama
|
||||
export GRAPHITI_EMBEDDER_PROVIDER=ollama
|
||||
export OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
export OLLAMA_EMBEDDING_MODEL=embeddinggemma
|
||||
export OLLAMA_EMBEDDING_DIM=768
|
||||
|
||||
NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
|
||||
For full offline operation, set a dummy key: export OPENAI_API_KEY=dummy
|
||||
The reranker will fail at search time, but embedding creation works.
|
||||
For production, use OpenAI API key for best search quality.
|
||||
|
||||
Usage:
|
||||
cd apps/backend
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py
|
||||
|
||||
# Run specific tests:
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test create
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
auto_claude_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(auto_claude_dir))
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = auto_claude_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
except ImportError:
|
||||
print("Note: python-dotenv not installed, using environment variables only")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
"""Print a section header."""
|
||||
print("\n" + "=" * 70)
|
||||
print(f" {title}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
def print_result(label: str, value: str, success: bool = True):
|
||||
"""Print a result line."""
|
||||
status = "PASS" if success else "FAIL"
|
||||
print(f" [{status}] {label}: {value}")
|
||||
|
||||
|
||||
def print_info(message: str):
|
||||
"""Print an info line."""
|
||||
print(f" INFO: {message}")
|
||||
|
||||
|
||||
def print_step(step: int, message: str):
|
||||
"""Print a step indicator."""
|
||||
print(f"\n Step {step}: {message}")
|
||||
|
||||
|
||||
def apply_ladybug_monkeypatch():
|
||||
"""Apply LadybugDB monkeypatch for embedded database support."""
|
||||
try:
|
||||
import real_ladybug
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
import kuzu # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 1: Ollama Embedding Generation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_ollama_embeddings() -> bool:
|
||||
"""
|
||||
Test Ollama embedding generation directly via API.
|
||||
|
||||
This validates that Ollama is running and can generate embeddings
|
||||
with the configured model.
|
||||
"""
|
||||
print_header("Test 1: Ollama Embedding Generation")
|
||||
|
||||
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "768"))
|
||||
|
||||
print(f" Ollama Model: {ollama_model}")
|
||||
print(f" Base URL: {ollama_base_url}")
|
||||
print(f" Expected Dimension: {expected_dim}")
|
||||
print()
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print_result("requests library", "Not installed - pip install requests", False)
|
||||
return False
|
||||
|
||||
# Step 1: Check Ollama is running
|
||||
print_step(1, "Checking Ollama server status")
|
||||
try:
|
||||
resp = requests.get(f"{ollama_base_url}/api/tags", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
f"Not responding (status {resp.status_code})",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
models = resp.json().get("models", [])
|
||||
model_names = [m.get("name", "") for m in models]
|
||||
print_result("Ollama server", f"Running with {len(models)} models", True)
|
||||
|
||||
# Check if embedding model is available
|
||||
embedding_model_found = any(
|
||||
ollama_model in name or ollama_model.split(":")[0] in name
|
||||
for name in model_names
|
||||
)
|
||||
if not embedding_model_found:
|
||||
print_info(f"Model '{ollama_model}' not found. Available: {model_names}")
|
||||
print_info(f"Pull it with: ollama pull {ollama_model}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
"Not running - start with 'ollama serve'",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
# Step 2: Generate test embedding
|
||||
print_step(2, "Generating test embeddings")
|
||||
|
||||
test_texts = [
|
||||
"This is a test memory about implementing OAuth authentication.",
|
||||
"The user prefers using TypeScript for frontend development.",
|
||||
"A gotcha discovered: always validate JWT tokens on the server side.",
|
||||
]
|
||||
|
||||
embeddings = []
|
||||
for i, text in enumerate(test_texts):
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": text},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Failed: {resp.status_code} - {resp.text[:100]}",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
data = resp.json()
|
||||
embedding = data.get("embedding", [])
|
||||
embeddings.append(embedding)
|
||||
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Generated {len(embedding)} dimensions",
|
||||
True,
|
||||
)
|
||||
|
||||
# Step 3: Validate embedding dimensions
|
||||
print_step(3, "Validating embedding dimensions")
|
||||
|
||||
for i, embedding in enumerate(embeddings):
|
||||
if len(embedding) != expected_dim:
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension",
|
||||
f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
|
||||
False,
|
||||
)
|
||||
print_info(f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config")
|
||||
return False
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension", f"{len(embedding)} matches expected", True
|
||||
)
|
||||
|
||||
# Step 4: Test embedding similarity (basic sanity check)
|
||||
print_step(4, "Testing embedding similarity")
|
||||
|
||||
def cosine_similarity(a, b):
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
dot_product = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = sum(x * x for x in a) ** 0.5
|
||||
norm_b = sum(x * x for x in b) ** 0.5
|
||||
return dot_product / (norm_a * norm_b) if norm_a and norm_b else 0
|
||||
|
||||
# Generate embedding for a similar query
|
||||
query = "OAuth authentication implementation"
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": query},
|
||||
timeout=60,
|
||||
)
|
||||
query_embedding = resp.json().get("embedding", [])
|
||||
|
||||
similarities = [cosine_similarity(query_embedding, emb) for emb in embeddings]
|
||||
|
||||
print(f" Query: '{query}'")
|
||||
print(" Similarities to test texts:")
|
||||
for i, (text, sim) in enumerate(zip(test_texts, similarities)):
|
||||
print(f" {i + 1}. {sim:.4f} - '{text[:50]}...'")
|
||||
|
||||
# First text (about OAuth) should have highest similarity to OAuth query
|
||||
if similarities[0] > similarities[1] and similarities[0] > similarities[2]:
|
||||
print_result("Semantic similarity", "OAuth query matches OAuth text best", True)
|
||||
else:
|
||||
print_info("Similarity ordering may vary - embeddings are still working")
|
||||
|
||||
print()
|
||||
print_result("Ollama Embeddings", "All tests passed", True)
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 2: Memory Creation with Ollama
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_creation(test_db_path: Path) -> tuple[Path, Path, bool]:
|
||||
"""
|
||||
Test creating memories using GraphitiMemory with Ollama embeddings.
|
||||
|
||||
Returns:
|
||||
Tuple of (spec_dir, project_dir, success)
|
||||
"""
|
||||
print_header("Test 2: Memory Creation with Ollama Embeddings")
|
||||
|
||||
# Create test directories
|
||||
spec_dir = test_db_path / "test_spec"
|
||||
project_dir = test_db_path / "test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" Spec dir: {spec_dir}")
|
||||
print(f" Project dir: {project_dir}")
|
||||
print(f" Database path: {test_db_path}")
|
||||
print()
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_ollama_memory"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
# Step 1: Initialize GraphitiMemory
|
||||
print_step(1, "Initializing GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
print(f" Is enabled: {memory.is_enabled}")
|
||||
print(f" Group ID: {memory.group_id}")
|
||||
|
||||
if not memory.is_enabled:
|
||||
print_result(
|
||||
"GraphitiMemory",
|
||||
"Not enabled - check GRAPHITI_ENABLED=true",
|
||||
False,
|
||||
)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
init_result = await memory.initialize()
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to initialize", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
# Step 2: Save session insights
|
||||
print_step(2, "Saving session insights")
|
||||
|
||||
session_insights = {
|
||||
"subtasks_completed": ["implement-oauth-login", "add-jwt-validation"],
|
||||
"discoveries": {
|
||||
"files_understood": {
|
||||
"auth/oauth.py": "OAuth 2.0 flow implementation with Google/GitHub",
|
||||
"auth/jwt.py": "JWT token generation and validation utilities",
|
||||
},
|
||||
"patterns_found": [
|
||||
"Pattern: Use refresh tokens for long-lived sessions",
|
||||
"Pattern: Store tokens in httpOnly cookies for security",
|
||||
],
|
||||
"gotchas_encountered": [
|
||||
"Gotcha: Always validate JWT signature on server side",
|
||||
"Gotcha: OAuth state parameter prevents CSRF attacks",
|
||||
],
|
||||
},
|
||||
"what_worked": [
|
||||
"Using PyJWT for token handling",
|
||||
"Separating OAuth providers into individual modules",
|
||||
],
|
||||
"what_failed": [],
|
||||
"recommendations_for_next_session": [
|
||||
"Consider adding refresh token rotation",
|
||||
"Add rate limiting to auth endpoints",
|
||||
],
|
||||
}
|
||||
|
||||
save_result = await memory.save_session_insights(
|
||||
session_num=1, insights=session_insights
|
||||
)
|
||||
print_result(
|
||||
"save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
|
||||
)
|
||||
|
||||
# Step 3: Save patterns
|
||||
print_step(3, "Saving code patterns")
|
||||
|
||||
patterns = [
|
||||
"OAuth implementation uses authorization code flow for web apps",
|
||||
"JWT tokens include user ID, roles, and expiration in payload",
|
||||
"Token refresh happens automatically when access token expires",
|
||||
]
|
||||
|
||||
for i, pattern in enumerate(patterns):
|
||||
result = await memory.save_pattern(pattern)
|
||||
print_result(f"save_pattern {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 4: Save gotchas
|
||||
print_step(4, "Saving gotchas (pitfalls)")
|
||||
|
||||
gotchas = [
|
||||
"Never store config values in frontend code or files checked into git",
|
||||
"API redirect URIs must exactly match the registered URIs",
|
||||
"Cache expiration times should be short for performance (15 min default)",
|
||||
]
|
||||
|
||||
for i, gotcha in enumerate(gotchas):
|
||||
result = await memory.save_gotcha(gotcha)
|
||||
print_result(f"save_gotcha {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 5: Save codebase discoveries
|
||||
print_step(5, "Saving codebase discoveries")
|
||||
|
||||
discoveries = {
|
||||
"api/routes/users.py": "User management API endpoints (list, create, update)",
|
||||
"middleware/logging.py": "Request logging middleware for all routes",
|
||||
"models/user.py": "User model with profile data and role management",
|
||||
"services/notifications.py": "Notification service integrations (email, SMS, push)",
|
||||
}
|
||||
|
||||
discovery_result = await memory.save_codebase_discoveries(discoveries)
|
||||
print_result(
|
||||
"save_codebase_discoveries",
|
||||
"SUCCESS" if discovery_result else "FAILED",
|
||||
discovery_result,
|
||||
)
|
||||
|
||||
# Brief wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 3 seconds for embedding processing...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
print_result("Memory Creation", "All memories saved successfully", True)
|
||||
return spec_dir, project_dir, True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 3: Memory Retrieval with Semantic Search
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_retrieval(spec_dir: Path, project_dir: Path) -> bool:
|
||||
"""
|
||||
Test retrieving memories using semantic search with Ollama embeddings.
|
||||
|
||||
This validates that saved memories can be found via semantic similarity.
|
||||
"""
|
||||
print_header("Test 3: Memory Retrieval with Semantic Search")
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Initialize memory (reconnect)
|
||||
print_step(1, "Reconnecting to GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to reconnect", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "Reconnected successfully", True)
|
||||
|
||||
# Step 2: Semantic search for API-related content
|
||||
print_step(2, "Searching for API-related memories")
|
||||
|
||||
api_query = "How do the API endpoints work in this project?"
|
||||
results = await memory.get_relevant_context(api_query, num_results=5)
|
||||
|
||||
print(f" Query: '{api_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
api_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "api" in content.lower() or "routes" in content.lower():
|
||||
api_found = True
|
||||
|
||||
if api_found:
|
||||
print_result("API search", "Found API-related content", True)
|
||||
else:
|
||||
print_info("API content may not be in top results - checking other queries")
|
||||
|
||||
# Step 3: Search for middleware-related content
|
||||
print_step(3, "Searching for middleware patterns")
|
||||
|
||||
middleware_query = "middleware and request handling best practices"
|
||||
results = await memory.get_relevant_context(middleware_query, num_results=5)
|
||||
|
||||
print(f" Query: '{middleware_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
middleware_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "middleware" in content.lower() or "routes" in content.lower():
|
||||
middleware_found = True
|
||||
|
||||
print_result(
|
||||
"Middleware search",
|
||||
"Found middleware-related content" if middleware_found else "No direct matches",
|
||||
middleware_found or len(results) > 0,
|
||||
)
|
||||
|
||||
# Step 4: Get session history
|
||||
print_step(4, "Retrieving session history")
|
||||
|
||||
history = await memory.get_session_history(limit=3)
|
||||
print(f" Found {len(history)} session records:")
|
||||
|
||||
for i, session in enumerate(history):
|
||||
session_num = session.get("session_number", "?")
|
||||
subtasks = session.get("subtasks_completed", [])
|
||||
print(f" Session {session_num}: {len(subtasks)} subtasks completed")
|
||||
for subtask in subtasks[:3]:
|
||||
print(f" - {subtask}")
|
||||
|
||||
print_result(
|
||||
"Session history", f"Retrieved {len(history)} sessions", len(history) > 0
|
||||
)
|
||||
|
||||
# Step 5: Get status summary
|
||||
print_step(5, "Memory status summary")
|
||||
|
||||
status = memory.get_status_summary()
|
||||
for key, value in status.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
all_passed = len(results) > 0 and len(history) > 0
|
||||
print_result(
|
||||
"Memory Retrieval",
|
||||
"All retrieval tests passed" if all_passed else "Some tests had issues",
|
||||
all_passed,
|
||||
)
|
||||
return all_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 4: Full Create → Store → Retrieve Cycle
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_full_cycle(test_db_path: Path) -> bool:
|
||||
"""
|
||||
Test the complete memory lifecycle:
|
||||
1. Create unique test data
|
||||
2. Store in graph database with Ollama embeddings
|
||||
3. Search and retrieve via semantic similarity
|
||||
4. Verify retrieved data matches what was stored
|
||||
"""
|
||||
print_header("Test 4: Full Create-Store-Retrieve Cycle")
|
||||
|
||||
# Create fresh test directories
|
||||
spec_dir = test_db_path / "cycle_test_spec"
|
||||
project_dir = test_db_path / "cycle_test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_full_cycle"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Create unique test content
|
||||
print_step(1, "Creating unique test content")
|
||||
|
||||
unique_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_pattern = (
|
||||
f"Unique pattern {unique_id}: Use dependency injection for database connections"
|
||||
)
|
||||
unique_gotcha = f"Unique gotcha {unique_id}: Always close database connections in finally blocks"
|
||||
|
||||
print(f" Unique ID: {unique_id}")
|
||||
print(f" Pattern: {unique_pattern[:60]}...")
|
||||
print(f" Gotcha: {unique_gotcha[:60]}...")
|
||||
|
||||
# Step 2: Store the content
|
||||
print_step(2, "Storing content in memory system")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
pattern_result = await memory.save_pattern(unique_pattern)
|
||||
print_result(
|
||||
"save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
|
||||
)
|
||||
|
||||
gotcha_result = await memory.save_gotcha(unique_gotcha)
|
||||
print_result("save_gotcha", "SUCCESS" if gotcha_result else "FAILED", gotcha_result)
|
||||
|
||||
# Wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 4 seconds for embedding processing and indexing...")
|
||||
await asyncio.sleep(4)
|
||||
|
||||
# Step 3: Search for the unique content
|
||||
print_step(3, "Searching for unique content")
|
||||
|
||||
# Search for the pattern
|
||||
pattern_query = "dependency injection database connections"
|
||||
pattern_results = await memory.get_relevant_context(pattern_query, num_results=5)
|
||||
|
||||
print(f" Query: '{pattern_query}'")
|
||||
print(f" Found {len(pattern_results)} results")
|
||||
|
||||
pattern_found = False
|
||||
for result in pattern_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
pattern_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Pattern retrieval",
|
||||
f"Found unique pattern (ID: {unique_id})"
|
||||
if pattern_found
|
||||
else "Unique pattern not in top results",
|
||||
pattern_found,
|
||||
)
|
||||
|
||||
# Search for the gotcha
|
||||
gotcha_query = "database connection cleanup finally block"
|
||||
gotcha_results = await memory.get_relevant_context(gotcha_query, num_results=5)
|
||||
|
||||
print(f" Query: '{gotcha_query}'")
|
||||
print(f" Found {len(gotcha_results)} results")
|
||||
|
||||
gotcha_found = False
|
||||
for result in gotcha_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
gotcha_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Gotcha retrieval",
|
||||
f"Found unique gotcha (ID: {unique_id})"
|
||||
if gotcha_found
|
||||
else "Unique gotcha not in top results",
|
||||
gotcha_found,
|
||||
)
|
||||
|
||||
# Step 4: Verify semantic similarity works
|
||||
print_step(4, "Verifying semantic similarity")
|
||||
|
||||
# Search with semantically similar but different wording
|
||||
alt_query = "closing connections properly in error handling"
|
||||
alt_results = await memory.get_relevant_context(alt_query, num_results=3)
|
||||
|
||||
print(f" Alternative query: '{alt_query}'")
|
||||
print(f" Found {len(alt_results)} semantically similar results:")
|
||||
|
||||
for i, result in enumerate(alt_results):
|
||||
content = result.get("content", "")[:80]
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. (score: {score:.4f}) {content}...")
|
||||
|
||||
semantic_works = len(alt_results) > 0
|
||||
print_result(
|
||||
"Semantic similarity",
|
||||
"Working - found related content" if semantic_works else "No results",
|
||||
semantic_works,
|
||||
)
|
||||
|
||||
await memory.close()
|
||||
|
||||
# Summary
|
||||
print()
|
||||
cycle_passed = (
|
||||
pattern_result
|
||||
and gotcha_result
|
||||
and (pattern_found or gotcha_found or len(alt_results) > 0)
|
||||
)
|
||||
print_result(
|
||||
"Full Cycle Test",
|
||||
"Create-Store-Retrieve cycle verified"
|
||||
if cycle_passed
|
||||
else "Some steps had issues",
|
||||
cycle_passed,
|
||||
)
|
||||
|
||||
return cycle_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run Ollama embedding memory tests."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Ollama Embedding Memory Integration"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
choices=["all", "embeddings", "create", "retrieve", "full-cycle"],
|
||||
default="all",
|
||||
help="Which test to run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-db",
|
||||
action="store_true",
|
||||
help="Keep test database after completion (default: cleanup)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" OLLAMA EMBEDDING MEMORY TEST SUITE")
|
||||
print("=" * 70)
|
||||
|
||||
# Configuration check
|
||||
print_header("Configuration Check")
|
||||
|
||||
config_items = {
|
||||
"GRAPHITI_ENABLED": os.environ.get("GRAPHITI_ENABLED", ""),
|
||||
"GRAPHITI_LLM_PROVIDER": os.environ.get("GRAPHITI_LLM_PROVIDER", ""),
|
||||
"GRAPHITI_EMBEDDER_PROVIDER": os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", ""),
|
||||
"OLLAMA_LLM_MODEL": os.environ.get("OLLAMA_LLM_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_MODEL": os.environ.get("OLLAMA_EMBEDDING_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_DIM": os.environ.get("OLLAMA_EMBEDDING_DIM", ""),
|
||||
"OLLAMA_BASE_URL": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434"),
|
||||
"OPENAI_API_KEY": "(set)"
|
||||
if os.environ.get("OPENAI_API_KEY")
|
||||
else "(not set - needed for reranker)",
|
||||
}
|
||||
|
||||
all_configured = True
|
||||
required_keys = [
|
||||
"GRAPHITI_ENABLED",
|
||||
"GRAPHITI_LLM_PROVIDER",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER",
|
||||
"OLLAMA_LLM_MODEL",
|
||||
"OLLAMA_EMBEDDING_MODEL",
|
||||
]
|
||||
|
||||
for key, value in config_items.items():
|
||||
is_optional = key in [
|
||||
"OLLAMA_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OLLAMA_EMBEDDING_DIM",
|
||||
]
|
||||
is_set = bool(value) if not is_optional else True
|
||||
display_value = value or "(not set)"
|
||||
if key == "OPENAI_API_KEY":
|
||||
display_value = value # Already formatted above
|
||||
is_set = True # Optional for testing
|
||||
print_result(key, display_value, is_set)
|
||||
if key in required_keys and not bool(os.environ.get(key)):
|
||||
all_configured = False
|
||||
|
||||
if not all_configured:
|
||||
print()
|
||||
print(" Missing required configuration. Please set:")
|
||||
print(" export GRAPHITI_ENABLED=true")
|
||||
print(" export GRAPHITI_LLM_PROVIDER=ollama")
|
||||
print(" export GRAPHITI_EMBEDDER_PROVIDER=ollama")
|
||||
print(" export OLLAMA_LLM_MODEL=deepseek-r1:7b")
|
||||
print(" export OLLAMA_EMBEDDING_MODEL=embeddinggemma")
|
||||
print(" export OLLAMA_EMBEDDING_DIM=768")
|
||||
print(" export OPENAI_API_KEY=dummy # For graphiti-core reranker")
|
||||
print()
|
||||
return
|
||||
|
||||
# Check LadybugDB
|
||||
if not apply_ladybug_monkeypatch():
|
||||
print()
|
||||
print_result("LadybugDB", "Not installed - pip install real-ladybug", False)
|
||||
return
|
||||
|
||||
print_result("LadybugDB", "Installed", True)
|
||||
|
||||
# Create temp directory for test database
|
||||
test_db_path = Path(tempfile.mkdtemp(prefix="ollama_memory_test_"))
|
||||
print()
|
||||
print_info(f"Test database: {test_db_path}")
|
||||
|
||||
# Run tests
|
||||
test = args.test
|
||||
results = {}
|
||||
|
||||
try:
|
||||
if test in ["all", "embeddings"]:
|
||||
results["embeddings"] = await test_ollama_embeddings()
|
||||
|
||||
spec_dir = None
|
||||
project_dir = None
|
||||
|
||||
if test in ["all", "create"]:
|
||||
spec_dir, project_dir, results["create"] = await test_memory_creation(
|
||||
test_db_path
|
||||
)
|
||||
|
||||
if test in ["all", "retrieve"]:
|
||||
if spec_dir and project_dir:
|
||||
results["retrieve"] = await test_memory_retrieval(spec_dir, project_dir)
|
||||
else:
|
||||
print_info(
|
||||
"Skipping retrieve test - no spec/project dir from create test"
|
||||
)
|
||||
|
||||
if test in ["all", "full-cycle"]:
|
||||
results["full-cycle"] = await test_full_cycle(test_db_path)
|
||||
|
||||
finally:
|
||||
# Cleanup unless --keep-db specified
|
||||
if not args.keep_db and test_db_path.exists():
|
||||
print()
|
||||
print_info(f"Cleaning up test database: {test_db_path}")
|
||||
shutil.rmtree(test_db_path, ignore_errors=True)
|
||||
|
||||
# Summary
|
||||
print_header("TEST SUMMARY")
|
||||
|
||||
all_passed = True
|
||||
for test_name, passed in results.items():
|
||||
status = "PASSED" if passed else "FAILED"
|
||||
print(f" {test_name}: {status}")
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
if all_passed:
|
||||
print(" All tests PASSED!")
|
||||
print()
|
||||
print(" The memory system is working correctly with Ollama embeddings.")
|
||||
print(" Memories can be created and retrieved using semantic search.")
|
||||
else:
|
||||
print(" Some tests FAILED. Check the output above for details.")
|
||||
print()
|
||||
print(" Common issues:")
|
||||
print(" - Ollama not running: ollama serve")
|
||||
print(" - Model not pulled: ollama pull embeddinggemma")
|
||||
print(" - Wrong dimension: Update OLLAMA_EMBEDDING_DIM to match model")
|
||||
|
||||
print()
|
||||
print(" Commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
|
||||
print()
|
||||
print(" # Run specific test:")
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
|
||||
)
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
|
||||
)
|
||||
print()
|
||||
print(" # Keep database for inspection:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test to demonstrate provider-specific database naming.
|
||||
|
||||
Shows how Auto Claude automatically generates provider-specific database names
|
||||
to prevent embedding dimension mismatches.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from integrations.graphiti.config import GraphitiConfig
|
||||
|
||||
|
||||
def test_provider_naming():
|
||||
"""Demonstrate provider-specific database naming."""
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" PROVIDER-SPECIFIC DATABASE NAMING")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
providers = [
|
||||
("openai", None, None),
|
||||
("ollama", "embeddinggemma", 768),
|
||||
("ollama", "qwen3-embedding:0.6b", 1024),
|
||||
("voyage", None, None),
|
||||
("google", None, None),
|
||||
]
|
||||
|
||||
for provider, model, dim in providers:
|
||||
# Create config
|
||||
config = GraphitiConfig.from_env()
|
||||
config.embedder_provider = provider
|
||||
|
||||
if provider == "ollama" and model:
|
||||
config.ollama_embedding_model = model
|
||||
if dim:
|
||||
config.ollama_embedding_dim = dim
|
||||
|
||||
# Get naming info
|
||||
dimension = config.get_embedding_dimension()
|
||||
signature = config.get_provider_signature()
|
||||
db_name = config.get_provider_specific_database_name("auto_claude_memory")
|
||||
|
||||
print(f"Provider: {provider}")
|
||||
if model:
|
||||
print(f" Model: {model}")
|
||||
print(f" Embedding Dimension: {dimension}")
|
||||
print(f" Provider Signature: {signature}")
|
||||
print(f" Database Name: {db_name}")
|
||||
print(f" Full Path: ~/.auto-claude/memories/{db_name}/")
|
||||
print()
|
||||
|
||||
print("=" * 70)
|
||||
print("\nKey Benefits:")
|
||||
print(" ✅ No dimension mismatch errors")
|
||||
print(" ✅ Each provider uses its own database")
|
||||
print(" ✅ Can switch providers without conflicts")
|
||||
print(" ✅ Migration utility available for data transfer")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_provider_naming()
|
||||
+217
-24
@@ -7,35 +7,53 @@ 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-5-20251101",
|
||||
"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",
|
||||
}
|
||||
|
||||
# Thinking level to budget tokens mapping (None = no extended thinking)
|
||||
# Values must match auto-claude-ui/src/shared/constants/models.ts THINKING_BUDGET_MAP
|
||||
THINKING_BUDGET_MAP: dict[str, int | None] = {
|
||||
"none": None,
|
||||
# Model shorthand to required SDK beta headers
|
||||
# Maps model shorthands that need special beta flags (e.g., 1M context window)
|
||||
MODEL_BETAS_MAP: dict[str, list[str]] = {
|
||||
"opus-1m": ["context-1m-2025-08-07"],
|
||||
}
|
||||
|
||||
# Thinking level to budget tokens mapping
|
||||
# Values must match apps/frontend/src/shared/constants/models.ts THINKING_BUDGET_MAP
|
||||
THINKING_BUDGET_MAP: dict[str, int] = {
|
||||
"low": 1024,
|
||||
"medium": 4096, # Moderate analysis
|
||||
"high": 16384, # Deep thinking for QA review
|
||||
"ultrathink": 63999, # Maximum reasoning depth (API requires max_tokens >= budget + 1, so 63999 + 1 = 64000 limit)
|
||||
}
|
||||
|
||||
# Effort level mapping for adaptive thinking models (e.g., Opus 4.6)
|
||||
# These models support CLAUDE_CODE_EFFORT_LEVEL env var for effort-based routing
|
||||
EFFORT_LEVEL_MAP: dict[str, str] = {"low": "low", "medium": "medium", "high": "high"}
|
||||
|
||||
# Models that support adaptive thinking via effort level (env var)
|
||||
# These models get both max_thinking_tokens AND effort_level
|
||||
ADAPTIVE_THINKING_MODELS: set[str] = {"claude-opus-4-6"}
|
||||
|
||||
# Spec runner phase-specific thinking levels
|
||||
# Heavy phases use ultrathink for deep analysis
|
||||
# Heavy phases use high for deep analysis
|
||||
# Light phases use medium after compaction
|
||||
SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
|
||||
# Heavy phases - ultrathink (discovery, spec creation, self-critique)
|
||||
"discovery": "ultrathink",
|
||||
"spec_writing": "ultrathink",
|
||||
"self_critique": "ultrathink",
|
||||
# Heavy phases - high (discovery, spec creation, self-critique)
|
||||
"discovery": "high",
|
||||
"spec_writing": "high",
|
||||
"self_critique": "high",
|
||||
# Light phases - medium (after first invocation with compaction)
|
||||
"requirements": "medium",
|
||||
"research": "medium",
|
||||
@@ -85,6 +103,7 @@ class TaskMetadataConfig(TypedDict, total=False):
|
||||
phaseThinking: PhaseThinkingConfig
|
||||
model: str
|
||||
thinkingLevel: str
|
||||
fastMode: bool
|
||||
|
||||
|
||||
Phase = Literal["spec", "planning", "coding", "qa"]
|
||||
@@ -112,6 +131,9 @@ def resolve_model_id(model: str) -> str:
|
||||
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"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:
|
||||
@@ -126,23 +148,69 @@ def resolve_model_id(model: str) -> str:
|
||||
return model
|
||||
|
||||
|
||||
def get_thinking_budget(thinking_level: str) -> int | None:
|
||||
def get_model_betas(model_short: str) -> list[str]:
|
||||
"""
|
||||
Get required SDK beta headers for a model shorthand.
|
||||
|
||||
Some model configurations (e.g., opus-1m for 1M context window) require
|
||||
passing beta headers to the Claude Agent SDK.
|
||||
|
||||
Args:
|
||||
model_short: Model shorthand (e.g., 'opus', 'opus-1m', 'sonnet')
|
||||
|
||||
Returns:
|
||||
List of beta header strings, or empty list if none required
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
thinking_level: Thinking level (none, low, medium, high, ultrathink)
|
||||
thinking_level: Thinking level (low, medium, high)
|
||||
|
||||
Returns:
|
||||
Token budget or None for no extended thinking
|
||||
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"]
|
||||
|
||||
@@ -214,6 +282,43 @@ def get_phase_model(
|
||||
return resolve_model_id(DEFAULT_PHASE_MODELS[phase])
|
||||
|
||||
|
||||
def get_phase_model_betas(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_model: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get required SDK beta headers for the model selected for a specific phase.
|
||||
|
||||
Uses the same priority logic as get_phase_model() to determine which model
|
||||
shorthand is selected, then looks up any required beta headers.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
cli_model: Model from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
List of beta header strings, or empty list if none required
|
||||
"""
|
||||
# Determine the model shorthand (before resolution to full ID)
|
||||
if cli_model:
|
||||
return get_model_betas(cli_model)
|
||||
|
||||
metadata = load_task_metadata(spec_dir)
|
||||
|
||||
if metadata:
|
||||
if metadata.get("isAutoProfile") and metadata.get("phaseModels"):
|
||||
phase_models = metadata["phaseModels"]
|
||||
model_short = phase_models.get(phase, DEFAULT_PHASE_MODELS[phase])
|
||||
return get_model_betas(model_short)
|
||||
|
||||
if metadata.get("model"):
|
||||
return get_model_betas(metadata["model"])
|
||||
|
||||
return get_model_betas(DEFAULT_PHASE_MODELS[phase])
|
||||
|
||||
|
||||
def get_phase_thinking(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
@@ -261,7 +366,7 @@ def get_phase_thinking_budget(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_thinking: str | None = None,
|
||||
) -> int | None:
|
||||
) -> int:
|
||||
"""
|
||||
Get the thinking budget tokens for a specific execution phase.
|
||||
|
||||
@@ -271,7 +376,7 @@ def get_phase_thinking_budget(
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Token budget or None for no extended thinking
|
||||
Token budget for extended thinking
|
||||
"""
|
||||
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
|
||||
return get_thinking_budget(thinking_level)
|
||||
@@ -282,7 +387,7 @@ def get_phase_config(
|
||||
phase: Phase,
|
||||
cli_model: str | None = None,
|
||||
cli_thinking: str | None = None,
|
||||
) -> tuple[str, str, int | None]:
|
||||
) -> tuple[str, str, int]:
|
||||
"""
|
||||
Get the full configuration for a specific execution phase.
|
||||
|
||||
@@ -302,7 +407,95 @@ def get_phase_config(
|
||||
return model_id, thinking_level, thinking_budget
|
||||
|
||||
|
||||
def get_spec_phase_thinking_budget(phase_name: str) -> int | None:
|
||||
def is_adaptive_model(model_id: str) -> bool:
|
||||
"""
|
||||
Check if a model supports adaptive thinking via effort level.
|
||||
|
||||
Adaptive models support the CLAUDE_CODE_EFFORT_LEVEL environment variable
|
||||
for effort-based routing in addition to max_thinking_tokens.
|
||||
|
||||
Args:
|
||||
model_id: Full model ID (e.g., 'claude-opus-4-6')
|
||||
|
||||
Returns:
|
||||
True if the model supports adaptive thinking
|
||||
"""
|
||||
return model_id in ADAPTIVE_THINKING_MODELS
|
||||
|
||||
|
||||
def get_thinking_kwargs_for_model(model_id: str, thinking_level: str) -> dict:
|
||||
"""
|
||||
Get thinking-related kwargs for create_client() based on model type.
|
||||
|
||||
For adaptive models (Opus 4.6): returns both max_thinking_tokens and effort_level.
|
||||
For other models (Sonnet, Haiku): returns only max_thinking_tokens.
|
||||
|
||||
Args:
|
||||
model_id: Full model ID (e.g., 'claude-opus-4-6')
|
||||
thinking_level: Thinking level string (low, medium, high)
|
||||
|
||||
Returns:
|
||||
Dict with 'max_thinking_tokens' and optionally 'effort_level'
|
||||
"""
|
||||
kwargs: dict = {"max_thinking_tokens": get_thinking_budget(thinking_level)}
|
||||
if is_adaptive_model(model_id):
|
||||
kwargs["effort_level"] = EFFORT_LEVEL_MAP.get(thinking_level, "medium")
|
||||
return kwargs
|
||||
|
||||
|
||||
def get_phase_client_thinking_kwargs(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
phase_model: str,
|
||||
cli_thinking: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Get thinking kwargs for create_client() for a specific execution phase.
|
||||
|
||||
Combines get_phase_thinking() and get_thinking_kwargs_for_model() to produce
|
||||
the correct kwargs dict based on phase config and model capabilities.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
phase_model: Resolved full model ID for this phase
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Dict with 'max_thinking_tokens' and optionally 'effort_level'
|
||||
"""
|
||||
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
|
||||
return get_thinking_kwargs_for_model(phase_model, thinking_level)
|
||||
|
||||
|
||||
def get_fast_mode(spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if Fast Mode is enabled for this task.
|
||||
|
||||
Fast Mode provides faster Opus 4.6 output at higher cost.
|
||||
Reads the fastMode flag from task_metadata.json.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if Fast Mode is enabled, False otherwise
|
||||
"""
|
||||
metadata = load_task_metadata(spec_dir)
|
||||
if metadata:
|
||||
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
|
||||
|
||||
|
||||
def get_spec_phase_thinking_budget(phase_name: str) -> int:
|
||||
"""
|
||||
Get the thinking budget for a specific spec runner phase.
|
||||
|
||||
@@ -313,7 +506,7 @@ def get_spec_phase_thinking_budget(phase_name: str) -> int | None:
|
||||
phase_name: Name of the spec phase (e.g., 'discovery', 'spec_writing')
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking, or None for no extended thinking
|
||||
Token budget for extended thinking
|
||||
"""
|
||||
thinking_level = SPEC_PHASE_THINKING_LEVELS.get(phase_name, "medium")
|
||||
return get_thinking_budget(thinking_level)
|
||||
|
||||
@@ -342,7 +342,7 @@ Input: { "libraryName": "[library name from subtask]" }
|
||||
|
||||
**Step 2: Get relevant documentation**
|
||||
```
|
||||
Tool: mcp__context7__get-library-docs
|
||||
Tool: mcp__context7__query-docs
|
||||
Input: {
|
||||
"context7CompatibleLibraryID": "[library-id]",
|
||||
"topic": "[specific feature you're implementing]",
|
||||
@@ -353,7 +353,7 @@ Input: {
|
||||
**Example workflow:**
|
||||
If subtask says "Add Stripe payment integration":
|
||||
1. `resolve-library-id` with "stripe"
|
||||
2. `get-library-docs` with topic "payments" or "checkout"
|
||||
2. `query-docs` with topic "payments" or "checkout"
|
||||
3. Use the exact patterns from documentation
|
||||
|
||||
**This prevents:**
|
||||
|
||||
@@ -271,9 +271,7 @@ Return one result per finding:
|
||||
"finding_id": "SEC-001",
|
||||
"validation_status": "confirmed_valid",
|
||||
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
|
||||
"line_range": [45, 45],
|
||||
"explanation": "SQL injection vulnerability confirmed. User input 'userId' is directly interpolated into the SQL query at line 45 without any sanitization. The query is executed via db.execute() on line 46.",
|
||||
"evidence_verified_in_file": true
|
||||
"explanation": "SQL injection vulnerability confirmed. User input 'userId' is directly interpolated into the SQL query at line 45 without any sanitization. The query is executed via db.execute() on line 46."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -282,9 +280,7 @@ Return one result per finding:
|
||||
"finding_id": "QUAL-002",
|
||||
"validation_status": "dismissed_false_positive",
|
||||
"code_evidence": "function processInput(data: string): string {\n const sanitized = DOMPurify.sanitize(data);\n return sanitized;\n}",
|
||||
"line_range": [23, 26],
|
||||
"explanation": "The original finding claimed XSS vulnerability, but the code uses DOMPurify.sanitize() before output. The input is properly sanitized at line 24 before being returned. The code evidence proves the issue does NOT exist.",
|
||||
"evidence_verified_in_file": true
|
||||
"explanation": "The original finding claimed XSS vulnerability, but the code uses DOMPurify.sanitize() before output. The input is properly sanitized at line 24 before being returned."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -293,9 +289,7 @@ Return one result per finding:
|
||||
"finding_id": "LOGIC-003",
|
||||
"validation_status": "needs_human_review",
|
||||
"code_evidence": "async function handleRequest(req) {\n // Complex async logic...\n}",
|
||||
"line_range": [100, 150],
|
||||
"explanation": "The original finding claims a race condition, but verifying this requires understanding the runtime behavior and concurrency model. The static code doesn't provide definitive evidence either way.",
|
||||
"evidence_verified_in_file": true
|
||||
"explanation": "The original finding claims a race condition, but verifying this requires understanding the runtime behavior and concurrency model. The static code doesn't provide definitive evidence either way."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -304,9 +298,7 @@ Return one result per finding:
|
||||
"finding_id": "HALLUC-004",
|
||||
"validation_status": "dismissed_false_positive",
|
||||
"code_evidence": "// Line 710 does not exist - file only has 600 lines",
|
||||
"line_range": [600, 600],
|
||||
"explanation": "The original finding claimed an issue at line 710, but the file only has 600 lines. This is a hallucinated finding - the code doesn't exist.",
|
||||
"evidence_verified_in_file": false
|
||||
"explanation": "The original finding claimed an issue at line 710, but the file only has 600 lines. This is a hallucinated finding - the code doesn't exist."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -324,7 +316,7 @@ Validation is binary based on what the code evidence shows:
|
||||
**Decision rules:**
|
||||
- If `code_evidence` contains problematic code → `confirmed_valid`
|
||||
- If `code_evidence` proves issue doesn't exist → `dismissed_false_positive`
|
||||
- If `evidence_verified_in_file` is false → `dismissed_false_positive` (hallucinated finding)
|
||||
- If the code/line doesn't exist → `dismissed_false_positive` (hallucinated finding)
|
||||
- If you can't determine from the code → `needs_human_review`
|
||||
|
||||
## Common False Positive Patterns
|
||||
@@ -403,7 +395,7 @@ CROSS-FILE CHECK:
|
||||
5. **When evidence is inconclusive, escalate** - Use `needs_human_review` rather than guessing
|
||||
6. **Look for mitigations** - Check surrounding code for sanitization/validation
|
||||
7. **Check the full context** - Read ±20 lines, not just the flagged line
|
||||
8. **Verify code exists** - Set `evidence_verified_in_file` to false if the code/line doesn't exist
|
||||
8. **Verify code exists** - Dismiss as false positive if the code/line doesn't exist
|
||||
9. **SEARCH BEFORE CLAIMING ABSENCE** - If you claim something doesn't exist (no helper, no validation, no error handling), you MUST show the search you performed:
|
||||
- Use Grep to search for the pattern
|
||||
- Include the search command in your explanation
|
||||
@@ -414,5 +406,5 @@ CROSS-FILE CHECK:
|
||||
- **Trusting the original finding blindly** - Always verify with actual code
|
||||
- **Dismissing without reading code** - Must provide code_evidence that proves your point
|
||||
- **Vague explanations** - Be specific about what the code shows and why it proves/disproves the issue
|
||||
- **Missing line numbers** - Always include line_range
|
||||
- **Vague evidence** - Always include actual code snippets
|
||||
- **Speculative conclusions** - Only conclude what the code evidence actually proves
|
||||
|
||||
@@ -303,35 +303,24 @@ Provide your synthesis as a structured response matching the ParallelFollowupRes
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis_summary": "Brief summary of what was analyzed",
|
||||
"agents_invoked": ["resolution-verifier", "finding-validator", "new-code-reviewer"],
|
||||
"commits_analyzed": 5,
|
||||
"files_changed": 12,
|
||||
"resolution_verifications": [...],
|
||||
"finding_validations": [
|
||||
{
|
||||
"finding_id": "SEC-001",
|
||||
"validation_status": "confirmed_valid",
|
||||
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
|
||||
"line_range": [45, 45],
|
||||
"explanation": "SQL injection is present - user input is concatenated directly into query"
|
||||
},
|
||||
{
|
||||
"finding_id": "QUAL-002",
|
||||
"validation_status": "dismissed_false_positive",
|
||||
"code_evidence": "const sanitized = DOMPurify.sanitize(data);",
|
||||
"line_range": [23, 26],
|
||||
"explanation": "Original finding claimed XSS but code uses DOMPurify for sanitization"
|
||||
}
|
||||
],
|
||||
"new_findings": [...],
|
||||
"comment_analyses": [...],
|
||||
"comment_findings": [...],
|
||||
"agent_agreement": {
|
||||
"agreed_findings": [],
|
||||
"conflicting_findings": [],
|
||||
"resolution_notes": null
|
||||
},
|
||||
"verdict": "READY_TO_MERGE",
|
||||
"verdict_reasoning": "2 findings resolved, 1 dismissed as false positive, 1 confirmed valid but LOW severity..."
|
||||
}
|
||||
|
||||
@@ -109,13 +109,15 @@ ELECTRON VALIDATION:
|
||||
### Handling Common Issues
|
||||
|
||||
**App Not Running:**
|
||||
If Electron app is not running or debug port is not accessible:
|
||||
1. Document that Electron validation was skipped
|
||||
2. Note reason: "App not running with --remote-debugging-port=9222"
|
||||
3. Add to QA report as "Manual verification required"
|
||||
If the Electron app is not running or debug port is not accessible:
|
||||
|
||||
1. Check the project commands listed in the PROJECT CAPABILITIES section for a debug/MCP startup script
|
||||
2. Try starting the app with the appropriate command
|
||||
3. If the app still cannot be started:
|
||||
- **For specs with UI changes**: This is a CRITICAL blocking issue. Mark as **REJECTED** — visual verification is mandatory for UI changes and cannot be skipped
|
||||
- **For non-UI changes**: Document as "Electron validation skipped — no UI files changed" and proceed with code-based review
|
||||
|
||||
**Headless Environment (CI/CD):**
|
||||
If running in headless environment without display:
|
||||
1. Skip interactive Electron validation
|
||||
2. Document: "Electron UI validation skipped - headless environment"
|
||||
3. Rely on unit/integration tests for validation
|
||||
1. For UI changes: Document as critical issue — "Visual verification required but unavailable in headless environment"
|
||||
2. For non-UI changes: Skip interactive Electron validation and rely on automated tests
|
||||
|
||||
@@ -97,3 +97,14 @@ When testing UI elements, prefer these selector strategies:
|
||||
3. `button:contains("Text")` - By visible text
|
||||
4. `.class-name` - CSS classes
|
||||
5. `input[name="..."]` - Form fields by name
|
||||
|
||||
### Handling Common Issues
|
||||
|
||||
**Dev Server Not Running:**
|
||||
If the development server is not running or the page cannot be loaded:
|
||||
|
||||
1. Check the project commands listed in the PROJECT CAPABILITIES section for the dev server command
|
||||
2. Start the dev server and wait for it to be ready
|
||||
3. If the server cannot be started:
|
||||
- **For specs with UI changes**: This is a CRITICAL blocking issue. Mark as **REJECTED** — visual verification is mandatory for UI changes
|
||||
- **For non-UI changes**: Document as "Browser validation skipped — no UI files changed" and proceed with code-based review
|
||||
|
||||
@@ -126,41 +126,86 @@ E2E TESTS:
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4: BROWSER VERIFICATION (If Frontend)
|
||||
## PHASE 4: VISUAL / UI VERIFICATION
|
||||
|
||||
For each page/component in the QA Acceptance Criteria:
|
||||
### 4.0: Determine Verification Scope (MANDATORY — DO NOT SKIP)
|
||||
|
||||
### 4.1: Navigate and Screenshot
|
||||
Review the file list from your Phase 0 git diff. Classify each changed file:
|
||||
|
||||
**UI files** (require visual verification):
|
||||
- Component files: .tsx, .jsx, .vue, .svelte, .astro
|
||||
- Style files: .css, .scss, .less, .sass
|
||||
- Files containing Tailwind classes, CSS-in-JS, or inline style changes
|
||||
- Files in directories: components/, pages/, views/, layouts/, styles/, renderer/
|
||||
|
||||
**Non-UI files** (do not require visual verification):
|
||||
- Backend logic: .py, .go, .rs, .java (without template rendering)
|
||||
- Configuration: .json, .yaml, .toml, .env (unless theme/style config)
|
||||
- Tests: *.test.*, *.spec.*
|
||||
- Documentation: .md, .txt
|
||||
|
||||
**Decision**:
|
||||
- If ANY changed file is a UI file → visual verification is REQUIRED below
|
||||
- If the spec describes visual/layout/CSS/styling changes → visual verification is REQUIRED
|
||||
- If NEITHER applies → document "Phase 4: N/A — no visual changes detected in diff" and proceed to Phase 5
|
||||
|
||||
**CRITICAL**: For UI changes, code review alone is NEVER sufficient verification. CSS properties interact with layout context, parent constraints, and specificity in ways that cannot be reliably verified by reading code alone. You MUST see the rendered result.
|
||||
|
||||
### 4.1: Start the Application
|
||||
|
||||
Check the PROJECT CAPABILITIES section above for available startup commands.
|
||||
|
||||
**For Electron apps** (if Electron MCP tools are available):
|
||||
1. Check if app is already running:
|
||||
```
|
||||
Tool: mcp__electron__get_electron_window_info
|
||||
```
|
||||
2. If not running, look for a debug/MCP script in the startup commands above and run it:
|
||||
```bash
|
||||
cd [frontend-path] && npm run dev:debug
|
||||
```
|
||||
Wait 15 seconds, then retry `get_electron_window_info`.
|
||||
|
||||
**For web frontends** (if Puppeteer tools are available):
|
||||
1. Start dev server using the dev_command from the startup commands above
|
||||
2. Wait for the server to be listening on the expected port
|
||||
3. Navigate with Puppeteer:
|
||||
```
|
||||
Tool: mcp__puppeteer__puppeteer_navigate
|
||||
Args: {"url": "http://localhost:[port]"}
|
||||
```
|
||||
|
||||
### 4.2: Capture and Verify Screenshots
|
||||
|
||||
For EACH visual success criterion in the spec:
|
||||
1. Navigate to the affected screen/component
|
||||
2. Set up test conditions (e.g., create long text to test overflow)
|
||||
3. Take a screenshot:
|
||||
- Electron: `mcp__electron__take_screenshot`
|
||||
- Web: `mcp__puppeteer__puppeteer_screenshot`
|
||||
4. Examine the screenshot and verify the criterion is met
|
||||
5. Document: "[Criterion]: VERIFIED via screenshot" or "FAILED: [what you observed]"
|
||||
|
||||
### 4.3: Check Console for Errors
|
||||
|
||||
- Electron: `mcp__electron__read_electron_logs` with `{"logType": "console", "lines": 50}`
|
||||
- Web: `mcp__puppeteer__puppeteer_evaluate` with `{"script": "window.__consoleErrors || []"}`
|
||||
|
||||
### 4.4: Document Findings
|
||||
|
||||
```
|
||||
# Use browser automation tools
|
||||
1. Navigate to URL
|
||||
2. Take screenshot
|
||||
3. Check for console errors
|
||||
4. Verify visual elements
|
||||
5. Test interactions
|
||||
VISUAL VERIFICATION:
|
||||
- Verification required: YES/NO (reason: [which UI files changed or "no UI files in diff"])
|
||||
- Application started: YES/NO (method: [Electron MCP / Puppeteer / N/A])
|
||||
- Screenshots captured: [count]
|
||||
- Visual criteria verified:
|
||||
- "[criterion 1]": PASS/FAIL
|
||||
- "[criterion 2]": PASS/FAIL
|
||||
- Console errors: [list or "None"]
|
||||
- Issues found: [list or "None"]
|
||||
```
|
||||
|
||||
### 4.2: Console Error Check
|
||||
|
||||
**CRITICAL**: Check for JavaScript errors in the browser console.
|
||||
|
||||
```
|
||||
# Check browser console for:
|
||||
- Errors (red)
|
||||
- Warnings (yellow)
|
||||
- Failed network requests
|
||||
```
|
||||
|
||||
### 4.3: Document Findings
|
||||
|
||||
```
|
||||
BROWSER VERIFICATION:
|
||||
- [Page/Component]: PASS/FAIL
|
||||
- Console errors: [list or "None"]
|
||||
- Visual check: PASS/FAIL
|
||||
- Interactions: PASS/FAIL
|
||||
```
|
||||
**If you cannot start the application for visual verification of UI changes**: This is a BLOCKING issue. Do NOT silently skip — document it as a critical issue and REJECT, requesting startup instructions be fixed.
|
||||
|
||||
---
|
||||
|
||||
@@ -239,7 +284,7 @@ Input: { "libraryName": "[library name]" }
|
||||
|
||||
**Step 3: Verify API usage matches documentation**
|
||||
```
|
||||
Tool: mcp__context7__get-library-docs
|
||||
Tool: mcp__context7__query-docs
|
||||
Input: {
|
||||
"context7CompatibleLibraryID": "[library-id]",
|
||||
"topic": "[relevant topic - e.g., the function being used]",
|
||||
@@ -354,7 +399,7 @@ Create a comprehensive QA report:
|
||||
| Unit Tests | ✓/✗ | X/Y passing |
|
||||
| Integration Tests | ✓/✗ | X/Y passing |
|
||||
| E2E Tests | ✓/✗ | X/Y passing |
|
||||
| Browser Verification | ✓/✗ | [summary] |
|
||||
| Visual Verification | ✓/✗/N/A | [Screenshot count] or "No UI changes" |
|
||||
| Project-Specific Validation | ✓/✗ | [summary based on project type] |
|
||||
| Database Verification | ✓/✗ | [summary] |
|
||||
| Third-Party API Validation | ✓/✗ | [Context7 verification summary] |
|
||||
@@ -362,6 +407,14 @@ Create a comprehensive QA report:
|
||||
| Pattern Compliance | ✓/✗ | [summary] |
|
||||
| Regression Check | ✓/✗ | [summary] |
|
||||
|
||||
## Visual Verification Evidence
|
||||
|
||||
If UI files were changed:
|
||||
- Screenshots taken: [count and description of each]
|
||||
- Console log check: [error count or "Clean"]
|
||||
|
||||
If skipped: [Explicit justification — must reference git diff showing no UI files changed]
|
||||
|
||||
## Issues Found
|
||||
|
||||
### Critical (Blocks Sign-off)
|
||||
@@ -505,7 +558,7 @@ All acceptance criteria verified:
|
||||
- Unit tests: PASS
|
||||
- Integration tests: PASS
|
||||
- E2E tests: PASS
|
||||
- Browser verification: PASS
|
||||
- Visual verification: PASS
|
||||
- Project-specific validation: PASS (or N/A)
|
||||
- Database verification: PASS
|
||||
- Security review: PASS
|
||||
|
||||
@@ -60,7 +60,7 @@ Tool: mcp__context7__resolve-library-id
|
||||
Input: { "libraryName": "[library from spec]" }
|
||||
|
||||
# Step 2: Verify API patterns mentioned in spec
|
||||
Tool: mcp__context7__get-library-docs
|
||||
Tool: mcp__context7__query-docs
|
||||
Input: {
|
||||
"context7CompatibleLibraryID": "[library-id]",
|
||||
"topic": "[specific API or feature mentioned in spec]",
|
||||
@@ -305,7 +305,7 @@ When analyzing, think through:
|
||||
>
|
||||
> Let me also verify with Context7 - I'll look up the actual package name and API patterns to confirm...
|
||||
> [Use mcp__context7__resolve-library-id to find the library]
|
||||
> [Use mcp__context7__get-library-docs to check API patterns]
|
||||
> [Use mcp__context7__query-docs to check API patterns]
|
||||
>
|
||||
> Next, looking at the API patterns. The research shows initialization requires [steps], but the spec shows [different steps]. Let me cross-reference with Context7 documentation... Another issue confirmed.
|
||||
>
|
||||
|
||||
@@ -63,7 +63,7 @@ This returns the Context7-compatible ID (e.g., "/vercel/next.js").
|
||||
Once you have the ID, fetch documentation for specific topics:
|
||||
|
||||
```
|
||||
Tool: mcp__context7__get-library-docs
|
||||
Tool: mcp__context7__query-docs
|
||||
Input: {
|
||||
"context7CompatibleLibraryID": "/vercel/next.js",
|
||||
"topic": "routing", // Focus on relevant topic
|
||||
@@ -244,7 +244,7 @@ research.json created successfully.
|
||||
|
||||
1. **Context7 MCP** (PRIMARY) - Best for official docs, API patterns, code examples
|
||||
- Use `resolve-library-id` first to get the library ID
|
||||
- Then `get-library-docs` with relevant topics
|
||||
- Then `query-docs` with relevant topics
|
||||
- Covers most popular libraries (React, Next.js, FastAPI, etc.)
|
||||
|
||||
2. **Web Search** - For package verification, recent info, obscure libraries
|
||||
@@ -271,7 +271,7 @@ Input: { "libraryName": "graphiti" }
|
||||
|
||||
If found in Context7:
|
||||
```
|
||||
Tool: mcp__context7__get-library-docs
|
||||
Tool: mcp__context7__query-docs
|
||||
Input: {
|
||||
"context7CompatibleLibraryID": "/zep/graphiti",
|
||||
"topic": "getting started",
|
||||
|
||||
@@ -529,18 +529,82 @@ This shows only changes made in the spec branch since it diverged from `{base_br
|
||||
|
||||
"""
|
||||
|
||||
# Add capability summary for transparency
|
||||
# Add capability summary as verification requirements table
|
||||
active_caps = [k for k, v in capabilities.items() if v]
|
||||
if active_caps:
|
||||
spec_context += (
|
||||
"Based on project analysis, the following capabilities were detected:\n"
|
||||
)
|
||||
for cap in active_caps:
|
||||
cap_name = (
|
||||
cap.replace("is_", "").replace("has_", "").replace("_", " ").title()
|
||||
spec_context += "Based on project analysis, the following verification requirements apply:\n\n"
|
||||
spec_context += "| Capability | Detected | Verification Requirement |\n"
|
||||
spec_context += "|-----------|----------|-------------------------|\n"
|
||||
|
||||
# NOTE: Keys must match those returned by detect_project_capabilities() in project_context.py.
|
||||
# If new capabilities are added there, update this dict to avoid silent omission.
|
||||
cap_requirements = {
|
||||
"is_electron": (
|
||||
"Electron Desktop App",
|
||||
"UI changes REQUIRE Electron MCP visual verification (screenshots)",
|
||||
),
|
||||
"is_web_frontend": (
|
||||
"Web Frontend",
|
||||
"UI changes REQUIRE browser-based visual verification (screenshots)",
|
||||
),
|
||||
"is_tauri": ("Tauri Desktop App", "UI changes REQUIRE visual verification"),
|
||||
"is_expo": (
|
||||
"Expo Mobile App",
|
||||
"UI changes require device/simulator verification",
|
||||
),
|
||||
"is_react_native": (
|
||||
"React Native App",
|
||||
"UI changes require device/simulator verification",
|
||||
),
|
||||
"is_nextjs": ("Next.js App", "Page changes require browser verification"),
|
||||
"is_nuxt": ("Nuxt App", "Page changes require browser verification"),
|
||||
"has_api": ("API Endpoints", "Endpoint changes require API testing"),
|
||||
"has_database": (
|
||||
"Database",
|
||||
"Schema changes require migration verification",
|
||||
),
|
||||
}
|
||||
|
||||
for cap_key in active_caps:
|
||||
if cap_key in cap_requirements:
|
||||
name, req = cap_requirements[cap_key]
|
||||
spec_context += f"| {name} | YES | {req} |\n"
|
||||
|
||||
spec_context += "\n"
|
||||
|
||||
# Inject startup commands from project_index services
|
||||
# Handle both dict format (services by name) and list format
|
||||
services = project_index.get("services", {})
|
||||
if isinstance(services, dict):
|
||||
services_iter = services.items()
|
||||
elif isinstance(services, list):
|
||||
services_iter = (
|
||||
(svc.get("name", f"service-{i}"), svc)
|
||||
for i, svc in enumerate(services)
|
||||
if isinstance(svc, dict)
|
||||
)
|
||||
spec_context += f"- {cap_name}\n"
|
||||
spec_context += "\nRelevant validation tools have been included below.\n\n"
|
||||
else:
|
||||
services_iter = iter([])
|
||||
for svc_name, svc in services_iter:
|
||||
svc_scripts = svc.get("scripts", {})
|
||||
dev_cmd = svc.get("dev_command", "")
|
||||
if svc_scripts or dev_cmd:
|
||||
spec_context += f"**{svc_name} service commands:**\n"
|
||||
if dev_cmd:
|
||||
spec_context += f"- Dev server: `{dev_cmd}`\n"
|
||||
# Surface debug/MCP scripts specifically
|
||||
debug_scripts = {
|
||||
k: v
|
||||
for k, v in svc_scripts.items()
|
||||
if any(term in k for term in ("debug", "mcp", "test", "e2e"))
|
||||
}
|
||||
if debug_scripts:
|
||||
pkg_mgr = svc.get("package_manager", "npm")
|
||||
for script_name, script_cmd in debug_scripts.items():
|
||||
spec_context += f"- {script_name}: `{pkg_mgr} run {script_name}` ({script_cmd})\n"
|
||||
spec_context += "\n"
|
||||
|
||||
spec_context += "Match changed files from the git diff against these capabilities to determine which verification phases are MANDATORY.\n\n"
|
||||
else:
|
||||
spec_context += (
|
||||
"No special project capabilities detected. Using standard validation.\n\n"
|
||||
|
||||
+54
-10
@@ -12,8 +12,10 @@ Memory Integration:
|
||||
from pathlib import Path
|
||||
|
||||
# Memory integration for cross-session learning
|
||||
from agents.base import sanitize_error_message
|
||||
from agents.memory_manager import get_graphiti_context, save_session_memory
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from core.error_utils import is_rate_limit_error, is_tool_concurrency_error
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
@@ -52,7 +54,7 @@ async def run_qa_fixer_session(
|
||||
fix_session: int,
|
||||
verbose: bool = False,
|
||||
project_dir: Path | None = None,
|
||||
) -> tuple[str, str]:
|
||||
) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Run a QA fixer agent session.
|
||||
|
||||
@@ -64,9 +66,13 @@ async def run_qa_fixer_session(
|
||||
project_dir: Project root directory (for memory context)
|
||||
|
||||
Returns:
|
||||
(status, response_text) where status is:
|
||||
- "fixed" if fixes were applied
|
||||
- "error" if an error occurred
|
||||
(status, response_text, error_info) where:
|
||||
- status: "fixed" if fixes were applied, "error" if an error occurred
|
||||
- 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
|
||||
"""
|
||||
# Derive project_dir from spec_dir if not provided
|
||||
# spec_dir is typically: /project/.auto-claude/specs/001-name/
|
||||
@@ -96,7 +102,12 @@ async def run_qa_fixer_session(
|
||||
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
|
||||
if not fix_request_file.exists():
|
||||
debug_error("qa_fixer", "QA_FIX_REQUEST.md not found")
|
||||
return "error", "QA_FIX_REQUEST.md not found"
|
||||
error_info = {
|
||||
"type": "other",
|
||||
"message": "QA_FIX_REQUEST.md not found",
|
||||
"exception_type": "FileNotFoundError",
|
||||
}
|
||||
return "error", "QA_FIX_REQUEST.md not found", error_info
|
||||
|
||||
# Load fixer prompt
|
||||
prompt = load_qa_fixer_prompt()
|
||||
@@ -293,7 +304,7 @@ async def run_qa_fixer_session(
|
||||
subtasks_completed=[f"qa_fixer_{fix_session}"],
|
||||
discoveries=fixer_discoveries,
|
||||
)
|
||||
return "fixed", response_text
|
||||
return "fixed", response_text, {}
|
||||
else:
|
||||
# Fixer didn't update the status properly, but we'll trust it worked
|
||||
debug_success("qa_fixer", "Fixes assumed applied (status not updated)")
|
||||
@@ -307,15 +318,48 @@ async def run_qa_fixer_session(
|
||||
subtasks_completed=[f"qa_fixer_{fix_session}"],
|
||||
discoveries=fixer_discoveries,
|
||||
)
|
||||
return "fixed", response_text
|
||||
return "fixed", response_text, {}
|
||||
|
||||
except Exception as e:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
is_rate_limited = is_rate_limit_error(e)
|
||||
|
||||
if is_concurrency:
|
||||
error_type = "tool_concurrency"
|
||||
elif is_rate_limited:
|
||||
error_type = "rate_limit"
|
||||
else:
|
||||
error_type = "other"
|
||||
|
||||
debug_error(
|
||||
"qa_fixer",
|
||||
f"Fixer session exception: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
error_category=error_type,
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
)
|
||||
print(f"Error during fixer session: {e}")
|
||||
|
||||
# Sanitize error message to remove potentially sensitive data
|
||||
sanitized_error = sanitize_error_message(str(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: {sanitized_error[:200]}\n")
|
||||
else:
|
||||
print(f"Error during fixer session: {sanitized_error}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"QA fixer error: {e}", LogPhase.VALIDATION)
|
||||
return "error", str(e)
|
||||
task_logger.log_error(
|
||||
f"QA fixer error: {sanitized_error}", LogPhase.VALIDATION
|
||||
)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": sanitized_error,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", sanitized_error, error_info
|
||||
|
||||
+60
-12
@@ -21,7 +21,12 @@ from linear_updater import (
|
||||
linear_qa_rejected,
|
||||
linear_qa_started,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import count_subtasks, is_build_complete
|
||||
from security.constants import PROJECT_DIR_ENV_VAR
|
||||
@@ -125,6 +130,12 @@ async def run_qa_validation_loop(
|
||||
{"iteration": 1, "maxIterations": MAX_QA_ITERATIONS},
|
||||
)
|
||||
|
||||
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"
|
||||
has_human_feedback = fix_request_file.exists()
|
||||
@@ -155,18 +166,23 @@ async def run_qa_validation_loop(
|
||||
|
||||
# Get model and thinking budget for fixer (uses QA phase config)
|
||||
qa_model = get_phase_model(spec_dir, "qa", model)
|
||||
fixer_thinking_budget = get_phase_thinking_budget(spec_dir, "qa")
|
||||
qa_betas = get_phase_model_betas(spec_dir, "qa", model)
|
||||
fixer_thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
spec_dir, "qa", qa_model
|
||||
)
|
||||
|
||||
fix_client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
qa_model,
|
||||
agent_type="qa_fixer",
|
||||
max_thinking_tokens=fixer_thinking_budget,
|
||||
betas=qa_betas,
|
||||
fast_mode=fast_mode,
|
||||
**fixer_thinking_kwargs,
|
||||
)
|
||||
|
||||
async with fix_client:
|
||||
fix_status, fix_response = await run_qa_fixer_session(
|
||||
fix_status, fix_response, fix_error_info = await run_qa_fixer_session(
|
||||
fix_client,
|
||||
spec_dir,
|
||||
0,
|
||||
@@ -175,7 +191,31 @@ async def run_qa_validation_loop(
|
||||
|
||||
if fix_status == "error":
|
||||
debug_error("qa_loop", f"Fixer error: {fix_response[:200]}")
|
||||
task_event_emitter.emit(
|
||||
"QA_FIXING_FAILED",
|
||||
{"iteration": 0, "error": fix_response[:200]},
|
||||
)
|
||||
print(f"\n❌ Fixer encountered error: {fix_response}")
|
||||
# Only delete fix request file on permanent errors
|
||||
# Preserve on transient errors (rate limit, concurrency) so user feedback isn't lost
|
||||
is_transient = fix_error_info.get("type") in (
|
||||
"tool_concurrency",
|
||||
"rate_limit",
|
||||
)
|
||||
if is_transient:
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Preserving QA_FIX_REQUEST.md (transient error - user feedback retained)",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
fix_request_file.unlink()
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Removed QA_FIX_REQUEST.md after permanent fixer error",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
debug_success("qa_loop", "Human feedback fixes applied")
|
||||
@@ -238,24 +278,27 @@ async def run_qa_validation_loop(
|
||||
|
||||
# Run QA reviewer with phase-specific model and thinking budget
|
||||
qa_model = get_phase_model(spec_dir, "qa", model)
|
||||
qa_thinking_budget = get_phase_thinking_budget(spec_dir, "qa")
|
||||
qa_betas = get_phase_model_betas(spec_dir, "qa", model)
|
||||
qa_thinking_kwargs = get_phase_client_thinking_kwargs(spec_dir, "qa", qa_model)
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Creating client for QA reviewer session...",
|
||||
model=qa_model,
|
||||
thinking_budget=qa_thinking_budget,
|
||||
thinking_budget=qa_thinking_kwargs.get("max_thinking_tokens"),
|
||||
)
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
qa_model,
|
||||
agent_type="qa_reviewer",
|
||||
max_thinking_tokens=qa_thinking_budget,
|
||||
betas=qa_betas,
|
||||
fast_mode=fast_mode,
|
||||
**qa_thinking_kwargs,
|
||||
)
|
||||
|
||||
async with client:
|
||||
debug("qa_loop", "Running QA reviewer agent session...")
|
||||
status, response = await run_qa_agent_session(
|
||||
status, response, _error_info = await run_qa_agent_session(
|
||||
client,
|
||||
project_dir, # Pass project_dir for capability-based tool injection
|
||||
spec_dir,
|
||||
@@ -426,12 +469,15 @@ async def run_qa_validation_loop(
|
||||
break
|
||||
|
||||
# Run fixer with phase-specific thinking budget
|
||||
fixer_thinking_budget = get_phase_thinking_budget(spec_dir, "qa")
|
||||
fixer_betas = get_phase_model_betas(spec_dir, "qa", model)
|
||||
fixer_thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
spec_dir, "qa", qa_model
|
||||
)
|
||||
debug(
|
||||
"qa_loop",
|
||||
"Starting QA fixer session...",
|
||||
model=qa_model,
|
||||
thinking_budget=fixer_thinking_budget,
|
||||
thinking_budget=fixer_thinking_kwargs.get("max_thinking_tokens"),
|
||||
)
|
||||
emit_phase(ExecutionPhase.QA_FIXING, "Fixing QA issues")
|
||||
task_event_emitter.emit(
|
||||
@@ -445,11 +491,13 @@ async def run_qa_validation_loop(
|
||||
spec_dir,
|
||||
qa_model,
|
||||
agent_type="qa_fixer",
|
||||
max_thinking_tokens=fixer_thinking_budget,
|
||||
betas=fixer_betas,
|
||||
fast_mode=fast_mode,
|
||||
**fixer_thinking_kwargs,
|
||||
)
|
||||
|
||||
async with fix_client:
|
||||
fix_status, fix_response = await run_qa_fixer_session(
|
||||
fix_status, fix_response, _fix_error_info = await run_qa_fixer_session(
|
||||
fix_client, spec_dir, qa_iteration, verbose
|
||||
)
|
||||
|
||||
|
||||
+54
-11
@@ -13,8 +13,10 @@ Memory Integration:
|
||||
from pathlib import Path
|
||||
|
||||
# Memory integration for cross-session learning
|
||||
from agents.base import sanitize_error_message
|
||||
from agents.memory_manager import get_graphiti_context, save_session_memory
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from core.error_utils import is_rate_limit_error, is_tool_concurrency_error
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from prompts_pkg import get_qa_reviewer_prompt
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
@@ -39,7 +41,7 @@ async def run_qa_agent_session(
|
||||
max_iterations: int,
|
||||
verbose: bool = False,
|
||||
previous_error: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
) -> tuple[str, str, dict]:
|
||||
"""
|
||||
Run a QA reviewer agent session.
|
||||
|
||||
@@ -53,10 +55,13 @@ async def run_qa_agent_session(
|
||||
previous_error: Error context from previous iteration for self-correction
|
||||
|
||||
Returns:
|
||||
(status, response_text) where status is:
|
||||
- "approved" if QA approves
|
||||
- "rejected" if QA finds issues
|
||||
- "error" if an error occurred
|
||||
(status, response_text, error_info) where:
|
||||
- status: "approved" if QA approves, "rejected" if QA finds issues, "error" if an error occurred
|
||||
- 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("qa_reviewer", f"QA Reviewer Session {qa_session}")
|
||||
debug(
|
||||
@@ -350,7 +355,7 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
|
||||
subtasks_completed=[f"qa_reviewer_{qa_session}"],
|
||||
discoveries=qa_discoveries,
|
||||
)
|
||||
return "approved", response_text
|
||||
return "approved", response_text, {}
|
||||
elif status and status.get("status") == "rejected":
|
||||
debug_error("qa_reviewer", "QA REJECTED")
|
||||
# Extract issues found for memory
|
||||
@@ -369,7 +374,7 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
|
||||
subtasks_completed=[],
|
||||
discoveries=qa_discoveries,
|
||||
)
|
||||
return "rejected", response_text
|
||||
return "rejected", response_text, {}
|
||||
else:
|
||||
# Agent didn't update the status properly - provide detailed error
|
||||
debug_error(
|
||||
@@ -393,15 +398,53 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
|
||||
if error_details:
|
||||
error_msg += f" ({'; '.join(error_details)})"
|
||||
|
||||
return "error", error_msg
|
||||
error_info = {
|
||||
"type": "other",
|
||||
"message": error_msg,
|
||||
"exception_type": "ComplianceError",
|
||||
}
|
||||
return "error", error_msg, error_info
|
||||
|
||||
except Exception as e:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
is_rate_limited = is_rate_limit_error(e)
|
||||
|
||||
if is_concurrency:
|
||||
error_type = "tool_concurrency"
|
||||
elif is_rate_limited:
|
||||
error_type = "rate_limit"
|
||||
else:
|
||||
error_type = "other"
|
||||
|
||||
debug_error(
|
||||
"qa_reviewer",
|
||||
f"QA session exception: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
error_category=error_type,
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
)
|
||||
print(f"Error during QA session: {e}")
|
||||
|
||||
# Sanitize error message to remove potentially sensitive data
|
||||
sanitized_error = sanitize_error_message(str(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: {sanitized_error[:200]}\n")
|
||||
else:
|
||||
print(f"Error during QA session: {sanitized_error}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"QA session error: {e}", LogPhase.VALIDATION)
|
||||
return "error", str(e)
|
||||
task_logger.log_error(
|
||||
f"QA session error: {sanitized_error}", LogPhase.VALIDATION
|
||||
)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": sanitized_error,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", sanitized_error, error_info
|
||||
|
||||
@@ -5,7 +5,9 @@ from services.recovery import (
|
||||
RecoveryAction,
|
||||
RecoveryManager,
|
||||
check_and_recover,
|
||||
clear_stuck_subtasks,
|
||||
get_recovery_context,
|
||||
reset_subtask,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -13,5 +15,7 @@ __all__ = [
|
||||
"FailureType",
|
||||
"RecoveryAction",
|
||||
"check_and_recover",
|
||||
"clear_stuck_subtasks",
|
||||
"get_recovery_context",
|
||||
"reset_subtask",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -997,6 +997,7 @@ class GitHubRunnerConfig:
|
||||
# to respect environment variable overrides (e.g., ANTHROPIC_DEFAULT_SONNET_MODEL)
|
||||
model: str = "sonnet"
|
||||
thinking_level: str = "medium"
|
||||
fast_mode: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -1019,6 +1020,7 @@ class GitHubRunnerConfig:
|
||||
"allow_fix_commits": self.allow_fix_commits,
|
||||
"model": self.model,
|
||||
"thinking_level": self.thinking_level,
|
||||
"fast_mode": self.fast_mode,
|
||||
}
|
||||
|
||||
def save_settings(self, github_dir: Path) -> None:
|
||||
|
||||
@@ -163,6 +163,10 @@ AI_PRICING = {
|
||||
# Claude 4.5 models (current)
|
||||
"claude-sonnet-4-5-20250929": {"input": 3.00, "output": 15.00},
|
||||
"claude-opus-4-5-20251101": {"input": 15.00, "output": 75.00},
|
||||
"claude-opus-4-6": {"input": 15.00, "output": 75.00},
|
||||
# Note: Opus 4.6 with 1M context (opus-1m) uses the same model ID with a beta
|
||||
# header, so it shares the same pricing key. Requests >200K tokens incur premium
|
||||
# rates (2x input, 1.5x output) automatically on the API side.
|
||||
"claude-haiku-4-5-20251001": {"input": 0.80, "output": 4.00},
|
||||
# Extended thinking models (higher output costs)
|
||||
"claude-sonnet-4-5-20250929-thinking": {"input": 3.00, "output": 15.00},
|
||||
|
||||
@@ -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))
|
||||
@@ -182,6 +183,7 @@ def get_config(args) -> GitHubRunnerConfig:
|
||||
bot_token=bot_token,
|
||||
model=args.model,
|
||||
thinking_level=args.thinking_level,
|
||||
fast_mode=getattr(args, "fast_mode", False),
|
||||
auto_fix_enabled=getattr(args, "auto_fix_enabled", False),
|
||||
auto_fix_labels=getattr(args, "auto_fix_labels", ["auto-fix"]),
|
||||
auto_post_reviews=getattr(args, "auto_post", False),
|
||||
@@ -688,8 +690,12 @@ 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)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fast-mode",
|
||||
action="store_true",
|
||||
help="Enable Fast Mode for faster Opus 4.6 output",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
@@ -796,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)
|
||||
|
||||
@@ -31,7 +31,11 @@ from claude_agent_sdk import AgentDefinition
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget, resolve_model_id
|
||||
from ...phase_config import (
|
||||
get_model_betas,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
from ..context_gatherer import _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
@@ -62,7 +66,11 @@ except (ImportError, ValueError, SystemError):
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from phase_config import get_thinking_budget, resolve_model_id
|
||||
from phase_config import (
|
||||
get_model_betas,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
from services.agent_utils import create_working_dir_injector
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
@@ -517,12 +525,13 @@ The SDK will run invoked agents in parallel automatically.
|
||||
# Resolve model shorthand via environment variable override if configured
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
betas = get_model_betas(model_shorthand)
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(model, thinking_level)
|
||||
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Using model={model}, "
|
||||
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
|
||||
f"thinking_level={thinking_level}, thinking_kwargs={thinking_kwargs}"
|
||||
)
|
||||
|
||||
# Create client with subagents defined (using worktree path)
|
||||
@@ -531,12 +540,14 @@ The SDK will run invoked agents in parallel automatically.
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_followup_parallel",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
agents=self._define_specialist_agents(project_root),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": ParallelFollowupResponse.model_json_schema(),
|
||||
},
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
|
||||
@@ -33,7 +33,12 @@ from claude_agent_sdk import AgentDefinition # noqa: F401
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget, resolve_model_id
|
||||
from ...phase_config import (
|
||||
get_model_betas,
|
||||
get_thinking_budget,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
from ..context_gatherer import PRContext, _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
@@ -69,7 +74,12 @@ except (ImportError, ValueError, SystemError):
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from phase_config import get_thinking_budget, resolve_model_id
|
||||
from phase_config import (
|
||||
get_model_betas,
|
||||
get_thinking_budget,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
from services.agent_utils import create_working_dir_injector
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
@@ -498,16 +508,23 @@ Report findings with specific file paths, line numbers, and code evidence.
|
||||
# Note: Agent type uses the generic "pr_reviewer" since individual
|
||||
# specialist types aren't registered in AGENT_CONFIGS. The specialist-specific
|
||||
# system prompt handles differentiation.
|
||||
# Get betas from model shorthand (before resolution to full ID)
|
||||
betas = get_model_betas(self.config.model or "sonnet")
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(
|
||||
model, self.config.thinking_level or "medium"
|
||||
)
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": SpecialistResponse.model_json_schema(),
|
||||
},
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
async with client:
|
||||
@@ -793,17 +810,24 @@ The SDK will run invoked agents in parallel automatically.
|
||||
Returns:
|
||||
Configured SDK client instance
|
||||
"""
|
||||
# Get betas from model shorthand (before resolution to full ID)
|
||||
betas = get_model_betas(self.config.model or "sonnet")
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(
|
||||
model, self.config.thinking_level or "medium"
|
||||
)
|
||||
return create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_orchestrator_parallel",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
agents=self._define_specialist_agents(project_root),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": ParallelOrchestratorResponse.model_json_schema(),
|
||||
},
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
def _extract_structured_output(
|
||||
@@ -1718,16 +1742,21 @@ For EACH finding above:
|
||||
|
||||
# Create validator client (inherits worktree filesystem access)
|
||||
try:
|
||||
# Get betas from model shorthand (before resolution to full ID)
|
||||
betas = get_model_betas(self.config.model or "sonnet")
|
||||
thinking_kwargs = get_thinking_kwargs_for_model(model, "medium")
|
||||
validator_client = create_client(
|
||||
project_dir=worktree_path,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_finding_validator",
|
||||
max_thinking_tokens=get_thinking_budget("medium"),
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FindingValidationResponse.model_json_schema(),
|
||||
},
|
||||
**thinking_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Failed to create validator client: {e}")
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from ...phase_config import resolve_model_id
|
||||
from ...phase_config import get_model_betas, resolve_model_id
|
||||
from ..context_gatherer import PRContext
|
||||
from ..models import (
|
||||
AICommentTriage,
|
||||
@@ -34,7 +34,7 @@ except (ImportError, ValueError, SystemError):
|
||||
ReviewPass,
|
||||
StructuralIssue,
|
||||
)
|
||||
from phase_config import resolve_model_id
|
||||
from phase_config import get_model_betas, resolve_model_id
|
||||
from services.io_utils import safe_print
|
||||
from services.prompt_manager import PromptManager
|
||||
from services.response_parsers import ResponseParser
|
||||
@@ -222,12 +222,16 @@ class PRReviewEngine:
|
||||
)
|
||||
|
||||
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
|
||||
model = resolve_model_id(self.config.model or "sonnet")
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
betas = get_model_betas(model_shorthand)
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
@@ -487,12 +491,16 @@ class PRReviewEngine:
|
||||
)
|
||||
|
||||
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
|
||||
model = resolve_model_id(self.config.model or "sonnet")
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
betas = get_model_betas(model_shorthand)
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
@@ -547,12 +555,16 @@ class PRReviewEngine:
|
||||
)
|
||||
|
||||
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
|
||||
model = resolve_model_id(self.config.model or "sonnet")
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
betas = get_model_betas(model_shorthand)
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
|
||||
@@ -34,41 +34,18 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VerificationEvidence(BaseModel):
|
||||
"""Evidence that a finding was verified against actual code.
|
||||
|
||||
All fields are required - schema enforcement guarantees evidence exists.
|
||||
This shifts quality control from programmatic filters to schema enforcement.
|
||||
"""
|
||||
"""Evidence that a finding was verified against actual code."""
|
||||
|
||||
code_examined: str = Field(
|
||||
min_length=1,
|
||||
description=(
|
||||
"REQUIRED: Exact code snippet that was examined. "
|
||||
"Must be actual code from the file, not a description of code. "
|
||||
"Copy-paste the relevant lines directly."
|
||||
),
|
||||
description="Code snippet that was examined to verify the finding",
|
||||
)
|
||||
line_range_examined: list[int] = Field(
|
||||
min_length=2,
|
||||
max_length=2,
|
||||
description=(
|
||||
"Start and end line numbers [start, end] of the examined code. "
|
||||
"Must match the code in code_examined."
|
||||
),
|
||||
default_factory=list,
|
||||
description="Start and end line numbers [start, end] of the examined code",
|
||||
)
|
||||
verification_method: Literal[
|
||||
"direct_code_inspection",
|
||||
"cross_file_trace",
|
||||
"test_verification",
|
||||
"dependency_analysis",
|
||||
] = Field(
|
||||
description=(
|
||||
"How the issue was verified: "
|
||||
"direct_code_inspection = found issue directly in the code shown; "
|
||||
"cross_file_trace = traced through imports/calls to find the issue; "
|
||||
"test_verification = verified through examination of test code; "
|
||||
"dependency_analysis = verified through analyzing dependencies"
|
||||
)
|
||||
verification_method: str = Field(
|
||||
default="direct_code_inspection",
|
||||
description="How the issue was verified (e.g. direct_code_inspection, cross_file_trace, test_verification)",
|
||||
)
|
||||
|
||||
|
||||
@@ -630,22 +607,17 @@ class ResolutionVerification(BaseModel):
|
||||
Field(description="Resolution status after AI verification")
|
||||
)
|
||||
evidence: str = Field(
|
||||
min_length=1,
|
||||
description="Actual code snippet showing the resolution status. Required.",
|
||||
)
|
||||
resolution_notes: str | None = Field(
|
||||
None, description="Detailed notes on how the issue was addressed"
|
||||
description="Code snippet or explanation showing the resolution status",
|
||||
)
|
||||
|
||||
|
||||
class ParallelFollowupFinding(BaseModel):
|
||||
"""A finding from parallel follow-up review with source agent tracking."""
|
||||
"""A finding from parallel follow-up review."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line for multi-line issues")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
@@ -659,99 +631,46 @@ class ParallelFollowupFinding(BaseModel):
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
evidence: str | None = Field(
|
||||
None,
|
||||
description="DEPRECATED: Use verification.code_examined instead. Will be removed in Phase 5.",
|
||||
)
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
source_agent: str = Field(
|
||||
description="Which agent reported this finding (resolution/newcode/comment)"
|
||||
)
|
||||
related_to_previous: str | None = Field(
|
||||
None, description="ID of related previous finding if this is a regression"
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"True if this finding is about impact on OTHER files (callers, dependents) "
|
||||
"outside the PR's changed files. Used by _is_finding_in_scope() to allow "
|
||||
"findings about related files that aren't directly in the PR diff."
|
||||
),
|
||||
description="True if this finding is about impact on OTHER files outside the PR diff",
|
||||
)
|
||||
|
||||
|
||||
class CommentAnalysis(BaseModel):
|
||||
"""Analysis of a contributor or AI comment."""
|
||||
|
||||
comment_id: str = Field(description="Identifier for the comment")
|
||||
author: str = Field(description="Comment author")
|
||||
is_ai_bot: bool = Field(description="Whether this is from an AI tool")
|
||||
requires_response: bool = Field(description="Whether this comment needs a response")
|
||||
sentiment: Literal["question", "concern", "suggestion", "praise", "neutral"] = (
|
||||
Field(description="Comment sentiment/type")
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the comment")
|
||||
action_needed: str | None = Field(None, description="What action is needed if any")
|
||||
|
||||
|
||||
class ParallelFollowupResponse(BaseModel):
|
||||
"""Complete response schema for parallel follow-up PR review."""
|
||||
"""Complete response schema for parallel follow-up PR review.
|
||||
|
||||
Simplified schema — only fields that are consumed downstream are included.
|
||||
Removing unused fields reduces schema size and validation failure rate.
|
||||
"""
|
||||
|
||||
# Analysis metadata
|
||||
analysis_summary: str = Field(
|
||||
description="Brief summary of what was analyzed in this follow-up"
|
||||
)
|
||||
agents_invoked: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of agent names that were invoked",
|
||||
)
|
||||
commits_analyzed: int = Field(0, description="Number of new commits analyzed")
|
||||
files_changed: int = Field(
|
||||
0, description="Number of files changed since last review"
|
||||
)
|
||||
|
||||
# Resolution verification (from resolution-verifier agent)
|
||||
resolution_verifications: list[ResolutionVerification] = Field(
|
||||
default_factory=list,
|
||||
description="AI-verified resolution status for each previous finding",
|
||||
description="Resolution status for each previous finding",
|
||||
)
|
||||
|
||||
# Finding validations (from finding-validator agent)
|
||||
finding_validations: list[FindingValidationResult] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Re-investigation results for unresolved findings. "
|
||||
"Validates whether findings are real issues or false positives."
|
||||
),
|
||||
description="Re-investigation results for unresolved findings",
|
||||
)
|
||||
|
||||
# New findings (from new-code-reviewer agent)
|
||||
new_findings: list[ParallelFollowupFinding] = Field(
|
||||
default_factory=list,
|
||||
description="New issues found in changes since last review",
|
||||
)
|
||||
|
||||
# Comment analysis (from comment-analyzer agent)
|
||||
comment_analyses: list[CommentAnalysis] = Field(
|
||||
default_factory=list,
|
||||
description="Analysis of contributor and AI comments",
|
||||
)
|
||||
comment_findings: list[ParallelFollowupFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Issues identified from comment analysis",
|
||||
)
|
||||
|
||||
# Agent agreement tracking
|
||||
agent_agreement: AgentAgreement = Field(
|
||||
default_factory=AgentAgreement,
|
||||
description="Information about agent agreement on findings",
|
||||
)
|
||||
|
||||
# Verdict
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
@@ -764,53 +683,17 @@ class ParallelFollowupResponse(BaseModel):
|
||||
|
||||
|
||||
class FindingValidationResult(BaseModel):
|
||||
"""
|
||||
Result of re-investigating an unresolved finding to validate it's actually real.
|
||||
|
||||
The finding-validator agent uses this to report whether a previous finding
|
||||
is a genuine issue or a false positive that should be dismissed.
|
||||
|
||||
EVIDENCE-BASED VALIDATION: No confidence scores - validation is binary.
|
||||
Either the evidence shows the issue exists, or it doesn't.
|
||||
"""
|
||||
"""Result of re-investigating an unresolved finding to determine if it's real."""
|
||||
|
||||
finding_id: str = Field(description="ID of the finding being validated")
|
||||
validation_status: Literal[
|
||||
"confirmed_valid", "dismissed_false_positive", "needs_human_review"
|
||||
] = Field(
|
||||
description=(
|
||||
"Validation result: "
|
||||
"confirmed_valid = code evidence proves issue IS real; "
|
||||
"dismissed_false_positive = code evidence proves issue does NOT exist; "
|
||||
"needs_human_review = cannot find definitive evidence either way"
|
||||
)
|
||||
)
|
||||
] = Field(description="Whether the finding is real, a false positive, or unclear")
|
||||
code_evidence: str = Field(
|
||||
min_length=1,
|
||||
description=(
|
||||
"REQUIRED: Exact code snippet examined from the file. "
|
||||
"Must be actual code copy-pasted from the file, not a description. "
|
||||
"This is the proof that determines the validation status."
|
||||
),
|
||||
)
|
||||
line_range: list[int] = Field(
|
||||
min_length=2,
|
||||
max_length=2,
|
||||
description="Start and end line numbers of the examined code [start, end]",
|
||||
description="Code snippet examined that supports the validation status",
|
||||
)
|
||||
explanation: str = Field(
|
||||
min_length=20,
|
||||
description=(
|
||||
"Detailed explanation connecting the code_evidence to the validation_status. "
|
||||
"Must explain: (1) what the original finding claimed, (2) what the actual code shows, "
|
||||
"(3) why this proves/disproves the issue."
|
||||
),
|
||||
)
|
||||
evidence_verified_in_file: bool = Field(
|
||||
description=(
|
||||
"True if the code_evidence was verified to exist at the specified line_range. "
|
||||
"False if the code couldn't be found (indicates hallucination in original finding)."
|
||||
)
|
||||
description="Why this finding was confirmed, dismissed, or flagged for human review",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,18 +15,18 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from ...analysis.test_discovery import TestDiscovery
|
||||
from ...core.client import create_client
|
||||
from ..context_gatherer import PRContext
|
||||
from ..models import PRReviewFinding, ReviewSeverity
|
||||
from .category_utils import map_category
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from analysis.test_discovery import TestDiscovery
|
||||
from category_utils import map_category
|
||||
from context_gatherer import PRContext
|
||||
from core.client import create_client
|
||||
from models import PRReviewFinding, ReviewSeverity
|
||||
|
||||
# TestDiscovery was removed - tests are now co-located in their respective modules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ async def spawn_security_review(
|
||||
project_dir: Path,
|
||||
github_dir: Path,
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
betas: list[str] | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> list[PRReviewFinding]:
|
||||
"""
|
||||
Spawn a focused security review subagent for specific files.
|
||||
@@ -129,6 +131,8 @@ async def spawn_security_review(
|
||||
spec_dir=github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
betas=betas or [],
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
|
||||
# Run review session
|
||||
@@ -164,6 +168,8 @@ async def spawn_quality_review(
|
||||
project_dir: Path,
|
||||
github_dir: Path,
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
betas: list[str] | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> list[PRReviewFinding]:
|
||||
"""
|
||||
Spawn a focused code quality review subagent for specific files.
|
||||
@@ -215,6 +221,8 @@ async def spawn_quality_review(
|
||||
spec_dir=github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
betas=betas or [],
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
@@ -246,6 +254,8 @@ async def spawn_deep_analysis(
|
||||
project_dir: Path,
|
||||
github_dir: Path,
|
||||
model: str = "claude-sonnet-4-5-20250929",
|
||||
betas: list[str] | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> list[PRReviewFinding]:
|
||||
"""
|
||||
Spawn a deep analysis subagent to investigate a specific concern.
|
||||
@@ -310,6 +320,8 @@ Output findings in JSON format:
|
||||
spec_dir=github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
betas=betas or [],
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
@@ -355,48 +367,58 @@ async def run_tests(
|
||||
"""
|
||||
logger.info("[Orchestrator] Running tests...")
|
||||
|
||||
# Determine test command based on project configuration
|
||||
# Try common test commands in order of preference
|
||||
test_commands = [
|
||||
"pytest --cov=.", # Python with coverage
|
||||
"pytest", # Python
|
||||
"npm test", # Node.js
|
||||
"npm run test", # Node.js (script form)
|
||||
"python -m pytest", # Python alternative
|
||||
]
|
||||
|
||||
try:
|
||||
# Discover test framework
|
||||
discovery = TestDiscovery()
|
||||
test_info = discovery.discover(project_dir)
|
||||
|
||||
if not test_info.has_tests:
|
||||
logger.warning("[Orchestrator] No tests found")
|
||||
return TestResult(executed=False, passed=False, error="No tests found")
|
||||
|
||||
# Get test command
|
||||
test_cmd = test_info.test_command
|
||||
if not test_cmd:
|
||||
return TestResult(
|
||||
executed=False, passed=False, error="No test command available"
|
||||
# Execute tests with timeout - try common commands
|
||||
for test_cmd in test_commands:
|
||||
logger.info(f"[Orchestrator] Attempting: {test_cmd}")
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
test_cmd,
|
||||
cwd=project_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# Execute tests with timeout
|
||||
logger.info(f"[Orchestrator] Executing: {test_cmd}")
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
test_cmd,
|
||||
cwd=project_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=300.0, # 5 min max
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("[Orchestrator] Tests timed out after 5 minutes")
|
||||
proc.kill()
|
||||
return TestResult(executed=True, passed=False, error="Timeout after 5min")
|
||||
|
||||
passed = proc.returncode == 0
|
||||
logger.info(f"[Orchestrator] Tests {'passed' if passed else 'failed'}")
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=300.0, # 5 min max
|
||||
)
|
||||
# If command not found (127) or not executable (126), try next command
|
||||
# For any other exit code (including test failures), the test framework exists
|
||||
if proc.returncode in (126, 127):
|
||||
# Command not found or not executable - try next one
|
||||
continue
|
||||
# Test ran (may have passed or failed) - return result
|
||||
passed = proc.returncode == 0
|
||||
logger.info(f"[Orchestrator] Tests {'passed' if passed else 'failed'}")
|
||||
return TestResult(
|
||||
executed=True,
|
||||
passed=passed,
|
||||
error=None if passed else stderr.decode("utf-8")[:500],
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Command timed out - kill it and try next command
|
||||
proc.kill()
|
||||
await proc.wait() # Ensure process is fully terminated
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
# Command not found - try next one
|
||||
continue
|
||||
|
||||
# If no test command worked
|
||||
logger.warning("[Orchestrator] No test command could be executed")
|
||||
return TestResult(
|
||||
executed=True,
|
||||
passed=passed,
|
||||
error=None if passed else stderr.decode("utf-8")[:500],
|
||||
executed=False, passed=False, error="No test command available"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -40,6 +40,11 @@ def _short_model_name(model: str | None) -> str:
|
||||
model_lower = model.lower()
|
||||
|
||||
# Handle new model naming (claude-{model}-{version}-{date})
|
||||
# Check 1M context variant first (more specific match)
|
||||
if "opus-4-6-1m" in model_lower or "opus-4.6-1m" in model_lower:
|
||||
return "opus-4.6-1m"
|
||||
if "opus-4-6" in model_lower or "opus-4.6" in model_lower:
|
||||
return "opus-4.6"
|
||||
if "opus-4-5" in model_lower or "opus-4.5" in model_lower:
|
||||
return "opus-4.5"
|
||||
if "sonnet-4-5" in model_lower or "sonnet-4.5" in model_lower:
|
||||
@@ -128,6 +133,52 @@ def _get_tool_detail(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
# Prevents runaway retry loops from consuming unbounded resources
|
||||
MAX_MESSAGE_COUNT = 500
|
||||
|
||||
# Abort after 1 consecutive repeat (2 total identical responses).
|
||||
# Low threshold catches error loops quickly (e.g., auth errors returned as AI text).
|
||||
# Normal AI responses never produce the exact same text block twice in a row.
|
||||
REPEATED_RESPONSE_THRESHOLD = 1
|
||||
|
||||
# Max length for auth error detection - real auth errors are short (~1-2 sentences).
|
||||
# Longer texts are likely AI discussion about auth topics, not actual errors.
|
||||
MAX_AUTH_ERROR_LENGTH = 300
|
||||
|
||||
|
||||
def _is_auth_error_response(text: str) -> bool:
|
||||
"""
|
||||
Detect authentication/access error messages returned as AI response text.
|
||||
|
||||
Some API errors are returned as conversational text rather than HTTP errors,
|
||||
causing the SDK to treat them as normal assistant responses. This leads to
|
||||
infinite retry loops as the conversation ping-pongs between prompts and
|
||||
error responses.
|
||||
|
||||
Real auth error responses are short messages (~1-2 sentences). AI discussion
|
||||
text that merely mentions auth topics (e.g., PR reviews about auth features)
|
||||
is much longer. We skip texts over MAX_AUTH_ERROR_LENGTH chars to avoid
|
||||
false positives.
|
||||
|
||||
Args:
|
||||
text: AI response text to check
|
||||
|
||||
Returns:
|
||||
True if the text is an auth/access error, False otherwise
|
||||
"""
|
||||
text_lower = text.lower().strip()
|
||||
# Real auth error responses are short messages, not long AI discussions.
|
||||
# Skip texts longer than MAX_AUTH_ERROR_LENGTH to avoid false positives
|
||||
# when AI discusses authentication topics (e.g., reviewing a PR about auth).
|
||||
if len(text_lower) > MAX_AUTH_ERROR_LENGTH:
|
||||
return False
|
||||
auth_error_patterns = [
|
||||
"please login again",
|
||||
# Catches both "does not have access to claude" and partial variants.
|
||||
# "account does not have access" was intentionally excluded — it's too
|
||||
# broad and can match short AI responses about access control generally.
|
||||
# Generic error loops are caught by REPEATED_RESPONSE_THRESHOLD instead.
|
||||
"not have access to claude",
|
||||
]
|
||||
return any(pattern in text_lower for pattern in auth_error_patterns)
|
||||
|
||||
|
||||
def _is_tool_concurrency_error(text: str) -> bool:
|
||||
"""
|
||||
@@ -221,6 +272,9 @@ async def process_sdk_stream(
|
||||
completed_agent_tool_ids: set[str] = set() # tool_ids of completed agents
|
||||
# Track tool concurrency errors for retry logic
|
||||
detected_concurrency_error = False
|
||||
# Track repeated identical responses to detect error loops early
|
||||
last_response_text: str | None = None
|
||||
repeated_response_count = 0
|
||||
|
||||
# Circuit breaker: max messages before aborting
|
||||
message_limit = max_messages if max_messages is not None else MAX_MESSAGE_COUNT
|
||||
@@ -239,6 +293,10 @@ async def process_sdk_stream(
|
||||
msg_type = type(msg).__name__
|
||||
msg_count += 1
|
||||
|
||||
# Check if a previous iteration set stream_error (e.g., auth error in text block)
|
||||
if stream_error:
|
||||
break
|
||||
|
||||
# CIRCUIT BREAKER: Abort if message count exceeds threshold
|
||||
# This prevents runaway retry loops (e.g., 400 errors causing infinite retries)
|
||||
if msg_count > message_limit:
|
||||
@@ -423,6 +481,40 @@ async def process_sdk_stream(
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Check for auth/access error returned as AI response text.
|
||||
# Note: break exits this inner for-loop over msg.content;
|
||||
# the outer message loop exits via `if stream_error: break`.
|
||||
if _is_auth_error_response(block.text):
|
||||
stream_error = (
|
||||
f"Authentication error detected in AI response: "
|
||||
f"{block.text[:200].strip()}"
|
||||
)
|
||||
logger.error(f"[{context_name}] {stream_error}")
|
||||
safe_print(f"[{context_name}] ERROR: {stream_error}")
|
||||
break
|
||||
# Check for repeated identical responses (error loop detection).
|
||||
# Skip empty text blocks so they don't reset the counter.
|
||||
_stripped = block.text.strip()
|
||||
if _stripped:
|
||||
if _stripped == last_response_text:
|
||||
repeated_response_count += 1
|
||||
if (
|
||||
repeated_response_count
|
||||
>= REPEATED_RESPONSE_THRESHOLD
|
||||
):
|
||||
stream_error = (
|
||||
f"Repeated response loop detected: same response "
|
||||
f"received {repeated_response_count + 1} times in a row. "
|
||||
f"Response: {_stripped[:200]}"
|
||||
)
|
||||
logger.error(f"[{context_name}] {stream_error}")
|
||||
safe_print(
|
||||
f"[{context_name}] ERROR: {stream_error}"
|
||||
)
|
||||
break
|
||||
else:
|
||||
last_response_text = _stripped
|
||||
repeated_response_count = 0
|
||||
# Check for tool concurrency error pattern in text output
|
||||
if _is_tool_concurrency_error(block.text):
|
||||
detected_concurrency_error = True
|
||||
|
||||
@@ -10,13 +10,13 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from ...phase_config import resolve_model_id
|
||||
from ...phase_config import get_model_betas, resolve_model_id
|
||||
from ..models import GitHubRunnerConfig, TriageCategory, TriageResult
|
||||
from .prompt_manager import PromptManager
|
||||
from .response_parsers import ResponseParser
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import GitHubRunnerConfig, TriageCategory, TriageResult
|
||||
from phase_config import resolve_model_id
|
||||
from phase_config import get_model_betas, resolve_model_id
|
||||
from services.prompt_manager import PromptManager
|
||||
from services.response_parsers import ResponseParser
|
||||
|
||||
@@ -74,12 +74,16 @@ class TriageEngine:
|
||||
|
||||
# Run AI
|
||||
# Resolve model shorthand (e.g., "sonnet") to full model ID for API compatibility
|
||||
model = resolve_model_id(self.config.model or "sonnet")
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
betas = get_model_betas(model_shorthand)
|
||||
client = create_client(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="qa_reviewer",
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,707 +0,0 @@
|
||||
"""
|
||||
Tests for Bot Detection Module
|
||||
================================
|
||||
|
||||
Tests the BotDetector class to ensure it correctly prevents infinite loops.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Use direct file import to avoid package import issues
|
||||
_github_dir = Path(__file__).parent
|
||||
if str(_github_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_dir))
|
||||
|
||||
from bot_detection import BotDetectionState, BotDetector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_state_dir(tmp_path):
|
||||
"""Create temporary state directory."""
|
||||
state_dir = tmp_path / "github"
|
||||
state_dir.mkdir()
|
||||
return state_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bot_detector(temp_state_dir):
|
||||
"""Create bot detector with mocked bot username."""
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"):
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
return detector
|
||||
|
||||
|
||||
class TestBotDetectionState:
|
||||
"""Test BotDetectionState data class."""
|
||||
|
||||
def test_save_and_load(self, temp_state_dir):
|
||||
"""Test saving and loading state."""
|
||||
state = BotDetectionState(
|
||||
reviewed_commits={
|
||||
"123": ["abc123", "def456"],
|
||||
"456": ["ghi789"],
|
||||
},
|
||||
last_review_times={
|
||||
"123": "2025-01-01T10:00:00",
|
||||
"456": "2025-01-01T11:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
# Save
|
||||
state.save(temp_state_dir)
|
||||
|
||||
# Load
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
|
||||
assert loaded.reviewed_commits == state.reviewed_commits
|
||||
assert loaded.last_review_times == state.last_review_times
|
||||
|
||||
def test_load_nonexistent(self, temp_state_dir):
|
||||
"""Test loading when file doesn't exist."""
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
|
||||
assert loaded.reviewed_commits == {}
|
||||
assert loaded.last_review_times == {}
|
||||
|
||||
|
||||
class TestBotDetectorInit:
|
||||
"""Test BotDetector initialization."""
|
||||
|
||||
def test_init_with_token(self, temp_state_dir):
|
||||
"""Test initialization with bot token."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"login": "my-bot"}),
|
||||
)
|
||||
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="ghp_test123",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
assert detector.bot_username == "my-bot"
|
||||
assert detector.review_own_prs is False
|
||||
|
||||
def test_init_without_token(self, temp_state_dir):
|
||||
"""Test initialization without bot token."""
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token=None,
|
||||
review_own_prs=True,
|
||||
)
|
||||
|
||||
assert detector.bot_username is None
|
||||
assert detector.review_own_prs is True
|
||||
|
||||
|
||||
class TestBotDetection:
|
||||
"""Test bot detection methods."""
|
||||
|
||||
def test_is_bot_pr(self, mock_bot_detector):
|
||||
"""Test detecting bot-authored PRs."""
|
||||
bot_pr = {"author": {"login": "test-bot"}}
|
||||
human_pr = {"author": {"login": "alice"}}
|
||||
|
||||
assert mock_bot_detector.is_bot_pr(bot_pr) is True
|
||||
assert mock_bot_detector.is_bot_pr(human_pr) is False
|
||||
|
||||
def test_is_bot_commit(self, mock_bot_detector):
|
||||
"""Test detecting bot-authored commits."""
|
||||
bot_commit = {"author": {"login": "test-bot"}}
|
||||
human_commit = {"author": {"login": "alice"}}
|
||||
bot_committer = {
|
||||
"committer": {"login": "test-bot"},
|
||||
"author": {"login": "alice"},
|
||||
}
|
||||
|
||||
assert mock_bot_detector.is_bot_commit(bot_commit) is True
|
||||
assert mock_bot_detector.is_bot_commit(human_commit) is False
|
||||
assert mock_bot_detector.is_bot_commit(bot_committer) is True
|
||||
|
||||
def test_get_last_commit_sha(self, mock_bot_detector):
|
||||
"""Test extracting last commit SHA."""
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit
|
||||
commits = [
|
||||
{"oid": "abc123"}, # Oldest commit
|
||||
{"oid": "def456"}, # Latest commit
|
||||
]
|
||||
|
||||
sha = mock_bot_detector.get_last_commit_sha(commits)
|
||||
assert sha == "def456" # Should return the LAST (latest) commit
|
||||
|
||||
# Test with sha field instead of oid
|
||||
commits_with_sha = [{"sha": "xyz789"}]
|
||||
sha = mock_bot_detector.get_last_commit_sha(commits_with_sha)
|
||||
assert sha == "xyz789"
|
||||
|
||||
# Empty commits
|
||||
assert mock_bot_detector.get_last_commit_sha([]) is None
|
||||
|
||||
|
||||
class TestCoolingOff:
|
||||
"""Test cooling off period.
|
||||
|
||||
Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs.
|
||||
"""
|
||||
|
||||
def test_within_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR within cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
assert is_cooling is True
|
||||
assert "Cooling off" in reason
|
||||
|
||||
def test_outside_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR outside cooling off period."""
|
||||
# Set last review to 2 minutes ago (outside 1 minute cooling off)
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
assert is_cooling is False
|
||||
assert reason == ""
|
||||
|
||||
def test_no_previous_review(self, mock_bot_detector):
|
||||
"""Test PR with no previous review."""
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(999)
|
||||
|
||||
assert is_cooling is False
|
||||
assert reason == ""
|
||||
|
||||
|
||||
class TestReviewedCommits:
|
||||
"""Test reviewed commit tracking."""
|
||||
|
||||
def test_has_reviewed_commit(self, mock_bot_detector):
|
||||
"""Test checking if commit was reviewed."""
|
||||
mock_bot_detector.state.reviewed_commits["123"] = ["abc123", "def456"]
|
||||
|
||||
assert mock_bot_detector.has_reviewed_commit(123, "abc123") is True
|
||||
assert mock_bot_detector.has_reviewed_commit(123, "xyz789") is False
|
||||
assert mock_bot_detector.has_reviewed_commit(999, "abc123") is False
|
||||
|
||||
def test_mark_reviewed(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking PR as reviewed."""
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# Check state
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
|
||||
assert "123" in mock_bot_detector.state.last_review_times
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" in loaded.reviewed_commits
|
||||
assert "abc123" in loaded.reviewed_commits["123"]
|
||||
|
||||
def test_mark_reviewed_multiple(self, mock_bot_detector):
|
||||
"""Test marking same PR reviewed multiple times."""
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
mock_bot_detector.mark_reviewed(123, "def456")
|
||||
|
||||
commits = mock_bot_detector.state.reviewed_commits["123"]
|
||||
assert len(commits) == 2
|
||||
assert "abc123" in commits
|
||||
assert "def456" in commits
|
||||
|
||||
|
||||
class TestShouldSkipReview:
|
||||
"""Test main should_skip_pr_review logic."""
|
||||
|
||||
def test_skip_bot_pr(self, mock_bot_detector):
|
||||
"""Test skipping bot-authored PR."""
|
||||
pr_data = {"author": {"login": "test-bot"}}
|
||||
commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "bot user" in reason
|
||||
|
||||
def test_skip_bot_commit(self, mock_bot_detector):
|
||||
"""Test skipping PR with bot commit as the latest commit."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit - which is the bot commit
|
||||
commits = [
|
||||
{"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice)
|
||||
{
|
||||
"author": {"login": "test-bot"},
|
||||
"oid": "def456",
|
||||
}, # Latest commit (by bot)
|
||||
]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "bot" in reason.lower()
|
||||
|
||||
def test_skip_cooling_off(self, mock_bot_detector):
|
||||
"""Test skipping during cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "Cooling off" in reason
|
||||
|
||||
def test_skip_already_reviewed(self, mock_bot_detector):
|
||||
"""Test skipping already-reviewed commit."""
|
||||
mock_bot_detector.state.reviewed_commits["123"] = ["abc123"]
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "Already reviewed" in reason
|
||||
|
||||
def test_allow_review(self, mock_bot_detector):
|
||||
"""Test allowing review when all checks pass."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is False
|
||||
assert reason == ""
|
||||
|
||||
def test_allow_review_own_prs(self, temp_state_dir):
|
||||
"""Test allowing review when review_own_prs is True."""
|
||||
with patch.object(BotDetector, "_get_bot_username", return_value="test-bot"):
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=True, # Allow bot to review own PRs
|
||||
)
|
||||
|
||||
pr_data = {"author": {"login": "test-bot"}}
|
||||
commits = [{"author": {"login": "test-bot"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
# Should not skip even though it's bot's own PR
|
||||
assert should_skip is False
|
||||
|
||||
|
||||
class TestStateManagement:
|
||||
"""Test state management methods."""
|
||||
|
||||
def test_clear_pr_state(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test clearing PR state."""
|
||||
# Set up state
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
mock_bot_detector.mark_reviewed(456, "def456")
|
||||
|
||||
# Clear one PR
|
||||
mock_bot_detector.clear_pr_state(123)
|
||||
|
||||
# Check in-memory state
|
||||
assert "123" not in mock_bot_detector.state.reviewed_commits
|
||||
assert "123" not in mock_bot_detector.state.last_review_times
|
||||
assert "456" in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" not in loaded.reviewed_commits
|
||||
assert "456" in loaded.reviewed_commits
|
||||
|
||||
def test_get_stats(self, mock_bot_detector):
|
||||
"""Test getting detector statistics."""
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
mock_bot_detector.mark_reviewed(123, "def456")
|
||||
mock_bot_detector.mark_reviewed(456, "ghi789")
|
||||
|
||||
stats = mock_bot_detector.get_stats()
|
||||
|
||||
assert stats["bot_username"] == "test-bot"
|
||||
assert stats["review_own_prs"] is False
|
||||
assert stats["total_prs_tracked"] == 2
|
||||
assert stats["total_reviews_performed"] == 3
|
||||
assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
|
||||
def test_no_commits(self, mock_bot_detector):
|
||||
"""Test handling PR with no commits."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = []
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
# Should not skip (no bot commit to detect)
|
||||
assert should_skip is False
|
||||
|
||||
def test_malformed_commit_data(self, mock_bot_detector):
|
||||
"""Test handling malformed commit data."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [
|
||||
{"author": {"login": "alice"}}, # Missing oid/sha
|
||||
{}, # Empty commit
|
||||
]
|
||||
|
||||
# Should not crash
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is False
|
||||
|
||||
def test_invalid_last_review_time(self, mock_bot_detector):
|
||||
"""Test handling invalid timestamp in state."""
|
||||
mock_bot_detector.state.last_review_times["123"] = "invalid-timestamp"
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
# Should not crash, should return False
|
||||
assert is_cooling is False
|
||||
|
||||
|
||||
class TestGhExecutableDetection:
|
||||
"""Test gh executable detection in bot_detector._get_bot_username."""
|
||||
|
||||
def test_get_bot_username_with_gh_not_found(self, temp_state_dir):
|
||||
"""Test _get_bot_username when gh CLI is not found."""
|
||||
with patch("bot_detection.get_gh_executable", return_value=None):
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
# Should not crash, username should be None
|
||||
assert detector.bot_username is None
|
||||
|
||||
def test_get_bot_username_with_detected_gh(self, temp_state_dir):
|
||||
"""Test _get_bot_username when gh CLI is found."""
|
||||
mock_gh_path = str(temp_state_dir / "gh")
|
||||
with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"login": "test-bot-user"}),
|
||||
)
|
||||
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
# Should use the detected gh path
|
||||
assert detector.bot_username == "test-bot-user"
|
||||
|
||||
# Verify subprocess was called with the correct gh path
|
||||
mock_run.assert_called_once()
|
||||
called_cmd_list = mock_run.call_args[0][0]
|
||||
assert called_cmd_list[0] == mock_gh_path
|
||||
assert called_cmd_list[1:] == ["api", "user"]
|
||||
|
||||
def test_get_bot_username_uses_get_gh_executable_return_value(self, temp_state_dir):
|
||||
"""Test that _get_bot_username uses the path returned by get_gh_executable."""
|
||||
# Note: GITHUB_CLI_PATH env var is tested by get_gh_executable's own tests
|
||||
# This test verifies _get_bot_username uses whatever get_gh_executable returns
|
||||
mock_gh_path = str(temp_state_dir / "gh")
|
||||
|
||||
with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"login": "env-bot-user"}),
|
||||
)
|
||||
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
# Verify the command was run with the path from get_gh_executable
|
||||
assert detector.bot_username == "env-bot-user"
|
||||
|
||||
# Verify subprocess was called with the correct path
|
||||
mock_run.assert_called_once()
|
||||
called_cmd_list = mock_run.call_args[0][0]
|
||||
assert called_cmd_list[0] == mock_gh_path
|
||||
assert called_cmd_list[1:] == ["api", "user"]
|
||||
|
||||
def test_get_bot_username_with_api_error(self, temp_state_dir):
|
||||
"""Test _get_bot_username when gh api command fails."""
|
||||
mock_gh_path = str(temp_state_dir / "gh")
|
||||
with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stderr="Authentication failed",
|
||||
)
|
||||
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="invalid-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
# Should handle error gracefully, username should be None
|
||||
assert detector.bot_username is None
|
||||
|
||||
def test_get_bot_username_with_subprocess_timeout(self, temp_state_dir):
|
||||
"""Test _get_bot_username when subprocess times out."""
|
||||
mock_gh_path = str(temp_state_dir / "gh")
|
||||
with patch("bot_detection.get_gh_executable", return_value=mock_gh_path):
|
||||
with patch(
|
||||
"subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 5)
|
||||
):
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token="fake-token",
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
# Should handle timeout gracefully, username should be None
|
||||
assert detector.bot_username is None
|
||||
|
||||
def test_get_bot_username_without_token(self, temp_state_dir):
|
||||
"""Test _get_bot_username when no bot token is provided."""
|
||||
with patch("subprocess.run") as mock_run:
|
||||
detector = BotDetector(
|
||||
state_dir=temp_state_dir,
|
||||
bot_token=None,
|
||||
review_own_prs=False,
|
||||
)
|
||||
|
||||
# Should return None without trying to call gh
|
||||
assert detector.bot_username is None
|
||||
# Verify subprocess.run was not called (no gh CLI invocation)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestInProgressTracking:
|
||||
"""Test in-progress review tracking."""
|
||||
|
||||
def test_mark_review_started(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking review as started."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
# Check state
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
start_time_str = mock_bot_detector.state.in_progress_reviews["123"]
|
||||
start_time = datetime.fromisoformat(start_time_str)
|
||||
|
||||
# Should be very recent (within last 5 seconds)
|
||||
time_diff = datetime.now() - start_time
|
||||
assert time_diff.total_seconds() < 5
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" in loaded.in_progress_reviews
|
||||
|
||||
def test_mark_review_finished_success(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking review as finished successfully."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.mark_review_finished(123, success=True)
|
||||
|
||||
# In-progress state should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" not in loaded.in_progress_reviews
|
||||
|
||||
def test_mark_review_finished_error(self, mock_bot_detector):
|
||||
"""Test marking review as finished with error."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_review_finished(123, success=False)
|
||||
|
||||
# In-progress state should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_is_review_in_progress_active(self, mock_bot_detector):
|
||||
"""Test detecting active in-progress review."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
assert is_in_progress is True
|
||||
assert "already in progress" in reason.lower()
|
||||
|
||||
def test_is_review_in_progress_not_started(self, mock_bot_detector):
|
||||
"""Test checking in-progress when review not started."""
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(999)
|
||||
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
|
||||
def test_is_review_in_progress_stale(self, mock_bot_detector):
|
||||
"""Test detecting stale in-progress review."""
|
||||
# Set review start time to 31 minutes ago (past timeout)
|
||||
stale_time = datetime.now() - timedelta(minutes=31)
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
# Should detect as stale and clear it
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
# Should be removed from state
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_is_review_in_progress_invalid_timestamp(self, mock_bot_detector):
|
||||
"""Test handling invalid timestamp in in-progress state."""
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = "invalid-timestamp"
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
# Should clear invalid state
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_should_skip_review_in_progress(self, mock_bot_detector):
|
||||
"""Test skipping PR when review is in progress."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "already in progress" in reason.lower()
|
||||
|
||||
def test_mark_reviewed_clears_in_progress(self, mock_bot_detector):
|
||||
"""Test that mark_reviewed also clears in-progress state."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# In-progress should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
# Reviewed state should be set
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
|
||||
|
||||
def test_clear_pr_state_clears_in_progress(self, mock_bot_detector):
|
||||
"""Test that clear_pr_state also clears in-progress state."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
assert (
|
||||
"123" in mock_bot_detector.state.in_progress_reviews or True
|
||||
) # May be cleared by mark_reviewed
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
# Start another review
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.clear_pr_state(123)
|
||||
|
||||
# Everything should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
assert "123" not in mock_bot_detector.state.reviewed_commits
|
||||
assert "123" not in mock_bot_detector.state.last_review_times
|
||||
|
||||
def test_get_stats_includes_in_progress(self, mock_bot_detector):
|
||||
"""Test that get_stats includes in-progress count."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_review_started(456)
|
||||
mock_bot_detector.mark_reviewed(789, "abc123")
|
||||
|
||||
stats = mock_bot_detector.get_stats()
|
||||
|
||||
assert stats["in_progress_reviews"] == 2
|
||||
assert stats["total_prs_tracked"] == 1 # Only 789 is tracked as reviewed
|
||||
assert stats["in_progress_timeout_minutes"] == 30
|
||||
|
||||
def test_cleanup_stale_prs_removes_stale_in_progress(self, mock_bot_detector):
|
||||
"""Test that cleanup_stale_prs removes stale in-progress reviews."""
|
||||
# Add a stale in-progress review (32 minutes ago)
|
||||
stale_time = datetime.now() - timedelta(minutes=32)
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
|
||||
|
||||
# Add an active in-progress review (5 minutes ago)
|
||||
active_time = datetime.now() - timedelta(minutes=5)
|
||||
mock_bot_detector.state.in_progress_reviews["456"] = active_time.isoformat()
|
||||
|
||||
# Add a stale reviewed PR (40 days ago)
|
||||
stale_review_time = datetime.now() - timedelta(days=40)
|
||||
mock_bot_detector.state.reviewed_commits["789"] = ["abc123"]
|
||||
mock_bot_detector.state.last_review_times["789"] = stale_review_time.isoformat()
|
||||
|
||||
cleaned = mock_bot_detector.cleanup_stale_prs(max_age_days=30)
|
||||
|
||||
# Should remove stale in-progress and stale reviewed PR
|
||||
assert cleaned == 2 # 1 stale in-progress + 1 stale reviewed
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
assert (
|
||||
"456" in mock_bot_detector.state.in_progress_reviews
|
||||
) # Active one remains
|
||||
assert "789" not in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,213 +0,0 @@
|
||||
"""
|
||||
Unit tests for PR Context Gatherer
|
||||
===================================
|
||||
|
||||
Tests the context gathering functionality without requiring actual GitHub API calls.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from context_gatherer import ChangedFile, PRContext, PRContextGatherer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_basic_pr_context(tmp_path):
|
||||
"""Test gathering basic PR context."""
|
||||
# Create a temporary project directory
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
|
||||
# Mock the subprocess calls
|
||||
pr_metadata = {
|
||||
"number": 123,
|
||||
"title": "Add new feature",
|
||||
"body": "This PR adds a new feature",
|
||||
"author": {"login": "testuser"},
|
||||
"baseRefName": "main",
|
||||
"headRefName": "feature/new-feature",
|
||||
"files": [
|
||||
{
|
||||
"path": "src/app.ts",
|
||||
"status": "modified",
|
||||
"additions": 10,
|
||||
"deletions": 5,
|
||||
}
|
||||
],
|
||||
"additions": 10,
|
||||
"deletions": 5,
|
||||
"changedFiles": 1,
|
||||
"labels": [{"name": "feature"}],
|
||||
}
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
# Mock metadata fetch
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout='{"number": 123, "title": "Add new feature"}'
|
||||
)
|
||||
|
||||
gatherer = PRContextGatherer(project_dir, 123)
|
||||
|
||||
# We can't fully test without real git, but we can verify the structure
|
||||
assert gatherer.pr_number == 123
|
||||
assert gatherer.project_dir == project_dir
|
||||
|
||||
|
||||
def test_normalize_status():
|
||||
"""Test file status normalization."""
|
||||
gatherer = PRContextGatherer(Path("/tmp"), 1)
|
||||
|
||||
assert gatherer._normalize_status("added") == "added"
|
||||
assert gatherer._normalize_status("ADD") == "added"
|
||||
assert gatherer._normalize_status("modified") == "modified"
|
||||
assert gatherer._normalize_status("mod") == "modified"
|
||||
assert gatherer._normalize_status("deleted") == "deleted"
|
||||
assert gatherer._normalize_status("renamed") == "renamed"
|
||||
|
||||
|
||||
def test_find_test_files(tmp_path):
|
||||
"""Test finding related test files."""
|
||||
# Create a project structure
|
||||
project_dir = tmp_path / "project"
|
||||
src_dir = project_dir / "src"
|
||||
src_dir.mkdir(parents=True)
|
||||
|
||||
# Create source file
|
||||
source_file = src_dir / "utils.ts"
|
||||
source_file.write_text("export const add = (a, b) => a + b;", encoding="utf-8")
|
||||
|
||||
# Create test file
|
||||
test_file = src_dir / "utils.test.ts"
|
||||
test_file.write_text("import { add } from './utils';", encoding="utf-8")
|
||||
|
||||
gatherer = PRContextGatherer(project_dir, 1)
|
||||
|
||||
# Find test files for the source file
|
||||
source_path = Path("src/utils.ts")
|
||||
test_files = gatherer._find_test_files(source_path)
|
||||
|
||||
assert "src/utils.test.ts" in test_files
|
||||
|
||||
|
||||
def test_resolve_import_path(tmp_path):
|
||||
"""Test resolving relative import paths."""
|
||||
# Create a project structure
|
||||
project_dir = tmp_path / "project"
|
||||
src_dir = project_dir / "src"
|
||||
src_dir.mkdir(parents=True)
|
||||
|
||||
# Create imported file
|
||||
utils_file = src_dir / "utils.ts"
|
||||
utils_file.write_text("export const helper = () => {};", encoding="utf-8")
|
||||
|
||||
# Create importing file
|
||||
app_file = src_dir / "app.ts"
|
||||
app_file.write_text("import { helper } from './utils';", encoding="utf-8")
|
||||
|
||||
gatherer = PRContextGatherer(project_dir, 1)
|
||||
|
||||
# Resolve import path
|
||||
source_path = Path("src/app.ts")
|
||||
resolved = gatherer._resolve_import_path("./utils", source_path)
|
||||
|
||||
assert resolved == "src/utils.ts"
|
||||
|
||||
|
||||
def test_detect_repo_structure_monorepo(tmp_path):
|
||||
"""Test detecting monorepo structure."""
|
||||
# Create monorepo structure
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
|
||||
apps_dir = project_dir / "apps"
|
||||
apps_dir.mkdir()
|
||||
|
||||
(apps_dir / "frontend").mkdir()
|
||||
(apps_dir / "backend").mkdir()
|
||||
|
||||
# Create package.json with workspaces
|
||||
package_json = project_dir / "package.json"
|
||||
package_json.write_text('{"workspaces": ["apps/*"]}', encoding="utf-8")
|
||||
|
||||
gatherer = PRContextGatherer(project_dir, 1)
|
||||
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Monorepo Apps" in structure
|
||||
assert "frontend" in structure
|
||||
assert "backend" in structure
|
||||
assert "Workspaces" in structure
|
||||
|
||||
|
||||
def test_detect_repo_structure_python(tmp_path):
|
||||
"""Test detecting Python project structure."""
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
|
||||
# Create pyproject.toml
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
pyproject.write_text("[tool.poetry]\nname = 'test'", encoding="utf-8")
|
||||
|
||||
gatherer = PRContextGatherer(project_dir, 1)
|
||||
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Python Project" in structure
|
||||
|
||||
|
||||
def test_find_config_files(tmp_path):
|
||||
"""Test finding configuration files."""
|
||||
project_dir = tmp_path / "project"
|
||||
src_dir = project_dir / "src"
|
||||
src_dir.mkdir(parents=True)
|
||||
|
||||
# Create config files
|
||||
(src_dir / "tsconfig.json").write_text("{}", encoding="utf-8")
|
||||
(src_dir / "package.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
gatherer = PRContextGatherer(project_dir, 1)
|
||||
|
||||
config_files = gatherer._find_config_files(Path("src"))
|
||||
|
||||
assert "src/tsconfig.json" in config_files
|
||||
assert "src/package.json" in config_files
|
||||
|
||||
|
||||
def test_get_file_extension():
|
||||
"""Test file extension mapping for syntax highlighting."""
|
||||
gatherer = PRContextGatherer(Path("/tmp"), 1)
|
||||
|
||||
assert gatherer._get_file_extension("app.ts") == "typescript"
|
||||
assert gatherer._get_file_extension("utils.tsx") == "typescript"
|
||||
assert gatherer._get_file_extension("script.js") == "javascript"
|
||||
assert gatherer._get_file_extension("script.jsx") == "javascript"
|
||||
assert gatherer._get_file_extension("main.py") == "python"
|
||||
assert gatherer._get_file_extension("config.json") == "json"
|
||||
assert gatherer._get_file_extension("readme.md") == "markdown"
|
||||
assert gatherer._get_file_extension("config.yml") == "yaml"
|
||||
|
||||
|
||||
def test_find_imports_typescript(tmp_path):
|
||||
"""Test finding imports in TypeScript code."""
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
|
||||
content = """
|
||||
import { Component } from 'react';
|
||||
import { helper } from './utils';
|
||||
import { config } from '../config';
|
||||
import external from 'lodash';
|
||||
"""
|
||||
|
||||
gatherer = PRContextGatherer(project_dir, 1)
|
||||
source_path = Path("src/app.tsx")
|
||||
|
||||
imports = gatherer._find_imports(content, source_path)
|
||||
|
||||
# Should only include relative imports
|
||||
assert len(imports) >= 0 # Depends on whether files actually exist
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,582 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validation tests for the Enhanced PR Review System.
|
||||
|
||||
These tests validate:
|
||||
1. Model serialization/deserialization
|
||||
2. Verdict generation logic
|
||||
3. Risk assessment calculation
|
||||
4. AI comment parsing
|
||||
5. Structural issue parsing
|
||||
6. Summary generation
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
|
||||
from context_gatherer import AI_BOT_PATTERNS, AIBotComment
|
||||
|
||||
# Direct imports (avoid parent __init__.py issues)
|
||||
from models import (
|
||||
AICommentTriage,
|
||||
AICommentVerdict,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewPass,
|
||||
ReviewSeverity,
|
||||
StructuralIssue,
|
||||
)
|
||||
|
||||
|
||||
def test_merge_verdict_enum():
|
||||
"""Test MergeVerdict enum values."""
|
||||
print("Testing MergeVerdict enum...")
|
||||
|
||||
assert MergeVerdict.READY_TO_MERGE.value == "ready_to_merge"
|
||||
assert MergeVerdict.MERGE_WITH_CHANGES.value == "merge_with_changes"
|
||||
assert MergeVerdict.NEEDS_REVISION.value == "needs_revision"
|
||||
assert MergeVerdict.BLOCKED.value == "blocked"
|
||||
|
||||
# Test string conversion
|
||||
assert MergeVerdict("ready_to_merge") == MergeVerdict.READY_TO_MERGE
|
||||
assert MergeVerdict("blocked") == MergeVerdict.BLOCKED
|
||||
|
||||
print(" ✅ MergeVerdict enum: PASS")
|
||||
|
||||
|
||||
def test_ai_comment_verdict_enum():
|
||||
"""Test AICommentVerdict enum values."""
|
||||
print("Testing AICommentVerdict enum...")
|
||||
|
||||
assert AICommentVerdict.CRITICAL.value == "critical"
|
||||
assert AICommentVerdict.IMPORTANT.value == "important"
|
||||
assert AICommentVerdict.NICE_TO_HAVE.value == "nice_to_have"
|
||||
assert AICommentVerdict.TRIVIAL.value == "trivial"
|
||||
assert AICommentVerdict.FALSE_POSITIVE.value == "false_positive"
|
||||
|
||||
print(" ✅ AICommentVerdict enum: PASS")
|
||||
|
||||
|
||||
def test_review_pass_enum():
|
||||
"""Test ReviewPass enum includes new passes."""
|
||||
print("Testing ReviewPass enum...")
|
||||
|
||||
assert ReviewPass.STRUCTURAL.value == "structural"
|
||||
assert ReviewPass.AI_COMMENT_TRIAGE.value == "ai_comment_triage"
|
||||
|
||||
# Ensure all 6 passes exist
|
||||
passes = [p.value for p in ReviewPass]
|
||||
assert len(passes) == 6
|
||||
assert "quick_scan" in passes
|
||||
assert "security" in passes
|
||||
assert "quality" in passes
|
||||
assert "deep_analysis" in passes
|
||||
assert "structural" in passes
|
||||
assert "ai_comment_triage" in passes
|
||||
|
||||
print(" ✅ ReviewPass enum: PASS")
|
||||
|
||||
|
||||
def test_ai_bot_patterns():
|
||||
"""Test AI bot detection patterns."""
|
||||
print("Testing AI bot patterns...")
|
||||
|
||||
# Check known patterns exist
|
||||
assert "coderabbitai" in AI_BOT_PATTERNS
|
||||
assert "greptile" in AI_BOT_PATTERNS
|
||||
assert "copilot" in AI_BOT_PATTERNS
|
||||
assert "sourcery-ai" in AI_BOT_PATTERNS
|
||||
|
||||
# Check pattern -> name mapping
|
||||
assert AI_BOT_PATTERNS["coderabbitai"] == "CodeRabbit"
|
||||
assert AI_BOT_PATTERNS["greptile"] == "Greptile"
|
||||
assert AI_BOT_PATTERNS["copilot"] == "GitHub Copilot"
|
||||
|
||||
# Check we have a reasonable number of patterns
|
||||
assert len(AI_BOT_PATTERNS) >= 15, (
|
||||
f"Expected at least 15 patterns, got {len(AI_BOT_PATTERNS)}"
|
||||
)
|
||||
|
||||
print(f" ✅ AI bot patterns ({len(AI_BOT_PATTERNS)} patterns): PASS")
|
||||
|
||||
|
||||
def test_ai_bot_comment_dataclass():
|
||||
"""Test AIBotComment dataclass."""
|
||||
print("Testing AIBotComment dataclass...")
|
||||
|
||||
comment = AIBotComment(
|
||||
comment_id=12345,
|
||||
author="coderabbitai[bot]",
|
||||
tool_name="CodeRabbit",
|
||||
body="This function has a potential SQL injection vulnerability.",
|
||||
file="src/db/queries.py",
|
||||
line=42,
|
||||
created_at="2024-01-15T10:30:00Z",
|
||||
)
|
||||
|
||||
assert comment.comment_id == 12345
|
||||
assert comment.tool_name == "CodeRabbit"
|
||||
assert "SQL injection" in comment.body
|
||||
assert comment.file == "src/db/queries.py"
|
||||
assert comment.line == 42
|
||||
|
||||
print(" ✅ AIBotComment dataclass: PASS")
|
||||
|
||||
|
||||
def test_ai_comment_triage_dataclass():
|
||||
"""Test AICommentTriage dataclass."""
|
||||
print("Testing AICommentTriage dataclass...")
|
||||
|
||||
triage = AICommentTriage(
|
||||
comment_id=12345,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="SQL injection vulnerability detected",
|
||||
verdict=AICommentVerdict.CRITICAL,
|
||||
reasoning="Verified - user input is directly concatenated into SQL query",
|
||||
response_comment="✅ Verified: Critical security issue - must fix before merge",
|
||||
)
|
||||
|
||||
assert triage.verdict == AICommentVerdict.CRITICAL
|
||||
assert triage.tool_name == "CodeRabbit"
|
||||
assert "Verified" in triage.reasoning
|
||||
|
||||
print(" ✅ AICommentTriage dataclass: PASS")
|
||||
|
||||
|
||||
def test_structural_issue_dataclass():
|
||||
"""Test StructuralIssue dataclass."""
|
||||
print("Testing StructuralIssue dataclass...")
|
||||
|
||||
issue = StructuralIssue(
|
||||
id="struct-1",
|
||||
issue_type="feature_creep",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
title="PR includes unrelated authentication refactor",
|
||||
description="The PR titled 'Fix payment bug' also refactors auth middleware.",
|
||||
impact="Bundles unrelated changes, harder to review and revert.",
|
||||
suggestion="Split into two PRs: one for payment fix, one for auth refactor.",
|
||||
)
|
||||
|
||||
assert issue.issue_type == "feature_creep"
|
||||
assert issue.severity == ReviewSeverity.HIGH
|
||||
assert "unrelated" in issue.title.lower()
|
||||
|
||||
print(" ✅ StructuralIssue dataclass: PASS")
|
||||
|
||||
|
||||
def test_pr_review_result_new_fields():
|
||||
"""Test PRReviewResult has all new fields."""
|
||||
print("Testing PRReviewResult new fields...")
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=123,
|
||||
repo="owner/repo",
|
||||
success=True,
|
||||
findings=[],
|
||||
summary="Test summary",
|
||||
overall_status="approve",
|
||||
# New fields
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="No blocking issues found",
|
||||
blockers=[],
|
||||
risk_assessment={
|
||||
"complexity": "low",
|
||||
"security_impact": "none",
|
||||
"scope_coherence": "good",
|
||||
},
|
||||
structural_issues=[],
|
||||
ai_comment_triages=[],
|
||||
quick_scan_summary={"purpose": "Test PR", "complexity": "low"},
|
||||
)
|
||||
|
||||
assert result.verdict == MergeVerdict.READY_TO_MERGE
|
||||
assert result.verdict_reasoning == "No blocking issues found"
|
||||
assert result.blockers == []
|
||||
assert result.risk_assessment["complexity"] == "low"
|
||||
assert result.structural_issues == []
|
||||
assert result.ai_comment_triages == []
|
||||
|
||||
print(" ✅ PRReviewResult new fields: PASS")
|
||||
|
||||
|
||||
def test_pr_review_result_serialization():
|
||||
"""Test PRReviewResult serializes and deserializes correctly."""
|
||||
print("Testing PRReviewResult serialization...")
|
||||
|
||||
# Create a complex result
|
||||
finding = PRReviewFinding(
|
||||
id="finding-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection",
|
||||
description="User input not sanitized",
|
||||
file="src/db.py",
|
||||
line=42,
|
||||
)
|
||||
|
||||
structural = StructuralIssue(
|
||||
id="struct-1",
|
||||
issue_type="feature_creep",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
title="Unrelated changes",
|
||||
description="Extra refactoring",
|
||||
impact="Harder to review",
|
||||
suggestion="Split PR",
|
||||
)
|
||||
|
||||
triage = AICommentTriage(
|
||||
comment_id=999,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="Missing null check",
|
||||
verdict=AICommentVerdict.TRIVIAL,
|
||||
reasoning="Value is guaranteed non-null by upstream validation",
|
||||
)
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=456,
|
||||
repo="test/repo",
|
||||
success=True,
|
||||
findings=[finding],
|
||||
summary="Test",
|
||||
overall_status="comment",
|
||||
verdict=MergeVerdict.MERGE_WITH_CHANGES,
|
||||
verdict_reasoning="1 high-priority issue",
|
||||
blockers=["Security: SQL Injection (src/db.py:42)"],
|
||||
risk_assessment={
|
||||
"complexity": "medium",
|
||||
"security_impact": "medium",
|
||||
"scope_coherence": "mixed",
|
||||
},
|
||||
structural_issues=[structural],
|
||||
ai_comment_triages=[triage],
|
||||
quick_scan_summary={"purpose": "Test", "complexity": "medium"},
|
||||
)
|
||||
|
||||
# Serialize to dict
|
||||
data = result.to_dict()
|
||||
|
||||
# Check serialized data
|
||||
assert data["verdict"] == "merge_with_changes"
|
||||
assert data["blockers"] == ["Security: SQL Injection (src/db.py:42)"]
|
||||
assert len(data["structural_issues"]) == 1
|
||||
assert len(data["ai_comment_triages"]) == 1
|
||||
assert data["structural_issues"][0]["issue_type"] == "feature_creep"
|
||||
assert data["ai_comment_triages"][0]["verdict"] == "trivial"
|
||||
|
||||
# Deserialize back
|
||||
loaded = PRReviewResult.from_dict(data)
|
||||
|
||||
assert loaded.verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
assert loaded.verdict_reasoning == "1 high-priority issue"
|
||||
assert len(loaded.structural_issues) == 1
|
||||
assert loaded.structural_issues[0].issue_type == "feature_creep"
|
||||
assert len(loaded.ai_comment_triages) == 1
|
||||
assert loaded.ai_comment_triages[0].verdict == AICommentVerdict.TRIVIAL
|
||||
|
||||
print(" ✅ PRReviewResult serialization: PASS")
|
||||
|
||||
|
||||
def test_verdict_generation_logic():
|
||||
"""Test verdict generation produces correct verdicts."""
|
||||
print("Testing verdict generation logic...")
|
||||
|
||||
# Test case 1: No issues -> READY_TO_MERGE
|
||||
findings = []
|
||||
structural = []
|
||||
triages = []
|
||||
|
||||
# Simulate verdict logic
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
||||
security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY]
|
||||
structural_blockers = [
|
||||
s
|
||||
for s in structural
|
||||
if s.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH)
|
||||
]
|
||||
ai_critical = [t for t in triages if t.verdict == AICommentVerdict.CRITICAL]
|
||||
|
||||
blockers = []
|
||||
for f in security_critical:
|
||||
blockers.append(f"Security: {f.title}")
|
||||
for f in critical:
|
||||
if f not in security_critical:
|
||||
blockers.append(f"Critical: {f.title}")
|
||||
for s in structural_blockers:
|
||||
blockers.append(f"Structure: {s.title}")
|
||||
for t in ai_critical:
|
||||
blockers.append(f"{t.tool_name}: {t.original_comment[:50]}")
|
||||
|
||||
if blockers:
|
||||
if security_critical:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
elif len(critical) > 0:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
else:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
elif high:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
else:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
|
||||
assert verdict == MergeVerdict.READY_TO_MERGE
|
||||
assert len(blockers) == 0
|
||||
print(" ✓ Case 1: No issues -> READY_TO_MERGE")
|
||||
|
||||
# Test case 2: Security critical -> BLOCKED
|
||||
findings = [
|
||||
PRReviewFinding(
|
||||
id="sec-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection",
|
||||
description="Test",
|
||||
file="test.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY]
|
||||
|
||||
blockers = []
|
||||
for f in security_critical:
|
||||
blockers.append(f"Security: {f.title}")
|
||||
|
||||
if blockers and security_critical:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
|
||||
assert verdict == MergeVerdict.BLOCKED
|
||||
assert len(blockers) == 1
|
||||
assert "SQL Injection" in blockers[0]
|
||||
print(" ✓ Case 2: Security critical -> BLOCKED")
|
||||
|
||||
# Test case 3: High severity only -> MERGE_WITH_CHANGES
|
||||
findings = [
|
||||
PRReviewFinding(
|
||||
id="q-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Missing error handling",
|
||||
description="Test",
|
||||
file="test.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
||||
security_critical = [f for f in critical if f.category == ReviewCategory.SECURITY]
|
||||
|
||||
blockers = []
|
||||
if not blockers and high:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
assert verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
print(" ✓ Case 3: High severity only -> MERGE_WITH_CHANGES")
|
||||
|
||||
print(" ✅ Verdict generation logic: PASS")
|
||||
|
||||
|
||||
def test_risk_assessment_logic():
|
||||
"""Test risk assessment calculation."""
|
||||
print("Testing risk assessment logic...")
|
||||
|
||||
# Test complexity levels
|
||||
def calculate_complexity(additions, deletions):
|
||||
total = additions + deletions
|
||||
if total > 500:
|
||||
return "high"
|
||||
elif total > 200:
|
||||
return "medium"
|
||||
else:
|
||||
return "low"
|
||||
|
||||
assert calculate_complexity(50, 20) == "low"
|
||||
assert calculate_complexity(150, 100) == "medium"
|
||||
assert calculate_complexity(400, 200) == "high"
|
||||
print(" ✓ Complexity calculation")
|
||||
|
||||
# Test security impact levels
|
||||
def calculate_security_impact(findings):
|
||||
security = [f for f in findings if f.category == ReviewCategory.SECURITY]
|
||||
if any(f.severity == ReviewSeverity.CRITICAL for f in security):
|
||||
return "critical"
|
||||
elif any(f.severity == ReviewSeverity.HIGH for f in security):
|
||||
return "medium"
|
||||
elif security:
|
||||
return "low"
|
||||
else:
|
||||
return "none"
|
||||
|
||||
assert calculate_security_impact([]) == "none"
|
||||
|
||||
findings_low = [
|
||||
PRReviewFinding(
|
||||
id="s1",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Test",
|
||||
description="",
|
||||
file="",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
assert calculate_security_impact(findings_low) == "low"
|
||||
|
||||
findings_critical = [
|
||||
PRReviewFinding(
|
||||
id="s2",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Test",
|
||||
description="",
|
||||
file="",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
assert calculate_security_impact(findings_critical) == "critical"
|
||||
print(" ✓ Security impact calculation")
|
||||
|
||||
print(" ✅ Risk assessment logic: PASS")
|
||||
|
||||
|
||||
def test_json_parsing_robustness():
|
||||
"""Test JSON parsing handles edge cases."""
|
||||
print("Testing JSON parsing robustness...")
|
||||
|
||||
import re
|
||||
|
||||
def parse_json_array(text):
|
||||
"""Simulate the JSON parsing from AI response."""
|
||||
try:
|
||||
json_match = re.search(r"```json\s*(\[.*?\])\s*```", text, re.DOTALL)
|
||||
if json_match:
|
||||
return json.loads(json_match.group(1))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
return []
|
||||
|
||||
# Test valid JSON
|
||||
valid = """
|
||||
Here is my analysis:
|
||||
```json
|
||||
[{"id": "f1", "title": "Test"}]
|
||||
```
|
||||
Done.
|
||||
"""
|
||||
result = parse_json_array(valid)
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "f1"
|
||||
print(" ✓ Valid JSON parsing")
|
||||
|
||||
# Test empty array
|
||||
empty = """
|
||||
```json
|
||||
[]
|
||||
```
|
||||
"""
|
||||
result = parse_json_array(empty)
|
||||
assert result == []
|
||||
print(" ✓ Empty array parsing")
|
||||
|
||||
# Test no JSON block
|
||||
no_json = "This response has no JSON block."
|
||||
result = parse_json_array(no_json)
|
||||
assert result == []
|
||||
print(" ✓ No JSON block handling")
|
||||
|
||||
# Test malformed JSON
|
||||
malformed = """
|
||||
```json
|
||||
[{"id": "f1", "title": "Missing close bracket"
|
||||
```
|
||||
"""
|
||||
result = parse_json_array(malformed)
|
||||
assert result == []
|
||||
print(" ✓ Malformed JSON handling")
|
||||
|
||||
print(" ✅ JSON parsing robustness: PASS")
|
||||
|
||||
|
||||
def test_confidence_threshold():
|
||||
"""Test 80% confidence threshold filtering."""
|
||||
print("Testing confidence threshold...")
|
||||
|
||||
CONFIDENCE_THRESHOLD = 0.80
|
||||
|
||||
findings_data = [
|
||||
{"id": "f1", "confidence": 0.95, "title": "High confidence"},
|
||||
{"id": "f2", "confidence": 0.80, "title": "At threshold"},
|
||||
{"id": "f3", "confidence": 0.79, "title": "Below threshold"},
|
||||
{"id": "f4", "confidence": 0.50, "title": "Low confidence"},
|
||||
{"id": "f5", "title": "No confidence field"}, # Should default to 0.85
|
||||
]
|
||||
|
||||
filtered = []
|
||||
for f in findings_data:
|
||||
confidence = float(f.get("confidence", 0.85))
|
||||
if confidence >= CONFIDENCE_THRESHOLD:
|
||||
filtered.append(f)
|
||||
|
||||
assert len(filtered) == 3
|
||||
assert filtered[0]["id"] == "f1" # 0.95 >= 0.80
|
||||
assert filtered[1]["id"] == "f2" # 0.80 >= 0.80
|
||||
assert filtered[2]["id"] == "f5" # 0.85 (default) >= 0.80
|
||||
|
||||
print(
|
||||
f" ✓ Filtered {len(findings_data) - len(filtered)}/{len(findings_data)} findings below threshold"
|
||||
)
|
||||
print(" ✅ Confidence threshold: PASS")
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all validation tests."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Enhanced PR Review System - Validation Tests")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
tests = [
|
||||
test_merge_verdict_enum,
|
||||
test_ai_comment_verdict_enum,
|
||||
test_review_pass_enum,
|
||||
test_ai_bot_patterns,
|
||||
test_ai_bot_comment_dataclass,
|
||||
test_ai_comment_triage_dataclass,
|
||||
test_structural_issue_dataclass,
|
||||
test_pr_review_result_new_fields,
|
||||
test_pr_review_result_serialization,
|
||||
test_verdict_generation_logic,
|
||||
test_risk_assessment_logic,
|
||||
test_json_parsing_robustness,
|
||||
test_confidence_threshold,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
except Exception as e:
|
||||
print(f" ❌ {test.__name__}: FAILED")
|
||||
print(f" Error: {e}")
|
||||
failed += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
print("=" * 60)
|
||||
|
||||
if failed > 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n✅ All validation tests passed! System is ready for production.\n")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_all_tests()
|
||||
@@ -1,333 +0,0 @@
|
||||
"""
|
||||
Test File Locking for Concurrent Operations
|
||||
===========================================
|
||||
|
||||
Demonstrates file locking preventing data corruption in concurrent scenarios.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from file_lock import (
|
||||
FileLock,
|
||||
FileLockTimeout,
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
locked_read,
|
||||
locked_write,
|
||||
)
|
||||
|
||||
|
||||
async def test_basic_file_lock():
|
||||
"""Test basic file locking mechanism."""
|
||||
print("\n=== Test 1: Basic File Lock ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "test.txt"
|
||||
test_file.write_text("initial content", encoding="utf-8")
|
||||
|
||||
# Acquire lock and hold it
|
||||
async with FileLock(test_file, timeout=5.0):
|
||||
print("✓ Lock acquired successfully")
|
||||
# Do work while holding lock
|
||||
await asyncio.sleep(0.1)
|
||||
print("✓ Lock held during work")
|
||||
|
||||
print("✓ Lock released automatically")
|
||||
|
||||
|
||||
async def test_locked_write():
|
||||
"""Test atomic locked write operations."""
|
||||
print("\n=== Test 2: Locked Write ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "data.json"
|
||||
|
||||
# Write data with locking
|
||||
data = {"count": 0, "items": ["a", "b", "c"]}
|
||||
async with locked_write(test_file, timeout=5.0) as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
print(f"✓ Written to {test_file.name}")
|
||||
|
||||
# Verify data was written correctly
|
||||
with open(test_file, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded == data
|
||||
print(f"✓ Data verified: {loaded}")
|
||||
|
||||
|
||||
async def test_locked_json_helpers():
|
||||
"""Test JSON helper functions."""
|
||||
print("\n=== Test 3: JSON Helpers ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "data.json"
|
||||
|
||||
# Write JSON
|
||||
data = {"users": [], "total": 0}
|
||||
await locked_json_write(test_file, data, timeout=5.0)
|
||||
print(f"✓ JSON written: {data}")
|
||||
|
||||
# Read JSON
|
||||
loaded = await locked_json_read(test_file, timeout=5.0)
|
||||
assert loaded == data
|
||||
print(f"✓ JSON read: {loaded}")
|
||||
|
||||
|
||||
async def test_locked_json_update():
|
||||
"""Test atomic read-modify-write updates."""
|
||||
print("\n=== Test 4: Atomic Updates ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "counter.json"
|
||||
|
||||
# Initialize counter
|
||||
await locked_json_write(test_file, {"count": 0}, timeout=5.0)
|
||||
print("✓ Counter initialized to 0")
|
||||
|
||||
# Define update function
|
||||
def increment_counter(data):
|
||||
data["count"] += 1
|
||||
return data
|
||||
|
||||
# Perform 5 atomic updates
|
||||
for i in range(5):
|
||||
await locked_json_update(test_file, increment_counter, timeout=5.0)
|
||||
|
||||
# Verify final count
|
||||
final = await locked_json_read(test_file, timeout=5.0)
|
||||
assert final["count"] == 5
|
||||
print(f"✓ Counter incremented 5 times: {final}")
|
||||
|
||||
|
||||
async def test_concurrent_updates_without_lock():
|
||||
"""Demonstrate data corruption WITHOUT file locking."""
|
||||
print("\n=== Test 5: Concurrent Updates WITHOUT Locking (UNSAFE) ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "unsafe.json"
|
||||
|
||||
# Initialize counter
|
||||
test_file.write_text(json.dumps({"count": 0}), encoding="utf-8")
|
||||
|
||||
async def unsafe_increment():
|
||||
"""Increment without locking - RACE CONDITION!"""
|
||||
# Read
|
||||
with open(test_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Simulate some processing
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Write
|
||||
data["count"] += 1
|
||||
with open(test_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Run 10 concurrent increments
|
||||
await asyncio.gather(*[unsafe_increment() for _ in range(10)])
|
||||
|
||||
# Check final count
|
||||
with open(test_file, encoding="utf-8") as f:
|
||||
final = json.load(f)
|
||||
|
||||
print("✗ Expected count: 10")
|
||||
print(f"✗ Actual count: {final['count']} (CORRUPTED due to race condition)")
|
||||
print(
|
||||
f"✗ Lost updates: {10 - final['count']} (multiple processes overwrote each other)"
|
||||
)
|
||||
|
||||
|
||||
async def test_concurrent_updates_with_lock():
|
||||
"""Demonstrate data integrity WITH file locking."""
|
||||
print("\n=== Test 6: Concurrent Updates WITH Locking (SAFE) ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "safe.json"
|
||||
|
||||
# Initialize counter
|
||||
await locked_json_write(test_file, {"count": 0}, timeout=5.0)
|
||||
|
||||
async def safe_increment():
|
||||
"""Increment with locking - NO RACE CONDITION!"""
|
||||
|
||||
def increment(data):
|
||||
# Simulate some processing
|
||||
time.sleep(0.01)
|
||||
data["count"] += 1
|
||||
return data
|
||||
|
||||
await locked_json_update(test_file, increment, timeout=5.0)
|
||||
|
||||
# Run 10 concurrent increments
|
||||
await asyncio.gather(*[safe_increment() for _ in range(10)])
|
||||
|
||||
# Check final count
|
||||
final = await locked_json_read(test_file, timeout=5.0)
|
||||
|
||||
assert final["count"] == 10
|
||||
print("✓ Expected count: 10")
|
||||
print(f"✓ Actual count: {final['count']} (CORRECT with file locking)")
|
||||
print("✓ No data corruption - all updates applied successfully")
|
||||
|
||||
|
||||
async def test_lock_timeout():
|
||||
"""Test lock timeout behavior."""
|
||||
print("\n=== Test 7: Lock Timeout ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "timeout.json"
|
||||
test_file.write_text(json.dumps({"data": "test"}), encoding="utf-8")
|
||||
|
||||
# Acquire lock and hold it
|
||||
lock1 = FileLock(test_file, timeout=1.0)
|
||||
await lock1.__aenter__()
|
||||
print("✓ First lock acquired")
|
||||
|
||||
try:
|
||||
# Try to acquire second lock with short timeout
|
||||
lock2 = FileLock(test_file, timeout=0.5)
|
||||
await lock2.__aenter__()
|
||||
print("✗ Second lock acquired (should have timed out!)")
|
||||
except FileLockTimeout as e:
|
||||
print(f"✓ Second lock timed out as expected: {e}")
|
||||
finally:
|
||||
await lock1.__aexit__(None, None, None)
|
||||
print("✓ First lock released")
|
||||
|
||||
|
||||
async def test_index_update_pattern():
|
||||
"""Test the index update pattern used in models.py."""
|
||||
print("\n=== Test 8: Index Update Pattern (Production Pattern) ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
index_file = Path(tmpdir) / "index.json"
|
||||
|
||||
# Simulate multiple PR reviews updating the index concurrently
|
||||
async def add_review(pr_number: int, status: str):
|
||||
"""Add or update a PR review in the index."""
|
||||
|
||||
def update_index(current_data):
|
||||
if current_data is None:
|
||||
current_data = {"reviews": [], "last_updated": None}
|
||||
|
||||
reviews = current_data.get("reviews", [])
|
||||
existing = next(
|
||||
(r for r in reviews if r["pr_number"] == pr_number), None
|
||||
)
|
||||
|
||||
entry = {
|
||||
"pr_number": pr_number,
|
||||
"status": status,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
reviews = [
|
||||
entry if r["pr_number"] == pr_number else r for r in reviews
|
||||
]
|
||||
else:
|
||||
reviews.append(entry)
|
||||
|
||||
current_data["reviews"] = reviews
|
||||
current_data["last_updated"] = time.time()
|
||||
|
||||
return current_data
|
||||
|
||||
await locked_json_update(index_file, update_index, timeout=5.0)
|
||||
|
||||
# Simulate 5 concurrent review updates
|
||||
print("Simulating 5 concurrent PR review updates...")
|
||||
await asyncio.gather(
|
||||
add_review(101, "approved"),
|
||||
add_review(102, "changes_requested"),
|
||||
add_review(103, "commented"),
|
||||
add_review(104, "approved"),
|
||||
add_review(105, "approved"),
|
||||
)
|
||||
|
||||
# Verify all reviews were recorded
|
||||
final_index = await locked_json_read(index_file, timeout=5.0)
|
||||
assert len(final_index["reviews"]) == 5
|
||||
print("✓ All 5 reviews recorded correctly")
|
||||
print(f"✓ Index state: {len(final_index['reviews'])} reviews")
|
||||
|
||||
# Update an existing review
|
||||
await add_review(102, "approved") # Change status
|
||||
updated_index = await locked_json_read(index_file, timeout=5.0)
|
||||
assert len(updated_index["reviews"]) == 5 # Still 5, not 6
|
||||
review_102 = next(r for r in updated_index["reviews"] if r["pr_number"] == 102)
|
||||
assert review_102["status"] == "approved"
|
||||
print("✓ Review #102 updated from 'changes_requested' to 'approved'")
|
||||
print("✓ No duplicate entries created")
|
||||
|
||||
|
||||
async def test_atomic_write_failure():
|
||||
"""Test that failed writes don't corrupt existing files."""
|
||||
print("\n=== Test 9: Atomic Write Failure Handling ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / "important.json"
|
||||
|
||||
# Write initial data
|
||||
initial_data = {"important": "data", "version": 1}
|
||||
await locked_json_write(test_file, initial_data, timeout=5.0)
|
||||
print(f"✓ Initial data written: {initial_data}")
|
||||
|
||||
# Try to write invalid data that will fail
|
||||
try:
|
||||
async with locked_write(test_file, timeout=5.0) as f:
|
||||
f.write("{invalid json")
|
||||
# Simulate an error during write
|
||||
raise Exception("Simulated write failure")
|
||||
except Exception as e:
|
||||
print(f"✓ Write failed as expected: {e}")
|
||||
|
||||
# Verify original data is intact (atomic write rolled back)
|
||||
current_data = await locked_json_read(test_file, timeout=5.0)
|
||||
assert current_data == initial_data
|
||||
print(f"✓ Original data intact after failed write: {current_data}")
|
||||
print(
|
||||
"✓ Atomic write prevented corruption (temp file discarded, original preserved)"
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all tests."""
|
||||
print("=" * 70)
|
||||
print("File Locking Tests - Preventing Concurrent Operation Corruption")
|
||||
print("=" * 70)
|
||||
|
||||
tests = [
|
||||
test_basic_file_lock,
|
||||
test_locked_write,
|
||||
test_locked_json_helpers,
|
||||
test_locked_json_update,
|
||||
test_concurrent_updates_without_lock,
|
||||
test_concurrent_updates_with_lock,
|
||||
test_lock_timeout,
|
||||
test_index_update_pattern,
|
||||
test_atomic_write_failure,
|
||||
]
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
await test()
|
||||
except Exception as e:
|
||||
print(f"✗ Test failed: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("All Tests Completed!")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
Tests for GHClient timeout and retry functionality.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from gh_client import GHClient, GHCommandError, GHTimeoutError
|
||||
|
||||
|
||||
class TestGHClient:
|
||||
"""Test suite for GHClient."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, tmp_path):
|
||||
"""Create a test client."""
|
||||
return GHClient(
|
||||
project_dir=tmp_path,
|
||||
default_timeout=2.0,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_raises_error(self, client):
|
||||
"""Test that commands timeout after max retries."""
|
||||
# Use a command that will timeout (sleep longer than timeout)
|
||||
with pytest.raises(GHTimeoutError) as exc_info:
|
||||
await client.run(["api", "/repos/nonexistent/repo"], timeout=0.1)
|
||||
|
||||
assert "timed out after 3 attempts" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_command_raises_error(self, client):
|
||||
"""Test that invalid commands raise GHCommandError."""
|
||||
with pytest.raises(GHCommandError):
|
||||
await client.run(["invalid-command"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_command(self, client):
|
||||
"""Test successful command execution."""
|
||||
# This test requires gh CLI to be installed
|
||||
try:
|
||||
result = await client.run(["--version"])
|
||||
assert result.returncode == 0
|
||||
assert "gh version" in result.stdout
|
||||
assert result.attempts == 1
|
||||
except Exception:
|
||||
pytest.skip("gh CLI not available")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_convenience_methods_timeout_protection(self, client):
|
||||
"""Test that convenience methods have timeout protection."""
|
||||
# These will fail because repo doesn't exist, but should not hang
|
||||
with pytest.raises((GHCommandError, GHTimeoutError)):
|
||||
await client.pr_list()
|
||||
|
||||
with pytest.raises((GHCommandError, GHTimeoutError)):
|
||||
await client.issue_list()
|
||||
|
||||
|
||||
class TestGHClientGhExecutableDetection:
|
||||
"""Test suite for GHClient gh executable detection."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, tmp_path):
|
||||
"""Create a test client."""
|
||||
return GHClient(
|
||||
project_dir=tmp_path,
|
||||
default_timeout=2.0,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_raises_error_when_gh_not_found(self, client):
|
||||
"""Test that run() raises GHCommandError when gh is not found."""
|
||||
with patch("gh_client.get_gh_executable", return_value=None):
|
||||
with pytest.raises(GHCommandError) as exc_info:
|
||||
await client.run(["--version"])
|
||||
|
||||
assert "not found" in str(exc_info.value)
|
||||
# Test verifies error message mentions GitHub CLI for user guidance
|
||||
assert "GitHub CLI" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_uses_detected_gh_executable(self, client):
|
||||
"""Test that run() uses the detected gh executable path."""
|
||||
mock_exec = "/custom/path/to/gh"
|
||||
|
||||
with patch("gh_client.get_gh_executable", return_value=mock_exec):
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
# Mock the subprocess to return immediately
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.communicate = AsyncMock(
|
||||
return_value=(b"gh version 2.0.0\n", b"")
|
||||
)
|
||||
mock_proc.returncode = 0
|
||||
mock_subprocess.return_value = mock_proc
|
||||
|
||||
await client.run(["--version"])
|
||||
|
||||
# Verify the correct gh path was used
|
||||
mock_subprocess.assert_called_once()
|
||||
called_cmd = mock_subprocess.call_args[0][0]
|
||||
assert called_cmd == mock_exec
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,393 +0,0 @@
|
||||
"""
|
||||
Unit Tests for GitHub Permission System
|
||||
=======================================
|
||||
|
||||
Tests for GitHubPermissionChecker and permission verification.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from permissions import GitHubPermissionChecker, PermissionCheckResult, PermissionError
|
||||
|
||||
|
||||
class MockGitHubClient:
|
||||
"""Mock GitHub API client for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.get = AsyncMock()
|
||||
self._get_headers = AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gh_client():
|
||||
"""Create a mock GitHub client."""
|
||||
return MockGitHubClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def permission_checker(mock_gh_client):
|
||||
"""Create a permission checker instance."""
|
||||
return GitHubPermissionChecker(
|
||||
gh_client=mock_gh_client,
|
||||
repo="owner/test-repo",
|
||||
allowed_roles=["OWNER", "MEMBER", "COLLABORATOR"],
|
||||
allow_external_contributors=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_scopes_success(permission_checker, mock_gh_client):
|
||||
"""Test successful token scope verification."""
|
||||
mock_gh_client._get_headers.return_value = {
|
||||
"X-OAuth-Scopes": "repo, read:org, admin:repo_hook"
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
await permission_checker.verify_token_scopes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_scopes_minimum(permission_checker, mock_gh_client):
|
||||
"""Test token with minimum scopes (repo only) triggers warning."""
|
||||
mock_gh_client._get_headers.return_value = {"X-OAuth-Scopes": "repo"}
|
||||
|
||||
# Should warn but not raise (for non-org repos)
|
||||
await permission_checker.verify_token_scopes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_scopes_insufficient(permission_checker, mock_gh_client):
|
||||
"""Test insufficient token scopes raises error."""
|
||||
mock_gh_client._get_headers.return_value = {"X-OAuth-Scopes": "read:user"}
|
||||
|
||||
with pytest.raises(PermissionError, match="missing required scopes"):
|
||||
await permission_checker.verify_token_scopes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_success(permission_checker, mock_gh_client):
|
||||
"""Test successfully finding who added a label."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Issue events
|
||||
[
|
||||
{
|
||||
"event": "labeled",
|
||||
"label": {"name": "auto-fix"},
|
||||
"actor": {"login": "alice"},
|
||||
},
|
||||
{
|
||||
"event": "commented",
|
||||
"actor": {"login": "bob"},
|
||||
},
|
||||
],
|
||||
# Collaborator permission check for alice
|
||||
{"permission": "write"},
|
||||
]
|
||||
|
||||
username, role = await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
assert username == "alice"
|
||||
assert role == "COLLABORATOR"
|
||||
mock_gh_client.get.assert_any_call("/repos/owner/test-repo/issues/123/events")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_not_found(permission_checker, mock_gh_client):
|
||||
"""Test error when label not found in events."""
|
||||
mock_gh_client.get.return_value = [
|
||||
{
|
||||
"event": "labeled",
|
||||
"label": {"name": "bug"},
|
||||
"actor": {"login": "alice"},
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(PermissionError, match="Label 'auto-fix' not found"):
|
||||
await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_owner(permission_checker, mock_gh_client):
|
||||
"""Test getting role for repository owner."""
|
||||
role = await permission_checker.get_user_role("owner")
|
||||
|
||||
assert role == "OWNER"
|
||||
# Should use cache, no API calls needed
|
||||
assert mock_gh_client.get.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_collaborator(permission_checker, mock_gh_client):
|
||||
"""Test getting role for collaborator with write access."""
|
||||
mock_gh_client.get.return_value = {"permission": "write"}
|
||||
|
||||
role = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role == "COLLABORATOR"
|
||||
mock_gh_client.get.assert_called_with(
|
||||
"/repos/owner/test-repo/collaborators/alice/permission"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_org_member(permission_checker, mock_gh_client):
|
||||
"""Test getting role for organization member."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Not a collaborator
|
||||
Exception("Not a collaborator"),
|
||||
# Repo info (org-owned)
|
||||
{"owner": {"type": "Organization"}},
|
||||
# Org membership check
|
||||
{"state": "active"},
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("bob")
|
||||
|
||||
assert role == "MEMBER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_contributor(permission_checker, mock_gh_client):
|
||||
"""Test getting role for external contributor."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Not a collaborator
|
||||
Exception("Not a collaborator"),
|
||||
# Repo info (user-owned, not org)
|
||||
{"owner": {"type": "User"}},
|
||||
# Contributors list
|
||||
[
|
||||
{"login": "alice"},
|
||||
{"login": "charlie"}, # The user we're checking
|
||||
],
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("charlie")
|
||||
|
||||
assert role == "CONTRIBUTOR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_none(permission_checker, mock_gh_client):
|
||||
"""Test getting role for user with no relationship to repo."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Not a collaborator
|
||||
Exception("Not a collaborator"),
|
||||
# Repo info
|
||||
{"owner": {"type": "User"}},
|
||||
# Contributors list (user not in it)
|
||||
[{"login": "alice"}],
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("stranger")
|
||||
|
||||
assert role == "NONE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_caching(permission_checker, mock_gh_client):
|
||||
"""Test that user roles are cached."""
|
||||
mock_gh_client.get.return_value = {"permission": "write"}
|
||||
|
||||
# First call
|
||||
role1 = await permission_checker.get_user_role("alice")
|
||||
assert role1 == "COLLABORATOR"
|
||||
|
||||
# Second call should use cache
|
||||
role2 = await permission_checker.get_user_role("alice")
|
||||
assert role2 == "COLLABORATOR"
|
||||
|
||||
# Only one API call should have been made
|
||||
assert mock_gh_client.get.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_owner(permission_checker, mock_gh_client):
|
||||
"""Test auto-fix permission for owner."""
|
||||
result = await permission_checker.is_allowed_for_autofix("owner")
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.username == "owner"
|
||||
assert result.role == "OWNER"
|
||||
assert result.reason is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_collaborator(permission_checker, mock_gh_client):
|
||||
"""Test auto-fix permission for collaborator."""
|
||||
mock_gh_client.get.return_value = {"permission": "write"}
|
||||
|
||||
result = await permission_checker.is_allowed_for_autofix("alice")
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.username == "alice"
|
||||
assert result.role == "COLLABORATOR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_denied(permission_checker, mock_gh_client):
|
||||
"""Test auto-fix permission denied for unauthorized user."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
Exception("Not a collaborator"),
|
||||
{"owner": {"type": "User"}},
|
||||
[], # Not in contributors
|
||||
]
|
||||
|
||||
result = await permission_checker.is_allowed_for_autofix("stranger")
|
||||
|
||||
assert result.allowed is False
|
||||
assert result.username == "stranger"
|
||||
assert result.role == "NONE"
|
||||
assert "not in allowed roles" in result.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_contributor_allowed(mock_gh_client):
|
||||
"""Test auto-fix permission for contributor when external contributors allowed."""
|
||||
checker = GitHubPermissionChecker(
|
||||
gh_client=mock_gh_client,
|
||||
repo="owner/test-repo",
|
||||
allow_external_contributors=True,
|
||||
)
|
||||
|
||||
mock_gh_client.get.side_effect = [
|
||||
Exception("Not a collaborator"),
|
||||
{"owner": {"type": "User"}},
|
||||
[{"login": "charlie"}], # Is a contributor
|
||||
]
|
||||
|
||||
result = await checker.is_allowed_for_autofix("charlie")
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.role == "CONTRIBUTOR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_org_membership_true(permission_checker, mock_gh_client):
|
||||
"""Test successful org membership check."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Repo info
|
||||
{"owner": {"type": "Organization"}},
|
||||
# Org membership
|
||||
{"state": "active"},
|
||||
]
|
||||
|
||||
is_member = await permission_checker.check_org_membership("alice")
|
||||
|
||||
assert is_member is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_org_membership_false(permission_checker, mock_gh_client):
|
||||
"""Test failed org membership check."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Repo info
|
||||
{"owner": {"type": "Organization"}},
|
||||
# Org membership check fails
|
||||
Exception("Not a member"),
|
||||
]
|
||||
|
||||
is_member = await permission_checker.check_org_membership("stranger")
|
||||
|
||||
assert is_member is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_org_membership_non_org_repo(permission_checker, mock_gh_client):
|
||||
"""Test org membership check for non-org repo returns True."""
|
||||
mock_gh_client.get.return_value = {"owner": {"type": "User"}}
|
||||
|
||||
is_member = await permission_checker.check_org_membership("anyone")
|
||||
|
||||
assert is_member is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_team_membership_true(permission_checker, mock_gh_client):
|
||||
"""Test successful team membership check."""
|
||||
mock_gh_client.get.return_value = {"state": "active"}
|
||||
|
||||
is_member = await permission_checker.check_team_membership("alice", "developers")
|
||||
|
||||
assert is_member is True
|
||||
mock_gh_client.get.assert_called_with(
|
||||
"/orgs/owner/teams/developers/memberships/alice"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_team_membership_false(permission_checker, mock_gh_client):
|
||||
"""Test failed team membership check."""
|
||||
mock_gh_client.get.side_effect = Exception("Not a team member")
|
||||
|
||||
is_member = await permission_checker.check_team_membership("bob", "developers")
|
||||
|
||||
assert is_member is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_automation_trigger_allowed(permission_checker, mock_gh_client):
|
||||
"""Test complete automation trigger verification (allowed)."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Issue events
|
||||
[
|
||||
{
|
||||
"event": "labeled",
|
||||
"label": {"name": "auto-fix"},
|
||||
"actor": {"login": "alice"},
|
||||
}
|
||||
],
|
||||
# Collaborator permission
|
||||
{"permission": "write"},
|
||||
]
|
||||
|
||||
result = await permission_checker.verify_automation_trigger(123, "auto-fix")
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.username == "alice"
|
||||
assert result.role == "COLLABORATOR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_automation_trigger_denied(permission_checker, mock_gh_client):
|
||||
"""Test complete automation trigger verification (denied)."""
|
||||
mock_gh_client.get.side_effect = [
|
||||
# Issue events
|
||||
[
|
||||
{
|
||||
"event": "labeled",
|
||||
"label": {"name": "auto-fix"},
|
||||
"actor": {"login": "stranger"},
|
||||
}
|
||||
],
|
||||
# Not a collaborator
|
||||
Exception("Not a collaborator"),
|
||||
# Repo info
|
||||
{"owner": {"type": "User"}},
|
||||
# Not in contributors
|
||||
[],
|
||||
]
|
||||
|
||||
result = await permission_checker.verify_automation_trigger(123, "auto-fix")
|
||||
|
||||
assert result.allowed is False
|
||||
assert result.username == "stranger"
|
||||
assert result.role == "NONE"
|
||||
|
||||
|
||||
def test_log_permission_denial(permission_checker, caplog):
|
||||
"""Test permission denial logging."""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
permission_checker.log_permission_denial(
|
||||
action="auto-fix",
|
||||
username="stranger",
|
||||
role="NONE",
|
||||
issue_number=123,
|
||||
)
|
||||
|
||||
assert "PERMISSION DENIED" in caplog.text
|
||||
assert "stranger" in caplog.text
|
||||
assert "auto-fix" in caplog.text
|
||||
@@ -1,506 +0,0 @@
|
||||
"""
|
||||
Tests for Rate Limiter
|
||||
======================
|
||||
|
||||
Comprehensive test suite for rate limiting system covering:
|
||||
- Token bucket algorithm
|
||||
- GitHub API rate limiting
|
||||
- AI cost tracking
|
||||
- Decorator functionality
|
||||
- Exponential backoff
|
||||
- Edge cases
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from rate_limiter import (
|
||||
CostLimitExceeded,
|
||||
CostTracker,
|
||||
RateLimiter,
|
||||
RateLimitExceeded,
|
||||
TokenBucket,
|
||||
check_rate_limit,
|
||||
rate_limited,
|
||||
)
|
||||
|
||||
|
||||
class TestTokenBucket:
|
||||
"""Test token bucket algorithm."""
|
||||
|
||||
def test_initial_state(self):
|
||||
"""Bucket starts full."""
|
||||
bucket = TokenBucket(capacity=100, refill_rate=10.0)
|
||||
assert bucket.available() == 100
|
||||
|
||||
def test_try_acquire_success(self):
|
||||
"""Can acquire tokens when available."""
|
||||
bucket = TokenBucket(capacity=100, refill_rate=10.0)
|
||||
assert bucket.try_acquire(10) is True
|
||||
assert bucket.available() == 90
|
||||
|
||||
def test_try_acquire_failure(self):
|
||||
"""Cannot acquire when insufficient tokens."""
|
||||
bucket = TokenBucket(capacity=100, refill_rate=10.0)
|
||||
bucket.try_acquire(100)
|
||||
assert bucket.try_acquire(1) is False
|
||||
assert bucket.available() == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_waits(self):
|
||||
"""Acquire waits for refill when needed."""
|
||||
bucket = TokenBucket(capacity=10, refill_rate=10.0) # 10 tokens/sec
|
||||
bucket.try_acquire(10) # Empty the bucket
|
||||
|
||||
start = time.monotonic()
|
||||
result = await bucket.acquire(1) # Should wait ~0.1s for 1 token
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert result is True
|
||||
assert elapsed >= 0.05 # At least some delay
|
||||
assert elapsed < 0.5 # But not too long
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_timeout(self):
|
||||
"""Acquire respects timeout."""
|
||||
bucket = TokenBucket(capacity=10, refill_rate=1.0) # 1 token/sec
|
||||
bucket.try_acquire(10) # Empty the bucket
|
||||
|
||||
start = time.monotonic()
|
||||
result = await bucket.acquire(100, timeout=0.1) # Need 100s, timeout 0.1s
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert result is False
|
||||
assert elapsed < 0.5 # Should timeout quickly
|
||||
|
||||
def test_refill_over_time(self):
|
||||
"""Tokens refill at correct rate."""
|
||||
bucket = TokenBucket(capacity=100, refill_rate=100.0) # 100 tokens/sec
|
||||
bucket.try_acquire(50) # Take 50
|
||||
assert bucket.available() == 50
|
||||
|
||||
time.sleep(0.5) # Wait 0.5s = 50 tokens
|
||||
available = bucket.available()
|
||||
assert 95 <= available <= 100 # Should be near full
|
||||
|
||||
def test_time_until_available(self):
|
||||
"""Calculate wait time correctly."""
|
||||
bucket = TokenBucket(capacity=100, refill_rate=10.0)
|
||||
bucket.try_acquire(100) # Empty
|
||||
|
||||
wait = bucket.time_until_available(10)
|
||||
assert 0.9 <= wait <= 1.1 # Should be ~1s for 10 tokens at 10/s
|
||||
|
||||
|
||||
class TestCostTracker:
|
||||
"""Test AI cost tracking."""
|
||||
|
||||
def test_calculate_cost_sonnet(self):
|
||||
"""Calculate cost for Sonnet model."""
|
||||
cost = CostTracker.calculate_cost(
|
||||
input_tokens=1_000_000,
|
||||
output_tokens=1_000_000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
)
|
||||
# $3 input + $15 output = $18 for 1M each
|
||||
assert cost == 18.0
|
||||
|
||||
def test_calculate_cost_opus(self):
|
||||
"""Calculate cost for Opus model."""
|
||||
cost = CostTracker.calculate_cost(
|
||||
input_tokens=1_000_000,
|
||||
output_tokens=1_000_000,
|
||||
model="claude-opus-4-5-20251101",
|
||||
)
|
||||
# $15 input + $75 output = $90 for 1M each
|
||||
assert cost == 90.0
|
||||
|
||||
def test_calculate_cost_haiku(self):
|
||||
"""Calculate cost for Haiku model."""
|
||||
cost = CostTracker.calculate_cost(
|
||||
input_tokens=1_000_000,
|
||||
output_tokens=1_000_000,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
)
|
||||
# $0.80 input + $4 output = $4.80 for 1M each
|
||||
assert cost == 4.80
|
||||
|
||||
def test_calculate_cost_unknown_model(self):
|
||||
"""Unknown model uses default pricing."""
|
||||
cost = CostTracker.calculate_cost(
|
||||
input_tokens=1_000_000,
|
||||
output_tokens=1_000_000,
|
||||
model="unknown-model",
|
||||
)
|
||||
# Default: $3 input + $15 output = $18
|
||||
assert cost == 18.0
|
||||
|
||||
def test_add_operation_under_limit(self):
|
||||
"""Can add operation under budget."""
|
||||
tracker = CostTracker(cost_limit=10.0)
|
||||
cost = tracker.add_operation(
|
||||
input_tokens=100_000, # $0.30
|
||||
output_tokens=50_000, # $0.75
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
operation_name="test",
|
||||
)
|
||||
assert 1.0 <= cost <= 1.1
|
||||
assert tracker.total_cost == cost
|
||||
assert len(tracker.operations) == 1
|
||||
|
||||
def test_add_operation_exceeds_limit(self):
|
||||
"""Cannot add operation that exceeds budget."""
|
||||
tracker = CostTracker(cost_limit=1.0)
|
||||
with pytest.raises(CostLimitExceeded):
|
||||
tracker.add_operation(
|
||||
input_tokens=1_000_000, # $3 - exceeds $1 limit
|
||||
output_tokens=0,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
)
|
||||
|
||||
def test_remaining_budget(self):
|
||||
"""Remaining budget calculated correctly."""
|
||||
tracker = CostTracker(cost_limit=10.0)
|
||||
tracker.add_operation(
|
||||
input_tokens=100_000,
|
||||
output_tokens=50_000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
)
|
||||
remaining = tracker.remaining_budget()
|
||||
assert 8.9 <= remaining <= 9.1
|
||||
|
||||
def test_usage_report(self):
|
||||
"""Usage report generated."""
|
||||
tracker = CostTracker(cost_limit=10.0)
|
||||
tracker.add_operation(
|
||||
input_tokens=100_000,
|
||||
output_tokens=50_000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
operation_name="operation1",
|
||||
)
|
||||
report = tracker.usage_report()
|
||||
assert "Total Cost:" in report
|
||||
assert "Budget:" in report
|
||||
assert "operation1" in report
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Test RateLimiter singleton."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset singleton before each test."""
|
||||
RateLimiter.reset_instance()
|
||||
|
||||
def test_singleton_pattern(self):
|
||||
"""Only one instance exists."""
|
||||
limiter1 = RateLimiter.get_instance()
|
||||
limiter2 = RateLimiter.get_instance()
|
||||
assert limiter1 is limiter2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_github(self):
|
||||
"""Can acquire GitHub tokens."""
|
||||
limiter = RateLimiter.get_instance(github_limit=10)
|
||||
assert await limiter.acquire_github() is True
|
||||
assert limiter.github_requests == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_github_rate_limited(self):
|
||||
"""GitHub rate limiting works."""
|
||||
limiter = RateLimiter.get_instance(
|
||||
github_limit=2,
|
||||
github_refill_rate=0.0, # No refill
|
||||
)
|
||||
assert await limiter.acquire_github() is True
|
||||
assert await limiter.acquire_github() is True
|
||||
# Third should timeout immediately
|
||||
assert await limiter.acquire_github(timeout=0.1) is False
|
||||
assert limiter.github_rate_limited == 1
|
||||
|
||||
def test_check_github_available(self):
|
||||
"""Check GitHub availability without consuming."""
|
||||
limiter = RateLimiter.get_instance(github_limit=100)
|
||||
available, msg = limiter.check_github_available()
|
||||
assert available is True
|
||||
assert "100" in msg
|
||||
|
||||
def test_track_ai_cost(self):
|
||||
"""Track AI costs."""
|
||||
limiter = RateLimiter.get_instance(cost_limit=10.0)
|
||||
cost = limiter.track_ai_cost(
|
||||
input_tokens=100_000,
|
||||
output_tokens=50_000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
operation_name="test",
|
||||
)
|
||||
assert cost > 0
|
||||
assert limiter.cost_tracker.total_cost == cost
|
||||
|
||||
def test_track_ai_cost_exceeds_limit(self):
|
||||
"""Cost limit enforcement."""
|
||||
limiter = RateLimiter.get_instance(cost_limit=1.0)
|
||||
with pytest.raises(CostLimitExceeded):
|
||||
limiter.track_ai_cost(
|
||||
input_tokens=1_000_000,
|
||||
output_tokens=1_000_000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
)
|
||||
|
||||
def test_check_cost_available(self):
|
||||
"""Check cost availability."""
|
||||
limiter = RateLimiter.get_instance(cost_limit=10.0)
|
||||
available, msg = limiter.check_cost_available()
|
||||
assert available is True
|
||||
assert "$10" in msg
|
||||
|
||||
def test_record_github_error(self):
|
||||
"""Record GitHub errors."""
|
||||
limiter = RateLimiter.get_instance()
|
||||
limiter.record_github_error()
|
||||
assert limiter.github_errors == 1
|
||||
|
||||
def test_statistics(self):
|
||||
"""Statistics collection."""
|
||||
limiter = RateLimiter.get_instance()
|
||||
stats = limiter.statistics()
|
||||
assert "github" in stats
|
||||
assert "cost" in stats
|
||||
assert "runtime_seconds" in stats
|
||||
|
||||
def test_report(self):
|
||||
"""Report generation."""
|
||||
limiter = RateLimiter.get_instance()
|
||||
report = limiter.report()
|
||||
assert "Rate Limiter Report" in report
|
||||
assert "GitHub API:" in report
|
||||
assert "AI Cost:" in report
|
||||
|
||||
|
||||
class TestRateLimitedDecorator:
|
||||
"""Test @rate_limited decorator."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset singleton before each test."""
|
||||
RateLimiter.reset_instance()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decorator_success(self):
|
||||
"""Decorator allows successful calls."""
|
||||
|
||||
@rate_limited(operation_type="github")
|
||||
async def test_func():
|
||||
return "success"
|
||||
|
||||
result = await test_func()
|
||||
assert result == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decorator_rate_limited(self):
|
||||
"""Decorator handles rate limiting."""
|
||||
limiter = RateLimiter.get_instance(
|
||||
github_limit=1,
|
||||
github_refill_rate=0.0, # No refill
|
||||
)
|
||||
|
||||
@rate_limited(operation_type="github", max_retries=0)
|
||||
async def test_func():
|
||||
# Consume token manually first
|
||||
if limiter.github_requests == 0:
|
||||
await limiter.acquire_github()
|
||||
return "success"
|
||||
|
||||
# First call succeeds
|
||||
result = await test_func()
|
||||
assert result == "success"
|
||||
|
||||
# Second call should fail (no tokens, no retry)
|
||||
with pytest.raises(RateLimitExceeded):
|
||||
await test_func()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decorator_retries(self):
|
||||
"""Decorator retries on rate limit."""
|
||||
limiter = RateLimiter.get_instance(
|
||||
github_limit=1,
|
||||
github_refill_rate=10.0, # Fast refill for test
|
||||
)
|
||||
call_count = 0
|
||||
|
||||
@rate_limited(operation_type="github", max_retries=2, base_delay=0.1)
|
||||
async def test_func():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# Consume all tokens
|
||||
await limiter.acquire_github()
|
||||
raise Exception("403 rate limit exceeded")
|
||||
return "success"
|
||||
|
||||
result = await test_func()
|
||||
assert result == "success"
|
||||
assert call_count == 2 # Initial + 1 retry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decorator_cost_limit_no_retry(self):
|
||||
"""Cost limit is not retried."""
|
||||
limiter = RateLimiter.get_instance(cost_limit=0.1)
|
||||
|
||||
@rate_limited(operation_type="github")
|
||||
async def test_func():
|
||||
# Exceed cost limit
|
||||
limiter.track_ai_cost(
|
||||
input_tokens=1_000_000,
|
||||
output_tokens=1_000_000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
)
|
||||
return "success"
|
||||
|
||||
with pytest.raises(CostLimitExceeded):
|
||||
await test_func()
|
||||
|
||||
|
||||
class TestCheckRateLimit:
|
||||
"""Test check_rate_limit helper."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset singleton before each test."""
|
||||
RateLimiter.reset_instance()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_github_success(self):
|
||||
"""Check passes when available."""
|
||||
RateLimiter.get_instance(github_limit=100)
|
||||
await check_rate_limit(operation_type="github") # Should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_github_failure(self):
|
||||
"""Check fails when rate limited."""
|
||||
limiter = RateLimiter.get_instance(
|
||||
github_limit=0, # No tokens
|
||||
github_refill_rate=0.0,
|
||||
)
|
||||
with pytest.raises(RateLimitExceeded):
|
||||
await check_rate_limit(operation_type="github")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_cost_success(self):
|
||||
"""Check passes when budget available."""
|
||||
RateLimiter.get_instance(cost_limit=10.0)
|
||||
await check_rate_limit(operation_type="cost") # Should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_cost_failure(self):
|
||||
"""Check fails when budget exceeded."""
|
||||
limiter = RateLimiter.get_instance(cost_limit=0.01)
|
||||
limiter.cost_tracker.total_cost = 10.0 # Manually exceed
|
||||
with pytest.raises(CostLimitExceeded):
|
||||
await check_rate_limit(operation_type="cost")
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests simulating real usage."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset singleton before each test."""
|
||||
RateLimiter.reset_instance()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_github_workflow(self):
|
||||
"""Simulate GitHub automation workflow."""
|
||||
limiter = RateLimiter.get_instance(
|
||||
github_limit=10,
|
||||
github_refill_rate=10.0,
|
||||
cost_limit=5.0,
|
||||
)
|
||||
|
||||
@rate_limited(operation_type="github")
|
||||
async def fetch_pr():
|
||||
return {"number": 123}
|
||||
|
||||
@rate_limited(operation_type="github")
|
||||
async def fetch_diff():
|
||||
return {"files": []}
|
||||
|
||||
# Simulate workflow
|
||||
pr = await fetch_pr()
|
||||
assert pr["number"] == 123
|
||||
|
||||
diff = await fetch_diff()
|
||||
assert "files" in diff
|
||||
|
||||
# Track AI review
|
||||
limiter.track_ai_cost(
|
||||
input_tokens=5000,
|
||||
output_tokens=2000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
operation_name="PR review",
|
||||
)
|
||||
|
||||
# Check stats
|
||||
stats = limiter.statistics()
|
||||
assert stats["github"]["total_requests"] >= 2
|
||||
assert stats["cost"]["total_cost"] > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_burst_handling(self):
|
||||
"""Handle burst of requests."""
|
||||
limiter = RateLimiter.get_instance(
|
||||
github_limit=5,
|
||||
github_refill_rate=5.0,
|
||||
)
|
||||
|
||||
@rate_limited(operation_type="github", max_retries=1, base_delay=0.1)
|
||||
async def api_call(n: int):
|
||||
return n
|
||||
|
||||
# Make 10 calls (will hit limit at 5, then wait for refill)
|
||||
results = []
|
||||
for i in range(10):
|
||||
result = await api_call(i)
|
||||
results.append(result)
|
||||
|
||||
assert len(results) == 10
|
||||
assert results == list(range(10))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cost_tracking_multiple_models(self):
|
||||
"""Track costs across different models."""
|
||||
limiter = RateLimiter.get_instance(cost_limit=100.0)
|
||||
|
||||
# Sonnet for review
|
||||
limiter.track_ai_cost(
|
||||
input_tokens=10_000,
|
||||
output_tokens=5_000,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
operation_name="PR review",
|
||||
)
|
||||
|
||||
# Haiku for triage
|
||||
limiter.track_ai_cost(
|
||||
input_tokens=5_000,
|
||||
output_tokens=2_000,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
operation_name="Issue triage",
|
||||
)
|
||||
|
||||
# Opus for complex analysis
|
||||
limiter.track_ai_cost(
|
||||
input_tokens=20_000,
|
||||
output_tokens=10_000,
|
||||
model="claude-opus-4-5-20251101",
|
||||
operation_name="Architecture review",
|
||||
)
|
||||
|
||||
stats = limiter.statistics()
|
||||
assert stats["cost"]["operations"] == 3
|
||||
assert stats["cost"]["total_cost"] < 100.0
|
||||
|
||||
report = limiter.cost_tracker.usage_report()
|
||||
assert "PR review" in report
|
||||
assert "Issue triage" in report
|
||||
assert "Architecture review" in report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -210,6 +210,7 @@ class GitLabRunnerConfig:
|
||||
# Model settings
|
||||
model: str = "claude-sonnet-4-5-20250929"
|
||||
thinking_level: str = "medium"
|
||||
fast_mode: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -218,6 +219,7 @@ class GitLabRunnerConfig:
|
||||
"instance_url": self.instance_url,
|
||||
"model": self.model,
|
||||
"thinking_level": self.thinking_level,
|
||||
"fast_mode": self.fast_mode,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -168,6 +168,7 @@ Provide your review in the following JSON format:
|
||||
Tuple of (findings, verdict, summary, blockers)
|
||||
"""
|
||||
from core.client import create_client
|
||||
from phase_config import get_model_betas, resolve_model_id
|
||||
|
||||
self._report_progress(
|
||||
"analyzing", 30, "Running AI analysis...", mr_iid=context.mr_iid
|
||||
@@ -229,11 +230,16 @@ Provide your review in the following JSON format:
|
||||
project_root = self.project_dir.parent.parent
|
||||
|
||||
# Create the client
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
betas = get_model_betas(model_shorthand)
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.gitlab_dir,
|
||||
model=self.config.model,
|
||||
model=model,
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
betas=betas,
|
||||
fast_mode=self.config.fast_mode,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
|
||||
@@ -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=["none", "low", "medium", "high", "ultrathink"],
|
||||
help="Thinking level for extended reasoning (default: medium)",
|
||||
help="Thinking level for extended reasoning (low, medium, high)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refresh",
|
||||
@@ -122,9 +122,17 @@ def main():
|
||||
action="store_true",
|
||||
help="Append new ideas to existing session instead of replacing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fast-mode",
|
||||
action="store_true",
|
||||
help="Enable Fast Mode for faster Opus 4.6 output",
|
||||
)
|
||||
|
||||
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():
|
||||
@@ -152,6 +160,7 @@ def main():
|
||||
thinking_level=args.thinking_level,
|
||||
refresh=args.refresh,
|
||||
append=args.append,
|
||||
fast_mode=args.fast_mode,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -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:
|
||||
@@ -190,7 +190,6 @@ Current question: {message}"""
|
||||
)
|
||||
|
||||
try:
|
||||
# Build options dict - only include max_thinking_tokens if not None
|
||||
options_kwargs = {
|
||||
"model": resolve_model_id(model), # Resolve via API Profile if configured
|
||||
"system_prompt": system_prompt,
|
||||
@@ -199,9 +198,7 @@ Current question: {message}"""
|
||||
"cwd": str(project_path),
|
||||
}
|
||||
|
||||
# Only add thinking tokens if the thinking level is not "none"
|
||||
if max_thinking_tokens is not None:
|
||||
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
|
||||
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
|
||||
|
||||
# Create Claude SDK client with appropriate settings for insights
|
||||
client = ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
@@ -356,11 +353,13 @@ def main():
|
||||
parser.add_argument(
|
||||
"--thinking-level",
|
||||
default="medium",
|
||||
choices=["none", "low", "medium", "high", "ultrathink"],
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from ui import muted, print_status
|
||||
|
||||
from .models import RoadmapPhaseResult
|
||||
@@ -146,23 +147,22 @@ Output your findings to competitor_analysis.json.
|
||||
|
||||
def _create_disabled_analysis_file(self):
|
||||
"""Create an analysis file indicating the feature is disabled."""
|
||||
with open(self.analysis_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"enabled": False,
|
||||
"reason": "Competitor analysis not enabled by user",
|
||||
"competitors": [],
|
||||
"market_gaps": [],
|
||||
"insights_summary": {
|
||||
"top_pain_points": [],
|
||||
"differentiator_opportunities": [],
|
||||
"market_trends": [],
|
||||
},
|
||||
"created_at": datetime.now().isoformat(),
|
||||
write_json_atomic(
|
||||
self.analysis_file,
|
||||
{
|
||||
"enabled": False,
|
||||
"reason": "Competitor analysis not enabled by user",
|
||||
"competitors": [],
|
||||
"market_gaps": [],
|
||||
"insights_summary": {
|
||||
"top_pain_points": [],
|
||||
"differentiator_opportunities": [],
|
||||
"market_trends": [],
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def _create_error_analysis_file(self, error: str, errors: list[str] | None = None):
|
||||
"""Create an analysis file with error information."""
|
||||
@@ -181,5 +181,4 @@ Output your findings to competitor_analysis.json.
|
||||
if errors:
|
||||
data["errors"] = errors
|
||||
|
||||
with open(self.analysis_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
write_json_atomic(self.analysis_file, data, indent=2)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
Graphiti integration for retrieving graph hints during roadmap generation.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from debug import debug, debug_error, debug_success
|
||||
from graphiti_providers import get_graph_hints, is_graphiti_enabled
|
||||
from ui import print_status
|
||||
@@ -81,42 +81,36 @@ class GraphHintsProvider:
|
||||
|
||||
def _create_disabled_hints_file(self):
|
||||
"""Create a hints file indicating Graphiti is disabled."""
|
||||
with open(self.hints_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"enabled": False,
|
||||
"reason": "Graphiti not configured",
|
||||
"hints": [],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
write_json_atomic(
|
||||
self.hints_file,
|
||||
{
|
||||
"enabled": False,
|
||||
"reason": "Graphiti not configured",
|
||||
"hints": [],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
def _save_hints(self, hints: list):
|
||||
"""Save retrieved hints to file."""
|
||||
with open(self.hints_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"enabled": True,
|
||||
"hints": hints,
|
||||
"hint_count": len(hints),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
write_json_atomic(
|
||||
self.hints_file,
|
||||
{
|
||||
"enabled": True,
|
||||
"hints": hints,
|
||||
"hint_count": len(hints),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
def _save_error_hints(self, error: str):
|
||||
"""Save error information to hints file."""
|
||||
with open(self.hints_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"enabled": True,
|
||||
"error": error,
|
||||
"hints": [],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
write_json_atomic(
|
||||
self.hints_file,
|
||||
{
|
||||
"enabled": True,
|
||||
"error": error,
|
||||
"hints": [],
|
||||
"created_at": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
@@ -509,8 +510,7 @@ Output the complete roadmap to roadmap.json.
|
||||
|
||||
# Write back the merged roadmap
|
||||
try:
|
||||
with open(self.roadmap_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
write_json_atomic(self.roadmap_file, data, indent=2)
|
||||
debug_success(
|
||||
"roadmap_phase",
|
||||
"Merged preserved features into roadmap.json",
|
||||
|
||||
@@ -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=["none", "low", "medium", "high", "ultrathink"],
|
||||
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,8 +185,7 @@ Examples:
|
||||
"--thinking-level",
|
||||
type=str,
|
||||
default="medium",
|
||||
choices=["none", "low", "medium", "high", "ultrathink"],
|
||||
help="Thinking level for extended thinking (none, low, medium, high, ultrathink)",
|
||||
help="Thinking level for extended thinking (low, medium, high)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-ai-assessment",
|
||||
@@ -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(
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Memory Save Verification Script
|
||||
================================
|
||||
|
||||
Tests the memory save functionality with Graphiti enabled.
|
||||
Run with DEBUG=true SENTRY_DEV=true to verify Sentry events.
|
||||
|
||||
Usage:
|
||||
cd apps/backend
|
||||
DEBUG=true python scripts/test_memory_save.py
|
||||
|
||||
# With Sentry enabled (for Sentry event verification):
|
||||
DEBUG=true SENTRY_DEV=true python scripts/test_memory_save.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Add the backend directory to the path so we can import modules
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BACKEND_DIR = SCRIPT_DIR.parent
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if os.environ.get("DEBUG") else logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def test_memory_imports():
|
||||
"""Test that all memory-related imports work correctly."""
|
||||
print("\n=== Testing Memory System Imports ===")
|
||||
|
||||
errors = []
|
||||
|
||||
# Test memory_manager imports
|
||||
try:
|
||||
from agents.memory_manager import (
|
||||
debug_memory_system_status,
|
||||
get_graphiti_context,
|
||||
save_session_memory,
|
||||
)
|
||||
|
||||
print("[OK] agents.memory_manager imports successful")
|
||||
except ImportError as e:
|
||||
errors.append(f"agents.memory_manager: {e}")
|
||||
print(f"[FAIL] agents.memory_manager: {e}")
|
||||
|
||||
# Test graphiti_helpers imports
|
||||
try:
|
||||
from memory.graphiti_helpers import (
|
||||
get_graphiti_memory,
|
||||
is_graphiti_memory_enabled,
|
||||
save_to_graphiti_async,
|
||||
)
|
||||
|
||||
print("[OK] memory.graphiti_helpers imports successful")
|
||||
except ImportError as e:
|
||||
errors.append(f"memory.graphiti_helpers: {e}")
|
||||
print(f"[FAIL] memory.graphiti_helpers: {e}")
|
||||
|
||||
# Test graphiti_config imports
|
||||
try:
|
||||
from graphiti_config import (
|
||||
get_graphiti_status,
|
||||
is_graphiti_enabled,
|
||||
)
|
||||
|
||||
print("[OK] graphiti_config imports successful")
|
||||
except ImportError as e:
|
||||
errors.append(f"graphiti_config: {e}")
|
||||
print(f"[FAIL] graphiti_config: {e}")
|
||||
|
||||
# Test sentry imports
|
||||
try:
|
||||
from core.sentry import (
|
||||
capture_exception,
|
||||
capture_message,
|
||||
init_sentry,
|
||||
)
|
||||
from core.sentry import (
|
||||
is_enabled as sentry_is_enabled,
|
||||
)
|
||||
|
||||
print("[OK] core.sentry imports successful")
|
||||
except ImportError as e:
|
||||
errors.append(f"core.sentry: {e}")
|
||||
print(f"[FAIL] core.sentry: {e}")
|
||||
|
||||
# Test graphiti queries_pkg imports
|
||||
try:
|
||||
from integrations.graphiti.queries_pkg.client import GraphitiClient
|
||||
from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory
|
||||
from integrations.graphiti.queries_pkg.queries import GraphitiQueries
|
||||
from integrations.graphiti.queries_pkg.search import GraphitiSearch
|
||||
|
||||
print("[OK] integrations.graphiti.queries_pkg imports successful")
|
||||
except ImportError as e:
|
||||
errors.append(f"integrations.graphiti.queries_pkg: {e}")
|
||||
print(f"[FAIL] integrations.graphiti.queries_pkg: {e}")
|
||||
|
||||
if errors:
|
||||
print(f"\n[FAIL] {len(errors)} import error(s) found")
|
||||
return False
|
||||
else:
|
||||
print("\n[OK] All imports successful")
|
||||
return True
|
||||
|
||||
|
||||
async def test_graphiti_status():
|
||||
"""Test Graphiti configuration status."""
|
||||
print("\n=== Testing Graphiti Status ===")
|
||||
|
||||
try:
|
||||
from graphiti_config import get_graphiti_status, is_graphiti_enabled
|
||||
|
||||
enabled = is_graphiti_enabled()
|
||||
status = get_graphiti_status()
|
||||
|
||||
print(f"Graphiti Enabled: {enabled}")
|
||||
print(f"Graphiti Available: {status.get('available')}")
|
||||
print(f" Host: {status.get('host')}")
|
||||
print(f" Port: {status.get('port')}")
|
||||
print(f" Database: {status.get('database')}")
|
||||
print(f" LLM Provider: {status.get('llm_provider')}")
|
||||
print(f" Embedder Provider: {status.get('embedder_provider')}")
|
||||
|
||||
if not status.get("available"):
|
||||
print(f" Reason: {status.get('reason')}")
|
||||
print(f" Errors: {status.get('errors')}")
|
||||
|
||||
return enabled
|
||||
except Exception as e:
|
||||
print(f"[FAIL] Error checking Graphiti status: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_sentry_status():
|
||||
"""Test Sentry configuration status.
|
||||
|
||||
Returns True if:
|
||||
- Sentry is enabled and ready, OR
|
||||
- Sentry is properly disabled due to configuration (no DSN, not dev mode, SDK not installed)
|
||||
|
||||
Only returns False if there's an unexpected error.
|
||||
"""
|
||||
print("\n=== Testing Sentry Status ===")
|
||||
|
||||
try:
|
||||
from core.sentry import init_sentry, is_enabled, is_initialized
|
||||
|
||||
# Check if SENTRY_DEV is set
|
||||
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
|
||||
sentry_dsn = os.environ.get("SENTRY_DSN", "")
|
||||
|
||||
print(f"SENTRY_DSN set: {bool(sentry_dsn)}")
|
||||
print(f"SENTRY_DEV: {sentry_dev}")
|
||||
|
||||
# Initialize Sentry
|
||||
init_sentry(component="memory-test")
|
||||
|
||||
print(f"Sentry Initialized: {is_initialized()}")
|
||||
print(f"Sentry Enabled: {is_enabled()}")
|
||||
|
||||
if is_enabled():
|
||||
print("[OK] Sentry is enabled and ready to capture events")
|
||||
else:
|
||||
# Sentry being disabled is OK - it just means configuration requires it
|
||||
if not sentry_dsn:
|
||||
print("[INFO] Sentry disabled - no SENTRY_DSN configured (expected)")
|
||||
elif not sentry_dev:
|
||||
print(
|
||||
"[INFO] Sentry disabled in dev mode - set SENTRY_DEV=true to enable"
|
||||
)
|
||||
else:
|
||||
print("[INFO] Sentry disabled - sentry-sdk may not be installed")
|
||||
print(
|
||||
"[OK] Sentry integration configured correctly (disabled by configuration)"
|
||||
)
|
||||
|
||||
# Return True even if disabled - we're testing that the integration works,
|
||||
# not that Sentry is necessarily enabled
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[FAIL] Error checking Sentry status: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_memory_save_flow():
|
||||
"""Test the memory save flow end-to-end."""
|
||||
print("\n=== Testing Memory Save Flow ===")
|
||||
|
||||
# Create temporary directories for testing
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
spec_dir = tmp_path / "test_spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir(parents=True)
|
||||
|
||||
print(f"Test spec_dir: {spec_dir}")
|
||||
print(f"Test project_dir: {project_dir}")
|
||||
|
||||
try:
|
||||
from agents.memory_manager import save_session_memory
|
||||
|
||||
# Test memory save with sample data
|
||||
subtask_id = "test-subtask-1"
|
||||
session_num = 1
|
||||
success = True
|
||||
subtasks_completed = ["test-subtask-1"]
|
||||
discoveries = {
|
||||
"files_understood": {"test.py": "Test file for memory verification"},
|
||||
"patterns_found": ["Test pattern: Always verify imports"],
|
||||
"gotchas_encountered": ["Test gotcha: Check async/await usage"],
|
||||
}
|
||||
|
||||
print("\nSaving test session memory...")
|
||||
print(f" subtask_id: {subtask_id}")
|
||||
print(f" session_num: {session_num}")
|
||||
print(f" success: {success}")
|
||||
|
||||
result, storage_type = await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=success,
|
||||
subtasks_completed=subtasks_completed,
|
||||
discoveries=discoveries,
|
||||
)
|
||||
|
||||
print("\nMemory Save Result:")
|
||||
print(f" Success: {result}")
|
||||
print(f" Storage Type: {storage_type}")
|
||||
|
||||
if result:
|
||||
print(f"[OK] Memory save succeeded using {storage_type} storage")
|
||||
|
||||
# Verify file was created if file-based
|
||||
if storage_type == "file":
|
||||
memory_file = (
|
||||
spec_dir
|
||||
/ "memory"
|
||||
/ "session_insights"
|
||||
/ f"session_{session_num:03d}.json"
|
||||
)
|
||||
if memory_file.exists():
|
||||
print(f"[OK] Memory file created: {memory_file}")
|
||||
else:
|
||||
print(f"[WARN] Memory file not found: {memory_file}")
|
||||
|
||||
return True
|
||||
else:
|
||||
print("[FAIL] Memory save failed")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FAIL] Error during memory save test: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Test Sentry capture
|
||||
try:
|
||||
from core.sentry import capture_exception, is_enabled
|
||||
|
||||
if is_enabled():
|
||||
capture_exception(
|
||||
e,
|
||||
operation="test_memory_save",
|
||||
context="verification_script",
|
||||
)
|
||||
print("[INFO] Exception captured to Sentry")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def test_sentry_capture():
|
||||
"""Test that Sentry capture works (only if Sentry is enabled)."""
|
||||
print("\n=== Testing Sentry Capture ===")
|
||||
|
||||
try:
|
||||
from core.sentry import (
|
||||
capture_exception,
|
||||
capture_message,
|
||||
is_enabled,
|
||||
)
|
||||
|
||||
if not is_enabled():
|
||||
print("[SKIP] Sentry not enabled - skipping capture test")
|
||||
print(" Set SENTRY_DSN and SENTRY_DEV=true to test Sentry capture")
|
||||
return True
|
||||
|
||||
# Test capture_message
|
||||
print("Sending test message to Sentry...")
|
||||
capture_message(
|
||||
"Memory save verification script test message",
|
||||
level="info",
|
||||
test_type="verification",
|
||||
component="memory-test",
|
||||
)
|
||||
print("[OK] Test message sent to Sentry")
|
||||
|
||||
# Test capture_exception
|
||||
print("Sending test exception to Sentry...")
|
||||
try:
|
||||
raise ValueError("Test exception for memory save verification")
|
||||
except Exception as e:
|
||||
capture_exception(
|
||||
e,
|
||||
operation="test_exception",
|
||||
context="verification_script",
|
||||
)
|
||||
print("[OK] Test exception sent to Sentry")
|
||||
|
||||
print("\n[INFO] Check your Sentry dashboard for the test events")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FAIL] Error testing Sentry capture: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all memory save verification tests."""
|
||||
print("=" * 60)
|
||||
print("Memory Save Verification Script")
|
||||
print("=" * 60)
|
||||
print(f"DEBUG: {os.environ.get('DEBUG', 'not set')}")
|
||||
print(f"SENTRY_DEV: {os.environ.get('SENTRY_DEV', 'not set')}")
|
||||
print(f"GRAPHITI_ENABLED: {os.environ.get('GRAPHITI_ENABLED', 'not set')}")
|
||||
|
||||
results = {}
|
||||
|
||||
# Run tests
|
||||
results["imports"] = await test_memory_imports()
|
||||
results["graphiti_status"] = await test_graphiti_status()
|
||||
results["sentry_status"] = await test_sentry_status()
|
||||
results["memory_save"] = await test_memory_save_flow()
|
||||
results["sentry_capture"] = await test_sentry_capture()
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("Test Summary")
|
||||
print("=" * 60)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test_name, result in results.items():
|
||||
status = "[OK]" if result else "[FAIL]"
|
||||
print(f" {status} {test_name}")
|
||||
if result:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
print(f"\nTotal: {passed} passed, {failed} failed")
|
||||
|
||||
if failed > 0:
|
||||
print("\n[FAIL] Some tests failed")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n[OK] All tests passed")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -604,3 +604,28 @@ def get_recovery_context(spec_dir: Path, project_dir: Path, subtask_id: str) ->
|
||||
"subtask_history": manager.get_subtask_history(subtask_id),
|
||||
"stuck_subtasks": manager.get_stuck_subtasks(),
|
||||
}
|
||||
|
||||
|
||||
def reset_subtask(spec_dir: Path, project_dir: Path, subtask_id: str) -> None:
|
||||
"""
|
||||
Reset a subtask's attempt history (module-level wrapper).
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project directory
|
||||
subtask_id: Subtask ID to reset
|
||||
"""
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
manager.reset_subtask(subtask_id)
|
||||
|
||||
|
||||
def clear_stuck_subtasks(spec_dir: Path, project_dir: Path) -> None:
|
||||
"""
|
||||
Clear all stuck subtasks (module-level wrapper).
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project directory
|
||||
"""
|
||||
manager = RecoveryManager(spec_dir, project_dir)
|
||||
manager.clear_stuck_subtasks()
|
||||
|
||||
@@ -382,7 +382,7 @@ async def run_ai_complexity_assessment(
|
||||
context += f"\n**Task Description**: {task_description or 'Not provided'}\n"
|
||||
|
||||
# Add project index if available
|
||||
auto_build_index = spec_dir.parent.parent / "auto-claude" / "project_index.json"
|
||||
auto_build_index = spec_dir.parent.parent / "project_index.json"
|
||||
if auto_build_index.exists():
|
||||
context += f"\n**Project Index**: Available at {auto_build_index}\n"
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def run_context_discovery(
|
||||
if context_file.exists():
|
||||
return True, "context.json already exists"
|
||||
|
||||
script_path = project_dir / "auto-claude" / "context.py"
|
||||
script_path = project_dir / ".auto-claude" / "context.py"
|
||||
if not script_path.exists():
|
||||
return False, f"Script not found: {script_path}"
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ def run_discovery_script(
|
||||
(success, output_message)
|
||||
"""
|
||||
spec_index = spec_dir / "project_index.json"
|
||||
auto_build_index = project_dir / "auto-claude" / "project_index.json"
|
||||
auto_build_index = project_dir / ".auto-claude" / "project_index.json"
|
||||
|
||||
# Check if project_index already exists
|
||||
if auto_build_index.exists() and not spec_index.exists():
|
||||
|
||||
@@ -22,7 +22,7 @@ def run_script(project_dir: Path, script: str, args: list[str]) -> tuple[bool, s
|
||||
Returns:
|
||||
Tuple of (success: bool, output: str)
|
||||
"""
|
||||
script_path = project_dir / "auto-claude" / script
|
||||
script_path = project_dir / ".auto-claude" / script
|
||||
|
||||
if not script_path.exists():
|
||||
return False, f"Script not found: {script_path}"
|
||||
|
||||
@@ -54,6 +54,7 @@ class AgentRunner:
|
||||
additional_context: str = "",
|
||||
interactive: bool = False,
|
||||
thinking_budget: int | None = None,
|
||||
thinking_level: str = "medium",
|
||||
prior_phase_summaries: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Run an agent with the given prompt.
|
||||
@@ -63,6 +64,7 @@ class AgentRunner:
|
||||
additional_context: Additional context to add to the prompt
|
||||
interactive: Whether to run in interactive mode
|
||||
thinking_budget: Token budget for extended thinking (None = disabled)
|
||||
thinking_level: Thinking level string (low, medium, high)
|
||||
prior_phase_summaries: Summaries from previous phases for context
|
||||
|
||||
Returns:
|
||||
@@ -121,12 +123,31 @@ class AgentRunner:
|
||||
)
|
||||
# Lazy import to avoid circular import with core.client
|
||||
from core.client import create_client
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_model_betas,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.spec_dir,
|
||||
self.model,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
resolved_model,
|
||||
betas=betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
current_tool = None
|
||||
|
||||
@@ -71,7 +71,7 @@ class SpecOrchestrator:
|
||||
spec_name: Optional spec name (for existing specs)
|
||||
spec_dir: Optional existing spec directory (for UI integration)
|
||||
model: The model to use for agent execution
|
||||
thinking_level: Thinking level (none, low, medium, high, ultrathink)
|
||||
thinking_level: Thinking level (low, medium, high)
|
||||
complexity_override: Force a specific complexity level
|
||||
use_ai_assessment: Whether to use AI for complexity assessment
|
||||
"""
|
||||
@@ -158,6 +158,7 @@ class SpecOrchestrator:
|
||||
additional_context,
|
||||
interactive,
|
||||
thinking_budget=thinking_budget,
|
||||
thinking_level=self.thinking_level,
|
||||
prior_phase_summaries=prior_summaries if prior_summaries else None,
|
||||
)
|
||||
|
||||
@@ -603,7 +604,7 @@ class SpecOrchestrator:
|
||||
The complexity assessment
|
||||
"""
|
||||
project_index = {}
|
||||
auto_build_index = self.project_dir / "auto-claude" / "project_index.json"
|
||||
auto_build_index = self.project_dir / ".auto-claude" / "project_index.json"
|
||||
if auto_build_index.exists():
|
||||
with open(auto_build_index, encoding="utf-8") as f:
|
||||
project_index = json.load(f)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Backward compatibility shim - import from analysis.test_discovery instead."""
|
||||
|
||||
from analysis.test_discovery import (
|
||||
FRAMEWORK_PATTERNS,
|
||||
TestDiscovery,
|
||||
TestDiscoveryResult,
|
||||
TestFramework,
|
||||
discover_tests,
|
||||
get_test_command,
|
||||
get_test_frameworks,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TestFramework",
|
||||
"TestDiscoveryResult",
|
||||
"TestDiscovery",
|
||||
"discover_tests",
|
||||
"get_test_command",
|
||||
"get_test_frameworks",
|
||||
"FRAMEWORK_PATTERNS",
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.6-beta.2",
|
||||
"version": "2.7.6-beta.3",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
@@ -95,7 +95,6 @@
|
||||
"react-dom": "^19.2.3",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.2.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* End-to-End Integration Tests for Rate Limit Subtask Recovery
|
||||
*
|
||||
* Tests the complete recovery flow:
|
||||
* 1. Task execution with multiple subtasks
|
||||
* 2. Rate limit error during execution
|
||||
* 3. Subtask reset to pending in implementation_plan.json
|
||||
* 4. IPC events emitted correctly
|
||||
* 5. Task resumes automatically
|
||||
* 6. Completed subtasks maintain their status
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Test directories
|
||||
let TEST_DIR: string;
|
||||
let TEST_SPEC_DIR: string;
|
||||
let PLAN_PATH: string;
|
||||
|
||||
// Setup test directories
|
||||
function setupTestDirs(): void {
|
||||
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'rate-limit-recovery-test-'));
|
||||
TEST_SPEC_DIR = path.join(TEST_DIR, '.auto-claude/specs/001-test-feature');
|
||||
PLAN_PATH = path.join(TEST_SPEC_DIR, 'implementation_plan.json');
|
||||
mkdirSync(TEST_SPEC_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Create implementation plan with mixed subtask states
|
||||
function createMixedStatePlan() {
|
||||
return {
|
||||
feature: 'Test Feature with Rate Limit Recovery',
|
||||
workflow_type: 'feature',
|
||||
services_involved: ['backend', 'frontend'],
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-1',
|
||||
name: 'Implementation Phase',
|
||||
type: 'implementation',
|
||||
subtasks: [
|
||||
{
|
||||
id: 'subtask-1-1',
|
||||
description: 'First subtask - already completed',
|
||||
status: 'completed',
|
||||
started_at: '2026-01-31T12:00:00Z',
|
||||
completed_at: '2026-01-31T12:05:00Z',
|
||||
service: 'backend'
|
||||
},
|
||||
{
|
||||
id: 'subtask-1-2',
|
||||
description: 'Second subtask - currently in progress',
|
||||
status: 'in_progress',
|
||||
started_at: '2026-01-31T12:05:00Z',
|
||||
completed_at: null,
|
||||
service: 'backend'
|
||||
},
|
||||
{
|
||||
id: 'subtask-1-3',
|
||||
description: 'Third subtask - pending',
|
||||
status: 'pending',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
service: 'frontend'
|
||||
},
|
||||
{
|
||||
id: 'subtask-1-4',
|
||||
description: 'Fourth subtask - failed previously',
|
||||
status: 'failed',
|
||||
started_at: '2026-01-31T11:00:00Z',
|
||||
completed_at: null,
|
||||
service: 'frontend'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'phase-2',
|
||||
name: 'Testing Phase',
|
||||
type: 'testing',
|
||||
subtasks: [
|
||||
{
|
||||
id: 'subtask-2-1',
|
||||
description: 'Write unit tests',
|
||||
status: 'pending',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
service: 'backend'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
status: 'in_progress',
|
||||
planStatus: 'in_progress',
|
||||
created_at: '2026-01-31T11:00:00Z',
|
||||
updated_at: '2026-01-31T12:05:00Z'
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to read plan from file
|
||||
function readPlan() {
|
||||
const content = readFileSync(PLAN_PATH, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
// Types for plan structure
|
||||
interface Subtask {
|
||||
id: string;
|
||||
description: string;
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
service: string;
|
||||
}
|
||||
|
||||
interface Phase {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
subtasks: Subtask[];
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
feature: string;
|
||||
workflow_type: string;
|
||||
services_involved: string[];
|
||||
phases: Phase[];
|
||||
status: string;
|
||||
planStatus: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Helper to find subtask in plan
|
||||
function findSubtask(plan: Plan, subtaskId: string): Subtask | null {
|
||||
for (const phase of plan.phases) {
|
||||
const subtask = phase.subtasks.find((s) => s.id === subtaskId);
|
||||
if (subtask) return subtask;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('Rate Limit Subtask Recovery - End-to-End', () => {
|
||||
beforeEach(() => {
|
||||
setupTestDirs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (TEST_DIR) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Subtask Reset on Rate Limit', () => {
|
||||
it('should reset in_progress subtask to pending when rate limit occurs', () => {
|
||||
// Setup: Create plan with in_progress subtask
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Verify initial state
|
||||
const initialPlan = readPlan();
|
||||
const inProgressSubtask = findSubtask(initialPlan, 'subtask-1-2')!;
|
||||
expect(inProgressSubtask).toBeTruthy();
|
||||
expect(inProgressSubtask.status).toBe('in_progress');
|
||||
expect(inProgressSubtask.started_at).toBeTruthy();
|
||||
|
||||
// Simulate rate limit reset logic (from resetStuckSubtasks helper)
|
||||
for (const phase of initialPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated plan
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(initialPlan, null, 2));
|
||||
|
||||
// Verify: subtask reset to pending
|
||||
const updatedPlan = readPlan();
|
||||
const resetSubtask = findSubtask(updatedPlan, 'subtask-1-2')!;
|
||||
expect(resetSubtask).toBeTruthy();
|
||||
expect(resetSubtask.status).toBe('pending');
|
||||
expect(resetSubtask.started_at).toBeNull();
|
||||
expect(resetSubtask.completed_at).toBeNull();
|
||||
});
|
||||
|
||||
it('should reset failed subtask to pending when recovery triggered', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Verify initial state
|
||||
const initialPlan = readPlan();
|
||||
const failedSubtask = findSubtask(initialPlan, 'subtask-1-4')!;
|
||||
expect(failedSubtask).toBeTruthy();
|
||||
expect(failedSubtask.status).toBe('failed');
|
||||
|
||||
// Simulate reset
|
||||
for (const phase of initialPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(initialPlan, null, 2));
|
||||
|
||||
// Verify: failed subtask reset
|
||||
const updatedPlan = readPlan();
|
||||
const resetSubtask = findSubtask(updatedPlan, 'subtask-1-4')!;
|
||||
expect(resetSubtask).toBeTruthy();
|
||||
expect(resetSubtask.status).toBe('pending');
|
||||
expect(resetSubtask.started_at).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve completed subtasks during reset', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Get completed subtask before reset
|
||||
const initialPlan = readPlan();
|
||||
const completedSubtask = findSubtask(initialPlan, 'subtask-1-1')!;
|
||||
expect(completedSubtask).toBeTruthy();
|
||||
expect(completedSubtask.status).toBe('completed');
|
||||
const originalCompletedAt = completedSubtask.completed_at;
|
||||
|
||||
// Simulate reset (should skip completed subtasks)
|
||||
for (const phase of initialPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(initialPlan, null, 2));
|
||||
|
||||
// Verify: completed subtask unchanged
|
||||
const updatedPlan = readPlan();
|
||||
const preservedSubtask = findSubtask(updatedPlan, 'subtask-1-1')!;
|
||||
expect(preservedSubtask).toBeTruthy();
|
||||
expect(preservedSubtask.status).toBe('completed');
|
||||
expect(preservedSubtask.completed_at).toBe(originalCompletedAt);
|
||||
});
|
||||
|
||||
it('should reset all stuck subtasks across multiple phases', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
const initialPlan = readPlan();
|
||||
|
||||
// Count stuck subtasks before reset
|
||||
let stuckCount = 0;
|
||||
for (const phase of initialPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
stuckCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(stuckCount).toBe(2); // subtask-1-2 (in_progress) + subtask-1-4 (failed)
|
||||
|
||||
// Simulate reset
|
||||
for (const phase of initialPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(initialPlan, null, 2));
|
||||
|
||||
// Verify: all stuck subtasks reset
|
||||
const updatedPlan = readPlan();
|
||||
let resetCount = 0;
|
||||
for (const phase of updatedPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.id === 'subtask-1-2' || subtask.id === 'subtask-1-4') {
|
||||
expect(subtask.status).toBe('pending');
|
||||
expect(subtask.started_at).toBeNull();
|
||||
resetCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(resetCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Resume After Recovery', () => {
|
||||
it('should allow task to resume with reset subtasks', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Reset stuck subtasks
|
||||
const updatedPlan = readPlan();
|
||||
for (const phase of updatedPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(updatedPlan, null, 2));
|
||||
|
||||
// Simulate get_next_subtask logic
|
||||
const resumedPlan = readPlan();
|
||||
let nextSubtask: Subtask | null = null;
|
||||
for (const phase of resumedPlan.phases) {
|
||||
const pending = phase.subtasks.find((s: Subtask) => s.status === 'pending');
|
||||
if (pending) {
|
||||
nextSubtask = pending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify: task can find next subtask to resume
|
||||
expect(nextSubtask).toBeTruthy();
|
||||
expect(nextSubtask!.id).toBe('subtask-1-2'); // Previously stuck, now pending
|
||||
expect(nextSubtask!.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should maintain correct subtask order after reset', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Reset and collect pending subtasks
|
||||
const updatedPlan = readPlan();
|
||||
for (const phase of updatedPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(updatedPlan, null, 2));
|
||||
|
||||
const resumedPlan = readPlan();
|
||||
const allPendingSubtasks: string[] = [];
|
||||
for (const phase of resumedPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'pending') {
|
||||
allPendingSubtasks.push(subtask.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify: pending subtasks in correct order
|
||||
expect(allPendingSubtasks).toEqual([
|
||||
'subtask-1-2', // Reset from in_progress
|
||||
'subtask-1-3', // Was already pending
|
||||
'subtask-1-4', // Reset from failed
|
||||
'subtask-2-1' // Was already pending
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Atomic File Operations', () => {
|
||||
it('should maintain valid JSON structure after reset', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Simulate reset
|
||||
const updatedPlan = readPlan();
|
||||
for (const phase of updatedPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write atomically (simulate atomic write)
|
||||
const tempPath = PLAN_PATH + '.tmp';
|
||||
writeFileSync(tempPath, JSON.stringify(updatedPlan, null, 2));
|
||||
rmSync(PLAN_PATH);
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(updatedPlan, null, 2));
|
||||
|
||||
// Verify: plan is valid JSON
|
||||
expect(() => {
|
||||
const verifyPlan = readPlan();
|
||||
expect(verifyPlan.phases).toBeDefined();
|
||||
expect(Array.isArray(verifyPlan.phases)).toBe(true);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle missing plan file gracefully', () => {
|
||||
// Don't create plan file
|
||||
const missingPlanPath = path.join(TEST_SPEC_DIR, 'nonexistent_plan.json');
|
||||
|
||||
// Simulate graceful handling
|
||||
let errorOccurred = false;
|
||||
try {
|
||||
readFileSync(missingPlanPath, 'utf-8');
|
||||
} catch (error) {
|
||||
errorOccurred = true;
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
|
||||
expect(errorOccurred).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset Count Tracking', () => {
|
||||
it('should count number of subtasks reset', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
const updatedPlan = readPlan();
|
||||
let resetCount = 0;
|
||||
|
||||
for (const phase of updatedPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
resetCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(resetCount).toBe(2); // subtask-1-2 and subtask-1-4
|
||||
});
|
||||
|
||||
it('should return zero when no subtasks need reset', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
|
||||
// Mark all subtasks as either completed or pending
|
||||
for (const phase of plan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
const updatedPlan = readPlan();
|
||||
let resetCount = 0;
|
||||
|
||||
for (const phase of updatedPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
resetCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(resetCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle plan with no phases', () => {
|
||||
const emptyPlan = {
|
||||
feature: 'Empty Plan',
|
||||
phases: [],
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(emptyPlan, null, 2));
|
||||
|
||||
const plan = readPlan();
|
||||
let resetCount = 0;
|
||||
|
||||
for (const phase of plan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
resetCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(resetCount).toBe(0);
|
||||
expect(plan.phases).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle phase with no subtasks', () => {
|
||||
const planWithEmptyPhase = {
|
||||
feature: 'Plan with Empty Phase',
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-1',
|
||||
name: 'Empty Phase',
|
||||
subtasks: []
|
||||
}
|
||||
],
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(planWithEmptyPhase, null, 2));
|
||||
|
||||
const plan = readPlan();
|
||||
let resetCount = 0;
|
||||
|
||||
for (const phase of plan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
resetCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(resetCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve all subtask fields except status and timestamps', () => {
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
const initialPlan = readPlan();
|
||||
const originalSubtask = findSubtask(initialPlan, 'subtask-1-2')!;
|
||||
expect(originalSubtask).toBeTruthy();
|
||||
const originalDescription = originalSubtask.description;
|
||||
const originalService = originalSubtask.service;
|
||||
|
||||
// Reset
|
||||
for (const phase of initialPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(initialPlan, null, 2));
|
||||
|
||||
const updatedPlan = readPlan();
|
||||
const resetSubtask = findSubtask(updatedPlan, 'subtask-1-2')!;
|
||||
expect(resetSubtask).toBeTruthy();
|
||||
|
||||
expect(resetSubtask.description).toBe(originalDescription);
|
||||
expect(resetSubtask.service).toBe(originalService);
|
||||
expect(resetSubtask.id).toBe('subtask-1-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with Recovery Flow', () => {
|
||||
beforeEach(() => {
|
||||
setupTestDirs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (TEST_DIR) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should complete full recovery cycle: error → reset → resume', () => {
|
||||
// Step 1: Task running with in_progress subtask
|
||||
const plan = createMixedStatePlan();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(plan, null, 2));
|
||||
|
||||
const initialPlan = readPlan();
|
||||
expect(findSubtask(initialPlan, 'subtask-1-2')!.status).toBe('in_progress');
|
||||
|
||||
// Step 2: Rate limit error occurs → subtask reset
|
||||
for (const phase of initialPlan.phases) {
|
||||
for (const subtask of phase.subtasks) {
|
||||
if (subtask.status === 'in_progress' || subtask.status === 'failed') {
|
||||
subtask.status = 'pending';
|
||||
subtask.started_at = null;
|
||||
subtask.completed_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(initialPlan, null, 2));
|
||||
|
||||
const resetPlan = readPlan();
|
||||
expect(findSubtask(resetPlan, 'subtask-1-2')!.status).toBe('pending');
|
||||
|
||||
// Step 3: Task resumes → finds next pending subtask
|
||||
let nextSubtask: Subtask | null = null;
|
||||
for (const phase of resetPlan.phases) {
|
||||
const pending = phase.subtasks.find((s: Subtask) => s.status === 'pending');
|
||||
if (pending) {
|
||||
nextSubtask = pending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(nextSubtask).toBeTruthy();
|
||||
expect(nextSubtask!.id).toBe('subtask-1-2');
|
||||
|
||||
// Step 4: Subtask execution starts → status updates to in_progress
|
||||
nextSubtask!.status = 'in_progress';
|
||||
nextSubtask!.started_at = new Date().toISOString();
|
||||
writeFileSync(PLAN_PATH, JSON.stringify(resetPlan, null, 2));
|
||||
|
||||
const resumedPlan = readPlan();
|
||||
expect(findSubtask(resumedPlan, 'subtask-1-2')!.status).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
@@ -607,6 +607,20 @@ Please authenticate and try again.`;
|
||||
|
||||
expect(result.isAuthFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT false-positive on AI discussion text mentioning auth topics', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
// This simulates an AI PR review that discusses authentication — it should NOT trigger auth detection
|
||||
const aiReviewText = `The PR adds authentication error detection to prevent infinite retry loops. ` +
|
||||
`When the API returns a message like "does not have access to Claude", the system now detects it. ` +
|
||||
`However, this pattern could also match if a user discusses authentication in a PR review. ` +
|
||||
`We should ensure the detection is specific enough to avoid false positives. ` +
|
||||
`Please login again is another phrase that could appear in normal discussion.`;
|
||||
const result = detectAuthFailure(aiReviewText);
|
||||
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
RoadmapConfig
|
||||
} from './types';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
import { resetStuckSubtasks } from '../ipc-handlers/task/plan-file-utils';
|
||||
import { AUTO_BUILD_PATHS, getSpecsDir, sanitizeThinkingLevel } from '../../shared/constants';
|
||||
import { projectStore } from '../project-store';
|
||||
|
||||
/**
|
||||
* Main AgentManager - orchestrates agent process lifecycle
|
||||
@@ -103,6 +106,78 @@ export class AgentManager extends EventEmitter {
|
||||
this.processManager.configure(pythonPath, autoBuildSourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run startup recovery scan to detect and reset stuck subtasks on app launch
|
||||
* Scans all projects for implementation_plan.json files and resets any stuck subtasks
|
||||
*/
|
||||
async runStartupRecoveryScan(): Promise<void> {
|
||||
console.log('[AgentManager] Running startup recovery scan for stuck subtasks...');
|
||||
|
||||
try {
|
||||
// Get all projects from the store
|
||||
const projects = projectStore.getProjects();
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log('[AgentManager] No projects found - skipping startup recovery scan');
|
||||
return;
|
||||
}
|
||||
|
||||
let totalScanned = 0;
|
||||
let totalReset = 0;
|
||||
|
||||
// Scan each project for stuck subtasks
|
||||
for (const project of projects) {
|
||||
if (!project.autoBuildPath) {
|
||||
continue; // Skip projects that haven't been initialized yet
|
||||
}
|
||||
|
||||
const specsDir = path.join(project.path, getSpecsDir(project.autoBuildPath));
|
||||
|
||||
// Check if specs directory exists
|
||||
if (!existsSync(specsDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read all spec directories
|
||||
try {
|
||||
const specDirs = readdirSync(specsDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
|
||||
// Process each spec directory
|
||||
for (const specDirName of specDirs) {
|
||||
const planPath = path.join(specsDir, specDirName, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
|
||||
// Check if implementation_plan.json exists
|
||||
if (!existsSync(planPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalScanned++;
|
||||
|
||||
// Reset stuck subtasks (pass project.id to invalidate tasks cache)
|
||||
const { success, resetCount } = await resetStuckSubtasks(planPath, project.id);
|
||||
|
||||
if (success && resetCount > 0) {
|
||||
totalReset += resetCount;
|
||||
console.log(`[AgentManager] Startup recovery: Reset ${resetCount} stuck subtask(s) in ${specDirName}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[AgentManager] Failed to scan specs directory for project ${project.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalReset > 0) {
|
||||
console.log(`[AgentManager] Startup recovery complete: Reset ${totalReset} stuck subtask(s) across ${totalScanned} task(s)`);
|
||||
} else {
|
||||
console.log(`[AgentManager] Startup recovery complete: No stuck subtasks found (scanned ${totalScanned} task(s))`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AgentManager] Startup recovery scan failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a task with the unified OperationRegistry for proactive swap support.
|
||||
* Extracted helper to avoid code duplication between spec creation and task execution.
|
||||
@@ -191,6 +266,20 @@ export class AgentManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset stuck subtasks if restarting an existing spec creation task
|
||||
if (specDir) {
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
console.log('[AgentManager] Resetting stuck subtasks before spec creation restart:', planPath);
|
||||
try {
|
||||
const { success, resetCount } = await resetStuckSubtasks(planPath);
|
||||
if (success && resetCount > 0) {
|
||||
console.log(`[AgentManager] Successfully reset ${resetCount} stuck subtask(s) before spec creation`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AgentManager] Failed to reset stuck subtasks before spec creation:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get combined environment variables
|
||||
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
|
||||
|
||||
@@ -215,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,9 +615,26 @@ export class AgentManager extends EventEmitter {
|
||||
console.log('[AgentManager] Killing current process for task:', taskId);
|
||||
this.killTask(taskId);
|
||||
|
||||
// Wait for cleanup, then restart
|
||||
// Wait for cleanup, then reset stuck subtasks and restart
|
||||
console.log('[AgentManager] Scheduling task restart in 500ms');
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
// Reset stuck subtasks before restart to avoid picking up stale in-progress states
|
||||
if (context.specId || context.specDir) {
|
||||
const planPath = context.specDir
|
||||
? path.join(context.specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN)
|
||||
: path.join(context.projectPath, AUTO_BUILD_PATHS.SPECS_DIR, context.specId, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
|
||||
console.log('[AgentManager] Resetting stuck subtasks before restart:', planPath);
|
||||
try {
|
||||
const { success, resetCount } = await resetStuckSubtasks(planPath);
|
||||
if (success && resetCount > 0) {
|
||||
console.log(`[AgentManager] Successfully reset ${resetCount} stuck subtask(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AgentManager] Failed to reset stuck subtasks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[AgentManager] Restarting task now:', taskId);
|
||||
if (context.isSpecCreation) {
|
||||
console.log('[AgentManager] Restarting as spec creation');
|
||||
|
||||
@@ -818,21 +818,23 @@ export class AgentProcessManager {
|
||||
if (code !== 0) {
|
||||
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
|
||||
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
|
||||
|
||||
if (wasHandled) {
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (code !== 0 && currentPhase !== 'complete' && currentPhase !== 'failed') {
|
||||
this.emitter.emit('execution-progress', taskId, {
|
||||
phase: 'failed',
|
||||
phaseProgress: 0,
|
||||
overallProgress: this.events.calculateOverallProgress(currentPhase, phaseProgress),
|
||||
message: `Process exited with code ${code}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
}, projectId);
|
||||
// Only emit 'failed' when failure was NOT handled by auto-swap
|
||||
if (currentPhase !== 'complete' && currentPhase !== 'failed') {
|
||||
this.emitter.emit('execution-progress', taskId, {
|
||||
phase: 'failed',
|
||||
phaseProgress: 0,
|
||||
overallProgress: this.events.calculateOverallProgress(currentPhase, phaseProgress),
|
||||
message: `Process exited with code ${code}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
}, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { existsSync, writeFileSync, mkdirSync, unlinkSync, promises as fsPromises } from 'fs';
|
||||
import { existsSync, mkdirSync, unlinkSync, promises as fsPromises } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
@@ -19,6 +19,8 @@ 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 { debounce } from '../utils/debounce';
|
||||
import { writeFileWithRetry } from '../utils/atomic-file';
|
||||
|
||||
/** Maximum length for status messages displayed in progress UI */
|
||||
const STATUS_MESSAGE_MAX_LENGTH = 200;
|
||||
@@ -43,6 +45,15 @@ export class AgentQueueManager {
|
||||
private events: AgentEvents;
|
||||
private processManager: AgentProcessManager;
|
||||
private emitter: EventEmitter;
|
||||
private debouncedPersistRoadmapProgress: (
|
||||
projectPath: string,
|
||||
phase: string,
|
||||
progress: number,
|
||||
message: string,
|
||||
startedAt: string,
|
||||
isRunning: boolean
|
||||
) => void;
|
||||
private cancelPersistRoadmapProgress: () => void;
|
||||
|
||||
constructor(
|
||||
state: AgentState,
|
||||
@@ -54,6 +65,17 @@ export class AgentQueueManager {
|
||||
this.events = events;
|
||||
this.processManager = processManager;
|
||||
this.emitter = emitter;
|
||||
|
||||
// Create debounced version of persistRoadmapProgress (300ms, leading + trailing)
|
||||
// This limits file writes to ~3-4 per second while ensuring immediate first write
|
||||
// and final state persistence after burst of updates
|
||||
const { fn: debouncedFn, cancel } = debounce(
|
||||
this.persistRoadmapProgress.bind(this),
|
||||
300,
|
||||
{ leading: true, trailing: true }
|
||||
);
|
||||
this.debouncedPersistRoadmapProgress = debouncedFn;
|
||||
this.cancelPersistRoadmapProgress = cancel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,14 +112,14 @@ export class AgentQueueManager {
|
||||
* @param startedAt - When generation started (ISO string)
|
||||
* @param isRunning - Whether generation is actively running
|
||||
*/
|
||||
private persistRoadmapProgress(
|
||||
private async persistRoadmapProgress(
|
||||
projectPath: string,
|
||||
phase: string,
|
||||
progress: number,
|
||||
message: string,
|
||||
startedAt: string,
|
||||
isRunning: boolean
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roadmapDir = path.join(projectPath, AUTO_BUILD_PATHS.ROADMAP_DIR);
|
||||
const progressPath = path.join(roadmapDir, AUTO_BUILD_PATHS.GENERATION_PROGRESS);
|
||||
@@ -116,7 +138,7 @@ export class AgentQueueManager {
|
||||
is_running: isRunning
|
||||
};
|
||||
|
||||
writeFileSync(progressPath, JSON.stringify(progressData, null, 2));
|
||||
await writeFileWithRetry(progressPath, JSON.stringify(progressData, null, 2), { encoding: 'utf-8' });
|
||||
debugLog('[Agent Queue] Persisted roadmap progress:', { phase, progress });
|
||||
} catch (err) {
|
||||
debugError('[Agent Queue] Failed to persist roadmap progress:', err);
|
||||
@@ -130,6 +152,9 @@ export class AgentQueueManager {
|
||||
* @param projectPath - The project directory path
|
||||
*/
|
||||
private clearRoadmapProgress(projectPath: string): void {
|
||||
// Cancel any pending debounced write to prevent re-creating the file after deletion
|
||||
this.cancelPersistRoadmapProgress();
|
||||
|
||||
try {
|
||||
const progressPath = path.join(
|
||||
projectPath,
|
||||
@@ -739,8 +764,8 @@ export class AgentQueueManager {
|
||||
// Track startedAt timestamp for progress persistence
|
||||
const roadmapStartedAt = new Date().toISOString();
|
||||
|
||||
// Persist initial progress state
|
||||
this.persistRoadmapProgress(
|
||||
// Persist initial progress state (debounced - will execute immediately due to leading: true)
|
||||
this.debouncedPersistRoadmapProgress(
|
||||
projectPath,
|
||||
progressPhase,
|
||||
progressPercent,
|
||||
@@ -777,8 +802,8 @@ export class AgentQueueManager {
|
||||
// Get status message for display
|
||||
const statusMessage = formatStatusMessage(log);
|
||||
|
||||
// Persist progress to disk for recovery after restart
|
||||
this.persistRoadmapProgress(
|
||||
// Persist progress to disk for recovery after restart (debounced to limit writes)
|
||||
this.debouncedPersistRoadmapProgress(
|
||||
projectPath,
|
||||
progressPhase,
|
||||
progressPercent,
|
||||
@@ -805,8 +830,8 @@ export class AgentQueueManager {
|
||||
|
||||
const statusMessage = formatStatusMessage(log);
|
||||
|
||||
// Persist progress to disk (also on stderr to show activity)
|
||||
this.persistRoadmapProgress(
|
||||
// Persist progress to disk (debounced - also on stderr to show activity)
|
||||
this.debouncedPersistRoadmapProgress(
|
||||
projectPath,
|
||||
progressPhase,
|
||||
progressPercent,
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface AgentManagerEvents {
|
||||
|
||||
export interface RoadmapConfig {
|
||||
model?: string; // Model shorthand (opus, sonnet, haiku)
|
||||
thinkingLevel?: string; // Thinking level (none, low, medium, high, ultrathink)
|
||||
thinkingLevel?: string; // Thinking level (low, medium, high)
|
||||
}
|
||||
|
||||
export interface TaskExecutionOptions {
|
||||
@@ -57,20 +57,20 @@ export interface SpecCreationMetadata {
|
||||
// Auto profile - phase-based model and thinking configuration
|
||||
isAutoProfile?: boolean;
|
||||
phaseModels?: {
|
||||
spec: 'haiku' | 'sonnet' | 'opus';
|
||||
planning: 'haiku' | 'sonnet' | 'opus';
|
||||
coding: 'haiku' | 'sonnet' | 'opus';
|
||||
qa: 'haiku' | 'sonnet' | 'opus';
|
||||
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: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
planning: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
coding: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
qa: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
spec: 'low' | 'medium' | 'high';
|
||||
planning: 'low' | 'medium' | 'high';
|
||||
coding: 'low' | 'medium' | 'high';
|
||||
qa: 'low' | 'medium' | 'high';
|
||||
};
|
||||
// Non-auto profile - single model and thinking level
|
||||
model?: 'haiku' | 'sonnet' | 'opus';
|
||||
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
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)
|
||||
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user