Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba49fd064e | |||
| f5f521da2a | |||
| 624c15d29b | |||
| 23f712c094 | |||
| 2f4393b7f1 | |||
| 5046d37d62 |
@@ -7,7 +7,6 @@
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
nul
|
||||
|
||||
# ===========================
|
||||
# Security - Environment & Secrets
|
||||
|
||||
+58
-71
@@ -1,36 +1,52 @@
|
||||
#!/bin/sh
|
||||
|
||||
# =============================================================================
|
||||
# GIT WORKTREE ENVIRONMENT CLEANUP
|
||||
# GIT WORKTREE CONTEXT HANDLING
|
||||
# =============================================================================
|
||||
# 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.
|
||||
# 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).
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
# =============================================================================
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
|
||||
# =============================================================================
|
||||
# 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."
|
||||
# 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
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -127,13 +143,6 @@ fi
|
||||
if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "Python changes detected, running backend checks..."
|
||||
|
||||
# Detect if we're in a worktree
|
||||
IS_WORKTREE=false
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
fi
|
||||
|
||||
# Determine ruff command (venv or global)
|
||||
RUFF=""
|
||||
if [ -f "apps/backend/.venv/bin/ruff" ]; then
|
||||
@@ -165,71 +174,49 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
fi
|
||||
else
|
||||
if [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: ruff not available in this worktree."
|
||||
echo " Python linting checks will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
# Run from repo root (not apps/backend) so tests that use Path.resolve() get correct CWD.
|
||||
# PYTHONPATH includes apps/backend so imports resolve correctly.
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
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)
|
||||
# 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"
|
||||
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"
|
||||
|
||||
# Determine Python executable from venv
|
||||
VENV_PYTHON=""
|
||||
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"
|
||||
if [ -f ".venv/bin/python" ]; then
|
||||
VENV_PYTHON=".venv/bin/python"
|
||||
elif [ -f ".venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON=".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=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $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=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
$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
|
||||
fi
|
||||
elif [ -d "apps/backend/.venv" ]; then
|
||||
elif [ -d ".venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
elif [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Python venv not available in this worktree."
|
||||
echo " Python tests will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
exit 77 # GNU convention for 'test skipped' (avoids pytest exit-code collision)
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
PYTHON_EXIT=$?
|
||||
if [ $PYTHON_EXIT -eq 77 ]; then
|
||||
echo "Backend checks passed! (Python tests skipped — worktree)"
|
||||
elif [ $PYTHON_EXIT -ne 0 ]; then
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Python tests failed. Please fix failing tests before committing."
|
||||
exit 1
|
||||
else
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+20
-12
@@ -97,8 +97,9 @@ repos:
|
||||
- id: ruff-format
|
||||
files: ^apps/backend/
|
||||
|
||||
# Python tests (apps/backend/) - run full test suite from project root
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
@@ -107,24 +108,31 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Run pytest directly from project root
|
||||
if [ -f "apps/backend/.venv/bin/pytest" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/bin/pytest"
|
||||
elif [ -f "apps/backend/.venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/Scripts/pytest.exe"
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD=".venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
$PYTEST_CMD tests/ \
|
||||
PYTHONPATH=. $PYTEST_CMD \
|
||||
../../tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--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_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
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
# GitHub Issues Documentation Design
|
||||
|
||||
**Date:** 2025-02-16
|
||||
**Status:** Approved
|
||||
**Author:** Claude (Superpowers Brainstorming)
|
||||
|
||||
## Overview
|
||||
|
||||
Create comprehensive, user-friendly documentation for Auto Claude's GitHub Issues integration. The documentation will serve a mixed audience (new and existing users) with progressive depth across three documents.
|
||||
|
||||
## Target Audience
|
||||
|
||||
**Mixed audience with progressive depth:**
|
||||
- **End users** - New to Auto Claude, want to understand features and get started quickly
|
||||
- **Technical users** - Existing users wanting to optimize AI configuration and manage costs
|
||||
- **Pro users/developers** - Want to customize prompts, context injection, and extend the system
|
||||
|
||||
## Document Structure
|
||||
|
||||
### New Directory: `guides/github-issues/`
|
||||
|
||||
```
|
||||
guides/
|
||||
└── github-issues/
|
||||
├── README.md # Navigation index
|
||||
├── github-issues-user-guide.md # Document 1
|
||||
├── github-issues-advanced-ai-configuration.md # Document 2
|
||||
└── github-issues-customization-guide.md # Document 3
|
||||
```
|
||||
|
||||
### Document 1: User Guide
|
||||
|
||||
**Audience:** End users (non-technical)
|
||||
**Focus:** Features, workflows, getting started
|
||||
|
||||
**Sections:**
|
||||
1. **Overview** - What is GitHub Issues integration? Why use it?
|
||||
2. **Quick Start (5 minutes)** - Get your first issue investigated end-to-end
|
||||
3. **Key Features** - Bullet points of main capabilities
|
||||
4. **Integration Workflow** - Import → Investigate → Create Task → Implement (EMPHASIS)
|
||||
5. **Setup & Configuration** - GitHub authentication, connecting repos
|
||||
6. **Using the Features**
|
||||
- Importing & browsing issues
|
||||
- Running AI investigations
|
||||
- Creating tasks from results
|
||||
- Posting findings back to GitHub
|
||||
7. **FAQ** - Common questions
|
||||
|
||||
**Tone:** Friendly, encouraging, approachable. Plain English with minimal jargon.
|
||||
|
||||
### Document 2: Advanced AI Configuration
|
||||
|
||||
**Audience:** Technical users, team leads
|
||||
**Focus:** AI tuning, performance, cost management
|
||||
|
||||
**Sections:**
|
||||
1. **Overview** - Who this guide is for
|
||||
2. **Opus 4.6 Features** - Fast Mode, 128K tokens, adaptive thinking
|
||||
3. **The 4 Specialist Agents** - Deep dive into each agent's role
|
||||
4. **Pricing & Cost Management** - Token limits, cost optimization strategies
|
||||
5. **Advanced Configuration** - Per-specialist settings, performance tuning
|
||||
6. **Technical Architecture** - How investigation works under the hood
|
||||
|
||||
**Tone:** Professional, technical but clear. Explains technical concepts in context.
|
||||
|
||||
### Document 3: Customization Guide
|
||||
|
||||
**Audience:** Developers, pro users extending Auto Claude
|
||||
**Focus:** System customization, prompts, context injection
|
||||
|
||||
**Sections:**
|
||||
1. **Overview** - Who this is for (developers extending Auto Claude)
|
||||
2. **Prompt System Architecture** - How agent prompts are structured
|
||||
3. **Context Injection System** - How context is built and passed to agents
|
||||
4. **Customizing Agent Prompts** - Modifying XML prompt files
|
||||
- Finding the prompt files
|
||||
- Prompt structure and variables
|
||||
- Creating custom investigation specialists
|
||||
5. **Context Configuration** - Customizing what data is included
|
||||
- File selection patterns
|
||||
- Context window management
|
||||
- Repository context settings
|
||||
6. **Adding Custom Specialists** - Creating new investigation agents
|
||||
7. **Extending the Integration** - Hooks, custom providers
|
||||
8. **Examples & Recipes** - Common customizations
|
||||
|
||||
**Tone:** Developer-to-developer, technical, code-heavy, minimal hand-holding.
|
||||
|
||||
## Content Approach
|
||||
|
||||
### Progressive Disclosure
|
||||
- Each document builds on the previous one
|
||||
- Clear navigation links between documents
|
||||
- Users can enter at their appropriate level
|
||||
|
||||
### Writing Style Guidelines
|
||||
|
||||
| Document | Tone | Language | Example Style |
|
||||
|----------|------|----------|---------------|
|
||||
| User Guide | Friendly, encouraging | Plain English | "Think of AI investigation as having a senior developer analyze the issue for you" |
|
||||
| Advanced Config | Professional, technical | Terms explained in context | "Fast Mode uses optimized token generation to reduce investigation time by 2.5x" |
|
||||
| Customization | Developer-to-developer | Technical, code-heavy | "Modify the `<system_context>` variable in `prompts/github/root_cause.xml`" |
|
||||
|
||||
### Code & Configuration Examples
|
||||
- **Doc 1:** Simple copy-paste examples, UI navigation
|
||||
- **Doc 2:** Configuration snippets, environment variables
|
||||
- **Doc 3:** Full XML/Python examples, file paths, code blocks
|
||||
|
||||
## Visual Elements
|
||||
|
||||
### Screenshots (Hybrid Approach)
|
||||
|
||||
**User Guide:**
|
||||
1. GitHub Issues main UI (issue list, filters, actions)
|
||||
2. Issue detail view (comments, labels, "Investigate" button)
|
||||
3. Investigation in progress (4 parallel specialists)
|
||||
4. Investigation results (completed report)
|
||||
5. Settings screen (GitHub auth, repo connection, Fast Mode toggle)
|
||||
|
||||
**Advanced AI Config:**
|
||||
1. Settings → AI Investigation panel (configurable options)
|
||||
2. Token usage display (cost visibility)
|
||||
3. Investigation pipeline flowchart (issue → 4 specialists → report)
|
||||
|
||||
**Customization Guide:**
|
||||
1. Directory structure diagram (prompt file locations)
|
||||
2. Annotated XML prompt file (variables and structure)
|
||||
3. Context injection flow diagram
|
||||
4. Code snippets throughout
|
||||
|
||||
### Diagram Style
|
||||
- Clean, simple flowcharts
|
||||
- Consistent color scheme: Blue (user actions), Green (AI agents), Orange (data flow)
|
||||
- Minimal text, focus on flow and relationships
|
||||
|
||||
## Navigation & Cross-References
|
||||
|
||||
### Linking Strategy
|
||||
|
||||
**User Guide → Deeper:**
|
||||
- "For detailed configuration, see [Advanced AI Configuration]"
|
||||
- "Learn how the 4 specialists work in [Advanced AI Configuration]"
|
||||
- "Want to customize agent behavior? See [Customization Guide]"
|
||||
|
||||
**Advanced AI Config → Both Directions:**
|
||||
- "New to GitHub Issues? Start with the [User Guide]"
|
||||
- "Want to modify agent prompts? See [Customization Guide]"
|
||||
|
||||
**Customization Guide → Reference:**
|
||||
- "Assumes familiarity with concepts from [User Guide] and [Advanced AI Config]"
|
||||
|
||||
### Navigation Aids
|
||||
- Table of Contents at the top of each document
|
||||
- "In this section" callouts at the start of major sections
|
||||
- "Next steps" boxes at the end of each workflow section
|
||||
|
||||
### External References
|
||||
- `guides/opus-4.6-features.md` - Opus 4.6 details (don't duplicate)
|
||||
- `ARCHITECTURE.md` - System architecture
|
||||
- GitHub CLI docs - Authentication setup
|
||||
|
||||
## Key Emphasis
|
||||
|
||||
**Integration Workflow** is the primary focus across all documents:
|
||||
|
||||
> **Import Issues** → **AI Investigation** → **Create Task** → **Implement** → **Merge**
|
||||
|
||||
This pipeline demonstrates the core value of Auto Claude - connecting GitHub issues to autonomous development.
|
||||
|
||||
## File Organization
|
||||
|
||||
### Naming Convention
|
||||
- Lowercase with hyphens (matches existing documentation style)
|
||||
- Descriptive names indicating audience and content
|
||||
- Keep under 80 characters for GitHub rendering
|
||||
|
||||
### README.md (Index Page)
|
||||
|
||||
```markdown
|
||||
# GitHub Issues Documentation
|
||||
|
||||
Choose the guide that matches your needs:
|
||||
|
||||
📖 **[User Guide](github-issues-user-guide.md)** - Get started with GitHub Issues integration
|
||||
For: All users | Focus: Using the features
|
||||
|
||||
⚙️ **[Advanced AI Configuration](github-issues-advanced-ai-configuration.md)** - Optimize AI investigations
|
||||
For: Technical users | Focus: Performance, costs, Opus 4.6
|
||||
|
||||
🔧 **[Customization Guide](github-issues-customization-guide.md)** - Extend and customize the system
|
||||
For: Developers | Focus: Prompts, context, customization
|
||||
```
|
||||
|
||||
## Markdown Format Guidelines
|
||||
|
||||
- Standard GitHub-flavored markdown
|
||||
- H1 for title, H2 for main sections, H3 for subsections
|
||||
- Code blocks with language specification (`xml`, `python`, `bash`)
|
||||
- Callout boxes using `> **Note:**` format
|
||||
- Internal links use relative paths
|
||||
- Front matter with title and description metadata
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Content Sources
|
||||
- `apps/frontend/src/renderer/components/github-issues/` - UI components
|
||||
- `apps/frontend/src/main/ipc-handlers/github/` - IPC handlers
|
||||
- `apps/backend/runners/github/` - Backend services
|
||||
- `guides/opus-4.6-features.md` - Reference for Opus 4.6 details
|
||||
- Existing code comments and docstrings
|
||||
|
||||
### Screenshots to Capture
|
||||
- Need to run the application in development mode (`npm run dev`)
|
||||
- Capture each UI state listed in Visual Elements section
|
||||
- Save to `guides/github-issues/images/` with descriptive names
|
||||
|
||||
### Diagram Creation
|
||||
- Use Mermaid syntax for flowcharts (if supported by docs build)
|
||||
- Alternatively, describe for manual creation with diagram tools
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ All three documents created in `guides/github-issues/`
|
||||
2. ✅ README.md index page created
|
||||
3. ✅ Progressive structure allows users to enter at appropriate level
|
||||
4. ✅ Integration workflow is clear and emphasized
|
||||
5. ✅ Screenshots included for key UI states
|
||||
6. ✅ Cross-references enable navigation between documents
|
||||
7. ✅ Writing style matches target audience for each document
|
||||
8. ✅ Content is accurate based on actual codebase features
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create implementation plan using writing-plans skill
|
||||
2. Set up directory structure
|
||||
3. Draft content for each document
|
||||
4. Capture screenshots
|
||||
5. Create diagrams
|
||||
6. Review and refine
|
||||
7. Commit to repository
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,8 +74,6 @@
|
||||
|
||||
### 🐛 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
|
||||
|
||||
@@ -40,26 +40,6 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
|
||||
|
||||
## Work Approach
|
||||
|
||||
**Investigate before speculating** — Always read the actual code before proposing root causes. Spawn agents to grep and read relevant source files before forming any hypothesis. Never guess at causes without evidence from the codebase.
|
||||
|
||||
**Spawn agents for complex tasks** — When tackling complex tasks, spawn sub-agents/agent teams immediately rather than trying to handle everything in a single context window. Never attempt to analyze large codebases or multiple features monolithically.
|
||||
|
||||
**Minimal fixes only** — Prefer the simplest approach (e.g., prompt-only changes, single guard clause) before suggesting multi-component solutions. If the user asks for X, implement X — don't bundle additional fixes they didn't request.
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
**Electron path resolution** — For bug fixes in the Electron app, always check path resolution differences between dev and production builds (`app.isPackaged`, `process.resourcesPath`). Paths that work in dev often break when Electron is bundled for production — verify both contexts.
|
||||
|
||||
### Resetting PR Review State
|
||||
|
||||
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.auto-claude/github/`:
|
||||
|
||||
1. `rm .auto-claude/github/pr/logs_*.json` — review log files
|
||||
2. `rm .auto-claude/github/pr/review_*.json` — review result files
|
||||
3. Reset `pr/index.json` to `{"reviews": [], "last_updated": null}`
|
||||
4. Reset `bot_detection_state.json` to `{"reviewed_commits": {}}` — this is the gatekeeper; without clearing it, the bot detector skips already-seen commits
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -118,6 +98,30 @@ cd apps/backend && uv venv && uv pip install -r requirements.txt
|
||||
cd apps/frontend && npm install
|
||||
```
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd apps/backend
|
||||
python spec_runner.py --interactive # Create spec interactively
|
||||
python spec_runner.py --task "description" # Create from task
|
||||
python run.py --spec 001 # Run autonomous build
|
||||
python run.py --spec 001 --qa # Run QA validation
|
||||
python run.py --spec 001 --merge # Merge completed build
|
||||
python run.py --list # List all specs
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd apps/frontend
|
||||
npm run dev # Dev mode (Electron + Vite HMR)
|
||||
npm run build # Production build
|
||||
npm run test # Vitest unit tests
|
||||
npm run test:watch # Vitest watch mode
|
||||
npm run lint # Biome check
|
||||
npm run lint:fix # Biome auto-fix
|
||||
npm run typecheck # TypeScript strict check
|
||||
npm run package # Package for distribution
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
| Stack | Command | Tool |
|
||||
@@ -141,7 +145,30 @@ See [RELEASE.md](RELEASE.md) for full release process.
|
||||
|
||||
Client: `apps/backend/core/client.py` — `create_client()` returns a configured `ClaudeSDKClient` with security hooks, tool permissions, and MCP server integration.
|
||||
|
||||
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values
|
||||
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values:
|
||||
|
||||
```python
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
|
||||
# Resolve model/thinking from user settings (Electron UI or CLI override)
|
||||
phase_model = get_phase_model(spec_dir, "coding", cli_model=None)
|
||||
phase_thinking = get_phase_thinking_budget(spec_dir, "coding", cli_thinking=None)
|
||||
|
||||
client = create_client(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=phase_model,
|
||||
agent_type="coder", # planner | coder | qa_reviewer | qa_fixer
|
||||
max_thinking_tokens=phase_thinking,
|
||||
)
|
||||
|
||||
# Run agent session (uses context manager + run_agent_session helper)
|
||||
async with client:
|
||||
status, response = await run_agent_session(client, prompt, spec_dir)
|
||||
```
|
||||
|
||||
Working examples: `agents/planner.py`, `agents/coder.py`, `qa/reviewer.py`, `qa/fixer.py`, `spec/`
|
||||
|
||||
### Agent Prompts (`apps/backend/prompts/`)
|
||||
|
||||
@@ -161,17 +188,6 @@ Each spec in `.auto-claude/specs/XXX-name/` contains: `spec.md`, `requirements.j
|
||||
|
||||
Graph-based semantic memory in `integrations/graphiti/`. Configured through the Electron app's onboarding/settings UI (CLI users can alternatively set `GRAPHITI_ENABLED=true` in `.env`). See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#memory-system) for details.
|
||||
|
||||
### Opus 4.6 Features
|
||||
|
||||
Auto Claude leverages Opus 4.6's advanced capabilities for GitHub issue investigations:
|
||||
|
||||
- **Fast Mode:** 2.5x faster investigations (toggle in Settings > GitHub > AI Investigation)
|
||||
- **128K Output Tokens:** Root cause specialist gets max tokens for deep analysis
|
||||
- **Per-Specialist Limits:** Different token limits per investigation specialist
|
||||
- **Adaptive Thinking:** High-effort mode for thorough investigations
|
||||
|
||||
See [guides/opus-4.6-features.md](guides/opus-4.6-features.md) for detailed documentation on Opus 4.6 features, pricing, and usage.
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Tech Stack
|
||||
@@ -307,8 +323,6 @@ cd apps/backend && python run.py --spec 001
|
||||
# Desktop app
|
||||
npm start # Production build + run
|
||||
npm run dev # Development mode with HMR
|
||||
npm run dev:debug # Debug mode with verbose output
|
||||
npm run dev:mcp # Electron MCP server for AI debugging
|
||||
|
||||
# Project data: .auto-claude/specs/ (gitignored)
|
||||
```
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
[](https://github.com/hesreallyhim/awesome-claude-code)
|
||||
|
||||
---
|
||||
|
||||
@@ -36,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.5)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.1)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-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-6
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-6
|
||||
# Default: claude-opus-4-5-20251101
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -62,16 +62,5 @@ Thumbs.db
|
||||
# Tests (development only)
|
||||
tests/
|
||||
|
||||
# Exceptions: Allow specific test directories
|
||||
!integrations/graphiti/tests/
|
||||
!tests/integration/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
coverage.json
|
||||
|
||||
# Auto Claude generated files
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.security-key
|
||||
logs/security/
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.5"
|
||||
__version__ = "2.7.6-beta.2"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -6,7 +6,6 @@ Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -15,85 +14,7 @@ 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 = (
|
||||
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
|
||||
)
|
||||
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
|
||||
|
||||
# Pause file constants for intelligent error recovery
|
||||
# These files signal pause/resume between frontend and backend
|
||||
RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited
|
||||
AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails
|
||||
RESUME_FILE = "RESUME" # Created by frontend to signal resume
|
||||
|
||||
# Maximum time to wait for rate limit reset (2 hours)
|
||||
# If reset time is beyond this, task should fail rather than wait indefinitely
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS = 7200
|
||||
|
||||
# Wait intervals for pause/resume checking
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS = (
|
||||
30 # Check for RESUME file every 30 seconds during rate limit wait
|
||||
)
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours)
|
||||
|
||||
|
||||
def sanitize_error_message(error_message: str, max_length: int = 500) -> str:
|
||||
"""
|
||||
Sanitize error messages to remove potentially sensitive information.
|
||||
|
||||
Redacts:
|
||||
- API keys (sk-..., key-...)
|
||||
- Bearer tokens
|
||||
- Token/secret values
|
||||
|
||||
Args:
|
||||
error_message: The raw error message to sanitize
|
||||
max_length: Maximum length to truncate to (default 500)
|
||||
|
||||
Returns:
|
||||
Sanitized and truncated error message
|
||||
"""
|
||||
if not error_message:
|
||||
return ""
|
||||
|
||||
# Redact patterns that look like API keys or tokens
|
||||
# Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...)
|
||||
sanitized = re.sub(
|
||||
r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message
|
||||
)
|
||||
|
||||
# Pattern: key-... (generic API keys)
|
||||
sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized)
|
||||
|
||||
# Pattern: Bearer ... (bearer tokens)
|
||||
sanitized = re.sub(
|
||||
r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized
|
||||
)
|
||||
|
||||
# Pattern: token= or token: followed by long strings
|
||||
sanitized = re.sub(
|
||||
r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_TOKEN]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern: secret= or secret: followed by strings
|
||||
sanitized = re.sub(
|
||||
r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_SECRET]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Truncate to max length
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length] + "..."
|
||||
|
||||
return sanitized
|
||||
# Concurrency retry constants
|
||||
MAX_CONCURRENCY_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY_SECONDS = 2
|
||||
MAX_RETRY_DELAY_SECONDS = 32
|
||||
|
||||
+12
-577
@@ -6,11 +6,8 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
@@ -21,12 +18,7 @@ from linear_updater import (
|
||||
linear_task_started,
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
@@ -65,22 +57,12 @@ from ui import (
|
||||
)
|
||||
|
||||
from .base import (
|
||||
AUTH_FAILURE_PAUSE_FILE,
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS,
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS,
|
||||
AUTO_CONTINUE_DELAY_SECONDS,
|
||||
HUMAN_INTERVENTION_FILE,
|
||||
INITIAL_RETRY_DELAY_SECONDS,
|
||||
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,
|
||||
sanitize_error_message,
|
||||
)
|
||||
from .investigation_context import load_investigation_context
|
||||
from .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||
from .session import post_session_processing, run_agent_session
|
||||
from .utils import (
|
||||
@@ -94,273 +76,6 @@ 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,
|
||||
fallback_resume_file: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if resume file exists and clean up both resume and pause files.
|
||||
|
||||
Also checks a fallback location (main project spec dir) in case the frontend
|
||||
couldn't find the worktree and only wrote the RESUME file there.
|
||||
|
||||
Args:
|
||||
resume_file: Path to RESUME file
|
||||
pause_file: Path to pause file (RATE_LIMIT_PAUSE or AUTH_PAUSE)
|
||||
fallback_resume_file: Optional fallback RESUME file path (e.g. main project spec dir)
|
||||
|
||||
Returns:
|
||||
True if resume file existed (early resume), False otherwise
|
||||
"""
|
||||
found = resume_file.exists()
|
||||
|
||||
# Check fallback location if primary not found
|
||||
if not found and fallback_resume_file and fallback_resume_file.exists():
|
||||
found = True
|
||||
try:
|
||||
fallback_resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up fallback resume file: {e}")
|
||||
|
||||
if found:
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
f"Error cleaning up resume files: {e} (resume: {resume_file}, pause: {pause_file})"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_rate_limit_reset(
|
||||
spec_dir: Path,
|
||||
wait_seconds: float,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Wait for rate limit reset with periodic checks for resume/cancel.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to check for RESUME file
|
||||
wait_seconds: Maximum time to wait in seconds
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
|
||||
Returns:
|
||||
True if resumed early, False if waited full duration
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= wait_seconds:
|
||||
break
|
||||
|
||||
# Check if user requested resume
|
||||
if _check_and_clear_resume_file(resume_file, pause_file, fallback_resume):
|
||||
return True
|
||||
|
||||
# Wait for next check interval or remaining time
|
||||
sleep_time = min(RATE_LIMIT_CHECK_INTERVAL_SECONDS, wait_seconds - elapsed)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
# Clean up pause file after wait completes
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_auth_resume(
|
||||
spec_dir: Path,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Wait for user re-authentication signal.
|
||||
|
||||
Blocks until:
|
||||
- RESUME file is created (user completed re-auth in UI)
|
||||
- AUTH_PAUSE file is deleted (alternative resume signal)
|
||||
- Maximum wait timeout is reached (24 hours)
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to monitor for signal files
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= AUTH_RESUME_MAX_WAIT_SECONDS:
|
||||
break
|
||||
|
||||
# Check for resume signals
|
||||
if (
|
||||
_check_and_clear_resume_file(resume_file, pause_file, fallback_resume)
|
||||
or not pause_file.exists()
|
||||
):
|
||||
# If pause file was deleted externally, still clean up resume file if it exists
|
||||
if not pause_file.exists():
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up resume file {resume_file}: {e}")
|
||||
return
|
||||
|
||||
await asyncio.sleep(AUTH_RESUME_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
# Timeout reached - clean up and return
|
||||
print_status(
|
||||
"Authentication wait timeout reached (24 hours) - resuming with original credentials",
|
||||
"warning",
|
||||
)
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file} after timeout: {e}")
|
||||
|
||||
|
||||
def parse_rate_limit_reset_time(error_info: dict | None) -> int | None:
|
||||
"""
|
||||
Parse rate limit reset time from error info.
|
||||
|
||||
Attempts to extract reset time from various formats in error messages.
|
||||
|
||||
TIMEZONE ASSUMPTIONS:
|
||||
- "in X minutes/hours" patterns are timezone-safe (relative time)
|
||||
- "at HH:MM" patterns assume LOCAL timezone, which is reasonable since:
|
||||
1. The user sees timestamps in their local timezone
|
||||
2. The wait calculation happens locally using datetime.now()
|
||||
3. If the API returns UTC "at" times, this would need adjustment
|
||||
(but Claude API typically returns relative times like "in X minutes")
|
||||
|
||||
Args:
|
||||
error_info: Error info dict with 'message' key
|
||||
|
||||
Returns:
|
||||
Unix timestamp of reset time, or None if not parseable
|
||||
"""
|
||||
if not error_info:
|
||||
return None
|
||||
|
||||
message = error_info.get("message", "")
|
||||
|
||||
# Try to find patterns like "resets at 3:00 PM" or "in 5 minutes"
|
||||
# Pattern: "in X minutes/hours" (timezone-safe - relative time)
|
||||
in_time_match = re.search(r"in\s+(\d+)\s*(minute|hour|min|hr)s?", message, re.I)
|
||||
if in_time_match:
|
||||
amount = int(in_time_match.group(1))
|
||||
unit = in_time_match.group(2).lower()
|
||||
if unit.startswith("hour") or unit.startswith("hr"):
|
||||
delta = timedelta(hours=amount)
|
||||
else:
|
||||
delta = timedelta(minutes=amount)
|
||||
return int((datetime.now() + delta).timestamp())
|
||||
|
||||
# Pattern: "at HH:MM" (12 or 24 hour)
|
||||
at_time_match = re.search(r"at\s+(\d{1,2}):(\d{2})(?:\s*(am|pm))?", message, re.I)
|
||||
if at_time_match:
|
||||
try:
|
||||
hour = int(at_time_match.group(1))
|
||||
minute = int(at_time_match.group(2))
|
||||
meridiem = at_time_match.group(3)
|
||||
|
||||
# Validate hour range when meridiem is present
|
||||
# Hours should be 1-12 for AM/PM format
|
||||
if meridiem and not (1 <= hour <= 12):
|
||||
return None
|
||||
|
||||
if meridiem:
|
||||
if meridiem.lower() == "pm" and hour < 12:
|
||||
hour += 12
|
||||
elif meridiem.lower() == "am" and hour == 12:
|
||||
hour = 0
|
||||
|
||||
# Validate hour and minute ranges
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
reset_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if reset_time <= now:
|
||||
reset_time += timedelta(days=1)
|
||||
return int(reset_time.timestamp())
|
||||
except ValueError:
|
||||
# Invalid time values - return None to fall back to standard retry
|
||||
return None
|
||||
|
||||
# No pattern matched - return None to let caller decide retry behavior
|
||||
return None
|
||||
|
||||
|
||||
async def run_autonomous_agent(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
@@ -585,28 +300,20 @@ 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_betas = get_phase_model_betas(spec_dir, current_phase, model)
|
||||
thinking_kwargs = get_phase_client_thinking_kwargs(
|
||||
spec_dir, current_phase, 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,
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -684,70 +391,6 @@ 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 = (
|
||||
@@ -783,84 +426,6 @@ async def run_autonomous_agent(
|
||||
prompt += "\n\n" + graphiti_context
|
||||
print_status("Graphiti memory context loaded", "success")
|
||||
|
||||
# Load investigation context if this is a GitHub-sourced task
|
||||
investigation_context = load_investigation_context(spec_dir)
|
||||
if investigation_context:
|
||||
# Format investigation context for the prompt
|
||||
inv = investigation_context
|
||||
inv_prompt = "\n## GitHub Investigation Context\n\n"
|
||||
inv_prompt += (
|
||||
"This task was created from a GitHub issue investigation. "
|
||||
)
|
||||
inv_prompt += "Use this context to guide your work.\n\n"
|
||||
|
||||
if inv.get("root_cause", {}).get("summary"):
|
||||
inv_prompt += f"**Root Cause:** {inv['root_cause']['summary']}\n\n"
|
||||
|
||||
if inv.get("root_cause", {}).get("evidence"):
|
||||
inv_prompt += f"**Evidence:**\n{inv['root_cause']['evidence']}\n\n"
|
||||
|
||||
if inv.get("root_cause", {}).get("code_paths"):
|
||||
inv_prompt += "**Code Paths:**\n"
|
||||
for path in inv["root_cause"]["code_paths"]:
|
||||
file_ref = path.get("file", "unknown")
|
||||
start = path.get("start_line", "")
|
||||
end = path.get("end_line", "")
|
||||
desc = path.get("description", "")
|
||||
line_range = f":{start}-{end}" if start and end else ""
|
||||
inv_prompt += f"- `{file_ref}{line_range}`"
|
||||
if desc:
|
||||
inv_prompt += f" — {desc}"
|
||||
inv_prompt += "\n"
|
||||
inv_prompt += "\n"
|
||||
|
||||
if inv.get("fix_approaches"):
|
||||
inv_prompt += "**Fix Approaches:**\n"
|
||||
for i, approach in enumerate(inv["fix_approaches"], 1):
|
||||
desc = approach.get("description", "Approach")
|
||||
complexity = approach.get("complexity", "")
|
||||
inv_prompt += f"{i}. {desc}"
|
||||
if complexity:
|
||||
inv_prompt += f" (complexity: {complexity})"
|
||||
inv_prompt += "\n"
|
||||
files_affected = approach.get("files_affected", [])
|
||||
if files_affected:
|
||||
inv_prompt += f" - Files: {', '.join(files_affected)}\n"
|
||||
inv_prompt += "\n"
|
||||
|
||||
if inv.get("gotchas"):
|
||||
inv_prompt += "**Gotchas:**\n"
|
||||
for gotcha in inv["gotchas"]:
|
||||
inv_prompt += f"- {gotcha}\n"
|
||||
inv_prompt += "\n"
|
||||
|
||||
if inv.get("patterns_to_follow"):
|
||||
inv_prompt += "**Patterns to Follow:**\n"
|
||||
for pattern in inv["patterns_to_follow"]:
|
||||
file_ref = pattern.get("file", "unknown")
|
||||
desc = pattern.get("description", "")
|
||||
inv_prompt += f"- `{file_ref}`"
|
||||
if desc:
|
||||
inv_prompt += f" — {desc}"
|
||||
inv_prompt += "\n"
|
||||
inv_prompt += "\n"
|
||||
|
||||
if inv.get("reproducer"):
|
||||
reproducer = inv["reproducer"]
|
||||
inv_prompt += "**Verification Steps:**\n"
|
||||
steps = reproducer.get("reproduction_steps", [])
|
||||
for step in steps:
|
||||
inv_prompt += f"- {step}\n"
|
||||
test_approach = reproducer.get("suggested_test_approach")
|
||||
if test_approach:
|
||||
inv_prompt += (
|
||||
f"\n**Suggested Test Approach:** {test_approach}\n"
|
||||
)
|
||||
inv_prompt += "\n"
|
||||
|
||||
prompt += inv_prompt
|
||||
print_status("Investigation context loaded", "success")
|
||||
|
||||
# Add concurrency error context if recovering from 400 error
|
||||
if concurrency_error_context:
|
||||
prompt += "\n\n" + concurrency_error_context
|
||||
@@ -941,12 +506,11 @@ 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 >= MAX_SUBTASK_RETRIES:
|
||||
if not success and attempt_count >= 3:
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id, f"Failed after {attempt_count} attempts"
|
||||
)
|
||||
@@ -1118,135 +682,6 @@ async def run_autonomous_agent(
|
||||
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
|
||||
)
|
||||
|
||||
elif error_info and error_info.get("type") == "rate_limit":
|
||||
# Rate limit error - intelligent wait for reset
|
||||
_reset_concurrency_state()
|
||||
|
||||
reset_timestamp = parse_rate_limit_reset_time(error_info)
|
||||
if reset_timestamp:
|
||||
wait_seconds = reset_timestamp - datetime.now().timestamp()
|
||||
|
||||
# Handle negative wait_seconds (reset time in the past)
|
||||
if wait_seconds <= 0:
|
||||
print_status(
|
||||
"Rate limit reset time already passed - retrying immediately",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
await asyncio.sleep(2) # Brief delay before retry
|
||||
continue
|
||||
|
||||
if wait_seconds > MAX_RATE_LIMIT_WAIT_SECONDS:
|
||||
# Wait time too long - fail the task
|
||||
print_status("Rate limit wait time too long", "error")
|
||||
print(
|
||||
f"Reset time would require waiting {wait_seconds / 3600:.1f} hours"
|
||||
)
|
||||
print(
|
||||
f"Maximum wait is {MAX_RATE_LIMIT_WAIT_SECONDS / 3600:.1f} hours"
|
||||
)
|
||||
emit_phase(
|
||||
ExecutionPhase.FAILED,
|
||||
"Rate limit wait time exceeds maximum allowed",
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
break
|
||||
|
||||
# Emit pause phase with reset time for frontend
|
||||
wait_minutes = wait_seconds / 60
|
||||
emit_phase(
|
||||
ExecutionPhase.RATE_LIMIT_PAUSED,
|
||||
f"Rate limit - resuming in {wait_minutes:.0f} minutes",
|
||||
reset_timestamp=reset_timestamp,
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Rate limit reached")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Rate limit reached"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"reset_timestamp": reset_timestamp,
|
||||
"error": sanitized_error,
|
||||
}
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print_status(
|
||||
f"Rate limited - waiting {wait_minutes:.0f} minutes for reset",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait with periodic checks for resume signal
|
||||
resumed_early = await wait_for_rate_limit_reset(
|
||||
spec_dir, wait_seconds, source_spec_dir
|
||||
)
|
||||
if resumed_early:
|
||||
print_status("Resumed early by user", "success")
|
||||
|
||||
# Resume execution
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after rate limit")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
else:
|
||||
# Couldn't parse reset time - fall back to standard retry
|
||||
print_status("Rate limit hit (unknown reset time)", "warning")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
_reset_concurrency_state()
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue
|
||||
|
||||
elif error_info and error_info.get("type") == "authentication":
|
||||
# Authentication error - pause for user re-authentication
|
||||
_reset_concurrency_state()
|
||||
|
||||
emit_phase(
|
||||
ExecutionPhase.AUTH_FAILURE_PAUSED,
|
||||
"Re-authentication required",
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Authentication failed")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Authentication failed"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"error": sanitized_error,
|
||||
"requires_action": "re-authenticate",
|
||||
}
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" AUTHENTICATION REQUIRED")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("OAuth token is invalid or expired.")
|
||||
print("Please re-authenticate in the Auto Claude settings.")
|
||||
print()
|
||||
print("The task will automatically resume once you re-authenticate.")
|
||||
print()
|
||||
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait for user to complete re-authentication
|
||||
await wait_for_auth_resume(spec_dir, source_spec_dir)
|
||||
|
||||
print_status("Authentication restored - resuming", "success")
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after re-authentication")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
|
||||
else:
|
||||
# Other errors - use standard retry logic
|
||||
print_status("Session encountered an error", "error")
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
Investigation context loading for agents.
|
||||
|
||||
Provides utilities to load investigation data from spec directories
|
||||
for GitHub-sourced tasks.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_investigation_context(spec_dir: Path) -> dict[str, Any] | None:
|
||||
"""
|
||||
Load investigation context if this spec was created from a GitHub issue.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Structured investigation context with root_cause, fix_approaches,
|
||||
reproducer, gotchas, and patterns_to_follow, or None if no
|
||||
investigation data exists.
|
||||
"""
|
||||
investigation_report_path = spec_dir / "investigation_report.json"
|
||||
|
||||
if not investigation_report_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(investigation_report_path) as f:
|
||||
report = json.load(f)
|
||||
|
||||
root_cause = report.get("root_cause", {})
|
||||
fix_advice = report.get("fix_advice", {})
|
||||
reproduction = report.get("reproduction", {})
|
||||
|
||||
# Structure the context for agents
|
||||
return {
|
||||
"root_cause": {
|
||||
"summary": root_cause.get("identified_root_cause"),
|
||||
"evidence": root_cause.get("evidence", ""),
|
||||
"code_paths": root_cause.get("code_paths", []),
|
||||
},
|
||||
"fix_approaches": fix_advice.get("approaches", []),
|
||||
"reproducer": reproduction if reproduction else None,
|
||||
"gotchas": fix_advice.get("gotchas", []),
|
||||
"patterns_to_follow": fix_advice.get("patterns_to_follow", []),
|
||||
"impact": report.get("impact", {}),
|
||||
}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def load_investigation_for_qa(
|
||||
spec_dir: Path, base_branch: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Load investigation context for QA validation.
|
||||
|
||||
Similar to load_investigation_context but includes base_branch
|
||||
for QA comparison.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
base_branch: Base branch to compare against (e.g., 'main', 'develop')
|
||||
|
||||
Returns:
|
||||
Structured investigation context with root_cause, reproducer,
|
||||
impact, expected_outcome, and base_branch, or None if no
|
||||
investigation data exists.
|
||||
"""
|
||||
investigation_report_path = spec_dir / "investigation_report.json"
|
||||
|
||||
if not investigation_report_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(investigation_report_path) as f:
|
||||
report = json.load(f)
|
||||
|
||||
root_cause = report.get("root_cause", {})
|
||||
reproduction = report.get("reproduction", {})
|
||||
|
||||
return {
|
||||
"root_cause": {
|
||||
"summary": root_cause.get("identified_root_cause"),
|
||||
"evidence": root_cause.get("evidence", ""),
|
||||
"code_paths": root_cause.get("code_paths", []),
|
||||
},
|
||||
"reproducer": reproduction if reproduction else None,
|
||||
"impact": report.get("impact", {}),
|
||||
"expected_outcome": report.get("ai_summary"),
|
||||
"base_branch": base_branch,
|
||||
}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
@@ -9,12 +9,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import (
|
||||
get_fast_mode,
|
||||
get_phase_client_thinking_kwargs,
|
||||
get_phase_model,
|
||||
get_phase_model_betas,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
@@ -99,22 +94,12 @@ 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_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"
|
||||
)
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
agent_type="planner",
|
||||
betas=planning_betas,
|
||||
fast_mode=fast_mode,
|
||||
**thinking_kwargs,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate follow-up planner prompt
|
||||
|
||||
+25
-153
@@ -10,12 +10,6 @@ import logging
|
||||
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 (
|
||||
@@ -26,7 +20,7 @@ from progress import (
|
||||
count_subtasks_detailed,
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager, check_and_recover, reset_subtask
|
||||
from recovery import RecoveryManager
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
@@ -40,7 +34,6 @@ from ui import (
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .base import sanitize_error_message
|
||||
from .memory_manager import save_session_memory
|
||||
from .utils import (
|
||||
find_subtask_in_plan,
|
||||
@@ -53,36 +46,26 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a 400 tool concurrency error from Claude API.
|
||||
|
||||
print_status(f"Recovery action: {recovery_action.action}", "info")
|
||||
print_status(f"Reason: {recovery_action.reason}", "info")
|
||||
Tool concurrency errors occur when too many tools are used simultaneously
|
||||
in a single API request, hitting Claude's concurrent tool use limit.
|
||||
|
||||
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")
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
Returns:
|
||||
True if this is a tool concurrency error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
# Check for 400 status AND tool concurrency keywords
|
||||
return "400" in error_str and (
|
||||
("tool" in error_str and "concurrency" in error_str)
|
||||
or "too many tools" in error_str
|
||||
or "concurrent tool" in error_str
|
||||
)
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
@@ -96,7 +79,6 @@ 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.
|
||||
@@ -114,7 +96,6 @@ 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
|
||||
@@ -246,77 +227,6 @@ 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)
|
||||
@@ -380,21 +290,6 @@ 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)
|
||||
@@ -673,18 +568,7 @@ async def run_agent_session(
|
||||
except Exception as e:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
is_rate_limit = is_rate_limit_error(e)
|
||||
is_auth = is_authentication_error(e)
|
||||
|
||||
# Classify error type for appropriate handling
|
||||
if is_concurrency:
|
||||
error_type = "tool_concurrency"
|
||||
elif is_rate_limit:
|
||||
error_type = "rate_limit"
|
||||
elif is_auth:
|
||||
error_type = "authentication"
|
||||
else:
|
||||
error_type = "other"
|
||||
error_type = "tool_concurrency" if is_concurrency else "other"
|
||||
|
||||
debug_error(
|
||||
"session",
|
||||
@@ -695,32 +579,20 @@ async def run_agent_session(
|
||||
tool_count=tool_count,
|
||||
)
|
||||
|
||||
# Sanitize error message to remove potentially sensitive data
|
||||
# Must happen BEFORE printing to stdout, since stdout is captured by the frontend
|
||||
sanitized_error = sanitize_error_message(str(e))
|
||||
|
||||
# Log errors prominently based on type
|
||||
# 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")
|
||||
elif is_rate_limit:
|
||||
print("\n⚠️ Rate limit reached")
|
||||
print(" API usage quota exceeded - waiting for reset")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_auth:
|
||||
print("\n⚠️ Authentication error")
|
||||
print(" OAuth token may be invalid or expired")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
print(f" Error: {str(e)[:200]}\n")
|
||||
else:
|
||||
print(f"Error during agent session: {sanitized_error}")
|
||||
print(f"Error during agent session: {e}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {sanitized_error}", phase)
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": sanitized_error,
|
||||
"message": str(e),
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", sanitized_error, error_info
|
||||
return "error", str(e), error_info
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/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__query-docs",
|
||||
"mcp__context7__get-library-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": "high",
|
||||
"thinking_default": "ultrathink",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
@@ -210,7 +210,7 @@ AGENT_CONFIGS = {
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "low", # Coding uses minimal thinking (effort: low for Opus, 1024 tokens for Sonnet/Haiku)
|
||||
"thinking_default": "none", # Coding doesn't use extended thinking
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 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 "low" for minimal thinking overhead
|
||||
# Haiku doesn't support thinking; create_simple_client() handles this
|
||||
"thinking_default": "low",
|
||||
# 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",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
@@ -292,14 +292,6 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_extraction": {
|
||||
# Lightweight extraction call for recovering data when structured output fails
|
||||
# Pure structured output extraction, no tools needed
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_finding_validator": {
|
||||
# Standalone validator for re-checking findings against actual code
|
||||
# Called separately from orchestrator to validate findings with fresh context
|
||||
@@ -308,13 +300,6 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"investigation_specialist": {
|
||||
# Read-only specialist for issue investigation (root cause, impact, fix, reproduction)
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -539,7 +524,7 @@ def get_default_thinking_level(agent_type: str) -> str:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (low, medium, high)
|
||||
Thinking level string (none, low, medium, high, ultrathink)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
|
||||
@@ -23,8 +23,7 @@ from .ci_discovery import CIDiscovery
|
||||
from .project_analyzer import ProjectAnalyzer
|
||||
from .risk_classifier import RiskClassifier
|
||||
from .security_scanner import SecurityScanner
|
||||
|
||||
# TestDiscovery was removed - tests are now co-located in their respective modules
|
||||
from .test_discovery import TestDiscovery
|
||||
|
||||
# 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
|
||||
@@ -38,5 +37,5 @@ __all__ = [
|
||||
"RiskClassifier",
|
||||
"SecurityScanner",
|
||||
"CIDiscovery",
|
||||
# "TestDiscovery", # Removed - tests now co-located in their modules
|
||||
"TestDiscovery",
|
||||
]
|
||||
|
||||
@@ -235,15 +235,10 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
|
||||
# Scripts
|
||||
scripts = pkg.get("scripts", {})
|
||||
pkg_mgr = self.analysis.get("package_manager", "npm")
|
||||
if "dev" in scripts:
|
||||
self.analysis["dev_command"] = f"{pkg_mgr} run dev"
|
||||
self.analysis["dev_command"] = "npm run dev"
|
||||
elif "start" in scripts:
|
||||
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)
|
||||
self.analysis["dev_command"] = "npm run start"
|
||||
|
||||
def _detect_go_framework(self, content: str) -> None:
|
||||
"""Detect Go framework."""
|
||||
|
||||
@@ -31,7 +31,6 @@ class ProjectAnalyzer:
|
||||
"""Run full project analysis."""
|
||||
self._detect_project_type()
|
||||
self._find_and_analyze_services()
|
||||
self._aggregate_dependency_locations()
|
||||
self._analyze_infrastructure()
|
||||
self._detect_conventions()
|
||||
self._map_dependencies()
|
||||
@@ -125,63 +124,6 @@ class ProjectAnalyzer:
|
||||
|
||||
self.index["services"] = services
|
||||
|
||||
def _aggregate_dependency_locations(self) -> None:
|
||||
"""Aggregate dependency location metadata from all services.
|
||||
|
||||
Collects dependency_locations from each service and stores them as
|
||||
paths relative to the project root (e.g., 'apps/backend/.venv'
|
||||
instead of just '.venv').
|
||||
"""
|
||||
aggregated: list[dict[str, Any]] = []
|
||||
|
||||
for service_name, service_info in self.index.get("services", {}).items():
|
||||
service_deps = service_info.get("dependency_locations", [])
|
||||
service_path = service_info.get("path", "")
|
||||
|
||||
# Compute service-relative prefix once per service
|
||||
service_rel: Path | None = None
|
||||
if service_path:
|
||||
try:
|
||||
service_rel = Path(service_path).relative_to(self.project_dir)
|
||||
except ValueError:
|
||||
# Service path is outside the project root — skip its deps
|
||||
# to avoid producing absolute paths that bypass containment
|
||||
continue
|
||||
|
||||
for dep in service_deps:
|
||||
dep_path = dep.get("path")
|
||||
if not dep_path:
|
||||
continue
|
||||
|
||||
# Build project-relative path from service path + dep path
|
||||
if service_rel is not None:
|
||||
project_relative = str(service_rel / dep_path)
|
||||
else:
|
||||
project_relative = dep_path
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"type": dep.get("type", "unknown"),
|
||||
"path": project_relative,
|
||||
"exists": dep.get("exists", False),
|
||||
"service": service_name,
|
||||
}
|
||||
if dep.get("requirements_file"):
|
||||
# Convert to project-relative path like we do for "path"
|
||||
if service_rel is not None:
|
||||
entry["requirements_file"] = str(
|
||||
service_rel / dep["requirements_file"]
|
||||
)
|
||||
else:
|
||||
entry["requirements_file"] = dep["requirements_file"]
|
||||
pkg_mgr = dep.get("package_manager") or service_info.get(
|
||||
"package_manager"
|
||||
)
|
||||
if pkg_mgr:
|
||||
entry["package_manager"] = pkg_mgr
|
||||
aggregated.append(entry)
|
||||
|
||||
self.index["dependency_locations"] = aggregated
|
||||
|
||||
def _analyze_infrastructure(self) -> None:
|
||||
"""Analyze infrastructure configuration."""
|
||||
infra = {}
|
||||
|
||||
@@ -40,8 +40,6 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
self._find_key_directories()
|
||||
self._find_entry_points()
|
||||
self._detect_dependencies()
|
||||
self._detect_dependency_locations()
|
||||
self._detect_package_manager()
|
||||
self._detect_testing()
|
||||
self._find_dockerfile()
|
||||
|
||||
@@ -211,121 +209,6 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
deps.append(match.group(1))
|
||||
self.analysis["dependencies"] = deps[:20]
|
||||
|
||||
def _detect_dependency_locations(self) -> None:
|
||||
"""Detect where dependencies live on disk for this service."""
|
||||
locations: list[dict[str, Any]] = []
|
||||
|
||||
# Node.js: node_modules (only if package.json exists)
|
||||
if self._exists("package.json"):
|
||||
node_modules = self.path / "node_modules"
|
||||
locations.append(
|
||||
{
|
||||
"type": "node_modules",
|
||||
"path": "node_modules",
|
||||
"exists": node_modules.exists() and node_modules.is_dir(),
|
||||
}
|
||||
)
|
||||
|
||||
# Python: .venv or venv
|
||||
for venv_dir in [".venv", "venv"]:
|
||||
venv_path = self.path / venv_dir
|
||||
if venv_path.exists() and venv_path.is_dir():
|
||||
entry: dict[str, Any] = {
|
||||
"type": "venv",
|
||||
"path": venv_dir,
|
||||
"exists": True,
|
||||
}
|
||||
# Find requirements file
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
entry["requirements_file"] = req_file
|
||||
break
|
||||
locations.append(entry)
|
||||
break
|
||||
else:
|
||||
# No venv found, still record requirements file if present
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
locations.append(
|
||||
{
|
||||
"type": "venv",
|
||||
"path": ".venv",
|
||||
"exists": False,
|
||||
"requirements_file": req_file,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
# PHP: vendor
|
||||
vendor_path = self.path / "vendor"
|
||||
if vendor_path.exists() and vendor_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_php",
|
||||
"path": "vendor",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Rust: target
|
||||
target_path = self.path / "target"
|
||||
if target_path.exists() and target_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "cargo_target",
|
||||
"path": "target",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Ruby: vendor/bundle
|
||||
bundle_path = self.path / "vendor" / "bundle"
|
||||
if bundle_path.exists() and bundle_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_bundle",
|
||||
"path": "vendor/bundle",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.analysis["dependency_locations"] = locations
|
||||
|
||||
def _detect_package_manager(self) -> None:
|
||||
"""Detect the package manager used by this service."""
|
||||
# Node.js package managers
|
||||
if self._exists("package-lock.json"):
|
||||
self.analysis["package_manager"] = "npm"
|
||||
elif self._exists("yarn.lock"):
|
||||
self.analysis["package_manager"] = "yarn"
|
||||
elif self._exists("pnpm-lock.yaml"):
|
||||
self.analysis["package_manager"] = "pnpm"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
self.analysis["package_manager"] = "bun"
|
||||
# Python package managers
|
||||
elif self._exists("Pipfile"):
|
||||
self.analysis["package_manager"] = "pipenv"
|
||||
elif self._exists("pyproject.toml"):
|
||||
if self._exists("uv.lock"):
|
||||
self.analysis["package_manager"] = "uv"
|
||||
elif self._exists("poetry.lock"):
|
||||
self.analysis["package_manager"] = "poetry"
|
||||
else:
|
||||
self.analysis["package_manager"] = "pip"
|
||||
elif self._exists("requirements.txt"):
|
||||
self.analysis["package_manager"] = "pip"
|
||||
# Other
|
||||
elif self._exists("Cargo.toml"):
|
||||
self.analysis["package_manager"] = "cargo"
|
||||
elif self._exists("go.mod"):
|
||||
self.analysis["package_manager"] = "go_mod"
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["package_manager"] = "gem"
|
||||
elif self._exists("composer.json"):
|
||||
self.analysis["package_manager"] = "composer"
|
||||
else:
|
||||
self.analysis["package_manager"] = None
|
||||
|
||||
def _detect_testing(self) -> None:
|
||||
"""Detect testing framework and configuration."""
|
||||
if self._exists("package.json"):
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
#!/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()
|
||||
@@ -10,7 +10,6 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from qa.criteria import is_fixes_applied, is_qa_approved, is_qa_rejected
|
||||
from ui import highlight, print_status
|
||||
|
||||
|
||||
@@ -152,22 +151,13 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Determine status (highest priority first)
|
||||
# Use authoritative QA status check, not just file existence
|
||||
if is_qa_approved(spec_dir):
|
||||
status = "qa_approved"
|
||||
elif is_qa_rejected(spec_dir):
|
||||
status = "qa_rejected"
|
||||
elif is_fixes_applied(spec_dir):
|
||||
status = "fixes_applied"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
# Check if there's a qa_report.md but no approval yet (QA in progress)
|
||||
if (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_in_progress"
|
||||
else:
|
||||
status = "building"
|
||||
elif (spec_dir / "spec.md").exists():
|
||||
# Determine status
|
||||
if (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
status = "building"
|
||||
elif (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_approved"
|
||||
else:
|
||||
status = "pending_spec"
|
||||
|
||||
@@ -175,10 +165,7 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
"pending_spec": "⏳",
|
||||
"spec_created": "📋",
|
||||
"building": "⚙️",
|
||||
"qa_in_progress": "🔍",
|
||||
"qa_approved": "✅",
|
||||
"qa_rejected": "❌",
|
||||
"fixes_applied": "🔧",
|
||||
"unknown": "❓",
|
||||
}.get(status, "❓")
|
||||
|
||||
@@ -205,10 +192,10 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print_status("No specs directory found", "info")
|
||||
return True
|
||||
|
||||
# Find completed specs (only QA-approved, matching status display logic)
|
||||
# Find completed specs
|
||||
completed = []
|
||||
for spec_dir in specs_dir.iterdir():
|
||||
if spec_dir.is_dir() and is_qa_approved(spec_dir):
|
||||
if spec_dir.is_dir() and (spec_dir / "qa_report.md").exists():
|
||||
completed.append(spec_dir.name)
|
||||
|
||||
if not completed:
|
||||
|
||||
@@ -61,8 +61,6 @@ def handle_build_command(
|
||||
skip_qa: bool,
|
||||
force_bypass_approval: bool,
|
||||
base_branch: str | None = None,
|
||||
issue_workflow: bool = False,
|
||||
issue_number: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Handle the main build command.
|
||||
@@ -79,8 +77,6 @@ def handle_build_command(
|
||||
skip_qa: Skip automatic QA validation
|
||||
force_bypass_approval: Force bypass approval check
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
issue_workflow: If True, run from a GitHub issue investigation
|
||||
issue_number: GitHub issue number (required when issue_workflow=True)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
@@ -99,30 +95,6 @@ def handle_build_command(
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
# Handle issue workflow: load investigation report and inject context
|
||||
if issue_workflow:
|
||||
if not issue_number:
|
||||
print("\nError: --issue-number is required with --issue-workflow")
|
||||
sys.exit(1)
|
||||
|
||||
pipeline_mode = _get_investigation_pipeline_mode(project_dir)
|
||||
_inject_issue_workflow_context(project_dir, spec_dir, issue_number)
|
||||
|
||||
# pipelineMode controls which phases to skip:
|
||||
# - "full": run complete spec + planning + coding + QA pipeline (default)
|
||||
# - "skip_to_planning": skip spec creation, go to planning (investigation = spec)
|
||||
# - "minimal": skip spec + planning, go straight to coding
|
||||
if pipeline_mode == "skip_to_planning":
|
||||
# Investigation report serves as the spec; bypass approval since
|
||||
# the investigation was already reviewed.
|
||||
force_bypass_approval = True
|
||||
elif pipeline_mode == "minimal":
|
||||
# Skip everything: create a minimal plan so the planner is bypassed
|
||||
# and the coder starts immediately from the investigation context.
|
||||
force_bypass_approval = True
|
||||
skip_qa = True
|
||||
_create_minimal_plan_for_issue(spec_dir, issue_number)
|
||||
|
||||
# Get the resolved model for the planning phase (first phase of build)
|
||||
# This respects task_metadata.json phase configuration from the UI
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
@@ -477,7 +449,7 @@ def _handle_build_interrupt(
|
||||
if choice == "skip":
|
||||
print()
|
||||
print_status("Resuming build...", "info")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
status_manager.update(state=BuildState.RUNNING)
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir,
|
||||
@@ -513,228 +485,3 @@ def _handle_build_interrupt(
|
||||
content.append(muted("Your build is in a separate workspace and is safe."))
|
||||
print(box(content, width=70, style="light"))
|
||||
print()
|
||||
|
||||
|
||||
def _create_minimal_plan_for_issue(spec_dir: Path, issue_number: int) -> None:
|
||||
"""Create a minimal implementation plan so the planner phase is skipped.
|
||||
|
||||
Used in "minimal" pipeline mode where the investigation context is
|
||||
sufficient and we want the coder to start immediately.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory path
|
||||
issue_number: GitHub issue number for context
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
# Don't overwrite an existing plan (e.g. if resuming)
|
||||
return
|
||||
|
||||
plan = {
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Implementation",
|
||||
"description": f"Implement fix for issue #{issue_number} based on investigation findings",
|
||||
"depends_on": [],
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "subtask-1-1",
|
||||
"description": (
|
||||
f"Implement the fix for issue #{issue_number}. "
|
||||
"Follow the investigation context in HUMAN_INPUT.md "
|
||||
"for root cause analysis, recommended fix approach, "
|
||||
"and files to modify."
|
||||
),
|
||||
"service": "main",
|
||||
"status": "pending",
|
||||
"files_to_create": [],
|
||||
"files_to_modify": [],
|
||||
"patterns_from": [],
|
||||
"verification": {
|
||||
"type": "manual",
|
||||
"run": "Verify the fix resolves the issue",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"complexity": "simple",
|
||||
"estimated_sessions": 1,
|
||||
"pipeline_mode": "minimal",
|
||||
"source_issue": issue_number,
|
||||
},
|
||||
}
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
|
||||
write_json_atomic(plan_file, plan)
|
||||
print(" Pipeline mode: minimal (skipping planner, created single-subtask plan)")
|
||||
|
||||
|
||||
def _get_investigation_pipeline_mode(project_dir: Path) -> str:
|
||||
"""Read the pipelineMode from investigation settings.
|
||||
|
||||
Reads from .auto-claude/github/config.json -> investigation_settings.pipelineMode.
|
||||
Defaults to "full" if not configured.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Pipeline mode string: "full", "skip_to_planning", or "minimal"
|
||||
"""
|
||||
import json
|
||||
|
||||
config_path = project_dir / ".auto-claude" / "github" / "config.json"
|
||||
if not config_path.exists():
|
||||
return "full"
|
||||
|
||||
try:
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
settings = data.get("investigation_settings", {})
|
||||
mode = settings.get("pipelineMode", "full")
|
||||
if mode in ("full", "skip_to_planning", "minimal"):
|
||||
return mode
|
||||
return "full"
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return "full"
|
||||
|
||||
|
||||
def _inject_issue_workflow_context(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
issue_number: int,
|
||||
) -> None:
|
||||
"""Inject investigation context into the build workflow.
|
||||
|
||||
Loads the investigation report for the given issue and writes a
|
||||
HUMAN_INPUT.md file in the spec directory with root cause analysis,
|
||||
fix advice, and other investigation context. This is read by the
|
||||
coder agent as additional guidance.
|
||||
|
||||
Also updates the investigation state to "building".
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
issue_number: GitHub issue number
|
||||
"""
|
||||
|
||||
# Use try/except for imports matching the codebase pattern
|
||||
try:
|
||||
from runners.github.services.investigation_persistence import (
|
||||
load_investigation_report,
|
||||
save_investigation_state,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
# Add parent to path if needed
|
||||
_backend = Path(__file__).parent.parent
|
||||
if str(_backend) not in sys.path:
|
||||
sys.path.insert(0, str(_backend))
|
||||
from runners.github.services.investigation_persistence import (
|
||||
load_investigation_report,
|
||||
save_investigation_state,
|
||||
)
|
||||
|
||||
report = load_investigation_report(project_dir, issue_number)
|
||||
if report is None:
|
||||
print(f"\nWarning: No investigation report found for issue #{issue_number}")
|
||||
print("Proceeding without investigation context.")
|
||||
return
|
||||
|
||||
# Build context string for the coder agent
|
||||
context_parts: list[str] = []
|
||||
context_parts.append(f"# Investigation Context for Issue #{issue_number}")
|
||||
context_parts.append("")
|
||||
context_parts.append(f"## {report.issue_title}")
|
||||
context_parts.append("")
|
||||
context_parts.append(f"**Severity:** {report.severity}")
|
||||
context_parts.append("")
|
||||
|
||||
# AI summary
|
||||
context_parts.append("## Summary")
|
||||
context_parts.append(report.ai_summary)
|
||||
context_parts.append("")
|
||||
|
||||
# Root cause
|
||||
context_parts.append("## Root Cause")
|
||||
context_parts.append(report.root_cause.identified_root_cause)
|
||||
context_parts.append("")
|
||||
|
||||
if report.root_cause.code_paths:
|
||||
context_parts.append("### Code Paths")
|
||||
for cp in report.root_cause.code_paths:
|
||||
end = cp.end_line if cp.end_line else cp.start_line
|
||||
context_parts.append(
|
||||
f"- `{cp.file}:{cp.start_line}-{end}`: {cp.description}"
|
||||
)
|
||||
context_parts.append("")
|
||||
|
||||
# Fix advice
|
||||
if report.fix_advice.approaches:
|
||||
rec_idx = report.fix_advice.recommended_approach
|
||||
context_parts.append("## Recommended Fix")
|
||||
if 0 <= rec_idx < len(report.fix_advice.approaches):
|
||||
approach = report.fix_advice.approaches[rec_idx]
|
||||
context_parts.append(approach.description)
|
||||
context_parts.append("")
|
||||
if approach.files_affected:
|
||||
context_parts.append("**Files to modify:**")
|
||||
for f in approach.files_affected:
|
||||
context_parts.append(f"- `{f}`")
|
||||
context_parts.append("")
|
||||
|
||||
# Gotchas
|
||||
if report.fix_advice.gotchas:
|
||||
context_parts.append("## Gotchas")
|
||||
for gotcha in report.fix_advice.gotchas:
|
||||
context_parts.append(f"- {gotcha}")
|
||||
context_parts.append("")
|
||||
|
||||
# Patterns
|
||||
if report.fix_advice.patterns_to_follow:
|
||||
context_parts.append("## Patterns to Follow")
|
||||
for pat in report.fix_advice.patterns_to_follow:
|
||||
context_parts.append(f"- `{pat.file}`: {pat.description}")
|
||||
context_parts.append("")
|
||||
|
||||
context = "\n".join(context_parts)
|
||||
|
||||
# Write as HUMAN_INPUT.md (existing mechanism for agent guidance injection)
|
||||
input_file = spec_dir / "HUMAN_INPUT.md"
|
||||
existing = ""
|
||||
if input_file.exists():
|
||||
existing = input_file.read_text(encoding="utf-8")
|
||||
|
||||
if existing:
|
||||
# Append investigation context to existing human input
|
||||
combined = existing + "\n\n" + context
|
||||
else:
|
||||
combined = context
|
||||
|
||||
input_file.write_text(combined, encoding="utf-8")
|
||||
|
||||
# Update investigation state to "building" (note: we use the broader
|
||||
# "task_created" status since "building" isn't a valid InvestigationState status)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
save_investigation_state(
|
||||
project_dir,
|
||||
issue_number,
|
||||
{
|
||||
"issue_number": issue_number,
|
||||
"status": "task_created",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"linked_spec_id": spec_dir.name,
|
||||
},
|
||||
)
|
||||
|
||||
print(f"\nIssue workflow: Injected investigation context for #{issue_number}")
|
||||
print(f" Root cause: {report.root_cause.identified_root_cause[:80]}...")
|
||||
print(f" Severity: {report.severity}")
|
||||
print()
|
||||
|
||||
@@ -280,19 +280,6 @@ Environment Variables:
|
||||
help="Actually delete files in cleanup (not just preview)",
|
||||
)
|
||||
|
||||
# Issue workflow
|
||||
parser.add_argument(
|
||||
"--issue-workflow",
|
||||
action="store_true",
|
||||
help="Run build from a GitHub issue investigation (requires --issue-number)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--issue-number",
|
||||
type=int,
|
||||
default=None,
|
||||
help="GitHub issue number for --issue-workflow",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -490,8 +477,6 @@ def _run_cli() -> None:
|
||||
skip_qa=args.skip_qa,
|
||||
force_bypass_approval=args.force,
|
||||
base_branch=args.base_branch,
|
||||
issue_workflow=args.issue_workflow,
|
||||
issue_number=args.issue_number,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.platform import (
|
||||
get_where_exe_path,
|
||||
is_linux,
|
||||
is_macos,
|
||||
is_windows,
|
||||
@@ -694,25 +693,10 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
|
||||
if _debug:
|
||||
# Log which auth env vars are set (presence only, never values)
|
||||
set_vars = [v for v in AUTH_TOKEN_ENV_VARS if os.environ.get(v)]
|
||||
logger.info(
|
||||
"[Auth] get_auth_token() called — config_dir param=%s, "
|
||||
"env vars present: %s, CLAUDE_CONFIG_DIR env=%s",
|
||||
repr(config_dir),
|
||||
set_vars or "(none)",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
# First check environment variables (highest priority)
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from env var: %s", var)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
|
||||
@@ -720,13 +704,12 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
|
||||
# Debug: Log which config_dir is being used for credential resolution
|
||||
if _debug and effective_config_dir:
|
||||
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
if debug and effective_config_dir:
|
||||
service_name = _get_keychain_service_name(effective_config_dir)
|
||||
logger.info(
|
||||
"[Auth] Resolving credentials for profile config_dir: %s "
|
||||
"(Keychain service: %s)",
|
||||
effective_config_dir,
|
||||
service_name,
|
||||
f"[Auth] Resolving credentials for profile config_dir: {effective_config_dir} "
|
||||
f"(Keychain service: {service_name})"
|
||||
)
|
||||
|
||||
# If a custom config directory is specified, read from there first
|
||||
@@ -734,37 +717,24 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
# Try reading from .credentials.json file in the config directory
|
||||
token = _get_token_from_config_dir(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from config dir file: %s",
|
||||
effective_config_dir,
|
||||
)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Also try the system credential store with hash-based service name
|
||||
# This is needed because macOS stores credentials in Keychain, not files
|
||||
token = get_token_from_keychain(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from Keychain (profile-specific)")
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# If config_dir was explicitly provided, DON'T fall back to default keychain
|
||||
# - that would return the wrong profile's token
|
||||
logger.debug(
|
||||
"No credentials found for config_dir '%s' in file or keychain",
|
||||
effective_config_dir,
|
||||
f"No credentials found for config_dir '{effective_config_dir}' "
|
||||
"in file or keychain"
|
||||
)
|
||||
return None
|
||||
|
||||
# No config_dir specified - use default system credential store
|
||||
keychain_token = get_token_from_keychain()
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from default Keychain: %s",
|
||||
"found" if keychain_token else "not found",
|
||||
)
|
||||
return _try_decrypt_token(keychain_token)
|
||||
return _try_decrypt_token(get_token_from_keychain())
|
||||
|
||||
|
||||
def get_auth_token_source(config_dir: str | None = None) -> str | None:
|
||||
@@ -884,9 +854,9 @@ def _find_git_bash_path() -> str | None:
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use full path to where.exe for reliability (works even when System32 isn't in PATH)
|
||||
# Use where.exe explicitly for reliability
|
||||
result = subprocess.run(
|
||||
[get_where_exe_path(), "git"],
|
||||
["where.exe", "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
@@ -999,18 +969,8 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
- API profile mode: requires ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env)
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip())
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] configure_sdk_authentication() — mode=%s, config_dir=%s, "
|
||||
"CLAUDE_CONFIG_DIR env=%s",
|
||||
"api_profile" if api_profile_mode else "oauth",
|
||||
repr(config_dir),
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
if api_profile_mode:
|
||||
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
|
||||
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
||||
@@ -1038,14 +998,6 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
logger.info("Using OAuth authentication")
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] SDK env check — CLAUDE_CONFIG_DIR=%s, "
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=%s",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
"set" if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") else "unset",
|
||||
)
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,6 @@ 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,
|
||||
@@ -450,9 +449,6 @@ 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.
|
||||
@@ -468,9 +464,10 @@ 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)
|
||||
- high: 16384 (spec creation, QA review)
|
||||
- medium: 4096 (planning, validation)
|
||||
- low: 1024 (coding)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
- medium: 5000 (planning, validation)
|
||||
- None: disabled (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
|
||||
@@ -478,16 +475,6 @@ 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
|
||||
@@ -515,24 +502,6 @@ 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']}")
|
||||
@@ -696,12 +665,7 @@ def create_client(
|
||||
print(" - Worktree permissions: granted for original project directories")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_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}")
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
@@ -853,12 +817,6 @@ 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")
|
||||
@@ -867,6 +825,7 @@ def create_client(
|
||||
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
|
||||
|
||||
# Add structured output format if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
if output_format:
|
||||
options_kwargs["output_format"] = output_format
|
||||
|
||||
@@ -875,8 +834,4 @@ 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))
|
||||
|
||||
@@ -235,10 +235,10 @@ def debug_section(module: str, title: str) -> None:
|
||||
return
|
||||
|
||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
separator = "-" * 60
|
||||
log_line = f"\n{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.DEBUG}{Colors.BOLD}+{separator}+{Colors.RESET}"
|
||||
log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}| {module}: {title}{' ' * (58 - len(module) - len(title) - 2)}|{Colors.RESET}"
|
||||
log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}+{separator}+{Colors.RESET}"
|
||||
separator = "─" * 60
|
||||
log_line = f"\n{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.DEBUG}{Colors.BOLD}┌{separator}┐{Colors.RESET}"
|
||||
log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}│ {module}: {title}{' ' * (58 - len(module) - len(title) - 2)}│{Colors.RESET}"
|
||||
log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}└{separator}┘{Colors.RESET}"
|
||||
|
||||
_write_log(log_line)
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
)
|
||||
@@ -1,76 +0,0 @@
|
||||
"""
|
||||
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,8 +10,6 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
_cached_gh_path: str | None = None
|
||||
|
||||
|
||||
@@ -55,11 +53,12 @@ def _run_where_command() -> str | None:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[get_where_exe_path(), "gh"],
|
||||
"where 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()
|
||||
@@ -134,7 +133,7 @@ def _find_gh_executable() -> str | None:
|
||||
if os.path.isfile(path) and _verify_gh_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
@@ -15,8 +15,6 @@ 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.
|
||||
@@ -126,13 +124,14 @@ def _find_git_executable() -> str:
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 4. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
# 4. Try 'where' command with shell=True (more reliable on Windows)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[get_where_exe_path(), "git"],
|
||||
"where 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,8 +10,6 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.platform import get_where_exe_path
|
||||
|
||||
_cached_glab_path: str | None = None
|
||||
|
||||
|
||||
@@ -55,11 +53,12 @@ def _run_where_command() -> str | None:
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[get_where_exe_path(), "glab"],
|
||||
"where 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()
|
||||
@@ -135,7 +134,7 @@ def _find_glab_executable() -> str | None:
|
||||
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
@@ -23,9 +23,6 @@ class ExecutionPhase(str, Enum):
|
||||
QA_FIXING = "qa_fixing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
# Pause states for intelligent error recovery
|
||||
RATE_LIMIT_PAUSED = "rate_limit_paused"
|
||||
AUTH_FAILURE_PAUSED = "auth_failure_paused"
|
||||
|
||||
|
||||
def emit_phase(
|
||||
@@ -34,19 +31,8 @@ def emit_phase(
|
||||
*,
|
||||
progress: int | None = None,
|
||||
subtask: str | None = None,
|
||||
reset_timestamp: int | None = None,
|
||||
profile_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit structured phase event to stdout for frontend parsing.
|
||||
|
||||
Args:
|
||||
phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED)
|
||||
message: Optional message describing the phase state
|
||||
progress: Optional progress percentage (0-100)
|
||||
subtask: Optional subtask identifier
|
||||
reset_timestamp: Optional Unix timestamp for rate limit reset time
|
||||
profile_id: Optional profile ID that triggered the pause
|
||||
"""
|
||||
"""Emit structured phase event to stdout for frontend parsing."""
|
||||
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -62,12 +48,6 @@ def emit_phase(
|
||||
if subtask is not None:
|
||||
payload["subtask"] = subtask
|
||||
|
||||
if reset_timestamp is not None:
|
||||
payload["reset_timestamp"] = reset_timestamp
|
||||
|
||||
if profile_id is not None:
|
||||
payload["profile_id"] = profile_id
|
||||
|
||||
try:
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
|
||||
@@ -430,22 +430,6 @@ 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.
|
||||
|
||||
@@ -9,11 +9,8 @@ Enhanced with colored output, icons, and better visual formatting.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from core.plan_normalization import normalize_subtask_aliases
|
||||
from ui import (
|
||||
Icons,
|
||||
@@ -233,8 +230,8 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
|
||||
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
|
||||
)
|
||||
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.debug(f"Failed to load plan file for phase summary: {e}")
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Ignore corrupted/unreadable progress files
|
||||
else:
|
||||
print()
|
||||
print_status("No implementation subtasks yet - planner needs to run", "pending")
|
||||
@@ -407,8 +404,6 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
"""
|
||||
Find the next subtask to work on, respecting phase dependencies.
|
||||
|
||||
Skips subtasks that are marked as stuck in the recovery manager's attempt history.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing implementation_plan.json
|
||||
|
||||
@@ -420,23 +415,6 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
|
||||
# Load stuck subtasks from recovery manager's attempt history
|
||||
stuck_subtask_ids = set()
|
||||
attempt_history_file = spec_dir / "memory" / "attempt_history.json"
|
||||
if attempt_history_file.exists():
|
||||
try:
|
||||
with open(attempt_history_file, encoding="utf-8") as f:
|
||||
attempt_history = json.load(f)
|
||||
# Collect IDs of subtasks marked as stuck
|
||||
stuck_subtask_ids = {
|
||||
entry["subtask_id"]
|
||||
for entry in attempt_history.get("stuck_subtasks", [])
|
||||
if "subtask_id" in entry
|
||||
}
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# If we can't read the file, continue without stuck checking
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
@@ -477,15 +455,9 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
if not deps_satisfied:
|
||||
continue
|
||||
|
||||
# Find first pending subtask in this phase (skip stuck subtasks)
|
||||
# Find first pending subtask in this phase
|
||||
for subtask in phase.get("subtasks", phase.get("chunks", [])):
|
||||
status = subtask.get("status", "pending")
|
||||
subtask_id = subtask.get("id")
|
||||
|
||||
# Skip stuck subtasks
|
||||
if subtask_id in stuck_subtask_ids:
|
||||
continue
|
||||
|
||||
if status in {"pending", "not_started", "not started"}:
|
||||
subtask_out, _changed = normalize_subtask_aliases(subtask)
|
||||
subtask_out["status"] = "pending"
|
||||
|
||||
@@ -186,12 +186,14 @@ def _before_send(event: dict, hint: dict) -> dict | None:
|
||||
|
||||
def init_sentry(
|
||||
component: str = "backend",
|
||||
force_enable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Initialize Sentry for the Python backend.
|
||||
|
||||
Args:
|
||||
component: Component name for tagging (e.g., "backend", "github-runner")
|
||||
force_enable: Force enable even without packaged app detection
|
||||
|
||||
Returns:
|
||||
True if Sentry was initialized, False otherwise
|
||||
@@ -210,11 +212,20 @@ def init_sentry(
|
||||
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
|
||||
return False
|
||||
|
||||
# DSN is present (checked above), so Sentry should be enabled.
|
||||
# The Electron main process only passes SENTRY_DSN to subprocesses in
|
||||
# production builds, so its presence is sufficient to gate activation.
|
||||
# In dev, set SENTRY_DSN in your environment to opt-in.
|
||||
# Check if we should enable Sentry
|
||||
# Enable if:
|
||||
# - Running from packaged app (detected by __compiled__ or frozen)
|
||||
# - SENTRY_DEV=true is set
|
||||
# - force_enable is True
|
||||
is_packaged = getattr(sys, "frozen", False) or hasattr(sys, "__compiled__")
|
||||
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
|
||||
should_enable = is_packaged or sentry_dev or force_enable
|
||||
|
||||
if not should_enable:
|
||||
logger.debug(
|
||||
"[Sentry] Development mode - error reporting disabled (set SENTRY_DEV=true to enable)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
@@ -31,7 +31,6 @@ 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
|
||||
|
||||
@@ -45,9 +44,6 @@ 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.
|
||||
@@ -69,11 +65,6 @@ 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
|
||||
@@ -91,17 +82,6 @@ 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)
|
||||
|
||||
@@ -124,19 +104,10 @@ 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,6 +17,7 @@ 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)
|
||||
@@ -27,17 +28,10 @@ _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
|
||||
@@ -80,9 +74,7 @@ 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,
|
||||
@@ -99,8 +91,6 @@ from .models import (
|
||||
MergeLockError,
|
||||
ParallelMergeResult,
|
||||
ParallelMergeTask,
|
||||
SpecNumberLock,
|
||||
SpecNumberLockError,
|
||||
WorkspaceChoice,
|
||||
WorkspaceMode,
|
||||
)
|
||||
@@ -120,9 +110,11 @@ from .setup import (
|
||||
__all__ = [
|
||||
# Merge Operations (from workspace.py)
|
||||
"merge_existing_build",
|
||||
# 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
|
||||
"_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)
|
||||
# Models
|
||||
"WorkspaceMode",
|
||||
"WorkspaceChoice",
|
||||
@@ -130,8 +122,6 @@ __all__ = [
|
||||
"ParallelMergeResult",
|
||||
"MergeLock",
|
||||
"MergeLockError",
|
||||
"SpecNumberLock",
|
||||
"SpecNumberLockError",
|
||||
# Git Utils
|
||||
"has_uncommitted_changes",
|
||||
"get_current_branch",
|
||||
@@ -141,11 +131,8 @@ __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",
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""
|
||||
Dependency Strategy Mapping
|
||||
============================
|
||||
|
||||
Maps dependency types to sharing strategies for worktree creation.
|
||||
|
||||
Each dependency ecosystem has different constraints:
|
||||
|
||||
- **node_modules**: Safe to symlink. Node's resolution algorithm follows symlinks
|
||||
correctly, and the directory is self-contained.
|
||||
|
||||
- **venv / .venv**: Must be recreated. Python's ``pyvenv.cfg`` discovery walks the
|
||||
real directory hierarchy without resolving symlinks (CPython bug #106045), so a
|
||||
symlinked venv resolves paths relative to the *target*, not the worktree.
|
||||
|
||||
- **vendor (PHP)**: Safe to symlink. Composer's autoloader uses ``__DIR__``-relative
|
||||
paths that resolve correctly through symlinks.
|
||||
|
||||
- **cargo target / go modules**: Skip entirely. Rust's ``target/`` dir contains
|
||||
per-machine build artifacts that must be rebuilt. Go uses a global module cache
|
||||
(``$GOPATH/pkg/mod``), so there is nothing in-tree to share.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import DependencyShareConfig, DependencyStrategy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default strategy map
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps dependency type identifiers to the strategy that should be used when
|
||||
# sharing that dependency across worktrees. Data-driven — add new entries
|
||||
# here rather than writing if/else branches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_STRATEGY_MAP: dict[str, DependencyStrategy] = {
|
||||
# JavaScript / Node.js — symlink is safe and fast
|
||||
"node_modules": DependencyStrategy.SYMLINK,
|
||||
# Python — venvs MUST be recreated (pyvenv.cfg symlink bug)
|
||||
"venv": DependencyStrategy.RECREATE,
|
||||
".venv": DependencyStrategy.RECREATE,
|
||||
# PHP — Composer vendor dir is safe to symlink
|
||||
"vendor_php": DependencyStrategy.SYMLINK,
|
||||
# Ruby — Bundler vendor/bundle is safe to symlink
|
||||
"vendor_bundle": DependencyStrategy.SYMLINK,
|
||||
# Rust — build output dir, skip (rebuilt per-worktree)
|
||||
"cargo_target": DependencyStrategy.SKIP,
|
||||
# Go — global module cache, nothing in-tree to share
|
||||
"go_modules": DependencyStrategy.SKIP,
|
||||
}
|
||||
|
||||
|
||||
def get_dependency_configs(
|
||||
project_index: dict | None,
|
||||
project_dir: Path | None = None,
|
||||
) -> list[DependencyShareConfig]:
|
||||
"""Derive dependency share configs from a project index.
|
||||
|
||||
If *project_index* is ``None`` or lacks ``dependency_locations``,
|
||||
falls back to a hardcoded node_modules config for backward compatibility
|
||||
with existing worktree setups.
|
||||
|
||||
Args:
|
||||
project_index: Parsed ``project_index.json`` dict, or ``None``.
|
||||
project_dir: Project root directory for resolved-path containment
|
||||
checks (defense-in-depth). Should always be provided when
|
||||
*project_index* is not ``None`` — omitting it disables the
|
||||
resolved-path security check.
|
||||
|
||||
Returns:
|
||||
List of :class:`DependencyShareConfig` objects — one per discovered
|
||||
dependency location.
|
||||
"""
|
||||
|
||||
configs: list[DependencyShareConfig] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
if project_index is not None:
|
||||
if project_dir is None:
|
||||
logger.warning(
|
||||
"get_dependency_configs called with project_index but no "
|
||||
"project_dir — resolved-path containment check is disabled"
|
||||
)
|
||||
|
||||
# Use the aggregated top-level dependency_locations which already
|
||||
# contain project-relative paths (e.g. "apps/backend/.venv" instead
|
||||
# of just ".venv"). This avoids a monorepo path resolution bug
|
||||
# where service-relative paths were incorrectly treated as project-
|
||||
# relative.
|
||||
dep_locations = project_index.get("dependency_locations") or []
|
||||
for dep in dep_locations:
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
|
||||
dep_type = dep.get("type", "")
|
||||
rel_path = dep.get("path", "")
|
||||
|
||||
if not dep_type or not rel_path:
|
||||
continue
|
||||
|
||||
# Path containment: reject absolute paths and traversals.
|
||||
# Check both POSIX and Windows path styles for cross-platform safety.
|
||||
p = PurePosixPath(rel_path)
|
||||
if p.is_absolute() or PureWindowsPath(rel_path).is_absolute():
|
||||
continue
|
||||
if ".." in p.parts or ".." in PureWindowsPath(rel_path).parts:
|
||||
continue
|
||||
|
||||
# Defense-in-depth: verify the resolved path stays within project_dir
|
||||
if project_dir is not None:
|
||||
resolved = (project_dir / rel_path).resolve()
|
||||
if not str(resolved).startswith(str(project_dir.resolve()) + os.sep):
|
||||
continue
|
||||
|
||||
# Deduplicate by relative path
|
||||
if rel_path in seen:
|
||||
continue
|
||||
seen.add(rel_path)
|
||||
|
||||
strategy = DEFAULT_STRATEGY_MAP.get(dep_type, DependencyStrategy.SKIP)
|
||||
|
||||
# Validate requirements_file path containment too
|
||||
req_file = dep.get("requirements_file")
|
||||
if req_file:
|
||||
rp = PurePosixPath(req_file)
|
||||
if (
|
||||
rp.is_absolute()
|
||||
or PureWindowsPath(req_file).is_absolute()
|
||||
or ".." in rp.parts
|
||||
or ".." in PureWindowsPath(req_file).parts
|
||||
):
|
||||
req_file = None
|
||||
|
||||
# Defense-in-depth: resolved-path containment (matches rel_path check)
|
||||
if req_file and project_dir is not None:
|
||||
resolved_req = (project_dir / req_file).resolve()
|
||||
if not str(resolved_req).startswith(
|
||||
str(project_dir.resolve()) + os.sep
|
||||
):
|
||||
req_file = None
|
||||
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type=dep_type,
|
||||
strategy=strategy,
|
||||
source_rel_path=rel_path,
|
||||
requirements_file=req_file,
|
||||
package_manager=dep.get("package_manager"),
|
||||
)
|
||||
)
|
||||
|
||||
# Fallback: if no configs were discovered, default to node_modules-only
|
||||
# so existing worktree behaviour is preserved.
|
||||
if not configs:
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="node_modules",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="apps/frontend/node_modules",
|
||||
)
|
||||
)
|
||||
|
||||
return configs
|
||||
@@ -273,31 +273,3 @@ class SpecNumberLock:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
|
||||
|
||||
class DependencyStrategy(Enum):
|
||||
"""Strategy for sharing dependency directories across worktrees.
|
||||
|
||||
SYMLINK is fast but unsafe for certain ecosystems. Notably, Python venv
|
||||
breaks when symlinked because CPython's pyvenv.cfg discovery walks the
|
||||
real directory hierarchy without resolving symlinks first
|
||||
(CPython bug #106045). This means a symlinked venv resolves its home
|
||||
path relative to the symlink target's parent, not the worktree, causing
|
||||
import failures and broken interpreters.
|
||||
"""
|
||||
|
||||
SYMLINK = "symlink" # Create a symlink to the source (fast, works for node_modules)
|
||||
RECREATE = "recreate" # Re-run the package manager to create a fresh copy
|
||||
COPY = "copy" # Deep-copy the directory (slow but always correct)
|
||||
SKIP = "skip" # Do nothing; let the agent handle it
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyShareConfig:
|
||||
"""Configuration for how a specific dependency type should be shared."""
|
||||
|
||||
dep_type: str # e.g. "node_modules", "venv", ".venv"
|
||||
strategy: DependencyStrategy
|
||||
source_rel_path: str # Relative path from project root, e.g. "node_modules"
|
||||
requirements_file: str | None = None # e.g. "requirements.txt", "pyproject.toml"
|
||||
package_manager: str | None = None # e.g. "npm", "uv", "pip"
|
||||
|
||||
@@ -14,7 +14,6 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
from core.platform import is_windows
|
||||
from merge import FileTimelineTracker
|
||||
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
from ui import (
|
||||
@@ -29,9 +28,8 @@ from ui import (
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
from .dependency_strategy import get_dependency_configs
|
||||
from .git_utils import has_uncommitted_changes
|
||||
from .models import DependencyShareConfig, DependencyStrategy, WorkspaceMode
|
||||
from .models import WorkspaceMode
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
@@ -191,37 +189,11 @@ def symlink_node_modules_to_worktree(
|
||||
"""
|
||||
Symlink node_modules directories from project root to worktree.
|
||||
|
||||
.. deprecated::
|
||||
Use :func:`setup_worktree_dependencies` instead, which handles all
|
||||
dependency types (node_modules, venvs, vendor dirs, etc.) via
|
||||
strategy-based dispatch.
|
||||
This ensures the worktree has access to dependencies for TypeScript checks
|
||||
and other tooling without requiring a separate npm install.
|
||||
|
||||
This is a thin backward-compatibility wrapper that delegates to
|
||||
``setup_worktree_dependencies()`` with no project index (fallback mode).
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of symlinked paths (relative to worktree)
|
||||
"""
|
||||
results = setup_worktree_dependencies(
|
||||
project_dir, worktree_path, project_index=None
|
||||
)
|
||||
# Flatten all processed paths for backward-compatible return value
|
||||
return [path for paths in results.values() for path in paths]
|
||||
|
||||
|
||||
def symlink_claude_config_to_worktree(
|
||||
project_dir: Path, worktree_path: Path
|
||||
) -> list[str]:
|
||||
"""
|
||||
Symlink .claude/ directory from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to Claude Code configuration
|
||||
(settings, CLAUDE.md, MCP servers, etc.) so that terminals opened
|
||||
in the worktree behave identically to the project root.
|
||||
Works with npm workspace hoisting where dependencies are hoisted to root
|
||||
and workspace-specific dependencies remain in nested node_modules.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
@@ -232,52 +204,81 @@ def symlink_claude_config_to_worktree(
|
||||
"""
|
||||
symlinked = []
|
||||
|
||||
source_path = project_dir / ".claude"
|
||||
target_path = worktree_path / ".claude"
|
||||
# Node modules locations to symlink for TypeScript and tooling support.
|
||||
# These are the standard locations for this monorepo structure.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Hardcoded paths are intentional for simplicity and reliability
|
||||
# - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
# and potential failure points without significant benefit
|
||||
# - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
# in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
#
|
||||
# To add new workspace locations:
|
||||
# 1. Add (source_rel, target_rel) tuple below
|
||||
# 2. Update the parallel TypeScript implementation in
|
||||
# apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts
|
||||
# 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
node_modules_locations = [
|
||||
("node_modules", "node_modules"),
|
||||
("apps/frontend/node_modules", "apps/frontend/node_modules"),
|
||||
]
|
||||
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - source does not exist")
|
||||
return symlinked
|
||||
for source_rel, target_rel in node_modules_locations:
|
||||
source_path = project_dir / source_rel
|
||||
target_path = worktree_path / target_rel
|
||||
|
||||
# Skip if target already exists
|
||||
if target_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - target already exists")
|
||||
return symlinked
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping {source_rel} - source does not exist")
|
||||
continue
|
||||
|
||||
# Also skip if target is a symlink (even if broken)
|
||||
if target_path.is_symlink():
|
||||
debug(MODULE, "Skipping .claude/ - symlink already exists (possibly broken)")
|
||||
return symlinked
|
||||
# Skip if target already exists (don't overwrite existing node_modules)
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping {target_rel} - target already exists")
|
||||
continue
|
||||
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks)
|
||||
if target_path.is_symlink():
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping {target_rel} - symlink already exists (possibly broken)",
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
# Junctions require absolute paths
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(target_rel)
|
||||
debug(MODULE, f"Symlinked {target_rel} -> {source_path}")
|
||||
except OSError as e:
|
||||
# Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem)
|
||||
# Log warning but don't fail - worktree is still usable, just without
|
||||
# TypeScript checking
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.",
|
||||
)
|
||||
# Warn user - pre-commit hooks may fail without dependencies
|
||||
print_status(
|
||||
f"Warning: Could not link {target_rel} - TypeScript checks may fail",
|
||||
"warning",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(".claude")
|
||||
debug(MODULE, f"Symlinked .claude/ -> {source_path}")
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink .claude/: {e}. Claude Code features may not work in worktree terminals.",
|
||||
)
|
||||
print_status(
|
||||
"Warning: Could not link .claude/ - Claude Code features may not work in terminals",
|
||||
"warning",
|
||||
)
|
||||
|
||||
return symlinked
|
||||
|
||||
@@ -373,33 +374,13 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Set up dependencies in worktree using strategy-based dispatch
|
||||
# Load project index if available for ecosystem-aware dependency handling
|
||||
project_index = None
|
||||
project_index_path = project_dir / ".auto-claude" / "project_index.json"
|
||||
if project_index_path.is_file():
|
||||
try:
|
||||
with open(project_index_path, encoding="utf-8") as f:
|
||||
project_index = json.load(f)
|
||||
debug(MODULE, "Loaded project_index.json for dependency setup")
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
debug_warning(MODULE, f"Could not load project_index.json: {e}")
|
||||
|
||||
dep_results = setup_worktree_dependencies(
|
||||
project_dir, worktree_info.path, project_index=project_index
|
||||
)
|
||||
for strategy_name, paths in dep_results.items():
|
||||
if paths:
|
||||
print_status(
|
||||
f"Dependencies ({strategy_name}): {', '.join(paths)}", "success"
|
||||
)
|
||||
|
||||
# Symlink .claude/ config to worktree for Claude Code features (settings, commands, etc.)
|
||||
symlinked_claude = symlink_claude_config_to_worktree(
|
||||
# Symlink node_modules to worktree for TypeScript and tooling support
|
||||
# This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
symlinked_modules = symlink_node_modules_to_worktree(
|
||||
project_dir, worktree_info.path
|
||||
)
|
||||
if symlinked_claude:
|
||||
print_status(f"Claude config linked: {', '.join(symlinked_claude)}", "success")
|
||||
if symlinked_modules:
|
||||
print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success")
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
@@ -593,299 +574,6 @@ def initialize_timeline_tracking(
|
||||
print(muted(f" Note: Timeline tracking could not be initialized: {e}"))
|
||||
|
||||
|
||||
def setup_worktree_dependencies(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
project_index: dict | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Set up dependencies in a worktree using strategy-based dispatch.
|
||||
|
||||
Reads dependency configs from the project index and applies the correct
|
||||
strategy for each: symlink, recreate, copy, or skip.
|
||||
|
||||
All operations are non-blocking — failures produce warnings but do not
|
||||
prevent worktree creation.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
project_index: Parsed project_index.json dict, or None
|
||||
|
||||
Returns:
|
||||
Dict mapping strategy names to lists of paths that were processed.
|
||||
"""
|
||||
configs = get_dependency_configs(project_index, project_dir=project_dir)
|
||||
results: dict[str, list[str]] = {}
|
||||
|
||||
for config in configs:
|
||||
strategy_name = config.strategy.value
|
||||
if strategy_name not in results:
|
||||
results[strategy_name] = []
|
||||
|
||||
try:
|
||||
performed = True
|
||||
if config.strategy == DependencyStrategy.SYMLINK:
|
||||
performed = _apply_symlink_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.RECREATE:
|
||||
performed = _apply_recreate_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.COPY:
|
||||
performed = _apply_copy_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.SKIP:
|
||||
_apply_skip_strategy(config)
|
||||
# Don't record skipped entries — only report actual work
|
||||
continue
|
||||
if performed:
|
||||
results[strategy_name].append(config.source_rel_path)
|
||||
except Exception as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Failed to apply {strategy_name} strategy for "
|
||||
f"{config.source_rel_path}: {e}",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _apply_symlink_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a symlink (or Windows junction) from worktree to project source.
|
||||
|
||||
Returns True if a symlink was created, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists() or target_path.is_symlink():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
# Windows: use directory junctions (no admin rights required).
|
||||
# os.symlink creates a directory symlink that needs admin/DevMode,
|
||||
# so we use mklink /J which creates a junction without privileges.
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# macOS/Linux: relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
debug(MODULE, f"Symlinked {config.source_rel_path} -> {source_path}")
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Symlink creation timed out for {config.source_rel_path}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Symlink creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {config.source_rel_path}: {e}",
|
||||
)
|
||||
print_status(f"Warning: Could not link {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_recreate_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a fresh virtual environment in the worktree and install deps.
|
||||
|
||||
Returns True if the venv was successfully created, False if skipped or failed.
|
||||
"""
|
||||
venv_path = worktree_path / config.source_rel_path
|
||||
|
||||
if venv_path.exists():
|
||||
debug(MODULE, f"Skipping recreate {config.source_rel_path} - already exists")
|
||||
return False
|
||||
|
||||
# Detect Python executable from the source venv or fall back to sys.executable
|
||||
source_venv = project_dir / config.source_rel_path
|
||||
python_exec = sys.executable
|
||||
|
||||
if source_venv.exists():
|
||||
# Try to use the same Python version as the source venv
|
||||
for candidate in ("bin/python", "Scripts/python.exe"):
|
||||
candidate_path = source_venv / candidate
|
||||
if candidate_path.exists():
|
||||
python_exec = str(candidate_path.resolve())
|
||||
break
|
||||
|
||||
# Create the venv
|
||||
try:
|
||||
debug(MODULE, f"Creating venv at {venv_path}")
|
||||
result = subprocess.run(
|
||||
[python_exec, "-m", "venv", str(venv_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
debug_warning(MODULE, f"venv creation failed: {result.stderr}")
|
||||
print_status(
|
||||
f"Warning: Could not create venv at {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(MODULE, f"venv creation timed out for {config.source_rel_path}")
|
||||
print_status(
|
||||
f"Warning: venv creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
# Install from requirements file if specified
|
||||
req_file = config.requirements_file
|
||||
if req_file:
|
||||
req_path = project_dir / req_file
|
||||
if req_path.is_file():
|
||||
# Determine pip executable inside the new venv
|
||||
if is_windows():
|
||||
pip_exec = str(venv_path / "Scripts" / "pip.exe")
|
||||
else:
|
||||
pip_exec = str(venv_path / "bin" / "pip")
|
||||
|
||||
# Build install command based on file type
|
||||
req_basename = Path(req_file).name
|
||||
if req_basename == "pyproject.toml":
|
||||
# pyproject.toml: snapshot-install from the worktree copy.
|
||||
# Non-editable so the venv doesn't symlink back to the source.
|
||||
worktree_req = worktree_path / req_file
|
||||
install_dir = str(
|
||||
worktree_req.parent if worktree_req.is_file() else req_path.parent
|
||||
)
|
||||
install_cmd = [pip_exec, "install", install_dir]
|
||||
elif req_basename == "Pipfile":
|
||||
# Pipfile: not directly installable via pip, skip
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping Pipfile-based install for {req_file} "
|
||||
"(use pipenv in the worktree)",
|
||||
)
|
||||
install_cmd = None
|
||||
else:
|
||||
# requirements.txt or similar: pip install -r
|
||||
install_cmd = [pip_exec, "install", "-r", str(req_path)]
|
||||
|
||||
if install_cmd:
|
||||
try:
|
||||
debug(MODULE, f"Installing deps from {req_file}")
|
||||
pip_result = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if pip_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install failed (exit {pip_result.returncode}): "
|
||||
f"{pip_result.stderr}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install failed for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install timed out for {req_file}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install timed out for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(MODULE, f"pip install failed: {e}")
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
debug(MODULE, f"Recreated venv at {config.source_rel_path}")
|
||||
return True
|
||||
|
||||
|
||||
def _apply_copy_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Deep-copy a dependency directory from project to worktree.
|
||||
|
||||
Returns True if the copy was performed, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if source_path.is_file():
|
||||
shutil.copy2(source_path, target_path)
|
||||
else:
|
||||
shutil.copytree(source_path, target_path)
|
||||
debug(MODULE, f"Copied {config.source_rel_path} to worktree")
|
||||
return True
|
||||
except (OSError, shutil.Error) as e:
|
||||
debug_warning(MODULE, f"Could not copy {config.source_rel_path}: {e}")
|
||||
print_status(f"Warning: Could not copy {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_skip_strategy(config: DependencyShareConfig) -> None:
|
||||
"""Skip — nothing to do for this dependency type."""
|
||||
debug(
|
||||
MODULE, f"Skipping {config.dep_type} ({config.source_rel_path}) - skip strategy"
|
||||
)
|
||||
|
||||
|
||||
# Export private functions for backward compatibility
|
||||
_ensure_timeline_hook_installed = ensure_timeline_hook_installed
|
||||
_initialize_timeline_tracking = initialize_timeline_tracking
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,10 +0,0 @@
|
||||
[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"')
|
||||
@@ -1,856 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,805 +0,0 @@
|
||||
#!/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
@@ -1,638 +0,0 @@
|
||||
#!/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,293 +0,0 @@
|
||||
#!/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
@@ -430,7 +430,6 @@ class WorktreeManager:
|
||||
if os.path.samefile(resolved_path, current_path):
|
||||
return line[len("branch refs/heads/") :]
|
||||
except OSError:
|
||||
# File system comparison errors are handled by fallback below
|
||||
pass
|
||||
# Fallback to normalized case comparison
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
@@ -511,7 +510,6 @@ class WorktreeManager:
|
||||
if os.path.samefile(resolved_path, registered_path):
|
||||
return True
|
||||
except OSError:
|
||||
# File system errors handled by fallback comparison below
|
||||
pass
|
||||
# Fallback to normalized case comparison for non-existent paths
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
|
||||
@@ -29,7 +29,6 @@ class IdeationConfigManager:
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
"""Initialize configuration manager.
|
||||
|
||||
@@ -65,7 +64,6 @@ class IdeationConfigManager:
|
||||
self.model,
|
||||
self.thinking_level,
|
||||
self.max_ideas_per_type,
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
self.analyzer = ProjectAnalyzer(
|
||||
self.project_dir,
|
||||
|
||||
@@ -17,12 +17,7 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from client import create_client
|
||||
from phase_config import (
|
||||
get_model_betas,
|
||||
get_thinking_budget,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
from phase_config import get_thinking_budget, resolve_model_id
|
||||
from ui import print_status
|
||||
|
||||
# Ideation types
|
||||
@@ -64,7 +59,6 @@ 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)
|
||||
@@ -72,7 +66,6 @@ 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(
|
||||
@@ -100,19 +93,12 @@ 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,
|
||||
resolved_model,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
betas=betas,
|
||||
fast_mode=self.fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -202,19 +188,12 @@ 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,
|
||||
resolved_model,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
betas=betas,
|
||||
fast_mode=self.fast_mode,
|
||||
**thinking_kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -46,7 +46,6 @@ class IdeationOrchestrator:
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
"""Initialize the ideation orchestrator.
|
||||
|
||||
@@ -61,7 +60,6 @@ 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(
|
||||
@@ -75,7 +73,6 @@ 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"
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
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"
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
openrouter_llm_model = os.environ.get(
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-sonnet-4"
|
||||
@@ -624,10 +624,7 @@ def get_graphiti_status() -> dict:
|
||||
|
||||
# CRITICAL FIX: Actually verify packages are importable before reporting available
|
||||
# Don't just check config.is_valid() - actually try to import the module
|
||||
# Note: This branch is currently unreachable because is_valid() returns True
|
||||
# whenever enabled is True. Kept for defensive purposes in case is_valid()
|
||||
# logic changes in the future.
|
||||
if not config.is_valid(): # pragma: no cover
|
||||
if not config.is_valid():
|
||||
status["reason"] = errors[0] if errors else "Configuration invalid"
|
||||
return status
|
||||
|
||||
@@ -638,7 +635,7 @@ def get_graphiti_status() -> dict:
|
||||
from graphiti_core.driver.falkordb_driver import FalkorDriver # noqa: F401
|
||||
|
||||
# If we got here, packages are importable
|
||||
status["available"] = True # pragma: no cover
|
||||
status["available"] = True
|
||||
except ImportError as e:
|
||||
status["available"] = False
|
||||
status["reason"] = f"Graphiti packages not installed: {e}"
|
||||
|
||||
@@ -12,8 +12,12 @@ The refactored code is now organized as:
|
||||
- graphiti/search.py - Semantic search logic
|
||||
- graphiti/schema.py - Graph schema definitions
|
||||
|
||||
Import from this module:
|
||||
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, GroupIdMode
|
||||
This facade ensures existing imports continue to work:
|
||||
from graphiti_memory import GraphitiMemory, is_graphiti_enabled
|
||||
|
||||
New code should prefer importing from the graphiti package:
|
||||
from graphiti import GraphitiMemory
|
||||
from graphiti.schema import GroupIdMode
|
||||
|
||||
For detailed documentation on the memory system architecture and usage,
|
||||
see graphiti/graphiti.py.
|
||||
@@ -72,8 +76,6 @@ async def test_graphiti_connection() -> tuple[bool, str]:
|
||||
"""
|
||||
Test if LadybugDB is available and Graphiti can connect.
|
||||
|
||||
Uses the embedded LadybugDB via the patched KuzuDriver (no remote connection).
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
@@ -89,48 +91,43 @@ async def test_graphiti_connection() -> tuple[bool, str]:
|
||||
|
||||
try:
|
||||
from graphiti_core import Graphiti
|
||||
from graphiti_core.driver.falkordb_driver import FalkorDriver
|
||||
from graphiti_providers import ProviderError, create_embedder, create_llm_client
|
||||
|
||||
# Import the patched driver creator (handles LadybugDB monkeypatch internally)
|
||||
from integrations.graphiti.queries_pkg.client import _apply_ladybug_monkeypatch
|
||||
from integrations.graphiti.queries_pkg.kuzu_driver_patched import (
|
||||
create_patched_kuzu_driver,
|
||||
)
|
||||
|
||||
# Create providers
|
||||
try:
|
||||
llm_client = create_llm_client(config) # pragma: no cover
|
||||
embedder = create_embedder(config) # pragma: no cover
|
||||
llm_client = create_llm_client(config)
|
||||
embedder = create_embedder(config)
|
||||
except ProviderError as e:
|
||||
return False, f"Provider error: {e}"
|
||||
|
||||
# Apply LadybugDB monkeypatch for embedded database
|
||||
if not _apply_ladybug_monkeypatch(): # pragma: no cover
|
||||
return False, "LadybugDB not installed (requires Python 3.12+)"
|
||||
# Try to connect
|
||||
driver = FalkorDriver(
|
||||
host=config.falkordb_host,
|
||||
port=config.falkordb_port,
|
||||
password=config.falkordb_password or None,
|
||||
database=config.database,
|
||||
)
|
||||
|
||||
# Create embedded database driver
|
||||
db_path = config.get_db_path()
|
||||
driver = create_patched_kuzu_driver(db=str(db_path)) # pragma: no cover
|
||||
|
||||
graphiti = Graphiti( # pragma: no cover
|
||||
graphiti = Graphiti(
|
||||
graph_driver=driver,
|
||||
llm_client=llm_client,
|
||||
embedder=embedder,
|
||||
)
|
||||
|
||||
# Try a simple operation
|
||||
await graphiti.build_indices_and_constraints() # pragma: no cover
|
||||
await graphiti.close() # pragma: no cover
|
||||
await graphiti.build_indices_and_constraints()
|
||||
await graphiti.close()
|
||||
|
||||
return True, ( # pragma: no cover
|
||||
f"Connected to LadybugDB at {db_path} "
|
||||
return True, (
|
||||
f"Connected to LadybugDB at {config.falkordb_host}:{config.falkordb_port} "
|
||||
f"(providers: {config.get_provider_summary()})"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
return False, f"Graphiti packages not installed: {e}"
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
except Exception as e:
|
||||
return False, f"Connection failed: {e}"
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ async def get_graph_hints(
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
# Determine project directory from project_id or use current dir
|
||||
project_dir = Path.cwd()
|
||||
|
||||
@@ -5,9 +5,7 @@ Handles database connection, initialization, and lifecycle management.
|
||||
Uses LadybugDB as the embedded graph database (no Docker required, Python 3.12+).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -16,27 +14,6 @@ from graphiti_config import GraphitiConfig, GraphitiState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Retry configuration for LadybugDB lock contention
|
||||
MAX_LOCK_RETRIES = 5
|
||||
INITIAL_BACKOFF_SECONDS = 0.5
|
||||
MAX_BACKOFF_SECONDS = 8.0
|
||||
JITTER_PERCENT = 0.2
|
||||
|
||||
|
||||
def _is_lock_error(error: Exception) -> bool:
|
||||
"""Check if an error indicates database lock contention."""
|
||||
error_msg = str(error).lower()
|
||||
return "could not set lock" in error_msg or (
|
||||
"lock" in error_msg and ("file" in error_msg or "database" in error_msg)
|
||||
)
|
||||
|
||||
|
||||
def _backoff_with_jitter(attempt: int) -> float:
|
||||
"""Calculate exponential backoff with jitter for retry delays."""
|
||||
backoff = min(INITIAL_BACKOFF_SECONDS * (2**attempt), MAX_BACKOFF_SECONDS)
|
||||
jitter = backoff * JITTER_PERCENT * (2 * random.random() - 1)
|
||||
return max(0.01, backoff + jitter)
|
||||
|
||||
|
||||
def _apply_ladybug_monkeypatch() -> bool:
|
||||
"""
|
||||
@@ -219,36 +196,32 @@ class GraphitiClient:
|
||||
)
|
||||
|
||||
db_path = self.config.get_db_path()
|
||||
|
||||
# Retry with exponential backoff for lock contention
|
||||
for attempt in range(MAX_LOCK_RETRIES + 1):
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"LadybugDB lock acquired after {attempt} retries"
|
||||
)
|
||||
break # Success
|
||||
except Exception as e:
|
||||
if _is_lock_error(e) and attempt < MAX_LOCK_RETRIES:
|
||||
wait_time = _backoff_with_jitter(attempt)
|
||||
logger.debug(
|
||||
f"LadybugDB lock contention (attempt {attempt + 1}/{MAX_LOCK_RETRIES}), retrying in {wait_time:.2f}s"
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Unexpected error initializing LadybugDB driver at {db_path}: {e}"
|
||||
)
|
||||
capture_exception(
|
||||
e,
|
||||
error_type=type(e).__name__,
|
||||
db_path=str(db_path),
|
||||
llm_provider=self.config.llm_provider,
|
||||
embedder_provider=self.config.embedder_provider,
|
||||
)
|
||||
return False
|
||||
logger.info(f"Initialized LadybugDB driver (patched) at: {db_path}")
|
||||
except ImportError as e:
|
||||
logger.warning(f"KuzuDriver not available: {e}")
|
||||
|
||||
@@ -15,10 +15,7 @@ from typing import Any
|
||||
# Import kuzu (might be real_ladybug via monkeypatch)
|
||||
try:
|
||||
import kuzu
|
||||
except ImportError: # pragma: no cover
|
||||
# Fallback to real_ladybug if kuzu is not available.
|
||||
# This import-time fallback is hard to test in normal unit tests
|
||||
# since the module is imported once before tests can mock anything.
|
||||
except ImportError:
|
||||
import real_ladybug as kuzu # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,8 +67,7 @@ class GraphitiSearch:
|
||||
Args:
|
||||
query: Search query
|
||||
num_results: Maximum number of results to return
|
||||
include_project_context: If True and in SPEC mode, also search project-wide
|
||||
min_score: Minimum relevance score threshold (0.0 to 1.0)
|
||||
include_project_context: If True and in PROJECT mode, search project-wide
|
||||
|
||||
Returns:
|
||||
List of relevant context items with content, score, and type
|
||||
@@ -102,14 +101,10 @@ class GraphitiSearch:
|
||||
or str(result)
|
||||
)
|
||||
|
||||
# Normalize score to float, treating None as 0.0
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
|
||||
context_items.append(
|
||||
{
|
||||
"content": content,
|
||||
"score": score,
|
||||
"score": getattr(result, "score", 0.0),
|
||||
"type": getattr(result, "type", "unknown"),
|
||||
}
|
||||
)
|
||||
@@ -117,9 +112,7 @@ class GraphitiSearch:
|
||||
# Filter by minimum score if specified
|
||||
if min_score > 0:
|
||||
context_items = [
|
||||
item
|
||||
for item in context_items
|
||||
if (item.get("score", 0.0)) >= min_score
|
||||
item for item in context_items if item.get("score", 0) >= min_score
|
||||
]
|
||||
|
||||
logger.info(
|
||||
@@ -232,14 +225,12 @@ class GraphitiSearch:
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get("type") == EPISODE_TYPE_TASK_OUTCOME:
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
outcomes.append(
|
||||
{
|
||||
"task_id": data.get("task_id"),
|
||||
"success": data.get("success"),
|
||||
"outcome": data.get("outcome"),
|
||||
"score": score,
|
||||
"score": getattr(result, "score", 0.0),
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
@@ -293,8 +284,7 @@ class GraphitiSearch:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
score = getattr(result, "score", 0.0)
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
@@ -330,8 +320,7 @@ class GraphitiSearch:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
raw_score = getattr(result, "score", None)
|
||||
score = raw_score if raw_score is not None else 0.0
|
||||
score = getattr(result, "score", 0.0)
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
|
||||
+20
-16
@@ -22,13 +22,13 @@ Usage:
|
||||
|
||||
# Run the test:
|
||||
cd auto-claude
|
||||
python integrations/graphiti/run_graphiti_memory_test.py
|
||||
python integrations/graphiti/test_graphiti_memory.py
|
||||
|
||||
# Or run specific tests:
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test connection
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test save
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test search
|
||||
python integrations/graphiti/run_graphiti_memory_test.py --test ollama
|
||||
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
|
||||
@@ -36,15 +36,18 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
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 = Path(__file__).parent.parent.parent.parent / ".env"
|
||||
env_file = auto_claude_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
@@ -167,9 +170,7 @@ async def test_save_episode(db_path: str, database: str) -> tuple[str, str]:
|
||||
"embedder": config.embedder_provider,
|
||||
}
|
||||
|
||||
episode_name = (
|
||||
f"test_episode_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
|
||||
)
|
||||
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}")
|
||||
@@ -185,7 +186,7 @@ async def test_save_episode(db_path: str, database: str) -> tuple[str, str]:
|
||||
name=episode_name,
|
||||
episode_body=json.dumps(test_data),
|
||||
source=EpisodeType.text,
|
||||
source_description="Test episode from run_graphiti_memory_test.py",
|
||||
source_description="Test episode from test_graphiti_memory.py",
|
||||
reference_time=datetime.now(timezone.utc),
|
||||
group_id=group_id,
|
||||
)
|
||||
@@ -431,8 +432,11 @@ async def test_graphiti_memory_class(db_path: str, database: str) -> bool:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
|
||||
# Create temporary directories for testing
|
||||
test_spec_dir = Path(tempfile.mkdtemp(prefix="graphiti_test_spec_"))
|
||||
test_project_dir = Path(tempfile.mkdtemp(prefix="graphiti_test_project_"))
|
||||
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}")
|
||||
@@ -700,14 +704,14 @@ async def main():
|
||||
print()
|
||||
print(" Quick commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/run_graphiti_memory_test.py")
|
||||
print(" python integrations/graphiti/test_graphiti_memory.py")
|
||||
print()
|
||||
print(" # Test just Ollama embeddings:")
|
||||
print(" python integrations/graphiti/run_graphiti_memory_test.py --test ollama")
|
||||
print(" python integrations/graphiti/test_graphiti_memory.py --test ollama")
|
||||
print()
|
||||
print(" # Test with production database:")
|
||||
print(
|
||||
" python integrations/graphiti/run_graphiti_memory_test.py --database auto_claude_memory"
|
||||
" python integrations/graphiti/test_graphiti_memory.py --database auto_claude_memory"
|
||||
)
|
||||
print()
|
||||
|
||||
+13
-13
@@ -36,13 +36,13 @@ NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
|
||||
|
||||
Usage:
|
||||
cd apps/backend
|
||||
python integrations/graphiti/run_ollama_embedding_test.py
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py
|
||||
|
||||
# Run specific tests:
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test embeddings
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test create
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test retrieve
|
||||
python integrations/graphiti/run_ollama_embedding_test.py --test full-cycle
|
||||
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
|
||||
@@ -54,15 +54,15 @@ import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
backend_dir = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
# 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 = backend_dir / ".env"
|
||||
env_file = auto_claude_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
@@ -843,18 +843,18 @@ async def main():
|
||||
print()
|
||||
print(" Commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/run_ollama_embedding_test.py")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
|
||||
print()
|
||||
print(" # Run specific test:")
|
||||
print(
|
||||
" python integrations/graphiti/run_ollama_embedding_test.py --test embeddings"
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
|
||||
)
|
||||
print(
|
||||
" python integrations/graphiti/run_ollama_embedding_test.py --test full-cycle"
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
|
||||
)
|
||||
print()
|
||||
print(" # Keep database for inspection:")
|
||||
print(" python integrations/graphiti/run_ollama_embedding_test.py --keep-db")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
|
||||
print()
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/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()
|
||||
@@ -1 +0,0 @@
|
||||
"""Tests for Graphiti memory integration."""
|
||||
@@ -1,610 +0,0 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for graphiti integration tests.
|
||||
|
||||
This module provides shared fixtures for testing the memory system integration,
|
||||
including mocks for external dependencies, test configurations, and client fixtures.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the backend directory to sys.path to allow imports
|
||||
backend_dir = Path(__file__).parent.parent.parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""
|
||||
Exclude validator functions from test collection.
|
||||
|
||||
The validators.py module contains functions named test_llm_connection and
|
||||
test_embedder_connection which are not pytest tests but validator functions.
|
||||
"""
|
||||
# Filter out items that are from validators.py and are not in test classes
|
||||
filtered_items = []
|
||||
for item in items:
|
||||
# Get the full path of the test
|
||||
item_path = str(item.fspath) if hasattr(item, "fspath") else str(item.path)
|
||||
|
||||
# Skip the standalone test_llm_connection and test_embedder_connection
|
||||
# functions from validators.py (they're not pytest tests)
|
||||
if item.name in [
|
||||
"test_llm_connection",
|
||||
"test_embedder_connection",
|
||||
"test_ollama_connection",
|
||||
]:
|
||||
# Check if it's from validators.py
|
||||
if "validators.py" in item_path or "test_providers.py" in item_path:
|
||||
# Only skip if it's a standalone function (not in a TestClass)
|
||||
if not item.parent.name.startswith("Test"):
|
||||
continue
|
||||
|
||||
filtered_items.append(item)
|
||||
|
||||
items[:] = filtered_items
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# External Dependency Mocks
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_core():
|
||||
"""Mock graphiti_core.Graphiti and related classes.
|
||||
|
||||
Patches the graphiti_core library to prevent actual graph database connections
|
||||
during tests.
|
||||
|
||||
Yields:
|
||||
tuple: (mock_graphiti_class, mock_graphiti_instance)
|
||||
"""
|
||||
with patch(
|
||||
"integrations.graphiti.queries_pkg.graphiti.graphiti_core.Graphiti"
|
||||
) as mock_graphiti:
|
||||
# Configure the mock to return a mock instance
|
||||
mock_instance = MagicMock()
|
||||
mock_graphiti.return_value = mock_instance
|
||||
|
||||
# Mock common methods that might be called
|
||||
mock_instance.add_edges = AsyncMock()
|
||||
mock_instance.add_nodes = AsyncMock()
|
||||
mock_instance.search = AsyncMock(return_value=[])
|
||||
mock_instance.delete_graph = AsyncMock()
|
||||
mock_instance.close = AsyncMock()
|
||||
|
||||
yield mock_graphiti, mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_falkor_driver():
|
||||
"""Mock graphiti_core.driver.falkordb_driver.FalkorDriver.
|
||||
|
||||
Prevents actual FalkorDB connections during tests.
|
||||
|
||||
Yields:
|
||||
tuple: (mock_driver_class, mock_driver_instance)
|
||||
"""
|
||||
with patch(
|
||||
"integrations.graphiti.queries_pkg.graphiti.graphiti_core.driver.falkordb_driver.FalkorDriver"
|
||||
) as mock_driver:
|
||||
mock_instance = MagicMock()
|
||||
mock_driver.return_value = mock_instance
|
||||
|
||||
# Mock driver methods
|
||||
mock_instance.close = MagicMock()
|
||||
mock_instance.execute_query = MagicMock(return_value=[])
|
||||
|
||||
yield mock_driver, mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_providers():
|
||||
"""Mock graphiti_providers module.
|
||||
|
||||
Patches the graphiti_providers module to prevent actual LLM/embedder calls.
|
||||
|
||||
Yields:
|
||||
tuple: (mock_get_client, mock_client_instance)
|
||||
"""
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.providers.get_client"
|
||||
) as mock_get_client:
|
||||
mock_client = MagicMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
yield mock_get_client, mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ladybug_db():
|
||||
"""Mock real_ladybug and kuzu database connections.
|
||||
|
||||
Prevents actual database connections during tests.
|
||||
|
||||
Yields:
|
||||
dict: Dictionary with 'ladybug' and 'kuzu' keys, each containing
|
||||
(mock_class, mock_instance) tuples.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"integrations.graphiti.queries_pkg.client.real_ladybug.Ladybug"
|
||||
) as mock_ladybug,
|
||||
patch("integrations.graphiti.queries_pkg.client.kuzu.Connection") as mock_kuzu,
|
||||
):
|
||||
# Mock Ladybug instance
|
||||
ladybug_instance = MagicMock()
|
||||
mock_ladybug.return_value = ladybug_instance
|
||||
ladybug_instance.close = MagicMock()
|
||||
|
||||
# Mock Kuzu connection
|
||||
kuzu_instance = MagicMock()
|
||||
mock_kuzu.return_value = kuzu_instance
|
||||
kuzu_instance.close = MagicMock()
|
||||
|
||||
yield {
|
||||
"ladybug": (mock_ladybug, ladybug_instance),
|
||||
"kuzu": (mock_kuzu, kuzu_instance),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Return a GraphitiConfig with test values.
|
||||
|
||||
Provides a test configuration that doesn't require real environment variables
|
||||
or database connections.
|
||||
|
||||
Returns:
|
||||
GraphitiConfig: Configuration with test values.
|
||||
"""
|
||||
from integrations.graphiti.config import GraphitiConfig
|
||||
|
||||
config = GraphitiConfig(
|
||||
enabled=True,
|
||||
database="test_dataset",
|
||||
db_path="/tmp/test_graphiti.db",
|
||||
llm_provider="openai",
|
||||
openai_model="gpt-5-mini",
|
||||
embedder_provider="openai",
|
||||
openai_embedding_model="text-embedding-3-small",
|
||||
openai_api_key="sk-test-key-for-testing",
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_vars(tmp_path):
|
||||
"""Set test environment variables for Graphiti configuration.
|
||||
|
||||
Sets up a clean environment with test values for all Graphiti-related
|
||||
environment variables.
|
||||
|
||||
Yields:
|
||||
dict: Dictionary of environment variables that were set.
|
||||
"""
|
||||
test_db_path = str(tmp_path / "test_graphiti.db")
|
||||
|
||||
env_vars = {
|
||||
"GRAPHITI_ENABLED": "true",
|
||||
"GRAPHITI_LLM_PROVIDER": "openai",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER": "openai",
|
||||
"GRAPHITI_DATABASE": "test_dataset",
|
||||
"GRAPHITI_DB_PATH": test_db_path,
|
||||
"OPENAI_MODEL": "gpt-5-mini",
|
||||
"OPENAI_EMBEDDING_MODEL": "text-embedding-3-small",
|
||||
"OPENAI_API_KEY": "sk-test-key-for-testing",
|
||||
}
|
||||
|
||||
# Save original values
|
||||
original = {k: os.environ.get(k) for k in env_vars}
|
||||
|
||||
# Set test values
|
||||
for key, value in env_vars.items():
|
||||
os.environ[key] = value
|
||||
|
||||
yield env_vars
|
||||
|
||||
# Restore original values
|
||||
for key, original_value in original.items():
|
||||
if original_value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = original_value
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Client Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_client():
|
||||
"""Mock GraphitiClient with all necessary methods.
|
||||
|
||||
Provides a mock client that simulates the behavior of the GraphitiClient
|
||||
without requiring actual graph database connections.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked GraphitiClient with typical methods mocked.
|
||||
"""
|
||||
client = Mock()
|
||||
client.graphiti = Mock()
|
||||
|
||||
# Core client methods
|
||||
client.is_initialized = Mock(return_value=True)
|
||||
client.initialize = AsyncMock()
|
||||
client.get_session_id = Mock(return_value="test_session")
|
||||
client.get_user_id = Mock(return_value="test_user")
|
||||
client.get_project_id = Mock(return_value="test_project")
|
||||
|
||||
# Memory operations (async)
|
||||
client.add_episode = AsyncMock(return_value="episode_id_123")
|
||||
client.add_episodic_memories = AsyncMock(return_value=["mem_id_1", "mem_id_2"])
|
||||
client.add_abstract_memories = AsyncMock(return_value=["abstract_id_1"])
|
||||
client.search = AsyncMock(return_value=[])
|
||||
client.delete_graph = AsyncMock()
|
||||
|
||||
# Graphiti instance methods
|
||||
client.graphiti.search = AsyncMock(return_value=[])
|
||||
|
||||
# Configuration
|
||||
client.get_config = Mock(
|
||||
return_value=Mock(
|
||||
enabled=True, database="test_dataset", db_path="/tmp/test_graphiti.db"
|
||||
)
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_graphiti_instance():
|
||||
"""Mock the Graphiti instance from graphiti_core.
|
||||
|
||||
Provides a mock of the actual Graphiti core instance with all methods
|
||||
that might be called during operations.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked Graphiti instance with typical methods mocked.
|
||||
"""
|
||||
instance = MagicMock()
|
||||
|
||||
# Search methods (async)
|
||||
instance.search = AsyncMock(return_value=[])
|
||||
instance.search_by_abstract = AsyncMock(return_value=[])
|
||||
instance.search_by_vector = AsyncMock(return_value=[])
|
||||
|
||||
# Add methods (async)
|
||||
instance.add_episode = AsyncMock(return_value="episode_id")
|
||||
instance.add_edges = AsyncMock()
|
||||
instance.add_nodes = AsyncMock()
|
||||
|
||||
# Graph management
|
||||
instance.delete_graph = AsyncMock()
|
||||
instance.close = AsyncMock()
|
||||
instance.get_graph_summary = Mock(return_value={"nodes": 0, "edges": 0})
|
||||
|
||||
# Configuration
|
||||
instance.database = "test_dataset"
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Directory Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_spec_dir(tmp_path):
|
||||
"""Create a temporary directory for spec testing.
|
||||
|
||||
Provides a temporary directory with spec-like structure for testing
|
||||
spec-related functionality.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in tmp_path fixture.
|
||||
|
||||
Returns:
|
||||
Path: Path to the temporary spec directory.
|
||||
"""
|
||||
spec_dir = tmp_path / "spec_001_test"
|
||||
spec_dir.mkdir()
|
||||
|
||||
# Create common spec subdirectories
|
||||
(spec_dir / ".auto-claude").mkdir()
|
||||
(spec_dir / "context").mkdir()
|
||||
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_project_dir(tmp_path):
|
||||
"""Create a temporary directory for project testing.
|
||||
|
||||
Provides a temporary directory with project-like structure for testing
|
||||
project-related functionality.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in tmp_path fixture.
|
||||
|
||||
Returns:
|
||||
Path: Path to the temporary project directory.
|
||||
"""
|
||||
project_dir = tmp_path / "test_project"
|
||||
project_dir.mkdir()
|
||||
|
||||
# Create common project subdirectories
|
||||
(project_dir / "src").mkdir()
|
||||
(project_dir / "tests").mkdir()
|
||||
(project_dir / ".auto-claude").mkdir()
|
||||
|
||||
return project_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path):
|
||||
"""Create a temporary path for test database.
|
||||
|
||||
Provides a temporary file path that can be used for database testing
|
||||
without affecting real databases.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in tmp_path fixture.
|
||||
|
||||
Returns:
|
||||
str: Path to temporary database file.
|
||||
"""
|
||||
db_path = str(tmp_path / "test_graphiti.db")
|
||||
return db_path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_client():
|
||||
"""Mocked LLM client for testing.
|
||||
|
||||
Provides a mock client that simulates LLM responses without making
|
||||
actual API calls.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked LLM client.
|
||||
"""
|
||||
client = Mock()
|
||||
|
||||
# Message methods
|
||||
client.messages = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.id = "msg_test_123"
|
||||
mock_response.content = []
|
||||
mock_response.model = "claude-3-5-sonnet-20241022"
|
||||
mock_response.role = "assistant"
|
||||
client.messages.create = Mock(return_value=mock_response)
|
||||
|
||||
# Streaming support
|
||||
client.messages.stream = Mock(return_value=iter([]))
|
||||
|
||||
# Token counting
|
||||
client.count_tokens = Mock(return_value=100)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedder():
|
||||
"""Mocked embedder with get_embedding() method.
|
||||
|
||||
Provides a mock embedder that returns fake embeddings without making
|
||||
actual API calls. Uses deterministic values for reproducibility.
|
||||
|
||||
Returns:
|
||||
tuple: (mock_embedder, test_embedding_list)
|
||||
"""
|
||||
embedder = Mock()
|
||||
|
||||
# Return a deterministic embedding vector (1536 dimensions is common for OpenAI)
|
||||
# Using 0.1 for all values makes tests reproducible
|
||||
test_embedding = [0.1] * 1536
|
||||
|
||||
embedder.get_embedding = Mock(return_value=test_embedding)
|
||||
embedder.get_embeddings = Mock(return_value=[test_embedding])
|
||||
|
||||
return embedder, test_embedding
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# State Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state():
|
||||
"""GraphitiState with test values.
|
||||
|
||||
Provides a mock state object with typical values for testing state-related
|
||||
functionality.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked GraphitiState with test values.
|
||||
"""
|
||||
from integrations.graphiti.config import GraphitiState
|
||||
|
||||
state = GraphitiState(
|
||||
initialized=True,
|
||||
database="test_dataset",
|
||||
indices_built=True,
|
||||
llm_provider="openai",
|
||||
embedder_provider="openai",
|
||||
)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_empty_state():
|
||||
"""Empty GraphitiState.
|
||||
|
||||
Provides a mock state object with default/uninitialized values for testing
|
||||
initialization logic.
|
||||
|
||||
Returns:
|
||||
Mock: Mocked GraphitiState with empty/default values.
|
||||
"""
|
||||
from integrations.graphiti.config import GraphitiState
|
||||
|
||||
state = GraphitiState()
|
||||
|
||||
return state
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Data Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_episode_data():
|
||||
"""Sample episode data for testing.
|
||||
|
||||
Provides realistic episode data structure for testing memory operations.
|
||||
|
||||
Returns:
|
||||
dict: Sample episode data.
|
||||
"""
|
||||
return {
|
||||
"episode_id": "episode_123",
|
||||
"content": "Test episode content about a feature implementation",
|
||||
"metadata": {
|
||||
"task_id": "task_001",
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"type": "implementation",
|
||||
},
|
||||
"session_id": "test_session",
|
||||
"user_id": "test_user",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_memory_nodes():
|
||||
"""Sample memory nodes for testing.
|
||||
|
||||
Provides realistic node data for testing graph operations.
|
||||
|
||||
Returns:
|
||||
list: List of sample memory node dictionaries.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"uuid": "node_1",
|
||||
"name": "Feature Implementation",
|
||||
"label": "CONCEPT",
|
||||
"summary": "Implementation of new feature",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"uuid": "node_2",
|
||||
"name": "Bug Fix",
|
||||
"label": "CONCEPT",
|
||||
"summary": "Fixed critical bug",
|
||||
"created_at": "2024-01-02T00:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_search_results():
|
||||
"""Sample search results for testing.
|
||||
|
||||
Provides realistic search result data for testing search operations.
|
||||
|
||||
Returns:
|
||||
list: List of sample search result dictionaries.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"uuid": "result_1",
|
||||
"name": "Search Result 1",
|
||||
"summary": "First search result",
|
||||
"score": 0.95,
|
||||
},
|
||||
{
|
||||
"uuid": "result_2",
|
||||
"name": "Search Result 2",
|
||||
"summary": "Second search result",
|
||||
"score": 0.87,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env():
|
||||
"""Fixture to ensure clean environment for each test.
|
||||
|
||||
Removes all Graphiti-related environment variables before the test
|
||||
and restores them afterward.
|
||||
|
||||
Yields:
|
||||
dict: Dictionary of original environment values.
|
||||
"""
|
||||
# Store original env vars
|
||||
env_keys = [
|
||||
"GRAPHITI_ENABLED",
|
||||
"GRAPHITI_LLM_PROVIDER",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER",
|
||||
"GRAPHITI_DATABASE",
|
||||
"GRAPHITI_DB_PATH",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_MODEL",
|
||||
"OPENAI_EMBEDDING_MODEL",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GRAPHITI_ANTHROPIC_MODEL",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_LLM_DEPLOYMENT",
|
||||
"AZURE_OPENAI_EMBEDDING_DEPLOYMENT",
|
||||
"VOYAGE_API_KEY",
|
||||
"VOYAGE_EMBEDDING_MODEL",
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_LLM_MODEL",
|
||||
"GOOGLE_EMBEDDING_MODEL",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENROUTER_BASE_URL",
|
||||
"OPENROUTER_LLM_MODEL",
|
||||
"OPENROUTER_EMBEDDING_MODEL",
|
||||
"OLLAMA_BASE_URL",
|
||||
"OLLAMA_LLM_MODEL",
|
||||
"OLLAMA_EMBEDDING_MODEL",
|
||||
"OLLAMA_EMBEDDING_DIM",
|
||||
]
|
||||
|
||||
original = {}
|
||||
for key in env_keys:
|
||||
original[key] = os.environ.get(key)
|
||||
if key in os.environ:
|
||||
os.environ.pop(key)
|
||||
|
||||
yield original
|
||||
|
||||
# Restore original values
|
||||
for key, value in original.items():
|
||||
if value is not None:
|
||||
os.environ[key] = value
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
||||
"""
|
||||
Tests for integrations.graphiti.providers_pkg.cross_encoder module.
|
||||
|
||||
Tests cover:
|
||||
1. create_cross_encoder():
|
||||
- Returns None for non-Ollama providers
|
||||
- Returns None when llm_client is None
|
||||
- Returns None on ImportError (graphiti_core not available)
|
||||
- Returns None on Exception during creation
|
||||
- Creates correct base_url for Ollama
|
||||
- Creates LLMConfig with correct parameters
|
||||
"""
|
||||
|
||||
import builtins
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# Test Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.llm_provider = "ollama"
|
||||
config.ollama_base_url = "http://localhost:11434"
|
||||
config.ollama_llm_model = "llama3.2"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_client():
|
||||
"""Mock LLM client."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graphiti_core_mocks():
|
||||
"""Mock graphiti_core modules and capture LLMConfig calls."""
|
||||
captured_config = {}
|
||||
|
||||
def capture_llm_config(**kwargs):
|
||||
captured_config.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.cross_encoder": MagicMock(),
|
||||
"graphiti_core.cross_encoder.openai_reranker_client": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.cross_encoder.openai_reranker_client import (
|
||||
OpenAIRerankerClient,
|
||||
)
|
||||
from graphiti_core.llm_client.config import LLMConfig
|
||||
|
||||
LLMConfig.side_effect = capture_llm_config
|
||||
OpenAIRerankerClient.return_value = MagicMock()
|
||||
|
||||
yield captured_config
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test create_cross_encoder()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateCrossEncoder:
|
||||
"""Tests for create_cross_encoder() function."""
|
||||
|
||||
def test_returns_none_for_non_ollama_provider(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None for non-Ollama providers."""
|
||||
mock_config.llm_provider = "openai"
|
||||
|
||||
import integrations.graphiti.providers_pkg.cross_encoder as ce_module
|
||||
|
||||
# The function returns None for non-ollama providers
|
||||
result = ce_module.create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_anthropic_provider(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None for Anthropic provider."""
|
||||
mock_config.llm_provider = "anthropic"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_google_provider(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None for Google provider."""
|
||||
mock_config.llm_provider = "google"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_llm_client_is_none(self, mock_config):
|
||||
"""Test create_cross_encoder returns None when llm_client is None."""
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
result = create_cross_encoder(mock_config, llm_client=None)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_base_url_without_v1_gets_suffix_added(
|
||||
self, mock_config, mock_llm_client, graphiti_core_mocks
|
||||
):
|
||||
"""Test that base_url without /v1 gets /v1 suffix added."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
_ = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
# Verify base_url was captured and has /v1 suffix added
|
||||
assert "base_url" in graphiti_core_mocks
|
||||
assert graphiti_core_mocks["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
def test_base_url_with_v1_is_preserved(
|
||||
self, mock_config, mock_llm_client, graphiti_core_mocks
|
||||
):
|
||||
"""Test that base_url with /v1 suffix is preserved."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434/v1"
|
||||
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
_ = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
# Verify base_url was preserved with /v1 suffix
|
||||
assert "base_url" in graphiti_core_mocks
|
||||
assert graphiti_core_mocks["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
def test_import_error_returns_none(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None when graphiti_core modules not available."""
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "graphiti_core.cross_encoder.openai_reranker_client":
|
||||
raise ImportError("graphiti_core not installed")
|
||||
if name == "graphiti_core.llm_client.config":
|
||||
raise ImportError("graphiti_core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_exception_during_creation_returns_none(self, mock_config, mock_llm_client):
|
||||
"""Test create_cross_encoder returns None on exception during creation."""
|
||||
from integrations.graphiti.providers_pkg.cross_encoder import (
|
||||
create_cross_encoder,
|
||||
)
|
||||
|
||||
# Mock the graphiti_core modules but make LLMConfig raise an exception
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.cross_encoder": MagicMock(),
|
||||
"graphiti_core.cross_encoder.openai_reranker_client": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.config import LLMConfig
|
||||
|
||||
# Make LLMConfig raise an exception
|
||||
LLMConfig.side_effect = Exception("Config creation failed")
|
||||
|
||||
result = create_cross_encoder(mock_config, mock_llm_client)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test module exports
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModuleExports:
|
||||
"""Tests for cross_encoder module exports."""
|
||||
|
||||
def test_create_cross_encoder_is_exported(self):
|
||||
"""Test that create_cross_encoder is exported from module."""
|
||||
from integrations.graphiti.providers_pkg import cross_encoder
|
||||
|
||||
assert hasattr(cross_encoder, "create_cross_encoder")
|
||||
assert callable(cross_encoder.create_cross_encoder)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,238 +0,0 @@
|
||||
"""
|
||||
Tests for integrations.graphiti.__init__ module.
|
||||
|
||||
Tests cover:
|
||||
- __getattr__ lazy import functionality
|
||||
- Direct imports (GraphitiConfig, validate_graphiti_config)
|
||||
- Invalid attribute access raises AttributeError
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestInitModuleDirectImports:
|
||||
"""Test direct imports that don't require lazy loading."""
|
||||
|
||||
def test_import_graphiti_config_directly(self):
|
||||
"""Test GraphitiConfig can be imported directly."""
|
||||
from integrations.graphiti import GraphitiConfig
|
||||
|
||||
assert GraphitiConfig is not None
|
||||
|
||||
def test_import_validate_graphiti_config_directly(self):
|
||||
"""Test validate_graphiti_config can be imported directly."""
|
||||
from integrations.graphiti import validate_graphiti_config
|
||||
|
||||
assert validate_graphiti_config is not None
|
||||
|
||||
def test___all___exports(self):
|
||||
"""Test __all__ contains expected exports."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
expected_all = [
|
||||
"GraphitiConfig",
|
||||
"validate_graphiti_config",
|
||||
"GraphitiMemory",
|
||||
"create_llm_client",
|
||||
"create_embedder",
|
||||
]
|
||||
assert graphiti_module.__all__ == expected_all
|
||||
|
||||
|
||||
class TestInitModuleLazyImports:
|
||||
"""Test __getattr__ lazy import functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory_module(self):
|
||||
"""Mock the memory module."""
|
||||
memory_mock = MagicMock()
|
||||
memory_mock.GraphitiMemory = MagicMock
|
||||
return memory_mock
|
||||
|
||||
@pytest.fixture
|
||||
def mock_providers_module(self):
|
||||
"""Mock the providers module."""
|
||||
providers_mock = MagicMock()
|
||||
providers_mock.create_llm_client = MagicMock(return_value=AsyncMock())
|
||||
providers_mock.create_embedder = MagicMock(return_value=AsyncMock())
|
||||
return providers_mock
|
||||
|
||||
def test_getattr_graphiti_memory_lazy_import(self, mock_memory_module):
|
||||
"""Test accessing GraphitiMemory triggers lazy import."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.memory": mock_memory_module,
|
||||
},
|
||||
):
|
||||
# Access the attribute via __getattr__
|
||||
result = graphiti_module.__getattr__("GraphitiMemory")
|
||||
|
||||
assert result == mock_memory_module.GraphitiMemory
|
||||
|
||||
def test_getattr_create_llm_client_lazy_import(self, mock_providers_module):
|
||||
"""Test accessing create_llm_client triggers lazy import."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.providers": mock_providers_module,
|
||||
},
|
||||
):
|
||||
result = graphiti_module.__getattr__("create_llm_client")
|
||||
|
||||
assert result == mock_providers_module.create_llm_client
|
||||
|
||||
def test_getattr_create_embedder_lazy_import(self, mock_providers_module):
|
||||
"""Test accessing create_embedder triggers lazy import."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.providers": mock_providers_module,
|
||||
},
|
||||
):
|
||||
result = graphiti_module.__getattr__("create_embedder")
|
||||
|
||||
assert result == mock_providers_module.create_embedder
|
||||
|
||||
def test_getattr_invalid_attribute_raises_attribute_error(self):
|
||||
"""Test accessing invalid attribute raises AttributeError."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with pytest.raises(AttributeError) as exc_info:
|
||||
graphiti_module.__getattr__("NonExistentAttribute")
|
||||
|
||||
assert "has no attribute" in str(exc_info.value)
|
||||
assert "NonExistentAttribute" in str(exc_info.value)
|
||||
|
||||
def test_getattr_empty_string_attribute(self):
|
||||
"""Test accessing empty string attribute raises AttributeError."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
graphiti_module.__getattr__("")
|
||||
|
||||
def test_getattr_case_sensitive(self):
|
||||
"""Test that __getattr__ is case-sensitive."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# lowercase should fail
|
||||
with pytest.raises(AttributeError):
|
||||
graphiti_module.__getattr__("graphitimemory")
|
||||
|
||||
# mixed case should fail
|
||||
with pytest.raises(AttributeError):
|
||||
graphiti_module.__getattr__("Graphiti_Memory")
|
||||
|
||||
|
||||
class TestInitModuleAccessPatterns:
|
||||
"""Test various access patterns for the init module."""
|
||||
|
||||
def test_hasattr_on_graphiti_memory(self):
|
||||
"""Test hasattr works correctly with lazy imports."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# Mock the import
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.memory": MagicMock(GraphitiMemory=MagicMock),
|
||||
},
|
||||
):
|
||||
# hasattr should call __getattr__ and not raise
|
||||
result = hasattr(graphiti_module, "GraphitiMemory")
|
||||
assert result is True
|
||||
|
||||
def test_hasattr_on_invalid_attribute(self):
|
||||
"""Test hasattr returns False for invalid attributes."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
result = hasattr(graphiti_module, "InvalidAttribute")
|
||||
assert result is False
|
||||
|
||||
def test_getattr_on_existing_direct_import(self):
|
||||
"""Test __getattr__ is not called for direct imports."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# GraphitiConfig is imported directly, so __getattr__ shouldn't be called
|
||||
# This tests that the normal import mechanism works
|
||||
assert hasattr(graphiti_module, "GraphitiConfig")
|
||||
|
||||
def test_module_docstring(self):
|
||||
"""Test the module has a docstring."""
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
assert graphiti_module.__doc__ is not None
|
||||
assert "Graphiti" in graphiti_module.__doc__
|
||||
|
||||
|
||||
class TestInitModuleIntegration:
|
||||
"""Integration tests for the init module."""
|
||||
|
||||
def test_import_star(self):
|
||||
"""Test 'from integrations.graphiti import *' includes direct imports."""
|
||||
# Create a new namespace for the import
|
||||
namespace = {}
|
||||
exec("from integrations.graphiti import *", namespace)
|
||||
|
||||
# Direct imports should be available
|
||||
assert "GraphitiConfig" in namespace
|
||||
assert "validate_graphiti_config" in namespace
|
||||
|
||||
def test_reimport_does_not_fail(self):
|
||||
"""Test that re-importing the module doesn't cause issues."""
|
||||
import importlib
|
||||
|
||||
import integrations.graphiti
|
||||
|
||||
# Reload the module
|
||||
importlib.reload(integrations.graphiti)
|
||||
|
||||
# Should still work
|
||||
assert hasattr(integrations.graphiti, "GraphitiConfig")
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_concurrent_attribute_access(self):
|
||||
"""Test that concurrent attribute access doesn't cause issues."""
|
||||
import concurrent.futures
|
||||
|
||||
import integrations.graphiti as graphiti_module
|
||||
|
||||
# Mock the imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"integrations.graphiti.memory": MagicMock(GraphitiMemory=MagicMock),
|
||||
"integrations.graphiti.providers": MagicMock(
|
||||
create_llm_client=MagicMock(return_value=AsyncMock()),
|
||||
create_embedder=MagicMock(return_value=AsyncMock()),
|
||||
),
|
||||
},
|
||||
):
|
||||
|
||||
def access_attribute(attr_name):
|
||||
try:
|
||||
return getattr(graphiti_module, attr_name)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
# Access multiple attributes concurrently
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = [
|
||||
executor.submit(access_attribute, "GraphitiMemory"),
|
||||
executor.submit(access_attribute, "create_llm_client"),
|
||||
executor.submit(access_attribute, "create_embedder"),
|
||||
]
|
||||
results = [f.result() for f in concurrent.futures.as_completed(futures)]
|
||||
|
||||
# All should succeed
|
||||
assert len(results) == 3
|
||||
assert all(r is not None for r in results)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,425 +0,0 @@
|
||||
"""
|
||||
Tests for integrations.graphiti.memory module.
|
||||
|
||||
This module is a backward compatibility facade that re-exports from
|
||||
queries_pkg and provides convenience functions.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# Test Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spec_dir(tmp_path):
|
||||
"""Create a temporary spec directory."""
|
||||
spec_dir = tmp_path / "specs" / "001-test"
|
||||
spec_dir.mkdir(parents=True)
|
||||
return spec_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_dir(tmp_path):
|
||||
"""Create a temporary project directory."""
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir(parents=True)
|
||||
return project_dir
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for module imports
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModuleImports:
|
||||
"""Test that all expected exports are available."""
|
||||
|
||||
def test_import_GraphitiMemory(self):
|
||||
"""Test GraphitiMemory can be imported."""
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
|
||||
assert GraphitiMemory is not None
|
||||
|
||||
def test_import_GroupIdMode(self):
|
||||
"""Test GroupIdMode can be imported."""
|
||||
from integrations.graphiti.memory import GroupIdMode
|
||||
|
||||
assert GroupIdMode is not None
|
||||
assert hasattr(GroupIdMode, "SPEC")
|
||||
assert hasattr(GroupIdMode, "PROJECT")
|
||||
|
||||
def test_import_is_graphiti_enabled(self):
|
||||
"""Test is_graphiti_enabled can be imported."""
|
||||
from integrations.graphiti.memory import is_graphiti_enabled
|
||||
|
||||
assert is_graphiti_enabled is not None
|
||||
|
||||
def test_import_get_graphiti_memory(self):
|
||||
"""Test get_graphiti_memory can be imported."""
|
||||
from integrations.graphiti.memory import get_graphiti_memory
|
||||
|
||||
assert get_graphiti_memory is not None
|
||||
|
||||
def test_import_test_graphiti_connection(self):
|
||||
"""Test test_graphiti_connection can be imported."""
|
||||
from integrations.graphiti.memory import test_graphiti_connection
|
||||
|
||||
assert test_graphiti_connection is not None
|
||||
|
||||
def test_import_test_provider_configuration(self):
|
||||
"""Test test_provider_configuration can be imported."""
|
||||
from integrations.graphiti.memory import test_provider_configuration
|
||||
|
||||
assert test_provider_configuration is not None
|
||||
|
||||
def test_import_episode_types(self):
|
||||
"""Test all episode type constants can be imported."""
|
||||
from integrations.graphiti.memory import (
|
||||
EPISODE_TYPE_CODEBASE_DISCOVERY,
|
||||
EPISODE_TYPE_GOTCHA,
|
||||
EPISODE_TYPE_HISTORICAL_CONTEXT,
|
||||
EPISODE_TYPE_PATTERN,
|
||||
EPISODE_TYPE_QA_RESULT,
|
||||
EPISODE_TYPE_SESSION_INSIGHT,
|
||||
EPISODE_TYPE_TASK_OUTCOME,
|
||||
)
|
||||
|
||||
assert EPISODE_TYPE_SESSION_INSIGHT == "session_insight"
|
||||
assert EPISODE_TYPE_CODEBASE_DISCOVERY == "codebase_discovery"
|
||||
assert EPISODE_TYPE_PATTERN == "pattern"
|
||||
assert EPISODE_TYPE_GOTCHA == "gotcha"
|
||||
assert EPISODE_TYPE_TASK_OUTCOME == "task_outcome"
|
||||
assert EPISODE_TYPE_QA_RESULT == "qa_result"
|
||||
assert EPISODE_TYPE_HISTORICAL_CONTEXT == "historical_context"
|
||||
|
||||
def test_import_MAX_CONTEXT_RESULTS(self):
|
||||
"""Test MAX_CONTEXT_RESULTS can be imported."""
|
||||
from integrations.graphiti.memory import MAX_CONTEXT_RESULTS
|
||||
|
||||
assert MAX_CONTEXT_RESULTS is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for get_graphiti_memory()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGetGraphitiMemory:
|
||||
"""Tests for get_graphiti_memory convenience function."""
|
||||
|
||||
def test_returns_graphiti_memory_instance(self, mock_spec_dir, mock_project_dir):
|
||||
"""Test get_graphiti_memory returns GraphitiMemory instance."""
|
||||
from integrations.graphiti.memory import get_graphiti_memory
|
||||
|
||||
memory = get_graphiti_memory(mock_spec_dir, mock_project_dir)
|
||||
|
||||
assert memory is not None
|
||||
assert hasattr(memory, "spec_dir")
|
||||
assert hasattr(memory, "project_dir")
|
||||
|
||||
def test_default_group_id_mode_is_project(self, mock_spec_dir, mock_project_dir):
|
||||
"""Test default group_id_mode is PROJECT."""
|
||||
from integrations.graphiti.memory import get_graphiti_memory
|
||||
from integrations.graphiti.queries_pkg.schema import GroupIdMode
|
||||
|
||||
memory = get_graphiti_memory(mock_spec_dir, mock_project_dir)
|
||||
|
||||
# Check that group_id_mode defaults to PROJECT
|
||||
assert memory.group_id_mode == GroupIdMode.PROJECT
|
||||
|
||||
def test_spec_group_id_mode(self, mock_spec_dir, mock_project_dir):
|
||||
"""Test SPEC group_id_mode can be set."""
|
||||
from integrations.graphiti.memory import get_graphiti_memory
|
||||
from integrations.graphiti.queries_pkg.schema import GroupIdMode
|
||||
|
||||
memory = get_graphiti_memory(mock_spec_dir, mock_project_dir, GroupIdMode.SPEC)
|
||||
|
||||
assert memory.group_id_mode == GroupIdMode.SPEC
|
||||
|
||||
def test_project_group_id_mode(self, mock_spec_dir, mock_project_dir):
|
||||
"""Test PROJECT group_id_mode can be set."""
|
||||
from integrations.graphiti.memory import get_graphiti_memory
|
||||
from integrations.graphiti.queries_pkg.schema import GroupIdMode
|
||||
|
||||
memory = get_graphiti_memory(
|
||||
mock_spec_dir, mock_project_dir, GroupIdMode.PROJECT
|
||||
)
|
||||
|
||||
assert memory.group_id_mode == GroupIdMode.PROJECT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for test_graphiti_connection()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTestGraphitiConnection:
|
||||
"""Tests for test_graphiti_connection function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_not_enabled(self):
|
||||
"""Test returns False when Graphiti not enabled."""
|
||||
from integrations.graphiti.memory import test_graphiti_connection
|
||||
|
||||
with patch("integrations.graphiti.memory.GraphitiConfig") as mock_config_class:
|
||||
mock_config = MagicMock()
|
||||
mock_config.enabled = False
|
||||
mock_config_class.from_env.return_value = mock_config
|
||||
|
||||
success, message = await test_graphiti_connection()
|
||||
|
||||
assert success is False
|
||||
assert "not enabled" in message.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_with_validation_errors(self):
|
||||
"""Test returns False when config has validation errors."""
|
||||
from integrations.graphiti.memory import test_graphiti_connection
|
||||
|
||||
with patch("integrations.graphiti.memory.GraphitiConfig") as mock_config_class:
|
||||
mock_config = MagicMock()
|
||||
mock_config.enabled = True
|
||||
mock_config.get_validation_errors.return_value = ["API key missing"]
|
||||
mock_config_class.from_env.return_value = mock_config
|
||||
|
||||
success, message = await test_graphiti_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Configuration errors" in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_on_import_error(self):
|
||||
"""Test returns False when graphiti_core not installed."""
|
||||
from integrations.graphiti.memory import test_graphiti_connection
|
||||
|
||||
with patch("integrations.graphiti.memory.GraphitiConfig") as mock_config_class:
|
||||
mock_config = MagicMock()
|
||||
mock_config.enabled = True
|
||||
mock_config.get_validation_errors.return_value = []
|
||||
mock_config_class.from_env.return_value = mock_config
|
||||
|
||||
# Only raise ImportError for graphiti_core imports
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def selective_import_error(name, *args, **kwargs):
|
||||
if "graphiti_core" in name:
|
||||
raise ImportError(f"No module named '{name}'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=selective_import_error):
|
||||
success, message = await test_graphiti_connection()
|
||||
|
||||
assert success is False
|
||||
assert "not installed" in message.lower()
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_true_on_successful_connection(self):
|
||||
"""Test returns True when connection succeeds (requires graphiti_core)."""
|
||||
from integrations.graphiti.memory import test_graphiti_connection
|
||||
|
||||
# This test requires graphiti_core to be installed
|
||||
# Marked as slow since it connects to actual database
|
||||
try:
|
||||
success, message = await test_graphiti_connection()
|
||||
|
||||
# If graphiti_core is not installed, success will be False
|
||||
if "not installed" in message.lower():
|
||||
assert success is False
|
||||
# If installed but DB not available, check for connection error
|
||||
elif "connection failed" in message.lower():
|
||||
assert success is False
|
||||
# If everything is set up, should succeed
|
||||
else:
|
||||
# Concrete assertion for successful connection
|
||||
assert success is True, (
|
||||
f"Expected success=True, got {success} with message: {message}"
|
||||
)
|
||||
assert message, "Message should not be empty for successful connection"
|
||||
|
||||
except AssertionError as e:
|
||||
# Re-raise AssertionError to properly surface test failures
|
||||
raise
|
||||
except Exception as e:
|
||||
# If there's an unexpected error, fail the test with useful info
|
||||
pytest.skip(f"Graphiti connection test failed: {e}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_provider_error(self):
|
||||
"""Test handles ProviderError during provider creation."""
|
||||
from integrations.graphiti.memory import test_graphiti_connection
|
||||
from integrations.graphiti.providers_pkg.exceptions import ProviderError
|
||||
|
||||
with patch("integrations.graphiti.memory.GraphitiConfig") as mock_config_class:
|
||||
mock_config = MagicMock()
|
||||
mock_config.enabled = True
|
||||
mock_config.get_validation_errors.return_value = []
|
||||
mock_config_class.from_env.return_value = mock_config
|
||||
|
||||
# Mock graphiti_core imports to succeed
|
||||
mock_graphiti = MagicMock()
|
||||
mock_falkordb_driver = MagicMock()
|
||||
|
||||
# Mock provider creation to raise ProviderError
|
||||
with patch("graphiti_providers.create_llm_client") as mock_create_llm:
|
||||
mock_create_llm.side_effect = ProviderError("Test provider error")
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(Graphiti=mock_graphiti),
|
||||
"graphiti_core.driver": MagicMock(),
|
||||
"graphiti_core.driver.falkordb_driver": mock_falkordb_driver,
|
||||
"graphiti_providers": MagicMock(
|
||||
ProviderError=ProviderError,
|
||||
create_embedder=MagicMock(),
|
||||
create_llm_client=mock_create_llm,
|
||||
),
|
||||
},
|
||||
):
|
||||
success, message = await test_graphiti_connection()
|
||||
|
||||
assert success is False
|
||||
assert "Provider error" in message
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for test_provider_configuration()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTestProviderConfiguration:
|
||||
"""Tests for test_provider_configuration function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_configuration_status(self):
|
||||
"""Test returns dict with configuration status."""
|
||||
pytest.importorskip("graphiti_providers")
|
||||
from integrations.graphiti.memory import test_provider_configuration
|
||||
|
||||
with patch("integrations.graphiti.memory.GraphitiConfig") as mock_config_class:
|
||||
mock_config = MagicMock()
|
||||
mock_config.is_valid.return_value = True
|
||||
mock_config.get_validation_errors.return_value = []
|
||||
mock_config.llm_provider = "openai"
|
||||
mock_config.embedder_provider = "openai"
|
||||
mock_config_class.from_env.return_value = mock_config
|
||||
|
||||
# Mock the test functions
|
||||
with patch(
|
||||
"graphiti_providers.test_llm_connection",
|
||||
return_value=(True, "LLM OK"),
|
||||
):
|
||||
with patch(
|
||||
"graphiti_providers.test_embedder_connection",
|
||||
return_value=(True, "Embedder OK"),
|
||||
):
|
||||
results = await test_provider_configuration()
|
||||
|
||||
assert isinstance(results, dict)
|
||||
assert results["config_valid"] is True
|
||||
assert results["validation_errors"] == []
|
||||
assert results["llm_provider"] == "openai"
|
||||
assert results["embedder_provider"] == "openai"
|
||||
assert results["llm_test"]["success"] is True
|
||||
assert results["embedder_test"]["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_ollama_test_when_ollama_provider(self):
|
||||
"""Test includes ollama_test when using ollama provider."""
|
||||
pytest.importorskip("graphiti_providers")
|
||||
from integrations.graphiti.memory import test_provider_configuration
|
||||
|
||||
with patch("integrations.graphiti.memory.GraphitiConfig") as mock_config_class:
|
||||
mock_config = MagicMock()
|
||||
mock_config.is_valid.return_value = True
|
||||
mock_config.get_validation_errors.return_value = []
|
||||
mock_config.llm_provider = "ollama"
|
||||
mock_config.embedder_provider = "openai"
|
||||
mock_config.ollama_base_url = "http://localhost:11434"
|
||||
mock_config_class.from_env.return_value = mock_config
|
||||
|
||||
with patch(
|
||||
"graphiti_providers.test_llm_connection",
|
||||
return_value=(True, "LLM OK"),
|
||||
):
|
||||
with patch(
|
||||
"graphiti_providers.test_embedder_connection",
|
||||
return_value=(True, "Embedder OK"),
|
||||
):
|
||||
with patch(
|
||||
"graphiti_providers.test_ollama_connection",
|
||||
return_value=(True, "Ollama OK"),
|
||||
):
|
||||
results = await test_provider_configuration()
|
||||
|
||||
assert "ollama_test" in results
|
||||
assert results["ollama_test"]["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omits_ollama_test_when_not_ollama_provider(self):
|
||||
"""Test omits ollama_test when not using ollama provider."""
|
||||
pytest.importorskip("graphiti_providers")
|
||||
from integrations.graphiti.memory import test_provider_configuration
|
||||
|
||||
with patch("integrations.graphiti.memory.GraphitiConfig") as mock_config_class:
|
||||
mock_config = MagicMock()
|
||||
mock_config.is_valid.return_value = True
|
||||
mock_config.get_validation_errors.return_value = []
|
||||
mock_config.llm_provider = "openai"
|
||||
mock_config.embedder_provider = "openai"
|
||||
mock_config_class.from_env.return_value = mock_config
|
||||
|
||||
with patch(
|
||||
"graphiti_providers.test_llm_connection",
|
||||
return_value=(True, "LLM OK"),
|
||||
):
|
||||
with patch(
|
||||
"graphiti_providers.test_embedder_connection",
|
||||
return_value=(True, "Embedder OK"),
|
||||
):
|
||||
results = await test_provider_configuration()
|
||||
|
||||
assert "ollama_test" not in results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for __all__ export list
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAllExports:
|
||||
"""Test __all__ contains expected exports."""
|
||||
|
||||
def test_all_exports_defined(self):
|
||||
"""Test __all__ is defined and contains expected items."""
|
||||
from integrations.graphiti import memory
|
||||
|
||||
assert hasattr(memory, "__all__")
|
||||
assert isinstance(memory.__all__, list)
|
||||
|
||||
expected_exports = [
|
||||
"GraphitiMemory",
|
||||
"GroupIdMode",
|
||||
"get_graphiti_memory",
|
||||
"is_graphiti_enabled",
|
||||
"test_graphiti_connection",
|
||||
"test_provider_configuration",
|
||||
"MAX_CONTEXT_RESULTS",
|
||||
"EPISODE_TYPE_SESSION_INSIGHT",
|
||||
"EPISODE_TYPE_CODEBASE_DISCOVERY",
|
||||
"EPISODE_TYPE_PATTERN",
|
||||
"EPISODE_TYPE_GOTCHA",
|
||||
"EPISODE_TYPE_TASK_OUTCOME",
|
||||
"EPISODE_TYPE_QA_RESULT",
|
||||
"EPISODE_TYPE_HISTORICAL_CONTEXT",
|
||||
]
|
||||
|
||||
for export in expected_exports:
|
||||
assert export in memory.__all__, f"{export} not in __all__"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,78 +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 pytest
|
||||
from integrations.graphiti.config import GraphitiConfig
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,model,dim",
|
||||
[
|
||||
("openai", None, None),
|
||||
("ollama", "embeddinggemma", 768),
|
||||
("ollama", "qwen3-embedding:0.6b", 1024),
|
||||
("voyage", None, None),
|
||||
("google", None, None),
|
||||
],
|
||||
)
|
||||
def test_provider_naming(provider, model, dim):
|
||||
"""Demonstrate provider-specific database naming."""
|
||||
# Create explicit config without relying on environment
|
||||
config = GraphitiConfig()
|
||||
config.embedder_provider = provider
|
||||
config.openai_embedding_model = "text-embedding-3-small"
|
||||
|
||||
if provider == "ollama" and model:
|
||||
config.ollama_embedding_model = model
|
||||
if dim is not None:
|
||||
config.ollama_embedding_dim = dim
|
||||
elif provider == "voyage":
|
||||
config.voyage_embedding_model = "voyage-3"
|
||||
elif provider == "google":
|
||||
config.google_embedding_model = "text-embedding-004"
|
||||
|
||||
# Get naming info
|
||||
dimension = config.get_embedding_dimension()
|
||||
signature = config.get_provider_signature()
|
||||
db_name = config.get_provider_specific_database_name("auto_claude_memory")
|
||||
|
||||
# Strengthened assertions with exact expected values where known
|
||||
if provider == "openai":
|
||||
assert dimension == 1536, f"OpenAI dimension should be 1536, got {dimension}"
|
||||
assert "openai" in signature.lower(), "OpenAI signature should contain 'openai'"
|
||||
# Signature format is provider_dimension for openai
|
||||
assert signature == "openai_1536", f"Expected 'openai_1536', got '{signature}'"
|
||||
elif provider == "ollama" and model == "embeddinggemma":
|
||||
assert dimension == 768, (
|
||||
f"Ollama gemma dimension should be 768, got {dimension}"
|
||||
)
|
||||
assert signature == f"ollama_{model}_{dimension}", (
|
||||
f"Expected 'ollama_{model}_{dimension}', got '{signature}'"
|
||||
)
|
||||
elif provider == "ollama" and model == "qwen3-embedding:0.6b":
|
||||
assert dimension == 1024, (
|
||||
f"Ollama qwen dimension should be 1024, got {dimension}"
|
||||
)
|
||||
# Colons in model names are replaced with underscores in signature
|
||||
assert signature == "ollama_qwen3-embedding_0_6b_1024", (
|
||||
f"Expected 'ollama_qwen3-embedding_0_6b_1024', got '{signature}'"
|
||||
)
|
||||
elif provider == "voyage":
|
||||
assert dimension == 1024, f"Voyage dimension should be 1024, got {dimension}"
|
||||
assert signature == "voyage_1024", f"Expected 'voyage_1024', got '{signature}'"
|
||||
elif provider == "google":
|
||||
assert dimension == 768, f"Google dimension should be 768, got {dimension}"
|
||||
assert signature == "google_768", f"Expected 'google_768', got '{signature}'"
|
||||
|
||||
# Verify signature appears in db_name
|
||||
assert signature is not None and signature != "", (
|
||||
f"Signature should be non-empty for {provider}"
|
||||
)
|
||||
assert signature in db_name, (
|
||||
f"Signature '{signature}' should appear in db_name '{db_name}' for {provider}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
Unit tests for Azure OpenAI embedder provider.
|
||||
|
||||
Tests cover:
|
||||
- create_azure_openai_embedder factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.embedder_providers.azure_openai_embedder import (
|
||||
create_azure_openai_embedder,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_azure_openai_embedder
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateAzureOpenAIEmbedder:
|
||||
"""Test create_azure_openai_embedder factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.azure_openai_api_key = "test-azure-key"
|
||||
config.azure_openai_base_url = "https://test.openai.azure.com"
|
||||
config.azure_openai_embedding_deployment = "test-embedding-deployment"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_azure_openai_embedder_success(self, mock_config):
|
||||
"""Test create_azure_openai_embedder returns embedder with valid config."""
|
||||
mock_azure_client = MagicMock()
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.azure_openai_embedder.AsyncOpenAI",
|
||||
return_value=mock_azure_client,
|
||||
):
|
||||
with patch(
|
||||
"graphiti_core.embedder.azure_openai.AzureOpenAIEmbedderClient",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
result = create_azure_openai_embedder(mock_config)
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_azure_openai_embedder_success_fast(self, mock_config):
|
||||
"""Fast test for create_azure_openai_embedder success path."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.embedder": MagicMock(),
|
||||
"graphiti_core.embedder.azure_openai": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.embedder.azure_openai import AzureOpenAIEmbedderClient
|
||||
|
||||
AzureOpenAIEmbedderClient.return_value = mock_embedder
|
||||
|
||||
result = create_azure_openai_embedder(mock_config)
|
||||
|
||||
# Verify the embedder was created and returned
|
||||
AzureOpenAIEmbedderClient.assert_called_once()
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_azure_openai_embedder_missing_api_key(self, mock_config):
|
||||
"""Test create_azure_openai_embedder raises ProviderError for missing API key."""
|
||||
mock_config.azure_openai_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_azure_openai_embedder(mock_config)
|
||||
|
||||
assert "AZURE_OPENAI_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_azure_openai_embedder_missing_base_url(self, mock_config):
|
||||
"""Test create_azure_openai_embedder raises ProviderError for missing base URL."""
|
||||
mock_config.azure_openai_base_url = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_azure_openai_embedder(mock_config)
|
||||
|
||||
assert "AZURE_OPENAI_BASE_URL" in str(exc_info.value)
|
||||
|
||||
def test_create_azure_openai_embedder_missing_deployment(self, mock_config):
|
||||
"""Test create_azure_openai_embedder raises ProviderError for missing deployment."""
|
||||
mock_config.azure_openai_embedding_deployment = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_azure_openai_embedder(mock_config)
|
||||
|
||||
assert "AZURE_OPENAI_EMBEDDING_DEPLOYMENT" in str(exc_info.value)
|
||||
|
||||
def test_create_azure_openai_embedder_import_error(self, mock_config):
|
||||
"""Test create_azure_openai_embedder raises ProviderNotInstalled on ImportError."""
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "graphiti_core.embedder.azure_openai":
|
||||
raise ImportError("graphiti-core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_azure_openai_embedder(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_azure_openai_embedder_passes_config_correctly(self, mock_config):
|
||||
"""Test create_azure_openai_embedder passes config values correctly."""
|
||||
mock_azure_client = MagicMock()
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.azure_openai_embedder.AsyncOpenAI",
|
||||
return_value=mock_azure_client,
|
||||
) as mock_openai:
|
||||
with patch(
|
||||
"graphiti_core.embedder.azure_openai.AzureOpenAIEmbedderClient",
|
||||
return_value=mock_embedder,
|
||||
) as mock_azure_embedder:
|
||||
create_azure_openai_embedder(mock_config)
|
||||
|
||||
# Verify AsyncOpenAI was called with correct arguments
|
||||
mock_openai.assert_called_once_with(
|
||||
base_url=mock_config.azure_openai_base_url,
|
||||
api_key=mock_config.azure_openai_api_key,
|
||||
)
|
||||
|
||||
# Verify AzureOpenAIEmbedderClient was called with correct arguments
|
||||
mock_azure_embedder.assert_called_once_with(
|
||||
azure_client=mock_azure_client,
|
||||
model=mock_config.azure_openai_embedding_deployment,
|
||||
)
|
||||
@@ -1,252 +0,0 @@
|
||||
"""
|
||||
Tests for integrations.graphiti.providers module.
|
||||
|
||||
This module is a re-export facade that re-exports all public APIs
|
||||
from the graphiti_providers package.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
# Expected exports from integrations.graphiti.providers module
|
||||
EXPECTED_EXPORTS = [
|
||||
"ProviderError",
|
||||
"ProviderNotInstalled",
|
||||
"create_llm_client",
|
||||
"create_embedder",
|
||||
"create_cross_encoder",
|
||||
"EMBEDDING_DIMENSIONS",
|
||||
"get_expected_embedding_dim",
|
||||
"validate_embedding_config",
|
||||
"test_llm_connection",
|
||||
"test_embedder_connection",
|
||||
"test_ollama_connection",
|
||||
"is_graphiti_enabled",
|
||||
"get_graph_hints",
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Tests for module imports
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModuleImports:
|
||||
"""Test that all expected exports are available."""
|
||||
|
||||
def test_import_ProviderError(self):
|
||||
"""Test ProviderError can be imported."""
|
||||
from integrations.graphiti.providers import ProviderError
|
||||
|
||||
assert ProviderError is not None
|
||||
# Should be an exception class
|
||||
assert issubclass(ProviderError, Exception)
|
||||
|
||||
def test_import_ProviderNotInstalled(self):
|
||||
"""Test ProviderNotInstalled can be imported."""
|
||||
from integrations.graphiti.providers import ProviderNotInstalled
|
||||
|
||||
assert ProviderNotInstalled is not None
|
||||
# Should be an exception class
|
||||
assert issubclass(ProviderNotInstalled, Exception)
|
||||
|
||||
def test_import_create_llm_client(self):
|
||||
"""Test create_llm_client can be imported."""
|
||||
from integrations.graphiti.providers import create_llm_client
|
||||
|
||||
assert create_llm_client is not None
|
||||
assert callable(create_llm_client)
|
||||
|
||||
def test_import_create_embedder(self):
|
||||
"""Test create_embedder can be imported."""
|
||||
from integrations.graphiti.providers import create_embedder
|
||||
|
||||
assert create_embedder is not None
|
||||
assert callable(create_embedder)
|
||||
|
||||
def test_import_create_cross_encoder(self):
|
||||
"""Test create_cross_encoder can be imported."""
|
||||
from integrations.graphiti.providers import create_cross_encoder
|
||||
|
||||
assert create_cross_encoder is not None
|
||||
assert callable(create_cross_encoder)
|
||||
|
||||
def test_import_EMBEDDING_DIMENSIONS(self):
|
||||
"""Test EMBEDDING_DIMENSIONS can be imported."""
|
||||
from integrations.graphiti.providers import EMBEDDING_DIMENSIONS
|
||||
|
||||
assert EMBEDDING_DIMENSIONS is not None
|
||||
assert isinstance(EMBEDDING_DIMENSIONS, dict)
|
||||
|
||||
def test_import_get_expected_embedding_dim(self):
|
||||
"""Test get_expected_embedding_dim can be imported."""
|
||||
from integrations.graphiti.providers import get_expected_embedding_dim
|
||||
|
||||
assert get_expected_embedding_dim is not None
|
||||
assert callable(get_expected_embedding_dim)
|
||||
|
||||
def test_import_validate_embedding_config(self):
|
||||
"""Test validate_embedding_config can be imported."""
|
||||
from integrations.graphiti.providers import validate_embedding_config
|
||||
|
||||
assert validate_embedding_config is not None
|
||||
assert callable(validate_embedding_config)
|
||||
|
||||
def test_import_test_llm_connection(self):
|
||||
"""Test test_llm_connection can be imported."""
|
||||
from integrations.graphiti.providers import test_llm_connection
|
||||
|
||||
assert test_llm_connection is not None
|
||||
assert callable(test_llm_connection)
|
||||
|
||||
def test_import_test_embedder_connection(self):
|
||||
"""Test test_embedder_connection can be imported."""
|
||||
from integrations.graphiti.providers import test_embedder_connection
|
||||
|
||||
assert test_embedder_connection is not None
|
||||
assert callable(test_embedder_connection)
|
||||
|
||||
def test_import_test_ollama_connection(self):
|
||||
"""Test test_ollama_connection can be imported."""
|
||||
from integrations.graphiti.providers import test_ollama_connection
|
||||
|
||||
assert test_ollama_connection is not None
|
||||
assert callable(test_ollama_connection)
|
||||
|
||||
def test_import_is_graphiti_enabled(self):
|
||||
"""Test is_graphiti_enabled can be imported."""
|
||||
from integrations.graphiti.providers import is_graphiti_enabled
|
||||
|
||||
assert is_graphiti_enabled is not None
|
||||
assert callable(is_graphiti_enabled)
|
||||
|
||||
def test_import_get_graph_hints(self):
|
||||
"""Test get_graph_hints can be imported."""
|
||||
from integrations.graphiti.providers import get_graph_hints
|
||||
|
||||
assert get_graph_hints is not None
|
||||
assert callable(get_graph_hints)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for __all__ export list
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestAllExports:
|
||||
"""Test __all__ contains expected exports."""
|
||||
|
||||
def test_all_exports_defined(self):
|
||||
"""Test __all__ is defined and contains expected items."""
|
||||
from integrations.graphiti import providers
|
||||
|
||||
assert hasattr(providers, "__all__")
|
||||
assert isinstance(providers.__all__, list)
|
||||
|
||||
for export in EXPECTED_EXPORTS:
|
||||
assert export in providers.__all__, f"{export} not in __all__"
|
||||
|
||||
def test_all_exports_count(self):
|
||||
"""Test __all__ contains the expected number of exports."""
|
||||
from integrations.graphiti import providers
|
||||
|
||||
# Should have same number of exports as EXPECTED_EXPORTS list
|
||||
assert len(providers.__all__) == len(EXPECTED_EXPORTS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for module docstring and metadata
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModuleMetadata:
|
||||
"""Test module has proper documentation."""
|
||||
|
||||
def test_module_has_docstring(self):
|
||||
"""Test module has docstring."""
|
||||
import integrations.graphiti.providers
|
||||
|
||||
assert integrations.graphiti.providers.__doc__ is not None
|
||||
assert len(integrations.graphiti.providers.__doc__) > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for re-export behavior
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestReExportBehavior:
|
||||
"""Test that re-exports work correctly."""
|
||||
|
||||
def test_ProviderError_is_exception(self):
|
||||
"""Test ProviderError can be raised and caught."""
|
||||
from integrations.graphiti.providers import ProviderError
|
||||
|
||||
with pytest.raises(ProviderError):
|
||||
raise ProviderError("Test error")
|
||||
|
||||
def test_ProviderNotInstalled_is_exception(self):
|
||||
"""Test ProviderNotInstalled can be raised and caught."""
|
||||
from integrations.graphiti.providers import ProviderNotInstalled
|
||||
|
||||
with pytest.raises(ProviderNotInstalled):
|
||||
raise ProviderNotInstalled("Test error")
|
||||
|
||||
def test_ProviderNotInstalled_subclass_of_ProviderError(self):
|
||||
"""Test ProviderNotInstalled is a subclass of ProviderError."""
|
||||
from integrations.graphiti.providers import ProviderError, ProviderNotInstalled
|
||||
|
||||
assert issubclass(ProviderNotInstalled, ProviderError)
|
||||
|
||||
def test_EMBEDDING_DIMENSIONS_has_expected_keys(self):
|
||||
"""Test EMBEDDING_DIMENSIONS has expected model keys."""
|
||||
from integrations.graphiti.providers import EMBEDDING_DIMENSIONS
|
||||
|
||||
# Check that expected model names exist in EMBEDDING_DIMENSIONS
|
||||
# Note: EMBEDDING_DIMENSIONS is keyed by model name, not provider name
|
||||
expected_models = [
|
||||
"text-embedding-3-small", # OpenAI
|
||||
"voyage-3", # Voyage AI
|
||||
"nomic-embed-text", # Ollama
|
||||
"all-minilm", # Ollama
|
||||
]
|
||||
|
||||
for model in expected_models:
|
||||
assert model in EMBEDDING_DIMENSIONS, f"{model} not in EMBEDDING_DIMENSIONS"
|
||||
assert isinstance(EMBEDDING_DIMENSIONS[model], int)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for namespace integrity
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestNamespaceIntegrity:
|
||||
"""Test module namespace remains consistent."""
|
||||
|
||||
def test_exports_are_accessible(self):
|
||||
"""Test all exports in __all__ are accessible."""
|
||||
from integrations.graphiti import providers
|
||||
|
||||
for name in providers.__all__:
|
||||
# Each export should be accessible
|
||||
assert hasattr(providers, name), f"{name} not accessible"
|
||||
|
||||
def test_import_from_module_works(self):
|
||||
"""Test 'from' imports work correctly."""
|
||||
# This tests the re-export mechanism
|
||||
from integrations.graphiti.providers import (
|
||||
ProviderError,
|
||||
create_embedder,
|
||||
create_llm_client,
|
||||
)
|
||||
|
||||
assert ProviderError is not None
|
||||
assert create_llm_client is not None
|
||||
assert create_embedder is not None
|
||||
|
||||
def test_module_level_import_works(self):
|
||||
"""Test module-level import works."""
|
||||
import integrations.graphiti.providers as providers
|
||||
|
||||
assert providers.ProviderError is not None
|
||||
assert providers.create_llm_client is not None
|
||||
assert providers.create_embedder is not None
|
||||
@@ -1,256 +0,0 @@
|
||||
"""
|
||||
Unit tests for Google embedder provider.
|
||||
|
||||
Tests cover:
|
||||
- create_google_embedder factory function
|
||||
- GoogleEmbedder class (create, create_batch methods)
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.embedder_providers.google_embedder import (
|
||||
DEFAULT_GOOGLE_EMBEDDING_MODEL,
|
||||
GoogleEmbedder,
|
||||
create_google_embedder,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Pytest fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def google_genai_mock():
|
||||
"""Mock google.generativeai module with common setup."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_genai.embed_content = MagicMock(return_value={"embedding": [0.1, 0.2, 0.3]})
|
||||
return mock_genai
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test GoogleEmbedder class
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGoogleEmbedder:
|
||||
"""Test GoogleEmbedder class."""
|
||||
|
||||
def test_google_embedder_init_success(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder initializes with API key and model."""
|
||||
# Inject mock into sys.modules before importing
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key", model="test-model")
|
||||
|
||||
assert embedder.api_key == "test-key"
|
||||
assert embedder.model == "test-model"
|
||||
google_genai_mock.configure.assert_called_once_with(api_key="test-key")
|
||||
|
||||
def test_google_embedder_init_default_model(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder uses default model when not specified."""
|
||||
# Inject mock into sys.modules before importing
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
|
||||
assert embedder.model == DEFAULT_GOOGLE_EMBEDDING_MODEL
|
||||
|
||||
def test_google_embedder_init_import_error(self):
|
||||
"""Test GoogleEmbedder raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "google.generativeai" or name.startswith("google.generativeai."):
|
||||
raise ImportError("google-generativeai not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
# Remove google.generativeai from sys.modules if present
|
||||
# to ensure the import actually goes through __import__
|
||||
with patch.dict(sys.modules, {"google.generativeai": None}):
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
GoogleEmbedder(api_key="test-key")
|
||||
|
||||
assert "google-generativeai" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_embedder_create_with_string(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder.create with string input."""
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
result = await embedder.create("test text")
|
||||
|
||||
assert result == [0.1, 0.2, 0.3]
|
||||
# Assert embed_content was called
|
||||
google_genai_mock.embed_content.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_embedder_create_with_list(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder.create with list input."""
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
result = await embedder.create(["test", "text"])
|
||||
|
||||
assert result == [0.1, 0.2, 0.3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_embedder_create_with_non_string_list(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder.create with non-string list items (lines 71-73)."""
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
# List with non-string items - should convert to string
|
||||
result = await embedder.create([123, 456])
|
||||
|
||||
assert result == [0.1, 0.2, 0.3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_embedder_create_with_empty_list(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder.create with empty or invalid input (line 75)."""
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
# Empty list - should be converted to string
|
||||
result = await embedder.create([])
|
||||
|
||||
assert result == [0.1, 0.2, 0.3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_embedder_create_batch(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder.create_batch with multiple inputs (lines 100-127)."""
|
||||
# Override embed_content return value for batch test
|
||||
google_genai_mock.embed_content = MagicMock(
|
||||
return_value={"embedding": [[0.1, 0.2], [0.3, 0.4]]}
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
result = await embedder.create_batch(["text1", "text2"])
|
||||
|
||||
# Should handle nested list response (lines 122-125)
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_embedder_create_batch_single_response(
|
||||
self, google_genai_mock
|
||||
):
|
||||
"""Test GoogleEmbedder.create_batch with single embedding response (lines 124-125)."""
|
||||
# Override embed_content return value for single response test
|
||||
google_genai_mock.embed_content = MagicMock(
|
||||
return_value={"embedding": [0.1, 0.2, 0.3]}
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
result = await embedder.create_batch(["text1"])
|
||||
|
||||
# Should handle single embedding response (line 125)
|
||||
assert len(result) == 1
|
||||
assert result[0] == [0.1, 0.2, 0.3]
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_embedder_create_batch_large_input(self, google_genai_mock):
|
||||
"""Test GoogleEmbedder.create_batch with >100 items (batching)."""
|
||||
# Override embed_content return value for large batch test
|
||||
google_genai_mock.embed_content = MagicMock(
|
||||
return_value={"embedding": [[0.1, 0.2]]}
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": google_genai_mock}):
|
||||
embedder = GoogleEmbedder(api_key="test-key")
|
||||
# Create 250 items - should be split into 3 batches (100, 100, 50)
|
||||
result = await embedder.create_batch([f"text{i}" for i in range(250)])
|
||||
|
||||
# Should call embed_content 3 times
|
||||
assert google_genai_mock.embed_content.call_count == 3
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test create_google_embedder
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateGoogleEmbedder:
|
||||
"""Test create_google_embedder factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.google_api_key = "test-google-key"
|
||||
config.google_embedding_model = None
|
||||
return config
|
||||
|
||||
def test_create_google_embedder_success(self, mock_config):
|
||||
"""Test create_google_embedder returns embedder with valid config."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.google_embedder.GoogleEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
result = create_google_embedder(mock_config)
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_google_embedder_missing_api_key(self, mock_config):
|
||||
"""Test create_google_embedder raises ProviderError for missing API key."""
|
||||
mock_config.google_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_google_embedder(mock_config)
|
||||
|
||||
assert "GOOGLE_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_google_embedder_with_custom_model(self, mock_config):
|
||||
"""Test create_google_embedder uses custom model when specified."""
|
||||
mock_config.google_embedding_model = "custom-model"
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.google_embedder.GoogleEmbedder",
|
||||
return_value=mock_embedder,
|
||||
) as mock_google_embedder:
|
||||
create_google_embedder(mock_config)
|
||||
|
||||
mock_google_embedder.assert_called_once_with(
|
||||
api_key=mock_config.google_api_key,
|
||||
model="custom-model",
|
||||
)
|
||||
|
||||
def test_create_google_embedder_with_default_model(self, mock_config):
|
||||
"""Test create_google_embedder uses default model when not specified."""
|
||||
mock_config.google_embedding_model = None
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.google_embedder.GoogleEmbedder",
|
||||
return_value=mock_embedder,
|
||||
) as mock_google_embedder:
|
||||
create_google_embedder(mock_config)
|
||||
|
||||
mock_google_embedder.assert_called_once_with(
|
||||
api_key=mock_config.google_api_key,
|
||||
model=DEFAULT_GOOGLE_EMBEDDING_MODEL,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Constants
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGoogleEmbedderConstants:
|
||||
"""Test Google embedder constants."""
|
||||
|
||||
def test_default_google_embedding_model(self):
|
||||
# Note: This test verifies the default Google embedding model.
|
||||
# The value should match the model used in production.
|
||||
assert DEFAULT_GOOGLE_EMBEDDING_MODEL == "text-embedding-004"
|
||||
@@ -1,146 +0,0 @@
|
||||
"""
|
||||
Unit tests for Anthropic LLM provider.
|
||||
|
||||
Tests cover:
|
||||
- create_anthropic_llm_client factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.llm_providers.anthropic_llm import (
|
||||
create_anthropic_llm_client,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_anthropic_llm_client
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateAnthropicLLMClient:
|
||||
"""Test create_anthropic_llm_client factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.anthropic_api_key = "sk-ant-test-key"
|
||||
config.anthropic_model = "claude-sonnet-4-20250514"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_anthropic_llm_client_success(self, mock_config):
|
||||
"""Test create_anthropic_llm_client returns client with valid config."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Patch at the location where the import happens (local import inside function)
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.anthropic_llm.AnthropicClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = create_anthropic_llm_client(mock_config)
|
||||
assert result == mock_client
|
||||
|
||||
def test_create_anthropic_llm_client_success_fast(self, mock_config):
|
||||
"""Fast test for create_anthropic_llm_client success path."""
|
||||
mock_llm_client = MagicMock()
|
||||
|
||||
# Create the config mock
|
||||
mock_config_module = MagicMock()
|
||||
mock_config_module.LLMConfig = MagicMock
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.anthropic_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": mock_config_module,
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.anthropic_client import AnthropicClient
|
||||
|
||||
AnthropicClient.return_value = mock_llm_client
|
||||
|
||||
result = create_anthropic_llm_client(mock_config)
|
||||
|
||||
# Verify the client was created and returned
|
||||
AnthropicClient.assert_called_once()
|
||||
assert result == mock_llm_client
|
||||
|
||||
def test_create_anthropic_llm_client_missing_api_key_fast(self, mock_config):
|
||||
"""Fast test for API key validation (line 41)."""
|
||||
# Mock the graphiti_core imports first to avoid ImportError
|
||||
mock_config_module = MagicMock()
|
||||
mock_config_module.LLMConfig = MagicMock
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.anthropic_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": mock_config_module,
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.anthropic_client import AnthropicClient
|
||||
|
||||
AnthropicClient.return_value = MagicMock()
|
||||
|
||||
# Now set API key to None to test validation
|
||||
mock_config.anthropic_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_anthropic_llm_client(mock_config)
|
||||
|
||||
assert "ANTHROPIC_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_anthropic_llm_client_import_error(self, mock_config):
|
||||
"""Test create_anthropic_llm_client raises ProviderNotInstalled on ImportError."""
|
||||
from types import ModuleType
|
||||
|
||||
# Create a broken module that raises ImportError on attribute access
|
||||
def broken_getattr(name):
|
||||
if name in ("llm_client", "anthropic_client", "config"):
|
||||
raise ImportError("graphiti-core[anthropic] not installed")
|
||||
raise AttributeError(f"module has no attribute '{name}'")
|
||||
|
||||
broken_module = ModuleType("graphiti_core")
|
||||
broken_module.__getattr__ = broken_getattr
|
||||
|
||||
# Patch both modules that are imported
|
||||
with patch.dict(sys.modules, {"graphiti_core": broken_module}):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_anthropic_llm_client(mock_config)
|
||||
|
||||
assert "graphiti-core[anthropic]" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_anthropic_llm_client_passes_config_correctly(self, mock_config):
|
||||
"""Test create_anthropic_llm_client passes config values correctly."""
|
||||
mock_config.anthropic_api_key = "sk-ant-test-key-123"
|
||||
mock_config.anthropic_model = "claude-opus-4-20250514"
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Patch at the location where the imports happen (local imports inside function)
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.anthropic_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.anthropic_llm.AnthropicClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_anthropic_llm_client(mock_config)
|
||||
|
||||
# Verify LLMConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "sk-ant-test-key-123"
|
||||
assert call_kwargs["model"] == "claude-opus-4-20250514"
|
||||
@@ -1,163 +0,0 @@
|
||||
"""
|
||||
Unit tests for Azure OpenAI LLM provider.
|
||||
|
||||
Tests cover:
|
||||
- create_azure_openai_llm_client factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.llm_providers.azure_openai_llm import (
|
||||
create_azure_openai_llm_client,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_azure_openai_llm_client
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateAzureOpenAILLMClient:
|
||||
"""Test create_azure_openai_llm_client factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.azure_openai_api_key = "test-azure-key"
|
||||
config.azure_openai_base_url = "https://test.openai.azure.com"
|
||||
config.azure_openai_llm_deployment = "test-llm-deployment"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_azure_openai_llm_client_success(self, mock_config):
|
||||
"""Test create_azure_openai_llm_client returns client with valid config."""
|
||||
mock_azure_client = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.azure_openai_llm.AsyncOpenAI",
|
||||
return_value=mock_azure_client,
|
||||
):
|
||||
with patch(
|
||||
"graphiti_core.llm_client.azure_openai_client.AzureOpenAILLMClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = create_azure_openai_llm_client(mock_config)
|
||||
assert result == mock_client
|
||||
|
||||
def test_create_azure_openai_llm_client_success_fast(self, mock_config):
|
||||
"""Fast test for create_azure_openai_llm_client success path."""
|
||||
mock_llm_client = MagicMock()
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.azure_openai_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.azure_openai_client import (
|
||||
AzureOpenAILLMClient,
|
||||
)
|
||||
|
||||
AzureOpenAILLMClient.return_value = mock_llm_client
|
||||
|
||||
result = create_azure_openai_llm_client(mock_config)
|
||||
|
||||
# Verify the client was created and returned
|
||||
AzureOpenAILLMClient.assert_called_once()
|
||||
assert result == mock_llm_client
|
||||
|
||||
def test_create_azure_openai_llm_client_missing_api_key(self, mock_config):
|
||||
"""Test create_azure_openai_llm_client raises ProviderError for missing API key."""
|
||||
mock_config.azure_openai_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_azure_openai_llm_client(mock_config)
|
||||
|
||||
assert "AZURE_OPENAI_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_azure_openai_llm_client_missing_base_url(self, mock_config):
|
||||
"""Test create_azure_openai_llm_client raises ProviderError for missing base URL."""
|
||||
mock_config.azure_openai_base_url = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_azure_openai_llm_client(mock_config)
|
||||
|
||||
assert "AZURE_OPENAI_BASE_URL" in str(exc_info.value)
|
||||
|
||||
def test_create_azure_openai_llm_client_missing_deployment(self, mock_config):
|
||||
"""Test create_azure_openai_llm_client raises ProviderError for missing deployment."""
|
||||
mock_config.azure_openai_llm_deployment = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_azure_openai_llm_client(mock_config)
|
||||
|
||||
assert "AZURE_OPENAI_LLM_DEPLOYMENT" in str(exc_info.value)
|
||||
|
||||
def test_create_azure_openai_llm_client_import_error(self, mock_config):
|
||||
"""Test create_azure_openai_llm_client raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if (
|
||||
name.startswith("graphiti_core.llm_client")
|
||||
or name == "openai"
|
||||
or name.startswith("openai.")
|
||||
):
|
||||
raise ImportError("Required package not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_azure_openai_llm_client(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
assert "openai" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_azure_openai_llm_client_passes_config_correctly(self, mock_config):
|
||||
"""Test create_azure_openai_llm_client passes config values correctly."""
|
||||
mock_azure_client = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.azure_openai_llm.AsyncOpenAI",
|
||||
return_value=mock_azure_client,
|
||||
) as mock_openai:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.azure_openai_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"graphiti_core.llm_client.azure_openai_client.AzureOpenAILLMClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_azure_openai_llm_client(mock_config)
|
||||
|
||||
# Verify AsyncOpenAI was called with correct arguments
|
||||
mock_openai.assert_called_once_with(
|
||||
base_url=mock_config.azure_openai_base_url,
|
||||
api_key=mock_config.azure_openai_api_key,
|
||||
)
|
||||
|
||||
# Verify LLMConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert (
|
||||
call_kwargs["model"] == mock_config.azure_openai_llm_deployment
|
||||
)
|
||||
assert (
|
||||
call_kwargs["small_model"]
|
||||
== mock_config.azure_openai_llm_deployment
|
||||
)
|
||||
@@ -1,410 +0,0 @@
|
||||
"""
|
||||
Unit tests for Google LLM provider.
|
||||
|
||||
Tests cover:
|
||||
- create_google_llm_client factory function
|
||||
- GoogleLLMClient class (generate_response, generate_response_with_tools)
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.llm_providers.google_llm import (
|
||||
DEFAULT_GOOGLE_LLM_MODEL,
|
||||
GoogleLLMClient,
|
||||
create_google_llm_client,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test GoogleLLMClient class
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGoogleLLMClient:
|
||||
"""Test GoogleLLMClient class."""
|
||||
|
||||
def test_google_llm_client_init_success(self):
|
||||
"""Test GoogleLLMClient initializes with API key and model."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key", model="test-model")
|
||||
|
||||
assert client.api_key == "test-key"
|
||||
assert client.model == "test-model"
|
||||
mock_genai.configure.assert_called_once_with(api_key="test-key")
|
||||
mock_genai.GenerativeModel.assert_called_once_with("test-model")
|
||||
|
||||
def test_google_llm_client_init_default_model(self):
|
||||
"""Test GoogleLLMClient uses default model when not specified."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
|
||||
assert client.model == DEFAULT_GOOGLE_LLM_MODEL
|
||||
|
||||
def test_google_llm_client_init_import_error(self):
|
||||
"""Test GoogleLLMClient raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "google.generativeai" or name.startswith("google.generativeai."):
|
||||
raise ImportError("google-generativeai not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
GoogleLLMClient(api_key="test-key")
|
||||
|
||||
assert "google-generativeai" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_user_message(self):
|
||||
"""Test GoogleLLMClient.generate_response with user message (lines 73-133)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response"
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
|
||||
assert result == "Test response"
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_user_message_slow(self):
|
||||
"""Test GoogleLLMClient.generate_response with user message (slow variant)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response"
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[{"role": "user", "content": "Hello"}]
|
||||
)
|
||||
|
||||
assert result == "Test response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_system_message(self):
|
||||
"""Test GoogleLLMClient.generate_response with system instruction (lines 84-98)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model_with_sys = MagicMock()
|
||||
mock_model_without_sys = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(
|
||||
side_effect=[mock_model_without_sys, mock_model_with_sys]
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response"
|
||||
mock_model_with_sys.generate_content = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result == "Test response"
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_system_message_slow(self):
|
||||
"""Test GoogleLLMClient.generate_response with system instruction (slow variant)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model_with_sys = MagicMock()
|
||||
mock_model_without_sys = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(
|
||||
side_effect=[mock_model_without_sys, mock_model_with_sys]
|
||||
)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response"
|
||||
mock_model_with_sys.generate_content = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result == "Test response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_assistant_message(self):
|
||||
"""Test GoogleLLMClient.generate_response with assistant role (lines 87-88)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response"
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result == "Test response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_response_model(self):
|
||||
"""Test GoogleLLMClient.generate_response with structured output (lines 103-127)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = '{"key": "value"}'
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
mock_genai.GenerationConfig = MagicMock()
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
from pydantic import BaseModel
|
||||
|
||||
class TestModel(BaseModel):
|
||||
key: str
|
||||
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[{"role": "user", "content": "Hello"}],
|
||||
response_model=TestModel,
|
||||
)
|
||||
|
||||
assert isinstance(result, TestModel)
|
||||
assert result.key == "value"
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_response_model_slow(self):
|
||||
"""Test GoogleLLMClient.generate_response with structured output (slow variant)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = '{"key": "value"}'
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
mock_genai.GenerationConfig = MagicMock()
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
from pydantic import BaseModel
|
||||
|
||||
class TestModel(BaseModel):
|
||||
key: str
|
||||
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[{"role": "user", "content": "Hello"}],
|
||||
response_model=TestModel,
|
||||
)
|
||||
|
||||
assert isinstance(result, TestModel)
|
||||
assert result.key == "value"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_json_decode_error(self):
|
||||
"""Test GoogleLLMClient.generate_response with JSON decode error (lines 122-127)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Not valid JSON"
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
mock_genai.GenerationConfig = MagicMock()
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
from pydantic import BaseModel
|
||||
|
||||
class TestModel(BaseModel):
|
||||
key: str
|
||||
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
result = await client.generate_response(
|
||||
[{"role": "user", "content": "Hello"}],
|
||||
response_model=TestModel,
|
||||
)
|
||||
|
||||
# Should return raw text when JSON parsing fails
|
||||
assert result == "Not valid JSON"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_tools(self):
|
||||
"""Test GoogleLLMClient.generate_response_with_tools (lines 155-160)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response"
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.google_llm.logger"
|
||||
) as mock_logger:
|
||||
result = await client.generate_response_with_tools(
|
||||
[{"role": "user", "content": "Hello"}],
|
||||
tools=[{"name": "test_tool"}],
|
||||
)
|
||||
|
||||
# Should log warning about tools not being supported
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "does not yet support tool calling" in str(
|
||||
mock_logger.warning.call_args
|
||||
)
|
||||
assert result == "Test response"
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_llm_client_generate_response_with_tools_slow(self):
|
||||
"""Test GoogleLLMClient.generate_response_with_tools (slow variant)."""
|
||||
mock_genai = MagicMock()
|
||||
mock_genai.configure = MagicMock()
|
||||
mock_model = MagicMock()
|
||||
mock_genai.GenerativeModel = MagicMock(return_value=mock_model)
|
||||
mock_response = MagicMock()
|
||||
mock_response.text = "Test response"
|
||||
mock_model.generate_content = MagicMock(return_value=mock_response)
|
||||
|
||||
with patch.dict(sys.modules, {"google.generativeai": mock_genai}):
|
||||
client = GoogleLLMClient(api_key="test-key")
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.google_llm.logger"
|
||||
) as mock_logger:
|
||||
result = await client.generate_response_with_tools(
|
||||
[{"role": "user", "content": "Hello"}],
|
||||
tools=[{"name": "test_tool"}],
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "does not yet support tool calling" in str(
|
||||
mock_logger.warning.call_args
|
||||
)
|
||||
assert result == "Test response"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test create_google_llm_client
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateGoogleLLMClient:
|
||||
"""Test create_google_llm_client factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.google_api_key = "test-google-key"
|
||||
config.google_llm_model = None
|
||||
return config
|
||||
|
||||
def test_create_google_llm_client_success(self, mock_config):
|
||||
"""Test create_google_llm_client returns client with valid config."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.google_llm.GoogleLLMClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = create_google_llm_client(mock_config)
|
||||
assert result == mock_client
|
||||
|
||||
def test_create_google_llm_client_missing_api_key(self, mock_config):
|
||||
"""Test create_google_llm_client raises ProviderError for missing API key."""
|
||||
mock_config.google_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_google_llm_client(mock_config)
|
||||
|
||||
assert "GOOGLE_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_google_llm_client_with_custom_model(self, mock_config):
|
||||
"""Test create_google_llm_client uses custom model when specified."""
|
||||
mock_config.google_llm_model = "custom-model"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.google_llm.GoogleLLMClient",
|
||||
return_value=mock_client,
|
||||
) as mock_google_client:
|
||||
create_google_llm_client(mock_config)
|
||||
|
||||
mock_google_client.assert_called_once_with(
|
||||
api_key=mock_config.google_api_key,
|
||||
model="custom-model",
|
||||
)
|
||||
|
||||
def test_create_google_llm_client_with_default_model(self, mock_config):
|
||||
"""Test create_google_llm_client uses default model when not specified."""
|
||||
mock_config.google_llm_model = None
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.google_llm.GoogleLLMClient",
|
||||
return_value=mock_client,
|
||||
) as mock_google_client:
|
||||
create_google_llm_client(mock_config)
|
||||
|
||||
mock_google_client.assert_called_once_with(
|
||||
api_key=mock_config.google_api_key,
|
||||
model=DEFAULT_GOOGLE_LLM_MODEL,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Constants
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGoogleLLMConstants:
|
||||
"""Test Google LLM constants."""
|
||||
|
||||
def test_default_google_llm_model(self):
|
||||
"""Test DEFAULT_GOOGLE_LLM_MODEL is set correctly."""
|
||||
assert DEFAULT_GOOGLE_LLM_MODEL == "gemini-2.0-flash"
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Unit tests for Ollama LLM provider.
|
||||
|
||||
Tests cover:
|
||||
- create_ollama_llm_client factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.llm_providers.ollama_llm import (
|
||||
create_ollama_llm_client,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_ollama_llm_client
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateOllamaLLMClient:
|
||||
"""Test create_ollama_llm_client factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.ollama_llm_model = "llama3.2"
|
||||
config.ollama_base_url = "http://localhost:11434"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_llm_client_success(self, mock_config):
|
||||
"""Test create_ollama_llm_client returns client with valid config."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.OpenAIGenericClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = create_ollama_llm_client(mock_config)
|
||||
assert result == mock_client
|
||||
|
||||
def test_create_ollama_llm_client_success_fast(self, mock_config):
|
||||
"""Fast test for create_ollama_llm_client success path."""
|
||||
mock_llm_client = MagicMock()
|
||||
|
||||
# Create the config mock
|
||||
mock_config_module = MagicMock()
|
||||
mock_config_module.LLMConfig = MagicMock
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": mock_config_module,
|
||||
"graphiti_core.llm_client.openai_generic_client": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.openai_generic_client import (
|
||||
OpenAIGenericClient,
|
||||
)
|
||||
|
||||
OpenAIGenericClient.return_value = mock_llm_client
|
||||
|
||||
result = create_ollama_llm_client(mock_config)
|
||||
|
||||
# Verify the client was created and returned
|
||||
OpenAIGenericClient.assert_called_once()
|
||||
assert result == mock_llm_client
|
||||
|
||||
def test_create_ollama_llm_client_missing_model(self, mock_config):
|
||||
"""Test create_ollama_llm_client raises ProviderError for missing model."""
|
||||
mock_config.ollama_llm_model = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_ollama_llm_client(mock_config)
|
||||
|
||||
assert "OLLAMA_LLM_MODEL" in str(exc_info.value)
|
||||
|
||||
def test_create_ollama_llm_client_import_error(self, mock_config):
|
||||
"""Test create_ollama_llm_client raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name.startswith("graphiti_core.llm_client"):
|
||||
raise ImportError("graphiti-core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_ollama_llm_client(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_llm_client_base_url_without_v1(self, mock_config):
|
||||
"""Test create_ollama_llm_client appends /v1 to base URL if missing."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.OpenAIGenericClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_ollama_llm_client(mock_config)
|
||||
|
||||
# Verify base_url has /v1 appended
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_llm_client_base_url_with_v1(self, mock_config):
|
||||
"""Test create_ollama_llm_client doesn't duplicate /v1 in base URL."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434/v1"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.OpenAIGenericClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_ollama_llm_client(mock_config)
|
||||
|
||||
# Verify base_url is not duplicated
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_llm_client_base_url_with_trailing_slash(self, mock_config):
|
||||
"""Test create_ollama_llm_client handles trailing slash correctly."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434/"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.OpenAIGenericClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_ollama_llm_client(mock_config)
|
||||
|
||||
# Verify trailing slash is handled
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_llm_client_passes_config_correctly(self, mock_config):
|
||||
"""Test create_ollama_llm_client passes config values correctly."""
|
||||
mock_config.ollama_llm_model = "qwen2.5"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.ollama_llm.OpenAIGenericClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_ollama_llm_client(mock_config)
|
||||
|
||||
# Verify LLMConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "ollama"
|
||||
assert call_kwargs["model"] == "qwen2.5"
|
||||
assert call_kwargs["small_model"] == "qwen2.5"
|
||||
@@ -1,207 +0,0 @@
|
||||
"""
|
||||
Unit tests for OpenAI LLM provider.
|
||||
|
||||
Tests cover:
|
||||
- create_openai_llm_client factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.llm_providers.openai_llm import (
|
||||
create_openai_llm_client,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_openai_llm_client
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateOpenAILLMClient:
|
||||
"""Test create_openai_llm_client factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.openai_api_key = "sk-test-key"
|
||||
config.openai_model = "gpt-4o"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openai_llm_client_success(self, mock_config):
|
||||
"""Test create_openai_llm_client returns client with valid config."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openai_llm.OpenAIClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = create_openai_llm_client(mock_config)
|
||||
assert result == mock_client
|
||||
|
||||
def test_create_openai_llm_client_success_fast(self, mock_config):
|
||||
"""Fast test for create_openai_llm_client success path."""
|
||||
mock_llm_client = MagicMock()
|
||||
|
||||
# Create the config mock
|
||||
mock_config_module = MagicMock()
|
||||
mock_config_module.LLMConfig = MagicMock
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": mock_config_module,
|
||||
"graphiti_core.llm_client.openai_client": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.openai_client import OpenAIClient
|
||||
|
||||
OpenAIClient.return_value = mock_llm_client
|
||||
|
||||
result = create_openai_llm_client(mock_config)
|
||||
|
||||
# Verify the client was created and returned
|
||||
OpenAIClient.assert_called_once()
|
||||
assert result == mock_llm_client
|
||||
|
||||
def test_create_openai_llm_client_missing_api_key(self, mock_config):
|
||||
"""Test create_openai_llm_client raises ProviderError for missing API key."""
|
||||
mock_config.openai_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_openai_llm_client(mock_config)
|
||||
|
||||
assert "OPENAI_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_openai_llm_client_import_error(self, mock_config):
|
||||
"""Test create_openai_llm_client raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name.startswith("graphiti_core.llm_client"):
|
||||
raise ImportError("graphiti-core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_openai_llm_client(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
|
||||
def test_create_openai_llm_client_gpt5_model_with_reasoning_fast(self, mock_config):
|
||||
"""Fast test for GPT-5 model with reasoning (line 58)."""
|
||||
mock_config.openai_model = "gpt-5-turbo"
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Create the config mock
|
||||
mock_config_module = MagicMock()
|
||||
mock_config_module.LLMConfig = MagicMock
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.llm_client": MagicMock(),
|
||||
"graphiti_core.llm_client.config": mock_config_module,
|
||||
"graphiti_core.llm_client.openai_client": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.llm_client.openai_client import OpenAIClient
|
||||
|
||||
OpenAIClient.return_value = mock_client
|
||||
|
||||
result = create_openai_llm_client(mock_config)
|
||||
|
||||
# Verify the client was created with default config (no extra params)
|
||||
OpenAIClient.assert_called_once()
|
||||
call_kwargs = OpenAIClient.call_args.kwargs
|
||||
# Should not have reasoning/verbosity params set to None for GPT-5
|
||||
assert (
|
||||
"reasoning" not in call_kwargs
|
||||
or call_kwargs.get("reasoning") is not False
|
||||
)
|
||||
assert (
|
||||
"verbosity" not in call_kwargs
|
||||
or call_kwargs.get("verbosity") is not False
|
||||
)
|
||||
assert result == mock_client
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected_reasoning,expected_verbosity",
|
||||
[
|
||||
pytest.param("gpt-5-turbo", True, None, id="gpt5"),
|
||||
pytest.param("o1-preview", True, None, id="o1"),
|
||||
pytest.param("o3-mini", True, None, id="o3"),
|
||||
],
|
||||
)
|
||||
def test_create_openai_llm_client_reasoning_models(
|
||||
self, mock_config, model, expected_reasoning, expected_verbosity
|
||||
):
|
||||
"""Test create_openai_llm_client with reasoning-capable models."""
|
||||
mock_config.openai_model = model
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openai_llm.OpenAIClient",
|
||||
return_value=mock_client,
|
||||
) as mock_openai_client:
|
||||
create_openai_llm_client(mock_config)
|
||||
|
||||
mock_openai_client.assert_called_once()
|
||||
call_kwargs = mock_openai_client.call_args.kwargs
|
||||
# Verify reasoning is set to True for reasoning models
|
||||
assert call_kwargs.get("reasoning") is expected_reasoning
|
||||
# Verify verbosity matches expected value (None for these models)
|
||||
assert call_kwargs.get("verbosity") == expected_verbosity
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openai_llm_client_gpt4_model_without_reasoning(self, mock_config):
|
||||
"""Test create_openai_llm_client with GPT-4 model disables reasoning."""
|
||||
mock_config.openai_model = "gpt-4o"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openai_llm.OpenAIClient",
|
||||
return_value=mock_client,
|
||||
) as mock_openai_client:
|
||||
create_openai_llm_client(mock_config)
|
||||
|
||||
# GPT-4 models should be created with reasoning=None, verbosity=None
|
||||
call_kwargs = mock_openai_client.call_args.kwargs
|
||||
assert call_kwargs.get("reasoning") is None
|
||||
assert call_kwargs.get("verbosity") is None
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openai_llm_client_passes_config_correctly(self, mock_config):
|
||||
"""Test create_openai_llm_client passes config values correctly."""
|
||||
mock_config.openai_api_key = "sk-test-key-123"
|
||||
mock_config.openai_model = "gpt-4o-mini"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openai_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openai_llm.OpenAIClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_openai_llm_client(mock_config)
|
||||
|
||||
# Verify LLMConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "sk-test-key-123"
|
||||
assert call_kwargs["model"] == "gpt-4o-mini"
|
||||
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
Unit tests for OpenRouter LLM provider.
|
||||
|
||||
Tests cover:
|
||||
- create_openrouter_llm_client factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.llm_providers.openrouter_llm import (
|
||||
create_openrouter_llm_client,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_openrouter_llm_client
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateOpenRouterLLMClient:
|
||||
"""Test create_openrouter_llm_client factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.openrouter_api_key = "sk-or-test-key"
|
||||
config.openrouter_llm_model = "anthropic/claude-sonnet-4"
|
||||
config.openrouter_base_url = "https://openrouter.ai/api/v1"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openrouter_llm_client_success(self, mock_config):
|
||||
"""Test create_openrouter_llm_client returns client with valid config."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"graphiti_core.llm_client.openai_client.OpenAIClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = create_openrouter_llm_client(mock_config)
|
||||
assert result == mock_client
|
||||
|
||||
def test_create_openrouter_llm_client_missing_api_key(self, mock_config):
|
||||
"""Test create_openrouter_llm_client raises ProviderError for missing API key."""
|
||||
mock_config.openrouter_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_openrouter_llm_client(mock_config)
|
||||
|
||||
assert "OPENROUTER_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_openrouter_llm_client_import_error(self, mock_config):
|
||||
"""Test create_openrouter_llm_client raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name.startswith("graphiti_core.llm_client"):
|
||||
raise ImportError("graphiti-core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_openrouter_llm_client(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openrouter_llm_client_passes_config_correctly(self, mock_config):
|
||||
"""Test create_openrouter_llm_client passes config values correctly."""
|
||||
mock_config.openrouter_api_key = "sk-or-test-key-123"
|
||||
mock_config.openrouter_llm_model = "openai/gpt-4o"
|
||||
mock_config.openrouter_base_url = "https://custom.openrouter.ai/api/v1"
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openrouter_llm.LLMConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openrouter_llm.OpenAIClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
create_openrouter_llm_client(mock_config)
|
||||
|
||||
# Verify LLMConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "sk-or-test-key-123"
|
||||
assert call_kwargs["model"] == "openai/gpt-4o"
|
||||
assert call_kwargs["base_url"] == "https://custom.openrouter.ai/api/v1"
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openrouter_llm_client_disables_reasoning(self, mock_config):
|
||||
"""Test create_openrouter_llm_client disables reasoning/verbosity for compatibility."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.llm_providers.openrouter_llm.OpenAIClient",
|
||||
return_value=mock_client,
|
||||
) as mock_openai_client:
|
||||
create_openrouter_llm_client(mock_config)
|
||||
|
||||
# OpenRouter should have reasoning=None, verbosity=None for compatibility
|
||||
call_kwargs = mock_openai_client.call_args.kwargs
|
||||
assert call_kwargs.get("reasoning") is None
|
||||
assert call_kwargs.get("verbosity") is None
|
||||
@@ -1,246 +0,0 @@
|
||||
"""
|
||||
Tests for integrations.graphiti.providers module.
|
||||
|
||||
Tests cover:
|
||||
- All re-exported items are accessible
|
||||
- __all__ exports match documentation
|
||||
- Module has proper docstring
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestProvidersModuleReExports:
|
||||
"""Test that all items are properly re-exported from graphiti_providers."""
|
||||
|
||||
def test_import_provider_error(self):
|
||||
"""Test ProviderError is re-exported."""
|
||||
from integrations.graphiti.providers import ProviderError
|
||||
|
||||
assert ProviderError is not None
|
||||
assert Exception in ProviderError.__mro__
|
||||
|
||||
def test_import_provider_not_installed(self):
|
||||
"""Test ProviderNotInstalled is re-exported."""
|
||||
from integrations.graphiti.providers import ProviderNotInstalled
|
||||
|
||||
assert ProviderNotInstalled is not None
|
||||
assert Exception in ProviderNotInstalled.__mro__
|
||||
|
||||
def test_import_create_llm_client(self):
|
||||
"""Test create_llm_client is re-exported."""
|
||||
from integrations.graphiti.providers import create_llm_client
|
||||
|
||||
assert create_llm_client is not None
|
||||
assert callable(create_llm_client)
|
||||
|
||||
def test_import_create_embedder(self):
|
||||
"""Test create_embedder is re-exported."""
|
||||
from integrations.graphiti.providers import create_embedder
|
||||
|
||||
assert create_embedder is not None
|
||||
assert callable(create_embedder)
|
||||
|
||||
def test_import_create_cross_encoder(self):
|
||||
"""Test create_cross_encoder is re-exported."""
|
||||
from integrations.graphiti.providers import create_cross_encoder
|
||||
|
||||
assert create_cross_encoder is not None
|
||||
assert callable(create_cross_encoder)
|
||||
|
||||
def test_import_embedding_dimensions(self):
|
||||
"""Test EMBEDDING_DIMENSIONS is re-exported."""
|
||||
from integrations.graphiti.providers import EMBEDDING_DIMENSIONS
|
||||
|
||||
assert EMBEDDING_DIMENSIONS is not None
|
||||
assert isinstance(EMBEDDING_DIMENSIONS, dict)
|
||||
|
||||
def test_import_get_expected_embedding_dim(self):
|
||||
"""Test get_expected_embedding_dim is re-exported."""
|
||||
from integrations.graphiti.providers import get_expected_embedding_dim
|
||||
|
||||
assert get_expected_embedding_dim is not None
|
||||
assert callable(get_expected_embedding_dim)
|
||||
|
||||
def test_import_validate_embedding_config(self):
|
||||
"""Test validate_embedding_config is re-exported."""
|
||||
from integrations.graphiti.providers import validate_embedding_config
|
||||
|
||||
assert validate_embedding_config is not None
|
||||
assert callable(validate_embedding_config)
|
||||
|
||||
def test_import_test_llm_connection(self):
|
||||
"""Test test_llm_connection is re-exported."""
|
||||
from integrations.graphiti.providers import test_llm_connection
|
||||
|
||||
assert test_llm_connection is not None
|
||||
assert callable(test_llm_connection)
|
||||
|
||||
def test_import_test_embedder_connection(self):
|
||||
"""Test test_embedder_connection is re-exported."""
|
||||
from integrations.graphiti.providers import test_embedder_connection
|
||||
|
||||
assert test_embedder_connection is not None
|
||||
assert callable(test_embedder_connection)
|
||||
|
||||
def test_import_test_ollama_connection(self):
|
||||
"""Test test_ollama_connection is re-exported."""
|
||||
from integrations.graphiti.providers import test_ollama_connection
|
||||
|
||||
assert test_ollama_connection is not None
|
||||
assert callable(test_ollama_connection)
|
||||
|
||||
def test_import_is_graphiti_enabled(self):
|
||||
"""Test is_graphiti_enabled is re-exported."""
|
||||
from integrations.graphiti.providers import is_graphiti_enabled
|
||||
|
||||
assert is_graphiti_enabled is not None
|
||||
assert callable(is_graphiti_enabled)
|
||||
|
||||
def test_import_get_graph_hints(self):
|
||||
"""Test get_graph_hints is re-exported."""
|
||||
from integrations.graphiti.providers import get_graph_hints
|
||||
|
||||
assert get_graph_hints is not None
|
||||
assert callable(get_graph_hints)
|
||||
|
||||
|
||||
class TestProvidersModuleAll:
|
||||
"""Test __all__ exports match documented exports."""
|
||||
|
||||
def test___all___contains_all_exports(self):
|
||||
"""Test __all__ contains all expected exports."""
|
||||
import integrations.graphiti.providers as providers_module
|
||||
|
||||
expected_all = [
|
||||
# Exceptions
|
||||
"ProviderError",
|
||||
"ProviderNotInstalled",
|
||||
# Factory functions
|
||||
"create_llm_client",
|
||||
"create_embedder",
|
||||
"create_cross_encoder",
|
||||
# Models
|
||||
"EMBEDDING_DIMENSIONS",
|
||||
"get_expected_embedding_dim",
|
||||
# Validators
|
||||
"validate_embedding_config",
|
||||
"test_llm_connection",
|
||||
"test_embedder_connection",
|
||||
"test_ollama_connection",
|
||||
# Utilities
|
||||
"is_graphiti_enabled",
|
||||
"get_graph_hints",
|
||||
]
|
||||
|
||||
assert providers_module.__all__ == expected_all
|
||||
|
||||
def test_import_star_includes_all_exports(self):
|
||||
"""Test 'from integrations.graphiti.providers import *' works."""
|
||||
namespace = {}
|
||||
exec("from integrations.graphiti.providers import *", namespace)
|
||||
|
||||
# Verify all __all__ items are in the namespace
|
||||
import integrations.graphiti.providers as providers_module
|
||||
|
||||
for item in providers_module.__all__:
|
||||
assert item in namespace, f"{item} not found in namespace"
|
||||
|
||||
def test_all_exports_are_accessible(self):
|
||||
"""Test all items in __all__ are accessible."""
|
||||
import integrations.graphiti.providers as providers_module
|
||||
|
||||
for item in providers_module.__all__:
|
||||
assert hasattr(providers_module, item), f"{item} not accessible"
|
||||
|
||||
|
||||
class TestProvidersModuleDocumentation:
|
||||
"""Test module documentation."""
|
||||
|
||||
def test_module_has_docstring(self):
|
||||
"""Test the module has a docstring."""
|
||||
import integrations.graphiti.providers as providers_module
|
||||
|
||||
assert providers_module.__doc__ is not None
|
||||
assert len(providers_module.__doc__) > 0
|
||||
|
||||
def test_docstring_contains_key_terms(self):
|
||||
"""Test the docstring contains key terms."""
|
||||
import integrations.graphiti.providers as providers_module
|
||||
|
||||
docstring = providers_module.__doc__.lower()
|
||||
assert "provider" in docstring
|
||||
assert "graphiti" in docstring
|
||||
|
||||
|
||||
class TestProvidersModuleReExportBehavior:
|
||||
"""Test re-export behavior matches the source module."""
|
||||
|
||||
def test_create_llm_client_matches_source(self):
|
||||
"""Test create_llm_client is the same as the source."""
|
||||
from graphiti_providers import create_llm_client as source
|
||||
from integrations.graphiti.providers import create_llm_client as re_export
|
||||
|
||||
assert re_export is source
|
||||
|
||||
def test_create_embedder_matches_source(self):
|
||||
"""Test create_embedder is the same as the source."""
|
||||
from graphiti_providers import create_embedder as source
|
||||
from integrations.graphiti.providers import create_embedder as re_export
|
||||
|
||||
assert re_export is source
|
||||
|
||||
def test_exceptions_match_source(self):
|
||||
"""Test exceptions are the same as the source."""
|
||||
from graphiti_providers import ProviderError as source_error
|
||||
from graphiti_providers import ProviderNotInstalled as source_not_installed
|
||||
from integrations.graphiti.providers import (
|
||||
ProviderError as re_export_error,
|
||||
)
|
||||
from integrations.graphiti.providers import (
|
||||
ProviderNotInstalled as re_export_not_installed,
|
||||
)
|
||||
|
||||
assert re_export_error is source_error
|
||||
assert re_export_not_installed is source_not_installed
|
||||
|
||||
def test_embedding_dimensions_matches_source(self):
|
||||
"""Test EMBEDDING_DIMENSIONS is the same as the source."""
|
||||
from graphiti_providers import EMBEDDING_DIMENSIONS as source
|
||||
from integrations.graphiti.providers import EMBEDDING_DIMENSIONS as re_export
|
||||
|
||||
assert re_export is source
|
||||
|
||||
|
||||
class TestProvidersModuleIntegration:
|
||||
"""Integration tests for the providers module."""
|
||||
|
||||
def test_module_can_be_imported_multiple_times(self):
|
||||
"""Test the module can be imported multiple times without issues."""
|
||||
import importlib
|
||||
|
||||
import integrations.graphiti.providers
|
||||
|
||||
importlib.reload(integrations.graphiti.providers)
|
||||
|
||||
# Should still work
|
||||
from integrations.graphiti.providers import create_llm_client
|
||||
|
||||
assert create_llm_client is not None
|
||||
|
||||
def test_concurrent_imports(self):
|
||||
"""Test concurrent imports don't cause issues."""
|
||||
import concurrent.futures
|
||||
|
||||
def import_module():
|
||||
from integrations.graphiti.providers import create_llm_client
|
||||
|
||||
return create_llm_client
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = [executor.submit(import_module) for _ in range(5)]
|
||||
results = [f.result() for f in concurrent.futures.as_completed(futures)]
|
||||
|
||||
# All should succeed
|
||||
assert len(results) == 5
|
||||
assert all(r is not None for r in results)
|
||||
@@ -1,285 +0,0 @@
|
||||
"""
|
||||
Unit tests for Ollama embedder provider.
|
||||
|
||||
Tests cover:
|
||||
- get_embedding_dim_for_model helper function
|
||||
- create_ollama_embedder factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder import (
|
||||
KNOWN_OLLAMA_EMBEDDING_MODELS,
|
||||
create_ollama_embedder,
|
||||
get_embedding_dim_for_model,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test get_embedding_dim_for_model
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGetEmbeddingDimForModel:
|
||||
"""Test get_embedding_dim_for_model helper function."""
|
||||
|
||||
def test_get_embedding_dim_for_model_exact_match(self):
|
||||
"""Test get_embedding_dim_for_model with exact model match."""
|
||||
result = get_embedding_dim_for_model("nomic-embed-text")
|
||||
assert result == 768
|
||||
|
||||
def test_get_embedding_dim_for_model_with_tag(self):
|
||||
"""Test get_embedding_dim_for_model with tagged model."""
|
||||
result = get_embedding_dim_for_model("qwen3-embedding:8b")
|
||||
assert result == 4096
|
||||
|
||||
def test_get_embedding_dim_for_model_base_name_fallback(self):
|
||||
"""Test get_embedding_dim_for_model falls back to base name."""
|
||||
result = get_embedding_dim_for_model("nomic-embed-text:custom-tag")
|
||||
assert result == 768 # Should use base model dimension
|
||||
|
||||
def test_get_embedding_dim_for_model_configured_dim_override(self):
|
||||
"""Test get_embedding_dim_for_model with configured dimension override."""
|
||||
result = get_embedding_dim_for_model("unknown-model", configured_dim=512)
|
||||
assert result == 512
|
||||
|
||||
def test_get_embedding_dim_for_model_unknown_model(self):
|
||||
"""Test get_embedding_dim_for_model raises ProviderError for unknown model."""
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
get_embedding_dim_for_model("totally-unknown-model")
|
||||
|
||||
assert "Unknown Ollama embedding model" in str(exc_info.value)
|
||||
assert "totally-unknown-model" in str(exc_info.value)
|
||||
assert "OLLAMA_EMBEDDING_DIM" in str(exc_info.value)
|
||||
|
||||
def test_get_embedding_dim_for_model_configured_dim_zero(self):
|
||||
"""Test get_embedding_dim_for_model ignores zero configured dimension."""
|
||||
# When configured_dim is 0, should use known model dimension
|
||||
result = get_embedding_dim_for_model("nomic-embed-text", configured_dim=0)
|
||||
assert result == 768
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test KNOWN_OLLAMA_EMBEDDING_MODELS constant
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestKnownOllamaEmbeddingModels:
|
||||
"""Test KNOWN_OLLAMA_EMBEDDING_MODELS constant."""
|
||||
|
||||
def test_known_models_contains_expected_entries(self):
|
||||
"""Test KNOWN_OLLAMA_EMBEDDING_MODELS has expected models."""
|
||||
expected_models = [
|
||||
"embeddinggemma",
|
||||
"qwen3-embedding",
|
||||
"nomic-embed-text",
|
||||
"mxbai-embed-large",
|
||||
"bge-large",
|
||||
"all-minilm",
|
||||
]
|
||||
|
||||
for model in expected_models:
|
||||
# Check if base model exists (without tag)
|
||||
base_found = any(
|
||||
key.startswith(model) for key in KNOWN_OLLAMA_EMBEDDING_MODELS.keys()
|
||||
)
|
||||
assert base_found, (
|
||||
f"Model {model} not found in KNOWN_OLLAMA_EMBEDDING_MODELS"
|
||||
)
|
||||
|
||||
def test_known_models_dimensions_are_positive(self):
|
||||
"""Test all dimensions in KNOWN_OLLAMA_EMBEDDING_MODELS are positive integers."""
|
||||
for model, dimension in KNOWN_OLLAMA_EMBEDDING_MODELS.items():
|
||||
assert isinstance(dimension, int), f"Dimension for {model} is not int"
|
||||
assert dimension > 0, f"Dimension for {model} is not positive: {dimension}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test create_ollama_embedder
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateOllamaEmbedder:
|
||||
"""Test create_ollama_embedder factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.ollama_embedding_model = "nomic-embed-text"
|
||||
config.ollama_embedding_dim = None
|
||||
config.ollama_base_url = "http://localhost:11434"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_embedder_success(self, mock_config):
|
||||
"""Test create_ollama_embedder returns embedder with valid config."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
result = create_ollama_embedder(mock_config)
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_ollama_embedder_success_fast(self, mock_config):
|
||||
"""Fast test for create_ollama_embedder success path."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
# Set embedding_dim to 0 to allow auto-detection
|
||||
mock_config.ollama_embedding_dim = 0
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.embedder": MagicMock(),
|
||||
"graphiti_core.embedder.openai": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.embedder.openai import OpenAIEmbedder
|
||||
|
||||
OpenAIEmbedder.return_value = mock_embedder
|
||||
|
||||
result = create_ollama_embedder(mock_config)
|
||||
|
||||
# Verify the embedder was created and returned
|
||||
OpenAIEmbedder.assert_called_once()
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_ollama_embedder_missing_model(self, mock_config):
|
||||
"""Test create_ollama_embedder raises ProviderError for missing model."""
|
||||
mock_config.ollama_embedding_model = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_ollama_embedder(mock_config)
|
||||
|
||||
assert "OLLAMA_EMBEDDING_MODEL" in str(exc_info.value)
|
||||
|
||||
def test_create_ollama_embedder_import_error(self, mock_config):
|
||||
"""Test create_ollama_embedder raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
# Only block the specific import that create_ollama_embedder uses
|
||||
if name == "graphiti_core.embedder.openai" or name.startswith(
|
||||
"graphiti_core.embedder.openai."
|
||||
):
|
||||
raise ImportError("graphiti-core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_ollama_embedder(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_embedder_base_url_without_v1(self, mock_config):
|
||||
"""Test create_ollama_embedder appends /v1 to base URL if missing."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434"
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedderConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_ollama_embedder(mock_config)
|
||||
|
||||
# Verify base_url has /v1 appended
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_embedder_base_url_with_v1(self, mock_config):
|
||||
"""Test create_ollama_embedder doesn't duplicate /v1 in base URL."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434/v1"
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedderConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_ollama_embedder(mock_config)
|
||||
|
||||
# Verify base_url is not duplicated
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_embedder_base_url_with_trailing_slash(self, mock_config):
|
||||
"""Test create_ollama_embedder handles trailing slash correctly."""
|
||||
mock_config.ollama_base_url = "http://localhost:11434/"
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedderConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_ollama_embedder(mock_config)
|
||||
|
||||
# Verify trailing slash is handled
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["base_url"] == "http://localhost:11434/v1"
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_embedder_passes_config_correctly(self, mock_config):
|
||||
"""Test create_ollama_embedder passes config values correctly."""
|
||||
mock_config.ollama_embedding_model = "mxbai-embed-large"
|
||||
mock_config.ollama_embedding_dim = None
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedderConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_ollama_embedder(mock_config)
|
||||
|
||||
# Verify OpenAIEmbedderConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "ollama"
|
||||
assert call_kwargs["embedding_model"] == "mxbai-embed-large"
|
||||
assert (
|
||||
call_kwargs["embedding_dim"] == 1024
|
||||
) # Known dimension for mxbai-embed-large
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_ollama_embedder_with_configured_dimension(self, mock_config):
|
||||
"""Test create_ollama_embedder uses configured dimension when set."""
|
||||
mock_config.ollama_embedding_dim = 512
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedderConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.ollama_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_ollama_embedder(mock_config)
|
||||
|
||||
# Verify configured dimension is used
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["embedding_dim"] == 512
|
||||
@@ -1,117 +0,0 @@
|
||||
"""
|
||||
Unit tests for OpenAI embedder provider.
|
||||
|
||||
Tests cover:
|
||||
- create_openai_embedder factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.embedder_providers.openai_embedder import (
|
||||
create_openai_embedder,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_openai_embedder
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateOpenAIEmbedder:
|
||||
"""Test create_openai_embedder factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.openai_api_key = "sk-test-key"
|
||||
config.openai_embedding_model = "text-embedding-3-small"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openai_embedder_success(self, mock_config):
|
||||
"""Test create_openai_embedder returns embedder with valid config."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.openai_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
result = create_openai_embedder(mock_config)
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_openai_embedder_success_fast(self, mock_config):
|
||||
"""Fast test for create_openai_embedder success path."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.embedder": MagicMock(),
|
||||
"graphiti_core.embedder.openai": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.embedder.openai import OpenAIEmbedder
|
||||
|
||||
OpenAIEmbedder.return_value = mock_embedder
|
||||
|
||||
result = create_openai_embedder(mock_config)
|
||||
|
||||
# Verify the embedder was created and returned
|
||||
OpenAIEmbedder.assert_called_once()
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_openai_embedder_missing_api_key(self, mock_config):
|
||||
"""Test create_openai_embedder raises ProviderError for missing API key."""
|
||||
mock_config.openai_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_openai_embedder(mock_config)
|
||||
|
||||
assert "OPENAI_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_openai_embedder_import_error(self, mock_config):
|
||||
"""Test create_openai_embedder raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name.startswith("graphiti_core.embedder"):
|
||||
raise ImportError("graphiti-core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_openai_embedder(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openai_embedder_passes_config_correctly(self, mock_config):
|
||||
"""Test create_openai_embedder passes config values correctly."""
|
||||
mock_config.openai_api_key = "sk-test-key-123"
|
||||
mock_config.openai_embedding_model = "text-embedding-3-large"
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.openai_embedder.OpenAIEmbedderConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.openai_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_openai_embedder(mock_config)
|
||||
|
||||
# Verify OpenAIEmbedderConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "sk-test-key-123"
|
||||
assert call_kwargs["embedding_model"] == "text-embedding-3-large"
|
||||
@@ -1,129 +0,0 @@
|
||||
"""
|
||||
Unit tests for OpenRouter embedder provider.
|
||||
|
||||
Tests cover:
|
||||
- create_openrouter_embedder factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.embedder_providers.openrouter_embedder import (
|
||||
create_openrouter_embedder,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_openrouter_embedder
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateOpenRouterEmbedder:
|
||||
"""Test create_openrouter_embedder factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.openrouter_api_key = "sk-or-test-key"
|
||||
config.openrouter_embedding_model = "openai/text-embedding-3-small"
|
||||
config.openrouter_base_url = "https://openrouter.ai/api/v1"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openrouter_embedder_success(self, mock_config):
|
||||
"""Test create_openrouter_embedder returns embedder with valid config."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.openrouter_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
result = create_openrouter_embedder(mock_config)
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_openrouter_embedder_success_fast(self, mock_config):
|
||||
"""Fast test for create_openrouter_embedder success path."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.embedder": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.embedder import OpenAIEmbedder
|
||||
|
||||
OpenAIEmbedder.return_value = mock_embedder
|
||||
|
||||
result = create_openrouter_embedder(mock_config)
|
||||
|
||||
# Verify the embedder was created and returned
|
||||
OpenAIEmbedder.assert_called_once()
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_openrouter_embedder_missing_api_key(self, mock_config):
|
||||
"""Test create_openrouter_embedder raises ProviderError for missing API key."""
|
||||
|
||||
mock_graphiti_core_embedder = MagicMock()
|
||||
mock_graphiti_core_embedder.EmbedderConfig = MagicMock
|
||||
mock_graphiti_core_embedder.OpenAIEmbedder = MagicMock
|
||||
|
||||
# Mock the graphiti_core.embedder module to allow import to succeed
|
||||
with patch.dict(
|
||||
sys.modules, {"graphiti_core.embedder": mock_graphiti_core_embedder}
|
||||
):
|
||||
mock_config.openrouter_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_openrouter_embedder(mock_config)
|
||||
|
||||
assert "OPENROUTER_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_openrouter_embedder_import_error(self, mock_config):
|
||||
"""Test create_openrouter_embedder raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name.startswith("graphiti_core.embedder"):
|
||||
raise ImportError("graphiti-core not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_openrouter_embedder(mock_config)
|
||||
|
||||
assert "graphiti-core" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_openrouter_embedder_passes_config_correctly(self, mock_config):
|
||||
"""Test create_openrouter_embedder passes config values correctly."""
|
||||
mock_config.openrouter_api_key = "sk-or-test-key-123"
|
||||
mock_config.openrouter_embedding_model = "voyage/voyage-3"
|
||||
mock_config.openrouter_base_url = "https://custom.openrouter.ai/api/v1"
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.openrouter_embedder.EmbedderConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"integrations.graphiti.providers_pkg.embedder_providers.openrouter_embedder.OpenAIEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_openrouter_embedder(mock_config)
|
||||
|
||||
# Verify EmbedderConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "sk-or-test-key-123"
|
||||
assert call_kwargs["model"] == "voyage/voyage-3"
|
||||
assert call_kwargs["base_url"] == "https://custom.openrouter.ai/api/v1"
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
Unit tests for Voyage AI embedder provider.
|
||||
|
||||
Tests cover:
|
||||
- create_voyage_embedder factory function
|
||||
- ProviderNotInstalled exception handling
|
||||
- ProviderError for missing configuration
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.providers_pkg.embedder_providers.voyage_embedder import (
|
||||
create_voyage_embedder,
|
||||
)
|
||||
from integrations.graphiti.providers_pkg.exceptions import (
|
||||
ProviderError,
|
||||
ProviderNotInstalled,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Test create_voyage_embedder
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCreateVoyageEmbedder:
|
||||
"""Test create_voyage_embedder factory function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock GraphitiConfig."""
|
||||
config = MagicMock()
|
||||
config.voyage_api_key = "test-voyage-key"
|
||||
config.voyage_embedding_model = "voyage-3"
|
||||
return config
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_voyage_embedder_success(self, mock_config):
|
||||
"""Test create_voyage_embedder returns embedder with valid config."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"graphiti_core.embedder.voyage.VoyageEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
result = create_voyage_embedder(mock_config)
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_voyage_embedder_success_fast(self, mock_config):
|
||||
"""Fast test for create_voyage_embedder success path."""
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
# Mock the graphiti_core imports
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"graphiti_core": MagicMock(),
|
||||
"graphiti_core.embedder": MagicMock(),
|
||||
"graphiti_core.embedder.voyage": MagicMock(),
|
||||
},
|
||||
):
|
||||
from graphiti_core.embedder.voyage import VoyageEmbedder
|
||||
|
||||
VoyageEmbedder.return_value = mock_embedder
|
||||
|
||||
result = create_voyage_embedder(mock_config)
|
||||
|
||||
# Verify the embedder was created and returned
|
||||
VoyageEmbedder.assert_called_once()
|
||||
assert result == mock_embedder
|
||||
|
||||
def test_create_voyage_embedder_missing_api_key(self, mock_config):
|
||||
"""Test create_voyage_embedder raises ProviderError for missing API key."""
|
||||
|
||||
mock_voyage = MagicMock()
|
||||
mock_voyage.VoyageAIConfig = MagicMock()
|
||||
mock_voyage.VoyageEmbedder = MagicMock()
|
||||
|
||||
# Clear sys.modules cache to ensure fresh import
|
||||
sys.modules.pop("graphiti_core.embedder.voyage", None)
|
||||
|
||||
# Mock the voyage module to allow import to succeed
|
||||
with patch.dict(sys.modules, {"graphiti_core.embedder.voyage": mock_voyage}):
|
||||
mock_config.voyage_api_key = None
|
||||
|
||||
with pytest.raises(ProviderError) as exc_info:
|
||||
create_voyage_embedder(mock_config)
|
||||
|
||||
assert "VOYAGE_API_KEY" in str(exc_info.value)
|
||||
|
||||
def test_create_voyage_embedder_import_error(self, mock_config):
|
||||
"""Test create_voyage_embedder raises ProviderNotInstalled on ImportError."""
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name.startswith("graphiti_core.embedder.voyage"):
|
||||
raise ImportError("graphiti-core[voyage] not installed")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
with pytest.raises(ProviderNotInstalled) as exc_info:
|
||||
create_voyage_embedder(mock_config)
|
||||
|
||||
assert "graphiti-core[voyage]" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_create_voyage_embedder_passes_config_correctly(self, mock_config):
|
||||
"""Test create_voyage_embedder passes config values correctly."""
|
||||
mock_config.voyage_api_key = "test-voyage-key-123"
|
||||
mock_config.voyage_embedding_model = "voyage-3-lite"
|
||||
mock_embedder = MagicMock()
|
||||
|
||||
with patch(
|
||||
"graphiti_core.embedder.voyage.VoyageAIConfig",
|
||||
) as mock_config_class:
|
||||
with patch(
|
||||
"graphiti_core.embedder.voyage.VoyageEmbedder",
|
||||
return_value=mock_embedder,
|
||||
):
|
||||
create_voyage_embedder(mock_config)
|
||||
|
||||
# Verify VoyageAIConfig was called with correct arguments
|
||||
call_kwargs = mock_config_class.call_args.kwargs
|
||||
assert call_kwargs["api_key"] == "test-voyage-key-123"
|
||||
assert call_kwargs["embedding_model"] == "voyage-3-lite"
|
||||
@@ -1,783 +0,0 @@
|
||||
"""
|
||||
Tests for GraphitiQueries class.
|
||||
|
||||
Tests cover:
|
||||
- GraphitiQueries initialization
|
||||
- add_session_insight()
|
||||
- add_codebase_discoveries()
|
||||
- add_pattern()
|
||||
- add_gotcha()
|
||||
- add_task_outcome()
|
||||
- add_structured_insights()
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# =============================================================================
|
||||
# Mock External Dependencies
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_graphiti_core_nodes():
|
||||
"""Auto-mock graphiti_core for all tests."""
|
||||
import sys
|
||||
|
||||
# Patch graphiti_core at module level before import
|
||||
mock_graphiti_core = MagicMock()
|
||||
mock_nodes = MagicMock()
|
||||
mock_episode_type = MagicMock()
|
||||
mock_episode_type.text = "text"
|
||||
mock_nodes.EpisodeType = mock_episode_type
|
||||
mock_graphiti_core.nodes = mock_nodes
|
||||
|
||||
sys.modules["graphiti_core"] = mock_graphiti_core
|
||||
sys.modules["graphiti_core.nodes"] = mock_nodes
|
||||
|
||||
try:
|
||||
yield mock_episode_type
|
||||
finally:
|
||||
# Clean up - always run even if test fails
|
||||
sys.modules.pop("graphiti_core", None)
|
||||
sys.modules.pop("graphiti_core.nodes", None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Client and Queries Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock GraphitiClient."""
|
||||
client = MagicMock()
|
||||
client.graphiti = MagicMock()
|
||||
client.graphiti.add_episode = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queries(mock_client):
|
||||
"""Create a GraphitiQueries instance."""
|
||||
from integrations.graphiti.queries_pkg.queries import GraphitiQueries
|
||||
|
||||
return GraphitiQueries(
|
||||
client=mock_client,
|
||||
group_id="test_group",
|
||||
spec_context_id="test_spec",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Classes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGraphitiQueriesInit:
|
||||
"""Test GraphitiQueries initialization."""
|
||||
|
||||
def test_init_sets_attributes(self, mock_client):
|
||||
"""Test constructor sets all attributes correctly."""
|
||||
from integrations.graphiti.queries_pkg.queries import GraphitiQueries
|
||||
|
||||
queries = GraphitiQueries(
|
||||
client=mock_client,
|
||||
group_id="my_group",
|
||||
spec_context_id="my_spec",
|
||||
)
|
||||
|
||||
assert queries.client == mock_client
|
||||
assert queries.group_id == "my_group"
|
||||
assert queries.spec_context_id == "my_spec"
|
||||
|
||||
|
||||
class TestAddSessionInsight:
|
||||
"""Test add_session_insight method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_insight_success(self, queries):
|
||||
"""Test successful session insight save."""
|
||||
insights = {
|
||||
"subtasks_completed": ["task-1", "task-2"],
|
||||
"discoveries": {"files_understood": {}},
|
||||
"what_worked": ["Using pytest"],
|
||||
"what_failed": [],
|
||||
}
|
||||
|
||||
result = await queries.add_session_insight(session_num=1, insights=insights)
|
||||
|
||||
assert result is True
|
||||
queries.client.graphiti.add_episode.assert_called_once()
|
||||
|
||||
# Verify episode format
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
assert "session_001_test_spec" in call_args[1]["name"]
|
||||
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["type"] == "session_insight"
|
||||
assert episode_body["session_number"] == 1
|
||||
assert episode_body["spec_id"] == "test_spec"
|
||||
assert "subtasks_completed" in episode_body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_session_insight_exception(self, queries):
|
||||
"""Test exception handling in add_session_insight."""
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Database error")
|
||||
|
||||
result = await queries.add_session_insight(session_num=1, insights={})
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAddCodebaseDiscoveries:
|
||||
"""Test add_codebase_discoveries method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_codebase_discoveries_empty_dict(self, queries):
|
||||
"""Test empty discoveries returns True without calling add_episode."""
|
||||
result = await queries.add_codebase_discoveries({})
|
||||
|
||||
assert result is True
|
||||
queries.client.graphiti.add_episode.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_codebase_discoveries_success(self, queries):
|
||||
"""Test successful codebase discoveries save."""
|
||||
discoveries = {
|
||||
"src/main.py": "Entry point for the application",
|
||||
"src/config.py": "Configuration module",
|
||||
}
|
||||
|
||||
result = await queries.add_codebase_discoveries(discoveries)
|
||||
|
||||
assert result is True
|
||||
queries.client.graphiti.add_episode.assert_called_once()
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["type"] == "codebase_discovery"
|
||||
assert episode_body["files"] == discoveries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_codebase_discoveries_exception(self, queries):
|
||||
"""Test exception handling in add_codebase_discoveries."""
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Database error")
|
||||
|
||||
result = await queries.add_codebase_discoveries({"file.py": "desc"})
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAddPattern:
|
||||
"""Test add_pattern method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_pattern_success(self, queries):
|
||||
"""Test successful pattern save."""
|
||||
pattern = "Use dependency injection for database connections"
|
||||
|
||||
result = await queries.add_pattern(pattern)
|
||||
|
||||
assert result is True
|
||||
queries.client.graphiti.add_episode.assert_called_once()
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["type"] == "pattern"
|
||||
assert episode_body["pattern"] == pattern
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_pattern_exception(self, queries):
|
||||
"""Test exception handling in add_pattern."""
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Database error")
|
||||
|
||||
result = await queries.add_pattern("test pattern")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAddGotcha:
|
||||
"""Test add_gotcha method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_gotcha_success(self, queries):
|
||||
"""Test successful gotcha save."""
|
||||
gotcha = "Always close database connections in finally blocks"
|
||||
|
||||
result = await queries.add_gotcha(gotcha)
|
||||
|
||||
assert result is True
|
||||
queries.client.graphiti.add_episode.assert_called_once()
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["type"] == "gotcha"
|
||||
assert episode_body["gotcha"] == gotcha
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_gotcha_exception(self, queries):
|
||||
"""Test exception handling in add_gotcha."""
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Database error")
|
||||
|
||||
result = await queries.add_gotcha("test gotcha")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAddTaskOutcome:
|
||||
"""Test add_task_outcome method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_task_outcome_success(self, queries):
|
||||
"""Test successful task outcome save."""
|
||||
result = await queries.add_task_outcome(
|
||||
task_id="task-123",
|
||||
success=True,
|
||||
outcome="Implementation completed successfully",
|
||||
metadata={"duration": 120},
|
||||
)
|
||||
|
||||
assert result is True
|
||||
queries.client.graphiti.add_episode.assert_called_once()
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["type"] == "task_outcome"
|
||||
assert episode_body["task_id"] == "task-123"
|
||||
assert episode_body["success"] is True
|
||||
assert episode_body["outcome"] == "Implementation completed successfully"
|
||||
assert episode_body["duration"] == 120
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_task_outcome_without_metadata(self, queries):
|
||||
"""Test task outcome save without metadata."""
|
||||
result = await queries.add_task_outcome(
|
||||
task_id="task-456",
|
||||
success=False,
|
||||
outcome="Failed due to timeout",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["task_id"] == "task-456"
|
||||
assert episode_body["success"] is False
|
||||
assert episode_body["outcome"] == "Failed due to timeout"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_task_outcome_exception(self, queries):
|
||||
"""Test exception handling in add_task_outcome."""
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Database error")
|
||||
|
||||
result = await queries.add_task_outcome("task-1", True, "success")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAddStructuredInsights:
|
||||
"""Test add_structured_insights method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_empty_dict(self, queries):
|
||||
"""Test empty insights returns True."""
|
||||
result = await queries.add_structured_insights({})
|
||||
|
||||
assert result is True
|
||||
queries.client.graphiti.add_episode.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_with_file_insights(self, queries):
|
||||
"""Test structured insights with file insights."""
|
||||
insights = {
|
||||
"file_insights": [
|
||||
{
|
||||
"path": "src/main.py",
|
||||
"purpose": "Entry point",
|
||||
"changes_made": "Added error handling",
|
||||
"patterns_used": ["error boundaries"],
|
||||
"gotchas": ["needs timeout"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
assert queries.client.graphiti.add_episode.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_with_patterns(self, queries):
|
||||
"""Test structured insights with discovered patterns."""
|
||||
insights = {
|
||||
"patterns_discovered": [
|
||||
{
|
||||
"pattern": "Use factory pattern for object creation",
|
||||
"applies_to": "Complex object initialization",
|
||||
"example": "src/factory.py",
|
||||
},
|
||||
"Simple pattern string", # Test non-dict pattern
|
||||
]
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
assert queries.client.graphiti.add_episode.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_with_gotchas(self, queries):
|
||||
"""Test structured insights with discovered gotchas."""
|
||||
insights = {
|
||||
"gotchas_discovered": [
|
||||
{
|
||||
"gotcha": "Don't use mutable default arguments",
|
||||
"trigger": "Function definition with [] as default",
|
||||
"solution": "Use None and check in function body",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_with_outcome(self, queries):
|
||||
"""Test structured insights with approach outcome."""
|
||||
insights = {
|
||||
"subtask_id": "task-1",
|
||||
"approach_outcome": {
|
||||
"success": True,
|
||||
"approach_used": "Used Graphiti for memory",
|
||||
"why_it_worked": "Efficient semantic search",
|
||||
"alternatives_tried": ["PostgreSQL"],
|
||||
},
|
||||
"changed_files": ["src/memory.py"],
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_with_recommendations(self, queries):
|
||||
"""Test structured insights with recommendations."""
|
||||
insights = {
|
||||
"subtask_id": "task-2",
|
||||
"recommendations": [
|
||||
"Add error handling",
|
||||
"Improve test coverage",
|
||||
],
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_handles_duplicate_facts_error(self, queries):
|
||||
"""Test that duplicate_facts error is handled as non-fatal."""
|
||||
insights = {"file_insights": [{"path": "src/test.py", "purpose": "Test file"}]}
|
||||
|
||||
# First call fails with duplicate_facts, second succeeds
|
||||
queries.client.graphiti.add_episode.side_effect = [
|
||||
Exception("invalid duplicate_facts idx"),
|
||||
None, # Second call succeeds
|
||||
]
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_string_pattern(self, queries):
|
||||
"""Test string pattern (non-dict) handling."""
|
||||
insights = {"patterns_discovered": ["Simple string pattern"]}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["pattern"] == "Simple string pattern"
|
||||
assert episode_body["applies_to"] == ""
|
||||
assert episode_body["example"] == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_string_gotcha(self, queries):
|
||||
"""Test string gotcha (non-dict) handling."""
|
||||
insights = {"gotchas_discovered": ["Simple string gotcha"]}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["gotcha"] == "Simple string gotcha"
|
||||
assert episode_body["trigger"] == ""
|
||||
assert episode_body["solution"] == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_file_insight_with_all_fields(self, queries):
|
||||
"""Test file insight with all optional fields."""
|
||||
insights = {
|
||||
"file_insights": [
|
||||
{
|
||||
"path": "src/test.py",
|
||||
"purpose": "Test module",
|
||||
"changes_made": "Added new tests",
|
||||
"patterns_used": ["pattern1", "pattern2"],
|
||||
"gotchas": ["gotcha1", "gotcha2"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["file_path"] == "src/test.py"
|
||||
assert episode_body["purpose"] == "Test module"
|
||||
assert episode_body["changes_made"] == "Added new tests"
|
||||
assert episode_body["patterns_used"] == ["pattern1", "pattern2"]
|
||||
assert episode_body["gotchas"] == ["gotcha1", "gotcha2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_gotcha_non_duplicate_exception(
|
||||
self, queries
|
||||
):
|
||||
"""Test gotcha save with non-duplicate_facts exception."""
|
||||
insights = {"gotchas_discovered": [{"gotcha": "Test gotcha"}]}
|
||||
|
||||
# Raise non-duplicate error
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Other error")
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return False since all saves failed
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_gotcha_duplicate_facts_exception(
|
||||
self, queries
|
||||
):
|
||||
"""Test gotcha save with duplicate_facts exception (lines 418-419)."""
|
||||
insights = {"gotchas_discovered": [{"gotcha": "Test gotcha"}]}
|
||||
|
||||
# Raise duplicate_facts error (should be counted as success)
|
||||
queries.client.graphiti.add_episode.side_effect = Exception(
|
||||
"invalid duplicate_facts idx"
|
||||
)
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return True because duplicate_facts is non-fatal
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_outcome_non_duplicate_exception(
|
||||
self, queries
|
||||
):
|
||||
"""Test outcome save with non-duplicate_facts exception."""
|
||||
insights = {
|
||||
"subtask_id": "task-1",
|
||||
"approach_outcome": {"success": True, "approach_used": "Test approach"},
|
||||
}
|
||||
|
||||
# Raise non-duplicate error
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Other error")
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return False since all saves failed
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_outcome_duplicate_facts_exception(
|
||||
self, queries
|
||||
):
|
||||
"""Test outcome save with duplicate_facts exception (lines 457-458)."""
|
||||
insights = {
|
||||
"subtask_id": "task-1",
|
||||
"approach_outcome": {"success": True, "approach_used": "Test approach"},
|
||||
}
|
||||
|
||||
# Raise duplicate_facts error (should be counted as success)
|
||||
queries.client.graphiti.add_episode.side_effect = Exception(
|
||||
"invalid duplicate_facts idx"
|
||||
)
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return True because duplicate_facts is non-fatal
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_recommendations_non_duplicate_exception(
|
||||
self, queries
|
||||
):
|
||||
"""Test recommendations save with non-duplicate_facts exception."""
|
||||
insights = {"subtask_id": "task-1", "recommendations": ["Test recommendation"]}
|
||||
|
||||
# Raise non-duplicate error
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Other error")
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return False since all saves failed
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_recommendations_duplicate_facts_exception(
|
||||
self, queries
|
||||
):
|
||||
"""Test recommendations save with duplicate_facts exception (lines 488-489)."""
|
||||
insights = {"subtask_id": "task-1", "recommendations": ["Test recommendation"]}
|
||||
|
||||
# Raise duplicate_facts error (should be counted as success)
|
||||
queries.client.graphiti.add_episode.side_effect = Exception(
|
||||
"invalid duplicate_facts idx"
|
||||
)
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return True because duplicate_facts is non-fatal
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_top_level_exception_with_content(
|
||||
self, queries
|
||||
):
|
||||
"""Test top-level exception with insights content."""
|
||||
insights = {
|
||||
"file_insights": [{"path": "test.py", "purpose": "test"}],
|
||||
"patterns_discovered": [{"pattern": "test pattern"}],
|
||||
"gotchas_discovered": [{"gotcha": "test gotcha"}],
|
||||
"approach_outcome": {"success": True},
|
||||
"recommendations": ["test recommendation"],
|
||||
}
|
||||
|
||||
# Mock exception during processing
|
||||
with patch(
|
||||
"integrations.graphiti.queries_pkg.queries.json.dumps",
|
||||
side_effect=Exception("JSON error"),
|
||||
):
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_outer_exception_handler(self, queries):
|
||||
"""Test outer exception handler for add_structured_insights (lines 499-523)."""
|
||||
insights = {
|
||||
"file_insights": [{"path": "test.py", "purpose": "test"}],
|
||||
"patterns_discovered": [{"pattern": "Test pattern"}],
|
||||
"gotchas_discovered": [{"gotcha": "Test gotcha"}],
|
||||
"approach_outcome": {"success": True, "approach_used": "Test approach"},
|
||||
"recommendations": ["Test recommendation"],
|
||||
}
|
||||
|
||||
# Mock EpisodeType import to fail, triggering outer exception handler
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "graphiti_core.nodes":
|
||||
raise ImportError("EpisodeType not available")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return False and trigger outer exception handler
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_all_fail(self, queries):
|
||||
"""Test when all episode saves fail."""
|
||||
insights = {"file_insights": [{"path": "test.py", "purpose": "test"}]}
|
||||
|
||||
queries.client.graphiti.add_episode.side_effect = Exception("Total failure")
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAddStructuredInsightsExceptionHandling:
|
||||
"""Test add_structured_insights exception handling branches."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"insights_key,insights_value",
|
||||
[
|
||||
("patterns_discovered", [{"pattern": "Test pattern"}]),
|
||||
("gotchas_discovered", [{"gotcha": "Test gotcha"}]),
|
||||
(
|
||||
"approach_outcome",
|
||||
{
|
||||
"subtask_id": "task-1",
|
||||
"success": True,
|
||||
"approach_used": "Test approach",
|
||||
},
|
||||
),
|
||||
(
|
||||
"recommendations",
|
||||
{"subtask_id": "task-1", "recommendations": ["Test recommendation"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_add_structured_insights_non_duplicate_exception(
|
||||
self, queries, insights_key, insights_value
|
||||
):
|
||||
"""Test exception handling for non-duplicate errors across different insight types."""
|
||||
insights = {insights_key: insights_value}
|
||||
|
||||
queries.client.graphiti.add_episode.side_effect = Exception(
|
||||
"Non-duplicate error"
|
||||
)
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_top_level_exception(self, queries):
|
||||
"""Test top-level exception handling in add_structured_insights."""
|
||||
insights = {"file_insights": [{"path": "test.py", "purpose": "test"}]}
|
||||
|
||||
# Simulate exception during JSON serialization
|
||||
with patch(
|
||||
"integrations.graphiti.queries_pkg.queries.json.dumps",
|
||||
side_effect=Exception("JSON error"),
|
||||
):
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_mixed_success_failure(self, queries):
|
||||
"""Test mixed success and failure in structured insights."""
|
||||
insights = {
|
||||
"file_insights": [
|
||||
{"path": "test1.py", "purpose": "test1"},
|
||||
{"path": "test2.py", "purpose": "test2"},
|
||||
]
|
||||
}
|
||||
|
||||
# First succeeds, second fails with non-duplicate error
|
||||
queries.client.graphiti.add_episode.side_effect = [
|
||||
None, # First succeeds
|
||||
Exception("Non-duplicate error"), # Second fails
|
||||
]
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return True because at least one succeeded
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_all_patterns_fail_with_duplicate(
|
||||
self, queries
|
||||
):
|
||||
"""Test all pattern saves fail with duplicate_facts error."""
|
||||
insights = {
|
||||
"patterns_discovered": [{"pattern": "Pattern 1"}, {"pattern": "Pattern 2"}]
|
||||
}
|
||||
|
||||
# Both fail with duplicate_facts error (should be counted as success)
|
||||
queries.client.graphiti.add_episode.side_effect = [
|
||||
Exception("invalid duplicate_facts idx"),
|
||||
Exception("invalid duplicate_facts idx"),
|
||||
]
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
# Should return True because duplicate_facts is non-fatal
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_dict_pattern_with_all_fields(self, queries):
|
||||
"""Test dict pattern with applies_to and example fields."""
|
||||
insights = {
|
||||
"patterns_discovered": [
|
||||
{
|
||||
"pattern": "Factory pattern",
|
||||
"applies_to": "Object creation",
|
||||
"example": "src/factory.py",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
assert queries.client.graphiti.add_episode.call_count == 1
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["pattern"] == "Factory pattern"
|
||||
assert episode_body["applies_to"] == "Object creation"
|
||||
assert episode_body["example"] == "src/factory.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_dict_gotcha_with_all_fields(self, queries):
|
||||
"""Test dict gotcha with trigger and solution fields."""
|
||||
insights = {
|
||||
"gotchas_discovered": [
|
||||
{
|
||||
"gotcha": "Mutable default args",
|
||||
"trigger": "Function with [] as default",
|
||||
"solution": "Use None and check in body",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["gotcha"] == "Mutable default args"
|
||||
assert episode_body["trigger"] == "Function with [] as default"
|
||||
assert episode_body["solution"] == "Use None and check in body"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_structured_insights_outcome_with_all_fields(self, queries):
|
||||
"""Test outcome with all optional fields."""
|
||||
insights = {
|
||||
"subtask_id": "task-1",
|
||||
"approach_outcome": {
|
||||
"success": True,
|
||||
"approach_used": "Test approach",
|
||||
"why_it_worked": "Because reasons",
|
||||
"why_it_failed": None,
|
||||
"alternatives_tried": ["Alt1", "Alt2"],
|
||||
},
|
||||
"changed_files": ["file1.py", "file2.py"],
|
||||
}
|
||||
|
||||
result = await queries.add_structured_insights(insights)
|
||||
|
||||
assert result is True
|
||||
|
||||
call_args = queries.client.graphiti.add_episode.call_args
|
||||
episode_body = json.loads(call_args[1]["episode_body"])
|
||||
assert episode_body["task_id"] == "task-1"
|
||||
assert episode_body["success"] is True
|
||||
assert episode_body["outcome"] == "Test approach"
|
||||
assert episode_body["why_worked"] == "Because reasons"
|
||||
assert episode_body["why_failed"] is None
|
||||
assert episode_body["alternatives_tried"] == ["Alt1", "Alt2"]
|
||||
assert episode_body["changed_files"] == ["file1.py", "file2.py"]
|
||||
@@ -1,123 +0,0 @@
|
||||
"""
|
||||
Tests for Graphiti schema constants and types.
|
||||
|
||||
Tests cover:
|
||||
- Episode type constants
|
||||
- MAX_CONTEXT_RESULTS constant
|
||||
- GroupIdMode enum values
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from integrations.graphiti.queries_pkg.schema import (
|
||||
EPISODE_TYPE_CODEBASE_DISCOVERY,
|
||||
EPISODE_TYPE_GOTCHA,
|
||||
EPISODE_TYPE_HISTORICAL_CONTEXT,
|
||||
EPISODE_TYPE_PATTERN,
|
||||
EPISODE_TYPE_QA_RESULT,
|
||||
EPISODE_TYPE_SESSION_INSIGHT,
|
||||
EPISODE_TYPE_TASK_OUTCOME,
|
||||
MAX_CONTEXT_RESULTS,
|
||||
MAX_RETRIES,
|
||||
RETRY_DELAY_SECONDS,
|
||||
GroupIdMode,
|
||||
)
|
||||
|
||||
|
||||
class TestEpisodeTypeConstants:
|
||||
"""Test episode type constants."""
|
||||
|
||||
def test_session_insight_constant(self):
|
||||
"""Test EPISODE_TYPE_SESSION_INSIGHT constant."""
|
||||
assert EPISODE_TYPE_SESSION_INSIGHT == "session_insight"
|
||||
assert isinstance(EPISODE_TYPE_SESSION_INSIGHT, str)
|
||||
|
||||
def test_codebase_discovery_constant(self):
|
||||
"""Test EPISODE_TYPE_CODEBASE_DISCOVERY constant."""
|
||||
assert EPISODE_TYPE_CODEBASE_DISCOVERY == "codebase_discovery"
|
||||
assert isinstance(EPISODE_TYPE_CODEBASE_DISCOVERY, str)
|
||||
|
||||
def test_pattern_constant(self):
|
||||
"""Test EPISODE_TYPE_PATTERN constant."""
|
||||
assert EPISODE_TYPE_PATTERN == "pattern"
|
||||
assert isinstance(EPISODE_TYPE_PATTERN, str)
|
||||
|
||||
def test_gotcha_constant(self):
|
||||
"""Test EPISODE_TYPE_GOTCHA constant."""
|
||||
assert EPISODE_TYPE_GOTCHA == "gotcha"
|
||||
assert isinstance(EPISODE_TYPE_GOTCHA, str)
|
||||
|
||||
def test_task_outcome_constant(self):
|
||||
"""Test EPISODE_TYPE_TASK_OUTCOME constant."""
|
||||
assert EPISODE_TYPE_TASK_OUTCOME == "task_outcome"
|
||||
assert isinstance(EPISODE_TYPE_TASK_OUTCOME, str)
|
||||
|
||||
def test_qa_result_constant(self):
|
||||
"""Test EPISODE_TYPE_QA_RESULT constant."""
|
||||
assert EPISODE_TYPE_QA_RESULT == "qa_result"
|
||||
assert isinstance(EPISODE_TYPE_QA_RESULT, str)
|
||||
|
||||
def test_historical_context_constant(self):
|
||||
"""Test EPISODE_TYPE_HISTORICAL_CONTEXT constant."""
|
||||
assert EPISODE_TYPE_HISTORICAL_CONTEXT == "historical_context"
|
||||
assert isinstance(EPISODE_TYPE_HISTORICAL_CONTEXT, str)
|
||||
|
||||
def test_all_episode_types_are_unique(self):
|
||||
"""Test that all episode type constants have unique values."""
|
||||
episode_types = [
|
||||
EPISODE_TYPE_SESSION_INSIGHT,
|
||||
EPISODE_TYPE_CODEBASE_DISCOVERY,
|
||||
EPISODE_TYPE_PATTERN,
|
||||
EPISODE_TYPE_GOTCHA,
|
||||
EPISODE_TYPE_TASK_OUTCOME,
|
||||
EPISODE_TYPE_QA_RESULT,
|
||||
EPISODE_TYPE_HISTORICAL_CONTEXT,
|
||||
]
|
||||
assert len(episode_types) == len(set(episode_types)), (
|
||||
"Episode types must be unique"
|
||||
)
|
||||
|
||||
|
||||
class TestMaxContextResults:
|
||||
"""Test MAX_CONTEXT_RESULTS constant."""
|
||||
|
||||
def test_max_context_results_is_positive_integer(self):
|
||||
"""Test MAX_CONTEXT_RESULTS is a positive integer."""
|
||||
assert isinstance(MAX_CONTEXT_RESULTS, int)
|
||||
assert MAX_CONTEXT_RESULTS > 0
|
||||
|
||||
def test_max_context_results_reasonable_value(self):
|
||||
"""Test MAX_CONTEXT_RESULTS has a reasonable value."""
|
||||
# Should be between 1 and 100 for practical use
|
||||
assert 1 <= MAX_CONTEXT_RESULTS <= 100
|
||||
|
||||
|
||||
class TestRetryConfiguration:
|
||||
"""Test retry configuration constants."""
|
||||
|
||||
def test_max_retries_is_positive_integer(self):
|
||||
"""Test MAX_RETRIES is a positive integer."""
|
||||
assert isinstance(MAX_RETRIES, int)
|
||||
assert MAX_RETRIES > 0
|
||||
|
||||
def test_retry_delay_is_positive_number(self):
|
||||
"""Test RETRY_DELAY_SECONDS is a positive number."""
|
||||
assert isinstance(RETRY_DELAY_SECONDS, (int, float))
|
||||
assert RETRY_DELAY_SECONDS >= 0
|
||||
|
||||
|
||||
class TestGroupIdMode:
|
||||
"""Test GroupIdMode class."""
|
||||
|
||||
def test_spec_mode_constant(self):
|
||||
"""Test GroupIdMode.SPEC constant."""
|
||||
assert GroupIdMode.SPEC == "spec"
|
||||
assert isinstance(GroupIdMode.SPEC, str)
|
||||
|
||||
def test_project_mode_constant(self):
|
||||
"""Test GroupIdMode.PROJECT constant."""
|
||||
assert GroupIdMode.PROJECT == "project"
|
||||
assert isinstance(GroupIdMode.PROJECT, str)
|
||||
|
||||
def test_modes_are_unique(self):
|
||||
"""Test that mode values are unique."""
|
||||
assert GroupIdMode.SPEC != GroupIdMode.PROJECT
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
|
||||
def is_graphiti_memory_enabled() -> bool:
|
||||
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
|
||||
return None
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
if project_dir is None:
|
||||
project_dir = spec_dir.parent.parent
|
||||
|
||||
+24
-217
@@ -7,53 +7,35 @@ Reads configuration from task_metadata.json and provides resolved model IDs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model shorthand to full model ID mapping
|
||||
# Values must match apps/frontend/src/shared/constants/models.ts MODEL_ID_MAP
|
||||
MODEL_ID_MAP: dict[str, str] = {
|
||||
"opus": "claude-opus-4-6",
|
||||
"opus-1m": "claude-opus-4-6",
|
||||
"opus-4.5": "claude-opus-4-5-20251101",
|
||||
"opus": "claude-opus-4-5-20251101",
|
||||
"sonnet": "claude-sonnet-4-5-20250929",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
# 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] = {
|
||||
# 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,
|
||||
"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 high for deep analysis
|
||||
# Heavy phases use ultrathink for deep analysis
|
||||
# Light phases use medium after compaction
|
||||
SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
|
||||
# Heavy phases - high (discovery, spec creation, self-critique)
|
||||
"discovery": "high",
|
||||
"spec_writing": "high",
|
||||
"self_critique": "high",
|
||||
# Heavy phases - ultrathink (discovery, spec creation, self-critique)
|
||||
"discovery": "ultrathink",
|
||||
"spec_writing": "ultrathink",
|
||||
"self_critique": "ultrathink",
|
||||
# Light phases - medium (after first invocation with compaction)
|
||||
"requirements": "medium",
|
||||
"research": "medium",
|
||||
@@ -103,7 +85,6 @@ class TaskMetadataConfig(TypedDict, total=False):
|
||||
phaseThinking: PhaseThinkingConfig
|
||||
model: str
|
||||
thinkingLevel: str
|
||||
fastMode: bool
|
||||
|
||||
|
||||
Phase = Literal["spec", "planning", "coding", "qa"]
|
||||
@@ -131,9 +112,6 @@ 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:
|
||||
@@ -148,69 +126,23 @@ def resolve_model_id(model: str) -> str:
|
||||
return model
|
||||
|
||||
|
||||
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:
|
||||
def get_thinking_budget(thinking_level: str) -> int | None:
|
||||
"""
|
||||
Get the thinking budget for a thinking level.
|
||||
|
||||
Args:
|
||||
thinking_level: Thinking level (low, medium, high)
|
||||
thinking_level: Thinking level (none, low, medium, high, ultrathink)
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking
|
||||
Token budget or None for no extended thinking
|
||||
"""
|
||||
import logging
|
||||
|
||||
if thinking_level not in THINKING_BUDGET_MAP:
|
||||
valid_levels = ", ".join(THINKING_BUDGET_MAP.keys())
|
||||
logger.warning(
|
||||
"Invalid thinking_level '%s'. Valid values: %s. Defaulting to 'medium'.",
|
||||
thinking_level,
|
||||
valid_levels,
|
||||
logging.warning(
|
||||
f"Invalid thinking_level '{thinking_level}'. Valid values: {valid_levels}. "
|
||||
f"Defaulting to 'medium'."
|
||||
)
|
||||
return THINKING_BUDGET_MAP["medium"]
|
||||
|
||||
@@ -282,43 +214,6 @@ 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,
|
||||
@@ -366,7 +261,7 @@ def get_phase_thinking_budget(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_thinking: str | None = None,
|
||||
) -> int:
|
||||
) -> int | None:
|
||||
"""
|
||||
Get the thinking budget tokens for a specific execution phase.
|
||||
|
||||
@@ -376,7 +271,7 @@ def get_phase_thinking_budget(
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking
|
||||
Token budget or None for no extended thinking
|
||||
"""
|
||||
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
|
||||
return get_thinking_budget(thinking_level)
|
||||
@@ -387,7 +282,7 @@ def get_phase_config(
|
||||
phase: Phase,
|
||||
cli_model: str | None = None,
|
||||
cli_thinking: str | None = None,
|
||||
) -> tuple[str, str, int]:
|
||||
) -> tuple[str, str, int | None]:
|
||||
"""
|
||||
Get the full configuration for a specific execution phase.
|
||||
|
||||
@@ -407,95 +302,7 @@ def get_phase_config(
|
||||
return model_id, thinking_level, thinking_budget
|
||||
|
||||
|
||||
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:
|
||||
def get_spec_phase_thinking_budget(phase_name: str) -> int | None:
|
||||
"""
|
||||
Get the thinking budget for a specific spec runner phase.
|
||||
|
||||
@@ -506,7 +313,7 @@ def get_spec_phase_thinking_budget(phase_name: str) -> int:
|
||||
phase_name: Name of the spec phase (e.g., 'discovery', 'spec_writing')
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking
|
||||
Token budget for extended thinking, or None for no 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__query-docs
|
||||
Tool: mcp__context7__get-library-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. `query-docs` with topic "payments" or "checkout"
|
||||
2. `get-library-docs` with topic "payments" or "checkout"
|
||||
3. Use the exact patterns from documentation
|
||||
|
||||
**This prevents:**
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<role>
|
||||
You are a fix strategy specialist. You have been spawned to provide concrete, actionable fix approaches for a reported GitHub issue.
|
||||
</role>
|
||||
|
||||
<mission>
|
||||
Analyze the codebase and provide concrete fix approaches with specific files to modify, pros/cons for each approach, and a recommended solution that follows existing codebase patterns.
|
||||
</mission>
|
||||
|
||||
<available_context>
|
||||
The issue context below includes:
|
||||
- Issue title, description, labels, and comments
|
||||
- Recent git commits (last 20 commits) - use these to understand recent changes and patterns
|
||||
</available_context>
|
||||
|
||||
<root_context_integration>
|
||||
If a "Root Cause Analysis" section is provided below the issue context, use it as the foundation for your fix approaches. The root cause agent has already identified the exact code location and cause — your job is to design fix strategies that address that specific root cause.
|
||||
|
||||
This means you can skip Step 1 (understanding the problem space) when root cause context is available, and instead focus on designing fixes that target the identified code paths.
|
||||
</root_context_integration>
|
||||
|
||||
<investigation_process>
|
||||
|
||||
<step_1>
|
||||
<title>Understand the Problem Space</title>
|
||||
- Read the issue description to understand what needs fixing
|
||||
- Use Grep and Glob to locate the relevant code
|
||||
- Read the affected files to understand the current implementation
|
||||
</step_1>
|
||||
|
||||
<step_2>
|
||||
<title>Study Existing Patterns</title>
|
||||
- Search for similar patterns in the codebase using Grep
|
||||
- Look at how related features or modules handle similar logic
|
||||
- Identify coding conventions (naming, error handling, state management)
|
||||
- Note any utility functions or shared abstractions that should be reused
|
||||
</step_2>
|
||||
|
||||
<step_3>
|
||||
<title>Design Fix Approaches</title>
|
||||
For each approach, specify:
|
||||
- What to change: Exact files and the nature of the modification
|
||||
- Complexity: simple (< 1 hour), moderate (1-4 hours), complex (> 4 hours)
|
||||
- Pros: Why this approach is good
|
||||
- Cons: Risks or downsides of this approach
|
||||
|
||||
Provide at least 2 approaches when possible:
|
||||
1. The minimal fix - smallest change that resolves the issue
|
||||
2. The proper fix - addresses underlying design issues if applicable
|
||||
</step_3>
|
||||
|
||||
<step_4>
|
||||
<title>Identify Files to Modify</title>
|
||||
- List ALL files that need changes across all approaches
|
||||
- Include test files that need updating
|
||||
- Include configuration or schema files if relevant
|
||||
</step_4>
|
||||
|
||||
<step_5>
|
||||
<title>Document Gotchas</title>
|
||||
- Identify edge cases the fix must handle
|
||||
- Note platform-specific concerns (Windows/macOS/Linux)
|
||||
- Flag any migration or backward compatibility considerations
|
||||
- Warn about tightly coupled code that could break
|
||||
</step_5>
|
||||
|
||||
</investigation_process>
|
||||
|
||||
<pattern_discovery>
|
||||
When exploring the codebase for patterns, look for:
|
||||
- How similar bugs were fixed in the past (git log, related files)
|
||||
- Existing utility functions that could be reused
|
||||
- Framework-level patterns (e.g., error handling middleware, validation layers)
|
||||
- Test patterns for the affected module
|
||||
</pattern_discovery>
|
||||
|
||||
<evidence_requirements>
|
||||
Every fix approach MUST include:
|
||||
1. Specific file paths - Not "the auth module" but "src/auth/login.ts"
|
||||
2. Nature of change - What code to add, modify, or remove
|
||||
3. Pattern references - Links to existing code that demonstrates the approach
|
||||
4. Complexity assessment - Realistic effort estimate
|
||||
</evidence_requirements>
|
||||
|
||||
<constraints>
|
||||
- Do not provide vague advice like "improve error handling"
|
||||
- Do not suggest rewriting large portions of code unless necessary
|
||||
- Do not ignore existing patterns in favor of "better" approaches
|
||||
- Do not recommend approaches that conflict with the project's architecture
|
||||
- Do not assess impact or severity (that is the Impact Assessor's job)
|
||||
- Do not analyze root cause (that is the Root Cause Analyzer's job)
|
||||
</constraints>
|
||||
|
||||
<output_format>
|
||||
Provide your analysis as structured output with:
|
||||
- approaches: List of fix approaches ordered by recommendation
|
||||
- recommended_approach: Index of the recommended approach
|
||||
- files_to_modify: All files that need modification across all approaches
|
||||
- patterns_to_follow: Existing codebase patterns the fix should follow
|
||||
- gotchas: Potential pitfalls when implementing the fix
|
||||
</output_format>
|
||||
@@ -1,89 +0,0 @@
|
||||
<role>
|
||||
You are an impact assessment specialist. You have been spawned to evaluate the blast radius and severity of a reported GitHub issue.
|
||||
</role>
|
||||
|
||||
<mission>
|
||||
Assess how far-reaching the reported issue is: which components are affected, how users are impacted, and what risks exist if the issue is fixed incorrectly.
|
||||
</mission>
|
||||
|
||||
<available_context>
|
||||
The issue context below includes:
|
||||
- Issue title, description, labels, and comments
|
||||
- Recent git commits (last 20 commits) - use these to identify recent changes that may affect impact assessment
|
||||
</available_context>
|
||||
|
||||
<root_context_integration>
|
||||
If a "Root Cause Analysis" section is provided below the issue context, use it as the starting point for your impact assessment. The root cause agent has already identified the problematic code — your job is to trace outward from those code paths to determine blast radius and severity.
|
||||
|
||||
This means you can skip Steps 1-2 (identifying affected code) when root cause context is available, and instead focus on mapping dependencies outward from the identified code paths.
|
||||
</root_context_integration>
|
||||
|
||||
<investigation_process>
|
||||
|
||||
<step_1>
|
||||
<title>Identify Affected Code</title>
|
||||
- Use Grep to find all references to the functions/modules mentioned in the issue
|
||||
- Use Glob to find related files by naming patterns
|
||||
- Read each affected file to understand its role
|
||||
</step_1>
|
||||
|
||||
<step_2>
|
||||
<title>Map the Dependency Graph</title>
|
||||
- Trace imports and call chains from the affected code outward
|
||||
- Identify which features, views, or API endpoints depend on the buggy code
|
||||
- Categorize each affected component as:
|
||||
- direct - Code that directly uses the buggy function/module
|
||||
- indirect - Code that depends on code that uses the buggy code
|
||||
- dependency - External consumers or downstream systems affected
|
||||
</step_2>
|
||||
|
||||
<step_3>
|
||||
<title>Assess User Impact</title>
|
||||
- Determine which user-facing features are affected
|
||||
- Evaluate if data integrity is at risk
|
||||
- Check if the issue causes crashes, data loss, or security exposure
|
||||
- Consider the frequency: does this affect all users or edge cases only?
|
||||
</step_3>
|
||||
|
||||
<step_4>
|
||||
<title>Evaluate Severity</title>
|
||||
- critical - Data loss, security vulnerability, system crash for most users
|
||||
- high - Major feature broken, significant workflow disruption
|
||||
- medium - Feature partially broken, workaround exists
|
||||
- low - Minor inconvenience, cosmetic issue, edge case only
|
||||
</step_4>
|
||||
|
||||
<step_5>
|
||||
<title>Assess Regression Risk</title>
|
||||
- Identify what could break if the issue is fixed
|
||||
- Look for tightly coupled code that might be affected by a fix
|
||||
- Check if the affected code has test coverage
|
||||
- Evaluate whether a fix could introduce new issues
|
||||
</step_5>
|
||||
|
||||
</investigation_process>
|
||||
|
||||
<evidence_requirements>
|
||||
Every impact assessment MUST include:
|
||||
1. File paths - Exact locations of affected components
|
||||
2. Component names - Clear identification of what is affected
|
||||
3. Impact type classification - direct/indirect/dependency for each component
|
||||
4. User-facing description - How end users experience the problem
|
||||
</evidence_requirements>
|
||||
|
||||
<constraints>
|
||||
- Do not identify the root cause (that is the Root Cause Analyzer's job)
|
||||
- Do not suggest fixes (that is the Fix Advisor's job)
|
||||
- Do not speculate about impact without reading the actual code
|
||||
- Do not inflate severity; be objective and evidence-based
|
||||
- Do not report components as affected unless you verified the dependency
|
||||
</constraints>
|
||||
|
||||
<output_format>
|
||||
Provide your analysis as structured output with:
|
||||
- severity: Overall severity (critical/high/medium/low)
|
||||
- affected_components: List of affected components with file paths and impact types
|
||||
- blast_radius: Description of how far-reaching the impact is
|
||||
- user_impact: How end users are affected
|
||||
- regression_risk: Risk assessment for potential fixes
|
||||
</output_format>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user