Compare commits

..

36 Commits

Author SHA1 Message Date
AndyMik90 9fc7320158 feat(web): extract i18n namespaces, consolidate settings locale, and harden stores
Extract all hardcoded UI strings into i18n namespace JSON files (kanban,
views, integrations, layout). Consolidate duplicated settings keys from
layout namespace into dedicated settings namespace. Distinguish network
errors from API errors in task-store so connectivity issues show empty
state while server errors surface to the UI. Add test coverage for
Settings button accessibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:39:29 +01:00
AndyMik90 d64e999db5 feat(web): wire onboarding wizard, add config files and pre-existing hooks
Connect OnboardingWizard to AppShell via UI store. Add the remaining
web app config files (next.config.js, tsconfig.json, postcss.config.js),
auth layer, Convex hooks, API routes, and Dockerignore files that were
part of the skeleton but not yet tracked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:09:21 +01:00
AndyMik90 85d6b18529 feat(web): add GitLab views, task creation wizard, onboarding, and keyboard shortcuts
- GitLabIssuesView with list/detail split pane matching GitHub pattern
- GitLabMRsView with approval tracking and AI review button
- TaskCreationWizard with 3-step flow (details, metadata, review)
- OnboardingWizard with step-by-step setup flow
- Keyboard shortcuts for all navigation views (K, N, D, I, L, C, G, P, B, R)
- Theme-aware rendering from settings store
- Sidebar now includes GitLab navigation items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:05:36 +01:00
AndyMik90 737060902f feat(web): add full app shell with sidebar, navigation, and all major page views
Build the web app as a 1:1 replica of the Electron app's UI structure:
- Sidebar with navigation matching Electron app (kanban, insights, roadmap, ideation, changelog, context, GitHub)
- Project tab bar with multi-project support
- KanbanBoard with task cards, status columns, and task detail modal
- RoadmapView with board/timeline/list tabs and feature detail panel
- IdeationView with category filters and idea cards
- InsightsView with AI chat interface and session sidebar
- ChangelogView with expandable version entries
- ContextView with project overview, services, and memories tabs
- GitHubIssuesView and GitHubPRsView with list/detail split pane
- SettingsView with section-based navigation
- Dual-mode data layer (HTTP API + Convex) with Zustand stores
- Dark mode theme system matching Electron app's design tokens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:02:20 +01:00
AndyMik90 062758bc1d refactor: rename libs/ to packages/ for consistency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:51:13 +01:00
AndyMik90 435cc0733a chore: bump to v2.7.7 stable and fix libs build chain for npm publishing
- Update all package versions to 2.7.7 (removing beta flags)
- Fix libs/ui build chain: add TypeScript paths mapping for dev typecheck,
  override with empty paths in build config to use workspace resolution
- Sync roadmap types: add TaskOutcome type and taskOutcome/previousStatus
  fields to RoadmapFeature in libs/types (drifted during rebase)
- Add root build:libs script for ordered types → ui compilation
- Update @auto-claude/types peer dependency in libs/ui to 2.7.7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:36:41 +01:00
Andy 5aac714b59 Cloud Phase 1 — OSS Repo Preparation: Extract shared types and UI into workspace packages (#1783)
* auto-claude: subtask-1-1 - Create libs/types/ and libs/ui/ package scaffolding

* auto-claude: subtask-1-2 - Add @auto-claude/types and @auto-claude/ui as workspace deps

Add workspace package dependencies to apps/frontend/package.json and verify
npm workspace resolution creates correct symlinks in node_modules/@auto-claude/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Copy all 11 portable type files from apps/frontend

Copies common.ts, task.ts, project.ts, settings.ts, agent.ts, kanban.ts,
changelog.ts, insights.ts, roadmap.ts, integrations.ts, pr-status.ts to
libs/types/src/. Creates constants.ts with inline type definitions for
ExecutionPhase, CompletablePhase, and SupportedLanguage. Updates import
paths in task.ts and settings.ts to use local constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Create cloud-specific types and future phase stubs

Add cloud.ts (Tier, CloudUser, SubscriptionStatus, FeatureFlag, TierLimits, Usage),
team.ts (Team, TeamMember, TeamRole), and Phase 3-6 stub types: billing.ts,
pr-lifecycle.ts, feedback.ts, sessions.ts with @stub JSDoc annotations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-3 - Create barrel exports and re-export layer

- libs/types/src/index.ts: barrel export for all shared type files
- apps/frontend/src/shared/types/index.ts: re-exports from @auto-claude/types
  plus Electron-specific types (terminal, ipc, screenshot, etc.)
- Fix duplicate ExecutionPhase export conflict in task.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Extract cn() utility and theme CSS to libs/ui

- Create libs/ui/src/utils.ts with cn() (clsx + tailwind-merge)
- Create libs/ui/src/styles/theme.css with @theme block, all 7 color
  themes (default, dusk, lime, ocean, retro, neo, forest) with
  light/dark variants
- Update frontend utils.ts to re-export cn() from @auto-claude/ui
- Export cn and ClassValue from libs/ui/src/index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Extract all 26 shadcn/ui primitive components to libs/ui

Copies all 26 UI primitives from apps/frontend/src/renderer/components/ui/
to libs/ui/src/primitives/ with updated imports:
- cn() import changed from '../../lib/utils' to '../utils'
- error-boundary: removed Sentry dependency, added optional onError prop
- toaster/use-toast: updated internal imports to local primitives
- input/textarea: kept react-i18next import (peer dependency)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-3 - Create primitives barrel export and wire up UI re-exports

- Create libs/ui/src/primitives/index.ts barrel export for all 26 primitives + useToast
- Create libs/ui/components.json for shadcn CLI compatibility
- Update libs/ui/src/index.ts to re-export primitives
- Replace all frontend UI component files with one-line re-exports from @auto-claude/ui
- Update frontend components/ui/index.ts to re-export from @auto-claude/ui/primitives
- Update globals.css: import shared theme.css, add @source directive, remove extracted @theme and color selectors
- Update use-toast hook to re-export from @auto-claude/ui
- Add @auto-claude/ui path mappings to frontend tsconfig.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Build three core feature gating components

Add FeatureGate, UsageLimitGate, and UpgradePrompt components to
libs/ui/src/core/ with tier hierarchy comparison, usage quota
enforcement, and upgrade CTA display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-4-2 - Build auth-related components in libs/ui/src/core/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-1 - Build spec management components in libs/ui/src/specs/

Add SpecCard, SpecList, and SpecEditor components as pure prop-driven
UI components for spec/task management. SpecCard displays a single task
with status badge, metadata, and action buttons. SpecList renders a
filterable/sortable list with loading and empty states. SpecEditor
provides a form for creating/editing specs with classification fields
and model selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-2 - Build persona management components in libs/ui/src

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-6-1 - Update build config for workspace packages

- Add @auto-claude/types and @auto-claude/ui to externalizeDepsPlugin exclude
  list so they are bundled (not externalized) in main process builds
- Add resolve.alias entries for workspace packages pointing to source dirs
  for proper Vite resolution and HMR support
- Add @auto-claude/types paths to tsconfig.json for TypeScript resolution
- Include libs/types/src in tsconfig include array
- Fix CSS import paths in globals.css (4 levels -> 5 levels to reach repo root)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-6-2 - Fix UI package exports and verify full build suite

Add explicit export mapping for use-toast.ts in @auto-claude/ui package
to fix resolution of .ts files alongside .tsx glob pattern.

Verification results:
- libs/types: tsc --noEmit ✓
- libs/ui: tsc --noEmit ✓
- apps/frontend: typecheck ✓, lint ✓ (warnings only), 3048 tests passed, build ✓

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-6-3 - Create GitHub Actions workflow for npm package publishing

Add publish-packages.yml workflow that:
- Triggers on v* tag pushes to build and publish @auto-claude/types and @auto-claude/ui
- Validates with typecheck on libs/types, libs/ui, and frontend + frontend tests
- Runs dry-run publish on PRs touching libs/ to catch issues early
- Publishes both packages with --access public using NPM_TOKEN secret

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Address PR review findings for OSS repo preparation

- Restore Sentry error reporting in ErrorBoundary by creating a
  wrapper in the frontend app that injects captureException as onError
- Add react-i18next and i18next as peerDependencies in libs/ui
- Add explicit directory exports (primitives, core, specs, personas)
  to libs/ui package.json for proper Node.js module resolution
- Fix .replace('_', ' ') to .replace(/_/g, ' ') in SpecCard and
  SpecEditor to handle categories with multiple underscores
- Add currentTier/upgradeTier props to UsageLimitGate instead of
  hardcoding 'pro'/'free'
- Add configurable label props with English defaults to ErrorBoundary,
  LoginForm, UpgradePrompt, AuthGate, UsageLimitGate, PersonaManager,
  and SpecEditor for i18n support at the consumer level
- Replace raw HTML input/select/button elements with shared Input,
  Label, Button primitives in PersonaManager and SpecEditor
- Clean up duplicate IPCResult type: update ipc.ts and mcp-api.ts to
  import from @auto-claude/types, convert common.ts to re-export
- Export new Labels types from barrel files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add --provenance flag to npm publish commands

The workflow already requests id-token: write for OIDC attestation
but the publish commands were missing the --provenance flag, making
the permission unused. Adding provenance enables npm supply chain
attestation for published packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add build step for npm packages, fix duplicate CSS, add publish recovery

- Add tsconfig.build.json for both @auto-claude/types and @auto-claude/ui
  that compiles TypeScript to JavaScript + declaration files in dist/
- Add build/clean/prepublishOnly scripts to both package.json files
- Update exports to use conditional exports with types + default conditions
  pointing to compiled dist/ output (source still shipped for reference)
- Add build steps to all CI workflow jobs (validate, dry-run, publish)
- Add version-exists check before each npm publish to handle re-runs
  gracefully when one package published but the other failed
- Remove redundant NPM_TOKEN env blocks from publish steps (already
  configured via .npmrc)
- Remove duplicate .progress-working CSS definition in globals.css that
  silently overrode the themed glow-sweep animation with a plain white
  shimmer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add tag-version validation, improve publish error handling, fix ErrorBoundary shadowing

- Add tag-to-package version validation step in publish workflow to
  catch mismatches before attempting to publish
- Improve version-exists check to distinguish network errors from
  "not published" state instead of swallowing all errors
- Re-export Sentry-integrated ErrorBoundary from ui barrel to prevent
  base component from shadowing the app-level wrapper
- Remove unnecessary @auto-claude/ui from main process exclude list
  since it is not imported in src/main/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Use ::warning:: for optimistic publish, simplify ErrorBoundary to function component

- Change ::error:: to ::warning:: in publish workflow fallback branches
  since the step intentionally proceeds with publishing anyway
- Convert ErrorBoundary wrapper from class to function component since
  all error boundary logic is delegated to BaseErrorBoundary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:55:18 +01:00
Andy 4d4234378f fix(sentry): enable Sentry for Python subprocesses and add diagnostic instrumentation (#1804)
* fix(sentry): enable Sentry for Python subprocesses and add diagnostic instrumentation

Sentry was broken for PR review (and all GitHub runner) subprocesses due to
two bugs: getRunnerEnv() didn't include getSentryEnvForSubprocess(), and
Python's init_sentry() required sys.frozen which is always False for the
non-frozen interpreter. Also adds a 120s health-check timeout to detect
subprocess hangs, Sentry breadcrumbs to PR review lifecycle, and forces
unbuffered Python output for reliable progress streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sentry): remove dead should_enable guard and add missing breadcrumb levels

The dsn_explicitly_set check was always True after the early return for
empty DSN, making should_enable always True and the gating block
unreachable dead code. Simplified to just a clear comment explaining
that DSN presence is sufficient to enable Sentry.

Also added missing level field to two safeBreadcrumb calls in PR review
handlers to match the established project convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sentry): clean up dead code, sanitize stderr, and add follow-up review instrumentation

- Remove dead force_enable parameter from init_sentry() (no callers use it)
- Fix misleading SENTRY_DEV comment — Python backend no longer reads it
- Remove SENTRY_DEV pass-through from getSentryEnvForSubprocess()
- Add sanitizeForSentry() to redact potential secrets (tokens, API keys)
  from subprocess stderr before sending to Sentry
- Add safeBreadcrumb and safeCaptureException to follow-up review handler
  for parity with the initial review handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:51:41 +01:00
Andy d1fbccde39 fix(pr-review): add three-tier recovery for structured output validation failure (#1797)
* fix(pr-review): add three-tier recovery for structured output validation failure

When structured output validation fails after SDK max retries, the followup
reviewer crashed with RuntimeError instead of recovering. This wastes all
multi-agent analysis work (often 100+ messages across 3 specialist agents).

Changes:
- sdk_utils: add error_recoverable flag and last_assistant_text to stream result
- followup reviewer: attempt extraction call with minimal schema before text fallback
- pydantic_models: add FollowupExtractionResponse (~6 flat fields, near-100% success)
- orchestrator reviewer: add structured_output to FindingValidator retryable errors

Recovery cascade: structured output → extraction call → text parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): address review findings from PR #1797

- Register pr_followup_extraction agent type in AGENT_CONFIGS (fixes Tier 2 dead code)
- Move RECOVERABLE_ERRORS to module-level constant in sdk_utils for importability
- Update docstring to document new return fields (last_assistant_text, error_recoverable)
- Use self.config.fast_mode instead of hardcoded True for consistency
- Rewrite tests to import actual production constants instead of reimplementing logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): fix import paths for CI environment

CI runs pytest from apps/backend/ so runners/github/ must be on sys.path
for services.sdk_utils and services.pydantic_models imports to resolve.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): use bare module imports to avoid services/ package collision

There are two services/ directories (apps/backend/services/ and
runners/github/services/). Adding github services dir to sys.path and
importing via `from services.sdk_utils` fails because Python finds the
wrong services/ package first. Fix: add the services dir directly and
use bare imports (from sdk_utils import ...).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): fix extraction call type error and control flow issues

- Use self.project_dir instead of str(Path.cwd()) for create_client (fixes
  AttributeError making Tier 2 always crash, and uses correct project path)
- Force structured_output = None on recoverable errors to skip redundant
  parse-then-fail cycle and go directly to Tier 2 extraction
- Include dismissed_finding_count in extraction return dict for symmetry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): address follow-up review findings

- Read dismissed_finding_count fallback in consumer (fixes silent data loss)
- Consolidate recoverable error handling into single control flow block
- Default text fallback verdict to NEEDS_REVISION (consistent with _create_empty_result)
- Add missing keys to _parse_text_output and _create_empty_result for consistent
  return dict contracts across all three recovery tiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: ruff format parallel_followup_reviewer.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:43:44 +01:00
StillKnotKnown ed93df698b test: improve backend agent test coverage to 94% (#1779)
* fix: add mock reset fixtures and resolve async iterator mock issues

- Add pytest_runtest_setup and pytest_runtest_teardown hooks to reset
  shared module-level mocks between tests
- Add module-specific mock reset fixtures for test_qa_fixer and
  test_qa_reviewer to prevent test interference
- Fix async iterator mock for receive_response to properly return
  an AsyncIteratorMock instance
- Update test_qa_fixer.py and test_qa_reviewer.py with proper mock
  setup for isolated test execution

* docs(agents): add CLAUDE.md documentation for agents module

Documents the agents module architecture including:
- Module components (coder, planner, session, memory_manager, base)
- Single-agent architecture without external parallelism
- Subagent architecture clarification

* Revert "docs(agents): add CLAUDE.md documentation for agents module"

This reverts commit bf1ddd7da08f2f34352d11a5d823da981f1a98bb.

* chore: update gitignore to allow agents/tests/

* fix(tests): resolve mock isolation and path permission issues

- Fix test_tool_concurrency_error_detection by patching where functions
  are used (qa.fixer) instead of where they're defined
- Add Path.exists/is_dir/glob mocks to avoid permission errors on
  nonexistent directories in test_validation_strategy.py
- Add helper function clean_project_index_files() to reduce code
  duplication in prereqs_validator tests
- Add comprehensive tests for spec validation validators
  (context, prereqs, spec_document)
- Fix similar mock/path issues in test_qa_reviewer.py,
  test_service_orchestrator.py, test_ci_discovery.py,
  test_prompt_generator.py, test_security_scanner.py

All 2103 tests now pass.

* fix(tests): remove unused imports and fix double assignment

- Remove unused 'patch' import from validator test files
- Remove unused 'pytest' import where not needed
- Fix double assignment typo in test_error_message_includes_filename

* fix(tests): move agents tests to tests/agents/ directory

- Move test_agent_architecture.py, test_agent_configs.py, and
  test_agent_flow.py from apps/backend/agents/tests/ to tests/agents/
- Fix path resolution to work from new location
- Remove gitignore exception for agents/tests/ (no longer needed)

This resolves the issue where tests were not included in the PR
because they were in an untracked location.

* fix(tests): simplify conftest.py mock management

- Remove redundant pytest_runtest_teardown and pytest_runtest_call hooks
  (autouse fixtures in test files already handle mock reset)
- Add prompts_pkg.project_context to potentially mocked modules list
- Remove prompts_pkg from test_qa_fixer entry (not used there)

This reduces maintenance burden by having mock reset in one place.

* refactor(tests): consolidate duplicate mock setup into shared helper

- Create tests/qa_test_helpers.py with shared mock infrastructure:
  - AsyncIteratorMock and ReceiveResponseMock classes
  - setup_qa_mocks(), cleanup_qa_mocks(), reset_qa_mocks() functions
  - Mock response creation helpers
  - Accessor functions for mock objects
- Refactor test_qa_fixer.py to use shared helpers
- Reduces ~80 lines of duplicated code per test file
- Fixes potential mock binding issues by using accessor functions

This addresses code quality issues identified in PR review:
- Duplicate mock setup between test_qa_fixer.py and test_qa_reviewer.py
- Duplicated _AsyncIteratorMock class across files

* refactor(tests): consolidate test_qa_reviewer.py with shared helpers

- Refactor test_qa_reviewer.py to use shared qa_test_helpers
- Remove ~170 lines of duplicated mock setup and helper functions
- Fix unused imports in test_qa_fixer.py (json, sys, MagicMock, etc.)
- Fix rate limit error detection tests to patch where functions are used
- Consolidate duplicated _create_*_response helper methods to module level

Addresses CodeQL warnings about unused imports and reduces code
duplication between test_qa_fixer.py and test_qa_reviewer.py.

* fix(tests): remove unused Path import from test_qa_reviewer.py

* fix(tests): address all PR review findings

PR Review Fixes:
- Remove unused create_mock_qa_approved_response/rejected_response functions
- Guard against overwriting _original_modules on second setup_qa_mocks() call
- Clear _original_modules in cleanup_qa_mocks() to prevent stale state
- Add prompts_pkg.project_context to test_qa_reviewer preserved_mocks in conftest
- Convert asyncio.run() pattern to native async tests in test_agent_flow.py
- Remove redundant @pytest.mark.asyncio decorators (asyncio_mode=auto)
- Remove unused pytest import from qa_test_helpers.py
- Fix structural duplication by keeping fixtures in test files

Code Quality:
- Removed ~100 lines of duplicated/unused code
- Consistent async test patterns across all QA test files
- Proper mock state management to prevent test pollution

* fix(tests): save original modules individually in setup_qa_mocks

The boolean guard `setup_done` prevented saving original modules on
subsequent calls with different parameters. When setup_qa_mocks was
called first with include_prompts_pkg=False, then with True, the
prompts_pkg modules were never saved to _original_modules. During
cleanup, these unsaved modules were deleted from sys.modules instead
of being restored, causing ModuleNotFoundError in subsequent tests.

Now checks each module individually before mocking, ensuring all
originals are saved across multiple setup calls.

* fix(tests): address all PR review findings including low priority

- Fix path in test_no_subtask_worker_config (parent.parent.parent)
- Add guard to prevent double setup in setup_qa_mocks()
- Don't clear _original_modules in cleanup to fix multi-module cleanup

* fix(tests): address PR review follow-up findings

- Fix module-level mock setup ordering dependency: now tracks
  include_prompts_pkg config and allows incremental setup when
  test_qa_fixer.py (False) is imported before test_qa_reviewer.py (True)
- Remove unused asyncio import from test_agent_flow.py
- Replace os.chdir() with monkeypatch.chdir() in prereqs validator
  tests for safe parallel test execution

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-02-12 15:06:25 +01:00
Andy 8872d33e32 fix(github): use UTC timestamps for reviewed_at to fix comment detection (#1795)
* fix(github): use UTC timestamps for reviewed_at to fix comment detection

datetime.now().isoformat() produces local time without timezone info.
When passed to GitHub API's `since` parameter (which expects UTC), this
shifts the cutoff by the local timezone offset, causing follow-up PR
reviews to miss human comments posted shortly after the previous review.

Replace all datetime.now().isoformat() with a UTC-aware _utc_now_iso()
helper using datetime.now(timezone.utc).isoformat().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(github): use Z suffix in UTC timestamps to avoid URL encoding issues

The + in +00:00 can be decoded as a space by GitHub API query
parameters, potentially causing missed comments. Z is semantically
identical in ISO 8601 and URL-safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:00:47 +01:00
AndyMik90 3b3ad75c1b chore: bump version to 2.7.6-beta.4 2026-02-12 14:17:58 +01:00
StillKnotKnown 8ece0009ee feat: add user-friendly GitHub API error handling (#1790)
* auto-claude: subtask-1-1 - Add GitHubErrorType and GitHubErrorInfo types

Add error classification types for GitHub API error handling:
- GitHubErrorType: Discriminated union for error categories
  (rate_limit, auth, permission, network, not_found, unknown)
- GitHubErrorInfo: Structured error info with user-friendly message,
  raw error, rate limit reset time, required OAuth scopes, and status code

These types will be used by the github-error-parser utility and
GitHubApiErrorDisplay component for consistent error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Create github-error-parser.ts utility with parseGitHubError function

- Create github-error-parser.ts utility to classify GitHub API errors
- Implement parseGitHubError() to detect error types: rate_limit, auth, permission, not_found, network, unknown
- Extract metadata from errors (rate limit reset times, required scopes, status codes)
- Add convenience functions: isRateLimitError, isAuthError, isNetworkError, isRecoverableError, requiresSettingsAction
- Export all functions from utils/index.ts barrel file
- Follow patterns from rate-limit-detector.ts with pattern arrays and classification functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Create GitHubErrorDisplay.tsx component

Add GitHubErrorDisplay component with error-type-specific rendering:
- Different icons per error type (Clock, Key, Shield, WifiOff, SearchX, AlertTriangle)
- Rate limit countdown timer with useEffect cleanup
- Conditional action buttons (retry for recoverable, settings for auth/permission)
- Compact and full card display variants
- i18n-ready with common namespace translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Add rate limit countdown timer with useEffect cleanup

- Fixed non-null assertion lint warning in countdown useEffect
- Extract resetTime to local variable with conditional check
- Maintains proper cleanup pattern with clearInterval on unmount

* auto-claude: subtask-2-3 - Export GitHubErrorDisplay from components/index.ts

* auto-claude: subtask-3-1 - Update IssueList.tsx to use GitHubErrorDisplay for blocking errors

- Added onRetry and onOpenSettings props to IssueListProps interface
- Updated IssueList component to use GitHubErrorDisplay for blocking errors (when issues.length === 0)
- Updated GitHubIssues.tsx to pass handleRefresh and onOpenSettings callbacks to IssueList
- Blocking errors now show user-friendly messages with retry/settings buttons based on error type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Update IssueList.tsx to use GitHubErrorDisplay for inline load-more errors

Replace the simple inline error div with GitHubErrorDisplay component using
the compact prop for better error handling when issues are already loaded.
This provides consistent error display with retry/settings actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Add githubErrors.* translation keys to en/common.json

Added translation keys for GitHub error display component:
- rateLimitTitle, authTitle, permissionTitle, notFoundTitle
- networkTitle, unknownTitle for error type titles
- resetsIn for rate limit countdown display
- rateLimitExpired for when rate limit has reset
- requiredScopes for permission error details

* auto-claude: subtask-4-2 - Add githubErrors.* translation keys to fr/common.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-1 - Create unit tests for github-error-parser.ts

Add comprehensive unit tests covering all error types and helper functions:
- parseGitHubError: rate_limit, auth, permission, not_found, network, unknown
- Helper functions: isRateLimitError, isAuthError, isNetworkError
- isRecoverableError, requiresSettingsAction
- Edge cases: null/undefined/empty, case insensitivity, multiline, JSON
- Cross-cutting concerns: consistency, status code extraction

92 tests total covering all patterns and behaviors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-2 - Create unit tests for GitHubErrorDisplay.tsx component

Added comprehensive unit tests covering:
- Null/empty error state handling
- String error and GitHubErrorInfo object parsing
- All error types (rate_limit, auth, permission, not_found, network, unknown)
- Compact mode vs full card mode rendering
- Retry and Settings button visibility based on error type
- Rate limit countdown display
- Required scopes display for permission errors
- Custom className prop support
- Callback stability and accessibility

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address lint and TypeScript issues in GitHub error handling

- Fix incorrect import path in test file (../../../types -> ../../types)
- Replace isNaN with Number.isNaN for safer type checking
- Fix unused parameter by prefixing with underscore
- Remove redundant switch case (case 'unknown' with default)
- Remove unused imports in test file (beforeEach, afterEach)
- Add comments to empty arrow functions in tests
- Use optional chaining instead of non-null assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback on GitHub error handling

- GitHubErrorDisplay.tsx:
  - Memoize errorInfo with useMemo to prevent useEffect churn
  - Remove unnecessary useCallback wrappers for trivial handlers
  - Simplify dead code conditional (if (!error) return null)
  - Use i18n keys for error messages instead of hardcoded strings

- github-error-parser.ts:
  - Add word boundaries to numeric regex patterns (401, 403, 404)
  - Make STATUS_CODE_PATTERN context-aware to avoid false positives

- Tests:
  - Add fake timer tests for countdown interval behavior
  - Add clearInterval spy for unmount cleanup verification
  - Add overlapping pattern priority tests
  - Update translation mock with new message keys

- i18n:
  - Add githubErrors.*Message keys to en/common.json and fr/common.json

* fix: address additional CodeRabbit review feedback

- GitHubErrorDisplay.tsx:
  - Stop interval when countdown expires (clearInterval on empty formatted)
  - Select specific message keys based on metadata (rateLimitMessageMinutes/Hours, permissionMessageScopes)

- github-error-parser.ts:
  - Tighten REQUIRED_SCOPES_PATTERN to stop at sentence boundaries

- Tests:
  - Update interval test to verify timer count
  - Update permission tests to avoid duplicate text matching
  - Add missing translation mocks for specific message keys

* fix: address final CodeRabbit review feedback

- GitHubErrorDisplay.tsx:
  - Extract getMessageKey to module scope (pure function)
  - Use cn() utility for className merging
  - Add title tooltip to compact variant for full error message

- github-error-parser.ts:
  - Fix extractRateLimitResetTime to handle relative durations ("in X seconds")
  - Separate relative vs absolute timestamp patterns
  - Remove unused RATE_LIMIT_RESET_PATTERN constant

- Tests:
  - Update mock type to Record<string, unknown> for accuracy
  - Add test for empty string error input

* fix: address CodeRabbit review feedback - accessibility and optimization

- GitHubErrorDisplay.tsx:
  - Add role="alert" to compact and full card variants for screen readers
  - Fix minutes/hours calculation to be undefined when <= 0 (avoid stale values)

- github-error-parser.ts:
  - Add optional parsedInfo parameter to convenience predicates
  - Avoids re-classification when caller already has parsed info
  - Updated: isRateLimitError, isAuthError, isNetworkError, isRecoverableError, requiresSettingsAction

- Tests:
  - Add tests for role="alert" accessibility in both full and compact modes

* fix: address CodeRabbit feedback - i18n countdown and pattern order

- GitHubErrorDisplay.tsx:
  - Hoist BASE_MESSAGE_KEYS to module scope to avoid recreation
  - Replace formatCountdown with getCountdownComponents returning numeric values
  - Add formatCountdownDisplay using i18n keys for hours/minutes/seconds

- github-error-parser.ts:
  - Reorder classifyError to check PERMISSION_PATTERNS before NOT_FOUND_PATTERNS
  - Properly classifies 403 responses that might contain "not found" text

- i18n:
  - Add countdownHoursMinutes and countdownMinutesSeconds keys (en/fr)
  - Enables locale-aware countdown formatting

- Tests:
  - Add mock translations for countdown formatting keys

* docs: clarify i18n usage for GitHubErrorInfo message field

- Add comprehensive JSDoc to GitHubErrorInfo interface explaining that
  the `message` field should only be used as i18n fallback defaultValue
- Update parseGitHubError function documentation with translation key
  mapping and proper usage example
- Addresses concern about direct consumers bypassing i18n

Note: role="alert" accessibility fix was already present on both
compact and full card variants (lines 272 and 311).

* fix: address Auto Claude PR review findings

- GitHubErrorDisplay.tsx:
  - Clear stale countdown state when error type changes away from rate_limit
  - Prevents stale countdown data from persisting across error type transitions

- github-error-parser.ts:
  - Add MAX_RESET_SECONDS constant (86400 seconds = 24 hours)
  - Validate relative duration seconds are within reasonable bounds
  - Prevents malformed error strings from creating far-future dates

* fix: address Auto Claude PR review findings - bounds validation and pattern fixes

- Add upper-bound validation (MAX_RESET_SECONDS=86400) on absolute timestamps
  in extractRateLimitResetTime to prevent far-future dates from malformed input
- Remove bare status code patterns (401/403/404) from AUTH_PATTERNS,
  PERMISSION_PATTERNS, and NOT_FOUND_PATTERNS to avoid misclassification
  (e.g., Issue #401 not found classified as auth instead of not_found)
  - STATUS_CODE_PATTERN already handles HTTP-context-aware matching
- Unify time-remaining calculation: compute diffMs once and pass to both
  getMessageKey() and translation interpolation to avoid boundary edge cases
- Fix useEffect dependency: use getTime() instead of Date object reference
  to prevent interval churn when callers pass new GitHubErrorInfo each render

* fix: restore status code classification via HTTP context-aware fallback

- Add 'requires:' pattern to PERMISSION_PATTERNS for scope context matching
- Modify classifyError to accept extracted status code as fallback
- Extract status code before classification to enable fallback logic
- Move status code fallback before network patterns to prioritize HTTP status
  (e.g., 'Network error: HTTP 401' now correctly classifies as auth)
- Preserves protection against bare number false positives while still
  supporting HTTP-context-aware status code classification

* fix: address LOW severity findings - accessibility and dead code

- Add aria-label to compact mode container for screen reader accessibility
  (title attribute alone is not reliably announced by screen readers)
- Simplify RATE_LIMIT_PATTERNS by removing unreachable patterns:
  - /rate\s*limit/i is a superset that matches all rate limit variations
  - Removed redundant: api rate limit exceeded, rate limit exceeded,
    abuse rate limit, secondary rate limit
  - Kept unique patterns: too many requests, 403.*rate

* fix: address PR review findings - pattern precision and helper consistency

MEDIUM fixes:
- Add 'requires authentication' pattern to AUTH_PATTERNS to catch GitHub 401 response
- Narrow permission pattern to match only known OAuth scope names (repo, admin, write,
  read, workflow, org, gist, notification, user, project, package, delete, discussion)
  to avoid misclassifying 'Requires authentication' as permission error

LOW fixes:
- Update STATUS_CODE_PATTERN comment to accurately describe ^ anchor matching behavior
  (matches status codes at string start for formats like '403 Forbidden')
- Fix helper functions (isRateLimitError, isAuthError, isNetworkError,
  isRecoverableError, requiresSettingsAction) to extract and pass status code
  to classifyError for consistent classification with parseGitHubError

* fix: address PR review findings - test coverage and edge cases

- Remove duplicate 'gist' from PERMISSION_PATTERNS regex
- Fix error display visibility during active search
- Extract resetTimeMs for stable useEffect dependency
- Add test coverage for parsedInfo shortcut paths in all 5 helper functions

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:16:24 +01:00
Andy 115576e85d fix(roadmap): sync roadmap features with task lifecycle (#1791)
* feat(roadmap): sync roadmap features with task lifecycle

When a roadmap feature is linked to a task (via linkedSpecId), the feature
now automatically updates when the task is completed, deleted, or archived.
Previously, features would show a broken "Go to Task" button pointing to
non-existent tasks.

- Add taskOutcome field to RoadmapFeature type
- Hook into task status changes (IPC listener) for real-time sync
- Update linked features on task deletion (main process)
- Update linked features on task archival (main process)
- Add startup reconciliation to catch missed updates
- Show status badges instead of broken "Go to Task" buttons
- Use AUTO_BUILD_PATHS constants and writeFileAtomicSync for consistency
- Add i18n translations (en/fr) for task outcome labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): address PR review findings

- Extract shared updateRoadmapFeatureOutcome utility with file locking
  and retry logic (eliminates duplication between crud-handlers and
  project-store, matches established roadmap-handlers pattern)
- Fix stale Zustand state read in useIpc.ts — re-read state after
  markFeatureDoneBySpecId mutation to persist correct data
- Add .catch() to saveRoadmap call in useIpc.ts for error handling
- Add Archive icon for archived outcome in PhaseCard (consistency with
  FeatureCard, SortableFeatureCard, and FeatureDetailPanel)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): address follow-up PR review findings

- Fix relative path bug: use path.join(project.path, AUTO_BUILD_PATHS)
  instead of path.join(autoBuildPath, 'roadmap') which produced relative
  paths causing roadmap updates to silently fail
- Allow taskOutcome transitions on already-done features (e.g.,
  completed→deleted) by relaxing the status check condition
- Extract withFileLock into shared file-lock.ts module so roadmap-utils
  and roadmap-handlers use the same lock map for cross-module coordination
- Show Trash2 icon for deleted tasks in PhaseCard instead of misleading
  green checkmark (visual distinction from completed)
- Remove unused writeFileAtomicSync import from crud-handlers.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(roadmap): extract TaskOutcome type and shared badge component

- Extract TaskOutcome type alias in shared/types/roadmap.ts, replacing
  inline union types across 5 locations (follows codebase convention)
- Create TaskOutcomeBadge shared component with consistent icon/color
  per outcome: completed=CheckCircle2/green, archived=Archive/green,
  deleted=Trash2/muted — eliminates duplicated rendering logic across
  SortableFeatureCard, FeatureCard, FeatureDetailPanel, PhaseCard
- Use text-muted-foreground for deleted outcome instead of misleading
  green success styling in all views

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): revert feature state when task is unarchived

When unarchiveTasks() is called, linked roadmap features are now reverted
from status='done'/taskOutcome='archived' back to status='in_progress'
with taskOutcome cleared. Without this, unarchived tasks left their
roadmap features permanently stuck in the archived state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): preserve original status on outcome update and fix deletion ordering

- Save previous_status before overwriting to 'done' so unarchive restores
  the correct original status instead of always defaulting to 'in_progress'
- Move roadmap feature update after hasErrors check in task deletion so
  roadmap is only updated on successful deletion

* update to .md

* fix(roadmap): round-trip previous_status and add backend completed handling

- Add previousStatus to RoadmapFeature interface so it survives
  renderer-initiated saves through the ROADMAP_SAVE handler
- Map previous_status in both ROADMAP_GET and ROADMAP_SAVE handlers
- Add backend-side roadmap update on PR creation so completed outcome
  is handled server-side like deleted and archived outcomes

* fix(roadmap): preserve previousStatus in renderer and guard empty task list

- Add previousStatus preservation to markFeatureDoneBySpecId so renderer
  path matches backend behavior for unarchive revert
- Guard reconcileLinkedFeatures against empty task arrays to prevent
  falsely marking all linked features as deleted
- Fix broken code fence in CLAUDE.md (2 backticks → 3)

* fix(roadmap): clear taskOutcome when feature is moved away from done

When dragging a feature out of the 'done' column via Kanban, clear
taskOutcome and previousStatus so stale outcome badges don't persist.

* fix(roadmap): clear task_outcome in IPC handler and add test coverage

- ROADMAP_UPDATE_FEATURE handler now clears task_outcome and
  previous_status when status moves away from done, matching the
  renderer store behavior
- Add tests for markFeatureDoneBySpecId (previousStatus preservation,
  taskOutcome setting, feature isolation)
- Add tests for updateFeatureStatus clearing taskOutcome/previousStatus

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:04:57 +01:00
Andy 3791b37bbd fix(github): resolve PR review hanging in bundled app (#1793)
* fix(github): resolve PR review hanging in bundled app

Use getEffectiveSourcePath() and getConfiguredPythonPath() in
subprocess-runner.ts so the GitHub PR review runner correctly
locates the backend and Python executable in packaged Electron
builds — same pattern already used by title-generator and insights.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(github): remove dead code and update stale JSDoc

Address PR review findings:
- Remove unused fileURLToPath import, __filename and __dirname declarations
- Update getBackendPath() JSDoc to reflect new path resolution strategy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(github): guard getPythonPath managed env with isEnvReady check

Only use the managed Python path when pythonEnvManager.isEnvReady()
is true, preventing the bare 'python' fallback from
getConfiguredPythonPath() from being used when the managed env
isn't set up. The backendPath .venv fallback remains for dev mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:45:34 +01:00
StillKnotKnown 2823873566 feat(profiles): implement unified profile swapping across OAuth and API accounts (#1794)
* auto-claude: subtask-1-1 - Create UnifiedAccount type in shared/types

- Add unified-account.ts with UnifiedAccount interface
- Extract type from AccountPriorityList.tsx for reusability
- Add JSDoc documentation for all fields
- Export new types from index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(profiles): implement unified profile swapping across OAuth and API accounts

Implements cross-type account switching between OAuth profiles (Claude Code
subscription) and API profiles (pay-per-use endpoints) when reaching usage
limits.

Changes:
- Add conversion utilities (claudeProfileToUnified, apiProfileToUnified) to
  unified-account.ts for converting profile types to unified format
- Add checkAPIProfileAvailability function for API profiles (no usage limits)
- Add getBestAvailableUnifiedAccount function for unified OAuth + API selection
- Add loadAPIProfiles method to ClaudeProfileManager
- Add getBestAvailableUnifiedAccount async method to ClaudeProfileManager
- Add QUEUE_GET_BEST_UNIFIED_ACCOUNT IPC channel and handler
- Add getBestUnifiedAccount method to queue preload API

All 3055 frontend tests pass. Backward compatibility maintained - existing
getBestAvailableProfile continues to work for OAuth-only scenarios.

Task: 070-unified-profile-swapping-across-oauth-and-api-acco

* fix(profiles): address code review feedback on unified profile swapping

- Fix critical bug: activeAPIId now correctly read from profiles.json's
  activeProfileId instead of incorrectly comparing OAuth ID against API IDs
- Fix high severity: scoreUnifiedAccount now enforces usage thresholds
  (sessionThreshold, weeklyThreshold) matching OAuth-only behavior
- Fix medium: Remove redundant rate limit check in claudeProfileToUnified
- Fix medium: Change apiProfileToUnified isAuthenticated default to false
  for safer default behavior
- Fix minor: Add guard against double-prefixing in toOAuthUnifiedId and
  toAPIUnifiedId helper functions
- Remove unused checkAPIProfileAvailability function

All 3055 frontend tests pass.

* refactor(profiles): move runtime functions from types to utils

Follow project convention by keeping shared/types/ for type definitions
only. Move conversion utilities and helper functions to shared/utils/:

- Create shared/utils/unified-account.ts for runtime functions
- Keep only types/interfaces in shared/types/unified-account.ts
- Update import in profile-scorer.ts to use new utils location

Functions moved:
- claudeProfileToUnified()
- apiProfileToUnified()
- isOAuthAccountId()
- isAPIAccountId()
- extractProfileId()
- toOAuthUnifiedId()
- toAPIUnifiedId()
- OAUTH_ID_PREFIX / API_ID_PREFIX constants

All 3055 frontend tests pass.

* fix(profiles): fix unified account authentication and ID handling

Critical fixes:
- Fix proactive switching: extractProfileId() now strips prefix before
  calling setActiveProfile/setActiveAPIProfile (fixes HIGH severity bug
  where prefixed IDs like 'oauth-primary' were passed to functions
  expecting raw IDs like 'primary')
- Fix OAuth profile authentication: claudeProfileToUnified now accepts
  explicit isAuthenticated option, and profile-scorer computes it using
  isProfileAuthenticated() before conversion (fixes critical bug where
  OAuth profiles scored -1000 due to undefined isAuthenticated)

Changes:
- Add isAuthenticated option to claudeProfileToUnified in unified-account.ts
- Compute isProfileAuthenticated() in profile-scorer.ts OAuth conversion loop
- Use extractProfileId() in usage-monitor.ts proactive switching
- Add TODO for API key validation tracking

All 3055 frontend tests pass.

* refactor(profiles): improve unified account selection API and logging

- Add UnifiedAccountSelectionOptions interface for cleaner API
- Gate debug logs behind isDebug flag to prevent PII leakage in production
- Fix new Date() allocation in rate limit check (compute once)
- Add needsReauthentication field to apiProfileToUnified for consistency

Addresses CodeRabbit feedback on PR #1794.

* refactor(profiles): address CodeRabbit feedback on unified account handling

- Use OAUTH_ID_PREFIX constant instead of hardcoded string
- Extract duplicated loadProfilesFile logic into shared helper
- Add cross-type prefix collision guards in toOAuthUnifiedId/toAPIUnifiedId
- Remove unnecessary extractProfileId call in usage-monitor (id is already raw)
- Remove unused import of extractProfileId

Addresses CodeRabbit feedback on PR #1794.

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:45:06 +01:00
StillKnotKnown 4f1b7b2a95 test: improve backend memory system test coverage to 100% (#1780)
* test: add comprehensive test suite for backend memory system

Add 25 test files covering the integrations/graphiti memory system:
- Core module tests (client, queries, search, graphiti, schema)
- Migration tests (migrate_embeddings, kuzu_driver_patched)
- Provider tests (6 embedder + 6 LLM providers)
- Cross-encoder and config tests

Coverage achievements:
- 134 passing tests for core modules
- graphiti.py: 95%, queries.py: 87%, client.py: 96%
- cross_encoder.py: 74%, search.py: 95%, config.py: 94%
- Overall: 51% coverage (up from 46%)

Tests were moved from apps/backend/tests/ (gitignored) to
tests/integrations/ to be included in version control.

* test: add pytest configuration with markers for long-running tests

Add pyproject.toml for backend testing with:
- pytest markers for slow/integration/smoke tests
- optimized test configuration (maxfail, -v, -m "not slow")
- coverage settings with HTML and terminal reporting
- mypy configuration for type checking

This ensures long-running tests are excluded from default CI runs
while maintaining comprehensive test coverage reporting.

* fix: resolve F821 undefined name errors in test_kuzu_driver_patched.py

Fixed 14 F821 undefined name errors for mock_kuzu_driver_module by
adding proper local definitions before each patch.dict call in test
methods that use the mock.

Also fixed encoding issue in test_config.py (added encoding='utf-8' to
open() call).

All 426 tests now pass with pre-commit hooks successful.

* test: add tests for __init__.py and providers.py modules

Added comprehensive test coverage for:
- integrations/graphiti/__init__.py: Test lazy import __getattr__ functionality
- integrations/graphiti/providers.py: Test re-exported items from graphiti_providers

These modules now have 100% test coverage.

* test: add error path tests for cross_encoder.py

Added tests for:
- ImportError when graphiti_core modules not available
- Exception during reranker creation

cross_encoder.py now has 100% test coverage (23 statements).

* test: add test for Windows non-pywin32 import error

Added test for Windows-specific import error that is not a pywin32 error,
which logs a debug message instead of an error.

client.py coverage improved from 95.9% to 96.7% (4 lines remaining).

* test: add fast success path tests for azure_openai_llm and openrouter_llm

Added fast (non-slow) tests for the success paths in:
- azure_openai_llm.py: Now 100% coverage (was 83.3%)
- openrouter_llm.py: Now 100% coverage (was 83.3%)

Both files now have complete test coverage without relying on slow test markers.

* test: add fast success path tests for azure_openai and openai embedders

Added fast (non-slow) tests for the success paths in:
- azure_openai_embedder.py: Now 100% coverage (was 87.5%)
- openai_embedder.py: Now 100% coverage (was 81.8%)

Both embedder files now have complete test coverage without relying on slow test markers.

* test: add fast success path tests for voyage, openrouter, and ollama embedders

Added fast (non-slow) tests for the success paths in:
- voyage_embedder.py: Now 100% coverage (was 81.8%)
- openrouter_embedder.py: Now 100% coverage (was 81.8%)
- ollama_embedder.py: Now 100% coverage (was 76.0%)

All embedder files now have complete test coverage without relying on slow test markers.

* test: add fast success path tests for ollama, openai, and anthropic LLM providers

Added fast (non-slow) tests for the success paths in:
- ollama_llm.py: Now 100% coverage (was 66.7%)
- openai_llm.py: Now 93.8% coverage (was 56.2%)
- anthropic_llm.py: Now 91.7% coverage (was 58.3%)

All LLM providers now have comprehensive test coverage without relying on slow test markers.

* test: improve backend memory system test coverage to 55.8%

- 100% coverage for 26 files including:
  - All embedder providers (ollama, openai, azure_openai, voyage, openrouter)
  - All LLM providers (ollama, openai, azure_openai, anthropic, openrouter)
  - validators.py, utils.py, search.py, client.py, schema.py
  - All __init__.py modules in providers_pkg

- Added comprehensive tests for:
  - validator functions (validate_embedding_config, test_llm_connection,
    test_embedder_connection, test_ollama_connection)
  - search methods (non-dict content handling, JSON decode errors)
  - provider exceptions and error handling
  - Fast test variants for slow-marked tests

- Fixed namespace package mocking for google providers
- Improved test patterns for local imports and exception handlers

507 tests passing

* test: improve queries.py coverage to 100%

- Added tests for duplicate_facts exception handling in:
  - gotchas_discovered (lines 418-419)
  - approach_outcome (lines 457-458)
  - recommendations (lines 488-489)

- Added test for outer exception handler (lines 499-523)
- Removed duplicate test definition
- All tests passing with comprehensive exception coverage

42 tests passing, 100% coverage for queries.py

* test: improve google_embedder.py, google_llm.py, migrate_embeddings.py coverage

- google_embedder.py: 100% coverage (was 42.9%)
- google_llm.py: 100% coverage (was 39.6%)
- migrate_embeddings.py: 61.5% coverage (was 33.3%)

Changes:
- Added fast variants of async tests without @pytest.mark.slow
- Added tests for assistant role handling in google_llm.py
- Added tests for JSON decode error handling in google_llm.py
- Added tests for timestamp parsing in migrate_embeddings.py
- Added tests for target exception handler in EmbeddingMigrator.initialize
- Fixed automatic_migration test config mocking to use side_effect

Overall coverage: 63.3% (30 files at 100%)

* test: improve kuzu_driver_patched.py coverage to 34.2%

- Added fast variant of execute_query test without @pytest.mark.slow
- Added fast variant of empty results test
- Fixed graphiti_core.graph_queries mocking in fast test
- Renamed slow variant to avoid duplicate test name

kuzu_driver_patched.py: 34.2% coverage (was 22.8%)
Overall coverage: 63.8% (30 files at 100%)

* test: improve backend memory system test coverage to 100%

- Add pragma: no cover comments for unreachable defensive code in config.py,
  memory.py, and kuzu_driver_patched.py (hard-to-test import-time fallbacks)

- Add comprehensive test files:
  - test___init__.py: Tests for lazy import pattern in __init__.py
  - test_graphiti.py: Comprehensive tests for GraphitiMemory class (100% coverage)
  - test_memory.py: Tests for memory.py facade functions
  - test_providers_facade.py: Tests for providers.py re-export facade

- Enhance existing test files:
  - test_config.py: Add test_get_graphiti_status_invalid_config_sets_reason
  - test_kuzu_driver_patched.py: Add tests for create_patched_kuzu_driver
  - test_migrate_embeddings.py: Add tests for migration scenarios

Coverage results:
- 684 tests passing, 7 skipped
- 93.1% overall coverage
- All core memory system files at 100% line coverage:
  - config.py, memory.py, migrate_embeddings.py
  - graphiti.py, kuzu_driver_patched.py, queries.py
  - client.py, search.py, schema.py
  - __init__.py, providers.py

* fix: address CodeRabbit AI review feedback

Fix all 21 test files as reported by CodeRabbit AI:

1. test___init__.py - Replace exec-based dynamic imports with importlib.import_module + getattr
2. test_client.py - Remove unused "result" assignments, remove unused imports
3. test_cross_encoder.py - Update test to actually call create_cross_encoder and assert base_url is preserved
4. test_graphiti_memory.py - Replace /tmp paths with tempfile.mkdtemp(), change datetime.now() to datetime.now(timezone.utc)
5. test_kuzu_driver_patched.py - Add assertions that install_calls and load_calls are non-empty after setup_schema
6. test_memory.py - Remove unused AsyncMock import, fix test to re-raise AssertionError
7. test_migrate_embeddings.py - Remove unused imports, remove duplicate slow tests
8. test_provider_naming.py - Remove sys.path.insert, fix imports properly, add assertions to verify behavior
9. test_providers_facade.py - Make assertion count derive from expected_exports list
10. test_providers_google.py - Remove duplicate slow tests, add assertion for embed_content call, remove unused AsyncMock
11. test_providers_llm_anthropic.py - Replace custom __getattr__ stub with ModuleType
12. test_providers_llm_azure_openai.py - Remove unused sys import
13. test_providers_llm_google.py - Remove unused AsyncMock import
14. test_providers_llm_openai.py - Add assertions for reasoning/verbosity parameters in GPT-5/O1/O3 tests
15. test_providers_llm_openrouter.py - Replace builtins.__import__ with sys.modules patch, remove redundant test
16. test_providers_voyage.py - Clear sys.modules cache before import test, instantiate MagicMocks properly
17. test_queries.py - Remove unused datetime, timezone imports
18. test_schema.py - Fix MAX_RETRIES test consistency (change >= 0 to > 0)
19. test_search.py - Fix non-dict content test, rename unused result to _result, remove unused Path import

* fix: address remaining CodeRabbit AI feedback

Fixed multiple test file issues reported by CodeRabbit AI:
- test_provider_naming.py: Removed excessive print statements
- test___init__.py: Updated lazy import test to handle ImportError gracefully
- test_client.py: Renamed test to match assertion (test_returns_true_if_already_initialized)
- test_cross_encoder.py: Added underscore prefix to unused result variable
- test_kuzu_driver_patched.py: Removed unused imports (re, Mock)
- test_memory.py: Removed unused Path import
- test_migrate_embeddings.py: Updated test to use caplog, attached mock_target_client
- test_providers_facade.py: Fixed EMBEDDING_DIMENSIONS test to check model names not providers
- test_providers_google.py: Added comment to DEFAULT_GOOGLE_EMBEDDING_MODEL test
- test_providers_llm_anthropic.py: Removed dead skipped test
- test_providers_llm_azure_openai.py: Removed unused LLMConfig import
- test_providers_llm_openai.py: Fixed patch path to target graphiti_core module
- test_providers_llm_openrouter.py: Fixed patches for create_openrouter_llm_client imports
- test_queries.py: Parametrized repetitive tests, improved autouse fixture cleanup
- test_search.py: Added underscore prefix to unused local variables

All tests pass (683 passed, 6 skipped) and ruff lint reports no errors.

* fix: address AndyMik90 PR review feedback - code duplication

Fixes:
- Extract repeated sys.modules cleanup into isolate_kuzu_module fixture in test_client.py
- Add _build_sys_modules_dict helper to eliminate 25-line sys.modules patching duplication in test_kuzu_driver_patched.py
- Fix inconsistent pragma in memory.py (lines 95-96 now both marked)
- Update testpaths in pyproject.toml to include "integrations/graphiti/tests"
- Remove duplicate test___init__.py file
- Remove coverage.json from git and add to .gitignore

Code reduction: 598 deletions vs 310 insertions
All 666 tests passing.

* fix: address detailed PR review feedback on test files

Fixes:
- test_client.py: Removed redundant _apply_ladybug_monkeypatch() call, fixed convoluted pywin32 assertion, used call.kwargs directly
- test_cross_encoder.py: Extracted duplicate sys.modules mocking into graphiti_core_mocks fixture
- test_kuzu_driver_patched.py: Parameterized slow tests, split test_execute_query_handles_empty_results, updated build_indices assertions to check SQL strings
- test_memory.py: Fixed fragile import mocking to only raise for graphiti_core imports
- test_migrate_embeddings.py: Created distinct MagicMock instances per iteration to avoid mutation issues
- test_provider_naming.py: Removed print statements and script-entry guard, used explicit config values, strengthened assertions
- test_providers_facade.py: Extracted expected_exports list into module-level constant
- test_providers_google.py: Extracted repeated MagicMock setup into google_genai_mock fixture
- test_providers_llm_openai.py: Replaced tautological assertions with concrete expectations and parametrized slow tests
- search.py: Fixed min_score filtering to handle None scores by normalizing to 0.0

All 667 tests passing.

* fix: address additional detailed PR review feedback

Fixes:
- search.py: Normalized result.score in get_patterns_and_gotchas and get_similar_task_outcomes to handle None values
- test_client.py: Fixed test_returns_false_when_ladybug_unavailable to ensure graphiti_core is present, extracted repeated boilerplate into graphiti_mocks fixture
- test_cross_encoder.py: Added concrete assertion for base_url value, removed original_func indirection
- test_kuzu_driver_patched.py: Added module-level MockKuzuDriver class, added DROP_FTS_INDEX assertion to test_build_indices_with_delete_existing
- test_memory.py: Fixed tautological else branch with concrete assertion
- test_migrate_embeddings.py: Renamed mock configs to match actual roles (current_config, source_config, target_config)
- test_provider_naming.py: Removed unused pytest import and unused embedding_model variable
- test_providers_google.py: Added sys.modules patching to test_google_embedder_init_import_error
- test_providers_llm_openai.py: Fixed patch target path for OpenAIClient to use consuming module's namespace

All 667 tests passing.

* fix: remove duplicate tests and improve test coverage

Fixes:
- test_client.py: Removed duplicate test_initialize_returns_false_on_ladybug_unavailable
- test_client.py: Removed duplicate test_updates_state_with_init_info
- test_cross_encoder.py: Changed unused result variable to _ discard
- test_kuzu_driver_patched.py: Removed duplicate test_execute_query_returns_rows
- test_memory.py: Added pytest.importorskip guards for graphiti_providers package
- test_provider_naming.py: Changed `if dim:` to `if dim is not None:`, converted for-loop to pytest.mark.parametrize

All 668 tests passing.

* fix: address PR review feedback - score normalization and code duplication

- Fix score normalization to correctly handle score of 0 vs None
  - Changed `getattr(result, "score", None) or 0.0` to explicit None check
  - This prevents treating a legitimate score of 0 as None

- Refactor test_client.py to eliminate code duplication
  - Created _make_mock_config() helper function for consistent mock config creation
  - Extended graphiti_mocks fixture with better documentation
  - Converted 15+ tests to use the fixture instead of duplicated boilerplate
  - Removed ~330 net lines of duplicated setup/teardown code

Addresses HIGH and MEDIUM severity issues from PR review.

* fix: address remaining medium severity PR review issues

1. Move standalone test scripts out of tests/ directory
   - Renamed test_graphiti_memory.py -> run_graphiti_memory_test.py
   - Renamed test_ollama_embedding_memory.py -> run_ollama_embedding_test.py
   - These are standalone executable scripts with argparse, not pytest tests

2. Remove fragile pytest_collection_modifyitems filtering
   - No longer needed since standalone scripts moved out of tests/
   - Only keep validator function filtering (legitimate use case)

3. Rename shadowing fixtures in test_graphiti.py
   - temp_spec_dir -> graphiti_test_spec_dir
   - temp_project_dir -> graphiti_test_project_dir
   - mock_config -> mock_graphiti_config
   - mock_state -> mock_graphiti_state
   - Names now indicate intentional difference from conftest fixtures

Addresses 3 MEDIUM severity issues from PR review.

* fix: update test_graphiti_connection for embedded LadybugDB

The function was using outdated FalkorDB configuration attributes
(falkordb_host, falkordb_port, falkordb_password) that no longer exist
on GraphitiConfig. Updated to use embedded LadybugDB via
create_patched_kuzu_driver with db_path instead.

- Replace FalkorDriver with patched KuzuDriver for embedded DB
- Use config.get_db_path() instead of host/port credentials
- Update tests to mock the new driver creation path
- Rename test to reflect new driver type

* fix: address PR review feedback on conftest fixtures and test comments

- Fix mock_config fixture to use actual GraphitiConfig fields (database
  instead of dataset_name, openai_model instead of llm_model, etc.)
- Fix mock_state fixture to use actual GraphitiState fields
- Fix mock_env_vars to use correct env var names (GRAPHITI_DATABASE,
  OPENAI_MODEL, OPENAI_EMBEDDING_MODEL)
- Fix test_search.py comments to accurately describe None->0.0 score
  conversion, add assertion to verify the behavior
- Update pyproject.toml testpaths to include core/workspace/tests
  and remove non-existent 'tests' directory

* fix: address all remaining PR review feedback including LOW severity

MEDIUM fixes:
- Update usage docs in run_graphiti_memory_test.py to reference new filename
- Update usage docs in run_ollama_embedding_test.py to reference new filename

LOW fixes:
- Fix get_relevant_context docstring: add min_score param, correct
  include_project_context description (works in SPEC mode, not PROJECT mode)
- Make mock_embedder fixture deterministic using [0.1] * 1536 instead of
  random values for reproducibility
- Add test coverage for None score handling in get_similar_task_outcomes
  and get_patterns_and_gotchas methods

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-02-12 11:40:54 +02:00
AndyMik90 5e78d748ee fix(ideation): guard against non-string properties in IdeaCard badges
Prevent "Objects are not valid as a React child" crash when the AI backend
returns malformed idea data with object properties where strings are expected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:55:30 +01:00
AndyMik90 aa5fc7f952 fix(updater): convert HTML release notes to markdown before rendering
electron-updater returns GitHub release bodies as HTML, but the update
dialog renders content with ReactMarkdown which expects markdown input.
This caused raw HTML tags to display as visible text in the update
notification. Convert HTML to markdown in formatReleaseNotes() so the
renderer's existing markdown pipeline works correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:54:21 +01:00
Andy 1d64615211 211-when-a-task-is-set-to-planning-column-on-the-kanba__JSON_ERROR_SUFFIX__ (#1786)
* auto-claude: subtask-1-1 - Add queue capacity check to handleStatusChange

When a task status is changed to 'in_progress' via handleStatusChange (e.g.,
from column header buttons or context menus), enforce the maxParallelTasks
limit by redirecting to 'queue' if capacity is full. Also auto-process the
queue when a task leaves in_progress. This mirrors the existing logic in
handleDragEnd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Add queue capacity check before startTask() in TaskCard, TaskDetailModal, WorkspaceMessages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: extract shared queue capacity logic and fix stuck task restart regression

- Extract `startTaskOrQueue()`, `isQueueAtCapacity()`, and
  `DEFAULT_MAX_PARALLEL_TASKS` into task-store.ts to eliminate identical
  queue capacity logic duplicated across 4 files (DRY violation)
- Fix stuck task restart regression: exclude the current task from the
  in_progress count so restarting a stuck task doesn't incorrectly queue it
- Fix inconsistent default: use ?? 3 everywhere (was ?? 1 in 3 new files
  vs ?? 3 in KanbanBoard, causing different behavior per UI element)
- Fix unawaited persistTaskStatus in TaskCard (was fire-and-forget in a
  sync handler) and TaskDetailModal (missing await in async handler)
- Add explanatory comment in KanbanBoard handleStatusChange about why
  isAutoPromotionInProgress guard is not needed (only user interactions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove duplicate processQueue() call in handleDragEnd

handleStatusChange already calls processQueue() when a task leaves
in_progress, so the second call in handleDragEnd was redundant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: log queue failures, remove dead bypass code, fix comment

- startTaskOrQueue now logs an error when persistTaskStatus fails
  instead of silently discarding the result
- Remove dead isAutoPromotionInProgress bypass from drag handler since
  handleStatusChange enforces capacity independently (the bypass was
  negated by the second check)
- Fix inaccurate comment: handleStatusChange is called from both the
  dropdown menu and the drag handler, not just the dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: return queue failure result from startTaskOrQueue and remove duplicate processQueue

startTaskOrQueue now returns a result object so callers can surface errors
to the user (toast in TaskDetailModal, console.error in WorkspaceMessages).
Removed explicit processQueue() from handleStatusChange since the useEffect
task status change listener already handles queue auto-promotion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct i18n key path and surface startTaskOrQueue failures to users

Fix wrong i18n key path (tasks:errors → tasks:wizard.errors) so the toast
shows the translated message instead of a raw key. Add toast feedback in
TaskCard on start failure. Add inline error display in WorkspaceMessages
when Proceed to Coding fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: show user feedback when task is queued instead of started

All three startTaskOrQueue callers (TaskCard, TaskDetailModal,
WorkspaceMessages) now notify the user when a task is redirected to the
queue due to the parallel task limit. Uses existing i18n keys
(tasks:queue.movedToQueue). Also clarifies startTaskOrQueue JSDoc
regarding fire-and-forget semantics of the 'started' action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use i18n and neutral styling for queued notice in WorkspaceMessages

Replace hardcoded English string with t('tasks:queue.movedToQueue') and
use a separate notice state with text-muted-foreground styling instead of
reusing the destructive error state. Also add missing status.queue key
to French translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:41:51 +01:00
Andy cd89147003 fix(pr-review): simplify structured output schema to reduce validation failures (#1787)
The ParallelFollowupResponse JSON schema was 10,743 chars with strict
constraints, causing LLM structured output validation failures after long
multi-agent sessions. Reduced to 4,561 chars (58% reduction) by removing
unused fields and relaxing unnecessary constraints.

- Remove unused fields: analysis_summary, commits_analyzed, files_changed,
  comment_analyses, agent_agreement, source_agent, related_to_previous,
  evidence (deprecated), end_line, and CommentAnalysis model
- Relax constraints: remove min_length validators, make line_range optional,
  change verification_method from Literal to str with default
- Update prompts to match simplified schema
- Fix flaky test_allows_normal_commit by adding monkeypatch.chdir for git
  isolation during pre-commit hook execution

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:14:53 +01:00
Andy ded6aad4f7 Fix Title Generation Production Build & Add Sentry Observability (#1781)
* auto-claude: subtask-1-1 - Add Sentry instrumentation to TitleGenerator

Add Sentry breadcrumbs and captureException calls to TitleGenerator.generateTitle()
at key decision points: source path resolution, Python path resolution, process spawn,
process exit (success/failure/timeout), rate limit detection, and process errors.
All Sentry calls wrapped in try/catch to prevent cascading failures.
Extended sentry-electron type stubs with addBreadcrumb and captureContext support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Replace spawn env with pythonEnvManager.getPythonEnv()

Replace process.env spread with pythonEnvManager.getPythonEnv() as the base
environment for the title generator subprocess. Add getSentryEnvForSubprocess()
overlay and a guard for pythonEnvManager.isEnvReady() that falls back gracefully.
Remove manual PYTHONUNBUFFERED/PYTHONIOENCODING/PYTHONUTF8 vars since
pythonEnvManager.getPythonEnv() already sets them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Add Sentry breadcrumbs to TASK_CREATE and TASK_UPDATE handlers

Add breadcrumbs for title generation lifecycle: invocation, success,
fallback to description truncation, and error cases. All Sentry calls
wrapped in try/catch for safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings for Sentry instrumentation

- Extract safeBreadcrumb() and safeCaptureException() helpers to sentry.ts,
  replacing repetitive try/catch boilerplate across title-generator and crud-handlers
- Extract generateTitleWithFallback() shared helper in crud-handlers.ts,
  eliminating ~100 lines of duplicated title generation logic between TASK_CREATE
  and TASK_UPDATE
- Add missing PYTHONUNBUFFERED=1 to title-generator subprocess env to match
  all other subprocess spawners in the codebase
- Move isEnvReady() guard before 'Spawning process' breadcrumb and reuse the
  cached venvReady variable instead of calling isEnvReady() twice

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:12:48 +01:00
Andy f149a7fbd7 fix(qa): enforce visual verification for UI changes and inject startup commands (#1784)
* fix(qa): enforce visual verification for UI changes and inject startup commands

QA agents were silently skipping visual verification even for UI changes,
leading to unverified CSS/layout regressions. This makes visual verification
mandatory when UI files are in the diff, injects project startup commands
into the QA context so agents can self-start dev servers, and surfaces a
structured verification requirements table based on detected capabilities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(qa): address PR review findings for qa-validation

- Handle both dict and list formats for services in QA prompt builder,
  matching the defensive pattern already used in project_context.py
- Use detected package_manager instead of hardcoding 'npm' in dev_command
- Rename 'Browser verification' to 'Visual verification' in Phase 10
  completion signal to match the renamed Phase 4 section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:12:26 +01:00
Andy c2245b8122 fix(plan-files): use atomic writes to prevent 0-byte corruption (#1785)
* fix(plan-files): use atomic writes to prevent 0-byte corruption

writeFileSync truncates the file before writing content. If the process
crashes between truncation and write, the file is left at 0 bytes,
causing "Unexpected end of JSON input" errors on next load.

Replace all bare writeFileSync calls for implementation_plan.json with
atomic write-to-temp-then-rename pattern across plan-file-utils.ts and
project-store.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: consolidate atomic write implementations into shared utility

Add writeFileAtomicSync to atomic-file.ts and replace three duplicate
implementations in plan-file-utils.ts, execution-handlers.ts, and
project-store.ts. Also convert the bare writeFileSync in
updateTaskMetadataPrUrl to use the atomic variant for consistency.
Uses randomBytes for collision-safe temp file naming instead of
process.pid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add path.resolve to writeFileAtomicSync and add test coverage

Add path.resolve() for API consistency with the async writeFileAtomic
variant. Add test suite covering: writing new files, overwriting,
Buffer data, relative path resolution, temp file cleanup on success
and error, and missing directory errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: improve writeFileAtomicSync tests and JSDoc

Use readdirSync instead of async fsPromises.readdir in sync tests.
Replace vacuous cleanup test with one that actually exercises the
unlinkSync cleanup path by targeting a directory (rename fails after
temp file creation). Add JSDoc note that sync variant does not create
parent directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use atomic writes for ProjectStore.save() and archive/unarchive

Replace bare writeFileSync with writeFileAtomicSync in the save()
method (highest-traffic write path) and in archiveTasks/unarchiveTasks
for task_metadata.json writes. Remove unused writeFileSync import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:11:52 +01:00
AndyMik90 950da45e4a fix(terminal): make worktree dropdown scrollable and show all items
Replace Radix ScrollArea with a plain overflow-y-auto div and increase
max height from 300px to min(500px, 60vh). The Radix ScrollArea wasn't
scrolling properly, causing task worktrees (209, 210, 211) to be hidden
below the fold with no visible scrollbar on macOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:59:25 +01:00
Andy 25acf2826c auto-claude: subtask-1-1 - Add adaptive thinking badge to thinking level label (#1782)
Import ADAPTIVE_THINKING_MODELS and Tooltip components, then add conditional
adaptive thinking badge with tooltip next to the thinking level label in the
phase configuration section, matching the pattern from AgentProfileSettings.tsx.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:23:36 +01:00
AndyMik90 5ac40f57c1 feat(subtasks): prevent text overflow in task modal
Prevent subtask text (titles, descriptions, and file badges) from overflowing outside the visible area in the task detail modal's Subtasks tab. Update TaskSubtasks component styling to ensure proper text containment.
2026-02-11 10:58:23 +01:00
AndyMik90 39aa088725 auto-claude: subtask-1-1 - Add overflow-hidden and break-words to subtask cards
Prevents text from escaping subtask card boundaries by adding overflow-hidden
to card containers and break-words to description text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:30:08 +01:00
AndyMik90 8de8039db2 refactor(app-updater): disable automatic downloads and allow intentional downgrades
- Changed autoUpdater.autoDownload to false to control downloads manually, preventing unintended downgrades.
- Introduced intentionalDowngrade flag to allow explicit downgrades when switching from beta to stable versions.
- Updated logging to reflect the new download behavior and added checks to skip non-newer updates unless intentional.
- Enhanced update handling to ensure only valid updates are downloaded and installed.
2026-02-11 10:27:53 +01:00
AndyMik90 68e782df1f fix terminal grids/resize 2026-02-11 10:27:23 +01:00
AndyMik90 6f751e5e74 chore: bump version to 2.7.6-beta.3 2026-02-11 09:29:24 +01:00
Andy f4788e4af8 fix(auth): detect auth errors in AI response text and prevent retry loops (#1776)
* fix(auth): detect auth errors returned as AI response text and prevent retry loops

Auth errors like "Your account does not have access to Claude" were returned
as conversational AI text rather than HTTP errors, causing process_sdk_stream
to loop ~500 times until the circuit breaker killed the session. This adds
detection at three layers:

- sdk_utils: _is_auth_error_response() catches auth errors in AI text blocks
  and breaks the stream immediately; repeated identical response detection
  aborts after 3 consecutive repeats
- error_utils: "does not have access to claude" and "please login again"
  patterns added to is_authentication_error()
- rate-limit-detector: matching regex patterns added to AUTH_FAILURE_PATTERNS
  for Electron-side subprocess monitoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): prevent false positive auth modal from AI response text

The previous commit (825c6217) added broad auth detection patterns that
match on normal AI discussion text — e.g., a PR review agent discussing
authentication would trigger the auth failure modal incorrectly.

Frontend: Remove two overly broad regex patterns from AUTH_FAILURE_PATTERNS
("does not have access to Claude", "please login again"). Real auth errors
are already caught by the remaining 11 structured patterns (JSON types,
HTTP status codes, CLI bracket-prefixed messages, Error: prefix).

Backend: Add MAX_AUTH_ERROR_LENGTH (300) guard to _is_auth_error_response()
so long AI discussion text mentioning auth topics is not flagged. Real API
auth error messages are consistently under 100 chars.

Tests: Replace removed positive-match tests with false-positive regression
test. Add backend boundary tests at exactly 300/301 chars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): address PR review findings in sdk_utils

- Remove redundant "does not have access to claude" pattern since
  "not have access to claude" already subsumes it as a substring
- Wrap repeated-response tracking in `if _stripped:` so empty text
  blocks don't reset the counter (prevents theoretical loop evasion)
- Add clarifying comment that auth error break exits inner for-loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(auth): remove overly broad access pattern and lower repeat threshold

Remove "account does not have access" from _is_auth_error_response() as
it could false-positive on short AI responses about general access control.
Lower REPEATED_RESPONSE_THRESHOLD from 3 to 1 so error loops (including
auth errors returned as AI text) are caught after just 2 identical messages,
making broad content matching unnecessary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:01:39 +01:00
StillKnotKnown 3f95765cf2 test: achieve 100% coverage for backend core workspace module (#1774)
* test: implement comprehensive test coverage for workspace module

Added extensive test coverage for the backend core workspace module:

- __init__.py: 100% coverage (workspace mode selection, uncommitted changes)
- display.py: 100% coverage (build summaries, conflict info display)
- models.py: 96% coverage (ParallelMergeTask, MergeLock, SpecNumberLock)
- git_utils.py: 93% coverage (file renames, path mapping, git operations)
- finalization.py: 86% coverage (workspace finalization workflows)
- setup.py: 61% coverage (env files, node_modules, spec copying)

Test Results:
- 367 tests passing, 1 skipped
- Overall coverage: 86% (899 statements)
- New test classes for all uncovered functions

* test: reorganize workspace tests to backend directory and improve coverage to 94%

- Move tests/test_workspace.py to apps/backend/tests/test_workspace.py for better co-location
- Add pytest.ini to apps/backend/ for backend-specific test configuration
- Improve coverage from 86% to 94% (+53 new tests)
- finalization.py: 86% → 97%
- git_utils.py: 93% → 99%
- models.py: 96% → 96%
- setup.py: 61% → 83%
- All 419 tests passing with proper long-running test markers

* test: fix test colocation - move workspace tests to module tests/ subfolder

Per test-team-implementer skill requirements, tests MUST be in tests/
subfolder within each module, not at the backend/tests level.

- Move test_workspace.py from apps/backend/tests/ to apps/backend/core/workspace/tests/
- Remove apps/backend/pytest.ini (no longer needed)
- Follow proper test colocation: module/tests/test_*.py pattern

This ensures tests are properly co-located with their source code for
better maintainability and clearer module associations.

* test: fix imports for co-located tests in workspace module

- Add sys.path fix to import parent workspace module
- Import WorktreeError for proper exception handling
- Copy conftest.py to tests/ subfolder for fixtures
- All 422 tests now passing from new location

Tests are now properly co-located at:
apps/backend/core/workspace/tests/test_workspace.py

* test: add finalization cd path tests and fix imports

Adds tests for finalization workspace cd path display when
get_existing_build_worktree returns None or a valid path.
Fixes sys.path manipulation for co-located tests in workspace
module tests/ subfolder.

Coverage improved from 97% to 99% for finalization.py.
Overall workspace coverage: 92% (420 tests passing).

* test: achieve 100% coverage for backend core workspace module

- Fixed 2 failing npx_fallback tests with correct Path.exists mocking
- Added pytest.ini with slow/integration marker registration
- Enhanced debug fallback test with proper import blocking
- Added setup_method to reset _git_hook_check_done global flag
- Added tests for hook installation edge cases (existing hook, exception handling)
- Added mock-based test for ValueError exception handler in _scan_specs_dir
- Renamed duplicate test classes to avoid F811 errors

Coverage Results:
- core/workspace/__init__.py: 100% (26 statements)
- core/workspace/display.py: 100% (109 statements)
- core/workspace/finalization.py: 100% (229 statements)
- core/workspace/git_utils.py: 100% (183 statements)
- core/workspace/models.py: 100% (147 statements)
- core/workspace/setup.py: 100% (205 statements)
- TOTAL: 100% (899 statements, 0 missed)

451 tests passed, 4 skipped (Windows-specific)

* fix: resolve CI failures - remove deleted test_discovery import

- Removed import of deleted analysis.test_discovery module from analysis/__init__.py
- Updated __all__ list to remove TestDiscovery export
- Added CodeQL exemption comment for intentionally unused merge imports in workspace conftest

Fixes: ModuleNotFoundError: No module named 'analysis.test_discovery'

* fix: remove TestDiscovery dependency and fix CodeQL warnings

- Removed TestDiscovery import from runners/github/services/review_tools.py
- Simplified run_tests() function to try common test commands instead of using TestDiscovery
- Fixed CodeQL unused import warnings in core/workspace/tests/conftest.py by using assignment

The TestDiscovery module was deleted as part of test colocation effort.
The run_tests() function now tries common test commands (pytest, npm test, etc.)
in order until one executes successfully.

Fixes: ModuleNotFoundError: No module named 'analysis.test_discovery'
Fixes: CodeQL unused import warnings for merge module imports

* fix: resolve remaining CI failures

- Delete root-level tests/test_discovery.py (tests deleted test_discovery module)
- Apply ruff formatting to runners/github/services/review_tools.py

Fixes CI errors:
- ModuleNotFoundError: No module named 'test_discovery' (root test import)
- Ruff formatting check failure in review_tools.py

* fix: resolve CodeQL warnings and test coverage issues

- Fixed chmod permissions (0o755 → 0o700) to avoid overly permissive file warnings
- Fixed pytest.raises unreachable code warnings by moving assertions inside with blocks
- Removed unused variables: git_add_line, temp_files_before, copied, warning_found, _merge_imports
- Fixed unused stdout/stderr in review_tools.py by using underscore discard pattern

Fixes CodeQL alerts:
- 3 High severity: Overly permissive file permissions
- 2 Warnings: Unreachable code
- 9 Notes: Unused variables

Improves test code quality and security posture.

* fix: resolve CodeQL failure and address PR review feedback

- Remove unused 'import sys' from workspace/__init__.py (NEW-004)
- Fix IndexError edge case in mock_run_agent_fn for empty side_effect (NEW-001)
- Fix fragile import from tests.test_fixtures with try/except fallback (NEW-003)
- Fix proc.returncode bug in review_tools.py - now checks for exit codes 126/127

Fixes CodeQL CI failure by removing unused sys import.
Also addresses Sentry bot bug report about test command fallback mechanism.

Related PR review findings:
- NEW-004: Unused 'import sys' removed
- NEW-001: Added guard for empty side_effect list
- NEW-002: Already fixed - call_count now properly synced
- NEW-003: Wrapped import in try/except with fallback definitions

* fix: remove private functions from __all__ and add SpecNumberLock exports

- Removed 11 private (_prefixed) functions from __all__ list
- Added SpecNumberLock and SpecNumberLockError to exports for consistency
- Private functions remain as module-level assignments for internal use
- Also removed unused 'import sys' that was causing CodeQL CI failure

This addresses PR review findings:
- de54cbbac404: 13 private functions exported in all
- 4d5a452082f4: SpecNumberLock not exported via init.py
- NEW-004: Unused import sys causing CodeQL failure

The __all__ list now only contains public API exports, maintaining
the underscore convention for private/internal functions.

* fix: resolve review_tools.py double execution and resource leak bugs

High: Remove double test execution (60s check + 300s rerun)
- Now runs tests once with 300s timeout instead of twice
- Previously skipped valid tests that took >60s to complete
- Reduces test execution time by ~50% for valid test frameworks

Medium: Fix resource leak in timeout exception handler
- Now kills the correct process (proc) when timeout occurs
- Added await proc.wait() to ensure process termination before continuing
- Previously killed wrong process (already-completed proc) when proc_full timed out

Fixes Sentry bot reports on resource management and test execution efficiency.

* refactor: split monolithic test file and trim conftest.py

This commit addresses all PR review findings related to code quality
and maintainability of the workspace test suite.

Major Changes:
- Split 8,499-line test_workspace.py into 8 focused test files:
  * test_models.py (47 tests) - Workspace models and locks
  * test_rebase.py (12 tests) - Rebase detection and operations
  * test_merge.py (122 tests) - AI merge, code fences, 3way merge
  * test_display.py (46 tests) - Display and UI functions
  * test_setup.py (9 tests) - Workspace setup and configuration
  * test_finalization.py (32 tests) - Finalization workflows
  * test_git_utils.py (97 tests) - Git utilities and helpers
  * test_workspace.py (89 tests) - Core workspace functionality

- Trimmed conftest.py from 1,376 lines to 251 lines:
  * Removed ~27 unused fixtures (python_project, node_project, etc.)
  * Removed dead module_mocks dictionary referencing non-existent tests
  * Removed conditional reload logic for qa/review modules
  * Kept only essential fixtures: temp_dir, temp_git_repo, spec_dir,
    project_dir, make_commit, stage_files
  * Added repo root to sys.path for robust test_fixtures import

- Standardized import styles across all test files:
  * Changed bare `from workspace import` to `from core.workspace import`
  * Removed duplicate imports and declarations
  * Added missing model imports (MergeLock, SpecNumberLock) to
    test_workspace.py
  * Fixed encoding issues (added encoding="utf-8" to file operations)

Coverage: 99% (898 statements, 3 missing lines are defensive fallbacks)

Fixes:
- Resolved monolithic test file maintainability issue
- Fixed massive conftest.py bloat from copy-paste
- Removed unused fixtures and dead code
- Standardized import style consistency
- Fixed fragile import depending on pytest rootdir

* fix: ruff format review_tools.py logger.info call

* fix: add asyncio_mode to workspace pytest.ini

Adds asyncio_mode = auto to workspace/tests/pytest.ini to prevent
future configuration issues when async tests are added. This
addresses PR review feedback NCR-NEW-003.

The review mentioned several issues that were already addressed in
commit 89c6c08a4:
- Monolithic test file was split into 8 test files
- conftest.py was trimmed from 1,376 to 250 lines
- Import styles were standardized to from core.workspace.
- _POTENTIALLY_MOCKED_MODULES already contains only 4 SDK modules

* fix: address all 8 PR review findings from test split

Fixes all findings from the Auto Claude PR review:

MEDIUM (Blocking):
- NEW-001: Moved _original_module_state capture BEFORE pre-mocking
  so cleanup doesn't restore MagicMock objects
- NEW-002: Added missing assertion in test_fresh_choice_discards_and_returns_false
- NEW-003: Completed truncated test_validate_merged_syntax_npx_fallback_with_mock

LOW:
- NEW-004: Added is_lock_file to __all__ exports
- NEW-005: Removed duplicate TEST_SPEC_NAME in test_rebase.py
- NEW-006: Removed stray section header in test_setup.py
- NEW-007: Updated docstrings to match actual content in 4 test files
- NEW-008: Removed redundant sys.path manipulation from individual test files
  (conftest.py already handles this), kept import sys needed for platform checks

All tests pass: 450 passed, 4 skipped

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-02-10 19:00:51 +01:00
Andy 923880f5b2 fix(title-generator): add production path resolution for backend source (#1778)
* fix(title-generator): add production path resolution for backend source

getAutoBuildSourcePath() only checked development-mode relative paths,
causing AI title generation to silently fail in packaged builds. Added
app.isPackaged check with userData override and process.resourcesPath
fallbacks, matching the pattern already used by terminal-name-generator
and other services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(path-resolution): consolidate duplicated backend path logic into shared resolver

Both title-generator and terminal-name-generator duplicated the
production path resolution logic (userData override, resourcesPath
fallback, dev paths) that already exists in getEffectiveSourcePath()
from updater/path-resolver.ts. Replaced inline implementations with
the shared utility, matching the pattern used by insights/config.ts.

Also removed unused imports (app, fileURLToPath, __dirname) that
were only needed for the old inline path resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:29:27 +01:00
Andy 390ba6a588 fix(fast-mode): use setting_sources instead of env var for CLI fast mode (#1771)
* fix(fast-mode): use setting_sources instead of env var for CLI fast mode

The Claude Code CLI reads fastMode from user settings (~/.claude/settings.json),
not from environment variables. The previous CLAUDE_CODE_FAST_MODE env var approach
was non-functional. This fix writes fastMode=true to user settings before spawning
the CLI and enables the "user" setting source so the CLI reads it.

Key changes:
- Fast mode now writes to ~/.claude/settings.json with atomic file writes
- Extracted shared fast mode helpers into core/fast_mode.py (DRY)
- Moved fast mode toggle from global settings to per-task configuration
- Added opus-4.5 model option and adaptive thinking badges
- Sanitize legacy thinking levels (ultrathink→high, none→low) at all layers
- Shared LEGACY_THINKING_MAP, PHASE_KEYS, sanitizeThinkingLevel in frontend
- Moved diagnostic test script to scripts/ to prevent pytest collection
- SDK requirement bumped to >=0.1.33 for Opus 4.6 adaptive thinking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: persist fastMode toggle state in task edit and creation dialogs

When users toggled fast mode OFF, the change didn't persist because the code used
a conditional that only set fastMode when true. This meant the old fastMode: true
value was preserved during metadata merge. Now fastMode is always set explicitly,
matching the pattern used by requireReviewBeforeCoding.

Fixed in both:
- TaskEditDialog.tsx line 251
- TaskCreationWizard.tsx line 459

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings for fast mode implementation

- Fix fastMode toggle not persisting when disabled in TaskEditDialog
- Replace manual atomic write with write_json_atomic from core/file_utils
- Rename _ensure_fast_mode_in_user_settings to public (no underscore)
- Replace hardcoded validLevels with VALID_THINKING_LEVELS constant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix github issues

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:34:16 +01:00
VDT-91 aa7f56e5d0 fix(windows): complete System32 executable path fixes for where.exe and taskkill.exe (#1715)
* fix(windows): complete System32 executable path fixes for where.exe and taskkill.exe

Complete the Windows System32 executable path fixes started in #1659.
The previous fix addressed where.exe in a few frontend files but missed
several critical locations in both backend and frontend.

Backend changes:
- Add get_where_exe_path() helper in core/platform/__init__.py
- Update auth.py, git_executable.py, gh_executable.py, glab_executable.py
  to use full path instead of bare 'where' command
- Remove shell=True from subprocess calls (security improvement)

Frontend changes:
- Add getTaskkillExePath() helper in windows-paths.ts
- Update platform/index.ts, subprocess-runner.ts, pty-daemon-client.ts
  to use full path for taskkill.exe
- Update claude-code-handlers.ts to use getWhereExePath()
- Add System32 to ESSENTIAL_SYSTEM_PATHS in env-utils.ts
- Update process-kill.test.ts to mock the new helper

Why full paths:
- Works even when System32 isn't in PATH (GUI launch scenarios)
- SystemRoot env var is a protected Windows system variable
- Prevents PATH hijacking attacks
- Removing shell=True prevents command injection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for Windows System32 executable paths

- Fix missed bare 'taskkill' at subprocess-runner.ts:174 (stopFn closure)
- Fix missed bare 'where' at mcp-handlers.ts:198 (MCP health check)
- Update stale comments in gh_executable.py and glab_executable.py
  (removed incorrect "shell=True" reference, now matches git_executable.py)
- Add error logging callback for taskkill in stopFn (was empty callback)
- Improve MCP health check error messages with actionable diagnostics
  for ENOENT and EACCES errors on both Windows and Unix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use getWhereExePath() for 'where gh' in validateGitHubModule

Fix missed bare 'where gh' at line 617 in subprocess-runner.ts.
This completes the System32 executable path refactoring.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use execFileAsync for gh CLI detection consistency

Replace shell string interpolation with execFileAsync for the gh CLI
check in subprocess-runner.ts, matching the pattern used in
claude-code-handlers.ts and mcp-handlers.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(windows): address PR review findings for System32 path consistency

- Use getTaskkillExePath() in claude-code-handlers.ts install commands
  instead of bare 'taskkill' (NEW-003)
- Add ENOENT error handling in subprocess-runner.ts validateGitHubModule
  to distinguish missing where.exe from missing gh CLI (NEW-006)
- Extract shared getSystemRoot() helper in windows-paths.ts to
  deduplicate SystemRoot resolution logic (NEW-002)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(windows): add tests, export getSystemRoot, add windowsHide to MCP spawn

- Add unit tests for getWhereExePath/getTaskkillExePath with env fallback coverage
- Export getSystemRoot() for reuse across modules
- Add windowsHide: true to mcp-handlers spawn call (consistency with other sites)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-02-09 22:20:41 +01:00
1069 changed files with 69736 additions and 197739 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.env*
*.md
tests
scripts
guides
apps/frontend
apps/backend
+165
View File
@@ -0,0 +1,165 @@
# Publish OSS Packages to npm
#
# Builds and publishes @auto-claude/types and @auto-claude/ui to npm
# when a version tag is pushed. Includes a dry-run job on PRs to catch
# issues before release.
name: Publish Packages
on:
push:
tags:
- 'v*'
pull_request:
branches: [main, develop]
paths:
- 'packages/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/publish-packages.yml'
concurrency:
group: publish-packages-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# --------------------------------------------------------------------------
# Validate - Typecheck and test before publishing
# --------------------------------------------------------------------------
validate:
name: Validate packages
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
with:
ignore-scripts: 'true'
- name: Typecheck @auto-claude/types
run: npx tsc --project packages/types/tsconfig.json --noEmit
- name: Typecheck @auto-claude/ui
run: npx tsc --project packages/ui/tsconfig.json --noEmit
- name: Build @auto-claude/types
run: npx tsc --project packages/types/tsconfig.build.json
- name: Build @auto-claude/ui
run: npx tsc --project packages/ui/tsconfig.build.json
- name: Typecheck frontend
working-directory: apps/frontend
run: npm run typecheck
- name: Run frontend tests
working-directory: apps/frontend
run: npm run test
# --------------------------------------------------------------------------
# Dry Run - Verify publish would succeed (PRs only)
# --------------------------------------------------------------------------
dry-run:
name: Publish dry run
runs-on: ubuntu-latest
needs: validate
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
with:
ignore-scripts: 'true'
- name: Build @auto-claude/types
run: npx tsc --project packages/types/tsconfig.build.json
- name: Build @auto-claude/ui
run: npx tsc --project packages/ui/tsconfig.build.json
- name: Dry run publish @auto-claude/types
run: npm publish --workspace packages/types --dry-run
- name: Dry run publish @auto-claude/ui
run: npm publish --workspace packages/ui --dry-run
# --------------------------------------------------------------------------
# Publish - Push packages to npm (tagged releases only)
# --------------------------------------------------------------------------
publish:
name: Publish to npm
runs-on: ubuntu-latest
needs: validate
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
with:
ignore-scripts: 'true'
- name: Configure npm authentication
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Validate tag matches package versions
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
TYPES_VERSION=$(node -p "require('./packages/types/package.json').version")
UI_VERSION=$(node -p "require('./packages/ui/package.json').version")
echo "Tag version: ${TAG_VERSION}"
echo "types version: ${TYPES_VERSION}"
echo "ui version: ${UI_VERSION}"
if [ "$TAG_VERSION" != "$TYPES_VERSION" ]; then
echo "::error::Tag v${TAG_VERSION} does not match @auto-claude/types version ${TYPES_VERSION}"
exit 1
fi
if [ "$TAG_VERSION" != "$UI_VERSION" ]; then
echo "::error::Tag v${TAG_VERSION} does not match @auto-claude/ui version ${UI_VERSION}"
exit 1
fi
- name: Build @auto-claude/types
run: npx tsc --project packages/types/tsconfig.build.json
- name: Build @auto-claude/ui
run: npx tsc --project packages/ui/tsconfig.build.json
- name: Publish @auto-claude/types
run: |
PACKAGE_VERSION=$(node -p "require('./packages/types/package.json').version")
HTTP_CODE=$(npm view "@auto-claude/types@${PACKAGE_VERSION}" version --json 2>&1) && RC=$? || RC=$?
if [ $RC -eq 0 ] && echo "$HTTP_CODE" | grep -q "$PACKAGE_VERSION"; then
echo "::notice::@auto-claude/types@${PACKAGE_VERSION} already published, skipping"
elif echo "$HTTP_CODE" | grep -qi "E404\|not found\|is not in this registry"; then
npm publish --workspace packages/types --access public --provenance
else
echo "::warning::Unexpected error checking @auto-claude/types@${PACKAGE_VERSION}: ${HTTP_CODE}"
npm publish --workspace packages/types --access public --provenance
fi
- name: Publish @auto-claude/ui
run: |
PACKAGE_VERSION=$(node -p "require('./packages/ui/package.json').version")
HTTP_CODE=$(npm view "@auto-claude/ui@${PACKAGE_VERSION}" version --json 2>&1) && RC=$? || RC=$?
if [ $RC -eq 0 ] && echo "$HTTP_CODE" | grep -q "$PACKAGE_VERSION"; then
echo "::notice::@auto-claude/ui@${PACKAGE_VERSION} already published, skipping"
elif echo "$HTTP_CODE" | grep -qi "E404\|not found\|is not in this registry"; then
npm publish --workspace packages/ui --access public --provenance
else
echo "::warning::Unexpected error checking @auto-claude/ui@${PACKAGE_VERSION}: ${HTTP_CODE}"
npm publish --workspace packages/ui --access public --provenance
fi
+2
View File
@@ -7,6 +7,7 @@
Thumbs.db
ehthumbs.db
Desktop.ini
nul
# ===========================
# Security - Environment & Secrets
@@ -118,6 +119,7 @@ apps/frontend/node_modules
# Build output
dist/
out/
.next
*.tsbuildinfo
apps/frontend/python-runtime/
+15 -48
View File
@@ -40,6 +40,18 @@ 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.
## Project Structure
```
@@ -98,30 +110,6 @@ 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 |
@@ -145,30 +133,7 @@ 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:
```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/`
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
### Agent Prompts (`apps/backend/prompts/`)
@@ -323,6 +288,8 @@ 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)
```
+13 -13
View File
@@ -16,17 +16,17 @@
### Stable Release
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.5-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
[![Stable](https://img.shields.io/badge/stable-2.7.7-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.7)
<!-- STABLE_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
| **Windows** | [Auto-Claude-2.7.7-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.7-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.7-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.7-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.7-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.flatpak) |
<!-- STABLE_DOWNLOADS_END -->
@@ -35,18 +35,18 @@
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
<!-- BETA_VERSION_BADGE -->
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.2-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.4-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.4)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
| **Windows** | [Auto-Claude-2.7.6-beta.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
-212
View File
@@ -1,212 +0,0 @@
# Test Optimization Plan
## Problem Summary
- 3 test directories timeout after 600 seconds
- Main culprit: `test_spec_pipeline_orchestrator.py` - integration tests that run real code
- Autouse fixture adding overhead to every test
## Optimization Strategies
### 1. Use Pytest Markers (Immediate Impact)
Already defined in `pytest.ini`:
```ini
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests
```
**Action:** Mark slow integration tests:
```python
@pytest.mark.slow
@pytest.mark.integration
async def test_run_with_auto_approve(self, tmp_path):
...
```
**Run fast tests only:**
```bash
pytest -m "not slow" -v
pytest -m "not integration" -v
```
**Run all tests when needed:**
```bash
pytest -v # or explicitly: pytest -m "slow or integration" -v
```
### 2. Fix Autouse Fixture Overhead
The `ensure_modules_not_mocked()` fixture runs before EVERY test.
**Action:** Remove autouse or make it conditional:
```python
@pytest.fixture(autouse=True, scope="function")
def ensure_modules_not_mocked():
"""Only run for tests that need real claude_agent_sdk"""
# Skip for tests that explicitly mock everything
if os.environ.get("PYTEST_SKIP_MODULE_CLEANUP"):
return
# ... existing code ...
```
### 3. Separate Unit and Integration Tests
**Directory structure:**
```
tests/
├── unit/ # Fast tests (seconds)
│ ├── test_*.py
├── integration/ # Slow tests (minutes)
│ ├── test_*.py
└── e2e/ # Very slow tests (10+ minutes)
├── test_*.py
```
**pytest.ini:**
```ini
[pytest]
testpaths = tests/unit # Default to fast tests
# Override with: pytest --testpaths=integration
```
### 4. Optimize Async Tests
Async tests have overhead. Use sync tests where possible:
```python
# SLOW (async overhead)
@pytest.mark.asyncio
async def test_something():
result = await some_async_function()
assert result
# FASTER (mock the async call)
def test_something():
with patch('module.some_async_function', return_value=expected):
result = some_function_that_calls_async()
assert result
```
### 5. Reduce Test Data Creation
Many tests create large temporary directories. Use shared fixtures:
```python
@pytest.fixture(scope="session")
def shared_test_data():
"""Create test data once per session, not per test"""
data = create_expensive_test_data()
yield data
# cleanup runs at session end
```
### 6. Disable Coverage by Default
Coverage collection adds overhead.
**pytest.ini:**
```ini
[pytest]
addopts = -q --tb=short # No --cov
# Enable coverage explicitly: pytest --cov=apps/backend
```
### 7. Increase Timeout for Integration Tests
For tests that must be slow:
```python
@pytest.mark.timeout(300) # 5 minutes for this specific test
async def test_slow_integration():
...
```
### 8. Use Test Profiles
Create pytest configs for different scenarios:
**pytest.unit.ini:**
```ini
[pytest]
testpaths = tests
addopts = -m "not slow and not integration" -q
```
**pytest.all.ini:**
```ini
[pytest]
testpaths = tests
addopts = -v
timeout = 1200 # 20 minutes
```
**Usage:**
```bash
pytest -c pytest.unit.ini # Fast PR checks
pytest -c pytest.all.ini # Full test suite
```
### 9. Mock Expensive Operations
Identify what's actually slow in the tests:
```python
# SLOW - Real file I/O
async def test_orchestrator():
orchestrator = SpecOrchestrator(project_dir)
await orchestrator.run() # Does real file operations
# FASTER - Mock the expensive parts
async def test_orchestrator():
with patch('spec.pipeline.orchestrator.discover_project'):
with patch('spec.pipeline.orchestrator.write_files'):
orchestrator = SpecOrchestrator(project_dir)
await orchestrator.run() # Just tests the logic
```
### 10. Run Tests in Parallel (Already Done)
Use the scripts we created:
```bash
./scripts/run-parallel-tests.sh # Directory-level
./scripts/run-tests-per-file.sh # File-level
```
## Implementation Priority
1. **HIGH** - Mark slow/integration tests (5 minutes)
- Add `@pytest.mark.slow` to `test_spec_pipeline_orchestrator.py`
- Add `@pytest.mark.integration` to integration tests
2. **HIGH** - Update CI to run fast tests by default (10 minutes)
- CI: `pytest -m "not slow"` for PR checks
- CI: `pytest` (full) for main branch
3. **MEDIUM** - Fix autouse fixture (30 minutes)
- Remove autouse or make it conditional
4. **MEDIUM** - Create test profiles (1 hour)
- Separate unit/integration configs
5. **LOW** - Refactor slow tests (ongoing)
- Better mocking
- Shared fixtures
## Quick Win Script
Add to Makefile or scripts:
```bash
# makefile or script
test-fast:
pytest -m "not slow" -v
test-all:
pytest -v
test-integration:
pytest -m "integration" -v
```
+4 -4
View File
@@ -269,9 +269,9 @@ GRAPHITI_ENABLED=true
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
# OpenRouter LLM Model (default: anthropic/claude-sonnet-4)
# Popular choices: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
@@ -368,5 +368,5 @@ GRAPHITI_ENABLED=true
# GRAPHITI_LLM_PROVIDER=openrouter
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
+4
View File
@@ -62,5 +62,9 @@ Thumbs.db
# Tests (development only)
tests/
# Exception: Allow colocated tests within integrations/graphiti
!integrations/graphiti/tests/
# Auto Claude data directory
.auto-claude/
coverage.json
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.6-beta.2"
__version__ = "2.7.7"
__author__ = "Auto Claude Team"
+1 -1
View File
@@ -1,3 +1,3 @@
"""Backward compatibility shim - import from core.agent instead."""
from core.agent import * # noqa: F401, F403
from core.agent import * # noqa: F403
+45 -70
View File
@@ -12,24 +12,11 @@ This module provides:
- Utility functions for git and plan management
Uses lazy imports to avoid circular dependencies.
Note: Module-level placeholders are defined to satisfy CodeQL static analysis.
These trigger the actual import on first access through __getattr__.
"""
from __future__ import annotations
from typing import Any
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
from .utils import (
find_phase_for_subtask,
find_subtask_in_plan,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_spec_to_source,
)
# Explicit import required by CodeQL static analysis
# (CodeQL doesn't recognize __getattr__ dynamic exports)
from .utils import sync_spec_to_source
__all__ = [
# Main API
@@ -55,67 +42,55 @@ __all__ = [
"HUMAN_INTERVENTION_FILE",
]
# Module cache for lazy imports
_module_cache = {}
def __getattr__(name):
"""Lazy imports to avoid circular dependencies."""
if name in ("AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE"):
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
def __getattr__(name: str) -> Any:
"""Lazy imports to avoid circular dependencies.
return locals()[name]
elif name == "run_autonomous_agent":
from .coder import run_autonomous_agent
Python 3.7+ calls this for attributes that don't exist at module level.
"""
if name == "run_autonomous_agent":
from . import coder # Ensure agents.coder is registered in sys.modules
return coder.run_autonomous_agent
elif name == "debug_memory_system_status":
from . import (
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
return run_autonomous_agent
elif name in (
"debug_memory_system_status",
"get_graphiti_context",
"save_session_memory",
"save_session_to_graphiti",
):
from .memory_manager import (
debug_memory_system_status,
get_graphiti_context,
save_session_memory,
save_session_to_graphiti,
)
return memory_manager.debug_memory_system_status
elif name == "get_graphiti_context":
from . import (
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
)
return memory_manager.get_graphiti_context
elif name == "save_session_memory":
from . import (
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
)
return memory_manager.save_session_memory
elif name == "save_session_to_graphiti":
from . import (
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
)
return memory_manager.save_session_to_graphiti
return locals()[name]
elif name == "run_followup_planner":
from . import planner # Ensure agents.planner is registered in sys.modules
from .planner import run_followup_planner
return planner.run_followup_planner
elif name == "post_session_processing":
from . import session # Ensure agents.session is registered in sys.modules
return run_followup_planner
elif name in ("post_session_processing", "run_agent_session"):
from .session import post_session_processing, run_agent_session
return session.post_session_processing
elif name == "run_agent_session":
from . import session # Ensure agents.session is registered in sys.modules
return locals()[name]
elif name in (
"find_phase_for_subtask",
"find_subtask_in_plan",
"get_commit_count",
"get_latest_commit",
"load_implementation_plan",
"sync_spec_to_source",
):
from .utils import (
find_phase_for_subtask,
find_subtask_in_plan,
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_spec_to_source,
)
return session.run_agent_session
return locals()[name]
raise AttributeError(f"module 'agents' has no attribute '{name}'")
def __dir__():
"""Return list of module attributes for autocomplete and dir()."""
return __all__ + [
"__all__",
"__doc__",
"__file__",
"__getattr__",
"__name__",
"__package__",
"__loader__",
"__spec__",
]
+28 -8
View File
@@ -21,7 +21,12 @@ from linear_updater import (
linear_task_started,
linear_task_stuck,
)
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_config import (
get_fast_mode,
get_phase_client_thinking_kwargs,
get_phase_model,
get_phase_model_betas,
)
from phase_event import ExecutionPhase, emit_phase
from progress import (
count_subtasks,
@@ -579,9 +584,17 @@ async def run_autonomous_agent(
# first_run means we're in planning phase, otherwise coding phase
current_phase = "planning" if first_run else "coding"
phase_model = get_phase_model(spec_dir, current_phase, model)
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
phase_betas = get_phase_model_betas(spec_dir, current_phase, model)
thinking_kwargs = get_phase_client_thinking_kwargs(
spec_dir, current_phase, phase_model
)
# Generate appropriate prompt
fast_mode = get_fast_mode(spec_dir)
logger.info(
f"[Coder] [Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for phase={current_phase}"
)
if first_run:
# Create client for planning phase
client = create_client(
@@ -589,7 +602,9 @@ async def run_autonomous_agent(
spec_dir,
phase_model,
agent_type="planner",
max_thinking_tokens=phase_thinking_budget,
betas=phase_betas,
fast_mode=fast_mode,
**thinking_kwargs,
)
prompt = generate_planner_prompt(spec_dir, project_dir)
if planning_retry_context:
@@ -647,13 +662,15 @@ async def run_autonomous_agent(
"Waiting for implementation plan to be ready...", "progress"
)
for retry_attempt in range(3):
await asyncio.sleep((retry_attempt + 1) * 2) # 2s, 4s, 6s
delay = (retry_attempt + 1) * 2 # 2s, 4s, 6s
await asyncio.sleep(delay)
next_subtask = get_next_subtask(spec_dir)
if next_subtask:
# Successfully found subtask after retry
delay = (retry_attempt + 1) * 2
# Update subtask_id and phase_name after successful retry
subtask_id = next_subtask.get("id")
phase_name = next_subtask.get("phase_name")
print_status(
f"Found subtask {next_subtask.get('id')} after {delay}s delay",
f"Found subtask {subtask_id} after {delay}s delay",
"success",
)
break
@@ -725,7 +742,9 @@ async def run_autonomous_agent(
spec_dir,
phase_model,
agent_type="coder",
max_thinking_tokens=phase_thinking_budget,
betas=phase_betas,
fast_mode=fast_mode,
**thinking_kwargs,
)
# Get attempt count for recovery context
@@ -843,6 +862,7 @@ 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
+3 -3
View File
@@ -247,7 +247,7 @@ async def get_graphiti_context(
if memory is not None:
try:
await memory.close()
except Exception:
except Exception as e:
logger.debug(
"Failed to close Graphiti memory connection", exc_info=True
)
@@ -417,9 +417,9 @@ async def save_session_memory(
if memory is not None:
try:
await memory.close()
except Exception:
except Exception as e:
logger.debug(
"Failed to close Graphiti memory connection", exc_info=True
"Failed to close Graphiti memory connection", exc_info=e
)
else:
if is_debug_enabled():
+18 -3
View File
@@ -9,7 +9,12 @@ import logging
from pathlib import Path
from core.client import create_client
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_config import (
get_fast_mode,
get_phase_client_thinking_kwargs,
get_phase_model,
get_phase_model_betas,
)
from phase_event import ExecutionPhase, emit_phase
from task_logger import (
LogPhase,
@@ -94,12 +99,22 @@ async def run_followup_planner(
# Create client with phase-specific model and thinking budget
# Respects task_metadata.json configuration when no CLI override
planning_model = get_phase_model(spec_dir, "planning", model)
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
planning_betas = get_phase_model_betas(spec_dir, "planning", model)
thinking_kwargs = get_phase_client_thinking_kwargs(
spec_dir, "planning", planning_model
)
fast_mode = get_fast_mode(spec_dir)
logger.info(
f"[Planner] [Fast Mode] {'ENABLED' if fast_mode else 'disabled'} for follow-up planning"
)
client = create_client(
project_dir,
spec_dir,
planning_model,
max_thinking_tokens=planning_thinking_budget,
agent_type="planner",
betas=planning_betas,
fast_mode=fast_mode,
**thinking_kwargs,
)
# Generate follow-up planner prompt
+121 -105
View File
@@ -7,10 +7,15 @@ memory updates, recovery tracking, and Linear integration.
"""
import logging
import re
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient
from core.error_utils import (
is_authentication_error,
is_rate_limit_error,
is_tool_concurrency_error,
)
from core.file_utils import write_json_atomic
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
from insight_extractor import extract_session_insights
from linear_updater import (
@@ -21,7 +26,7 @@ from progress import (
count_subtasks_detailed,
is_build_complete,
)
from recovery import RecoveryManager
from recovery import RecoveryManager, check_and_recover, reset_subtask
from security.tool_input_validator import get_safe_tool_input
from task_logger import (
LogEntryType,
@@ -48,113 +53,36 @@ from .utils import (
logger = logging.getLogger(__name__)
def is_tool_concurrency_error(error: Exception) -> bool:
"""
Check if an error is a 400 tool concurrency error from Claude API.
def _execute_recovery_action(
recovery_action,
recovery_manager: RecoveryManager,
spec_dir: Path,
project_dir: Path,
subtask_id: str,
) -> None:
"""Execute a recovery action (rollback/retry/skip/escalate)."""
if not recovery_action:
return
Tool concurrency errors occur when too many tools are used simultaneously
in a single API request, hitting Claude's concurrent tool use limit.
print_status(f"Recovery action: {recovery_action.action}", "info")
print_status(f"Reason: {recovery_action.reason}", "info")
Args:
error: The exception to check
if recovery_action.action == "rollback":
print_status(f"Rolling back to {recovery_action.target[:8]}", "warning")
if recovery_manager.rollback_to_commit(recovery_action.target):
print_status("Rollback successful", "success")
else:
print_status("Rollback failed", "error")
Returns:
True if this is a tool concurrency error, False otherwise
"""
error_str = str(error).lower()
# Check for 400 status AND tool concurrency keywords
return "400" in error_str and (
("tool" in error_str and "concurrency" in error_str)
or "too many tools" in error_str
or "concurrent tool" in error_str
)
elif recovery_action.action == "retry":
print_status(f"Resetting subtask {subtask_id} for retry", "info")
reset_subtask(spec_dir, project_dir, subtask_id)
print_status("Subtask reset - will retry with different approach", "success")
def is_rate_limit_error(error: Exception) -> bool:
"""
Check if an error is a rate limit error (429 or similar).
Rate limit errors occur when the API usage quota is exceeded,
either for session limits or weekly limits.
Args:
error: The exception to check
Returns:
True if this is a rate limit error, False otherwise
"""
error_str = str(error).lower()
# Check for HTTP 429 with word boundaries to avoid false positives
if re.search(r"\b429\b", error_str):
return True
# Check for other rate limit indicators
return any(
p in error_str
for p in [
"limit reached",
"rate limit",
"too many requests",
"usage limit",
"quota exceeded",
]
)
def is_authentication_error(error: Exception) -> bool:
"""
Check if an error is an authentication error (401, token expired, etc.).
Authentication errors occur when OAuth tokens are invalid, expired,
or have been revoked (e.g., after token refresh on another process).
Validation approach:
- HTTP 401 status code is checked with word boundaries to minimize false positives
- Additional string patterns are validated against lowercase error messages
- Patterns are designed to match known Claude API and OAuth error formats
Known false positive risks:
- Generic error messages containing "unauthorized" or "access denied" may match
even if not related to authentication (e.g., file permission errors)
- Error messages containing these keywords in user-provided content could match
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
Real-world validation:
- Pattern matching has been tested against actual Claude API error responses
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
- If false positive occurs, user can simply resume without re-authenticating
Args:
error: The exception to check
Returns:
True if this is an authentication error, False otherwise
"""
error_str = str(error).lower()
# Check for HTTP 401 with word boundaries to avoid false positives
if re.search(r"\b401\b", error_str):
return True
# Check for other authentication indicators
# NOTE: "authentication failed" and "authentication error" are more specific patterns
# to reduce false positives from generic "authentication" mentions
return any(
p in error_str
for p in [
"authentication failed",
"authentication error",
"unauthorized",
"invalid token",
"token expired",
"authentication_error",
"invalid_token",
"token_expired",
"not authenticated",
"http 401",
]
)
elif recovery_action.action in ("skip", "escalate"):
print_status(f"Marking subtask {subtask_id} as stuck", "warning")
recovery_manager.mark_subtask_stuck(subtask_id, recovery_action.reason)
print_status("Subtask marked for human intervention", "warning")
async def post_session_processing(
@@ -168,6 +96,7 @@ async def post_session_processing(
linear_enabled: bool = False,
status_manager: StatusManager | None = None,
source_spec_dir: Path | None = None,
error_info: dict | None = None,
) -> bool:
"""
Process session results and update memory automatically.
@@ -185,6 +114,7 @@ async def post_session_processing(
linear_enabled: Whether Linear integration is enabled
status_manager: Optional status manager for ccstatusline
source_spec_dir: Original spec directory (for syncing back from worktree)
error_info: Error information from run_agent_session (for rate limit detection)
Returns:
True if subtask was completed successfully
@@ -316,6 +246,77 @@ async def post_session_processing(
error="Subtask not marked as completed",
)
# Check if this was a concurrency error - if so, reset subtask to pending for retry
is_concurrency_error = (
error_info and error_info.get("type") == "tool_concurrency"
)
if is_concurrency_error:
print_status(
f"Rate limit detected - resetting subtask {subtask_id} to pending for retry",
"info",
)
# Use recovery system's reset_subtask for consistency
reset_subtask(spec_dir, project_dir, subtask_id)
# Also reset in implementation plan
plan = load_implementation_plan(spec_dir)
if plan:
# Find and reset the subtask
subtask_found = False
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
if subtask.get("id") == subtask_id:
# Reset subtask to pending state
subtask["status"] = "pending"
subtask["started_at"] = None
subtask["completed_at"] = None
subtask_found = True
break
if subtask_found:
break
if subtask_found:
# Save plan atomically to prevent corruption
try:
plan_path = spec_dir / "implementation_plan.json"
write_json_atomic(plan_path, plan, indent=2)
print_status(
f"Subtask {subtask_id} reset to pending status", "success"
)
except Exception as e:
logger.error(
f"Failed to save implementation plan after reset: {e}"
)
print_status("Failed to save plan after reset", "error")
else:
print_status(
f"Warning: Could not find subtask {subtask_id} in plan",
"warning",
)
else:
print_status(
"Warning: Could not load implementation plan for reset", "warning"
)
else:
# Non-rate-limit error - use automatic recovery flow
error_message = (
error_info.get("message", "Subtask not marked as completed")
if error_info
else "Subtask not marked as completed"
)
recovery_action = check_and_recover(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
error=error_message,
)
_execute_recovery_action(
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
)
# Still record commit if one was made (partial progress)
if commit_after and commit_after != commit_before:
recovery_manager.record_good_commit(commit_after, subtask_id)
@@ -379,6 +380,21 @@ async def post_session_processing(
error=f"Subtask status is {subtask_status}",
)
# Automatic recovery flow - determine and execute recovery action
error_message = f"Subtask status is {subtask_status}"
if error_info:
error_message = error_info.get("message", error_message)
recovery_action = check_and_recover(
spec_dir=spec_dir,
project_dir=project_dir,
subtask_id=subtask_id,
error=error_message,
)
_execute_recovery_action(
recovery_action, recovery_manager, spec_dir, project_dir, subtask_id
)
# Record Linear session result (if enabled)
if linear_enabled:
attempt_count = recovery_manager.get_attempt_count(subtask_id)
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/env python3
"""
Verification script for agent module refactoring.
This script verifies that:
1. All modules can be imported
2. All public API functions are accessible
3. Backwards compatibility is maintained
"""
import io
import sys
from pathlib import Path
# Configure safe encoding on Windows to handle Unicode characters in output
if sys.platform == "win32":
for _stream_name in ("stdout", "stderr"):
_stream = getattr(sys, _stream_name)
# Method 1: Try reconfigure (works for TTY)
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
continue
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support reconfigure
pass # no-op
# Method 2: Wrap with TextIOWrapper for piped output
try:
if hasattr(_stream, "buffer"):
_new_stream = io.TextIOWrapper(
_stream.buffer,
encoding="utf-8",
errors="replace",
line_buffering=True,
)
setattr(sys, _stream_name, _new_stream)
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support wrapper
pass # no-op
# Clean up temporary variables
del _stream_name, _stream
if "_new_stream" in dir():
del _new_stream
# 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)
+8
View File
@@ -292,6 +292,14 @@ 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
@@ -79,7 +79,7 @@ async def _save_to_graphiti_async(
# Always close the memory connection (swallow exceptions to avoid overriding)
try:
await memory.close()
except Exception:
except Exception as e:
logger.debug(
"Failed to close Graphiti memory connection", exc_info=True
)
@@ -312,8 +312,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
for path, info in list(discoveries.items())[:20]: # Limit to 20
desc = info.get("description", "No description")
result_parts.append(f"- `{path}`: {desc}")
except Exception: # Non-critical error; continue
pass # no-op: memory files are optional
except Exception:
pass
# Load gotchas
gotchas_file = memory_dir / "gotchas.md"
@@ -326,8 +326,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
result_parts.append(
content[-1000:] if len(content) > 1000 else content
)
except Exception: # Non-critical error; continue
pass # no-op: memory files are optional
except Exception:
pass
# Load patterns
patterns_file = memory_dir / "patterns.md"
@@ -339,8 +339,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
result_parts.append(
content[-1000:] if len(content) > 1000 else content
)
except Exception: # Non-critical error; continue
pass # no-op: memory files are optional
except Exception:
pass
if not result_parts:
return {
+3 -2
View File
@@ -23,7 +23,8 @@ from .ci_discovery import CIDiscovery
from .project_analyzer import ProjectAnalyzer
from .risk_classifier import RiskClassifier
from .security_scanner import SecurityScanner
from .test_discovery import TestDiscovery
# TestDiscovery was removed - tests are now co-located in their respective modules
# insight_extractor is a module with functions, not a class, so don't import it here
# Import it directly when needed: from analysis import insight_extractor
@@ -37,5 +38,5 @@ __all__ = [
"RiskClassifier",
"SecurityScanner",
"CIDiscovery",
"TestDiscovery",
# "TestDiscovery", # Removed - tests now co-located in their modules
]
-38
View File
@@ -29,47 +29,9 @@ All actual implementation is in focused submodules for better maintainability.
from __future__ import annotations
import io
import json
import sys
from pathlib import Path
# Configure safe encoding on Windows to handle Unicode characters in output
if sys.platform == "win32":
for _stream_name in ("stdout", "stderr"):
_stream = getattr(sys, _stream_name)
# Method 1: Try reconfigure (works for TTY)
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
continue
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support reconfigure
pass # no-op
# Method 2: Wrap with TextIOWrapper for piped output
try:
if hasattr(_stream, "buffer"):
_new_stream = io.TextIOWrapper(
_stream.buffer,
encoding="utf-8",
errors="replace",
line_buffering=True,
)
setattr(sys, _stream_name, _new_stream)
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support wrapper
pass # no-op
# Clean up temporary variables
del _stream_name, _stream
if "_new_stream" in dir():
del _new_stream
# Import from the new modular structure
from .analyzers import (
ProjectAnalyzer,
@@ -14,6 +14,7 @@ Main exports:
from __future__ import annotations
from pathlib import Path
from typing import Any
from .project_analyzer_module import ProjectAnalyzer
from .service_analyzer import ServiceAnalyzer
@@ -235,10 +235,15 @@ class FrameworkAnalyzer(BaseAnalyzer):
# Scripts
scripts = pkg.get("scripts", {})
pkg_mgr = self.analysis.get("package_manager", "npm")
if "dev" in scripts:
self.analysis["dev_command"] = "npm run dev"
self.analysis["dev_command"] = f"{pkg_mgr} run dev"
elif "start" in scripts:
self.analysis["dev_command"] = "npm run start"
self.analysis["dev_command"] = f"{pkg_mgr} run start"
# Capture available scripts for downstream consumers (QA agents, init.sh)
if scripts:
self.analysis["scripts"] = dict(scripts)
def _detect_go_framework(self, content: str) -> None:
"""Detect Go framework."""
@@ -362,8 +367,9 @@ class FrameworkAnalyzer(BaseAnalyzer):
dependencies = self._detect_spm_dependencies()
if dependencies:
self.analysis["spm_dependencies"] = dependencies
except Exception: # Silently fail if Swift detection has issues
pass # no-op
except Exception:
# Silently fail if Swift detection has issues
pass
def _detect_spm_dependencies(self) -> list[str]:
"""Detect Swift Package Manager dependencies."""
+12 -14
View File
@@ -115,14 +115,6 @@ class CIDiscovery:
CIConfig if CI found, None otherwise
"""
project_dir = Path(project_dir)
# Return None if project directory doesn't exist or is inaccessible
try:
if not project_dir.exists():
return None
except (OSError, PermissionError):
return None
cache_key = str(project_dir.resolve())
if cache_key in self._cache:
@@ -307,8 +299,8 @@ class CIDiscovery:
if isinstance(variables, dict):
result.environment_variables.extend(variables.keys())
except Exception: # Non-critical error; continue
pass # no-op: CI parsing is best-effort
except Exception:
pass
return result
@@ -365,8 +357,8 @@ class CIDiscovery:
)
)
except Exception: # Non-critical error; continue
pass # no-op: CI parsing is best-effort
except Exception:
pass
return result
@@ -385,11 +377,17 @@ class CIDiscovery:
matches = sh_pattern.findall(content)
steps = []
test_related = False
for cmd in matches:
steps.append(cmd)
self._extract_test_commands(cmd, result)
if any(
kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]
):
test_related = True
# Extract stage names
stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)')
stages = stage_pattern.findall(content)
@@ -404,8 +402,8 @@ class CIDiscovery:
)
)
except Exception: # Non-critical error; continue
pass # no-op: CI parsing is best-effort
except Exception:
pass
return result
+3 -1
View File
@@ -22,11 +22,13 @@ logger = logging.getLogger(__name__)
# Check for Claude SDK availability
try:
from claude_agent_sdk import ClaudeSDKClient # noqa: F401 (re-export)
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
SDK_AVAILABLE = True
except ImportError:
SDK_AVAILABLE = False
ClaudeAgentOptions = None
ClaudeSDKClient = None
from core.auth import ensure_claude_code_oauth_token, get_auth_token
+15 -50
View File
@@ -31,11 +31,12 @@ from typing import Any
# Import the existing secrets scanner
try:
from security.scan_secrets import get_all_tracked_files, scan_files
from security.scan_secrets import SecretMatch, get_all_tracked_files, scan_files
HAS_SECRETS_SCANNER = True
except ImportError:
HAS_SECRETS_SCANNER = False
SecretMatch = None
# =============================================================================
@@ -107,20 +108,6 @@ class SecurityScanner:
self._bandit_available: bool | None = None
self._npm_available: bool | None = None
def _is_accessible_directory(self, path: Path) -> bool:
"""Check if a directory is accessible for scanning."""
try:
return path.is_dir()
except (OSError, PermissionError):
return False
def _safe_exists(self, path: Path) -> bool:
"""Safely check if a path exists, handling permission errors."""
try:
return path.exists()
except (OSError, PermissionError):
return False
def scan(
self,
project_dir: Path,
@@ -242,8 +229,9 @@ class SecurityScanner:
src_dirs = []
for candidate in ["src", "app", project_dir.name, "."]:
candidate_path = project_dir / candidate
if self._safe_exists(candidate_path) and self._safe_exists(
candidate_path / "__init__.py"
if (
candidate_path.exists()
and (candidate_path / "__init__.py").exists()
):
src_dirs.append(str(candidate_path))
@@ -310,7 +298,7 @@ class SecurityScanner:
) -> None:
"""Run dependency vulnerability audits."""
# npm audit for JavaScript projects
if self._safe_exists(project_dir / "package.json"):
if (project_dir / "package.json").exists():
self._run_npm_audit(project_dir, result)
# pip-audit for Python projects (if available)
@@ -401,15 +389,15 @@ class SecurityScanner:
else None,
)
)
except json.JSONDecodeError: # Invalid JSON; skip
pass # no-op: invalid audit output, skip
except json.JSONDecodeError:
pass
except FileNotFoundError:
pass # pip-audit not available
except subprocess.TimeoutExpired: # Non-critical error; continue
pass # no-op: timeout is non-critical
except Exception: # Non-critical error; continue
pass # no-op: pip-audit is optional
except subprocess.TimeoutExpired:
pass
except Exception:
pass
def _is_python_project(self, project_dir: Path) -> bool:
"""Check if this is a Python project."""
@@ -419,7 +407,7 @@ class SecurityScanner:
project_dir / "setup.py",
project_dir / "setup.cfg",
]
return any(self._safe_exists(p) for p in indicators)
return any(p.exists() for p in indicators)
def _check_bandit_available(self) -> bool:
"""Check if Bandit is available."""
@@ -439,20 +427,8 @@ class SecurityScanner:
"""Redact a secret for safe logging."""
if len(text) <= 8:
return "*" * len(text)
# Show only first 4 and last 4 characters, redact the middle
return text[:4] + "*" * (len(text) - 8) + text[-4:]
def _redact_log_message(self, message: str) -> str:
"""Redact potentially sensitive information from log messages."""
# Remove common secret patterns from log messages
import re
# Redact things that look like API keys, tokens, passwords
redacted = re.sub(r"([a-zA-Z0-9_-]{32,})", "[REDACTED]", message)
# Redact things that look like base64
redacted = re.sub(r"[A-Za-z0-9+/]{64,}={0,2}", "[REDACTED]", redacted)
return redacted[:500] # Also limit length
def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None:
"""Save scan results to spec directory."""
spec_dir = Path(spec_dir)
@@ -465,12 +441,7 @@ class SecurityScanner:
json.dump(output_data, f, indent=2)
def to_dict(self, result: SecurityScanResult) -> dict[str, Any]:
"""Convert result to dictionary for JSON serialization.
Note: All secret values are redacted via _redact_secret() before being
included in the result dict. Only first 4 and last 4 chars are shown.
"""
# lgtm[py/clear-text-logging-of-sensitive-data] - secrets are redacted
"""Convert result to dictionary for JSON serialization."""
return {
"secrets": result.secrets,
"vulnerabilities": [
@@ -599,7 +570,6 @@ def main() -> None:
)
if args.json:
# lgtm[py/clear-text-logging-of-sensitive-data] - secrets are redacted
print(json.dumps(scanner.to_dict(result), indent=2))
else:
print(f"Secrets Found: {len(result.secrets)}")
@@ -610,12 +580,7 @@ def main() -> None:
if result.secrets:
print("\nSecrets Detected:")
for secret in result.secrets:
# Only log safe fields (pattern name, file path, line number)
# The actual secret value is already redacted in matched_text
pattern = secret.get("pattern", "unknown")
file_path = secret.get("file", "unknown")
line_num = secret.get("line", "?")
print(f" - {pattern} in {file_path}:{line_num}")
print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}")
if result.vulnerabilities:
print(f"\nVulnerabilities ({len(result.vulnerabilities)}):")
-690
View File
@@ -1,690 +0,0 @@
#!/usr/bin/env python3
"""
Test Discovery Module
=====================
Detects test frameworks, test commands, and test directories in a project.
This module analyzes project configuration files to discover how tests
should be run.
The test discovery results are used by:
- QA Agent: To determine what test commands to run
- Test Creator: To know what framework to use when creating tests
- Planner: To include correct test commands in verification strategy
Usage:
from test_discovery import TestDiscovery
discovery = TestDiscovery()
result = discovery.discover(project_dir)
print(f"Test frameworks: {result['frameworks']}")
print(f"Test command: {result['test_command']}")
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class TestFramework:
"""
Represents a detected test framework.
Attributes:
name: Name of the framework (e.g., "pytest", "jest", "vitest")
type: Type of testing (unit, integration, e2e, all)
command: Command to run tests
config_file: Configuration file if found
version: Version if detected
coverage_command: Command for coverage if available
"""
__test__ = False # Prevent pytest from collecting this as a test class
name: str
type: str # unit, integration, e2e, all
command: str
config_file: str | None = None
version: str | None = None
coverage_command: str | None = None
@dataclass
class TestDiscoveryResult:
"""
Result of test framework discovery.
Attributes:
frameworks: List of detected test frameworks
test_command: Primary test command to run
test_directories: Discovered test directories
package_manager: Detected package manager
has_tests: Whether any test files were found
coverage_command: Command for coverage if available
"""
__test__ = False # Prevent pytest from collecting this as a test class
frameworks: list[TestFramework] = field(default_factory=list)
test_command: str = ""
test_directories: list[str] = field(default_factory=list)
package_manager: str = ""
has_tests: bool = False
coverage_command: str | None = None
# =============================================================================
# FRAMEWORK DETECTORS
# =============================================================================
# Pattern-based framework detection
FRAMEWORK_PATTERNS = {
# JavaScript/TypeScript
"jest": {
"config_files": [
"jest.config.js",
"jest.config.ts",
"jest.config.mjs",
"jest.config.cjs",
],
"package_key": "jest",
"type": "unit",
"command": "npx jest",
"coverage_command": "npx jest --coverage",
},
"vitest": {
"config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
"package_key": "vitest",
"type": "unit",
"command": "npx vitest run",
"coverage_command": "npx vitest run --coverage",
},
"mocha": {
"config_files": [
".mocharc.js",
".mocharc.json",
".mocharc.yaml",
".mocharc.yml",
],
"package_key": "mocha",
"type": "unit",
"command": "npx mocha",
"coverage_command": "npx nyc mocha",
},
"playwright": {
"config_files": ["playwright.config.js", "playwright.config.ts"],
"package_key": "@playwright/test",
"type": "e2e",
"command": "npx playwright test",
"coverage_command": None,
},
"cypress": {
"config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"],
"package_key": "cypress",
"type": "e2e",
"command": "npx cypress run",
"coverage_command": None,
},
# Python
"pytest": {
"config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"],
"pyproject_key": "pytest",
"requirements_key": "pytest",
"type": "all",
"command": "pytest",
"coverage_command": "pytest --cov",
},
"unittest": {
"config_files": [],
"type": "unit",
"command": "python -m unittest discover",
"coverage_command": "coverage run -m unittest discover",
},
# Rust
"cargo_test": {
"config_files": ["Cargo.toml"],
"type": "all",
"command": "cargo test",
"coverage_command": "cargo tarpaulin",
},
# Go
"go_test": {
"config_files": ["go.mod"],
"type": "all",
"command": "go test ./...",
"coverage_command": "go test -cover ./...",
},
# Ruby
"rspec": {
"config_files": [".rspec", "spec/spec_helper.rb"],
"gemfile_key": "rspec",
"type": "all",
"command": "bundle exec rspec",
"coverage_command": "bundle exec rspec --format documentation",
},
"minitest": {
"config_files": [],
"gemfile_key": "minitest",
"type": "unit",
"command": "bundle exec rake test",
"coverage_command": None,
},
}
# =============================================================================
# TEST DISCOVERY
# =============================================================================
class TestDiscovery:
"""
Discovers test frameworks and configurations in a project.
Analyzes:
- Package files (package.json, pyproject.toml, Cargo.toml, etc.)
- Configuration files (jest.config.js, pytest.ini, etc.)
- Directory structure (tests/, spec/, __tests__/)
"""
__test__ = False # Prevent pytest from collecting this as a test class
def __init__(self) -> None:
"""Initialize the test discovery."""
self._cache: dict[str, TestDiscoveryResult] = {}
def discover(self, project_dir: Path) -> TestDiscoveryResult:
"""
Discover test frameworks and configuration in the project.
Args:
project_dir: Path to the project root
Returns:
TestDiscoveryResult with detected frameworks and commands
"""
project_dir = Path(project_dir)
cache_key = str(project_dir.resolve())
if cache_key in self._cache:
return self._cache[cache_key]
result = TestDiscoveryResult()
# Detect package manager
result.package_manager = self._detect_package_manager(project_dir)
# Discover frameworks based on project type
if (project_dir / "package.json").exists():
self._discover_js_frameworks(project_dir, result)
# Check for Python project indicators
python_indicators = [
project_dir / "pyproject.toml",
project_dir / "requirements.txt",
project_dir / "setup.py",
project_dir / "pytest.ini",
project_dir / "conftest.py",
project_dir / "tests" / "conftest.py",
]
if any(p.exists() for p in python_indicators):
self._discover_python_frameworks(project_dir, result)
if (project_dir / "Cargo.toml").exists():
self._discover_rust_frameworks(project_dir, result)
if (project_dir / "go.mod").exists():
self._discover_go_frameworks(project_dir, result)
if (project_dir / "Gemfile").exists():
self._discover_ruby_frameworks(project_dir, result)
# Find test directories
result.test_directories = self._find_test_directories(project_dir)
# Check if tests exist
result.has_tests = self._has_test_files(project_dir, result.test_directories)
# Set primary test command
if result.frameworks:
result.test_command = result.frameworks[0].command
# Set coverage command from first framework that has one
if not result.coverage_command:
for framework in result.frameworks:
if framework.coverage_command:
result.coverage_command = framework.coverage_command
break
self._cache[cache_key] = result
return result
def _detect_package_manager(self, project_dir: Path) -> str:
"""Detect the package manager used by the project."""
if (project_dir / "pnpm-lock.yaml").exists():
return "pnpm"
if (project_dir / "yarn.lock").exists():
return "yarn"
if (project_dir / "package-lock.json").exists():
return "npm"
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
return "bun"
if (project_dir / "uv.lock").exists():
return "uv"
if (project_dir / "poetry.lock").exists():
return "poetry"
if (project_dir / "Pipfile.lock").exists():
return "pipenv"
if (project_dir / "Cargo.lock").exists():
return "cargo"
if (project_dir / "go.sum").exists():
return "go"
if (project_dir / "Gemfile.lock").exists():
return "bundler"
return ""
def _discover_js_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover JavaScript/TypeScript test frameworks."""
package_json = project_dir / "package.json"
if not package_json.exists():
return
try:
with open(package_json, encoding="utf-8") as f:
pkg = json.load(f)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return
deps = pkg.get("dependencies", {})
dev_deps = pkg.get("devDependencies", {})
all_deps = {**deps, **dev_deps}
scripts = pkg.get("scripts", {})
# Check for test frameworks in dependencies
for name, pattern in FRAMEWORK_PATTERNS.items():
if "package_key" not in pattern:
continue
if pattern["package_key"] in all_deps:
# Check for config file
config_file = None
for cf in pattern.get("config_files", []):
if (project_dir / cf).exists():
config_file = cf
break
# Get version
version = all_deps.get(pattern["package_key"], "")
if version.startswith("^") or version.startswith("~"):
version = version[1:]
# Determine command - prefer npm scripts if available
command = pattern["command"]
if "test" in scripts and pattern["package_key"] in scripts.get(
"test", ""
):
command = f"{result.package_manager or 'npm'} test"
result.frameworks.append(
TestFramework(
name=name,
type=pattern["type"],
command=command,
config_file=config_file,
version=version,
coverage_command=pattern.get("coverage_command"),
)
)
# Check npm scripts for test commands
if not result.frameworks and "test" in scripts:
test_script = scripts["test"]
if (
test_script
and test_script != 'echo "Error: no test specified" && exit 1'
):
# Try to infer framework from script
framework_name = "npm_test"
framework_type = "unit"
if "jest" in test_script:
framework_name = "jest"
elif "vitest" in test_script:
framework_name = "vitest"
elif "mocha" in test_script:
framework_name = "mocha"
elif "playwright" in test_script:
framework_name = "playwright"
framework_type = "e2e"
elif "cypress" in test_script:
framework_name = "cypress"
framework_type = "e2e"
result.frameworks.append(
TestFramework(
name=framework_name,
type=framework_type,
command=f"{result.package_manager or 'npm'} test",
config_file=None,
)
)
def _discover_python_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Python test frameworks."""
# Check for pytest.ini first (explicit pytest config)
if (project_dir / "pytest.ini").exists():
if not any(f.name == "pytest" for f in result.frameworks):
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file="pytest.ini",
)
)
# Check pyproject.toml
pyproject = project_dir / "pyproject.toml"
if pyproject.exists():
content = pyproject.read_text(encoding="utf-8")
# Check for pytest
if "pytest" in content:
if not any(f.name == "pytest" for f in result.frameworks):
config_file = (
"pyproject.toml" if "[tool.pytest" in content else None
)
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file=config_file,
)
)
# Check requirements.txt
requirements = project_dir / "requirements.txt"
if requirements.exists():
content = requirements.read_text(encoding="utf-8").lower()
if "pytest" in content and not any(
f.name == "pytest" for f in result.frameworks
):
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file=None,
)
)
# Check for conftest.py (pytest marker)
conftest_root = project_dir / "conftest.py"
conftest_tests = project_dir / "tests" / "conftest.py"
if conftest_root.exists() or conftest_tests.exists():
if not any(f.name == "pytest" for f in result.frameworks):
result.frameworks.append(
TestFramework(
name="pytest",
type="all",
command="pytest",
config_file="conftest.py",
)
)
# Fall back to unittest if test files exist but no framework detected
if not result.frameworks:
test_dirs = self._find_test_directories(project_dir)
if test_dirs:
result.frameworks.append(
TestFramework(
name="unittest",
type="unit",
command="python -m unittest discover",
config_file=None,
)
)
def _discover_rust_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Rust test frameworks."""
cargo_toml = project_dir / "Cargo.toml"
if cargo_toml.exists():
result.frameworks.append(
TestFramework(
name="cargo_test",
type="all",
command="cargo test",
config_file="Cargo.toml",
)
)
def _discover_go_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Go test frameworks."""
go_mod = project_dir / "go.mod"
if go_mod.exists():
result.frameworks.append(
TestFramework(
name="go_test",
type="all",
command="go test ./...",
config_file="go.mod",
)
)
def _discover_ruby_frameworks(
self, project_dir: Path, result: TestDiscoveryResult
) -> None:
"""Discover Ruby test frameworks."""
gemfile = project_dir / "Gemfile"
if not gemfile.exists():
return
content = gemfile.read_text(encoding="utf-8").lower()
if "rspec" in content or (project_dir / ".rspec").exists():
result.frameworks.append(
TestFramework(
name="rspec",
type="all",
command="bundle exec rspec",
config_file=".rspec" if (project_dir / ".rspec").exists() else None,
)
)
elif "minitest" in content:
result.frameworks.append(
TestFramework(
name="minitest",
type="unit",
command="bundle exec rake test",
config_file=None,
)
)
def _find_test_directories(self, project_dir: Path) -> list[str]:
"""Find test directories in the project."""
test_dir_patterns = [
"tests",
"test",
"spec",
"__tests__",
"specs",
"test_*",
]
found_dirs = []
for pattern in test_dir_patterns:
if pattern.endswith("*"):
# Glob pattern
for d in project_dir.glob(pattern):
if d.is_dir():
found_dirs.append(str(d.relative_to(project_dir)))
else:
# Exact name
test_dir = project_dir / pattern
if test_dir.is_dir():
found_dirs.append(pattern)
return found_dirs
def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool:
"""Check if any test files exist."""
test_file_patterns = [
"**/test_*.py",
"**/*_test.py",
"**/*.test.js",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.spec.ts",
"**/*.spec.tsx",
"**/test_*.go",
"**/*_test.go",
"**/*_test.rs",
"**/spec/**/*_spec.rb",
]
# Check in test directories
for test_dir in test_directories:
test_path = project_dir / test_dir
if test_path.exists():
for pattern in test_file_patterns:
if list(test_path.glob(pattern.replace("**/", ""))):
return True
# Check project-wide
for pattern in test_file_patterns:
if list(project_dir.glob(pattern)):
return True
return False
def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]:
"""Convert result to dictionary for JSON serialization."""
return {
"frameworks": [
{
"name": f.name,
"type": f.type,
"command": f.command,
"config_file": f.config_file,
"version": f.version,
"coverage_command": f.coverage_command,
}
for f in result.frameworks
],
"test_command": result.test_command,
"test_directories": result.test_directories,
"package_manager": result.package_manager,
"has_tests": result.has_tests,
"coverage_command": result.coverage_command,
}
def clear_cache(self) -> None:
"""Clear the internal cache."""
self._cache.clear()
# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================
def discover_tests(project_dir: Path) -> TestDiscoveryResult:
"""
Convenience function to discover tests in a project.
Args:
project_dir: Path to project root
Returns:
TestDiscoveryResult with detected frameworks
"""
discovery = TestDiscovery()
return discovery.discover(project_dir)
def get_test_command(project_dir: Path) -> str:
"""
Get the primary test command for a project.
Args:
project_dir: Path to project root
Returns:
Test command string, or empty string if not found
"""
discovery = TestDiscovery()
result = discovery.discover(project_dir)
return result.test_command
def get_test_frameworks(project_dir: Path) -> list[str]:
"""
Get list of test framework names in a project.
Args:
project_dir: Path to project root
Returns:
List of framework names
"""
discovery = TestDiscovery()
result = discovery.discover(project_dir)
return [f.name for f in result.frameworks]
# =============================================================================
# CLI
# =============================================================================
def main() -> None:
"""CLI entry point for testing."""
import argparse
parser = argparse.ArgumentParser(description="Discover test frameworks")
parser.add_argument("project_dir", type=Path, help="Path to project root")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
discovery = TestDiscovery()
result = discovery.discover(args.project_dir)
if args.json:
print(json.dumps(discovery.to_dict(result), indent=2))
else:
print(f"Package Manager: {result.package_manager or 'unknown'}")
print(f"Has Tests: {result.has_tests}")
print(f"Test Command: {result.test_command or 'none'}")
print(f"Test Directories: {', '.join(result.test_directories) or 'none'}")
print(f"Coverage Command: {result.coverage_command or 'none'}")
print(f"\nFrameworks ({len(result.frameworks)}):")
for f in result.frameworks:
print(f" - {f.name} ({f.type})")
print(f" Command: {f.command}")
if f.config_file:
print(f" Config: {f.config_file}")
if f.version:
print(f" Version: {f.version}")
if __name__ == "__main__":
main()
+5 -6
View File
@@ -140,17 +140,16 @@ def handle_batch_status_command(project_dir: str) -> bool:
spec_name = spec_dir.name
req_file = spec_dir / "requirements.json"
# Get title from requirements file, default to spec name
status = "unknown"
title = spec_name
if req_file.exists():
try:
with open(req_file, encoding="utf-8") as f:
req = json.load(f)
title = req.get("task_description", spec_name)
title = req.get("task_description", title)
except json.JSONDecodeError:
# Invalid JSON; use default title from directory name
title = spec_name
else:
title = spec_name
pass
# Determine status
if (spec_dir / "spec.md").exists():
+7 -5
View File
@@ -179,10 +179,10 @@ def handle_build_command(
continue_existing = check_existing_build(project_dir, spec_dir.name)
if continue_existing:
# Continue with existing worktree
pass # no-op
pass
else:
# User chose to start fresh or merged existing
pass # no-op
pass
# Choose workspace (skip for parallel mode - it always uses worktrees)
working_dir = project_dir
@@ -297,6 +297,7 @@ def handle_build_command(
except KeyboardInterrupt:
print("\n\nQA validation paused.")
print(f"Resume: python auto-claude/run.py --spec {spec_dir.name} --qa")
qa_approved = False
# Post-build finalization (only for isolated sequential mode)
# This happens AFTER QA validation so the worktree still exists
@@ -448,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,
@@ -468,8 +469,9 @@ def _handle_build_interrupt(
status_manager = StatusManager(project_dir)
status_manager.set_inactive()
sys.exit(0)
except EOFError: # stdin closed
pass # no-op
except EOFError:
# stdin closed
pass
# Resume instructions (shown when user provided instructions or chose file/type/paste)
print()
-37
View File
@@ -12,48 +12,11 @@ Usage:
"""
import argparse
import io
import json
import sys
import uuid
from pathlib import Path
# Configure safe encoding on Windows to handle Unicode characters in output
if sys.platform == "win32":
for _stream_name in ("stdout", "stderr"):
_stream = getattr(sys, _stream_name)
# Method 1: Try reconfigure (works for TTY)
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
continue
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support reconfigure
pass # no-op
# Method 2: Wrap with TextIOWrapper for piped output
try:
if hasattr(_stream, "buffer"):
_new_stream = io.TextIOWrapper(
_stream.buffer,
encoding="utf-8",
errors="replace",
line_buffering=True,
)
setattr(sys, _stream_name, _new_stream)
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support wrapper
pass # no-op
# Clean up temporary variables
del _stream_name, _stream
if "_new_stream" in dir():
del _new_stream
from cli.utils import find_specs_dir
+24 -14
View File
@@ -325,32 +325,42 @@ def _detect_parallel_task_conflicts(
try:
from debug import (
debug,
debug_detailed,
debug_error,
debug_section,
debug_success,
debug_verbose,
is_debug_enabled,
)
except ImportError:
def debug(*args, **kwargs):
"""Fallback debug function when debug module is not available."""
pass # no-op
pass
def debug_error(*args, **kwargs):
"""Fallback debug_error function when debug module is not available."""
pass # no-op
def debug_section(*args, **kwargs):
"""Fallback debug_section function when debug module is not available."""
pass # no-op
def debug_success(*args, **kwargs):
"""Fallback debug_success function when debug module is not available."""
pass # no-op
def debug_detailed(*args, **kwargs):
"""Fallback debug_detailed function when debug module is not available."""
pass
def debug_verbose(*args, **kwargs):
"""Fallback debug_verbose function when debug module is not available."""
pass # no-op
pass
def debug_success(*args, **kwargs):
"""Fallback debug_success function when debug module is not available."""
pass
def debug_error(*args, **kwargs):
"""Fallback debug_error function when debug module is not available."""
pass
def debug_section(*args, **kwargs):
"""Fallback debug_section function when debug module is not available."""
pass
def is_debug_enabled():
"""Fallback is_debug_enabled function when debug module is not available."""
return False
MODULE = "cli.workspace_commands"
@@ -807,7 +817,7 @@ def _check_git_merge_conflicts(
import re
match = re.search(
r"Merge conflict in\s+(.+?)\s*$",
r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()",
line,
)
if match:
+3 -6
View File
@@ -5,10 +5,6 @@ Provides Claude API client utilities.
Uses lazy imports to avoid circular dependencies.
"""
# Explicit import required for CodeQL static analysis
# (CodeQL doesn't recognize __getattr__ dynamic exports)
from core.client import create_client as _create_client_impl
def __getattr__(name):
"""Lazy import to avoid circular imports with auto_claude_tools."""
@@ -19,10 +15,11 @@ def __getattr__(name):
def create_client(*args, **kwargs):
"""Create a Claude client instance."""
return _create_client_impl(*args, **kwargs)
from core.client import create_client as _create_client
return _create_client(*args, **kwargs)
# Export list must come after all exports are defined to satisfy CodeQL
__all__ = [
"create_client",
]
+1 -40
View File
@@ -13,7 +13,6 @@ Features:
from __future__ import annotations
import asyncio
import io
import json
import logging
import re
@@ -21,46 +20,8 @@ import sys
from pathlib import Path
from typing import TYPE_CHECKING
# Configure safe encoding on Windows to handle Unicode characters in output
# This is needed because this module may be imported in contexts that log
# Unicode content, and the logging system uses stdout/stderr.
if sys.platform == "win32":
for _stream_name in ("stdout", "stderr"):
_stream = getattr(sys, _stream_name)
# Method 1: Try reconfigure (works for TTY)
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
continue
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support reconfigure
pass # no-op
# Method 2: Wrap with TextIOWrapper for piped output
try:
if hasattr(_stream, "buffer"):
_new_stream = io.TextIOWrapper(
_stream.buffer,
encoding="utf-8",
errors="replace",
line_buffering=True,
)
setattr(sys, _stream_name, _new_stream)
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support wrapper
pass # no-op
# Clean up temporary variables
del _stream_name, _stream
if "_new_stream" in dir():
del _new_stream
if TYPE_CHECKING:
pass # no-op
pass
logger = logging.getLogger(__name__)
+4 -7
View File
@@ -40,12 +40,9 @@ class ContextBuilder:
try:
with open(index_file, encoding="utf-8") as f:
return json.load(f)
except (
OSError,
json.JSONDecodeError,
UnicodeDecodeError,
): # Corrupted or legacy-encoded file, regenerate
pass # no-op
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
# Corrupted or legacy-encoded file, regenerate
pass
# Try to create one
from analyzer import analyze_project
@@ -117,7 +114,7 @@ class ContextBuilder:
try:
# Run the async function in a new event loop if necessary
try:
_ = asyncio.get_running_loop()
loop = asyncio.get_running_loop()
# We're already in an async context - this shouldn't happen in CLI
# but handle it gracefully
graph_hints = []
-38
View File
@@ -26,47 +26,9 @@ The context builder will:
4. Output focused context for AI agents
"""
import io
import json
import sys
from pathlib import Path
# Configure safe encoding on Windows to handle Unicode characters in output
if sys.platform == "win32":
for _stream_name in ("stdout", "stderr"):
_stream = getattr(sys, _stream_name)
# Method 1: Try reconfigure (works for TTY)
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
continue
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support reconfigure
pass # no-op
# Method 2: Wrap with TextIOWrapper for piped output
try:
if hasattr(_stream, "buffer"):
_new_stream = io.TextIOWrapper(
_stream.buffer,
encoding="utf-8",
errors="replace",
line_buffering=True,
)
setattr(sys, _stream_name, _new_stream)
except (
AttributeError,
io.UnsupportedOperation,
OSError,
): # Stream doesn't support wrapper
pass # no-op
# Clean up temporary variables
del _stream_name, _stream
if "_new_stream" in dir():
del _new_stream
from context import (
ContextBuilder,
FileMatch,
+15 -38
View File
@@ -3,63 +3,40 @@ Core Framework Module
=====================
Core components for the Auto Claude autonomous coding framework.
Note: We use lazy imports here because the full agent module has many dependencies
that may not be needed for basic operations.
"""
from typing import Any
# Module-level placeholders for CodeQL static analysis.
# The actual exported names trigger __getattr__ for lazy loading.
# Use list placeholder to satisfy CodeQL's "defined but not set to None" check.
run_autonomous_agent: Any = []
run_followup_planner: Any = []
WorktreeManager: Any = []
# Note: We use lazy imports here because the full agent module has many dependencies
# that may not be needed for basic operations like workspace management.
__all__ = [
"run_autonomous_agent",
"run_followup_planner",
"WorkspaceManager",
"WorktreeManager",
"create_claude_client",
"ClaudeClient",
"ProgressTracker",
]
def __getattr__(name: str) -> Any:
def __getattr__(name):
"""Lazy imports to avoid circular dependencies and heavy imports."""
if name == "run_autonomous_agent":
from .agent import run_autonomous_agent
if name in ("run_autonomous_agent", "run_followup_planner"):
from .agent import run_autonomous_agent, run_followup_planner
return run_autonomous_agent
elif name == "run_followup_planner":
from .agent import run_followup_planner
return locals()[name]
elif name == "WorkspaceManager":
from .workspace import WorkspaceManager
return run_followup_planner
return WorkspaceManager
elif name == "WorktreeManager":
from .worktree import WorktreeManager
return WorktreeManager
elif name == "ProgressTracker":
from .progress import ProgressTracker
return ProgressTracker
elif name in ("create_claude_client", "ClaudeClient"):
from . import client as _client
return getattr(_client, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _do_lazy_import(name: str) -> Any:
"""Perform the actual lazy import for a given name."""
if name == "run_autonomous_agent":
from .agent import run_autonomous_agent
return run_autonomous_agent
if name == "run_followup_planner":
from .agent import run_followup_planner
return run_followup_planner
if name == "WorktreeManager":
from .worktree import WorktreeManager
return WorktreeManager
raise AssertionError(f"Unknown lazy import name: {name}")
+12 -8
View File
@@ -15,6 +15,7 @@ import subprocess
from typing import TYPE_CHECKING
from core.platform import (
get_where_exe_path,
is_linux,
is_macos,
is_windows,
@@ -466,6 +467,7 @@ def _get_token_from_macos_keychain(config_dir: str | None = None) -> str | None:
if not (token.startswith("sk-ant-oat01-") or token.startswith("enc:")):
return None
logger.debug(f"Found token in keychain service '{service_name}'")
return token
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
@@ -500,6 +502,7 @@ def _get_token_from_windows_credential_files(
token.startswith("sk-ant-oat01-")
or token.startswith("enc:")
):
logger.debug(f"Found token in {cred_path}")
return token
# If config_dir provided but no token found, don't fall back to default
return None
@@ -595,6 +598,9 @@ def _get_token_from_linux_secret_service(config_dir: str | None = None) -> str |
if token and (
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
):
logger.debug(
f"Found token in secret service with label '{target_label}'"
)
return token
except json.JSONDecodeError:
continue
@@ -655,6 +661,7 @@ def _get_token_from_config_dir(config_dir: str) -> str | None:
if token and (
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
):
logger.debug(f"Found token in {cred_path}")
return token
except (json.JSONDecodeError, KeyError, Exception) as e:
logger.debug(f"Failed to read {cred_path}: {e}")
@@ -848,9 +855,9 @@ def _find_git_bash_path() -> str | None:
# Method 1: Use 'where' command to find git.exe
try:
# Use where.exe explicitly for reliability
# Use full path to where.exe for reliability (works even when System32 isn't in PATH)
result = subprocess.run(
["where.exe", "git"],
[get_where_exe_path(), "git"],
capture_output=True,
text=True,
timeout=5,
@@ -861,12 +868,9 @@ def _find_git_bash_path() -> str | None:
git_paths = result.stdout.strip().splitlines()
if git_paths:
git_path = git_paths[0].strip()
except (
subprocess.TimeoutExpired,
FileNotFoundError,
subprocess.SubprocessError,
): # Intentionally suppress errors - best-effort detection with fallback to common paths
pass # no-op
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
# Intentionally suppress errors - best-effort detection with fallback to common paths
pass
# Method 2: Check common installation paths if 'where' didn't work
if not git_path:
+23 -15
View File
@@ -21,6 +21,7 @@ import time
from pathlib import Path
from typing import Any
from core.fast_mode import ensure_fast_mode_in_user_settings
from core.platform import (
is_windows,
validate_cli_path,
@@ -483,9 +484,10 @@ def create_client(
"medium", "high"). When set, injected as CLAUDE_CODE_EFFORT_LEVEL
env var for the SDK subprocess. Only meaningful for models that
support adaptive thinking (e.g., Opus 4.6).
fast_mode: Enable Fast Mode for faster Opus 4.6 output. When True, injected
as CLAUDE_CODE_FAST_MODE=true env var. Requires extra usage enabled
on Claude subscription; falls back to standard speed automatically.
fast_mode: Enable Fast Mode for faster Opus 4.6 output. When True, enables
the "user" setting source so the CLI reads fastMode from
~/.claude/settings.json. Requires extra usage enabled on Claude
subscription; falls back to standard speed automatically.
Returns:
Configured ClaudeSDKClient
@@ -510,16 +512,6 @@ def create_client(
# Configure SDK authentication (OAuth or API profile mode)
configure_sdk_authentication(config_dir)
if config_dir:
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
# Get the config dir for profile-specific credential lookup
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
# Configure SDK authentication (OAuth or API profile mode)
configure_sdk_authentication(config_dir)
if config_dir:
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
@@ -527,9 +519,19 @@ def create_client(
if effort_level:
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
# Inject fast mode for faster Opus 4.6 output
# Fast mode requires the CLI to read "fastMode" from user settings.
# The SDK default (setting_sources=None) passes --setting-sources "" which
# blocks ALL filesystem settings. We must explicitly enable "user" source
# so the CLI reads ~/.claude/settings.json where fastMode: true lives.
# See: https://code.claude.com/docs/en/fast-mode
if fast_mode:
sdk_env["CLAUDE_CODE_FAST_MODE"] = "true"
ensure_fast_mode_in_user_settings()
logger.info("[Fast Mode] ACTIVE — will enable user setting source for fastMode")
print(
"[Fast Mode] ACTIVE — enabling user settings source for CLI to read fastMode"
)
else:
logger.info("[Fast Mode] inactive — not requested for this client")
# Debug: Log git-bash path detection on Windows
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
@@ -851,6 +853,12 @@ def create_client(
"enable_file_checkpointing": True,
}
# Fast mode: enable user setting source so CLI reads fastMode from
# ~/.claude/settings.json. Without this, the SDK's default --setting-sources ""
# blocks all filesystem settings and the CLI never sees fastMode: true.
if fast_mode:
options_kwargs["setting_sources"] = ["user"]
# Optional: Allow CLI path override via environment variable
# The SDK bundles its own CLI, but users can override if needed
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
+2
View File
@@ -114,5 +114,7 @@ def is_authentication_error(error: Exception) -> bool:
"token_expired",
"not authenticated",
"http 401",
"does not have access to claude",
"please login again",
]
)
+76
View File
@@ -0,0 +1,76 @@
"""
Fast Mode Settings Helper
=========================
Manages the fastMode flag in ~/.claude/settings.json for temporary
per-task fast mode overrides. Shared by both client.py and simple_client.py.
"""
import json
import logging
from pathlib import Path
from core.file_utils import write_json_atomic
logger = logging.getLogger(__name__)
_fast_mode_atexit_registered = False
def _write_fast_mode_setting(enabled: bool) -> None:
"""Write fastMode value to ~/.claude/settings.json (atomic read-modify-write).
Uses write_json_atomic from core.file_utils to prevent corruption when
multiple concurrent task processes modify the file simultaneously.
"""
settings_file = Path.home() / ".claude" / "settings.json"
try:
settings: dict = {}
if settings_file.exists():
settings = json.loads(settings_file.read_text(encoding="utf-8"))
if settings.get("fastMode") != enabled:
settings["fastMode"] = enabled
settings_file.parent.mkdir(parents=True, exist_ok=True)
# Atomic write using shared utility
write_json_atomic(settings_file, settings)
state = "true" if enabled else "false"
logger.info(
f"[Fast Mode] Wrote fastMode={state} to ~/.claude/settings.json"
)
except Exception as e:
logger.warning(f"[Fast Mode] Could not update ~/.claude/settings.json: {e}")
def _disable_fast_mode_on_exit() -> None:
"""atexit handler: restore fastMode=false so interactive CLI sessions stay standard."""
_write_fast_mode_setting(False)
def ensure_fast_mode_in_user_settings() -> None:
"""
Enable fastMode in ~/.claude/settings.json and register cleanup.
The CLI reads fastMode from user settings (loaded via --setting-sources user).
This function:
1. Writes fastMode=true before spawning the CLI subprocess
2. Registers an atexit handler to restore fastMode=false when the process exits
This ensures fast mode is a temporary override per task process, not a permanent
setting change. The CLI subprocess reads settings at startup, so restoring false
after exit doesn't affect running tasks — only prevents fast mode from leaking
into subsequent interactive CLI sessions or non-fast-mode tasks.
"""
global _fast_mode_atexit_registered
_write_fast_mode_setting(True)
# Register cleanup once per process — idempotent on repeated calls
if not _fast_mode_atexit_registered:
import atexit
atexit.register(_disable_fast_mode_on_exit)
_fast_mode_atexit_registered = True
logger.info(
"[Fast Mode] Registered atexit cleanup (will restore fastMode=false)"
)
+7 -8
View File
@@ -10,6 +10,8 @@ import os
import shutil
import subprocess
from core.platform import get_where_exe_path
_cached_gh_path: str | None = None
@@ -53,12 +55,11 @@ def _run_where_command() -> str | None:
"""
try:
result = subprocess.run(
"where gh",
[get_where_exe_path(), "gh"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
shell=True, # Required: 'where' command must be executed through shell on Windows
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
@@ -68,11 +69,9 @@ def _run_where_command() -> str | None:
and _verify_gh_executable(found_path)
):
return found_path
except (
subprocess.TimeoutExpired,
OSError,
): # 'where' command failed or timed out - fall through to return None
pass # no-op
except (subprocess.TimeoutExpired, OSError):
# 'where' command failed or timed out - fall through to return None
pass
return None
@@ -135,7 +134,7 @@ def _find_gh_executable() -> str | None:
if os.path.isfile(path) and _verify_gh_executable(path):
return path
# 5. Try 'where' command with shell=True (more reliable on Windows)
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
return _run_where_command()
return None
+6 -11
View File
@@ -15,12 +15,7 @@ import shutil
import subprocess
from pathlib import Path
__all__ = [
"get_git_executable",
"get_isolated_git_env",
"run_git",
"GIT_ENV_VARS_TO_CLEAR",
]
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.
@@ -86,8 +81,9 @@ def get_git_executable() -> str:
if _cached_git_path is not None:
return _cached_git_path
_cached_git_path = _find_git_executable()
return _cached_git_path
git_path = _find_git_executable()
_cached_git_path = git_path
return git_path
def _find_git_executable() -> str:
@@ -130,14 +126,13 @@ def _find_git_executable() -> str:
except OSError:
continue
# 4. Try 'where' command with shell=True (more reliable on Windows)
# 4. Try 'where' command with full path (works even when System32 isn't in PATH)
try:
result = subprocess.run(
"where git",
[get_where_exe_path(), "git"],
capture_output=True,
text=True,
timeout=5,
shell=True,
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
+7 -8
View File
@@ -10,6 +10,8 @@ import os
import shutil
import subprocess
from core.platform import get_where_exe_path
_cached_glab_path: str | None = None
@@ -53,12 +55,11 @@ def _run_where_command() -> str | None:
"""
try:
result = subprocess.run(
"where glab",
[get_where_exe_path(), "glab"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
shell=True, # Required: 'where' command must be executed through shell on Windows
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
@@ -68,11 +69,9 @@ def _run_where_command() -> str | None:
and _verify_glab_executable(found_path)
):
return found_path
except (
subprocess.TimeoutExpired,
OSError,
): # 'where' command failed or timed out - fall through to return None
pass # no-op
except (subprocess.TimeoutExpired, OSError):
# 'where' command failed or timed out - fall through to return None
pass
return None
@@ -136,7 +135,7 @@ def _find_glab_executable() -> str | None:
if os.path.isfile(path) and _verify_glab_executable(path):
return path
# 5. Try 'where' command with shell=True (more reliable on Windows)
# 5. Try 'where' command with full path (works even when System32 isn't in PATH)
return _run_where_command()
return None
+8 -16
View File
@@ -13,7 +13,6 @@ cases gracefully.
from __future__ import annotations
import logging
import os
import sys
logger = logging.getLogger(__name__)
@@ -21,9 +20,6 @@ logger = logging.getLogger(__name__)
# Track if pipe is broken to avoid repeated failed writes
_pipe_broken = False
# Check if running in tests to prevent closing stdout
_IN_TESTS = os.environ.get("AUTO_CLAUDE_TESTS", "0") == "1"
def safe_print(message: str, flush: bool = True) -> None:
"""
@@ -49,12 +45,10 @@ def safe_print(message: str, flush: bool = True) -> None:
# Pipe closed by parent process - this is expected during shutdown
_pipe_broken = True
# Quietly close stdout to prevent further errors
# Skip closing stdout during tests to avoid pytest capture issues
if not _IN_TESTS:
try:
sys.stdout.close()
except Exception: # stdout may already be closed
pass # no-op
try:
sys.stdout.close()
except Exception:
pass
logger.debug("Output pipe closed by parent process")
except ValueError as e:
# Handle writes to closed file (can happen after stdout.close())
@@ -68,12 +62,10 @@ def safe_print(message: str, flush: bool = True) -> None:
# Handle other pipe-related errors (EPIPE, etc.)
if e.errno == 32: # EPIPE - Broken pipe
_pipe_broken = True
# Skip closing stdout during tests to avoid pytest capture issues
if not _IN_TESTS:
try:
sys.stdout.close()
except Exception: # stdout may already be closed
pass # no-op
try:
sys.stdout.close()
except Exception:
pass
logger.debug("Output pipe closed (EPIPE)")
else:
# Re-raise unexpected OS errors
+16
View File
@@ -430,6 +430,22 @@ def requires_shell(command: str) -> bool:
return ext.lower() in {".cmd", ".bat", ".ps1"}
def get_where_exe_path() -> str:
"""Get full path to where.exe on Windows.
Using the full path ensures where.exe works even when System32 isn't in PATH,
which can happen in restricted environments or when the app doesn't inherit
the full system PATH.
Returns:
Full path to where.exe (e.g., C:\\Windows\\System32\\where.exe)
"""
system_root = os.environ.get(
"SystemRoot", os.environ.get("SYSTEMROOT", "C:\\Windows")
)
return os.path.join(system_root, "System32", "where.exe")
def get_comspec_path() -> str:
"""
Get the path to cmd.exe on Windows.
+6 -17
View File
@@ -186,14 +186,12 @@ 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
@@ -212,20 +210,11 @@ def init_sentry(
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
return False
# 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
# 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.
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
@@ -248,8 +237,8 @@ def init_sentry(
parsed = float(env_rate)
if 0 <= parsed <= 1:
traces_sample_rate = parsed
except Exception: # Sentry not configured or unavailable
pass # no-op
except (ValueError, TypeError):
pass
# Configure logging integration to capture errors and warnings
logging_integration = LoggingIntegration(
+19 -2
View File
@@ -31,6 +31,7 @@ from core.auth import (
configure_sdk_authentication,
get_sdk_env_vars,
)
from core.fast_mode import ensure_fast_mode_in_user_settings
from core.platform import validate_cli_path
from phase_config import get_thinking_budget
@@ -71,8 +72,8 @@ def create_simple_client(
betas: Optional list of SDK beta header strings (e.g., ["context-1m-2025-08-07"])
effort_level: Optional effort level for adaptive thinking models (e.g., "low",
"medium", "high"). Injected as CLAUDE_CODE_EFFORT_LEVEL env var.
fast_mode: Enable Fast Mode for faster Opus 4.6 output. Injected as
CLAUDE_CODE_FAST_MODE=true env var.
fast_mode: Enable Fast Mode for faster Opus 4.6 output. Enables the "user"
setting source so the CLI reads fastMode from ~/.claude/settings.json.
Returns:
Configured ClaudeSDKClient for single-turn operations
@@ -90,6 +91,17 @@ def create_simple_client(
# Configure SDK authentication (OAuth or API profile mode)
configure_sdk_authentication(config_dir)
# Inject effort level for adaptive thinking models (e.g., Opus 4.6)
if effort_level:
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
# Fast mode: the CLI reads "fastMode" from user settings (~/.claude/settings.json).
# By default the SDK passes --setting-sources "" which blocks all filesystem settings.
# We enable "user" source so the CLI can read fastMode from user settings.
if fast_mode:
ensure_fast_mode_in_user_settings()
logger.info("[Fast Mode] ACTIVE — will enable user setting source for fastMode")
# Get agent configuration (raises ValueError if unknown type)
config = get_agent_config(agent_type)
@@ -112,6 +124,11 @@ def create_simple_client(
"env": sdk_env,
}
# Fast mode: enable user setting source so CLI reads fastMode from
# ~/.claude/settings.json. Without this, --setting-sources "" blocks it.
if fast_mode:
options_kwargs["setting_sources"] = ["user"]
# Only add max_thinking_tokens if not None (Haiku doesn't support extended thinking)
if max_thinking_tokens is not None:
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
+16 -5
View File
@@ -44,23 +44,30 @@ try:
debug_success,
debug_verbose,
debug_warning,
is_debug_enabled,
)
except ImportError:
def debug(*args, **kwargs):
pass # no-op
pass
def debug_detailed(*args, **kwargs):
pass
def debug_verbose(*args, **kwargs):
pass # no-op
pass
def debug_success(*args, **kwargs):
pass # no-op
pass
def debug_error(*args, **kwargs):
pass # no-op
pass
def debug_warning(*args, **kwargs):
pass # no-op
pass
def is_debug_enabled():
return False
# Import merge system
@@ -978,6 +985,10 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
debug_warning(MODULE, "Could not find merge base")
return result
_merge_base = (
merge_base_result.stdout.strip()
) # Reserved for future conflict detection
# Get commit hashes
main_commit_result = run_git(
["rev-parse", result["base_branch"]],
+19 -6
View File
@@ -17,7 +17,6 @@ Public API exported from sub-modules.
"""
import importlib.util
import sys
from pathlib import Path
# Import merge functions from workspace.py (which coexists with this package)
@@ -28,10 +27,17 @@ _workspace_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_workspace_module)
merge_existing_build = _workspace_module.merge_existing_build
_run_parallel_merges = _workspace_module._run_parallel_merges
_resolve_git_conflicts_with_ai = _workspace_module._resolve_git_conflicts_with_ai
AI_MERGE_SYSTEM_PROMPT = _workspace_module.AI_MERGE_SYSTEM_PROMPT
_build_merge_prompt = _workspace_module._build_merge_prompt
_check_git_conflicts = _workspace_module._check_git_conflicts
_rebase_spec_branch = _workspace_module._rebase_spec_branch
_create_merge_progress_callback = _workspace_module._create_merge_progress_callback
_infer_language_from_path = _workspace_module._infer_language_from_path
_strip_code_fences = _workspace_module._strip_code_fences
_try_simple_3way_merge = _workspace_module._try_simple_3way_merge
_attempt_ai_merge = _workspace_module._attempt_ai_merge
_merge_file_with_ai_async = _workspace_module._merge_file_with_ai_async
# Models and Enums
# Display Functions
@@ -74,7 +80,9 @@ from .git_utils import (
# Export private names for backward compatibility
_is_process_running,
_validate_merged_syntax,
apply_path_mapping,
create_conflict_file_with_git,
detect_file_renames,
get_binary_file_content_from_ref,
get_changed_files_from_branch,
get_current_branch,
@@ -91,6 +99,8 @@ from .models import (
MergeLockError,
ParallelMergeResult,
ParallelMergeTask,
SpecNumberLock,
SpecNumberLockError,
WorkspaceChoice,
WorkspaceMode,
)
@@ -110,11 +120,9 @@ from .setup import (
__all__ = [
# Merge Operations (from workspace.py)
"merge_existing_build",
"_run_parallel_merges", # Private but used internally
"AI_MERGE_SYSTEM_PROMPT", # System prompt for AI merge (ACS-194)
"_build_merge_prompt", # Internal prompt builder (ACS-194)
"_check_git_conflicts", # Internal git conflict detection (ACS-224)
"_rebase_spec_branch", # Internal rebase function (ACS-224)
# Note: Private functions (_run_parallel_merges, _resolve_git_conflicts_with_ai, etc.)
# are kept as module-level assignments for internal use but not exported in __all__
# to maintain the underscore convention for private/internal APIs
# Models
"WorkspaceMode",
"WorkspaceChoice",
@@ -122,6 +130,8 @@ __all__ = [
"ParallelMergeResult",
"MergeLock",
"MergeLockError",
"SpecNumberLock",
"SpecNumberLockError",
# Git Utils
"has_uncommitted_changes",
"get_current_branch",
@@ -131,8 +141,11 @@ __all__ = [
"get_changed_files_from_branch",
"is_process_running",
"is_binary_file",
"is_lock_file",
"validate_merged_syntax",
"create_conflict_file_with_git",
"detect_file_renames", # File rename detection
"apply_path_mapping", # Path mapping for renamed files
# Setup
"choose_workspace",
"copy_spec_to_worktree",
+3 -11
View File
@@ -15,19 +15,11 @@ from ui import (
)
from worktree import WorktreeManager
__all__ = [
"print_merge_success",
"print_conflict_info",
"_print_merge_success",
"_print_conflict_info",
"show_build_summary",
"show_changed_files",
]
def show_build_summary(manager: WorktreeManager, spec_name: str) -> None:
"""Show a summary of what was built."""
summary = manager.get_change_summary(spec_name)
files = manager.get_changed_files(spec_name)
total = summary["new_files"] + summary["modified_files"] + summary["deleted_files"]
@@ -233,5 +225,5 @@ def print_conflict_info(result: dict) -> None:
# Export private names for backward compatibility
_print_merge_success = print_merge_success # noqa: F811 (intentional re-export)
_print_conflict_info = print_conflict_info # noqa: F811 (intentional re-export)
_print_merge_success = print_merge_success
_print_conflict_info = print_conflict_info
@@ -88,6 +88,7 @@ def finalize_workspace(
# Get the worktree path for test instructions
worktree_info = manager.get_worktree_info(spec_name)
staging_path = worktree_info.path if worktree_info else None
# Enhanced menu for post-build options
options = [
+2 -2
View File
@@ -503,7 +503,7 @@ def validate_merged_syntax(
return True, "" # Timeout = assume ok
except FileNotFoundError:
return True, "" # No esbuild = skip validation
except Exception:
except Exception as e:
return True, "" # Other errors = skip validation
# Python validation
@@ -589,7 +589,7 @@ def create_conflict_file_with_git(
Path(wt_path).unlink(missing_ok=True)
Path(base_path).unlink(missing_ok=True)
except Exception:
except Exception as e:
return None, False
+10 -14
View File
@@ -53,7 +53,7 @@ class ParallelMergeResult:
class MergeLockError(Exception):
"""Raised when a merge lock cannot be acquired."""
pass # no-op
pass
class MergeLock:
@@ -88,14 +88,12 @@ class MergeLock:
fd = os.open(
str(self.lock_file),
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
0o600,
0o644,
)
# Ensure permissions are correct (override umask)
os.fchmod(fd, 0o600)
# Write PID directly to the file descriptor
os.write(fd, str(os.getpid()).encode("utf-8"))
os.close(fd)
# Write our PID to the lock file
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
self.acquired = True
return self
@@ -142,7 +140,7 @@ class MergeLock:
class SpecNumberLockError(Exception):
"""Raised when a spec number lock cannot be acquired."""
pass # no-op
pass
class SpecNumberLock:
@@ -180,14 +178,12 @@ class SpecNumberLock:
fd = os.open(
str(self.lock_file),
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
0o600,
0o644,
)
# Ensure permissions are correct (override umask)
os.fchmod(fd, 0o600)
# Write PID directly to the file descriptor
os.write(fd, str(os.getpid()).encode("utf-8"))
os.close(fd)
# Write our PID to the lock file
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
self.acquired = True
return self
@@ -273,7 +269,7 @@ class SpecNumberLock:
try:
num = int(folder.name[:3])
max_num = max(max_num, num)
except ValueError: # Folder name doesn't start with a number; skip it
pass # no-op: skip invalid folder names
except ValueError:
pass
return max_num
+4 -14
View File
@@ -31,26 +31,16 @@ from worktree import WorktreeManager
from .git_utils import has_uncommitted_changes
from .models import WorkspaceMode
__all__ = [
"setup_workspace",
"copy_spec_to_worktree",
"choose_workspace",
"ensure_timeline_hook_installed",
"initialize_timeline_tracking",
"_ensure_timeline_hook_installed",
"_initialize_timeline_tracking",
]
# Import debug utilities
try:
from debug import debug, debug_warning
except ImportError:
def debug(*args, **kwargs):
pass # no-op
pass
def debug_warning(*args, **kwargs):
pass # no-op
pass
# Track if we've already tried to install the git hook this session
@@ -585,5 +575,5 @@ def initialize_timeline_tracking(
# Export private functions for backward compatibility
_ensure_timeline_hook_installed = ensure_timeline_hook_installed # noqa: F811 (intentional re-export)
_initialize_timeline_tracking = initialize_timeline_tracking # noqa: F811 (intentional re-export)
_ensure_timeline_hook_installed = ensure_timeline_hook_installed
_initialize_timeline_tracking = initialize_timeline_tracking
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
Pytest Configuration and Shared Fixtures for Workspace Tests
==============================================================
Provides test fixtures for the workspace module tests.
"""
import os
import shutil
import subprocess
import sys
import tempfile
from collections.abc import Generator
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# =============================================================================
# MODULE MOCK CLEANUP - Prevents test isolation issues
# =============================================================================
# List of modules that might be mocked by test files
_POTENTIALLY_MOCKED_MODULES = [
"claude_code_sdk",
"claude_code_sdk.types",
"claude_agent_sdk",
"claude_agent_sdk.types",
]
# Store original module references at import time (BEFORE pre-mocking)
_original_module_state = {}
for _name in _POTENTIALLY_MOCKED_MODULES:
if _name in sys.modules:
_original_module_state[_name] = sys.modules[_name]
# =============================================================================
# PRE-MOCK EXTERNAL SDK MODULES - Must happen BEFORE adding auto-claude to path
# =============================================================================
# These SDK modules may not be installed, so we mock them before any imports
# that might trigger loading code that depends on them.
def _create_sdk_mock():
"""Create a comprehensive mock for SDK modules."""
mock = MagicMock()
mock.ClaudeAgentOptions = MagicMock
mock.ClaudeSDKClient = MagicMock
mock.HookMatcher = MagicMock
return mock
# Pre-mock claude_agent_sdk if not installed
if "claude_agent_sdk" not in sys.modules:
sys.modules["claude_agent_sdk"] = _create_sdk_mock()
sys.modules["claude_agent_sdk.types"] = MagicMock()
# Pre-mock claude_code_sdk if not installed
if "claude_code_sdk" not in sys.modules:
sys.modules["claude_code_sdk"] = _create_sdk_mock()
sys.modules["claude_code_sdk.types"] = MagicMock()
# Add backend directory to path for imports
# When co-located at workspace/tests/, go up to backend directory
# workspace/tests -> workspace -> core -> backend (4 levels up)
_backend = Path(__file__).resolve().parent.parent.parent.parent
sys.path.insert(0, str(_backend))
# Add repo root to sys.path for test_fixtures import fallback
_repo_root = _backend.parent.parent
sys.path.insert(0, str(_repo_root))
def _cleanup_mocked_modules():
"""Remove any MagicMock modules from sys.modules."""
for name in _POTENTIALLY_MOCKED_MODULES:
if name in sys.modules:
module = sys.modules[name]
if isinstance(module, MagicMock):
if name in _original_module_state:
sys.modules[name] = _original_module_state[name]
else:
del sys.modules[name]
def pytest_sessionstart(session):
"""Clean up any mocked modules before the test session starts."""
_cleanup_mocked_modules()
# =============================================================================
# DIRECTORY FIXTURES
# =============================================================================
@pytest.fixture
def temp_dir() -> Generator[Path, None, None]:
"""Create a temporary directory that's cleaned up after the test."""
temp_path = Path(tempfile.mkdtemp())
yield temp_path
shutil.rmtree(temp_path, ignore_errors=True)
@pytest.fixture
def temp_git_repo(temp_dir: Path) -> Generator[Path, None, None]:
"""Create a temporary git repository with initial commit.
IMPORTANT: This fixture properly isolates git operations by clearing
git environment variables that may be set by pre-commit hooks. Without
this isolation, git operations could affect the parent repository when
tests run inside a git worktree (e.g., during pre-commit validation).
See: https://git-scm.com/docs/git#_environment_variables
"""
# Save original environment values to restore later
orig_env = {}
# These git env vars may be set by pre-commit hooks and MUST be cleared
# to avoid git operations affecting the parent repository instead of
# our isolated test repo. This is critical when running inside worktrees.
git_vars_to_clear = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
]
# Clear interfering git environment variables
for key in git_vars_to_clear:
orig_env[key] = os.environ.get(key)
if key in os.environ:
del os.environ[key]
# Set GIT_CEILING_DIRECTORIES to prevent git from discovering parent .git
# directories. This is critical for test isolation when running inside
# another git repo (like during pre-commit hooks in worktrees).
orig_env["GIT_CEILING_DIRECTORIES"] = os.environ.get("GIT_CEILING_DIRECTORIES")
os.environ["GIT_CEILING_DIRECTORIES"] = str(temp_dir.parent)
try:
# Initialize git repo
subprocess.run(["git", "init"], cwd=temp_dir, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=temp_dir,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=temp_dir,
capture_output=True,
)
# Create initial commit
test_file = temp_dir / "README.md"
test_file.write_text("# Test Project\n", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"], cwd=temp_dir, capture_output=True
)
# Ensure branch is named 'main' (some git configs default to 'master')
subprocess.run(
["git", "branch", "-M", "main"], cwd=temp_dir, capture_output=True
)
yield temp_dir
finally:
# Restore original environment variables
for key, value in orig_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
@pytest.fixture
def spec_dir(temp_dir: Path) -> Path:
"""Create a spec directory inside temp_dir."""
spec_path = temp_dir / "spec"
spec_path.mkdir(parents=True)
return spec_path
@pytest.fixture
def project_dir(temp_dir: Path) -> Path:
"""Create a project directory inside temp_dir."""
project_path = temp_dir / "project"
project_path.mkdir(parents=True)
return project_path
@pytest.fixture
def make_commit(temp_git_repo: Path):
"""Fixture to make commits in the test git repo.
Usage:
def test_something(make_commit):
make_commit("message", files={"file.txt": "content"})
"""
def _make_commit(message: str, files: dict[str, str] | None = None):
"""Create a commit with the given message and files.
Args:
message: Commit message
files: Optional dict of {filepath: content} to create before committing
"""
if files:
for file_path, content in files.items():
full_path = temp_git_repo / file_path
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content, encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", message],
cwd=temp_git_repo,
capture_output=True,
)
return _make_commit
@pytest.fixture
def stage_files(temp_git_repo: Path):
"""Fixture to stage files in the test git repo.
Usage:
def test_something(stage_files):
stage_files({"file.txt": "content"})
"""
def _stage_files(files: dict[str, str]):
"""Stage files for commit.
Args:
files: Dict of {filepath: content} to create and stage
"""
for file_path, content in files.items():
full_path = temp_git_repo / file_path
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_text(content, encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
return _stage_files
@@ -0,0 +1,10 @@
[pytest]
# Pytest configuration for workspace module tests
# Async test mode
asyncio_mode = auto
# Register custom markers
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests (deselect with '-m "not integration"')
@@ -0,0 +1,856 @@
#!/usr/bin/env python3
"""
Tests for Workspace Display Functions
======================================
Tests the display.py module functionality including:
- Build summary display
- Changed files display
- Merge success printing
- Conflict info display
- Environment file operations
- Node modules symlink operations
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
# Test constant - in the new per-spec architecture, each spec has its own worktree
# named after the spec itself. This constant is used for test assertions.
TEST_SPEC_NAME = "test-spec"
class TestShowBuildSummary:
"""Tests for show_build_summary display function."""
def test_show_build_summary_no_changes(self, capsys):
"""show_build_summary prints info message when no changes."""
from unittest.mock import MagicMock
from core.workspace.display import show_build_summary
mock_manager = MagicMock()
mock_manager.get_change_summary.return_value = {
"new_files": 0,
"modified_files": 0,
"deleted_files": 0,
}
mock_manager.get_changed_files.return_value = []
show_build_summary(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "No changes were made" in captured.out
def test_show_build_summary_with_new_files(self, capsys):
"""show_build_summary displays new files count correctly."""
from unittest.mock import MagicMock
from core.workspace.display import show_build_summary
mock_manager = MagicMock()
mock_manager.get_change_summary.return_value = {
"new_files": 3,
"modified_files": 0,
"deleted_files": 0,
}
mock_manager.get_changed_files.return_value = [
("A", "file1.py"),
("A", "file2.py"),
("A", "file3.py"),
]
show_build_summary(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "What was built" in captured.out
assert "+ 3 new files" in captured.out
def test_show_build_summary_singular_new_file(self, capsys):
"""show_build_summary uses singular form for one new file."""
from unittest.mock import MagicMock
from core.workspace.display import show_build_summary
mock_manager = MagicMock()
mock_manager.get_change_summary.return_value = {
"new_files": 1,
"modified_files": 0,
"deleted_files": 0,
}
mock_manager.get_changed_files.return_value = [("A", "file1.py")]
show_build_summary(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "+ 1 new file" in captured.out
assert "files" not in captured.out.split("new file")[1].split("\n")[0]
def test_show_build_summary_with_modified_files(self, capsys):
"""show_build_summary displays modified files count correctly."""
from unittest.mock import MagicMock
from core.workspace.display import show_build_summary
mock_manager = MagicMock()
mock_manager.get_change_summary.return_value = {
"new_files": 0,
"modified_files": 2,
"deleted_files": 0,
}
mock_manager.get_changed_files.return_value = [
("M", "file1.py"),
("M", "file2.py"),
]
show_build_summary(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "~ 2 modified files" in captured.out
def test_show_build_summary_with_deleted_files(self, capsys):
"""show_build_summary displays deleted files count correctly."""
from unittest.mock import MagicMock
from core.workspace.display import show_build_summary
mock_manager = MagicMock()
mock_manager.get_change_summary.return_value = {
"new_files": 0,
"modified_files": 0,
"deleted_files": 1,
}
mock_manager.get_changed_files.return_value = [("D", "old.py")]
show_build_summary(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "- 1 deleted file" in captured.out
def test_show_build_summary_mixed_changes(self, capsys):
"""show_build_summary displays all change types together."""
from unittest.mock import MagicMock
from core.workspace.display import show_build_summary
mock_manager = MagicMock()
mock_manager.get_change_summary.return_value = {
"new_files": 2,
"modified_files": 3,
"deleted_files": 1,
}
mock_manager.get_changed_files.return_value = [
("A", "new1.py"),
("A", "new2.py"),
("M", "mod1.py"),
("M", "mod2.py"),
("M", "mod3.py"),
("D", "old.py"),
]
show_build_summary(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "+ 2 new files" in captured.out
assert "~ 3 modified files" in captured.out
assert "- 1 deleted file" in captured.out
class TestShowChangedFiles:
"""Tests for show_changed_files display function."""
def test_show_changed_files_empty_list(self, capsys):
"""show_changed_files prints info message when no files changed."""
from unittest.mock import MagicMock
from core.workspace.display import show_changed_files
mock_manager = MagicMock()
mock_manager.get_changed_files.return_value = []
show_changed_files(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "No changes" in captured.out
def test_show_changed_files_with_added_file(self, capsys):
"""show_changed_files displays added file with + prefix."""
from unittest.mock import MagicMock
from core.workspace.display import show_changed_files
mock_manager = MagicMock()
mock_manager.get_changed_files.return_value = [("A", "new_file.py")]
show_changed_files(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "Changed files" in captured.out
assert "+ new_file.py" in captured.out
def test_show_changed_files_with_modified_file(self, capsys):
"""show_changed_files displays modified file with ~ prefix."""
from unittest.mock import MagicMock
from core.workspace.display import show_changed_files
mock_manager = MagicMock()
mock_manager.get_changed_files.return_value = [("M", "changed.py")]
show_changed_files(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "~ changed.py" in captured.out
def test_show_changed_files_with_deleted_file(self, capsys):
"""show_changed_files displays deleted file with - prefix."""
from unittest.mock import MagicMock
from core.workspace.display import show_changed_files
mock_manager = MagicMock()
mock_manager.get_changed_files.return_value = [("D", "removed.py")]
show_changed_files(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "- removed.py" in captured.out
def test_show_changed_files_with_unknown_status(self, capsys):
"""show_changed_files displays unknown status code without decoration."""
from unittest.mock import MagicMock
from core.workspace.display import show_changed_files
mock_manager = MagicMock()
mock_manager.get_changed_files.return_value = [("R", "renamed.py")]
show_changed_files(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "R renamed.py" in captured.out
def test_show_changed_files_multiple_files(self, capsys):
"""show_changed_files displays all changed files."""
from unittest.mock import MagicMock
from core.workspace.display import show_changed_files
mock_manager = MagicMock()
mock_manager.get_changed_files.return_value = [
("A", "new.py"),
("M", "modified.py"),
("D", "deleted.py"),
("R", "renamed.py"),
]
show_changed_files(mock_manager, "test-spec")
captured = capsys.readouterr()
assert "+ new.py" in captured.out
assert "~ modified.py" in captured.out
assert "- deleted.py" in captured.out
assert "R renamed.py" in captured.out
class TestPrintMergeSuccess:
"""Tests for print_merge_success display function."""
def test_print_merge_success_no_commit_basic(self, capsys):
"""print_merge_success with no_commit=True shows basic message."""
from core.workspace.display import print_merge_success
print_merge_success(no_commit=True)
captured = capsys.readouterr()
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
assert "working directory" in captured.out
assert "Review the changes" in captured.out
assert "commit when ready" in captured.out
def test_print_merge_success_no_commit_with_lock_files(self, capsys):
"""print_merge_success with lock_files_excluded shows lock file note."""
from core.workspace.display import print_merge_success
stats = {"lock_files_excluded": 2}
print_merge_success(no_commit=True, stats=stats)
captured = capsys.readouterr()
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
assert "Lock files kept from main" in captured.out
assert "npm install" in captured.out
def test_print_merge_success_no_commit_with_keep_worktree(self, capsys):
"""print_merge_success with keep_worktree shows discard command."""
from core.workspace.display import print_merge_success
print_merge_success(no_commit=True, spec_name="spec-001", keep_worktree=True)
captured = capsys.readouterr()
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
assert "Worktree kept for testing" in captured.out
assert "python auto-claude/run.py --spec spec-001 --discard" in captured.out
def test_print_merge_success_no_commit_full_scenario(self, capsys):
"""print_merge_success with all optional parameters."""
from core.workspace.display import print_merge_success
stats = {"lock_files_excluded": 1}
print_merge_success(
no_commit=True,
stats=stats,
spec_name="test-spec",
keep_worktree=True,
)
captured = capsys.readouterr()
assert "CHANGES ADDED TO YOUR PROJECT" in captured.out
assert "Lock files kept from main" in captured.out
assert "Worktree kept for testing" in captured.out
assert "--spec test-spec --discard" in captured.out
def test_print_merge_success_with_commit_basic(self, capsys):
"""print_merge_success with no_commit=False shows commit message."""
from core.workspace.display import print_merge_success
print_merge_success(no_commit=False)
captured = capsys.readouterr()
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
assert "separate workspace has been cleaned up" in captured.out
def test_print_merge_success_with_commit_and_stats(self, capsys):
"""print_merge_success with stats shows file counts."""
from core.workspace.display import print_merge_success
stats = {
"files_added": 5,
"files_modified": 3,
"files_deleted": 1,
}
print_merge_success(no_commit=False, stats=stats)
captured = capsys.readouterr()
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
assert "What changed" in captured.out
assert "+ 5 files added" in captured.out
assert "~ 3 files modified" in captured.out
assert "- 1 file deleted" in captured.out
def test_print_merge_success_singular_file_counts(self, capsys):
"""print_merge_success uses singular form for single file counts."""
from core.workspace.display import print_merge_success
stats = {
"files_added": 1,
"files_modified": 1,
"files_deleted": 1,
}
print_merge_success(no_commit=False, stats=stats)
captured = capsys.readouterr()
assert "+ 1 file added" in captured.out
assert "~ 1 file modified" in captured.out
assert "- 1 file deleted" in captured.out
def test_print_merge_success_with_keep_worktree(self, capsys):
"""print_merge_success with keep_worktree shows discard command."""
from core.workspace.display import print_merge_success
print_merge_success(no_commit=False, keep_worktree=True, spec_name="my-spec")
captured = capsys.readouterr()
assert "FEATURE ADDED TO YOUR PROJECT" in captured.out
assert "Worktree kept for testing" in captured.out
assert "--spec my-spec --discard" in captured.out
assert "separate workspace has been cleaned up" not in captured.out
def test_print_merge_success_zero_file_counts_not_shown(self, capsys):
"""print_merge_success doesn't show file types with zero count."""
from core.workspace.display import print_merge_success
stats = {
"files_added": 2,
"files_modified": 0,
"files_deleted": 0,
}
print_merge_success(no_commit=False, stats=stats)
captured = capsys.readouterr()
assert "+ 2 files added" in captured.out
assert "files modified" not in captured.out
assert "files deleted" not in captured.out
class TestPrintConflictInfoExtended:
"""Extended tests for print_conflict_info display function."""
def test_print_conflict_info_empty_conflicts(self, capsys):
"""print_conflict_info returns early with empty conflicts list."""
from core.workspace.display import print_conflict_info
result = {"conflicts": []}
print_conflict_info(result)
captured = capsys.readouterr()
assert captured.out == ""
def test_print_conflict_info_no_conflicts_key(self, capsys):
"""print_conflict_info returns early when conflicts key missing."""
from core.workspace.display import print_conflict_info
result = {}
print_conflict_info(result)
captured = capsys.readouterr()
assert captured.out == ""
def test_print_conflict_info_critical_severity(self, capsys):
"""print_conflict_info shows critical severity icon."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{
"file": "critical.py",
"reason": "Breaking change",
"severity": "critical",
}
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "critical.py" in captured.out
assert "" in captured.out
assert "Breaking change" in captured.out
def test_print_conflict_info_high_severity(self, capsys):
"""print_conflict_info shows high severity icon."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{"file": "high.py", "reason": "Major conflict", "severity": "high"}
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "high.py" in captured.out
assert "🔴" in captured.out
assert "Major conflict" in captured.out
def test_print_conflict_info_medium_severity(self, capsys):
"""print_conflict_info shows medium severity icon."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{"file": "medium.py", "reason": "Minor conflict", "severity": "medium"}
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "medium.py" in captured.out
assert "🟡" in captured.out
assert "Minor conflict" in captured.out
def test_print_conflict_info_low_severity_no_icon(self, capsys):
"""print_conflict_info shows no icon for low severity."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{"file": "low.py", "reason": "Trivial issue", "severity": "low"}
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "low.py" in captured.out
assert "Trivial issue" in captured.out
assert "" not in captured.out
assert "🔴" not in captured.out
assert "🟡" not in captured.out
def test_print_conflict_info_unknown_severity(self, capsys):
"""print_conflict_info handles unknown severity gracefully."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{"file": "unknown.py", "reason": "Unknown", "severity": "unknown"}
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "unknown.py" in captured.out
assert "Unknown" in captured.out
def test_print_conflict_info_missing_file_key(self, capsys):
"""print_conflict_info handles missing file key."""
from core.workspace.display import print_conflict_info
result = {"conflicts": [{"reason": "No file specified", "severity": "high"}]}
print_conflict_info(result)
captured = capsys.readouterr()
assert "unknown" in captured.out
assert "No file specified" in captured.out
def test_print_conflict_info_missing_reason_key(self, capsys):
"""print_conflict_info handles missing reason key."""
from core.workspace.display import print_conflict_info
result = {"conflicts": [{"file": "noreason.py", "severity": "medium"}]}
print_conflict_info(result)
captured = capsys.readouterr()
assert "noreason.py" in captured.out
def test_print_conflict_info_dict_no_reason(self, capsys):
"""print_conflict_info with dict missing reason."""
from core.workspace.display import print_conflict_info
result = {"conflicts": [{"file": "test.py", "severity": "high"}]}
print_conflict_info(result)
captured = capsys.readouterr()
assert "test.py" in captured.out
assert "🔴" in captured.out
def test_print_conflict_info_multiple_conflicts(self, capsys):
"""print_conflict_info handles multiple conflicts."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{"file": "critical.py", "reason": "Critical", "severity": "critical"},
{"file": "high.py", "reason": "High", "severity": "high"},
{"file": "medium.py", "reason": "Medium", "severity": "medium"},
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "3 file" in captured.out
assert "" in captured.out
assert "🔴" in captured.out
assert "🟡" in captured.out
def test_print_conflict_info_shows_marker_conflict_message(self, capsys):
"""print_conflict_info shows marker conflict message for string conflicts."""
from core.workspace.display import print_conflict_info
result = {"conflicts": ["conflict.py"]}
print_conflict_info(result)
captured = capsys.readouterr()
assert "conflict markers" in captured.out
# Check that the conflict markers are mentioned in the message
def test_print_conflict_info_shows_ai_conflict_message(self, capsys):
"""print_conflict_info shows AI conflict message for dict conflicts."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{
"file": "ai-conflict.py",
"reason": "AI merge failed",
"severity": "high",
}
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "could not be auto-merged" in captured.out
def test_print_conflict_info_shows_both_messages_mixed(self, capsys):
"""print_conflict_info shows both messages for mixed conflicts."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
"marker.py",
{"file": "ai.py", "reason": "AI failed", "severity": "high"},
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "conflict markers" in captured.out
assert "could not be auto-merged" in captured.out
def test_print_conflict_info_shows_git_commands(self, capsys):
"""print_conflict_info shows git add and commit commands."""
from core.workspace.display import print_conflict_info
result = {"conflicts": ["file1.py", "file2.py"]}
print_conflict_info(result)
captured = capsys.readouterr()
assert "git add" in captured.out
assert "git commit" in captured.out
def test_print_conflict_info_quotes_special_paths(self, capsys):
"""print_conflict_info properly quotes file paths with special characters."""
from core.workspace.display import print_conflict_info
result = {"conflicts": ["file with spaces.py", "file'with'quotes.py"]}
print_conflict_info(result)
captured = capsys.readouterr()
# shlex.quote should quote paths with spaces
assert "git add" in captured.out
assert "file with spaces.py" in captured.out
def test_print_conflict_info_deduplicates_files(self, capsys):
"""print_conflict_info deduplicates file paths in git command."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
"file1.py",
{"file": "file1.py", "reason": "Also here", "severity": "medium"},
"file2.py",
]
}
print_conflict_info(result)
captured = capsys.readouterr()
# Count occurrences of file1.py
count = captured.out.count("file1.py")
assert count == 3 # Display shows it twice (string + dict), once in git add
def test_print_conflict_info_preserves_order(self, capsys):
"""print_conflict_info preserves file order while deduplicating."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
"first.py",
{"file": "second.py", "severity": "high"},
"first.py", # Duplicate
{"file": "third.py", "severity": "medium"},
]
}
print_conflict_info(result)
captured = capsys.readouterr()
# First occurrence should be preserved
lines = captured.out.split("\n")
first_idx = None
second_idx = None
for i, line in enumerate(lines):
if "first.py" in line:
if first_idx is None:
first_idx = i
if "second.py" in line:
if second_idx is None:
second_idx = i
assert first_idx is not None
assert second_idx is not None
class TestCopyEnvFilesToWorktree:
"""Tests for copy_env_files_to_worktree function."""
def test_copies_all_env_files(self, temp_git_repo: Path):
"""Copies all .env files when they exist in project dir."""
from core.workspace.setup import copy_env_files_to_worktree
# Create .env files in project
(temp_git_repo / ".env").write_text("FOO=bar", encoding="utf-8")
(temp_git_repo / ".env.local").write_text("LOCAL=1", encoding="utf-8")
(temp_git_repo / ".env.development").write_text("DEV=1", encoding="utf-8")
# Create worktree directory
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
# Copy env files
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
# Check all files were copied
assert ".env" in copied
assert ".env.local" in copied
assert ".env.development" in copied
assert len(copied) == 3
# Verify files exist in worktree
assert (worktree_path / ".env").exists()
assert (worktree_path / ".env.local").exists()
assert (worktree_path / ".env.development").exists()
def test_skips_nonexistent_env_files(self, temp_git_repo: Path):
"""Only copies env files that exist."""
from core.workspace.setup import copy_env_files_to_worktree
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
assert len(copied) == 0
def test_does_not_overwrite_existing_env_files(self, temp_git_repo: Path):
"""Does not overwrite .env files that already exist in worktree."""
from core.workspace.setup import copy_env_files_to_worktree
# Create .env in project
(temp_git_repo / ".env").write_text("PROJECT=1", encoding="utf-8")
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
# Create existing .env in worktree with different content
(worktree_path / ".env").write_text("WORKTREE=1", encoding="utf-8")
copied = copy_env_files_to_worktree(temp_git_repo, worktree_path)
# .env should not be in copied list since it already existed
assert ".env" not in copied
# Worktree .env should keep its original content
assert (worktree_path / ".env").read_text(encoding="utf-8") == "WORKTREE=1"
class TestSymlinkNodeModulesToWorktree:
"""Tests for symlink_node_modules_to_worktree function."""
@pytest.mark.skipif(sys.platform != "linux", reason="Unix-specific test")
def test_symlinks_node_modules_on_unix(self, temp_git_repo: Path):
"""Creates relative symlinks on Unix systems."""
from core.workspace.setup import symlink_node_modules_to_worktree
# Create node_modules in project
node_modules = temp_git_repo / "node_modules"
node_modules.mkdir()
(node_modules / "test.txt").write_text("test", encoding="utf-8")
# Create apps/frontend/node_modules
frontend_node_modules = temp_git_repo / "apps" / "frontend" / "node_modules"
frontend_node_modules.mkdir(parents=True)
(frontend_node_modules / "test2.txt").write_text("test2", encoding="utf-8")
# Create worktree
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
(worktree_path / "apps" / "frontend").mkdir(parents=True)
# Create symlinks
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
assert len(symlinked) == 2
assert "node_modules" in symlinked
assert "apps/frontend/node_modules" in symlinked
# Verify symlinks exist and point to correct location
assert (worktree_path / "node_modules").is_symlink()
assert (worktree_path / "apps" / "frontend" / "node_modules").is_symlink()
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test")
def test_creates_junctions_on_windows(self, temp_git_repo: Path, monkeypatch):
"""Creates junctions on Windows systems."""
from unittest.mock import patch
from core.workspace.setup import symlink_node_modules_to_worktree
# Create node_modules in project
node_modules = temp_git_repo / "node_modules"
node_modules.mkdir()
(node_modules / "test.txt").write_text("test", encoding="utf-8")
# Create worktree
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
# Mock subprocess.run to simulate mklink /J success
def mock_subprocess_run(cmd, capture_output=False, text=False):
result = type("obj", (object,), {"returncode": 0, "stderr": ""})()
return result
with patch("subprocess.run", side_effect=mock_subprocess_run):
with monkeypatch.context() as m:
m.setattr("sys.platform", "win32")
symlinked = symlink_node_modules_to_worktree(
temp_git_repo, worktree_path
)
assert "node_modules" in symlinked
def test_skips_nonexistent_node_modules(self, temp_git_repo: Path):
"""Skips node_modules that don't exist in project."""
from core.workspace.setup import symlink_node_modules_to_worktree
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
assert len(symlinked) == 0
def test_skips_existing_symlinks(self, temp_git_repo: Path):
"""Does not recreate symlinks that already exist."""
from core.workspace.setup import symlink_node_modules_to_worktree
# Create node_modules in project
node_modules = temp_git_repo / "node_modules"
node_modules.mkdir()
(node_modules / "test.txt").write_text("test", encoding="utf-8")
# Create worktree
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
# Create existing symlink
if sys.platform != "win32":
os.symlink(temp_git_repo / "node_modules", worktree_path / "node_modules")
symlinked = symlink_node_modules_to_worktree(temp_git_repo, worktree_path)
# Should skip existing symlink
assert "node_modules" not in symlinked
@@ -0,0 +1,805 @@
#!/usr/bin/env python3
"""
Tests for Workspace Selection and Management
=============================================
Tests the workspace.py module functionality including:
- Workspace mode selection (isolated vs direct)
- Uncommitted changes detection
- Workspace setup
- Build finalization workflows
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
# Add parent directory to path so we can import the workspace module
# When co-located at workspace/tests/, we need to add backend to path
# workspace/tests -> workspace -> core -> backend (4 levels up)
_backend = Path(__file__).resolve().parent.parent.parent.parent
sys.path.insert(0, str(_backend))
from core.workspace import (
WorkspaceChoice,
WorkspaceMode,
get_current_branch,
get_existing_build_worktree,
has_uncommitted_changes,
setup_workspace,
)
from worktree import WorktreeError, WorktreeManager
# Test constant - in the new per-spec architecture, each spec has its own worktree
# named after the spec itself. This constant is used for test assertions.
TEST_SPEC_NAME = "test-spec"
# =============================================================================
# TESTS FOR finalization.py
# =============================================================================
class TestFinalizeWorkspace:
"""Tests for finalize_workspace function."""
def test_direct_mode_returns_merge(self, temp_git_repo: Path, monkeypatch, capsys):
"""Direct mode returns MERGE choice and shows completion message."""
from core.workspace.finalization import finalize_workspace
# Mock the UI functions
def mock_box(content, width=60, style="heavy"):
return content
monkeypatch.setattr("core.workspace.finalization.box", mock_box)
result = finalize_workspace(
temp_git_repo,
"test-spec",
manager=None,
auto_continue=False,
)
assert result == WorkspaceChoice.MERGE
captured = capsys.readouterr()
assert "BUILD COMPLETE" in captured.out
assert "directly to your project" in captured.out
def test_auto_continue_mode_returns_later(self, temp_git_repo: Path):
"""Auto-continue mode returns LATER choice."""
from core.workspace.finalization import finalize_workspace
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree info
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
result = finalize_workspace(
temp_git_repo,
spec_name,
manager=manager,
auto_continue=True,
)
assert result == WorkspaceChoice.LATER
def test_isolated_mode_shows_menu(self, temp_git_repo: Path, monkeypatch):
"""Isolated mode shows menu with test/review/merge/later options."""
from core.workspace.finalization import finalize_workspace
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock select_menu to return "test"
def mock_select_menu(title, options, allow_quit):
return "test"
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
result = finalize_workspace(
temp_git_repo,
spec_name,
manager=manager,
auto_continue=False,
)
assert result == WorkspaceChoice.TEST
class TestHandleWorkspaceChoice:
"""Tests for handle_workspace_choice function."""
def test_choice_test_shows_instructions(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""TEST choice shows testing instructions."""
from core.workspace.finalization import handle_workspace_choice
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
captured = capsys.readouterr()
assert "TEST YOUR FEATURE" in captured.out
assert str(worktree_path) in captured.out
def test_choice_merge_calls_merge_worktree(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""MERGE choice calls manager.merge_worktree."""
from core.workspace.finalization import handle_workspace_choice
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree and commit something
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
(worktree_path / "test.py").write_text("test", encoding="utf-8")
# Initialize git in worktree and commit
subprocess.run(["git", "init"], cwd=worktree_path, capture_output=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=worktree_path,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=worktree_path,
capture_output=True,
)
subprocess.run(["git", "add", "."], cwd=worktree_path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Test"], cwd=worktree_path, capture_output=True
)
handle_workspace_choice(
WorkspaceChoice.MERGE, temp_git_repo, spec_name, manager
)
captured = capsys.readouterr()
assert "Adding changes" in captured.out
def test_choice_review_shows_changed_files(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""REVIEW choice shows changed files."""
from core.workspace.finalization import handle_workspace_choice
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock show_changed_files
mock_shown = []
def mock_show_changed_files(manager, spec_name):
mock_shown.append(spec_name)
monkeypatch.setattr(
"core.workspace.finalization.show_changed_files", mock_show_changed_files
)
handle_workspace_choice(
WorkspaceChoice.REVIEW, temp_git_repo, spec_name, manager
)
assert len(mock_shown) == 1
assert mock_shown[0] == spec_name
captured = capsys.readouterr()
assert "To see full details" in captured.out
def test_choice_later_shows_deferred_message(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""LATER choice shows deferral message."""
from core.workspace.finalization import handle_workspace_choice
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
handle_workspace_choice(
WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
)
captured = capsys.readouterr()
assert "No problem!" in captured.out
assert "saved" in captured.out
class TestReviewExistingBuild:
"""Tests for review_existing_build function."""
def test_no_existing_build_shows_warning(self, temp_git_repo: Path, capsys):
"""Shows warning when no existing build found."""
from core.workspace.finalization import review_existing_build
result = review_existing_build(temp_git_repo, "nonexistent-spec")
assert result is False
captured = capsys.readouterr()
assert "No existing build found" in captured.out
def test_shows_build_contents(self, temp_git_repo: Path, capsys):
"""Shows build summary and changed files when build exists."""
from core.workspace.finalization import review_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
result = review_existing_build(temp_git_repo, spec_name)
assert result is True
captured = capsys.readouterr()
assert "BUILD CONTENTS" in captured.out
class TestDiscardExistingBuild:
"""Tests for discard_existing_build function."""
def test_no_existing_build_returns_false(self, temp_git_repo: Path, capsys):
"""Returns False when no existing build found."""
from core.workspace.finalization import discard_existing_build
result = discard_existing_build(temp_git_repo, "nonexistent-spec")
assert result is False
captured = capsys.readouterr()
assert "No existing build found" in captured.out
def test_confirmation_deletes_build(self, temp_git_repo: Path, monkeypatch, capsys):
"""Deletes build when user types 'delete' to confirm."""
from core.workspace.finalization import discard_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock input to return "delete"
monkeypatch.setattr("builtins.input", lambda: "delete")
result = discard_existing_build(temp_git_repo, spec_name)
assert result is True
captured = capsys.readouterr()
assert "Build deleted" in captured.out
def test_cancelled_confirmation_returns_false(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""Returns False when user doesn't confirm."""
from core.workspace.finalization import discard_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock input to return "no"
monkeypatch.setattr("builtins.input", lambda: "no")
result = discard_existing_build(temp_git_repo, spec_name)
assert result is False
captured = capsys.readouterr()
assert "Cancelled" in captured.out
class TestCheckExistingBuild:
"""Tests for check_existing_build function."""
def test_no_existing_build_returns_false(self, temp_git_repo: Path):
"""Returns False when no existing build."""
from core.workspace.finalization import check_existing_build
result = check_existing_build(temp_git_repo, "nonexistent-spec")
assert result is False
def test_shows_menu_for_existing_build(self, temp_git_repo: Path, monkeypatch):
"""Shows menu when existing build found."""
from core.workspace.finalization import check_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock select_menu to return "continue"
def mock_select_menu(title, options, allow_quit):
return "continue"
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
result = check_existing_build(temp_git_repo, spec_name)
assert result is True
def test_review_choice_reviews_and_continues(
self, temp_git_repo: Path, monkeypatch
):
"""Review choice reviews build then continues."""
from core.workspace.finalization import check_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
review_called = []
def mock_review(project_dir, spec_name):
review_called.append(spec_name)
return True
def mock_select_menu(title, options, allow_quit):
return "review"
def mock_input(prompt):
return ""
monkeypatch.setattr(
"core.workspace.finalization.review_existing_build", mock_review
)
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
monkeypatch.setattr("builtins.input", mock_input)
result = check_existing_build(temp_git_repo, spec_name)
assert result is True
assert spec_name in review_called
class TestListAllWorktrees:
"""Tests for list_all_worktrees function."""
def test_returns_empty_list_when_no_worktrees(self, temp_git_repo: Path):
"""Returns empty list when no worktrees exist."""
from core.workspace.finalization import list_all_worktrees
result = list_all_worktrees(temp_git_repo)
assert result == []
def test_lists_existing_worktrees(self, temp_git_repo: Path):
"""Returns list of existing worktrees."""
from core.workspace.finalization import list_all_worktrees
# Create worktrees
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
(worktrees_dir / "spec-001").mkdir()
(worktrees_dir / "spec-002").mkdir()
result = list_all_worktrees(temp_git_repo)
assert len(result) == 2
spec_names = {wt.spec_name for wt in result}
assert "spec-001" in spec_names
assert "spec-002" in spec_names
class TestCleanupAllWorktrees:
"""Tests for cleanup_all_worktrees function."""
def test_no_worktrees_returns_false(self, temp_git_repo: Path, capsys):
"""Returns False when no worktrees found."""
from core.workspace.finalization import cleanup_all_worktrees
result = cleanup_all_worktrees(temp_git_repo, confirm=False)
assert result is False
captured = capsys.readouterr()
assert "No worktrees found" in captured.out
def test_cleanup_without_confirmation(self, temp_git_repo: Path):
"""Cleans up worktrees when confirm=False."""
from core.workspace.finalization import cleanup_all_worktrees
# Create worktrees
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
spec1_path = worktrees_dir / "spec-001"
spec1_path.mkdir()
spec2_path = worktrees_dir / "spec-002"
spec2_path.mkdir()
result = cleanup_all_worktrees(temp_git_repo, confirm=False)
assert result is True
assert not spec1_path.exists()
assert not spec2_path.exists()
def test_cleanup_with_confirmation_yes(self, temp_git_repo: Path, monkeypatch):
"""Cleans up worktrees when user confirms with 'yes'."""
from core.workspace.finalization import cleanup_all_worktrees
# Create worktrees
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
spec1_path = worktrees_dir / "spec-001"
spec1_path.mkdir()
# Mock input to return "yes"
monkeypatch.setattr("builtins.input", lambda: "yes")
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
assert result is True
assert not spec1_path.exists()
def test_cleanup_with_confirmation_no(self, temp_git_repo: Path, monkeypatch):
"""Cancels cleanup when user doesn't confirm."""
from core.workspace.finalization import cleanup_all_worktrees
# Create worktrees
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
spec1_path = worktrees_dir / "spec-001"
spec1_path.mkdir()
# Mock input to return "no"
monkeypatch.setattr("builtins.input", lambda: "no")
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
assert result is False
assert spec1_path.exists() # Should still exist
def test_cleanup_with_confirmation_keyboard_interrupt(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""Cancels cleanup when user presses Ctrl+C (KeyboardInterrupt)."""
from core.workspace.finalization import cleanup_all_worktrees
# Create worktrees
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
spec1_path = worktrees_dir / "spec-001"
spec1_path.mkdir()
# Mock input to raise KeyboardInterrupt
def mock_input(prompt=""):
raise KeyboardInterrupt()
monkeypatch.setattr("builtins.input", mock_input)
result = cleanup_all_worktrees(temp_git_repo, confirm=True)
assert result is False
assert spec1_path.exists() # Should still exist
captured = capsys.readouterr()
assert "Cancelled" in captured.out
class TestFinalizeWorkspaceBranchCoverage:
"""Additional tests for finalize_workspace to cover missing branches."""
def test_isolated_mode_merge_choice(self, temp_git_repo: Path, monkeypatch):
"""Isolated mode returns MERGE when user selects merge."""
from core.workspace.finalization import finalize_workspace
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock select_menu to return "merge"
def mock_select_menu(title, options, allow_quit):
return "merge"
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
result = finalize_workspace(
temp_git_repo,
spec_name,
manager=manager,
auto_continue=False,
)
assert result == WorkspaceChoice.MERGE
def test_isolated_mode_review_choice(self, temp_git_repo: Path, monkeypatch):
"""Isolated mode returns REVIEW when user selects review."""
from core.workspace.finalization import finalize_workspace
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock select_menu to return "review"
def mock_select_menu(title, options, allow_quit):
return "review"
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
result = finalize_workspace(
temp_git_repo,
spec_name,
manager=manager,
auto_continue=False,
)
assert result == WorkspaceChoice.REVIEW
def test_isolated_mode_later_choice(self, temp_git_repo: Path, monkeypatch):
"""Isolated mode returns LATER when user selects later."""
from core.workspace.finalization import finalize_workspace
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock select_menu to return "later"
def mock_select_menu(title, options, allow_quit):
return "later"
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
result = finalize_workspace(
temp_git_repo,
spec_name,
manager=manager,
auto_continue=False,
)
assert result == WorkspaceChoice.LATER
class TestHandleWorkspaceChoiceBranchCoverage:
"""Additional tests for handle_workspace_choice to cover missing branches."""
def test_choice_test_without_staging_path(self, temp_git_repo: Path, capsys):
"""TEST choice shows fallback instructions when staging_path is None."""
from core.workspace.finalization import handle_workspace_choice
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree directory (but not through manager, so no staging_path)
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
handle_workspace_choice(WorkspaceChoice.TEST, temp_git_repo, spec_name, manager)
captured = capsys.readouterr()
assert "TEST YOUR FEATURE" in captured.out
# Should show the fallback path
assert (
str(worktree_path) in captured.out
or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
)
def test_choice_merge_success(self, temp_git_repo: Path, capsys):
"""MERGE choice shows success message when merge succeeds."""
from core.workspace.finalization import handle_workspace_choice
from worktree import WorktreeManager
# Setup a proper isolated workspace with git worktree
working_dir, manager, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# Make changes and commit
(working_dir / "test.py").write_text("test content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Add test"], cwd=working_dir, capture_output=True
)
handle_workspace_choice(
WorkspaceChoice.MERGE, temp_git_repo, "test-spec", manager
)
captured = capsys.readouterr()
assert "Your feature has been added" in captured.out
def test_choice_later_without_staging_path(self, temp_git_repo: Path, capsys):
"""LATER choice shows fallback path when staging_path is None."""
from core.workspace.finalization import handle_workspace_choice
manager = WorktreeManager(temp_git_repo)
spec_name = "test-spec"
# Create worktree directory (but not through manager, so no staging_path)
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
handle_workspace_choice(
WorkspaceChoice.LATER, temp_git_repo, spec_name, manager
)
captured = capsys.readouterr()
assert "No problem!" in captured.out
# Should show the fallback path
assert (
str(worktree_path) in captured.out
or f".auto-claude/worktrees/tasks/{spec_name}" in captured.out
)
class TestDiscardExistingBuildBranchCoverage:
"""Additional tests for discard_existing_build to cover missing branches."""
def test_keyboard_interrupt_cancels_discard(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""KeyboardInterrupt during confirmation returns False."""
from core.workspace.finalization import discard_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock input to raise KeyboardInterrupt
def mock_input(prompt=""):
raise KeyboardInterrupt()
monkeypatch.setattr("builtins.input", mock_input)
result = discard_existing_build(temp_git_repo, spec_name)
assert result is False
captured = capsys.readouterr()
assert "Cancelled" in captured.out
class TestCheckExistingBuildBranchCoverage:
"""Additional tests for check_existing_build to cover missing branches."""
def test_none_choice_exits(self, temp_git_repo: Path, monkeypatch):
"""None choice (quit) calls sys.exit(0)."""
import sys
from core.workspace.finalization import check_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
# Mock select_menu to return None (quit)
def mock_select_menu(title, options, allow_quit):
return None
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
# Should raise SystemExit
with pytest.raises(SystemExit) as exc_info:
check_existing_build(temp_git_repo, spec_name)
assert exc_info.value.code == 0
def test_merge_choice_merges_and_returns_false(
self, temp_git_repo: Path, monkeypatch
):
"""Merge choice calls merge_existing_build and returns False."""
from unittest.mock import MagicMock
from core.workspace.finalization import check_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
merge_called = []
def mock_merge_existing_build(project_dir, spec_name):
merge_called.append(spec_name)
def mock_select_menu(title, options, allow_quit):
return "merge"
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
# Mock the workspace module import
import workspace as ws
original_merge = getattr(ws, "merge_existing_build", None)
ws.merge_existing_build = mock_merge_existing_build
try:
result = check_existing_build(temp_git_repo, spec_name)
assert result is False
assert spec_name in merge_called
finally:
if original_merge:
ws.merge_existing_build = original_merge
def test_fresh_choice_discards_and_returns_false(
self, temp_git_repo: Path, monkeypatch
):
"""Fresh choice discards build and returns False (start fresh)."""
from core.workspace.finalization import check_existing_build
spec_name = "test-spec"
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_path = worktrees_dir / spec_name
worktree_path.mkdir(parents=True)
def mock_select_menu(title, options, allow_quit):
return "fresh"
monkeypatch.setattr("core.workspace.finalization.select_menu", mock_select_menu)
# Mock input to return "delete" for confirmation
monkeypatch.setattr("builtins.input", lambda: "delete")
result = check_existing_build(temp_git_repo, spec_name)
assert result is False, "Fresh choice should return False"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,638 @@
#!/usr/bin/env python3
"""
Tests for Workspace Models
==========================
Tests the workspace.py module models including:
- WorkspaceMode enum
- WorkspaceChoice enum
- ParallelMergeTask
- ParallelMergeResult
- MergeLock and MergeLockError
- SpecNumberLock and SpecNumberLockError
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
# Add parent directory to path so we can import the workspace module
# When co-located at workspace/tests/, we need to add backend to path
# workspace/tests -> workspace -> core -> backend (4 levels up)
_backend = Path(__file__).resolve().parent.parent.parent.parent
sys.path.insert(0, str(_backend))
from core.workspace.models import (
MergeLock,
MergeLockError,
ParallelMergeResult,
ParallelMergeTask,
SpecNumberLock,
SpecNumberLockError,
)
from worktree import WorktreeError, WorktreeManager
# Test constant - in the new per-spec architecture, each spec has its own worktree
# named after the spec itself. This constant is used for test assertions.
TEST_SPEC_NAME = "test-spec"
class TestWorkspaceMode:
"""Tests for WorkspaceMode enum."""
def test_isolated_mode(self):
"""ISOLATED mode value is correct."""
from core.workspace.models import WorkspaceMode
assert WorkspaceMode.ISOLATED.value == "isolated"
def test_direct_mode(self):
"""DIRECT mode value is correct."""
from core.workspace.models import WorkspaceMode
assert WorkspaceMode.DIRECT.value == "direct"
class TestWorkspaceChoice:
"""Tests for WorkspaceChoice enum."""
def test_merge_choice(self):
"""MERGE choice value is correct."""
from core.workspace.models import WorkspaceChoice
assert WorkspaceChoice.MERGE.value == "merge"
def test_review_choice(self):
"""REVIEW choice value is correct."""
from core.workspace.models import WorkspaceChoice
assert WorkspaceChoice.REVIEW.value == "review"
def test_test_choice(self):
"""TEST choice value is correct."""
from core.workspace.models import WorkspaceChoice
assert WorkspaceChoice.TEST.value == "test"
def test_later_choice(self):
"""LATER choice value is correct."""
from core.workspace.models import WorkspaceChoice
assert WorkspaceChoice.LATER.value == "later"
class TestParallelMergeTask:
"""Tests for ParallelMergeTask dataclass."""
def test_create_merge_task(self):
"""ParallelMergeTask can be instantiated with all fields."""
task = ParallelMergeTask(
file_path="src/example.py",
main_content="main content",
worktree_content="worktree content",
base_content="base content",
spec_name="test-spec",
project_dir=Path("/project"),
)
assert task.file_path == "src/example.py"
assert task.main_content == "main content"
assert task.worktree_content == "worktree content"
assert task.base_content == "base content"
assert task.spec_name == "test-spec"
assert task.project_dir == Path("/project")
def test_merge_task_with_none_base(self):
"""ParallelMergeTask can have None for base_content."""
task = ParallelMergeTask(
file_path="src/example.py",
main_content="main content",
worktree_content="worktree content",
base_content=None,
spec_name="test-spec",
project_dir=Path("/project"),
)
assert task.base_content is None
def test_merge_task_field_assignment(self):
"""ParallelMergeTask fields can be reassigned."""
task = ParallelMergeTask(
file_path="src/example.py",
main_content="main",
worktree_content="worktree",
base_content=None,
spec_name="spec-1",
project_dir=Path("/project"),
)
task.file_path = "src/updated.py"
task.main_content = "updated main"
task.worktree_content = "updated worktree"
task.base_content = "updated base"
task.spec_name = "spec-2"
task.project_dir = Path("/updated")
assert task.file_path == "src/updated.py"
assert task.main_content == "updated main"
assert task.worktree_content == "updated worktree"
assert task.base_content == "updated base"
assert task.spec_name == "spec-2"
assert task.project_dir == Path("/updated")
class TestParallelMergeResult:
"""Tests for ParallelMergeResult dataclass."""
def test_create_successful_result(self):
"""ParallelMergeResult can represent a successful merge."""
result = ParallelMergeResult(
file_path="src/example.py",
merged_content="merged content",
success=True,
error=None,
was_auto_merged=True,
)
assert result.file_path == "src/example.py"
assert result.merged_content == "merged content"
assert result.success is True
assert result.error is None
assert result.was_auto_merged is True
def test_create_failed_result(self):
"""ParallelMergeResult can represent a failed merge."""
result = ParallelMergeResult(
file_path="src/example.py",
merged_content=None,
success=False,
error="Merge conflict occurred",
was_auto_merged=False,
)
assert result.file_path == "src/example.py"
assert result.merged_content is None
assert result.success is False
assert result.error == "Merge conflict occurred"
assert result.was_auto_merged is False
def test_result_default_values(self):
"""ParallelMergeResult has correct default values."""
result = ParallelMergeResult(
file_path="src/example.py",
merged_content="content",
success=True,
)
assert result.error is None
assert result.was_auto_merged is False
def test_result_field_assignment(self):
"""ParallelMergeResult fields can be reassigned."""
result = ParallelMergeResult(
file_path="src/example.py",
merged_content="merged",
success=True,
error=None,
was_auto_merged=False,
)
result.file_path = "src/updated.py"
result.merged_content = "updated merged"
result.success = False
result.error = "New error"
result.was_auto_merged = True
assert result.file_path == "src/updated.py"
assert result.merged_content == "updated merged"
assert result.success is False
assert result.error == "New error"
assert result.was_auto_merged is True
class TestMergeLockError:
"""Tests for MergeLockError exception."""
def test_merge_lock_error_creation(self):
"""MergeLockError can be instantiated with a message."""
error = MergeLockError("Could not acquire lock")
assert str(error) == "Could not acquire lock"
def test_merge_lock_error_is_exception(self):
"""MergeLockError is an Exception subclass."""
error = MergeLockError("test")
assert isinstance(error, Exception)
assert isinstance(error, MergeLockError)
def test_raise_merge_lock_error(self):
"""MergeLockError can be raised and caught."""
with pytest.raises(MergeLockError) as exc_info:
raise MergeLockError("Lock timeout")
assert str(exc_info.value) == "Lock timeout"
class TestMergeLock:
"""Tests for MergeLock context manager."""
def test_merge_lock_initialization(self, temp_git_repo: Path):
"""MergeLock initializes with correct paths."""
lock = MergeLock(temp_git_repo, "test-spec")
assert lock.project_dir == temp_git_repo
assert lock.spec_name == "test-spec"
assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
assert lock.lock_file == lock.lock_dir / "merge-test-spec.lock"
assert lock.acquired is False
def test_merge_lock_acquire_and_release(self, temp_git_repo: Path):
"""MergeLock can be acquired and released."""
lock = MergeLock(temp_git_repo, "test-spec")
with lock:
assert lock.acquired is True
assert lock.lock_file.exists()
# After context, lock should be released
assert lock.lock_file.exists() is False
def test_merge_lock_creates_lock_dir(self, temp_git_repo: Path):
"""MergeLock creates lock directory if it doesn't exist."""
lock = MergeLock(temp_git_repo, "test-spec")
# Remove lock dir if it exists
if lock.lock_dir.exists():
lock.lock_dir.rmdir()
with lock:
assert lock.lock_dir.exists()
def test_merge_lock_writes_pid(self, temp_git_repo: Path):
"""MergeLock writes current PID to lock file."""
lock = MergeLock(temp_git_repo, "test-spec")
with lock:
pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
assert pid_content == str(os.getpid())
@pytest.mark.slow
def test_merge_lock_timeout_on_contention(self, temp_git_repo: Path):
"""MergeLock raises MergeLockError when lock is held by another process."""
lock1 = MergeLock(temp_git_repo, "test-spec")
# Acquire first lock
lock1.__enter__()
try:
# Create a second lock for the same spec
lock2 = MergeLock(temp_git_repo, "test-spec")
# This should timeout because lock1 holds the lock
with pytest.raises(MergeLockError) as exc_info:
lock2.__enter__()
assert "Could not acquire merge lock" in str(exc_info.value)
assert "test-spec" in str(exc_info.value)
assert "after 30s" in str(exc_info.value)
finally:
lock1.__exit__(None, None, None)
def test_merge_lock_removes_stale_lock(self, temp_git_repo: Path):
"""MergeLock removes stale lock from dead process."""
lock1 = MergeLock(temp_git_repo, "test-spec")
with lock1:
# Write a fake PID that doesn't exist
fake_pid = 999999
lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
# Create a new lock - it should remove the stale lock
lock2 = MergeLock(temp_git_repo, "test-spec")
with lock2:
assert lock2.acquired is True
def test_merge_lock_handles_invalid_pid(self, temp_git_repo: Path):
"""MergeLock handles invalid PID in lock file."""
lock1 = MergeLock(temp_git_repo, "test-spec")
with lock1:
# Write invalid content to lock file
lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
# Create a new lock - it should remove the invalid lock
lock2 = MergeLock(temp_git_repo, "test-spec")
with lock2:
assert lock2.acquired is True
def test_merge_lock_cleanup_on_exception(self, temp_git_repo: Path):
"""MergeLock releases lock even if exception occurs in context."""
lock = MergeLock(temp_git_repo, "test-spec")
try:
with lock:
assert lock.acquired is True
raise ValueError("Test exception")
except ValueError:
pass
# Lock should be released despite exception
assert lock.lock_file.exists() is False
def test_merge_lock_idempotent_release(self, temp_git_repo: Path):
"""MergeLock __exit__ can be called multiple times safely."""
lock = MergeLock(temp_git_repo, "test-spec")
with lock:
pass
# Call __exit__ again - should not raise
lock.__exit__(None, None, None)
lock.__exit__(None, None, None)
def test_merge_lock_different_specs_dont_conflict(self, temp_git_repo: Path):
"""MergeLock for different specs can be held simultaneously."""
lock1 = MergeLock(temp_git_repo, "spec-1")
lock2 = MergeLock(temp_git_repo, "spec-2")
with lock1:
with lock2:
assert lock1.acquired is True
assert lock2.acquired is True
assert lock1.lock_file != lock2.lock_file
class TestSpecNumberLockError:
"""Tests for SpecNumberLockError exception."""
def test_spec_number_lock_error_creation(self):
"""SpecNumberLockError can be instantiated with a message."""
error = SpecNumberLockError("Could not acquire spec numbering lock")
assert str(error) == "Could not acquire spec numbering lock"
def test_spec_number_lock_error_is_exception(self):
"""SpecNumberLockError is an Exception subclass."""
error = SpecNumberLockError("test")
assert isinstance(error, Exception)
assert isinstance(error, SpecNumberLockError)
def test_raise_spec_number_lock_error(self):
"""SpecNumberLockError can be raised and caught."""
with pytest.raises(SpecNumberLockError) as exc_info:
raise SpecNumberLockError("Lock timeout")
assert str(exc_info.value) == "Lock timeout"
class TestSpecNumberLock:
"""Tests for SpecNumberLock context manager."""
def test_spec_number_lock_initialization(self, temp_git_repo: Path):
"""SpecNumberLock initializes with correct paths."""
lock = SpecNumberLock(temp_git_repo)
assert lock.project_dir == temp_git_repo
assert lock.lock_dir == temp_git_repo / ".auto-claude" / ".locks"
assert lock.lock_file == lock.lock_dir / "spec-numbering.lock"
assert lock.acquired is False
assert lock._global_max is None
def test_spec_number_lock_acquire_and_release(self, temp_git_repo: Path):
"""SpecNumberLock can be acquired and released."""
lock = SpecNumberLock(temp_git_repo)
with lock:
assert lock.acquired is True
assert lock.lock_file.exists()
# After context, lock should be released
assert lock.lock_file.exists() is False
def test_spec_number_lock_creates_lock_dir(self, temp_git_repo: Path):
"""SpecNumberLock creates lock directory if it doesn't exist."""
lock = SpecNumberLock(temp_git_repo)
# Remove lock dir if it exists
if lock.lock_dir.exists():
lock.lock_dir.rmdir()
with lock:
assert lock.lock_dir.exists()
def test_spec_number_lock_writes_pid(self, temp_git_repo: Path):
"""SpecNumberLock writes current PID to lock file."""
lock = SpecNumberLock(temp_git_repo)
with lock:
pid_content = lock.lock_file.read_text(encoding="utf-8").strip()
assert pid_content == str(os.getpid())
def test_get_next_spec_number_no_existing_specs(self, temp_git_repo: Path):
"""get_next_spec_number returns 1 when no specs exist."""
lock = SpecNumberLock(temp_git_repo)
with lock:
next_num = lock.get_next_spec_number()
assert next_num == 1
def test_get_next_spec_number_with_existing_specs(self, temp_git_repo: Path):
"""get_next_spec_number returns max existing spec number + 1."""
# Create spec directories
specs_dir = temp_git_repo / ".auto-claude" / "specs"
specs_dir.mkdir(parents=True)
(specs_dir / "001-first").mkdir()
(specs_dir / "003-third").mkdir()
lock = SpecNumberLock(temp_git_repo)
with lock:
next_num = lock.get_next_spec_number()
assert next_num == 4
def test_get_next_spec_number_caches_result(self, temp_git_repo: Path):
"""get_next_spec_number caches the global max."""
specs_dir = temp_git_repo / ".auto-claude" / "specs"
specs_dir.mkdir(parents=True)
(specs_dir / "005-test").mkdir()
lock = SpecNumberLock(temp_git_repo)
with lock:
next_num1 = lock.get_next_spec_number()
next_num2 = lock.get_next_spec_number()
# Should return the same value (cached)
assert next_num1 == next_num2 == 6
assert lock._global_max == 5
def test_get_next_spec_number_requires_lock(self, temp_git_repo: Path):
"""get_next_spec_number raises SpecNumberLockError if lock not acquired."""
lock = SpecNumberLock(temp_git_repo)
with pytest.raises(SpecNumberLockError) as exc_info:
lock.get_next_spec_number()
assert "Lock must be acquired" in str(exc_info.value)
def test_get_next_spec_number_scans_worktrees(self, temp_git_repo: Path):
"""get_next_spec_number scans all worktree spec directories."""
# Create main project specs
main_specs = temp_git_repo / ".auto-claude" / "specs"
main_specs.mkdir(parents=True)
(main_specs / "002-main").mkdir()
# Create worktree with specs
worktrees_dir = temp_git_repo / ".auto-claude" / "worktrees" / "tasks"
worktrees_dir.mkdir(parents=True)
worktree_spec_dir = worktrees_dir / "test-worktree" / ".auto-claude" / "specs"
worktree_spec_dir.mkdir(parents=True)
(worktree_spec_dir / "005-worktree").mkdir()
lock = SpecNumberLock(temp_git_repo)
with lock:
next_num = lock.get_next_spec_number()
# Should find max of 2 and 5, return 6
assert next_num == 6
def test_scan_specs_dir_nonexistent(self, temp_git_repo: Path):
"""_scan_specs_dir returns 0 for nonexistent directory."""
lock = SpecNumberLock(temp_git_repo)
with lock:
# Use a path inside temp_dir that doesn't exist
nonexistent = temp_git_repo / "this_does_not_exist_specs"
result = lock._scan_specs_dir(nonexistent)
assert result == 0
def test_scan_specs_dir_ignores_invalid_names(self, temp_git_repo: Path):
"""_scan_specs_dir ignores directories with invalid spec names."""
specs_dir = temp_git_repo / ".auto-claude" / "specs"
specs_dir.mkdir(parents=True)
(specs_dir / "001-valid").mkdir()
(specs_dir / "invalid-name").mkdir()
(specs_dir / "abc").mkdir()
(specs_dir / "100-valid").mkdir()
lock = SpecNumberLock(temp_git_repo)
with lock:
result = lock._scan_specs_dir(specs_dir)
# Should only count 001 and 100
assert result == 100
@pytest.mark.slow
def test_spec_number_lock_timeout_on_contention(self, temp_git_repo: Path):
"""SpecNumberLock raises SpecNumberLockError when lock is held."""
lock1 = SpecNumberLock(temp_git_repo)
# Acquire first lock
lock1.__enter__()
try:
# Create a second lock
lock2 = SpecNumberLock(temp_git_repo)
# This should timeout because lock1 holds the lock
with pytest.raises(SpecNumberLockError) as exc_info:
lock2.__enter__()
assert "Could not acquire spec numbering lock" in str(exc_info.value)
assert "after 30s" in str(exc_info.value)
finally:
lock1.__exit__(None, None, None)
def test_spec_number_lock_removes_stale_lock(self, temp_git_repo: Path):
"""SpecNumberLock removes stale lock from dead process."""
lock1 = SpecNumberLock(temp_git_repo)
with lock1:
# Write a fake PID that doesn't exist
fake_pid = 999999
lock1.lock_file.write_text(str(fake_pid), encoding="utf-8")
# Create a new lock - it should remove the stale lock
lock2 = SpecNumberLock(temp_git_repo)
with lock2:
assert lock2.acquired is True
def test_spec_number_lock_handles_invalid_pid(self, temp_git_repo: Path):
"""SpecNumberLock handles invalid PID in lock file."""
lock1 = SpecNumberLock(temp_git_repo)
with lock1:
# Write invalid content to lock file
lock1.lock_file.write_text("invalid-pid", encoding="utf-8")
# Create a new lock - it should remove the invalid lock
lock2 = SpecNumberLock(temp_git_repo)
with lock2:
assert lock2.acquired is True
def test_spec_number_lock_cleanup_on_exception(self, temp_git_repo: Path):
"""SpecNumberLock releases lock even if exception occurs in context."""
lock = SpecNumberLock(temp_git_repo)
try:
with lock:
assert lock.acquired is True
raise ValueError("Test exception")
except ValueError:
pass
# Lock should be released despite exception
assert lock.lock_file.exists() is False
def test_spec_number_lock_idempotent_release(self, temp_git_repo: Path):
"""SpecNumberLock __exit__ can be called multiple times safely."""
lock = SpecNumberLock(temp_git_repo)
with lock:
pass
# Call __exit__ again - should not raise
lock.__exit__(None, None, None)
lock.__exit__(None, None, None)
def test_spec_number_lock_returns_self(self, temp_git_repo: Path):
"""SpecNumberLock __enter__ returns self."""
lock = SpecNumberLock(temp_git_repo)
with lock as entered_lock:
assert entered_lock is lock
def test_merge_success_returns_true(self, temp_git_repo: Path):
"""Successful merge returns True (ACS-163 verification)."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with non-conflicting changes
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "worker-file.txt").write_text(
"worker content", encoding="utf-8"
)
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path,
capture_output=True,
)
# Merge should succeed
result = manager.merge_worktree("worker-spec", delete_after=False)
assert result is True
# Verify the file was merged into base branch
subprocess.run(
["git", "checkout", manager.base_branch],
cwd=temp_git_repo,
capture_output=True,
)
assert (temp_git_repo / "worker-file.txt").exists(), (
"Merged file should exist in base branch"
)
merged_content = (temp_git_repo / "worker-file.txt").read_text(encoding="utf-8")
assert merged_content == "worker content", (
"Merged file should have worktree content"
)
@@ -1,542 +1,31 @@
#!/usr/bin/env python3
"""
Tests for Workspace Selection and Management
=============================================
Tests for Workspace Rebase Operations
======================================
Tests the workspace.py module functionality including:
- Workspace mode selection (isolated vs direct)
- Uncommitted changes detection
- Workspace setup
- Build finalization workflows
Tests the rebase functionality including:
- Rebase detection (_check_git_conflicts)
- Spec branch rebase operations
- Rebase integration tests
- Rebase error handling
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from workspace import (
WorkspaceChoice,
WorkspaceMode,
get_current_branch,
get_existing_build_worktree,
has_uncommitted_changes,
setup_workspace,
)
from worktree import WorktreeManager
# Mark all tests in this file as slow (heavy git operations)
pytestmark = pytest.mark.slow
from worktree import WorktreeError, WorktreeManager
# Test constant - in the new per-spec architecture, each spec has its own worktree
# named after the spec itself. This constant is used for test assertions.
TEST_SPEC_NAME = "test-spec"
class TestWorkspaceMode:
"""Tests for WorkspaceMode enum."""
def test_isolated_mode(self):
"""ISOLATED mode value is correct."""
assert WorkspaceMode.ISOLATED.value == "isolated"
def test_direct_mode(self):
"""DIRECT mode value is correct."""
assert WorkspaceMode.DIRECT.value == "direct"
class TestWorkspaceChoice:
"""Tests for WorkspaceChoice enum."""
def test_merge_choice(self):
"""MERGE choice value is correct."""
assert WorkspaceChoice.MERGE.value == "merge"
def test_review_choice(self):
"""REVIEW choice value is correct."""
assert WorkspaceChoice.REVIEW.value == "review"
def test_test_choice(self):
"""TEST choice value is correct."""
assert WorkspaceChoice.TEST.value == "test"
def test_later_choice(self):
"""LATER choice value is correct."""
assert WorkspaceChoice.LATER.value == "later"
class TestHasUncommittedChanges:
"""Tests for uncommitted changes detection."""
def test_clean_repo_no_changes(self, temp_git_repo: Path):
"""Clean repo returns False."""
result = has_uncommitted_changes(temp_git_repo)
assert result is False
def test_untracked_file_has_changes(self, temp_git_repo: Path):
"""Untracked file counts as changes."""
(temp_git_repo / "new_file.txt").write_text("content")
result = has_uncommitted_changes(temp_git_repo)
assert result is True
def test_modified_file_has_changes(self, temp_git_repo: Path):
"""Modified tracked file counts as changes."""
(temp_git_repo / "README.md").write_text("modified content")
result = has_uncommitted_changes(temp_git_repo)
assert result is True
def test_staged_file_has_changes(self, temp_git_repo: Path):
"""Staged file counts as changes."""
(temp_git_repo / "README.md").write_text("modified")
subprocess.run(["git", "add", "README.md"], cwd=temp_git_repo, capture_output=True)
result = has_uncommitted_changes(temp_git_repo)
assert result is True
class TestGetCurrentBranch:
"""Tests for current branch detection."""
def test_gets_main_branch(self, temp_git_repo: Path):
"""Gets the main/master branch."""
branch = get_current_branch(temp_git_repo)
# Could be main or master depending on git config
assert branch in ["main", "master"]
def test_gets_feature_branch(self, temp_git_repo: Path):
"""Gets feature branch name."""
subprocess.run(
["git", "checkout", "-b", "feature/test-branch"],
cwd=temp_git_repo, capture_output=True
)
branch = get_current_branch(temp_git_repo)
assert branch == "feature/test-branch"
class TestGetExistingBuildWorktree:
"""Tests for existing build worktree detection."""
def test_no_existing_worktree(self, temp_git_repo: Path):
"""Returns None when no worktree exists."""
result = get_existing_build_worktree(temp_git_repo, "test-spec")
assert result is None
def test_existing_worktree(self, temp_git_repo: Path):
"""Returns path when worktree exists."""
# Create the worktree directory structure (per-spec architecture)
worktree_path = temp_git_repo / ".worktrees" / TEST_SPEC_NAME
worktree_path.mkdir(parents=True)
result = get_existing_build_worktree(temp_git_repo, TEST_SPEC_NAME)
assert result == worktree_path
class TestSetupWorkspace:
"""Tests for workspace setup."""
def test_setup_direct_mode(self, temp_git_repo: Path):
"""Direct mode returns project dir and no manager."""
working_dir, manager, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.DIRECT,
)
assert working_dir == temp_git_repo
assert manager is None
def test_setup_isolated_mode(self, temp_git_repo: Path):
"""Isolated mode creates worktree and returns manager."""
working_dir, manager, _ = setup_workspace(
temp_git_repo,
TEST_SPEC_NAME,
WorkspaceMode.ISOLATED,
)
assert working_dir != temp_git_repo
assert manager is not None
assert working_dir.exists()
# Per-spec architecture: worktree is named after the spec
assert working_dir.name == TEST_SPEC_NAME
def test_setup_isolated_creates_worktrees_dir(self, temp_git_repo: Path):
"""Isolated mode creates worktrees directory."""
setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
assert (temp_git_repo / ".auto-claude" / "worktrees" / "tasks").exists()
class TestWorkspaceUtilities:
"""Tests for workspace utility functions."""
def test_per_spec_worktree_naming(self, temp_git_repo: Path):
"""Per-spec architecture uses spec name for worktree directory."""
spec_name = "my-spec-001"
working_dir, manager, _ = setup_workspace(
temp_git_repo,
spec_name,
WorkspaceMode.ISOLATED,
)
# Worktree should be named after the spec
assert working_dir.name == spec_name
# New path: .auto-claude/worktrees/tasks/{spec_name}
assert working_dir.parent.name == "tasks"
class TestWorkspaceIntegration:
"""Integration tests for workspace management."""
def test_isolated_workflow(self, temp_git_repo: Path):
"""Full isolated workflow: setup -> work -> finalize."""
# Setup isolated workspace
working_dir, manager, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# Make changes in workspace
(working_dir / "feature.py").write_text("# New feature\n")
# Verify changes are in workspace
assert (working_dir / "feature.py").exists()
# Verify changes are NOT in main project
assert not (temp_git_repo / "feature.py").exists()
def test_direct_workflow(self, temp_git_repo: Path):
"""Full direct workflow: setup -> work."""
# Setup direct workspace
working_dir, manager, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.DIRECT,
)
# Working dir is the project dir
assert working_dir == temp_git_repo
# Make changes directly
(working_dir / "feature.py").write_text("# New feature\n")
# Changes are in main project
assert (temp_git_repo / "feature.py").exists()
def test_isolated_merge(self, temp_git_repo: Path):
"""Can merge isolated workspace back to main."""
# Setup
working_dir, manager, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# Make changes and commit using git directly
(working_dir / "feature.py").write_text("# New feature\n")
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Add feature"],
cwd=working_dir, capture_output=True
)
# Merge back using merge_worktree
result = manager.merge_worktree("test-spec", delete_after=False)
assert result is True
# Check changes are in main
subprocess.run(
["git", "checkout", manager.base_branch],
cwd=temp_git_repo, capture_output=True
)
assert (temp_git_repo / "feature.py").exists()
class TestWorkspaceCleanup:
"""Tests for workspace cleanup."""
def test_cleanup_after_merge(self, temp_git_repo: Path):
"""Workspace is cleaned up after merge with delete_after=True."""
working_dir, manager, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# Commit changes using git directly
(working_dir / "test.py").write_text("test")
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Test"],
cwd=working_dir, capture_output=True
)
# Merge with cleanup
manager.merge_worktree("test-spec", delete_after=True)
# Workspace should be removed
assert not working_dir.exists()
def test_workspace_preserved_after_merge_no_delete(self, temp_git_repo: Path):
"""Workspace preserved after merge with delete_after=False."""
working_dir, manager, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# Commit changes using git directly
(working_dir / "test.py").write_text("test")
subprocess.run(["git", "add", "."], cwd=working_dir, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Test"],
cwd=working_dir, capture_output=True
)
# Merge without cleanup
manager.merge_worktree("test-spec", delete_after=False)
# Workspace should still exist
assert working_dir.exists()
class TestWorkspaceReuse:
"""Tests for reusing existing workspaces."""
def test_reuse_existing_workspace(self, temp_git_repo: Path):
"""Can reuse existing workspace on second setup."""
# First setup
working_dir1, manager1, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# Add a marker file
(working_dir1 / "marker.txt").write_text("marker")
# Second setup (should reuse)
working_dir2, manager2, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# Should be the same directory
assert working_dir1 == working_dir2
# Marker should still exist
assert (working_dir2 / "marker.txt").exists()
class TestWorkspaceErrors:
"""Tests for workspace error handling."""
def test_setup_non_git_directory(self, temp_dir: Path):
"""Handles non-git directories gracefully."""
with pytest.raises(Exception):
# This should fail because temp_dir is not a git repo
setup_workspace(
temp_dir,
"test-spec",
WorkspaceMode.ISOLATED,
)
class TestPerSpecWorktreeName:
"""Tests for per-spec worktree naming (new architecture)."""
def test_worktree_named_after_spec(self, temp_git_repo: Path):
"""Worktree is named after the spec."""
spec_name = "spec-1"
working_dir, _, _ = setup_workspace(
temp_git_repo,
spec_name,
WorkspaceMode.ISOLATED,
)
# Per-spec architecture: worktree directory matches spec name
assert working_dir.name == spec_name
def test_different_specs_get_different_worktrees(self, temp_git_repo: Path):
"""Different specs create separate worktrees."""
working_dir1, _, _ = setup_workspace(
temp_git_repo,
"spec-1",
WorkspaceMode.ISOLATED,
)
working_dir2, _, _ = setup_workspace(
temp_git_repo,
"spec-2",
WorkspaceMode.ISOLATED,
)
# Each spec has its own worktree
assert working_dir1.name == "spec-1"
assert working_dir2.name == "spec-2"
assert working_dir1 != working_dir2
def test_worktree_path_in_worktrees_dir(self, temp_git_repo: Path):
"""Worktree is created in worktrees directory."""
working_dir, _, _ = setup_workspace(
temp_git_repo,
"test-spec",
WorkspaceMode.ISOLATED,
)
# New path: .auto-claude/worktrees/tasks/{spec_name}
assert "worktrees" in str(working_dir)
assert working_dir.parent.name == "tasks"
class TestConflictInfoDisplay:
"""Tests for conflict info display function (ACS-179)."""
def test_print_conflict_info_with_string_list(self, capsys):
"""print_conflict_info handles string list of file paths (ACS-179)."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": ["file1.txt", "file2.py", "file3.js"]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "3 file" in captured.out
assert "file1.txt" in captured.out
assert "file2.py" in captured.out
assert "file3.js" in captured.out
assert "git add" in captured.out
def test_print_conflict_info_with_dict_list(self, capsys):
"""print_conflict_info handles dict list with file/reason/severity (ACS-179)."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
{"file": "file1.txt", "reason": "Syntax error", "severity": "high"},
{"file": "file2.py", "reason": "Merge conflict", "severity": "medium"},
{"file": "file3.js", "reason": "Unknown error", "severity": "low"},
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "3 file" in captured.out
assert "file1.txt" in captured.out
assert "file2.py" in captured.out
assert "file3.js" in captured.out
assert "Syntax error" in captured.out
assert "Merge conflict" in captured.out
# Verify severity emoji indicators
assert "🔴" in captured.out # High severity
assert "🟡" in captured.out # Medium severity
def test_print_conflict_info_mixed_formats(self, capsys):
"""print_conflict_info handles mixed string and dict conflicts (ACS-179)."""
from core.workspace.display import print_conflict_info
result = {
"conflicts": [
"simple-file.txt",
{"file": "complex-file.py", "reason": "AI merge failed", "severity": "high"},
]
}
print_conflict_info(result)
captured = capsys.readouterr()
assert "2 file" in captured.out
assert "simple-file.txt" in captured.out
assert "complex-file.py" in captured.out
assert "AI merge failed" in captured.out
class TestMergeErrorHandling:
"""Tests for merge error handling (ACS-163)."""
def test_merge_failure_returns_false_immediately(self, temp_git_repo: Path):
"""Failed merge returns False without falling through (ACS-163)."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with changes
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "worker-file.txt").write_text("worker content")
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path, capture_output=True
)
# Create a conflicting change on main
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
(temp_git_repo / "worker-file.txt").write_text("main content")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main commit"],
cwd=temp_git_repo, capture_output=True
)
# Merge should fail (conflict) and return False
# This tests the fix for ACS-163 where failed merge would fall through
result = manager.merge_worktree("worker-spec", delete_after=False)
# Should return False on merge conflict
assert result is False
# Verify side effects: base branch content is unchanged
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
base_content = (temp_git_repo / "worker-file.txt").read_text()
assert base_content == "main content", "Base branch should be unchanged after failed merge"
# Verify worktree still exists (delete_after=False)
assert worker_info.path.exists(), "Worktree should still exist after failed merge"
# Verify worktree content is unchanged
worktree_content = (worker_info.path / "worker-file.txt").read_text()
assert worktree_content == "worker content", "Worktree content should be unchanged"
def test_merge_success_returns_true(self, temp_git_repo: Path):
"""Successful merge returns True (ACS-163 verification)."""
manager = WorktreeManager(temp_git_repo)
manager.setup()
# Create a worktree with non-conflicting changes
worker_info = manager.create_worktree("worker-spec")
(worker_info.path / "worker-file.txt").write_text("worker content")
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Worker commit"],
cwd=worker_info.path, capture_output=True
)
# Merge should succeed
result = manager.merge_worktree("worker-spec", delete_after=False)
assert result is True
# Verify the file was merged into base branch
subprocess.run(["git", "checkout", manager.base_branch], cwd=temp_git_repo, capture_output=True)
assert (temp_git_repo / "worker-file.txt").exists(), "Merged file should exist in base branch"
merged_content = (temp_git_repo / "worker-file.txt").read_text()
assert merged_content == "worker content", "Merged file should have worktree content"
class TestRebaseDetection:
"""Tests for automatic rebase detection (ACS-224)."""
def test_check_git_conflicts_detects_branch_behind(self, temp_git_repo: Path):
"""_check_git_conflicts detects when spec branch is behind base branch (ACS-224)."""
from core.workspace import _check_git_conflicts
@@ -550,7 +39,7 @@ class TestRebaseDetection:
)
# Add a commit to spec branch
(temp_git_repo / "spec-file.txt").write_text("spec content")
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -564,7 +53,7 @@ class TestRebaseDetection:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "main-file.txt").write_text("main content")
(temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main commit after spec"],
@@ -577,7 +66,9 @@ class TestRebaseDetection:
assert result is not None
assert result.get("needs_rebase") is True, "Should detect branch is behind"
assert result.get("commits_behind") == 1, "Should count commits behind correctly"
assert result.get("commits_behind") == 1, (
"Should count commits behind correctly"
)
assert result.get("spec_branch") == spec_branch
def test_check_git_conflicts_no_commits_behind(self, temp_git_repo: Path):
@@ -591,7 +82,7 @@ class TestRebaseDetection:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "spec-file.txt").write_text("spec content")
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -627,7 +118,7 @@ class TestRebaseDetection:
)
# Add a commit to spec branch
(temp_git_repo / "spec-file.txt").write_text("spec content")
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -642,7 +133,9 @@ class TestRebaseDetection:
capture_output=True,
)
for i in range(3):
(temp_git_repo / f"main-file-{i}.txt").write_text(f"main content {i}")
(temp_git_repo / f"main-file-{i}.txt").write_text(
f"main content {i}", encoding="utf-8"
)
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"Main commit {i}"],
@@ -674,7 +167,7 @@ class TestRebaseSpecBranch:
)
# Add a commit to spec branch
(temp_git_repo / "spec-file.txt").write_text("spec content")
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -688,7 +181,7 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "main-file.txt").write_text("main content")
(temp_git_repo / "main-file.txt").write_text("main content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main commit"],
@@ -702,8 +195,6 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
).stdout.strip()
# Rebase the spec branch
@@ -717,8 +208,6 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
).stdout.strip()
# Commits should be different (rebase changed the commit hash)
@@ -730,12 +219,12 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
).stdout
assert "Main commit" in log, "Spec branch should have main commit after rebase"
def test_rebase_spec_branch_with_conflicts_aborts_cleanly(self, temp_git_repo: Path):
def test_rebase_spec_branch_with_conflicts_aborts_cleanly(
self, temp_git_repo: Path
):
"""_rebase_spec_branch handles conflicts by aborting and returning False (ACS-224)."""
from core.workspace import _rebase_spec_branch
@@ -748,7 +237,7 @@ class TestRebaseSpecBranch:
)
# Create a file that will conflict
(temp_git_repo / "conflict.txt").write_text("spec version")
(temp_git_repo / "conflict.txt").write_text("spec version", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec conflict"],
@@ -762,7 +251,7 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "conflict.txt").write_text("main version")
(temp_git_repo / "conflict.txt").write_text("main version", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main conflict"],
@@ -803,8 +292,6 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
assert current_branch.stdout.strip() == "main", "Should still be on main branch"
@@ -820,8 +307,6 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
assert status_result.stdout.strip() == "", "Git status should be clean"
@@ -836,7 +321,7 @@ class TestRebaseSpecBranch:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "spec-file.txt").write_text("spec content")
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -855,7 +340,9 @@ class TestRebaseSpecBranch:
# (branch already up-to-date is a success condition)
result = _rebase_spec_branch(temp_git_repo, "test-spec", "main")
assert result is True, "Rebase should return True when branch is already up-to-date"
assert result is True, (
"Rebase should return True when branch is already up-to-date"
)
class TestRebaseIntegration:
@@ -872,7 +359,9 @@ class TestRebaseIntegration:
worker_info = manager.create_worktree("test-spec")
# Add a file in spec worktree and commit
(worker_info.path / "spec-file.txt").write_text("spec content")
(worker_info.path / "spec-file.txt").write_text(
"spec content", encoding="utf-8"
)
subprocess.run(["git", "add", "."], cwd=worker_info.path, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -887,7 +376,7 @@ class TestRebaseIntegration:
capture_output=True,
)
for i in range(2):
(temp_git_repo / f"main-{i}.txt").write_text(f"main {i}")
(temp_git_repo / f"main-{i}.txt").write_text(f"main {i}", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"Main {i}"],
@@ -919,7 +408,7 @@ class TestRebaseIntegration:
)
# Add a commit to spec
(temp_git_repo / "spec.txt").write_text("spec")
(temp_git_repo / "spec.txt").write_text("spec", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec"],
@@ -933,7 +422,7 @@ class TestRebaseIntegration:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "main.txt").write_text("main")
(temp_git_repo / "main.txt").write_text("main", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Main"],
@@ -979,7 +468,7 @@ class TestRebaseErrorHandling:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "spec-file.txt").write_text("spec content")
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -993,8 +482,6 @@ class TestRebaseErrorHandling:
cwd=temp_git_repo,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
commit_hash = commit_result.stdout.strip()
subprocess.run(
@@ -1022,7 +509,6 @@ class TestRebaseErrorHandling:
def test_check_git_conflicts_handles_corrupted_repo(self, temp_git_repo: Path):
"""_check_git_conflicts handles corrupted repo metadata gracefully (ACS-224)."""
import shutil
from core.workspace import _check_git_conflicts
@@ -1033,7 +519,7 @@ class TestRebaseErrorHandling:
cwd=temp_git_repo,
capture_output=True,
)
(temp_git_repo / "spec-file.txt").write_text("spec content")
(temp_git_repo / "spec-file.txt").write_text("spec content", encoding="utf-8")
subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True)
subprocess.run(
["git", "commit", "-m", "Spec commit"],
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
Tests for Workspace Setup Operations
=====================================
Tests the setup functionality including:
- Spec copy to workspace operations
- Timeline hook installation
- Timeline tracking initialization
"""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
# Test constant - in the new per-spec architecture, each spec has its own worktree
# named after the spec itself. This constant is used for test assertions.
TEST_SPEC_NAME = "test-spec"
class TestCopySpecToWorktree:
"""Tests for copy_spec_to_worktree function."""
def test_copies_spec_files_to_worktree(self, temp_git_repo: Path):
"""Copies spec directory to worktree .auto-claude/specs/ location."""
from core.workspace.setup import copy_spec_to_worktree
# Create source spec directory
source_spec = temp_git_repo / "specs" / "test-spec"
source_spec.mkdir(parents=True)
(source_spec / "spec.md").write_text("# Test Spec", encoding="utf-8")
(source_spec / "requirements.json").write_text("{}", encoding="utf-8")
# Create worktree
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
# Copy spec
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
# Verify path is correct
expected = worktree_path / ".auto-claude" / "specs" / "test-spec"
assert result == expected
# Verify files were copied
assert (expected / "spec.md").exists()
assert (expected / "requirements.json").exists()
assert (expected / "spec.md").read_text(encoding="utf-8") == "# Test Spec"
def test_overwrites_existing_spec_in_worktree(self, temp_git_repo: Path):
"""Overwrites spec files if they already exist in worktree."""
from core.workspace.setup import copy_spec_to_worktree
# Create source spec
source_spec = temp_git_repo / "specs" / "test-spec"
source_spec.mkdir(parents=True)
(source_spec / "spec.md").write_text("# New Spec", encoding="utf-8")
# Create worktree with existing spec
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
existing_spec = worktree_path / ".auto-claude" / "specs" / "test-spec"
existing_spec.mkdir(parents=True)
(existing_spec / "spec.md").write_text("# Old Spec", encoding="utf-8")
# Copy spec
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
# Verify new content was copied
assert (result / "spec.md").read_text(encoding="utf-8") == "# New Spec"
def test_creates_parent_directories(self, temp_git_repo: Path):
"""Creates .auto-claude/specs directory if it doesn't exist."""
from core.workspace.setup import copy_spec_to_worktree
source_spec = temp_git_repo / "specs" / "test-spec"
source_spec.mkdir(parents=True)
(source_spec / "spec.md").write_text("# Test", encoding="utf-8")
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / "test-spec"
)
worktree_path.mkdir(parents=True)
result = copy_spec_to_worktree(source_spec, worktree_path, "test-spec")
# Parent directories should be created
assert result.exists()
assert (result.parent).exists()
class TestEnsureTimelineHookInstalled:
"""Tests for ensure_timeline_hook_installed function."""
def test_skips_if_not_git_repo(self, temp_dir: Path):
"""Skips hook installation if directory is not a git repo."""
from core.workspace.setup import ensure_timeline_hook_installed
# Should not raise exception
ensure_timeline_hook_installed(temp_dir)
def test_skips_if_hook_already_installed(self, temp_git_repo: Path, monkeypatch):
"""Skips if FileTimelineTracker hook is already installed."""
from core.workspace.setup import ensure_timeline_hook_installed
# Create hooks directory
hooks_dir = temp_git_repo / ".git" / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
# Create hook with FileTimelineTracker marker
hook_file = hooks_dir / "post-commit"
hook_file.write_text(
"#!/bin/sh\n# FileTimelineTracker hook\necho 'tracked'", encoding="utf-8"
)
# Mock install_hook to track if it was called
install_called = []
def mock_install_hook(project_dir):
install_called.append(True)
monkeypatch.setattr("merge.install_hook.install_hook", mock_install_hook)
ensure_timeline_hook_installed(temp_git_repo)
# install_hook should not be called
assert len(install_called) == 0
def test_installs_hook_if_missing(self, temp_git_repo: Path):
"""Installs hook if it doesn't exist."""
from core.workspace.setup import ensure_timeline_hook_installed
# Create hooks directory but no hook file
hooks_dir = temp_git_repo / ".git" / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
# This test verifies the function runs without error
# The actual install_hook call is hard to mock because it's imported locally
# In production, the real install_hook would be called
ensure_timeline_hook_installed(temp_git_repo)
# Verify hooks directory exists (function ran)
assert hooks_dir.exists()
class TestInitializeTimelineTracking:
"""Tests for initialize_timeline_tracking function."""
def test_with_implementation_plan(self, temp_git_repo: Path, monkeypatch):
"""Initializes tracking with files from implementation plan."""
from core.workspace.setup import initialize_timeline_tracking
# Create source spec with implementation plan
spec_name = "test-spec"
source_spec = temp_git_repo / ".auto-claude" / "specs" / spec_name
source_spec.mkdir(parents=True)
plan = {
"title": "Test Feature",
"description": "Test description",
"phases": [
{
"subtasks": [
{"files": ["app/main.py", "app/utils.py"]},
{"files": ["tests/test_main.py"]},
]
}
],
}
(source_spec / "implementation_plan.json").write_text(
json.dumps(plan), encoding="utf-8"
)
# Create worktree
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
)
worktree_path.mkdir(parents=True)
# Mock FileTimelineTracker
mock_tracker_calls = []
class MockTracker:
def __init__(self, project_dir):
pass
def on_task_start(
self,
task_id,
files_to_modify,
branch_point_commit,
task_intent,
task_title,
):
mock_tracker_calls.append(
{
"task_id": task_id,
"files": files_to_modify,
"branch": branch_point_commit,
"intent": task_intent,
"title": task_title,
}
)
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
initialize_timeline_tracking(
temp_git_repo, spec_name, worktree_path, source_spec
)
# Verify tracker was called with correct parameters
assert len(mock_tracker_calls) == 1
call = mock_tracker_calls[0]
assert call["task_id"] == spec_name
assert set(call["files"]) == {
"app/main.py",
"app/utils.py",
"tests/test_main.py",
}
assert call["title"] == "Test Feature"
assert call["intent"] == "Test description"
def test_without_implementation_plan(self, temp_git_repo: Path, monkeypatch):
"""Initializes tracking retroactively from worktree if no plan."""
from core.workspace.setup import initialize_timeline_tracking
spec_name = "test-spec"
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
)
worktree_path.mkdir(parents=True)
# Mock FileTimelineTracker
mock_calls = []
class MockTracker:
def __init__(self, project_dir):
pass
def initialize_from_worktree(
self, task_id, worktree_path, task_intent, task_title
):
mock_calls.append(
{
"task_id": task_id,
"worktree": worktree_path,
"intent": task_intent,
"title": task_title,
}
)
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", MockTracker)
initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
# Should use retroactive initialization
assert len(mock_calls) == 1
assert mock_calls[0]["task_id"] == spec_name
def test_handles_exception_gracefully(
self, temp_git_repo: Path, monkeypatch, capsys
):
"""Logs warning but doesn't raise exception on error."""
from core.workspace.setup import initialize_timeline_tracking
spec_name = "test-spec"
worktree_path = (
temp_git_repo / ".auto-claude" / "worktrees" / "tasks" / spec_name
)
worktree_path.mkdir(parents=True)
# Mock FileTimelineTracker to raise exception
class FailingTracker:
def __init__(self, project_dir):
raise Exception("Tracker init failed")
monkeypatch.setattr("core.workspace.setup.FileTimelineTracker", FailingTracker)
# Should not raise
initialize_timeline_tracking(temp_git_repo, spec_name, worktree_path, None)
# Should print warning
captured = capsys.readouterr()
assert "Timeline tracking" in captured.out or "Note:" in captured.out
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -153,7 +153,7 @@ class PushAndCreatePRResult(TypedDict, total=False):
class WorktreeError(Exception):
"""Error during worktree operations."""
pass # no-op
pass
@dataclass
@@ -429,8 +429,8 @@ class WorktreeManager:
if current_path.exists() and resolved_path.exists():
if os.path.samefile(resolved_path, current_path):
return line[len("branch refs/heads/") :]
except OSError: # Files may not exist or be inaccessible; fall through to case comparison
pass # no-op: fall through to case comparison
except OSError:
pass
# Fallback to normalized case comparison
if os.path.normcase(str(resolved_path)) == os.path.normcase(
str(current_path)
@@ -509,8 +509,8 @@ class WorktreeManager:
if resolved_path.exists() and registered_path.exists():
if os.path.samefile(resolved_path, registered_path):
return True
except OSError: # Files may not exist or be inaccessible; fall through to case comparison
pass # no-op: fall through to case comparison
except OSError:
pass
# Fallback to normalized case comparison for non-existent paths
if os.path.normcase(str(resolved_path)) == os.path.normcase(
str(registered_path)
@@ -583,11 +583,9 @@ class WorktreeManager:
stats["days_since_last_commit"] = (
datetime.now() - last_commit_date
).days
except (
ValueError,
TypeError,
): # If parsing fails, silently continue without date info
pass # no-op
except (ValueError, TypeError) as e:
# If parsing fails, silently continue without date info
pass
# Diff stats
result = self._run_git(
@@ -1796,8 +1794,9 @@ class WorktreeManager:
try:
data = json.loads(result.stdout)
return data.get("web_url")
except json.JSONDecodeError: # If JSON parsing fails, return None
pass # no-op
except json.JSONDecodeError:
# If JSON parsing fails, return None
pass
except (
subprocess.TimeoutExpired,
FileNotFoundError,
+1 -1
View File
@@ -1,3 +1,3 @@
"""Backward compatibility shim - import from spec.critique instead."""
from spec.critique import * # noqa: F401, F403
from spec.critique import * # noqa: F403
+1 -1
View File
@@ -1,3 +1,3 @@
"""Backward compatibility shim - import from integrations.graphiti.config instead."""
from integrations.graphiti.config import * # noqa: F401, F403
from integrations.graphiti.config import * # noqa: F403
+1 -1
View File
@@ -1,3 +1,3 @@
"""Backward compatibility shim - import from integrations.graphiti.providers_pkg instead."""
from integrations.graphiti.providers_pkg import * # noqa: F401, F403
from integrations.graphiti.providers_pkg import * # noqa: F403
+13 -20
View File
@@ -53,22 +53,15 @@ class ProjectAnalyzer:
try:
with open(project_index_path, encoding="utf-8") as f:
index = json.load(f)
# Extract tech stack from services (only if index is a dict)
if isinstance(index, dict):
for service_name, service_info in index.get(
"services", {}
).items():
if service_info.get("language"):
context["tech_stack"].append(service_info["language"])
if service_info.get("framework"):
context["tech_stack"].append(service_info["framework"])
context["tech_stack"] = list(set(context["tech_stack"]))
except (
json.JSONDecodeError,
KeyError,
AttributeError,
): # Invalid JSON; skip
pass # no-op: skip invalid files
# Extract tech stack from services
for service_name, service_info in index.get("services", {}).items():
if service_info.get("language"):
context["tech_stack"].append(service_info["language"])
if service_info.get("framework"):
context["tech_stack"].append(service_info["framework"])
context["tech_stack"] = list(set(context["tech_stack"]))
except (json.JSONDecodeError, KeyError):
pass
# Get roadmap context if enabled
if self.include_roadmap:
@@ -85,8 +78,8 @@ class ProjectAnalyzer:
# Get target audience
audience = roadmap.get("target_audience", {})
context["target_audience"] = audience.get("primary")
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
pass # no-op: skip invalid files
except (json.JSONDecodeError, KeyError):
pass
# Also check discovery for audience
discovery_path = (
@@ -104,8 +97,8 @@ class ProjectAnalyzer:
context["existing_features"] = current_state.get(
"existing_features", []
)
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
pass # no-op: skip invalid files
except (json.JSONDecodeError, KeyError):
pass
# Get kanban context if enabled
if self.include_kanban:
+6 -6
View File
@@ -45,8 +45,8 @@ class IdeationFormatter:
print_status(
f"Preserving {len(existing_ideas)} existing ideas", "info"
)
except json.JSONDecodeError: # Invalid JSON; skip
pass # no-op: treat as empty file
except json.JSONDecodeError:
pass
# Collect new ideas from the enabled types
new_ideas = []
@@ -61,8 +61,8 @@ class IdeationFormatter:
ideas = data.get(ideation_type, [])
new_ideas.extend(ideas)
output_files.append(str(type_file))
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
pass # no-op: skip invalid files
except (json.JSONDecodeError, KeyError):
pass
# In append mode, filter out ideas from types we're regenerating
# (to avoid duplicates) and keep ideas from other types
@@ -141,6 +141,6 @@ class IdeationFormatter:
try:
with open(context_file, encoding="utf-8") as f:
context_data = json.load(f)
except json.JSONDecodeError: # Invalid JSON; skip
pass # no-op: treat as empty file
except json.JSONDecodeError:
pass
return context_data
+18 -4
View File
@@ -100,12 +100,19 @@ class IdeationGenerator:
# Create client with thinking budget
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
# which can cause 60-second timeout delays
resolved_model = resolve_model_id(self.model)
betas = get_model_betas(self.model)
thinking_kwargs = get_thinking_kwargs_for_model(
resolved_model, self.thinking_level
)
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
max_thinking_tokens=self.thinking_budget,
resolved_model,
agent_type="ideation",
betas=betas,
fast_mode=self.fast_mode,
**thinking_kwargs,
)
try:
@@ -195,12 +202,19 @@ Write the fixed JSON to the file now.
"""
# Use agent_type="ideation" for recovery agent as well
resolved_model = resolve_model_id(self.model)
betas = get_model_betas(self.model)
thinking_kwargs = get_thinking_kwargs_for_model(
resolved_model, self.thinking_level
)
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
max_thinking_tokens=self.thinking_budget,
resolved_model,
agent_type="ideation",
betas=betas,
fast_mode=self.fast_mode,
**thinking_kwargs,
)
try:
+1 -1
View File
@@ -302,7 +302,7 @@ Generate up to {self.max_ideas_per_type} {self.generator.get_type_label(ideation
Avoid duplicating features that are already planned (see ideation_context.json).
Output your ideas to {output_file.name}.
"""
await self.generator.run_agent(
success, output = await self.generator.run_agent(
prompt_file,
additional_context=context,
)
+1 -1
View File
@@ -207,7 +207,7 @@ class ImplementationPlan:
# (spec is complete, waiting for user to approve before coding starts)
if self.status == "human_review" and self.planStatus == "review":
# Keep the plan approval status - don't reset to backlog
pass # no-op
pass
else:
self.status = "backlog"
self.planStatus = "pending"
+4 -9
View File
@@ -3,14 +3,9 @@ Integrations Module
===================
External service integrations for Auto Claude.
Submodules:
- linear: Linear issue tracking integration
- graphiti: Graphiti knowledge graph integration
"""
# Submodules are accessible via standard Python import mechanism:
# from integrations.linear import ...
# from integrations.graphiti import ...
__all__ = []
__all__ = [
"linear",
"graphiti",
]
+5 -32
View File
@@ -3,23 +3,12 @@ Graphiti Integration
====================
Integration with Graphiti knowledge graph for semantic memory.
Module-level placeholders (with _ prefix) are defined for CodeQL static
analysis. The actual exported names (without _ prefix) trigger __getattr__
for lazy loading.
"""
from typing import Any
# Config imports don't require graphiti package
from .config import GraphitiConfig, validate_graphiti_config
# Module-level placeholders for CodeQL static analysis.
# Use list placeholder to satisfy CodeQL's "defined but not set to None" check.
GraphitiMemory: Any = []
create_llm_client: Any = []
create_embedder: Any = []
# Lazy imports for components that require graphiti package
__all__ = [
"GraphitiConfig",
"validate_graphiti_config",
@@ -29,34 +18,18 @@ __all__ = [
]
def __getattr__(name: str) -> Any:
def __getattr__(name):
"""Lazy import to avoid requiring graphiti package for config-only imports."""
private_map = {
"GraphitiMemory": "_GraphitiMemory",
"create_llm_client": "_create_llm_client",
"create_embedder": "_create_embedder",
}
if name in private_map:
private_name = private_map[name]
globals()[private_name] = _do_lazy_import(name)
return globals()[private_name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _do_lazy_import(name: str) -> Any:
"""Perform the actual lazy import for a given name."""
if name == "GraphitiMemory":
from .memory import GraphitiMemory
return GraphitiMemory
if name == "create_llm_client":
elif name == "create_llm_client":
from .providers import create_llm_client
return create_llm_client
if name == "create_embedder":
elif name == "create_embedder":
from .providers import create_embedder
return create_embedder
raise AssertionError(f"Unknown lazy import name: {name}")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+6 -3
View File
@@ -210,7 +210,7 @@ class GraphitiConfig:
"OPENROUTER_BASE_URL", "https://openrouter.ai/api"
)
openrouter_llm_model = os.environ.get(
"OPENROUTER_LLM_MODEL", "anthropic/claude-3.5-sonnet"
"OPENROUTER_LLM_MODEL", "anthropic/claude-sonnet-4"
)
openrouter_embedding_model = os.environ.get(
"OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small"
@@ -624,7 +624,10 @@ 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
if not config.is_valid():
# 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
status["reason"] = errors[0] if errors else "Configuration invalid"
return status
@@ -635,7 +638,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
status["available"] = True # pragma: no cover
except ImportError as e:
status["available"] = False
status["reason"] = f"Graphiti packages not installed: {e}"
@@ -1,43 +0,0 @@
"""Fixtures for graphiti integration tests."""
from importlib.util import find_spec
from pathlib import Path
import pytest
def _check_graphiti_available() -> bool:
"""Check if graphiti dependencies are available."""
return find_spec("kuzu") is not None or find_spec("real_ladybug") is not None
# Skip all graphiti integration tests if dependencies are not available
pytestmark = pytest.mark.skipif(
not _check_graphiti_available(),
reason="graphiti dependencies (kuzu or real_ladybug) not available",
)
@pytest.fixture
def db_path(tmp_path: Path) -> str:
"""Provide a temporary database path for testing.
Args:
tmp_path: pytest's built-in temporary directory fixture
Returns:
str: Path to temporary database directory
"""
db_dir = tmp_path / "graphiti_db"
db_dir.mkdir(exist_ok=True)
return str(db_dir)
@pytest.fixture
def database() -> str:
"""Provide a test database name.
Returns:
str: Test database name
"""
return "test_db"
+24 -17
View File
@@ -22,7 +22,7 @@ see graphiti/graphiti.py.
from pathlib import Path
# Import config utilities
from integrations.graphiti.config import (
from graphiti_config import (
GraphitiConfig,
is_graphiti_enabled,
)
@@ -72,6 +72,8 @@ 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)
"""
@@ -87,43 +89,48 @@ 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)
embedder = create_embedder(config)
llm_client = create_llm_client(config) # pragma: no cover
embedder = create_embedder(config) # pragma: no cover
except ProviderError as e:
return False, f"Provider error: {e}"
# Try to connect
driver = FalkorDriver(
host=config.falkordb_host,
port=config.falkordb_port,
password=config.falkordb_password or None,
database=config.database,
)
# Apply LadybugDB monkeypatch for embedded database
if not _apply_ladybug_monkeypatch(): # pragma: no cover
return False, "LadybugDB not installed (requires Python 3.12+)"
graphiti = Graphiti(
# 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
graph_driver=driver,
llm_client=llm_client,
embedder=embedder,
)
# Try a simple operation
await graphiti.build_indices_and_constraints()
await graphiti.close()
await graphiti.build_indices_and_constraints() # pragma: no cover
await graphiti.close() # pragma: no cover
return True, (
f"Connected to LadybugDB at {config.falkordb_host}:{config.falkordb_port} "
return True, ( # pragma: no cover
f"Connected to LadybugDB at {db_path} "
f"(providers: {config.get_provider_summary()})"
)
except ImportError as e:
return False, f"Graphiti packages not installed: {e}"
except Exception as e:
except Exception as e: # pragma: no cover
return False, f"Connection failed: {e}"
@@ -9,10 +9,10 @@ Exception classes for provider-related errors.
class ProviderError(Exception):
"""Raised when a provider cannot be initialized."""
pass # no-op
pass
class ProviderNotInstalled(ProviderError):
"""Raised when required packages for a provider are not installed."""
pass # no-op
pass
@@ -35,7 +35,7 @@ def create_openrouter_llm_client(config: "GraphitiConfig") -> Any:
>>> from auto_claude.integrations.graphiti.config import GraphitiConfig
>>> config = GraphitiConfig(
... openrouter_api_key="sk-or-...",
... openrouter_llm_model="anthropic/claude-3.5-sonnet"
... openrouter_llm_model="anthropic/claude-sonnet-4"
... )
>>> client = create_openrouter_llm_client(config)
"""
@@ -80,7 +80,7 @@ async def test_llm_connection(config: "GraphitiConfig") -> tuple[bool, str]:
from .factory import create_llm_client
try:
_llm_client = create_llm_client(config)
llm_client = create_llm_client(config)
# Most clients don't have a ping method, so just verify creation succeeded
return (
True,
@@ -112,7 +112,7 @@ async def test_embedder_connection(config: "GraphitiConfig") -> tuple[bool, str]
return False, msg
try:
_embedder = create_embedder(config)
embedder = create_embedder(config)
return (
True,
f"Embedder created successfully for provider: {config.embedder_provider}",
@@ -139,11 +139,6 @@ async def test_ollama_connection(
"""
import asyncio
# Normalize URL first (used in both paths)
url = base_url.rstrip("/")
if url.endswith("/v1"):
url = url[:-3]
try:
import aiohttp
except ImportError:
@@ -152,12 +147,16 @@ async def test_ollama_connection(
import urllib.request
try:
# Normalize URL (remove /v1 suffix if present)
url = base_url.rstrip("/")
if url.endswith("/v1"):
url = url[:-3]
req = urllib.request.Request(f"{url}/api/tags", method="GET")
with urllib.request.urlopen(req, timeout=5) as response:
status = response.status
if status == 200:
if response.status == 200:
return True, f"Ollama is running at {url}"
return False, f"Ollama returned status {status}"
return False, f"Ollama returned status {response.status}"
except urllib.error.URLError as e:
return False, f"Cannot connect to Ollama at {url}: {e.reason}"
except Exception as e:
@@ -165,20 +164,21 @@ async def test_ollama_connection(
# Use aiohttp if available
try:
# Normalize URL
url = base_url.rstrip("/")
if url.endswith("/v1"):
url = url[:-3]
async with aiohttp.ClientSession() as session:
try:
async with session.get(
f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)
) as response:
status = response.status
if status == 200:
return True, f"Ollama is running at {url}"
return False, f"Ollama returned status {status}"
except asyncio.TimeoutError:
return False, f"Ollama connection timed out at {url}"
except Exception:
# Catch aiohttp.ClientError and any other exceptions from session.get
return False, f"Cannot connect to Ollama at {url}"
except Exception:
# Catch any exceptions from ClientSession creation
return False, f"Cannot connect to Ollama at {url}"
async with session.get(
f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
return True, f"Ollama is running at {url}"
return False, f"Ollama returned status {response.status}"
except asyncio.TimeoutError:
return False, f"Ollama connection timed out at {url}"
except aiohttp.ClientError as e:
return False, f"Cannot connect to Ollama at {url}: {e}"
except Exception as e:
return False, f"Ollama connection error: {e}"
@@ -25,14 +25,6 @@ from .schema import (
GroupIdMode,
)
# Import kuzu_driver_patched for test mocking support
# This module is used internally but needs to be accessible for patching
try:
from . import kuzu_driver_patched # noqa: F401
except ImportError:
# kuzu_driver_patched may not be available if real_ladybug is not installed
kuzu_driver_patched = None # type: ignore
# Re-export for convenience
__all__ = [
"GraphitiMemory",
@@ -362,7 +362,7 @@ class GraphitiMemory:
if result and self.state:
# Episode count updated in queries module
pass # no-op
pass
return result
except Exception as e:
@@ -15,7 +15,10 @@ from typing import Any
# Import kuzu (might be real_ladybug via monkeypatch)
try:
import kuzu
except ImportError:
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.
import real_ladybug as kuzu # type: ignore
logger = logging.getLogger(__name__)
@@ -117,8 +120,9 @@ def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int
logger.debug(
f"Dropped existing FTS index: {index_name}"
)
except Exception: # Index might not exist, that's fine
pass # no-op
except Exception:
# Index might not exist, that's fine
pass
# Create the FTS index
conn.execute(query)
@@ -172,7 +176,4 @@ def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int
# Run the parent schema setup (creates tables)
super().setup_schema()
# Export the class at module level for tests to import
globals()["PatchedKuzuDriver"] = PatchedKuzuDriver
return PatchedKuzuDriver(db=db, max_concurrent_queries=max_concurrent_queries)
@@ -67,7 +67,8 @@ class GraphitiSearch:
Args:
query: Search query
num_results: Maximum number of results to return
include_project_context: If True and in PROJECT mode, search project-wide
include_project_context: If True and in SPEC mode, also search project-wide
min_score: Minimum relevance score threshold (0.0 to 1.0)
Returns:
List of relevant context items with content, score, and type
@@ -101,10 +102,14 @@ 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": getattr(result, "score", 0.0),
"score": score,
"type": getattr(result, "type", "unknown"),
}
)
@@ -112,7 +117,9 @@ 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) >= min_score
item
for item in context_items
if (item.get("score", 0.0)) >= min_score
]
logger.info(
@@ -225,12 +232,14 @@ 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": getattr(result, "score", 0.0),
"score": score,
}
)
except (json.JSONDecodeError, TypeError, AttributeError):
@@ -284,7 +293,8 @@ class GraphitiSearch:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
raw_score = getattr(result, "score", None)
score = raw_score if raw_score is not None else 0.0
if score < min_score:
continue
@@ -320,7 +330,8 @@ class GraphitiSearch:
content = getattr(result, "content", None) or getattr(
result, "fact", None
)
score = getattr(result, "score", 0.0)
raw_score = getattr(result, "score", None)
score = raw_score if raw_score is not None else 0.0
if score < min_score:
continue
@@ -22,35 +22,29 @@ Usage:
# Run the test:
cd auto-claude
python integrations/graphiti/test_graphiti_memory.py
python integrations/graphiti/run_graphiti_memory_test.py
# Or run specific tests:
python integrations/graphiti/test_graphiti_memory.py --test connection
python integrations/graphiti/test_graphiti_memory.py --test save
python integrations/graphiti/test_graphiti_memory.py --test search
python integrations/graphiti/test_graphiti_memory.py --test ollama
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
"""
# Exclude this file from pytest collection - this is a standalone test script
__test__ = False
import argparse
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 = auto_claude_dir / ".env"
env_file = Path(__file__).parent.parent.parent.parent / ".env"
if env_file.exists():
load_dotenv(env_file)
print(f"Loaded .env from {env_file}")
@@ -65,8 +59,8 @@ def apply_ladybug_monkeypatch():
sys.modules["kuzu"] = real_ladybug
return True
except Exception: # Test cleanup, ignore errors
pass # no-op
except ImportError:
pass
# Try native kuzu as fallback
try:
@@ -122,15 +116,8 @@ async def test_ladybugdb_connection(db_path: str, database: str) -> bool:
# Test basic query
result = conn.execute("RETURN 1 + 1 as test")
query_result = result.get_as_df()
# Handle both list and DataFrame return types from get_as_df()
if isinstance(query_result, list):
# real-ladybug returns a list of dicts
test_value = query_result[0].get("test") if query_result else None
else:
# pandas DataFrame
test_value = query_result["test"].iloc[0] if len(query_result) > 0 else None
df = result.get_as_df()
test_value = df["test"].iloc[0] if len(df) > 0 else None
if test_value == 2:
print_result("Connection", "SUCCESS - Database responds correctly", True)
@@ -180,7 +167,9 @@ async def test_save_episode(db_path: str, database: str) -> tuple[str, str]:
"embedder": config.embedder_provider,
}
episode_name = f"test_episode_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
episode_name = (
f"test_episode_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
)
group_id = "ladybug_test_group"
print(f" Episode name: {episode_name}")
@@ -196,7 +185,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 test_graphiti_memory.py",
source_description="Test episode from run_graphiti_memory_test.py",
reference_time=datetime.now(timezone.utc),
group_id=group_id,
)
@@ -251,29 +240,15 @@ async def test_keyword_search(db_path: str, database: str) -> bool:
try:
result = conn.execute(query)
query_result = result.get_as_df()
df = result.get_as_df()
# Handle both list and DataFrame return types from get_as_df()
if isinstance(query_result, list):
# real-ladybug returns a list of dicts
print(f" Found {len(query_result)} results:")
for row in query_result:
name = row.get("name", "unknown")[:50]
content = str(row.get("content", ""))[:60]
print(f" - {name}: {content}...")
print_result(
"Keyword Search", f"Found {len(query_result)} results", True
)
else:
# pandas DataFrame
print(f" Found {len(query_result)} results:")
for _, row in query_result.iterrows():
name = row.get("name", "unknown")[:50]
content = str(row.get("content", ""))[:60]
print(f" - {name}: {content}...")
print_result(
"Keyword Search", f"Found {len(query_result)} results", True
)
print(f" Found {len(df)} results:")
for _, row in df.iterrows():
name = row.get("name", "unknown")[:50]
content = str(row.get("content", ""))[:60]
print(f" - {name}: {content}...")
print_result("Keyword Search", f"Found {len(df)} results", True)
return True
except Exception as e:
@@ -456,11 +431,8 @@ 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("/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)
test_spec_dir = Path(tempfile.mkdtemp(prefix="graphiti_test_spec_"))
test_project_dir = Path(tempfile.mkdtemp(prefix="graphiti_test_project_"))
print(f" Spec dir: {test_spec_dir}")
print(f" Project dir: {test_project_dir}")
@@ -587,15 +559,8 @@ async def test_database_contents(db_path: str, database: str) -> bool:
for table in tables_to_check:
try:
result = conn.execute(f"MATCH (n:{table}) RETURN count(n) as count")
query_result = result.get_as_df()
# Handle both list and DataFrame return types from get_as_df()
if isinstance(query_result, list):
count = query_result[0].get("count", 0) if query_result else 0
else:
count = (
query_result["count"].iloc[0] if len(query_result) > 0 else 0
)
df = result.get_as_df()
count = df["count"].iloc[0] if len(df) > 0 else 0
print(f" {table}: {count} nodes")
except Exception as e:
if "not exist" in str(e).lower() or "cannot" in str(e).lower():
@@ -613,21 +578,13 @@ async def test_database_contents(db_path: str, database: str) -> bool:
ORDER BY e.created_at DESC
LIMIT 5
""")
query_result = result.get_as_df()
df = result.get_as_df()
# Handle both list and DataFrame return types from get_as_df()
if isinstance(query_result, list):
if len(query_result) == 0:
print(" (none)")
else:
for row in query_result:
print(f" - {row.get('name', 'unknown')}")
if len(df) == 0:
print(" (none)")
else:
if len(query_result) == 0:
print(" (none)")
else:
for _, row in query_result.iterrows():
print(f" - {row.get('name', 'unknown')}")
for _, row in df.iterrows():
print(f" - {row.get('name', 'unknown')}")
except Exception as e:
if "Episodic" in str(e):
print(" (table not created yet)")
@@ -743,14 +700,14 @@ async def main():
print()
print(" Quick commands:")
print(" # Run all tests:")
print(" python integrations/graphiti/test_graphiti_memory.py")
print(" python integrations/graphiti/run_graphiti_memory_test.py")
print()
print(" # Test just Ollama embeddings:")
print(" python integrations/graphiti/test_graphiti_memory.py --test ollama")
print(" python integrations/graphiti/run_graphiti_memory_test.py --test ollama")
print()
print(" # Test with production database:")
print(
" python integrations/graphiti/test_graphiti_memory.py --database auto_claude_memory"
" python integrations/graphiti/run_graphiti_memory_test.py --database auto_claude_memory"
)
print()
@@ -36,18 +36,15 @@ NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
Usage:
cd apps/backend
python integrations/graphiti/test_ollama_embedding_memory.py
python integrations/graphiti/run_ollama_embedding_test.py
# Run specific tests:
python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
python integrations/graphiti/test_ollama_embedding_memory.py --test create
python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
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
"""
# Exclude this file from pytest collection - this is a standalone test script
__test__ = False
import argparse
import asyncio
import os
@@ -57,15 +54,15 @@ import tempfile
from datetime import datetime
from pathlib import Path
# Add auto-claude to path
auto_claude_dir = Path(__file__).parent.parent.parent
sys.path.insert(0, str(auto_claude_dir))
# Add backend to path
backend_dir = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(backend_dir))
# Load .env file
try:
from dotenv import load_dotenv
env_file = auto_claude_dir / ".env"
env_file = backend_dir / ".env"
if env_file.exists():
load_dotenv(env_file)
print(f"Loaded .env from {env_file}")
@@ -108,8 +105,8 @@ def apply_ladybug_monkeypatch():
sys.modules["kuzu"] = real_ladybug
return True
except Exception: # Test cleanup, ignore errors
pass # no-op
except ImportError:
pass
# Try native kuzu as fallback
try:
@@ -846,18 +843,18 @@ async def main():
print()
print(" Commands:")
print(" # Run all tests:")
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
print(" python integrations/graphiti/run_ollama_embedding_test.py")
print()
print(" # Run specific test:")
print(
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
" python integrations/graphiti/run_ollama_embedding_test.py --test embeddings"
)
print(
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
" python integrations/graphiti/run_ollama_embedding_test.py --test full-cycle"
)
print()
print(" # Keep database for inspection:")
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
print(" python integrations/graphiti/run_ollama_embedding_test.py --keep-db")
print()
@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""
Quick test to demonstrate provider-specific database naming.
Shows how Auto Claude automatically generates provider-specific database names
to prevent embedding dimension mismatches.
"""
import os
import sys
from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from integrations.graphiti.config import GraphitiConfig
def test_provider_naming():
"""Demonstrate provider-specific database naming."""
print("\n" + "=" * 70)
print(" PROVIDER-SPECIFIC DATABASE NAMING")
print("=" * 70 + "\n")
providers = [
("openai", None, None),
("ollama", "embeddinggemma", 768),
("ollama", "qwen3-embedding:0.6b", 1024),
("voyage", None, None),
("google", None, None),
]
for provider, model, dim in providers:
# Create config
config = GraphitiConfig.from_env()
config.embedder_provider = provider
if provider == "ollama" and model:
config.ollama_embedding_model = model
if dim:
config.ollama_embedding_dim = dim
# Get naming info
dimension = config.get_embedding_dimension()
signature = config.get_provider_signature()
db_name = config.get_provider_specific_database_name("auto_claude_memory")
print(f"Provider: {provider}")
if model:
print(f" Model: {model}")
print(f" Embedding Dimension: {dimension}")
print(f" Provider Signature: {signature}")
print(f" Database Name: {db_name}")
print(f" Full Path: ~/.auto-claude/memories/{db_name}/")
print()
print("=" * 70)
print("\nKey Benefits:")
print(" ✅ No dimension mismatch errors")
print(" ✅ Each provider uses its own database")
print(" ✅ Can switch providers without conflicts")
print(" ✅ Migration utility available for data transfer")
print()
if __name__ == "__main__":
test_provider_naming()
@@ -0,0 +1 @@
"""Tests for Graphiti memory integration."""
@@ -0,0 +1,610 @@
"""
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
@@ -0,0 +1,216 @@
"""
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
@@ -0,0 +1,238 @@
"""
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
@@ -0,0 +1,425 @@
"""
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
@@ -0,0 +1,78 @@
#!/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

Some files were not shown because too many files have changed in this diff Show More