Compare commits

..

22 Commits

Author SHA1 Message Date
Test User ba49fd064e feat(github): add support for excluding CI checks during PR reviews
Enhanced the GitHub integration to allow users to specify CI checks to be excluded from blocking PR approvals. Key changes include:
- Introduced a new environment variable `GITHUB_EXCLUDED_CI_CHECKS` to configure excluded checks.
- Updated the `GHClient` methods to accept an `excluded_checks` parameter for filtering CI checks.
- Added UI components in the settings to manage excluded checks, including a textarea for input.
- Updated relevant documentation and translations for the new feature.

These improvements enable more flexible handling of CI checks, particularly for those that are known to be flaky or stuck, improving the overall user experience during PR reviews.
2026-02-05 14:16:41 +01:00
Test User f5f521da2a fix(github): enhance CI status handling to include pending checks
Updated the GitHubOrchestrator to account for pending CI checks in the verdict determination process. The changes include:
- Added logic to check for pending CI statuses alongside failing and awaiting approvals.
- Updated the blocker detection to include messages for pending checks.
- Enhanced reasoning for verdict updates to reflect pending CI checks.
- Adjusted summary messages to inform users about pending CI statuses.

These improvements ensure that the merging process accurately reflects the current state of CI checks, preventing merges when checks are still running.
2026-02-05 10:39:36 +01:00
Test User 624c15d29b docs 2026-02-05 09:33:11 +01:00
Test User 23f712c094 fix(github): enable text fallback when structured output validation fails
When Claude cannot produce valid structured output matching the Pydantic
schema (error_max_structured_output_retries), the reviewers now proceed
to use text fallback parsing instead of raising an exception or
returning empty results.

Previously, when structured output validation failed:
- parallel_followup_reviewer.py raised RuntimeError, losing all findings
- parallel_orchestrator_reviewer.py returned empty findings list

Now, when structured output validation fails:
- Warning is logged indicating text fallback will be used
- structured_output is cleared to ensure fallback parsing runs
- The markdown fallback parser extracts findings from AI's text response

Also reduces verbose debug logging in frontend modules:
- Removed frequent TRACE/CACHE/buffer update logs
- Kept error logs and state-change notifications
- Debug output is now cleaner and more actionable

Files modified:
- parallel_followup_reviewer.py: Handle error_max_structured_output_retries
- parallel_orchestrator_reviewer.py: Same fix in 2 locations
- usage-monitor.ts: Removed verbose trace logs
- credential-utils.ts: Removed cache hit logs
- terminal-session-store.ts: Removed buffer update logs
- token-refresh.ts: Removed frequent validity check logs
- claude-profile-manager.ts: Removed getActiveProfile log

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 19:46:01 +01:00
Test User 2f4393b7f1 refactor(github): extract shared markdown parser and add normalization tests
Extract duplicated _parse_markdown_output() logic (~280 lines) from
parallel_orchestrator_reviewer.py and parallel_followup_reviewer.py into
a shared markdown_utils.py module. This improves maintainability by
having a single source of truth for fallback markdown parsing.

Add comprehensive unit tests (72 tests) for the normalize_category() and
normalize_verdict() helper functions that map AI-generated values to
valid Pydantic schema enums. Tests cover synonym mapping, case handling,
whitespace normalization, and edge cases.

Fixes for test infrastructure:
- Add mock for services.markdown_utils in test_integration_phase4.py
- Fix import ordering in test_pydantic_models.py for ruff compliance
- Create test package structure under tests/runners/github/services/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:56:12 +01:00
Test User 5046d37d62 fix(github): add structured output fallback and enum normalization
Addresses structured output validation failures (error_max_structured_output_retries)
where AI outputs findings in markdown format after schema validation fails.

Changes:
- Add markdown fallback parser to extract findings when JSON fails
- Add Pydantic field validators to normalize enum values:
  - "duplication" -> "redundancy"
  - "NEEDS REVISION" -> "NEEDS_REVISION"
- Add enum cheat sheets to orchestrator prompts with exact valid values
- Improve error logging to capture validation failure details

Closes #1581

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:44:09 +01:00
Andy a5e3cc9a2a feat: add subscriptionType and rateLimitTier to ClaudeProfile (#1688)
* feat: add subscriptionType and rateLimitTier to ClaudeProfile

Add subscription metadata fields to ClaudeProfile type to enable
displaying "Max" vs "Pro" subscription status in the UI without
hitting the Keychain on every render.

- Add subscriptionType and rateLimitTier fields to ClaudeProfile interface
- Populate fields from Keychain credentials during OAuth authentication
- Add populateSubscriptionMetadata() migration for existing profiles
- Update 4 auth code paths to save subscription metadata

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

* fix: resolve Windows test failure and ruff formatting

- Register orchestrator_module in sys.modules before exec_module to fix
  dataclass decorator failure on Windows
- Apply ruff formatting to parallel_orchestrator_reviewer.py and pydantic_models.py

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

* refactor: extract updateProfileSubscriptionMetadata helper to reduce code duplication

This addresses the PR review finding about duplicated subscription metadata
update patterns across 5 locations. The helper:
- Reads subscriptionType and rateLimitTier from Keychain credentials
- Updates the profile object with these values
- Accepts either a configDir path or pre-fetched credentials (efficiency)
- Supports optional onlyIfMissing mode for migration/initialization code

Updated files:
- credential-utils.ts: Added updateProfileSubscriptionMetadata helper
- claude-integration-handler.ts: 4 instances replaced with helper calls
- claude-code-handlers.ts: 1 instance replaced with helper call
- claude-profile-manager.ts: 1 instance replaced with helper call

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

* ci: retrigger CI

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 14:07:30 +01:00
Andy 4587162e43 auto-claude: subtask-1-1 - Add useTaskStore import and update task state after successful PR creation (#1683)
- Added useTaskStore import from stores/task-store
- Added task state update logic in handleCreatePRs after successful PR creation
- Tasks now transition from human_review to done status when PR is created
- Task metadata is updated with prUrl from successful PR result
- Only updates tasks that had successful PR creation (not skipped, error, or alreadyExists)
- Follows exact pattern from TaskDetailModal.tsx for consistency

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 14:07:13 +01:00
Andy b4e6b2fe43 auto-claude: 182-implement-pagination-and-filtering-for-github-pr-l (#1654)
* auto-claude: subtask-1-1 - Add GITHUB_PR_LIST_MORE IPC channel constant

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

* auto-claude: subtask-1-2 - Add listMorePRs IPC handler for pagination

- Add GITHUB_PR_LIST_MORE handler that accepts cursor parameter
- Update PRListResult interface to include endCursor field
- Update existing GITHUB_PR_LIST handler to also return endCursor
- Both handlers use GraphQL pagination with cursor-based navigation

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

* auto-claude: subtask-1-3 - Update PRListResult type to include endCursor field

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

* auto-claude: subtask-1-4 - Add listMorePRs method to GitHubAPI interface

Add listMorePRs method for cursor-based pagination to the GitHubAPI
interface and createGitHubAPI implementation in preload. Also update
browser-mock.ts to include the new method for type consistency.

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

* auto-claude: subtask-2-1 - Add sortBy option to PRFilterState interface and DEFAULT_FILTERS

- Added PRSortOption type with 'newest' | 'oldest' | 'largest' options
- Added sortBy field to PRFilterState interface
- Set default sortBy to 'newest' in DEFAULT_FILTERS

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

* auto-claude: subtask-2-2 - Add sorting logic to filteredPRs useMemo in usePRF

* auto-claude: subtask-2-4 - Add loadMore function, endCursor state, and isLoadingMore state

- Add isLoadingMore state to track pagination loading
- Add endCursor state to track pagination cursor
- Add loadMore function for cursor-based pagination
- Update UseGitHubPRsResult interface with new properties
- Store endCursor from fetchPRs API response
- Reset endCursor when project changes
- Batch preload review results for newly loaded PRs

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

* auto-claude: subtask-3-1 - Add sort dropdown to PRFilterBar using FilterDropdown

- Add SortDropdown component with single-select behavior for sorting PRs
- Add SORT_OPTIONS constant with newest/oldest/largest options
- Add onSortChange prop to PRFilterBarProps interface
- Import ArrowUpDown, Clock, FileCode icons from lucide-react
- Import PRSortOption type from usePRFiltering hook
- Update GitHubPRs.tsx to pass setSortBy as onSortChange prop
- Add i18n translations for sort labels (en/fr)

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

* auto-claude: subtask-3-2 - Add onLoadMore and isLoadingMore props to PRList component

Add pagination props to PRListProps interface:
- onLoadMore: Optional callback to load more PRs when hasMore is true
- isLoadingMore: Optional boolean to track loading state for pagination

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

* auto-claude: subtask-3-3 - Replace status indicator text with Load More button

- Add Load More button component to PRList when hasMore is true
- Show loading spinner with "Loading..." text when isLoadingMore is true
- Keep "All PRs loaded" text when all PRs are displayed
- Add prReview.loadMore and prReview.loadingMore translation keys
- Import Button and Loader2 components

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

* auto-claude: subtask-3-4 - Wire up new props in GitHubPRs.tsx parent component

- Add loadMore and isLoadingMore to useGitHubPRs destructuring
- Pass loadMore as onLoadMore prop to PRList component
- Pass isLoadingMore to PRList component for loading state
- setSortBy already wired to PRFilterBar's onSortChange

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

* auto-claude: subtask-4-1 - Add English translation keys for sortBy, sortNewest, sortOldest, sortLargest, loadMore, loadingMore

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

* auto-claude: subtask-4-2 - Add French translation keys for sortBy, sortNewest

Adds French translations for pagination and sorting UI elements:
- sort.sortBy: "Trier par"
- sort.sortNewest: "Plus récent"
- sort.sortOldest: "Plus ancien"
- sort.sortLargest: "Plus grand"
- pagination.loadMore: "Charger plus"
- pagination.loadingMore: "Chargement..."

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

* Fix ruff formatting and Windows dataclass import error

- Apply ruff formatting to parallel_orchestrator_reviewer.py (3 long lines)
- Apply ruff formatting to pydantic_models.py (Field on single line)
- Fix Windows test collection error: register module in sys.modules before
  exec_module so dataclass decorator can find it

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

* Fix PR review findings: dedup mapping, race conditions, unused i18n keys

- Extract shared mapGraphQLPRToData helper to eliminate duplicated PR
  mapping logic between listPRs and listMorePRs handlers
- Add staleness checks to loadMore using generation counter and
  projectId ref to prevent race conditions with refresh and project
  switching
- Reset isLoadingMore on project change to prevent stuck loading state
- Remove unused common.sort.* and common.pagination.* translation keys
  from en/fr locale files (code uses prReview.* keys instead)
- Align endCursor type to string | null in preload PRListResult
- Preserve sortBy preference when clearing filters for consistency
  with hasActiveFilters

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

* Fix PR review findings: dedup PR handlers, stable sort order

- Extract fetchPRsFromGraphQL helper to deduplicate listPRs/listMorePRs handlers
- Add secondary sort key (createdAt) to 'largest' sort for stable ordering

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

* Fix PR review findings: deduplication and keyboard navigation

- Add deduplication by PR number when appending paginated PRs to prevent
  duplicates if a PR shifts position between pagination requests
- Add keyboard navigation to SortDropdown (ArrowUp/Down, Enter, Space, Escape)
  matching the pattern used in FilterDropdown for accessibility consistency

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

* Fix follow-up review findings: pagination, sort, keyboard UX

- Preserve pagination state on failure response to allow retry
- Pre-compute timestamps before sorting to avoid Date object creation
- Add scrollIntoView for keyboard-focused items in FilterDropdown
- Focus current selection when SortDropdown opens for better keyboard UX

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
2026-02-04 14:06:49 +01:00
Andy d9cd300fee auto-claude: 181-add-expand-button-for-long-task-descriptions (#1653)
* auto-claude: subtask-1-1 - Add expandable description container with toggle button

- Add isExpanded and hasOverflow state for expand/collapse functionality
- Add useLayoutEffect to detect content overflow (scrollHeight > clientHeight)
- Apply max-h-[200px] with overflow-hidden when collapsed
- Add gradient overlay at bottom when content is truncated
- Add centered ghost button with ChevronDown/ChevronUp icons
- Add i18n translations for showMore/showLess in en and fr

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

* fix: remove unrelated changes from branch (qa-requested)

Reset files that were not related to the expand button feature back to
their develop branch state:
- .gitignore
- apps/backend/agents/ (base.py, coder.py, planner.py, session.py)
- apps/backend/core/ (client.py, simple_client.py)
- apps/frontend/src/renderer/App.tsx
- apps/frontend/src/renderer/components/AuthStatusIndicator.tsx
- apps/frontend/src/renderer/components/KanbanBoard.tsx
- apps/frontend/src/renderer/stores/task-store.ts
- apps/frontend/src/shared/i18n/locales/*/common.json
- tests/test_auth.py
- tests/test_issue_884_plan_schema.py

The expand button feature implementation remains intact.

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

* fix: resolve CI failures in Python tests and lint

- Fix test_integration_phase4.py: Register module in sys.modules before
  exec_module to allow dataclass decorator to find module by name
- Fix ruff format issues in parallel_orchestrator_reviewer.py: Break
  long f-string lines for logger.error and RuntimeError calls
- Fix ruff format issues in pydantic_models.py: Combine Field description
  on single line

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

* fix: resolve PR review findings - reset expand state, fix test mocks, remove dead code

- Reset isExpanded when switching tasks to prevent stale expanded state leaking between tasks
- Fix all remaining get_token_from_keychain mock signatures to accept _config_dir parameter
- Remove disabled old orchestrator code block in parallel_orchestrator_reviewer.py

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

* fix: resolve PR review critical and medium issues

- Restore missing constants in base.py that coder.py imports
  (MAX_CONCURRENCY_RETRIES, INITIAL_RETRY_DELAY_SECONDS, MAX_RETRY_DELAY_SECONDS)
- Fix test_issue_884_plan_schema.py mock return types to match
  run_agent_session 3-tuple signature (str, str, dict)
- Add accessibility attributes to expand/collapse button in TaskMetadata.tsx
  (aria-expanded, aria-controls, aria-hidden on icons, id on content)

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

* fix: restore loadClaudeProfiles() and add trailing newline to .gitignore

- Restore loadClaudeProfiles() call in App.tsx initial load useEffect
  to fix onboarding detection for OAuth-only users
- Add trailing newline to .gitignore per POSIX convention

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
2026-02-04 14:06:40 +01:00
VDT-91 f5a7e26d99 fix(terminal): resolve text alignment issues on expand/minimize (#1650)
* fix(terminal): force PTY resize on mount and add auto-correction

Root cause: Architecture mismatch between PTY lifecycle (persists in main
process) and xterm lifecycle (destroyed/recreated on expand/minimize).
When terminal remounts, PTY keeps old dimensions but new xterm assumes
they match.

Changes:
- Force PTY resize on terminal mount/creation to ensure PTY matches xterm
- Add auto-correction to checkDimensionMismatch() with cooldown to fix
  any detected mismatches automatically
- Add validation and error handling to resizePty() in pty-manager
- Revert console.log to debugLog for production readiness

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

* fix(terminal): add IPC acknowledgment, platform-specific timing, and correction monitoring

- Change resizeTerminal from fire-and-forget to invoke/handle pattern
  so renderer gets confirmation of resize success/failure
- Add platform detection via contextBridge (isWindows, isMacOS, isLinux, isUnix)
- Use shorter grace periods on Unix (100ms) vs Windows (500ms) since
  Unix PTY resize is much faster than Windows ConPTY
- Track auto-correction frequency and log warning if >5 corrections
  occur per minute, indicating potential deeper sync issues
- Update all 4 resizeTerminal call sites to handle Promise and log failures

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

* fix(terminal): prevent stale closures in dimension handling

- Read xterm dimensions from ref instead of React state to avoid stale closures
- Fix onCreated callback using potentially outdated ptyDimensions
- Fix expansion effect using old cols/rows after fit()
- Fix post-PTY creation timeout using stale dimensions
- Change warning threshold from > to >= for consistency

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

* fix(terminal): only update lastPtyDimensionsRef after successful resize

Previously, lastPtyDimensionsRef was updated optimistically before the
async resizeTerminal() call completed. If the resize failed, the ref
would hold incorrect dimensions, causing future resize attempts with
the same target dimensions to be incorrectly skipped.

Now the ref is only updated after resizeTerminal() succeeds, and
reverted to previous dimensions on failure. This ensures dimension
mismatches aren't masked by failed resize operations.

Fixed in 4 locations:
- Auto-correction path
- onResize callback
- onCreated (PTY creation)
- performFit (expansion)

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

* fix: resolve CI failures from outdated develop branch

- Fix Ruff formatting in parallel_orchestrator_reviewer.py and pydantic_models.py
- Fix test_integration_phase4.py to register module in sys.modules before
  exec_module for Python 3.12+ dataclass compatibility

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

* fix: address PR review feedback - race condition and platform detection

- Fix race condition in concurrent resize calls using sequence numbers
  to prevent stale dimension corruption when calls complete out-of-order
- Use consistent platform detection via os-detection.ts module instead
  of window.platform for codebase consistency
- Extract duplicated resizeTerminal promise handling to helper function
  resizePtyWithTracking() reducing code from 4 occurrences to 1
- Capture setTimeout ID for post-creation timeout to enable cleanup
- Add proper cleanup in unmount effect for the timeout ref

Addresses all blocking issues from Auto Claude PR Review:
- NEW-001 [HIGH]: Race condition in concurrent resize calls
- 47ffdb7e4a98 [HIGH]: Inconsistent platform detection
- eabaccf549e4 [MEDIUM]: Duplicated resizeTerminal promise handling
- 7821024a350c [LOW]: Uncleaned timeout in onCreated callback

Co-Authored-By: Claude Opus 4.5 <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-04 12:18:15 +01:00
VDT-91 5f63daa3cc fix(windows): use full path to where.exe for reliable executable lookup (#1659)
* fix(windows): use full path to where.exe for reliable executable lookup

Use full path to where.exe (C:\Windows\System32\where.exe) instead of
relying on it being in PATH. This fixes issues in restricted environments
or when Electron doesn't inherit the full system PATH.

Changes:
- Export getWhereExePath() from windows-paths.ts as single source of truth
- Update getWhichCommand() in paths.ts to use the shared helper
- Fix shell injection vulnerability in release-handlers.ts by using
  execFileSync with array arguments instead of string template
- Standardize SystemRoot env var fallback (check both SystemRoot and
  SYSTEMROOT variants) for consistency across all usages

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

* fix: resolve CI failures from outdated develop branch

- Fix Ruff formatting in parallel_orchestrator_reviewer.py and pydantic_models.py
- Fix test_integration_phase4.py to register module in sys.modules before
  exec_module for Python 3.12+ dataclass compatibility

Co-Authored-By: Claude Opus 4.5 <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-04 12:18:02 +01:00
VDT-91 e6e8da17c8 fix: resolve ideation stuck at 3/6 types bug (#1660)
* fix: resolve ideation stuck at 3/6 types bug

Root causes identified and fixed:

1. Missing agent_type="ideation" in generator.py
   - Was defaulting to "coder" which loads MCP servers
   - MCP servers caused 60-second timeout delays per ideation type
   - Added agent_type="ideation" to both create_client() calls

2. No timeout protection on asyncio.gather() in runner.py
   - One stuck task could block forever
   - Added 5-minute timeout with proper error handling

3. Hardcoded totalTypes=7 in agent-queue.ts
   - There are exactly 6 ideation types, not 7
   - Progress calculation was always wrong (3/7 vs 3/6)

4. Log buffer limited to 100 lines in ideation-store.ts
   - Error messages were being truncated
   - Increased to 500 lines for better debugging

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

* fix: properly cancel asyncio tasks on ideation timeout

Prevents resource leaks by explicitly creating tasks with
asyncio.create_task() and cancelling them when the 5-minute
timeout is reached. This ensures orphaned tasks don't continue
consuming API calls or writing files after timeout.

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

* fix: resolve CI failures and address PR review feedback

- Fix timeout handling in ideation runner to preserve completed results
  instead of discarding all results on timeout (HIGH priority feedback)
- Extract IDEATION_TIMEOUT_SECONDS constant to replace magic number
- Derive totalTypes from --types argument instead of hardcoding 6
- Extract MAX_LOG_ENTRIES constant from magic number 500
- Fix Ruff formatting in parallel_orchestrator_reviewer.py and pydantic_models.py
- Fix test_integration_phase4.py to register module in sys.modules before
  exec_module for Python 3.12+ dataclass compatibility

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:17:36 +01:00
Andy 9317148b6b Clarify Local and Origin Branch Distinction (#1652)
* auto-claude: subtask-1-1 - Add BranchInfo type to shared types for structured branch data

- Add BranchType union type ('local' | 'remote') for branch classification
- Add BranchInfo interface with name, type, displayName, and optional isCurrent
- Add getGitBranchesWithInfo API method to ElectronAPI interface
- Keep existing getGitBranches for backward compatibility during migration
- Add mock implementation for browser testing

* auto-claude: subtask-2-1 - Update getGitBranches() to return structured BranchInfo[]

- Add new getGitBranchesWithInfo() function that returns BranchInfo[] with type indicators (local/remote)
- Keep both local and remote versions when a branch exists in both places (no deduplication)
- Add isCurrent indicator for the currently checked out branch
- Register new IPC handler GIT_GET_BRANCHES_WITH_INFO for the new function
- Keep existing getGitBranches() for backward compatibility (marked deprecated)
- Update preload API to expose the new method to renderer

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

* auto-claude: subtask-2-2 - Add useLocalBranch option to worktree creation

Added useLocalBranch?: boolean option to CreateTerminalWorktreeRequest type.
When true, the worktree creation logic skips auto-switching from local branch
to origin/branch, allowing users to preserve gitignored files (.env, configs)
that may not exist on remote branches.

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

* auto-claude: subtask-3-1 - Add English translations for branch type labels

Added i18n keys for branch type labels and group headers:
- terminal.json: worktree.branchGroups.local/remote, worktree.branchType.local/remote
- tasks.json: wizard.gitOptions.branchGroups.local/remote, wizard.gitOptions.branchType.local/remote

These translations will be used to display visual indicators distinguishing
local branches from remote branches in branch selection dropdowns.

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

* auto-claude: subtask-3-2 - Add French translations for branch type labels and group headers

Added French translations for:
- terminal.json: worktree.branchGroups.local/remote, worktree.branchType.local/remote
- tasks.json: wizard.gitOptions.branchGroups.local/remote, wizard.gitOptions.branchType.local/remote

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

* auto-claude: subtask-4-1 - Extend Combobox component to support option groups

- Add 'group' property to ComboboxOption for grouping options by category
- Add 'icon' property for displaying icons before the label (e.g., GitBranch)
- Add 'badge' property for displaying badges after the label
- Render group headers with visual separation when consecutive options have different groups
- Display icon and badge in trigger button when option is selected
- Maintain keyboard navigation across groups

This enables branch selection dropdowns to group by Local/Remote branches
with visual type indicators.

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

* auto-claude: subtask-4-2 - Update CreateWorktreeDialog to use grouped branch options

- Switch from getGitBranches to getGitBranchesWithInfo for structured branch data
- Group branches by type (Local Branches, Remote Branches) in dropdown
- Add icons (GitBranch for local, Cloud for remote) to branch options
- Add colored badges (green for local, blue for remote) as type indicators
- Pass useLocalBranch flag when creating worktree from a local branch
- This preserves gitignored files (.env, configs) when using local branches

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

* auto-claude: subtask-4-3 - Update TaskCreationWizard to use grouped branch options

- Switch from getGitBranches to getGitBranchesWithInfo for structured BranchInfo[] data
- Group branches by type (Local Branches, Remote Branches) with visual headers
- Add icons (GitBranch for local, Cloud for remote) to each branch option
- Add colored badges (green for local, blue for remote) as type indicators
- Add isSelectedBranchLocal memo to track if selected branch is local
- Pass useLocalBranch: true when creating task from local branch to preserve gitignored files
- Add useLocalBranch field to TaskMetadata type

* auto-claude: subtask-5 - Consolidate branch selection with shared utility

- Create buildBranchOptions() utility in branch-utils.tsx for consistent
  branch display across all branch selectors
- Refactor TaskCreationWizard to use shared utility (~75 lines removed)
- Refactor CreateWorktreeDialog to use shared utility (~75 lines removed)
- Update GitHubIntegration to use Combobox with shared utility instead of
  custom BranchSelector component (~140 lines removed)
- Add translations for GitHub settings branch selector (en/fr)
- Fix: Local default branch now appears in dropdown (was filtered out)
- Fix: "Use project default" option now shows Local/Remote badge

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

* docs: update README to v2.7.6-beta.1 [skip ci]

* fix: address all PR review findings for branch distinction feature

- Remove unused exports (createBranchTypeBadge, getBranchIcon) from branch-utils
- Extract badge styling constants (BADGE_BASE_CLASSES, LOCAL/REMOTE_BADGE_CLASSES)
- Thread useLocalBranch flag from frontend through backend worktree creation
- Consolidate branch group/type i18n keys into common.json namespace
- Rename BranchType → GitBranchType, BranchInfo → GitBranchDetail for consistency
- Fix incorrect GitBranchInfo → GitBranchDetail rename in changelog API type

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

* fix: trailing commas in JSON and unsorted Python imports

- Remove trailing commas in settings.json and tasks.json (en/fr) that
  were left after removing branchGroups/branchType sections
- Fix ruff I001 import sorting in build_commands.py

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

* style: apply ruff format to setup.py and worktree.py

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Test User <test@example.com>
2026-02-04 11:21:35 +01:00
Andy 4730206214 auto-claude: 186-set-default-dark-mode-on-startup (#1656)
* auto-claude: subtask-1-1 - Update DEFAULT_APP_SETTINGS.theme to dark

Change default theme from 'system' to 'dark' so the app starts in dark mode
by default on new installations.

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

* test: update test to expect dark as default theme

Update the settings:get handler test to expect 'dark' as the
default theme instead of 'system' to match the new default.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:20:11 +01:00
Andy ae703be9f3 auto-claude: subtask-1-1 - Add min-h-0 to enable scrolling in Roadmap tabs (#1655)
Fix scrolling in Roadmap Phases tab and other tabs by adding min-h-0
to flex containers. This is a classic flexbox fix where flex items
won't shrink below their content's minimum height without min-h-0.

Changes:
- Roadmap.tsx: Add min-h-0 to content wrapper div
- RoadmapTabs.tsx: Add min-h-0 to all TabsContent elements (kanban,
  phases, features, priorities)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:19:47 +01:00
kaigler 5293fb3996 fix: XState status lifecycle & cross-project contamination fixes (#1647)
* fix: XState status lifecycle & cross-project contamination fixes (#1646)

Fixes 7 interrelated bugs from the XState task state machine migration (PR #1575):

1. Cross-project task contamination: Added projectId to all agent event
   signatures, threaded through the entire pipeline from execution-handlers
   through agent-process to event handlers. findTaskAndProject now scopes
   search by projectId.

2. "Incomplete" badge on plan review tasks: Added PLANNING_COMPLETE to
   TERMINAL_EVENTS set and fixed handleProcessExited to only mark
   unexpected for non-zero exit codes.

3. Backend qa.py racing with XState: Removed direct status writes from
   qa.py - XState is now the sole owner of status transitions.

4. Plan file overwrite by planner agent: Added re-stamp mechanism in file
   watcher to re-persist XState state when backend overwrites plan file.

5. QA tasks in wrong column after project switch: Fixed persistPlanPhaseSync
   phase-to-status mapping (qa_review/qa_fixing -> ai_review).

6. updateTaskStatus not applying reviewReason: Added reviewReason to task
   spread and updated skip condition to check both status and reviewReason.

7. Task stuck in "In Progress" after planning with requireReviewBeforeCoding:
   Added XState settled-state guard in execution-progress handler to prevent
   persistPlanPhaseSync from overwriting XState's status on process exit.

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

* fix: address code review findings — deduplicate constants, remove fallback, debug logging

- HIGH: findTaskAndProject no longer falls back to all-project search when
  projectId is explicitly provided (prevents cross-project contamination)
- MEDIUM: Extract XSTATE_TO_PHASE, XSTATE_SETTLED_STATES, and mapStateToLegacy
  into shared task-state-utils.ts module (eliminates duplicate constants)
- MEDIUM: Use typed TaskStateName const array derived from machine states
- MEDIUM: Add tests for non-existent projectId and warning log assertion
- LOW: Add console.warn when provided projectId not found in projects list
- LOW: Convert verbose console.log statements to console.debug in
  agent-events-handlers.ts and task-state-manager.ts

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

* fix: address self-review findings — clarify error in settled states, add guard tests

- HIGH: Added clarifying comment explaining why `error` is correctly in
  XSTATE_SETTLED_STATES (USER_RESUMED transitions synchronously to `coding`
  before new agent events arrive, so the guard no longer blocks)
- MEDIUM: Added 15 tests for settled state guard logic covering all state
  combinations, XSTATE_TO_PHASE completeness, and guard behavior

Co-Authored-By: Claude Opus 4.5 <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-02 20:34:05 +01:00
Test User 8030c59f25 hotfix: fix test_integration_phase4 dataclass import error
Register parallel_orchestrator_reviewer module in sys.modules before
exec_module() to fix Python dataclass decorator resolution failure.

The @dataclass decorator added in a2c3507d6 requires the module to be
in sys.modules during execution. Also applies ruff formatting fixes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:51:46 +01:00
Test User ab91f7baf8 fix: restore version 2.7.6-beta.2 after accidental revert
The hotfix commit a2c3507d6 accidentally reverted the version back to
2.7.5. This restores the correct 2.7.6-beta.2 version and associated
package.json changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 10:41:52 +01:00
Test User a2c3507d61 hotfix/pr-review-bug 2026-02-02 10:28:14 +01:00
AndyMik90 26134c289c chore: bump version to 2.7.6-beta.2 2026-01-30 22:13:30 +01:00
StillKnotKnown 303b3781a4 fix: bundle xstate in main process for packaged Electron app (#1637)
Add xstate to the externalizeDepsPlugin exclude list in
electron.vite.config.ts to ensure it's bundled into the main
process during build. This fixes ERR_MODULE_NOT_FOUND crashes
in packaged apps (AppImage, deb, dmg, Windows exe) where xstate
is not available in node_modules at runtime.

Resolves issue where the Auto-Claude desktop app crashes on
startup after the XState v5 migration (PR #1575).

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-01-30 21:58:21 +01:00
178 changed files with 5914 additions and 27617 deletions
-1
View File
@@ -174,4 +174,3 @@ OPUS_ANALYSIS_AND_IDEAS.md
/shared_docs
logs/security/
Agents.md
packages/
-6
View File
@@ -64,9 +64,3 @@ tests/
# Auto Claude data directory
.auto-claude/
# Auto Claude generated files
.auto-claude-security.json
.auto-claude-status
.security-key
logs/security/
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.6-beta.1"
__version__ = "2.7.6-beta.2"
__author__ = "Auto Claude Team"
+4 -6
View File
@@ -14,9 +14,7 @@ logger = logging.getLogger(__name__)
AUTO_CONTINUE_DELAY_SECONDS = 3
HUMAN_INTERVENTION_FILE = "PAUSE"
# Retry configuration for 400 tool concurrency errors
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
INITIAL_RETRY_DELAY_SECONDS = (
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
)
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
# Concurrency retry constants
MAX_CONCURRENCY_RETRIES = 5
INITIAL_RETRY_DELAY_SECONDS = 2
MAX_RETRY_DELAY_SECONDS = 32
+4 -8
View File
@@ -56,14 +56,10 @@ def _apply_qa_update(
"ready_for_qa_revalidation": status == "fixes_applied",
}
# Update plan status to match QA result
# This ensures the UI shows the correct column after QA
if status == "approved":
plan["status"] = "human_review"
plan["planStatus"] = "review"
elif status == "rejected":
plan["status"] = "human_review"
plan["planStatus"] = "review"
# NOTE: Do NOT write plan["status"] or plan["planStatus"] here.
# The frontend XState task state machine owns status transitions.
# Writing status here races with XState's persistPlanStatusAndReasonSync()
# and can clobber the reviewReason field, causing tasks to appear "incomplete".
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
+8 -1
View File
@@ -87,7 +87,10 @@ def handle_build_command(
debug_success,
)
from phase_config import get_phase_model
from prompts_pkg.prompts import get_base_branch_from_metadata
from prompts_pkg.prompts import (
get_base_branch_from_metadata,
get_use_local_branch_from_metadata,
)
from qa_loop import run_qa_validation_loop, should_run_qa
from .utils import print_banner, validate_environment
@@ -203,6 +206,9 @@ def handle_build_command(
base_branch = metadata_branch
debug("run.py", f"Using base branch from task metadata: {base_branch}")
# Check if user requested local branch (preserves gitignored files like .env)
use_local_branch = get_use_local_branch_from_metadata(spec_dir)
if workspace_mode == WorkspaceMode.ISOLATED:
# Keep reference to original spec directory for syncing progress back
source_spec_dir = spec_dir
@@ -213,6 +219,7 @@ def handle_build_command(
workspace_mode,
source_spec_dir=spec_dir,
base_branch=base_branch,
use_local_branch=use_local_branch,
)
# Use the localized spec directory (inside worktree) for AI access
if localized_spec_dir:
+5 -1
View File
@@ -325,6 +325,7 @@ def setup_workspace(
mode: WorkspaceMode,
source_spec_dir: Path | None = None,
base_branch: str | None = None,
use_local_branch: bool = False,
) -> tuple[Path, WorktreeManager | None, Path | None]:
"""
Set up the workspace based on user's choice.
@@ -337,6 +338,7 @@ def setup_workspace(
mode: The workspace mode to use
source_spec_dir: Optional source spec directory to copy to worktree
base_branch: Base branch for worktree creation (default: current branch)
use_local_branch: If True, use local branch directly instead of preferring origin/branch
Returns:
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
@@ -357,7 +359,9 @@ def setup_workspace(
# Ensure timeline tracking hook is installed (once per session)
ensure_timeline_hook_installed(project_dir)
manager = WorktreeManager(project_dir, base_branch=base_branch)
manager = WorktreeManager(
project_dir, base_branch=base_branch, use_local_branch=use_local_branch
)
manager.setup()
# Get or create worktree for THIS SPECIFIC SPEC
+21 -10
View File
@@ -186,9 +186,15 @@ class WorktreeManager:
CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
def __init__(self, project_dir: Path, base_branch: str | None = None):
def __init__(
self,
project_dir: Path,
base_branch: str | None = None,
use_local_branch: bool = False,
):
self.project_dir = project_dir
self.base_branch = base_branch or self._detect_base_branch()
self.use_local_branch = use_local_branch
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
self._merge_lock = asyncio.Lock()
@@ -695,18 +701,23 @@ class WorktreeManager:
else:
# Branch doesn't exist - create new branch from remote or local base
# Determine the start point for the worktree
remote_ref = f"origin/{self.base_branch}"
start_point = self.base_branch # Default to local branch
# Check if remote ref exists and use it as the source of truth
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
if check_remote.returncode == 0:
start_point = remote_ref
print(f"Creating worktree from remote: {remote_ref}")
if self.use_local_branch:
# User explicitly requested local branch - skip auto-switch to remote
# This preserves gitignored files (.env, configs) that may not exist on remote
print(f"Creating worktree from local branch: {self.base_branch}")
else:
print(
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
)
# Check if remote ref exists and use it as the source of truth
remote_ref = f"origin/{self.base_branch}"
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
if check_remote.returncode == 0:
start_point = remote_ref
print(f"Creating worktree from remote: {remote_ref}")
else:
print(
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
)
# Create worktree with new branch from the start point
result = self._run_git(
+5
View File
@@ -91,11 +91,14 @@ class IdeationGenerator:
prompt += f"\n{additional_context}\n"
# Create client with thinking budget
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
# which can cause 60-second timeout delays
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
max_thinking_tokens=self.thinking_budget,
agent_type="ideation",
)
try:
@@ -184,11 +187,13 @@ Common fixes:
Write the fixed JSON to the file now.
"""
# Use agent_type="ideation" for recovery agent as well
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
max_thinking_tokens=self.thinking_budget,
agent_type="ideation",
)
try:
+36 -6
View File
@@ -28,6 +28,7 @@ from .types import IdeationPhaseResult
# Configuration
MAX_RETRIES = 3
IDEATION_TIMEOUT_SECONDS = 5 * 60 # 5 minutes max for all ideation types
class IdeationOrchestrator:
@@ -173,16 +174,45 @@ class IdeationOrchestrator:
"progress",
)
# Create tasks for all enabled types
ideation_tasks = [
self.output_streamer.stream_ideation_result(
ideation_type, self.phase_executor, MAX_RETRIES
# Create tasks explicitly so we can cancel them on timeout
ideation_task_objs = [
asyncio.create_task(
self.output_streamer.stream_ideation_result(
ideation_type, self.phase_executor, MAX_RETRIES
)
)
for ideation_type in self.enabled_types
]
# Run all ideation types concurrently
ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True)
# Run all ideation types concurrently with timeout protection
# 5 minute timeout prevents infinite hangs if one type stalls
try:
ideation_results = await asyncio.wait_for(
asyncio.gather(*ideation_task_objs, return_exceptions=True),
timeout=IDEATION_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
print_status(
"Ideation generation timed out after 5 minutes",
"error",
)
# Cancel all pending tasks to prevent resource leaks
for task in ideation_task_objs:
if not task.done():
task.cancel()
# Wait for cancellation to complete and preserve results from completed tasks
# Tasks that finished before timeout will return their results;
# cancelled tasks will return CancelledError
results_after_cancel = await asyncio.gather(
*ideation_task_objs, return_exceptions=True
)
# Convert CancelledError to timeout exception, preserve completed results
ideation_results = [
Exception("Ideation timed out")
if isinstance(res, asyncio.CancelledError)
else res
for res in results_after_cancel
]
# Process results
for i, result in enumerate(ideation_results):
@@ -297,6 +297,41 @@ When multiple agents report on the same area:
- **Track consensus**: Note which findings have cross-agent validation
- **Evidence-based, not confidence-based**: Multiple agents agreeing doesn't skip validation - all findings still verified
## STRUCTURED OUTPUT REQUIREMENTS
**CRITICAL: Use EXACT values below. Schema validation will fail otherwise.**
### Valid category values (use EXACTLY one of these, lowercase):
- `security` - Authentication, authorization, injection, XSS, secrets
- `quality` - Code quality, error handling, maintainability
- `logic` - Logic errors, edge cases, race conditions
- `test` - Test coverage, test quality
- `docs` - Documentation issues
- `regression` - Regression from previous fix
- `incomplete_fix` - Partial fix that needs more work
### Valid severity values (use EXACTLY one of these, lowercase):
- `critical` - Security vulnerabilities, data loss risk
- `high` - Significant bugs, breaking changes
- `medium` - Quality issues, should fix
- `low` - Suggestions, minor improvements
### Valid verdict values (use EXACTLY one of these, UPPERCASE with underscores):
- `READY_TO_MERGE` - All issues resolved, can merge
- `MERGE_WITH_CHANGES` - Only LOW severity issues
- `NEEDS_REVISION` - HIGH or MEDIUM issues (NOT "NEEDS REVISION")
- `BLOCKED` - CRITICAL issues or CI failing
### Common mistakes to AVOID:
| Wrong | Correct |
|-------|---------|
| `"duplication"` | Use `"quality"` or `"redundancy"` if in scope |
| `"NEEDS REVISION"` | `"NEEDS_REVISION"` |
| `"needs revision"` | `"NEEDS_REVISION"` |
| `"Ready to Merge"` | `"READY_TO_MERGE"` |
| `"HIGH"` | `"high"` (lowercase for severity) |
| `"CRITICAL"` | `"critical"` (lowercase for severity) |
## Output Format
Provide your synthesis as a structured response matching the ParallelFollowupResponse schema:
@@ -626,6 +626,44 @@ The `agent_agreement` field in structured output tracks:
**Note:** Agent agreement data is logged for monitoring. The cross-validation results
are reflected in each finding's source_agents, cross_validated, and confidence fields.
## STRUCTURED OUTPUT REQUIREMENTS
**CRITICAL: Use EXACT values below. Schema validation will fail otherwise.**
### Valid category values (use EXACTLY one of these, lowercase):
- `security` - Authentication, authorization, injection, XSS, secrets
- `quality` - Code quality, error handling, maintainability
- `logic` - Logic errors, edge cases, race conditions
- `codebase_fit` - Architectural consistency, pattern adherence
- `test` - Test coverage, test quality
- `docs` - Documentation issues
- `redundancy` - Code duplication (NOT "duplication"!)
- `pattern` - Anti-patterns, design issues
- `performance` - Performance problems
### Valid severity values (use EXACTLY one of these, lowercase):
- `critical` - Security vulnerabilities, data loss risk
- `high` - Significant bugs, breaking changes
- `medium` - Quality issues, should fix
- `low` - Suggestions, minor improvements
### Valid verdict values (use EXACTLY one of these, UPPERCASE with underscores):
- `APPROVE` - No issues found
- `COMMENT` - Only LOW severity issues
- `NEEDS_REVISION` - HIGH or MEDIUM issues (NOT "NEEDS REVISION")
- `BLOCKED` - CRITICAL issues
### Common mistakes to AVOID:
| Wrong | Correct |
|-------|---------|
| `"duplication"` | `"redundancy"` |
| `"NEEDS REVISION"` | `"NEEDS_REVISION"` |
| `"needs revision"` | `"NEEDS_REVISION"` |
| `"Ready to Merge"` | `"APPROVE"` |
| `"READY_TO_MERGE"` | `"APPROVE"` (this schema uses APPROVE) |
| `"HIGH"` | `"high"` (lowercase for severity) |
| `"CRITICAL"` | `"critical"` (lowercase for severity) |
## Output Format
After synthesis and validation, output your final review in this JSON format:
+25
View File
@@ -81,6 +81,31 @@ def get_base_branch_from_metadata(spec_dir: Path) -> str | None:
return None
def get_use_local_branch_from_metadata(spec_dir: Path) -> bool:
"""
Read useLocalBranch from task_metadata.json if it exists.
When True, the worktree should be created from the local branch directly
instead of preferring origin/branch. This preserves gitignored files
(.env, configs) that may not exist on the remote.
Args:
spec_dir: Directory containing the spec files
Returns:
True if useLocalBranch is set in metadata, False otherwise
"""
metadata_path = spec_dir / "task_metadata.json"
if metadata_path.exists():
try:
with open(metadata_path, encoding="utf-8") as f:
metadata = json.load(f)
return bool(metadata.get("useLocalBranch", False))
except (json.JSONDecodeError, OSError):
pass
return False
# Alias for backwards compatibility (internal use)
_get_base_branch_from_metadata = get_base_branch_from_metadata
+11 -4
View File
@@ -21,10 +21,17 @@ from typing import Any
logger = logging.getLogger(__name__)
from phase_config import resolve_model_id
from runners.github.batch_validator import BatchValidator
from runners.github.duplicates import SIMILAR_THRESHOLD
from runners.github.file_lock import locked_json_write
# Import validators
try:
from ..phase_config import resolve_model_id
from .batch_validator import BatchValidator
from .duplicates import SIMILAR_THRESHOLD
from .file_lock import locked_json_write
except (ImportError, ValueError, SystemError):
from batch_validator import BatchValidator
from duplicates import SIMILAR_THRESHOLD
from file_lock import locked_json_write
from phase_config import resolve_model_id
class ClaudeBatchAnalyzer:
@@ -18,11 +18,7 @@ from typing import Any
logger = logging.getLogger(__name__)
# Check for Claude SDK availability without importing (avoids unused import warning)
try:
CLAUDE_SDK_AVAILABLE = importlib.util.find_spec("claude_agent_sdk") is not None
except (ValueError, AttributeError):
# Handle edge case where module's __spec__ is not set (can happen in test environments)
CLAUDE_SDK_AVAILABLE = False
CLAUDE_SDK_AVAILABLE = importlib.util.find_spec("claude_agent_sdk") is not None
# Default model and thinking configuration
# Note: Default uses shorthand "sonnet" which gets resolved via resolve_model_id()
+4 -1
View File
@@ -49,7 +49,10 @@ from core.gh_executable import get_gh_executable
logger = logging.getLogger(__name__)
from runners.github.file_lock import FileLock, atomic_write
try:
from .file_lock import FileLock, atomic_write
except (ImportError, ValueError, SystemError):
from file_lock import FileLock, atomic_write
@dataclass
+2 -2
View File
@@ -46,8 +46,8 @@ from typing import Any
# Import learning tracker if available
try:
from runners.github.learning import LearningPattern, LearningTracker
except ImportError:
from .learning import LearningPattern, LearningTracker
except (ImportError, ValueError, SystemError):
LearningTracker = None
LearningPattern = None
+19 -10
View File
@@ -24,8 +24,14 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from runners.github.gh_client import GHClient, PRTooLargeError
from runners.github.services.io_utils import safe_print
try:
from .gh_client import GHClient, PRTooLargeError
from .services.io_utils import safe_print
except (ImportError, ValueError, SystemError):
# Import from core.io_utils directly to avoid circular import with services package
# (services/__init__.py imports pr_review_engine which imports context_gatherer)
from core.io_utils import safe_print
from gh_client import GHClient, PRTooLargeError
# Validation patterns for git refs and paths (defense-in-depth)
# These patterns allow common valid characters while rejecting potentially dangerous ones
@@ -81,7 +87,10 @@ def _validate_file_path(path: str) -> bool:
if TYPE_CHECKING:
from runners.github.models import FollowupReviewContext, PRReviewResult
try:
from .models import FollowupReviewContext, PRReviewResult
except (ImportError, ValueError, SystemError):
from models import FollowupReviewContext, PRReviewResult
@dataclass
@@ -973,16 +982,13 @@ class PRContextGatherer:
# when CWD is different from project root (e.g., running from apps/backend/)
resolved = (self.project_dir / base_dir / import_path).resolve()
# Resolve project_dir to handle symlinks consistently (e.g., /var -> /private/var on macOS)
project_dir_resolved = self.project_dir.resolve()
# Try common extensions if no extension provided
if not resolved.suffix:
for ext in [".ts", ".tsx", ".js", ".jsx"]:
candidate = resolved.with_suffix(ext)
if candidate.exists() and candidate.is_file():
try:
rel_path = candidate.relative_to(project_dir_resolved)
rel_path = candidate.relative_to(self.project_dir)
return str(rel_path)
except ValueError:
# File is outside project directory
@@ -993,7 +999,7 @@ class PRContextGatherer:
index_file = resolved / f"index{ext}"
if index_file.exists() and index_file.is_file():
try:
rel_path = index_file.relative_to(project_dir_resolved)
rel_path = index_file.relative_to(self.project_dir)
return str(rel_path)
except ValueError:
return None
@@ -1001,7 +1007,7 @@ class PRContextGatherer:
# File with extension
if resolved.exists() and resolved.is_file():
try:
rel_path = resolved.relative_to(project_dir_resolved)
rel_path = resolved.relative_to(self.project_dir)
return str(rel_path)
except ValueError:
return None
@@ -1340,7 +1346,10 @@ class FollowupContextGatherer:
FollowupReviewContext with changes since last review
"""
# Import here to avoid circular imports
from runners.github.models import FollowupReviewContext
try:
from .models import FollowupReviewContext
except (ImportError, ValueError, SystemError):
from models import FollowupReviewContext
previous_sha = self.previous_review.reviewed_commit_sha
+28 -5
View File
@@ -21,7 +21,11 @@ from pathlib import Path
from typing import Any
from core.gh_executable import get_gh_executable
from runners.github.rate_limiter import RateLimiter, RateLimitExceeded
try:
from .rate_limiter import RateLimiter, RateLimitExceeded
except (ImportError, ValueError, SystemError):
from rate_limiter import RateLimiter, RateLimitExceeded
# Configure logger
logger = logging.getLogger(__name__)
@@ -814,7 +818,9 @@ class GHClient:
return commits[-1].get("oid")
return None
async def get_pr_checks(self, pr_number: int) -> dict[str, Any]:
async def get_pr_checks(
self, pr_number: int, excluded_checks: list[str] | None = None
) -> dict[str, Any]:
"""
Get CI check runs status for a PR.
@@ -822,6 +828,7 @@ class GHClient:
Args:
pr_number: PR number
excluded_checks: List of check names to exclude from counts (for stuck/broken checks)
Returns:
Dict with:
@@ -830,7 +837,9 @@ class GHClient:
- failing: Number of failing checks
- pending: Number of pending checks
- failed_checks: List of failed check names
- excluded_checks: List of checks that were excluded from counting
"""
excluded_set = set(excluded_checks or [])
try:
# Note: gh pr checks --json only supports: bucket, completedAt, description,
# event, link, name, startedAt, state, workflow
@@ -845,11 +854,20 @@ class GHClient:
failing = 0
pending = 0
failed_checks = []
excluded_from_count = []
for check in checks:
state = check.get("state", "").upper()
name = check.get("name", "Unknown")
# Skip excluded checks (for stuck/broken CI checks)
if name in excluded_set:
excluded_from_count.append(name)
logger.info(
f"Excluding CI check '{name}' from counts (user-configured)"
)
continue
# gh pr checks 'state' directly contains: SUCCESS, FAILURE, PENDING, NEUTRAL, etc.
if state in ("SUCCESS", "NEUTRAL", "SKIPPED"):
passing += 1
@@ -866,6 +884,7 @@ class GHClient:
"failing": failing,
"pending": pending,
"failed_checks": failed_checks,
"excluded_checks": excluded_from_count,
}
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
logger.warning(f"Failed to get PR checks for #{pr_number}: {e}")
@@ -875,6 +894,7 @@ class GHClient:
"failing": 0,
"pending": 0,
"failed_checks": [],
"excluded_checks": [],
"error": str(e),
}
@@ -969,7 +989,9 @@ class GHClient:
logger.warning(f"Failed to approve workflow run {run_id}: {e}")
return False
async def get_pr_checks_comprehensive(self, pr_number: int) -> dict[str, Any]:
async def get_pr_checks_comprehensive(
self, pr_number: int, excluded_checks: list[str] | None = None
) -> dict[str, Any]:
"""
Get comprehensive CI status including workflows awaiting approval.
@@ -979,12 +1001,13 @@ class GHClient:
Args:
pr_number: PR number
excluded_checks: List of check names to exclude from counts (for stuck/broken checks)
Returns:
Dict with all check information including awaiting_approval count
"""
# Get standard checks
checks = await self.get_pr_checks(pr_number)
# Get standard checks (with exclusions applied)
checks = await self.get_pr_checks(pr_number, excluded_checks=excluded_checks)
# Get workflows awaiting approval
awaiting = await self.get_workflows_awaiting_approval(pr_number)
+4 -1
View File
@@ -16,7 +16,10 @@ from datetime import datetime
from enum import Enum
from pathlib import Path
from runners.github.file_lock import locked_json_update, locked_json_write
try:
from .file_lock import locked_json_update, locked_json_write
except (ImportError, ValueError, SystemError):
from file_lock import locked_json_update, locked_json_write
class ReviewSeverity(str, Enum):
+10 -1
View File
@@ -39,7 +39,16 @@ from enum import Enum
from pathlib import Path
from typing import Any
from runners.github.providers.protocol import LabelData
# Import providers
try:
from .providers.protocol import LabelData
except (ImportError, ValueError, SystemError):
@dataclass
class LabelData:
name: str
color: str
description: str = ""
class OnboardingPhase(str, Enum):
+150 -40
View File
@@ -14,37 +14,69 @@ REFACTORED: Service layer architecture - orchestrator delegates to specialized s
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from runners.github.bot_detection import BotDetector
from runners.github.context_gatherer import PRContext, PRContextGatherer
from runners.github.gh_client import GHClient
from runners.github.models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
AICommentTriage,
AICommentVerdict,
AutoFixState,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
StructuralIssue,
TriageResult,
)
from runners.github.permissions import GitHubPermissionChecker
from runners.github.rate_limiter import RateLimiter
from runners.github.services import (
AutoFixProcessor,
BatchProcessor,
PRReviewEngine,
TriageEngine,
)
from runners.github.services.io_utils import safe_print
try:
# When imported as part of package
from .bot_detection import BotDetector
from .context_gatherer import PRContext, PRContextGatherer
from .gh_client import GHClient
from .models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
AICommentTriage,
AICommentVerdict,
AutoFixState,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
StructuralIssue,
TriageResult,
)
from .permissions import GitHubPermissionChecker
from .rate_limiter import RateLimiter
from .services import (
AutoFixProcessor,
BatchProcessor,
PRReviewEngine,
TriageEngine,
)
from .services.io_utils import safe_print
except (ImportError, ValueError, SystemError):
# When imported directly (runner.py adds github dir to path)
from bot_detection import BotDetector
from context_gatherer import PRContext, PRContextGatherer
from gh_client import GHClient
from models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
AICommentTriage,
AICommentVerdict,
AutoFixState,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
StructuralIssue,
TriageResult,
)
from permissions import GitHubPermissionChecker
from rate_limiter import RateLimiter
from services import (
AutoFixProcessor,
BatchProcessor,
PRReviewEngine,
TriageEngine,
)
from services.io_utils import safe_print
@dataclass
@@ -125,6 +157,17 @@ class GitHubOrchestrator:
# Initialize rate limiter singleton
self.rate_limiter = RateLimiter.get_instance()
# Load excluded CI checks from environment (for stuck/broken checks)
excluded_ci_env = os.environ.get("GITHUB_EXCLUDED_CI_CHECKS", "")
self.excluded_ci_checks: list[str] = [
check.strip() for check in excluded_ci_env.split(",") if check.strip()
]
if self.excluded_ci_checks:
safe_print(
f"[CI] Excluding checks from review: {', '.join(self.excluded_ci_checks)}",
flush=True,
)
# Initialize service layer
self.pr_review_engine = PRReviewEngine(
project_dir=self.project_dir,
@@ -400,7 +443,10 @@ class GitHubOrchestrator:
)
# Check CI status (comprehensive - includes workflows awaiting approval)
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
# Excluded checks are filtered out based on user configuration
ci_status = await self.gh_client.get_pr_checks_comprehensive(
pr_number, excluded_checks=self.excluded_ci_checks
)
# Log CI status with awaiting approval info
awaiting = ci_status.get("awaiting_approval", 0)
@@ -625,8 +671,12 @@ class GitHubOrchestrator:
try:
# Import here to avoid circular imports at module level
from runners.github.context_gatherer import FollowupContextGatherer
from runners.github.services.followup_reviewer import FollowupReviewer
try:
from .context_gatherer import FollowupContextGatherer
from .services.followup_reviewer import FollowupReviewer
except (ImportError, ValueError, SystemError):
from context_gatherer import FollowupContextGatherer
from services.followup_reviewer import FollowupReviewer
# Gather follow-up context
gatherer = FollowupContextGatherer(
@@ -669,7 +719,10 @@ class GitHubOrchestrator:
# ALWAYS fetch current CI status to detect CI recovery
# This must happen BEFORE the early return check to avoid stale CI verdicts
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
# Excluded checks are filtered out based on user configuration
ci_status = await self.gh_client.get_pr_checks_comprehensive(
pr_number, excluded_checks=self.excluded_ci_checks
)
followup_context.ci_status = ci_status
if not has_commits and not has_file_changes:
@@ -679,11 +732,14 @@ class GitHubOrchestrator:
# If CI was failing before but now passes, we need to update the verdict
current_failing = ci_status.get("failing", 0)
current_awaiting = ci_status.get("awaiting_approval", 0)
current_pending = ci_status.get("pending", 0)
# Helper to detect CI-related blockers (includes workflows pending)
# Helper to detect CI-related blockers (includes workflows pending and CI pending)
def is_ci_blocker(b: str) -> bool:
return b.startswith("CI Failed:") or b.startswith(
"Workflows Pending:"
return (
b.startswith("CI Failed:")
or b.startswith("Workflows Pending:")
or b.startswith("CI Pending:")
)
previous_blockers = getattr(previous_review, "blockers", [])
@@ -693,11 +749,13 @@ class GitHubOrchestrator:
)
# Determine the appropriate verdict based on current CI status
# CI/Workflow status check (both block merging)
ci_or_workflow_blocking = current_failing > 0 or current_awaiting > 0
# CI/Workflow/Pending status check (all block merging)
ci_or_workflow_blocking = (
current_failing > 0 or current_awaiting > 0 or current_pending > 0
)
if ci_or_workflow_blocking:
# CI is still failing or workflows pending - keep blocked verdict
# CI is still failing, pending, or workflows pending - keep blocked verdict
updated_verdict = MergeVerdict.BLOCKED
if current_failing > 0:
updated_reasoning = (
@@ -714,6 +772,15 @@ class GitHubOrchestrator:
f"No new commits since last review. "
f"CI status: {current_failing} check(s) failing.{ci_note}"
)
elif current_pending > 0:
updated_reasoning = (
f"No code changes since last review. "
f"{current_pending} CI check(s) still pending."
)
no_change_summary = (
f"No new commits since last review. "
f"CI status: {current_pending} check(s) still running."
)
else:
updated_reasoning = (
f"No code changes since last review. "
@@ -802,6 +869,14 @@ class GitHubOrchestrator:
if blocker_msg not in blockers:
blockers.append(blocker_msg)
# Add pending CI checks to blockers
if current_pending > 0:
blocker_msg = (
f"CI Pending: {current_pending} check(s) still running"
)
if blocker_msg not in blockers:
blockers.append(blocker_msg)
# Map verdict to overall_status (consistent with rest of codebase)
if updated_verdict == MergeVerdict.BLOCKED:
overall_status = "request_changes"
@@ -855,9 +930,14 @@ class GitHubOrchestrator:
"[AI] Using parallel orchestrator for follow-up review (SDK subagents)...",
flush=True,
)
from runners.github.services.parallel_followup_reviewer import (
ParallelFollowupReviewer,
)
try:
from .services.parallel_followup_reviewer import (
ParallelFollowupReviewer,
)
except (ImportError, ValueError, SystemError):
from services.parallel_followup_reviewer import (
ParallelFollowupReviewer,
)
reviewer = ParallelFollowupReviewer(
project_dir=self.project_dir,
@@ -919,6 +999,36 @@ class GitHubOrchestrator:
if ci_warning not in result.summary:
result.summary += ci_warning
# Also check for pending CI checks
pending_checks = followup_context.ci_status.get("pending", 0)
if pending_checks > 0:
safe_print(
f"[Followup] CI checks still pending: {pending_checks}",
flush=True,
)
# Override verdict if CI is pending
if result.verdict in (
MergeVerdict.READY_TO_MERGE,
MergeVerdict.MERGE_WITH_CHANGES,
):
result.verdict = MergeVerdict.BLOCKED
result.verdict_reasoning = (
f"Blocked: {pending_checks} CI check(s) still running. "
"Wait for CI to complete before merge."
)
result.overall_status = "request_changes"
# Add pending CI to blockers
blocker_msg = f"CI Pending: {pending_checks} check(s) still running"
if blocker_msg not in result.blockers:
result.blockers.append(blocker_msg)
# Update summary to reflect CI status
ci_pending_warning = (
f"\n\n**⏳ CI Status:** {pending_checks} check(s) still running. "
"Wait for CI to complete."
)
if ci_pending_warning not in result.summary:
result.summary += ci_pending_warning
# Save result
await result.save(self.github_dir)
@@ -12,7 +12,11 @@ import re
from pathlib import Path
from typing import Any
from runners.github.models import PRReviewFinding, ReviewSeverity
try:
from .models import PRReviewFinding, ReviewSeverity
except (ImportError, ValueError, SystemError):
# For direct module loading in tests
from models import PRReviewFinding, ReviewSeverity
class FindingValidator:
+6 -2
View File
@@ -19,8 +19,12 @@ from enum import Enum
from pathlib import Path
from typing import Any
from runners.github.audit import ActorType, AuditLogger
from runners.github.file_lock import locked_json_update
try:
from .audit import ActorType, AuditLogger
from .file_lock import locked_json_update
except (ImportError, ValueError, SystemError):
from audit import ActorType, AuditLogger
from file_lock import locked_json_update
class OverrideType(str, Enum):
@@ -13,7 +13,11 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from runners.github.gh_client import GHClient
# Import from parent package or direct import
try:
from ..gh_client import GHClient
except (ImportError, ValueError, SystemError):
from gh_client import GHClient
from .protocol import (
IssueData,
@@ -10,8 +10,12 @@ from __future__ import annotations
import json
from pathlib import Path
from runners.github.models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from runners.github.permissions import GitHubPermissionChecker
try:
from ..models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from ..permissions import GitHubPermissionChecker
except (ImportError, ValueError, SystemError):
from models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from permissions import GitHubPermissionChecker
class AutoFixProcessor:
@@ -10,8 +10,12 @@ from __future__ import annotations
import json
from pathlib import Path
from runners.github.models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from runners.github.services.io_utils import safe_print
try:
from ..models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from .io_utils import safe_print
except (ImportError, ValueError, SystemError):
from models import AutoFixState, AutoFixStatus, GitHubRunnerConfig
from services.io_utils import safe_print
class BatchProcessor:
@@ -65,7 +69,10 @@ class BatchProcessor:
Returns:
List of IssueBatch objects that were created
"""
from runners.github.batch_issues import BatchStatus, IssueBatcher
try:
from ..batch_issues import BatchStatus, IssueBatcher
except (ImportError, ValueError, SystemError):
from batch_issues import BatchStatus, IssueBatcher
self._report_progress("batching", 10, "Analyzing issues for batching...")
@@ -180,7 +187,10 @@ class BatchProcessor:
Returns:
Dict with proposed batches and statistics for user review
"""
from runners.github.batch_issues import IssueBatcher
try:
from ..batch_issues import IssueBatcher
except (ImportError, ValueError, SystemError):
from batch_issues import IssueBatcher
self._report_progress("analyzing", 10, "Fetching issues for analysis...")
@@ -394,12 +404,20 @@ class BatchProcessor:
Returns:
List of created IssueBatch objects
"""
from runners.github.batch_issues import (
BatchStatus,
IssueBatch,
IssueBatcher,
IssueBatchItem,
)
try:
from ..batch_issues import (
BatchStatus,
IssueBatch,
IssueBatcher,
IssueBatchItem,
)
except (ImportError, ValueError, SystemError):
from batch_issues import (
BatchStatus,
IssueBatch,
IssueBatcher,
IssueBatchItem,
)
if not approved_batches:
return []
@@ -475,7 +493,10 @@ class BatchProcessor:
async def get_batch_status(self) -> dict:
"""Get status of all batches."""
from runners.github.batch_issues import IssueBatcher
try:
from ..batch_issues import IssueBatcher
except (ImportError, ValueError, SystemError):
from batch_issues import IssueBatcher
batcher = IssueBatcher(
github_dir=self.github_dir,
@@ -505,7 +526,10 @@ class BatchProcessor:
async def process_pending_batches(self) -> int:
"""Process all pending batches."""
from runners.github.batch_issues import BatchStatus, IssueBatcher
try:
from ..batch_issues import BatchStatus, IssueBatcher
except (ImportError, ValueError, SystemError):
from batch_issues import BatchStatus, IssueBatcher
batcher = IssueBatcher(
github_dir=self.github_dir,
@@ -10,7 +10,11 @@ This module provides a centralized category mapping system used across all PR re
from __future__ import annotations
from runners.github.models import ReviewCategory
try:
from ..models import ReviewCategory
except (ImportError, ValueError, SystemError):
from models import ReviewCategory
# Map AI-generated category names to valid ReviewCategory enum values
CATEGORY_MAPPING: dict[str, ReviewCategory] = {
@@ -23,20 +23,34 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from runners.github.models import FollowupReviewContext, GitHubRunnerConfig
from ..models import FollowupReviewContext, GitHubRunnerConfig
from runners.github.gh_client import GHClient
from runners.github.models import (
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
)
from runners.github.services.category_utils import map_category
from runners.github.services.io_utils import safe_print
from runners.github.services.prompt_manager import PromptManager
from runners.github.services.pydantic_models import FollowupReviewResponse
try:
from ..gh_client import GHClient
from ..models import (
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
)
from .category_utils import map_category
from .io_utils import safe_print
from .prompt_manager import PromptManager
from .pydantic_models import FollowupReviewResponse
except (ImportError, ValueError, SystemError):
from gh_client import GHClient
from models import (
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewCategory,
ReviewSeverity,
)
from services.category_utils import map_category
from services.io_utils import safe_print
from services.prompt_manager import PromptManager
from services.pydantic_models import FollowupReviewResponse
logger = logging.getLogger(__name__)
@@ -0,0 +1,163 @@
"""
Markdown Parsing Utilities for PR Reviews
==========================================
Shared utilities for parsing markdown output from AI reviewers.
"""
from __future__ import annotations
import hashlib
import logging
import re
try:
from ..models import PRReviewFinding, ReviewCategory, ReviewSeverity
from .category_utils import map_category
except (ImportError, ValueError, SystemError):
from models import PRReviewFinding, ReviewCategory, ReviewSeverity
from services.category_utils import map_category
logger = logging.getLogger(__name__)
def parse_markdown_findings(
output: str, context_name: str = "Review"
) -> list[PRReviewFinding]:
"""Parse findings from markdown output when JSON extraction fails.
This handles cases where the AI outputs findings in markdown format
after structured output validation fails. Extracts findings from patterns like:
- **CRITICAL - Code Duplication**
- ### Critical Issues (Must Fix)
- 1. **HIGH - Race condition in auth.ts:45**
Args:
output: Markdown text output to parse
context_name: Name of the context (e.g., "ParallelOrchestrator", "ParallelFollowup")
Returns:
List of PRReviewFinding instances extracted from markdown
"""
findings: list[PRReviewFinding] = []
# Normalize line endings
output = output.replace("\r\n", "\n")
# Pattern: **SEVERITY - Title** or **SEVERITY: Title** or numbered lists
numbered_pattern = r"(?:\d+\.\s*)?\*\*(\w+)\s*[-:]\s*([^*]+)\*\*"
# Track positions to avoid duplicates
found_titles: set[str] = set()
# Find all severity-title matches
for match in re.finditer(numbered_pattern, output, re.IGNORECASE):
severity_str = match.group(1).strip().upper()
title = match.group(2).strip()
# Skip if already found this title (avoid duplicates)
title_key = title.lower()[:50]
if title_key in found_titles:
continue
found_titles.add(title_key)
# Map severity string to enum
severity_map = {
"CRITICAL": ReviewSeverity.CRITICAL,
"HIGH": ReviewSeverity.HIGH,
"MEDIUM": ReviewSeverity.MEDIUM,
"MED": ReviewSeverity.MEDIUM,
"LOW": ReviewSeverity.LOW,
"SUGGESTION": ReviewSeverity.LOW,
}
severity = severity_map.get(severity_str, ReviewSeverity.MEDIUM)
# Try to extract file and line from title or nearby text
file_line_match = re.search(
r"[`(]?([a-zA-Z0-9_./\-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|c|cpp|h|hpp|swift|kt|scala|php|vue|svelte)):(\d+)[`)]?",
title + output[match.end() : match.end() + 200],
)
file_path = "unknown"
line_num = 0
if file_line_match:
file_path = file_line_match.group(1)
line_num = int(file_line_match.group(2))
# Extract description - text following the title until next heading or finding
desc_start = match.end()
desc_end_patterns = [
r"\n\s*\*\*\w+\s*[-:]", # Next finding
r"\n\s*#{1,3}\s", # Next heading
r"\n\s*\d+\.\s*\*\*", # Next numbered item
r"\n\s*---", # Horizontal rule
]
desc_end = len(output)
for pattern in desc_end_patterns:
end_match = re.search(pattern, output[desc_start:])
if end_match:
desc_end = min(desc_end, desc_start + end_match.start())
description = output[desc_start:desc_end].strip()
description = re.sub(r"^\s*[-*]\s*", "", description, flags=re.MULTILINE)
description = description[:500] # Limit length
# Infer category from title/description
category_keywords = {
"security": [
"security",
"injection",
"xss",
"auth",
"credential",
"secret",
],
"redundancy": [
"duplication",
"redundant",
"duplicate",
"similar",
"copy",
],
"performance": ["performance", "slow", "optimize", "memory", "leak"],
"logic": ["logic", "race", "condition", "edge case", "bug", "error"],
"quality": ["quality", "maintainability", "readability", "complexity"],
"test": ["test", "coverage", "assertion"],
"docs": ["doc", "comment", "readme"],
"regression": ["regression", "broke", "revert"],
"incomplete_fix": ["incomplete", "partial", "still"],
}
category = ReviewCategory.QUALITY # Default
title_desc_lower = (title + " " + description).lower()
for cat, keywords in category_keywords.items():
if any(kw in title_desc_lower for kw in keywords):
category = map_category(cat)
break
# Generate finding ID
finding_id = hashlib.md5(
f"{file_path}:{line_num}:{title[:50]}".encode(),
usedforsecurity=False,
).hexdigest()[:12]
finding = PRReviewFinding(
id=finding_id,
file=file_path,
line=line_num,
title=title[:80],
description=description if description else title,
category=category,
severity=severity,
suggested_fix="",
evidence=None,
)
findings.append(finding)
if findings:
logger.info(
f"[{context_name}] Extracted {len(findings)} findings from markdown output"
)
return findings
@@ -25,28 +25,53 @@ from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from runners.github.models import FollowupReviewContext
from ..models import FollowupReviewContext
from claude_agent_sdk import AgentDefinition
from core.client import create_client
from phase_config import get_thinking_budget, resolve_model_id
from runners.github.context_gatherer import _validate_git_ref
from runners.github.gh_client import GHClient
from runners.github.models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from runners.github.services.agent_utils import create_working_dir_injector
from runners.github.services.category_utils import map_category
from runners.github.services.io_utils import safe_print
from runners.github.services.pr_worktree_manager import PRWorktreeManager
from runners.github.services.pydantic_models import ParallelFollowupResponse
from runners.github.services.sdk_utils import process_sdk_stream
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget, resolve_model_id
from ..context_gatherer import _validate_git_ref
from ..gh_client import GHClient
from ..models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from .agent_utils import create_working_dir_injector
from .category_utils import map_category
from .io_utils import safe_print
from .markdown_utils import parse_markdown_findings
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import ParallelFollowupResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import _validate_git_ref
from core.client import create_client
from gh_client import GHClient
from models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from phase_config import get_thinking_budget, resolve_model_id
from services.agent_utils import create_working_dir_injector
from services.category_utils import map_category
from services.io_utils import safe_print
from services.markdown_utils import parse_markdown_findings
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import ParallelFollowupResponse
from services.sdk_utils import process_sdk_stream
logger = logging.getLogger(__name__)
@@ -542,13 +567,27 @@ The SDK will run invoked agents in parallel automatically.
)
# Check for stream processing errors
if stream_result.get("error"):
logger.error(
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
)
raise RuntimeError(
f"SDK stream processing failed: {stream_result['error']}"
)
stream_error = stream_result.get("error")
if stream_error:
# Don't raise for structured_output_validation_failed - use text fallback instead
if stream_error == "structured_output_validation_failed":
logger.warning(
"[ParallelFollowup] Structured output validation failed after retries. "
"Will use text fallback to extract findings."
)
safe_print(
"[ParallelFollowup] Structured output failed - using text fallback",
flush=True,
)
# Clear structured_output to ensure fallback is used
stream_result["structured_output"] = None
else:
logger.error(
f"[ParallelFollowup] SDK stream failed: {stream_error}"
)
raise RuntimeError(
f"SDK stream processing failed: {stream_error}"
)
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
@@ -1024,12 +1063,35 @@ The SDK will run invoked agents in parallel automatically.
return self._create_empty_result()
def _parse_markdown_output(self, output: str) -> list[PRReviewFinding]:
"""Parse findings from markdown output when JSON extraction fails.
Delegates to shared markdown parsing utility.
Args:
output: Markdown text output to parse
Returns:
List of PRReviewFinding instances extracted from markdown
"""
return parse_markdown_findings(output, context_name="ParallelFollowup")
def _parse_text_output(self, text: str, context: FollowupReviewContext) -> dict:
"""Parse text output when structured output fails."""
"""Parse text output when structured output fails.
Attempts markdown parsing to extract findings when the AI outputs
findings in markdown format after structured output validation fails.
"""
logger.warning("[ParallelFollowup] Falling back to text parsing")
# Simple heuristic parsing
findings = []
# Try markdown parsing first to extract findings
findings = self._parse_markdown_output(text)
new_finding_ids = [f.id for f in findings]
if findings:
logger.info(
f"[ParallelFollowup] Extracted {len(findings)} findings via markdown fallback"
)
# Look for verdict keywords
text_lower = text.lower()
@@ -1040,13 +1102,18 @@ The SDK will run invoked agents in parallel automatically.
elif "needs revision" in text_lower or "request changes" in text_lower:
verdict = MergeVerdict.NEEDS_REVISION
else:
verdict = MergeVerdict.MERGE_WITH_CHANGES
# If we found findings, default to NEEDS_REVISION
verdict = (
MergeVerdict.NEEDS_REVISION
if findings
else MergeVerdict.MERGE_WITH_CHANGES
)
return {
"findings": findings,
"resolved_ids": [],
"unresolved_ids": [],
"new_finding_ids": [],
"new_finding_ids": new_finding_ids,
"verdict": verdict,
"verdict_reasoning": text[:500] if text else "Unable to parse response",
}
@@ -17,37 +17,118 @@ Key Design:
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from claude_agent_sdk import AgentDefinition
from core.client import create_client
from phase_config import get_thinking_budget, resolve_model_id
from runners.github.context_gatherer import PRContext, _validate_git_ref
from runners.github.gh_client import GHClient
from runners.github.models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from runners.github.services.agent_utils import create_working_dir_injector
from runners.github.services.category_utils import map_category
from runners.github.services.io_utils import safe_print
from runners.github.services.pr_worktree_manager import PRWorktreeManager
from runners.github.services.pydantic_models import (
AgentAgreement,
FindingValidationResponse,
ParallelOrchestratorResponse,
)
from runners.github.services.sdk_utils import process_sdk_stream
# Note: AgentDefinition import kept for backwards compatibility but no longer used
# The Task tool's custom subagent_type feature is broken in Claude Code CLI
# See: https://github.com/anthropics/claude-code/issues/8697
from claude_agent_sdk import AgentDefinition # noqa: F401
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget, resolve_model_id
from ..context_gatherer import PRContext, _validate_git_ref
from ..gh_client import GHClient
from ..models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from .agent_utils import create_working_dir_injector
from .category_utils import map_category
from .io_utils import safe_print
from .markdown_utils import parse_markdown_findings
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import (
AgentAgreement,
FindingValidationResponse,
ParallelOrchestratorResponse,
SpecialistResponse,
)
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext, _validate_git_ref
from core.client import create_client
from gh_client import GHClient
from models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
PRReviewResult,
ReviewSeverity,
)
from phase_config import get_thinking_budget, resolve_model_id
from services.agent_utils import create_working_dir_injector
from services.category_utils import map_category
from services.io_utils import safe_print
from services.markdown_utils import parse_markdown_findings
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import (
AgentAgreement,
FindingValidationResponse,
ParallelOrchestratorResponse,
SpecialistResponse,
)
from services.sdk_utils import process_sdk_stream
# =============================================================================
# Specialist Configuration for Parallel SDK Sessions
# =============================================================================
@dataclass
class SpecialistConfig:
"""Configuration for a specialist agent in parallel SDK sessions."""
name: str
prompt_file: str
tools: list[str]
description: str
# Define specialist configurations
# Each specialist runs as its own SDK session with its own system prompt and tools
SPECIALIST_CONFIGS: list[SpecialistConfig] = [
SpecialistConfig(
name="security",
prompt_file="pr_security_agent.md",
tools=["Read", "Grep", "Glob"],
description="Security vulnerabilities, OWASP Top 10, auth issues, injection, XSS",
),
SpecialistConfig(
name="quality",
prompt_file="pr_quality_agent.md",
tools=["Read", "Grep", "Glob"],
description="Code quality, complexity, duplication, error handling, patterns",
),
SpecialistConfig(
name="logic",
prompt_file="pr_logic_agent.md",
tools=["Read", "Grep", "Glob"],
description="Logic correctness, edge cases, algorithms, race conditions",
),
SpecialistConfig(
name="codebase-fit",
prompt_file="pr_codebase_fit_agent.md",
tools=["Read", "Grep", "Glob"],
description="Naming conventions, ecosystem fit, architectural alignment",
),
]
logger = logging.getLogger(__name__)
@@ -308,6 +389,317 @@ class ParallelOrchestratorReviewer:
),
}
# =========================================================================
# Parallel SDK Sessions Implementation
# =========================================================================
# This replaces the broken Task tool subagent approach.
# Each specialist runs as its own SDK session in parallel via asyncio.gather()
# See: https://github.com/anthropics/claude-code/issues/8697
def _build_specialist_prompt(
self,
config: SpecialistConfig,
context: PRContext,
project_root: Path,
) -> str:
"""Build the full prompt for a specialist agent.
Args:
config: Specialist configuration
context: PR context with files and patches
project_root: Working directory for the agent
Returns:
Full system prompt with context injected
"""
# Load base prompt from file
base_prompt = self._load_prompt(config.prompt_file)
if not base_prompt:
base_prompt = f"You are a {config.name} specialist for PR review."
# Inject working directory using the existing helper
with_working_dir = create_working_dir_injector(project_root)
prompt_with_cwd = with_working_dir(
base_prompt,
f"You are a {config.name} specialist. Find {config.description}.",
)
# Build file list
files_list = []
for file in context.changed_files:
files_list.append(
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
)
# Build diff content (limited to avoid context overflow)
patches = []
MAX_DIFF_CHARS = 150_000 # Smaller limit per specialist
for file in context.changed_files:
if file.patch:
patches.append(f"\n### File: {file.path}\n{file.patch}")
diff_content = "\n".join(patches)
if len(diff_content) > MAX_DIFF_CHARS:
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
# Compose full prompt with PR context
pr_context = f"""
## PR Context
**PR #{context.pr_number}**: {context.title}
**Description:**
{context.description or "(No description provided)"}
### Changed Files ({len(context.changed_files)} files, +{context.total_additions}/-{context.total_deletions})
{chr(10).join(files_list)}
### Diff
{diff_content}
## Your Task
Analyze this PR for {config.description}.
Use the Read, Grep, and Glob tools to explore the codebase as needed.
Report findings with specific file paths, line numbers, and code evidence.
"""
return prompt_with_cwd + pr_context
async def _run_specialist_session(
self,
config: SpecialistConfig,
context: PRContext,
project_root: Path,
model: str,
thinking_budget: int | None,
) -> tuple[str, list[PRReviewFinding]]:
"""Run a single specialist as its own SDK session.
Args:
config: Specialist configuration
context: PR context
project_root: Working directory
model: Model to use
thinking_budget: Max thinking tokens
Returns:
Tuple of (specialist_name, findings)
"""
safe_print(
f"[Specialist:{config.name}] Starting analysis...",
flush=True,
)
# Build the specialist prompt with PR context
prompt = self._build_specialist_prompt(config, context, project_root)
try:
# Create SDK client for this specialist
# Note: Agent type uses the generic "pr_reviewer" since individual
# specialist types aren't registered in AGENT_CONFIGS. The specialist-specific
# system prompt handles differentiation.
client = create_client(
project_dir=project_root,
spec_dir=self.github_dir,
model=model,
agent_type="pr_reviewer",
max_thinking_tokens=thinking_budget,
output_format={
"type": "json_schema",
"schema": SpecialistResponse.model_json_schema(),
},
)
async with client:
await client.query(prompt)
# Process SDK stream
stream_result = await process_sdk_stream(
client=client,
context_name=f"Specialist:{config.name}",
model=model,
system_prompt=prompt,
agent_definitions={}, # No subagents for specialists
)
error = stream_result.get("error")
if error:
# Don't bail out for structured_output_validation_failed - use text fallback
if error == "structured_output_validation_failed":
logger.warning(
f"[Specialist:{config.name}] Structured output validation failed. "
"Using text fallback."
)
safe_print(
f"[Specialist:{config.name}] Structured output failed - using text fallback",
flush=True,
)
# Clear structured_output to ensure fallback is used
stream_result["structured_output"] = None
else:
logger.error(
f"[Specialist:{config.name}] SDK stream failed: {error}"
)
safe_print(
f"[Specialist:{config.name}] Analysis failed: {error}",
flush=True,
)
return (config.name, [])
# Parse structured output
structured_output = stream_result.get("structured_output")
findings = self._parse_specialist_output(
config.name, structured_output, stream_result.get("result_text", "")
)
safe_print(
f"[Specialist:{config.name}] Complete: {len(findings)} findings",
flush=True,
)
return (config.name, findings)
except Exception as e:
logger.error(
f"[Specialist:{config.name}] Session failed: {e}",
exc_info=True,
)
safe_print(
f"[Specialist:{config.name}] Error: {e}",
flush=True,
)
return (config.name, [])
def _parse_specialist_output(
self,
specialist_name: str,
structured_output: dict[str, Any] | None,
result_text: str,
) -> list[PRReviewFinding]:
"""Parse findings from specialist output.
Args:
specialist_name: Name of the specialist
structured_output: Structured JSON output if available
result_text: Raw text output as fallback
Returns:
List of PRReviewFinding objects
"""
findings = []
if structured_output:
try:
result = SpecialistResponse.model_validate(structured_output)
for f in result.findings:
finding_id = hashlib.md5(
f"{f.file}:{f.line}:{f.title}".encode(),
usedforsecurity=False,
).hexdigest()[:12]
category = map_category(f.category)
try:
severity = ReviewSeverity(f.severity.lower())
except ValueError:
severity = ReviewSeverity.MEDIUM
finding = PRReviewFinding(
id=finding_id,
file=f.file,
line=f.line,
end_line=f.end_line,
title=f.title,
description=f.description,
category=category,
severity=severity,
suggested_fix=f.suggested_fix or "",
evidence=f.evidence,
source_agents=[specialist_name],
is_impact_finding=f.is_impact_finding,
)
findings.append(finding)
logger.info(
f"[Specialist:{specialist_name}] Parsed {len(findings)} findings from structured output"
)
except Exception as e:
logger.error(
f"[Specialist:{specialist_name}] Failed to parse structured output: {e}"
)
# Fall through to text parsing
if not findings and result_text:
# Fallback to text parsing
findings = self._parse_text_output(result_text)
for f in findings:
f.source_agents = [specialist_name]
return findings
async def _run_parallel_specialists(
self,
context: PRContext,
project_root: Path,
model: str,
thinking_budget: int | None,
) -> tuple[list[PRReviewFinding], list[str]]:
"""Run all specialists in parallel and collect findings.
Args:
context: PR context
project_root: Working directory
model: Model to use
thinking_budget: Max thinking tokens
Returns:
Tuple of (all_findings, agents_invoked)
"""
safe_print(
f"[ParallelOrchestrator] Launching {len(SPECIALIST_CONFIGS)} specialists in parallel...",
flush=True,
)
# Create tasks for all specialists
tasks = [
self._run_specialist_session(
config=config,
context=context,
project_root=project_root,
model=model,
thinking_budget=thinking_budget,
)
for config in SPECIALIST_CONFIGS
]
# Run all specialists in parallel
results = await asyncio.gather(*tasks, return_exceptions=True)
# Collect findings and track which agents ran
all_findings: list[PRReviewFinding] = []
agents_invoked: list[str] = []
for result in results:
if isinstance(result, Exception):
logger.error(f"[ParallelOrchestrator] Specialist task failed: {result}")
continue
specialist_name, findings = result
agents_invoked.append(specialist_name)
all_findings.extend(findings)
safe_print(
f"[ParallelOrchestrator] All specialists complete. "
f"Total findings: {len(all_findings)}",
flush=True,
)
return (all_findings, agents_invoked)
def _build_orchestrator_prompt(self, context: PRContext) -> str:
"""Build full prompt for orchestrator with PR context."""
# Load orchestrator prompt
@@ -693,11 +1085,6 @@ The SDK will run invoked agents in parallel automatically.
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
# No need to pre-scan the codebase programmatically
# Build orchestrator prompt AFTER worktree creation and related files rescan
prompt = self._build_orchestrator_prompt(context)
# Capture agent definitions for debug logging (with worktree path)
agent_defs = self._define_specialist_agents(project_root)
# Use model and thinking level from config (user settings)
# Resolve model shorthand via environment variable override if configured
model_shorthand = self.config.model or "sonnet"
@@ -710,122 +1097,39 @@ The SDK will run invoked agents in parallel automatically.
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
)
# Create client with subagents defined
# SDK handles parallel execution when Claude invokes multiple Task tools
client = self._create_sdk_client(project_root, model, thinking_budget)
self._report_progress(
"orchestrating",
40,
"Orchestrator delegating to specialist agents...",
"Running specialist agents in parallel...",
pr_number=context.pr_number,
)
# Run orchestrator session using shared SDK stream processor
# Retry logic for tool use concurrency errors
MAX_RETRIES = 3
RETRY_DELAY = 2.0 # seconds between retries
# =================================================================
# PARALLEL SDK SESSIONS APPROACH
# =================================================================
# Instead of using broken Task tool subagents, we spawn each
# specialist as its own SDK session and run them in parallel.
# See: https://github.com/anthropics/claude-code/issues/8697
#
# This gives us:
# - True parallel execution via asyncio.gather()
# - Full control over each specialist's tools and prompts
# - No dependency on broken CLI features
# =================================================================
result_text = ""
structured_output = None
agents_invoked = []
msg_count = 0
last_error = None
# Run all specialists in parallel
findings, agents_invoked = await self._run_parallel_specialists(
context=context,
project_root=project_root,
model=model,
thinking_budget=thinking_budget,
)
for attempt in range(MAX_RETRIES):
if attempt > 0:
logger.info(
f"[ParallelOrchestrator] Retry attempt {attempt}/{MAX_RETRIES - 1} "
f"after tool concurrency error"
)
safe_print(
f"[ParallelOrchestrator] Retry {attempt}/{MAX_RETRIES - 1} "
f"(tool concurrency error detected)"
)
# Small delay before retry
import asyncio
await asyncio.sleep(RETRY_DELAY)
# Recreate client for retry (fresh session)
client = self._create_sdk_client(
project_root, model, thinking_budget
)
try:
async with client:
await client.query(prompt)
safe_print(
f"[ParallelOrchestrator] Running orchestrator ({model})...",
flush=True,
)
# Process SDK stream with shared utility
stream_result = await process_sdk_stream(
client=client,
context_name="ParallelOrchestrator",
model=model,
system_prompt=prompt,
agent_definitions=agent_defs,
)
error = stream_result.get("error")
# Check for tool concurrency error specifically
if (
error == "tool_use_concurrency_error"
and attempt < MAX_RETRIES - 1
):
logger.warning(
f"[ParallelOrchestrator] Tool concurrency error on attempt {attempt + 1}, "
f"will retry..."
)
last_error = error
continue # Retry
# Check for other stream processing errors
if error:
logger.error(
f"[ParallelOrchestrator] SDK stream failed: {error}"
)
raise RuntimeError(f"SDK stream processing failed: {error}")
# Success - extract results and break retry loop
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
agents_invoked = stream_result["agents_invoked"]
msg_count = stream_result["msg_count"]
break # Success, exit retry loop
except Exception as e:
# Check if this is a retryable error
error_str = str(e).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "tool_use" in error_str
)
if is_retryable and attempt < MAX_RETRIES - 1:
logger.warning(
f"[ParallelOrchestrator] Retryable error on attempt {attempt + 1}: {e}"
)
last_error = str(e)
continue # Retry
# Not retryable or out of retries - re-raise
raise
else:
# All retries exhausted
logger.error(
f"[ParallelOrchestrator] Failed after {MAX_RETRIES} attempts. "
f"Last error: {last_error}"
)
raise RuntimeError(
f"Orchestrator failed after {MAX_RETRIES} retry attempts. "
f"Last error: {last_error}"
)
# Log results
logger.info(
f"[ParallelOrchestrator] Parallel specialists complete: "
f"{len(findings)} findings from {len(agents_invoked)} agents"
)
self._report_progress(
"finalizing",
@@ -834,20 +1138,9 @@ The SDK will run invoked agents in parallel automatically.
pr_number=context.pr_number,
)
# Parse findings from output (structured output also returns agents)
findings, agents_from_structured = self._extract_structured_output(
structured_output, result_text
)
# Use agents from structured output (more reliable than streaming detection)
final_agents = (
agents_from_structured if agents_from_structured else agents_invoked
)
logger.info(
f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}"
)
# Log completion with agent info
safe_print(
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
f"[ParallelOrchestrator] Complete. Agents invoked: {agents_invoked}",
flush=True,
)
@@ -950,7 +1243,7 @@ The SDK will run invoked agents in parallel automatically.
verdict_reasoning=verdict_reasoning,
blockers=blockers,
findings=unique_findings,
agents_invoked=final_agents,
agents_invoked=agents_invoked,
)
# Map verdict to overall_status
@@ -1107,6 +1400,19 @@ The SDK will run invoked agents in parallel automatically.
return None
def _parse_markdown_output(self, output: str) -> list[PRReviewFinding]:
"""Parse findings from markdown output when JSON extraction fails.
Delegates to shared markdown parsing utility.
Args:
output: Markdown text output to parse
Returns:
List of PRReviewFinding instances extracted from markdown
"""
return parse_markdown_findings(output, context_name="ParallelOrchestrator")
def _create_finding_from_dict(self, f_data: dict[str, Any]) -> PRReviewFinding:
"""Create a PRReviewFinding from dictionary data.
@@ -1141,23 +1447,39 @@ The SDK will run invoked agents in parallel automatically.
)
def _parse_text_output(self, output: str) -> list[PRReviewFinding]:
"""Parse findings from text output (fallback)."""
findings = []
"""Parse findings from text output (fallback).
Attempts JSON extraction first, then falls back to markdown parsing
when structured output validation fails and AI outputs findings in
markdown format.
"""
findings: list[PRReviewFinding] = []
try:
# Extract JSON from text
# First, try to extract JSON from text
data = self._extract_json_from_text(output)
if not data:
if data:
# Get findings array from JSON
findings_data = data.get("findings", [])
# Convert each finding dict to PRReviewFinding
for f_data in findings_data:
finding = self._create_finding_from_dict(f_data)
findings.append(finding)
if findings:
return findings
# Fallback: Try markdown parsing when JSON extraction fails
# This handles the case where structured output validation failed
# and Claude output findings in markdown format instead
findings = self._parse_markdown_output(output)
if findings:
logger.info(
f"[ParallelOrchestrator] Extracted {len(findings)} findings via markdown fallback"
)
return findings
# Get findings array from JSON
findings_data = data.get("findings", [])
# Convert each finding dict to PRReviewFinding
for f_data in findings_data:
finding = self._create_finding_from_dict(f_data)
findings.append(finding)
except Exception as e:
logger.error(f"[ParallelOrchestrator] Text parsing failed: {e}")
@@ -1471,25 +1793,34 @@ For EACH finding above:
error = stream_result.get("error")
if error:
# Check for specific error types that warrant retry
error_str = str(error).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "circuit breaker" in error_str
or "tool_use" in error_str
)
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
# Handle structured output validation failure - use text fallback
if error == "structured_output_validation_failed":
logger.warning(
f"[PRReview] Retryable validation error: {error}"
"[PRReview:FindingValidator] Structured output validation failed. "
"Will use text fallback to parse validation results."
)
# Clear structured_output to trigger text parsing below
stream_result["structured_output"] = None
else:
# Check for specific error types that warrant retry
error_str = str(error).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "circuit breaker" in error_str
or "tool_use" in error_str
)
last_error = Exception(error)
continue # Retry
logger.error(f"[PRReview] Validation failed: {error}")
# Fail-safe: return original findings
return findings
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
logger.warning(
f"[PRReview] Retryable validation error: {error}"
)
last_error = Exception(error)
continue # Retry
logger.error(f"[PRReview] Validation failed: {error}")
# Fail-safe: return original findings
return findings
structured_output = stream_result.get("structured_output")
@@ -12,19 +12,32 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
# Use absolute imports since apps/backend is in sys.path
from phase_config import resolve_model_id
from runners.github.context_gatherer import PRContext
from runners.github.models import (
AICommentTriage,
GitHubRunnerConfig,
PRReviewFinding,
ReviewPass,
StructuralIssue,
)
from runners.github.services.io_utils import safe_print
from runners.github.services.prompt_manager import PromptManager
from runners.github.services.response_parsers import ResponseParser
try:
from ...phase_config import resolve_model_id
from ..context_gatherer import PRContext
from ..models import (
AICommentTriage,
GitHubRunnerConfig,
PRReviewFinding,
ReviewPass,
StructuralIssue,
)
from .io_utils import safe_print
from .prompt_manager import PromptManager
from .response_parsers import ResponseParser
except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext
from models import (
AICommentTriage,
GitHubRunnerConfig,
PRReviewFinding,
ReviewPass,
StructuralIssue,
)
from phase_config import resolve_model_id
from services.io_utils import safe_print
from services.prompt_manager import PromptManager
from services.response_parsers import ResponseParser
# Define a local ProgressCallback to avoid circular import
@@ -9,7 +9,10 @@ from __future__ import annotations
from pathlib import Path
from runners.github.models import ReviewPass
try:
from ..models import ReviewPass
except (ImportError, ValueError, SystemError):
from models import ReviewPass
class PromptManager:
@@ -26,7 +26,87 @@ from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# =============================================================================
# Enum Normalization Helpers
# =============================================================================
# These help the AI produce valid output by mapping common synonyms/typos to
# the exact values required by the schema. This reduces structured output
# validation failures where the AI uses natural language like "duplication"
# when the schema requires "redundancy".
def normalize_category(value: str) -> str:
"""Normalize category value to match schema requirements.
Maps common synonyms and typos to valid category values.
"""
if not isinstance(value, str):
return value
# Map synonyms to valid category values
synonyms = {
# Redundancy synonyms
"duplication": "redundancy",
"code duplication": "redundancy",
"duplicate": "redundancy",
"duplicated": "redundancy",
"copy": "redundancy",
"copied": "redundancy",
# Performance synonyms
"perf": "performance",
"slow": "performance",
"optimization": "performance",
# Quality synonyms
"code quality": "quality",
"maintainability": "quality",
# Logic synonyms
"correctness": "logic",
"bug": "logic",
# Test synonyms
"testing": "test",
"tests": "test",
# Docs synonyms
"documentation": "docs",
"doc": "docs",
# Security synonyms
"sec": "security",
"vulnerability": "security",
# Style (map to quality or pattern)
"style": "quality",
"formatting": "quality",
}
normalized = value.lower().strip()
return synonyms.get(normalized, normalized)
def normalize_verdict(value: str) -> str:
"""Normalize verdict value to match schema requirements.
Handles common format variations like spaces, hyphens, and case sensitivity.
"""
if not isinstance(value, str):
return value
# Normalize: uppercase, replace spaces/hyphens with underscores
normalized = value.upper().strip().replace(" ", "_").replace("-", "_")
# Map common variations
synonyms = {
"READY": "READY_TO_MERGE",
"MERGE": "READY_TO_MERGE",
"APPROVED": "APPROVE",
"NEEDS_CHANGES": "NEEDS_REVISION",
"REQUEST_CHANGES": "NEEDS_REVISION",
"CHANGES_REQUESTED": "NEEDS_REVISION",
"BLOCK": "BLOCKED",
"REJECT": "BLOCKED",
}
return synonyms.get(normalized, normalized)
# =============================================================================
# Verification Evidence (Required for All Findings)
@@ -112,11 +192,22 @@ class QualityFinding(BaseFinding):
category: Literal[
"redundancy", "quality", "test", "performance", "pattern", "docs"
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. MUST be exactly one of: redundancy, quality, test, "
"performance, pattern, docs. Use 'redundancy' for code duplication "
"(NOT 'duplication')."
)
)
redundant_with: str | None = Field(
None, description="Reference to duplicate code (file:line) if redundant"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class DeepAnalysisFinding(BaseFinding):
"""A finding from deep analysis with verification info."""
@@ -128,11 +219,20 @@ class DeepAnalysisFinding(BaseFinding):
"pattern",
"performance",
"logic",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
)
)
verification_note: str | None = Field(
None, description="What evidence is missing or couldn't be verified"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class StructuralIssue(BaseModel):
"""A structural issue with the PR."""
@@ -194,7 +294,10 @@ class FollowupFinding(BaseModel):
description="Issue severity level"
)
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
description="Issue category"
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"test, docs."
)
)
title: str = Field(description="Brief issue title")
description: str = Field(description="Detailed explanation of the issue")
@@ -206,6 +309,11 @@ class FollowupFinding(BaseModel):
description="Evidence that this finding was verified against actual code"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class FollowupReviewResponse(BaseModel):
"""Complete response schema for follow-up PR review."""
@@ -222,9 +330,19 @@ class FollowupReviewResponse(BaseModel):
)
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
] = Field(
description=(
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Initial Review Responses (Multi-Pass)
@@ -289,9 +407,19 @@ class StructuralPassResult(BaseModel):
)
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Structural verdict")
] = Field(
description=(
"Structural verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
class AICommentTriageResult(BaseModel):
"""Result from AI comment triage pass."""
@@ -366,7 +494,11 @@ class OrchestratorFinding(BaseModel):
"performance",
"logic",
"test",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
)
)
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
@@ -379,19 +511,34 @@ class OrchestratorFinding(BaseModel):
description="Evidence that this finding was verified against actual code"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class OrchestratorReviewResponse(BaseModel):
"""Complete response schema for orchestrator PR review."""
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
] = Field(
description=(
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
findings: list[OrchestratorFinding] = Field(
default_factory=list, description="Issues found during review"
)
summary: str = Field(description="Brief summary of the review")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Parallel Orchestrator Review Response (SDK Subagents)
@@ -446,7 +593,13 @@ class ParallelOrchestratorFinding(BaseModel):
"redundancy",
"pattern",
"performance",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"codebase_fit, test, docs, redundancy, pattern, performance. "
"Use 'redundancy' for code duplication (NOT 'duplication')."
)
)
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
@@ -482,6 +635,11 @@ class ParallelOrchestratorFinding(BaseModel):
False, description="Whether multiple agents agreed on this finding"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class AgentAgreement(BaseModel):
"""Tracks agreement between agents on findings."""
@@ -537,6 +695,61 @@ class ValidationSummary(BaseModel):
)
class SpecialistFinding(BaseModel):
"""A finding from a specialist agent (used in parallel SDK sessions)."""
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
category: Literal[
"security", "quality", "logic", "performance", "pattern", "test", "docs"
] = Field(
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"performance, pattern, test, docs."
)
)
title: str = Field(description="Brief issue title (max 80 chars)")
description: str = Field(description="Detailed explanation of the issue")
file: str = Field(description="File path where issue was found")
line: int = Field(0, description="Line number of the issue")
end_line: int | None = Field(None, description="End line number if multi-line")
suggested_fix: str | None = Field(None, description="How to fix this issue")
evidence: str = Field(
min_length=1,
description="Actual code snippet examined that shows the issue. Required.",
)
is_impact_finding: bool = Field(
False,
description="True if this is about affected code outside the PR (callers, dependencies)",
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class SpecialistResponse(BaseModel):
"""Response schema for individual specialist agent (parallel SDK sessions).
Used when each specialist runs as its own SDK session rather than via Task tool.
"""
specialist_name: str = Field(
description="Name of the specialist (security, quality, logic, codebase-fit)"
)
analysis_summary: str = Field(description="Brief summary of what was analyzed")
files_examined: list[str] = Field(
default_factory=list,
description="List of files that were examined",
)
findings: list[SpecialistFinding] = Field(
default_factory=list,
description="Issues found during analysis",
)
class ParallelOrchestratorResponse(BaseModel):
"""Complete response schema for parallel orchestrator PR review."""
@@ -567,10 +780,18 @@ class ParallelOrchestratorResponse(BaseModel):
description="Information about agent agreement on findings",
)
verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field(
description="Overall PR verdict"
description=(
"Overall PR verdict. MUST be exactly one of: APPROVE, COMMENT, "
"NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Parallel Follow-up Review Response (SDK Subagents for Follow-up)
@@ -610,7 +831,12 @@ class ParallelFollowupFinding(BaseModel):
"docs",
"regression",
"incomplete_fix",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"test, docs, regression, incomplete_fix."
)
)
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
@@ -638,6 +864,11 @@ class ParallelFollowupFinding(BaseModel):
),
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class CommentAnalysis(BaseModel):
"""Analysis of a contributor or AI comment."""
@@ -709,9 +940,19 @@ class ParallelFollowupResponse(BaseModel):
# Verdict
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
] = Field(
description=(
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Finding Validation Response (Re-investigation of unresolved findings)
@@ -10,17 +10,30 @@ from __future__ import annotations
import json
import re
from runners.github.models import (
AICommentTriage,
AICommentVerdict,
PRReviewFinding,
ReviewCategory,
ReviewSeverity,
StructuralIssue,
TriageCategory,
TriageResult,
)
from runners.github.services.io_utils import safe_print
try:
from ..models import (
AICommentTriage,
AICommentVerdict,
PRReviewFinding,
ReviewCategory,
ReviewSeverity,
StructuralIssue,
TriageCategory,
TriageResult,
)
from .io_utils import safe_print
except (ImportError, ValueError, SystemError):
from models import (
AICommentTriage,
AICommentVerdict,
PRReviewFinding,
ReviewCategory,
ReviewSeverity,
StructuralIssue,
TriageCategory,
TriageResult,
)
from services.io_utils import safe_print
# Evidence-based validation replaces confidence scoring
# Findings without evidence are filtered out instead of using confidence thresholds
@@ -14,11 +14,18 @@ import logging
from dataclasses import dataclass
from pathlib import Path
from analysis.test_discovery import TestDiscovery
from core.client import create_client
from runners.github.context_gatherer import PRContext
from runners.github.models import PRReviewFinding, ReviewSeverity
from runners.github.services.category_utils import map_category
try:
from ...analysis.test_discovery import TestDiscovery
from ...core.client import create_client
from ..context_gatherer import PRContext
from ..models import PRReviewFinding, ReviewSeverity
from .category_utils import map_category
except (ImportError, ValueError, SystemError):
from analysis.test_discovery import TestDiscovery
from category_utils import map_category
from context_gatherer import PRContext
from core.client import create_client
from models import PRReviewFinding, ReviewSeverity
logger = logging.getLogger(__name__)
@@ -15,7 +15,10 @@ import os
from collections.abc import Callable
from typing import Any
from runners.github.services.io_utils import safe_print
try:
from .io_utils import safe_print
except (ImportError, ValueError, SystemError):
from core.io_utils import safe_print
logger = logging.getLogger(__name__)
@@ -457,12 +460,17 @@ async def process_sdk_stream(
)
if subtype == "error_max_structured_output_retries":
# SDK failed to produce valid structured output after retries
# Log ALL available fields for debugging - helps diagnose enum mismatches
result_detail = getattr(msg, "result", None)
is_error = getattr(msg, "is_error", False)
logger.warning(
f"[{context_name}] Claude could not produce valid structured output "
f"after maximum retries - schema validation failed"
f"after maximum retries - schema validation failed. "
f"is_error={is_error}, result_preview={str(result_detail)[:500] if result_detail else 'None'}"
)
safe_print(
f"[{context_name}] WARNING: Structured output validation failed after retries"
f"[{context_name}] WARNING: Structured output validation failed after retries. "
f"Result preview: {str(result_detail)[:300] if result_detail else 'None'}"
)
if not stream_error:
stream_error = "structured_output_validation_failed"
@@ -9,10 +9,16 @@ from __future__ import annotations
from pathlib import Path
from phase_config import resolve_model_id
from runners.github.models import GitHubRunnerConfig, TriageCategory, TriageResult
from runners.github.services.prompt_manager import PromptManager
from runners.github.services.response_parsers import ResponseParser
try:
from ...phase_config import resolve_model_id
from ..models import GitHubRunnerConfig, TriageCategory, TriageResult
from .prompt_manager import PromptManager
from .response_parsers import ResponseParser
except (ImportError, ValueError, SystemError):
from models import GitHubRunnerConfig, TriageCategory, TriageResult
from phase_config import resolve_model_id
from services.prompt_manager import PromptManager
from services.response_parsers import ResponseParser
class TriageEngine:
+5 -3
View File
@@ -41,10 +41,12 @@ env_file = Path(__file__).parent.parent.parent / ".env"
if env_file.exists():
load_dotenv(env_file)
from core.io_utils import safe_print
# Add gitlab runner directory to path for direct imports
sys.path.insert(0, str(Path(__file__).parent))
from .models import GitLabRunnerConfig
from .orchestrator import GitLabOrchestrator, ProgressCallback
from core.io_utils import safe_print
from models import GitLabRunnerConfig
from orchestrator import GitLabOrchestrator, ProgressCallback
def print_progress(callback: ProgressCallback) -> None:
+3 -1
View File
@@ -43,7 +43,9 @@ export default defineConfig({
'debug',
'ms',
// Minimatch for glob pattern matching in worktree handlers
'minimatch'
'minimatch',
// XState for task state machine
'xstate'
]
})],
build: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.7.6-beta.1",
"version": "2.7.6-beta.2",
"type": "module",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"homepage": "https://github.com/AndyMik90/Auto-Claude",
@@ -297,7 +297,7 @@ describe('Subprocess Spawn Integration', () => {
// Simulate stdout data (must include newline for buffered output processing)
mockStdout.emit('data', Buffer.from('Test log output\n'));
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n');
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n', undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit log events from stderr', async () => {
@@ -313,7 +313,7 @@ describe('Subprocess Spawn Integration', () => {
// Simulate stderr data (must include newline for buffered output processing)
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n');
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n', undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit exit event when process exits', async () => {
@@ -329,8 +329,8 @@ describe('Subprocess Spawn Integration', () => {
// Simulate process exit
mockProcess.emit('exit', 0);
// Exit event includes taskId, exit code, and process type
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
// Exit event includes taskId, exit code, process type, and optional projectId
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String), undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should emit error event when process errors', async () => {
@@ -346,7 +346,7 @@ describe('Subprocess Spawn Integration', () => {
// Simulate process error
mockProcess.emit('error', new Error('Spawn failed'));
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed');
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed', undefined);
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should kill task and remove from tracking', async () => {
@@ -1,809 +0,0 @@
/**
* Tests for Claude Profile Manager
* Comprehensive test coverage for profile management, tokens, and auto-switching
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { app } from 'electron';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import type { ClaudeProfile, ClaudeUsageData, ClaudeAutoSwitchSettings } from '../../shared/types';
// Mock dependencies before importing the module under test
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/test-app-data'),
isPackaged: false
}
}));
vi.mock('fs');
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
mkdir: vi.fn()
}));
// Mock profile modules
vi.mock('../claude-profile/token-encryption', () => ({
encryptToken: vi.fn((token: string) => `encrypted_${token}`),
decryptToken: vi.fn((encrypted: string) => encrypted.replace('encrypted_', ''))
}));
vi.mock('../claude-profile/usage-parser', () => ({
parseUsageOutput: vi.fn()
}));
vi.mock('../claude-profile/rate-limit-manager', () => ({
recordRateLimitEvent: vi.fn(),
isProfileRateLimited: vi.fn(() => ({ limited: false })),
clearRateLimitEvents: vi.fn()
}));
vi.mock('../claude-profile/profile-storage', () => ({
loadProfileStore: vi.fn(),
loadProfileStoreAsync: vi.fn(),
saveProfileStore: vi.fn(),
DEFAULT_AUTO_SWITCH_SETTINGS: {
enabled: false,
proactiveSwapEnabled: false,
sessionThreshold: 95,
weeklyThreshold: 99,
autoSwitchOnRateLimit: false,
usageCheckInterval: 30000
}
}));
vi.mock('../claude-profile/profile-scorer', () => ({
getBestAvailableProfile: vi.fn(),
shouldProactivelySwitch: vi.fn(() => ({ shouldSwitch: false })),
getProfilesSortedByAvailability: vi.fn((profiles) => [...profiles])
}));
vi.mock('../claude-profile/credential-utils', () => ({
getCredentialsFromKeychain: vi.fn(() => ({ token: null })),
normalizeWindowsPath: vi.fn((path: string) => path)
}));
vi.mock('../claude-profile/profile-utils', () => ({
CLAUDE_PROFILES_DIR: '/tmp/.claude-profiles',
generateProfileId: vi.fn((name: string, profiles: any[]) => {
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
return base;
}),
createProfileDirectory: vi.fn(async (name: string) => {
const dir = `/tmp/.claude-profiles/${name.toLowerCase()}`;
return dir;
}),
isProfileAuthenticated: vi.fn(() => true),
hasValidToken: vi.fn(() => true),
expandHomePath: vi.fn((path: string) => path.replace('~', '/home/user')),
getEmailFromConfigDir: vi.fn(() => null)
}));
// Import after mocks are set up
import { ClaudeProfileManager, getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
import * as profileStorage from '../claude-profile/profile-storage';
import * as tokenEncryption from '../claude-profile/token-encryption';
import * as usageParser from '../claude-profile/usage-parser';
import * as rateLimitManager from '../claude-profile/rate-limit-manager';
import * as profileScorer from '../claude-profile/profile-scorer';
import * as credentialUtils from '../claude-profile/credential-utils';
import * as profileUtils from '../claude-profile/profile-utils';
describe('ClaudeProfileManager', () => {
let manager: ClaudeProfileManager;
const mockProfileData = {
version: 3,
profiles: [
{
id: 'primary',
name: 'Primary',
configDir: '/tmp/.claude-profiles/primary',
isDefault: true,
description: 'Primary Claude account',
createdAt: new Date('2024-01-01')
}
],
activeProfileId: 'primary',
autoSwitch: {
enabled: false,
proactiveSwapEnabled: false,
sessionThreshold: 95,
weeklyThreshold: 99,
autoSwitchOnRateLimit: false,
usageCheckInterval: 30000
}
};
beforeEach(async () => {
vi.clearAllMocks();
// Mock fs operations
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockProfileData));
vi.mocked(writeFileSync).mockImplementation(() => {});
vi.mocked(mkdirSync).mockImplementation(() => undefined);
// Mock async profile loading
vi.mocked(profileStorage.loadProfileStoreAsync).mockResolvedValue(mockProfileData);
vi.mocked(profileStorage.loadProfileStore).mockReturnValue(mockProfileData);
// Create and initialize manager
manager = new ClaudeProfileManager();
await manager.initialize();
});
describe('Initialization', () => {
it('should initialize with default profile', () => {
const settings = manager.getSettings();
expect(settings.profiles).toHaveLength(1);
expect(settings.profiles[0].name).toBe('Primary');
expect(settings.profiles[0].isDefault).toBe(true);
expect(settings.activeProfileId).toBe('primary');
});
it('should load existing profiles from disk', async () => {
const customData = {
...mockProfileData,
profiles: [
...mockProfileData.profiles,
{
id: 'work',
name: 'Work Account',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date('2024-01-02')
}
]
};
vi.mocked(profileStorage.loadProfileStoreAsync).mockResolvedValue(customData);
const newManager = new ClaudeProfileManager();
await newManager.initialize();
const settings = newManager.getSettings();
expect(settings.profiles).toHaveLength(2);
expect(settings.profiles[1].name).toBe('Work Account');
});
it('should create config directory if it does not exist', async () => {
const { mkdir } = await import('fs/promises');
await manager.initialize();
expect(mkdir).toHaveBeenCalledWith(
expect.stringContaining('config'),
{ recursive: true }
);
});
it('should mark as initialized after setup', async () => {
expect(manager.isInitialized()).toBe(true);
});
it('should not re-initialize if already initialized', async () => {
await manager.initialize();
await manager.initialize();
const { mkdir } = await import('fs/promises');
// Should only be called once from beforeEach initialization
expect(mkdir).toHaveBeenCalledTimes(1);
});
});
describe('Profile Management', () => {
it('should get active profile', () => {
const active = manager.getActiveProfile();
expect(active.id).toBe('primary');
expect(active.name).toBe('Primary');
});
it('should get specific profile by ID', () => {
const profile = manager.getProfile('primary');
expect(profile).toBeDefined();
expect(profile?.name).toBe('Primary');
});
it('should return undefined for non-existent profile', () => {
const profile = manager.getProfile('nonexistent');
expect(profile).toBeUndefined();
});
it('should save new profile', () => {
const newProfile: ClaudeProfile = {
id: 'work',
name: 'Work Account',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date()
};
const saved = manager.saveProfile(newProfile);
expect(saved.id).toBe('work');
expect(profileStorage.saveProfileStore).toHaveBeenCalled();
});
it('should update existing profile', () => {
const settings = manager.getSettings();
const profile = { ...settings.profiles[0], name: 'Updated Name' };
manager.saveProfile(profile);
const updated = manager.getProfile('primary');
expect(updated?.name).toBe('Updated Name');
});
it('should expand home path in configDir when saving', () => {
const profile: ClaudeProfile = {
id: 'test',
name: 'Test',
configDir: '~/.claude-test',
isDefault: false,
createdAt: new Date()
};
manager.saveProfile(profile);
expect(profileUtils.expandHomePath).toHaveBeenCalledWith('~/.claude-test');
});
it('should set active profile', () => {
const newProfile: ClaudeProfile = {
id: 'work',
name: 'Work',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date()
};
manager.saveProfile(newProfile);
const result = manager.setActiveProfile('work');
expect(result).toBe(true);
expect(manager.getActiveProfile().id).toBe('work');
});
it('should fail to set non-existent profile as active', () => {
// Ensure no other profiles exist from previous tests
const currentActive = manager.getActiveProfile().id;
const result = manager.setActiveProfile('nonexistent');
expect(result).toBe(false);
expect(manager.getActiveProfile().id).toBe(currentActive);
});
it('should update lastUsedAt when setting active profile', () => {
const before = new Date();
manager.setActiveProfile('primary');
const after = new Date();
const profile = manager.getProfile('primary');
expect(profile?.lastUsedAt).toBeDefined();
expect(profile!.lastUsedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(profile!.lastUsedAt!.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('should mark profile as used', () => {
manager.markProfileUsed('primary');
const profile = manager.getProfile('primary');
expect(profile?.lastUsedAt).toBeDefined();
});
it('should rename profile', () => {
const result = manager.renameProfile('primary', 'New Name');
expect(result).toBe(true);
expect(manager.getProfile('primary')?.name).toBe('New Name');
});
it('should fail to rename with empty name', () => {
const result = manager.renameProfile('primary', ' ');
expect(result).toBe(false);
});
it('should delete non-default profile', () => {
const newProfile: ClaudeProfile = {
id: 'work',
name: 'Work',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date()
};
manager.saveProfile(newProfile);
const result = manager.deleteProfile('work');
expect(result).toBe(true);
expect(manager.getProfile('work')).toBeUndefined();
});
it('should not delete default profile', () => {
const result = manager.deleteProfile('primary');
expect(result).toBe(false);
expect(manager.getProfile('primary')).toBeDefined();
});
it('should switch to default when deleting active profile', () => {
const work: ClaudeProfile = {
id: 'work',
name: 'Work',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date()
};
manager.saveProfile(work);
manager.setActiveProfile('work');
manager.deleteProfile('work');
expect(manager.getActiveProfile().id).toBe('primary');
});
});
describe('Token Management', () => {
it('should set OAuth token for profile (encrypted)', () => {
const token = 'test-oauth-token';
const result = manager.setProfileToken('primary', token, 'user@example.com');
expect(result).toBe(true);
expect(tokenEncryption.encryptToken).toHaveBeenCalledWith(token);
});
it('should get decrypted token for active profile', () => {
manager.setProfileToken('primary', 'test-token');
const token = manager.getActiveProfileToken();
expect(token).toBe('test-token'); // Decrypted by mock
expect(tokenEncryption.decryptToken).toHaveBeenCalled();
});
it('should get decrypted token for specific profile', () => {
manager.setProfileToken('primary', 'test-token');
const token = manager.getProfileToken('primary');
expect(token).toBe('test-token');
});
it('should return undefined for profile without token', () => {
// Get a fresh manager instance for this test
const freshManager = new ClaudeProfileManager();
vi.mocked(profileStorage.loadProfileStore).mockReturnValue({
...mockProfileData,
profiles: [{ ...mockProfileData.profiles[0], oauthToken: undefined }]
});
// Re-initialize with fresh data (synchronous load for this test)
const token = freshManager.getProfileToken('primary');
expect(token).toBeUndefined();
});
it('should validate token age', () => {
vi.mocked(profileUtils.hasValidToken).mockReturnValue(true);
const result = manager.hasValidToken('primary');
expect(result).toBe(true);
});
it('should update email when setting token', () => {
manager.setProfileToken('primary', 'token', 'user@example.com');
const profile = manager.getProfile('primary');
expect(profile?.email).toBe('user@example.com');
});
it('should clear rate limit events when setting new token', () => {
manager.setProfileToken('primary', 'new-token');
const profile = manager.getProfile('primary');
expect(profile?.rateLimitEvents).toEqual([]);
});
});
describe('Usage Tracking', () => {
it('should update usage from terminal output', () => {
const usageOutput = 'Session: 50% | Weekly: 75%';
const mockUsage: ClaudeUsageData = {
sessionUsagePercent: 50,
sessionResetTime: '2h',
weeklyUsagePercent: 75,
weeklyResetTime: '3d',
lastUpdated: new Date()
};
vi.mocked(usageParser.parseUsageOutput).mockReturnValue(mockUsage);
const result = manager.updateProfileUsage('primary', usageOutput);
expect(result).toEqual(mockUsage);
expect(usageParser.parseUsageOutput).toHaveBeenCalledWith(usageOutput);
});
it('should update usage from API percentages', () => {
const result = manager.updateProfileUsageFromAPI('primary', 60, 80);
expect(result).toBeDefined();
expect(result?.sessionUsagePercent).toBe(60);
expect(result?.weeklyUsagePercent).toBe(80);
});
it('should batch update usage for multiple profiles', () => {
const work: ClaudeProfile = {
id: 'work',
name: 'Work',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date()
};
manager.saveProfile(work);
const updates = [
{ profileId: 'primary', sessionPercent: 50, weeklyPercent: 70 },
{ profileId: 'work', sessionPercent: 30, weeklyPercent: 40 }
];
const count = manager.batchUpdateProfileUsageFromAPI(updates);
expect(count).toBe(2);
expect(manager.getProfile('primary')?.usage?.sessionUsagePercent).toBe(50);
expect(manager.getProfile('work')?.usage?.sessionUsagePercent).toBe(30);
});
it('should skip invalid profiles in batch update', () => {
const updates = [
{ profileId: 'primary', sessionPercent: 50, weeklyPercent: 70 },
{ profileId: 'invalid', sessionPercent: 30, weeklyPercent: 40 }
];
const count = manager.batchUpdateProfileUsageFromAPI(updates);
expect(count).toBe(1);
});
it('should preserve existing reset times in API update', () => {
const existing: ClaudeUsageData = {
sessionUsagePercent: 40,
sessionResetTime: '2h',
weeklyUsagePercent: 60,
weeklyResetTime: '3d',
lastUpdated: new Date()
};
manager.getProfile('primary')!.usage = existing;
manager.updateProfileUsageFromAPI('primary', 50, 70);
const profile = manager.getProfile('primary');
expect(profile?.usage?.sessionResetTime).toBe('2h');
expect(profile?.usage?.weeklyResetTime).toBe('3d');
});
});
describe('Rate Limiting', () => {
it('should record rate limit event', () => {
const mockEvent = {
hitAt: new Date(),
resetAt: new Date(Date.now() + 3600000),
resetTimeString: 'in 1 hour',
type: 'session' as const
};
vi.mocked(rateLimitManager.recordRateLimitEvent).mockReturnValue(mockEvent);
const event = manager.recordRateLimitEvent('primary', '1h');
expect(event).toEqual(mockEvent);
expect(rateLimitManager.recordRateLimitEvent).toHaveBeenCalled();
});
it('should check if profile is rate limited', () => {
vi.mocked(rateLimitManager.isProfileRateLimited).mockReturnValue({
limited: true,
type: 'session',
resetAt: new Date()
});
const result = manager.isProfileRateLimited('primary');
expect(result.limited).toBe(true);
expect(result.type).toBe('session');
});
it('should clear rate limit events', () => {
manager.clearRateLimitEvents('primary');
expect(rateLimitManager.clearRateLimitEvents).toHaveBeenCalled();
});
});
describe('Auto-Switch Settings', () => {
it('should get auto-switch settings', () => {
const settings = manager.getAutoSwitchSettings();
expect(settings.enabled).toBe(false);
expect(settings.sessionThreshold).toBe(95);
expect(settings.weeklyThreshold).toBe(99);
});
it('should update auto-switch settings', () => {
manager.updateAutoSwitchSettings({
enabled: true,
sessionThreshold: 90
});
const settings = manager.getAutoSwitchSettings();
expect(settings.enabled).toBe(true);
expect(settings.sessionThreshold).toBe(90);
expect(settings.weeklyThreshold).toBe(99); // Preserved
});
it('should get best available profile', () => {
const mockBestProfile: ClaudeProfile = {
id: 'work',
name: 'Work',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date()
};
vi.mocked(profileScorer.getBestAvailableProfile).mockReturnValue(mockBestProfile);
const result = manager.getBestAvailableProfile('primary');
expect(result).toEqual(mockBestProfile);
});
it('should determine if should proactively switch', () => {
vi.mocked(profileScorer.shouldProactivelySwitch).mockReturnValue({
shouldSwitch: true,
reason: 'High usage',
suggestedProfile: {
id: 'work',
name: 'Work',
configDir: '/tmp/.claude-profiles/work',
isDefault: false,
createdAt: new Date()
}
});
const result = manager.shouldProactivelySwitch('primary');
expect(result.shouldSwitch).toBe(true);
expect(result.reason).toBe('High usage');
});
it('should get profiles sorted by availability', () => {
const sorted = manager.getProfilesSortedByAvailability();
expect(profileScorer.getProfilesSortedByAvailability).toHaveBeenCalled();
expect(sorted).toBeInstanceOf(Array);
});
});
describe('Account Priority Order', () => {
it('should get account priority order', () => {
const order = manager.getAccountPriorityOrder();
expect(order).toBeInstanceOf(Array);
});
it('should set account priority order', () => {
const order = ['oauth-primary', 'oauth-work', 'api-backup'];
manager.setAccountPriorityOrder(order);
expect(manager.getAccountPriorityOrder()).toEqual(order);
});
});
describe('Environment Variables', () => {
it('should get environment for active profile', () => {
const env = manager.getActiveProfileEnv();
expect(env.CLAUDE_CONFIG_DIR).toBeDefined();
expect(env.CLAUDE_CONFIG_DIR).toContain('primary');
});
it('should get environment for specific profile', () => {
const env = manager.getProfileEnv('primary');
expect(env.CLAUDE_CONFIG_DIR).toBeDefined();
});
it('should expand home directory in config path', () => {
const profile: ClaudeProfile = {
id: 'test',
name: 'Test',
configDir: '~/.claude-test',
isDefault: false,
createdAt: new Date()
};
manager.saveProfile(profile);
const env = manager.getProfileEnv('test');
expect(env.CLAUDE_CONFIG_DIR).not.toContain('~');
});
it('should retrieve OAuth token from Keychain', () => {
vi.mocked(credentialUtils.getCredentialsFromKeychain).mockReturnValue({
token: 'keychain-token',
email: 'test@example.com'
});
const env = manager.getProfileEnv('primary');
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('keychain-token');
});
it('should continue without token if Keychain retrieval fails', () => {
vi.mocked(credentialUtils.getCredentialsFromKeychain).mockImplementation(() => {
throw new Error('Keychain error');
});
const env = manager.getProfileEnv('primary');
expect(env.CLAUDE_CONFIG_DIR).toBeDefined();
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});
});
describe('Profile Utilities', () => {
it('should generate unique profile ID', () => {
const id = manager.generateProfileId('Work Account');
expect(profileUtils.generateProfileId).toHaveBeenCalledWith(
'Work Account',
expect.any(Array)
);
expect(id).toBe('work-account');
});
it('should create profile directory', async () => {
const dir = await manager.createProfileDirectory('Work');
expect(profileUtils.createProfileDirectory).toHaveBeenCalledWith('Work');
expect(dir).toContain('work');
});
it('should check if profile is authenticated', () => {
vi.mocked(profileUtils.isProfileAuthenticated).mockReturnValue(true);
const result = manager.isProfileAuthenticated(manager.getActiveProfile());
expect(result).toBe(true);
});
it('should check if profile has valid auth', () => {
vi.mocked(profileUtils.hasValidToken).mockReturnValue(true);
const result = manager.hasValidAuth('primary');
expect(result).toBe(true);
});
it('should check configDir auth if no valid token', () => {
vi.mocked(profileUtils.hasValidToken).mockReturnValue(false);
vi.mocked(profileUtils.isProfileAuthenticated).mockReturnValue(true);
const result = manager.hasValidAuth('primary');
expect(result).toBe(true);
});
});
describe('Profile Migration', () => {
it('should get migrated profile IDs', () => {
const ids = manager.getMigratedProfileIds();
expect(ids).toBeInstanceOf(Array);
});
it('should clear migrated profile after re-authentication', async () => {
// Set up manager with migrated profile - must mock async loader
vi.mocked(profileStorage.loadProfileStoreAsync).mockResolvedValue({
...mockProfileData,
migratedProfileIds: ['primary']
});
// Create new manager and initialize to load data
const mgr = new ClaudeProfileManager();
await mgr.initialize();
// Clear only the call history, not the mock implementations
vi.mocked(profileStorage.saveProfileStore).mockClear();
mgr.clearMigratedProfile('primary');
expect(profileStorage.saveProfileStore).toHaveBeenCalled();
});
it('should check if profile is migrated', () => {
const result = manager.isProfileMigrated('primary');
expect(typeof result).toBe('boolean');
});
});
describe('Settings Integration', () => {
it('should include authentication status in settings', () => {
vi.mocked(profileUtils.isProfileAuthenticated).mockReturnValue(true);
vi.mocked(profileUtils.hasValidToken).mockReturnValue(false);
const settings = manager.getSettings();
expect(settings.profiles[0].isAuthenticated).toBe(true);
});
it('should combine token and configDir auth status', () => {
vi.mocked(profileUtils.isProfileAuthenticated).mockReturnValue(false);
vi.mocked(profileUtils.hasValidToken).mockReturnValue(true);
const settings = manager.getSettings();
expect(settings.profiles[0].isAuthenticated).toBe(true);
});
});
describe('Singleton Pattern', () => {
it('should return same instance from getClaudeProfileManager', () => {
const instance1 = getClaudeProfileManager();
const instance2 = getClaudeProfileManager();
expect(instance1).toBe(instance2);
});
it('should initialize singleton async', async () => {
const instance = await initializeClaudeProfileManager();
expect(instance).toBeDefined();
expect(instance.isInitialized()).toBe(true);
});
it('should cache initialization promise', async () => {
// Note: The singleton manager is already initialized from beforeEach
// This test verifies subsequent calls return the same instance
const instance1 = await initializeClaudeProfileManager();
const instance2 = await initializeClaudeProfileManager();
expect(instance1).toBe(instance2);
expect(instance1.isInitialized()).toBe(true);
});
});
describe('Error Handling', () => {
it('should handle missing profile in token operations', () => {
const result = manager.setProfileToken('nonexistent', 'token');
expect(result).toBe(false);
});
it('should handle missing profile in usage update', () => {
const result = manager.updateProfileUsage('nonexistent', 'output');
expect(result).toBeNull();
});
it('should throw error when recording rate limit for missing profile', () => {
expect(() => {
manager.recordRateLimitEvent('nonexistent', '1h');
}).toThrow('Profile not found');
});
it('should return false for rate limit check on missing profile', () => {
const result = manager.isProfileRateLimited('nonexistent');
expect(result.limited).toBe(false);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -564,7 +564,7 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
expect(result).toHaveProperty("success", true);
const data = (result as { data: { theme: string } }).data;
expect(data).toHaveProperty("theme", "system");
expect(data).toHaveProperty("theme", "dark");
});
});
@@ -390,6 +390,83 @@ describe('TaskStateManager', () => {
// Should have sent PROCESS_EXITED event with unexpected=true
// This should transition to error state
});
it('should NOT mark exit code 0 as unexpected (plan_review stays intact)', () => {
// Simulate: PLANNING_STARTED → PLANNING_COMPLETE (requireReview) → process exits code 0
const planningStarted = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
const planningComplete = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 1,
hasSubtasks: false,
subtaskCount: 0,
requireReviewBeforeCoding: true
};
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
// XState should be in plan_review now
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
// Process exits with code 0 - should NOT transition to error
manager.handleProcessExited(mockTask.id, 0, mockTask, mockProject);
// PLANNING_COMPLETE is a terminal event, so handleProcessExited should skip entirely
// Task should remain in plan_review
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
});
it('should treat PLANNING_COMPLETE as a terminal event', () => {
// PLANNING_COMPLETE should prevent handleProcessExited from running
const planningStarted = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
const planningComplete = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 1,
hasSubtasks: true,
subtaskCount: 3,
requireReviewBeforeCoding: false
};
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
// XState should be in coding (no review required)
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
// Process exits with code 1 - should still skip because PLANNING_COMPLETE is terminal
manager.handleProcessExited(mockTask.id, 1, mockTask, mockProject);
// Task should remain in coding, NOT transition to error
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
});
});
describe('actor state restoration', () => {
+21 -13
View File
@@ -32,6 +32,7 @@ export class AgentManager extends EventEmitter {
metadata?: SpecCreationMetadata;
baseBranch?: string;
swapCount: number;
projectId?: string;
}> = new Map();
constructor() {
@@ -51,7 +52,7 @@ export class AgentManager extends EventEmitter {
});
// Listen for task completion to clean up context (prevent memory leak)
this.on('exit', (taskId: string, code: number | null) => {
this.on('exit', (taskId: string, code: number | null, _processType?: string, _projectId?: string) => {
// Clean up context when:
// 1. Task completed successfully (code === 0), or
// 2. Task failed and won't be restarted (handled by auto-swap logic)
@@ -93,7 +94,8 @@ export class AgentManager extends EventEmitter {
taskDescription: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string
baseBranch?: string,
projectId?: string
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
@@ -173,10 +175,10 @@ export class AgentManager extends EventEmitter {
}
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
// Note: This is spec-creation but it chains to task-execution via run.py
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
}
/**
@@ -186,7 +188,8 @@ export class AgentManager extends EventEmitter {
taskId: string,
projectPath: string,
specId: string,
options: TaskExecutionOptions = {}
options: TaskExecutionOptions = {},
projectId?: string
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
@@ -251,9 +254,9 @@ export class AgentManager extends EventEmitter {
// which allows per-phase configuration for planner, coder, and QA phases
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, specId, options, false);
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
}
/**
@@ -262,7 +265,8 @@ export class AgentManager extends EventEmitter {
async startQAProcess(
taskId: string,
projectPath: string,
specId: string
specId: string,
projectId?: string
): Promise<void> {
// Ensure Python environment is ready before spawning process (prevents exit code 127 race condition)
const pythonStatus = await this.processManager.ensurePythonEnvReady('AgentManager');
@@ -290,7 +294,7 @@ export class AgentManager extends EventEmitter {
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process', projectId);
}
/**
@@ -387,7 +391,8 @@ export class AgentManager extends EventEmitter {
taskDescription?: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string
baseBranch?: string,
projectId?: string
): void {
// Preserve swapCount if context already exists (for restarts)
const existingContext = this.taskExecutionContext.get(taskId);
@@ -402,7 +407,8 @@ export class AgentManager extends EventEmitter {
specDir,
metadata,
baseBranch,
swapCount // Preserve existing count instead of resetting
swapCount, // Preserve existing count instead of resetting
projectId
});
}
@@ -464,7 +470,8 @@ export class AgentManager extends EventEmitter {
context.taskDescription!,
context.specDir,
context.metadata,
context.baseBranch
context.baseBranch,
context.projectId
);
} else {
console.log('[AgentManager] Restarting as task execution');
@@ -472,7 +479,8 @@ export class AgentManager extends EventEmitter {
taskId,
context.projectPath,
context.specId,
context.options
context.options,
context.projectId
);
}
}, 500);
+14 -13
View File
@@ -510,7 +510,8 @@ export class AgentProcessManager {
cwd: string,
args: string[],
extraEnv: Record<string, string> = {},
processType: ProcessType = 'task-execution'
processType: ProcessType = 'task-execution',
projectId?: string
): Promise<void> {
const isSpecRunner = processType === 'spec-creation';
this.killProcess(taskId);
@@ -562,7 +563,7 @@ export class AgentProcessManager {
// spawn() failed synchronously (e.g., command not found, permission denied)
// Clean up tracking entry and propagate error
this.state.deleteProcess(taskId);
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err));
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err), projectId);
throw err;
}
@@ -609,7 +610,7 @@ export class AgentProcessManager {
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...',
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
const isDebug = ['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '');
@@ -629,7 +630,7 @@ export class AgentProcessManager {
const taskEvent = parseTaskEvent(line);
if (taskEvent) {
console.log(`[AgentProcess:${taskId}] Parsed task event:`, taskEvent.type, taskEvent);
this.emitter.emit('task-event', taskId, taskEvent);
this.emitter.emit('task-event', taskId, taskEvent, projectId);
}
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
@@ -689,7 +690,7 @@ export class AgentProcessManager {
message: lastMessage,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
}
};
@@ -709,7 +710,7 @@ export class AgentProcessManager {
for (const line of lines) {
if (line.trim()) {
this.emitter.emit('log', taskId, line + '\n');
this.emitter.emit('log', taskId, line + '\n', projectId);
processLog(line);
if (isDebug) {
console.log(`[Agent:${taskId}] ${line}`);
@@ -730,11 +731,11 @@ export class AgentProcessManager {
childProcess.on('exit', (code: number | null) => {
if (stdoutBuffer.trim()) {
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
this.emitter.emit('log', taskId, stdoutBuffer + '\n', projectId);
processLog(stdoutBuffer);
}
if (stderrBuffer.trim()) {
this.emitter.emit('log', taskId, stderrBuffer + '\n');
this.emitter.emit('log', taskId, stderrBuffer + '\n', projectId);
processLog(stderrBuffer);
}
@@ -749,7 +750,7 @@ export class AgentProcessManager {
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
if (wasHandled) {
this.emitter.emit('exit', taskId, code, processType);
this.emitter.emit('exit', taskId, code, processType, projectId);
return;
}
}
@@ -762,10 +763,10 @@ export class AgentProcessManager {
message: `Process exited with code ${code}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
}
this.emitter.emit('exit', taskId, code, processType);
this.emitter.emit('exit', taskId, code, processType, projectId);
});
// Handle process error
@@ -780,9 +781,9 @@ export class AgentProcessManager {
message: `Error: ${err.message}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}, projectId);
this.emitter.emit('error', taskId, err.message);
this.emitter.emit('error', taskId, err.message, projectId);
});
}
+6 -1
View File
@@ -417,7 +417,12 @@ export class AgentQueueManager {
// Track completed types for progress calculation
const completedTypes = new Set<string>();
const totalTypes = 7; // Default all types
// Derive totalTypes from --types argument instead of hardcoding
const typesArgIndex = args.findIndex((arg) => arg === '--types');
const totalTypes =
typesArgIndex > -1 && args[typesArgIndex + 1]
? args[typesArgIndex + 1].split(',').length
: 6; // Default to 6 if not specified
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
childProcess.stdout?.on('data', (data: Buffer) => {
+7 -5
View File
@@ -31,11 +31,11 @@ export interface ExecutionProgressData {
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
export interface AgentManagerEvents {
log: (taskId: string, log: string) => void;
error: (taskId: string, error: string) => void;
exit: (taskId: string, code: number | null, processType: ProcessType) => void;
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
'task-event': (taskId: string, event: TaskEventPayload) => void;
log: (taskId: string, log: string, projectId?: string) => void;
error: (taskId: string, error: string, projectId?: string) => void;
exit: (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => void;
'execution-progress': (taskId: string, progress: ExecutionProgressData, projectId?: string) => void;
'task-event': (taskId: string, event: TaskEventPayload, projectId?: string) => void;
}
// IdeationConfig now imported from shared types to maintain consistency
@@ -50,6 +50,7 @@ export interface TaskExecutionOptions {
workers?: number;
baseBranch?: string;
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
}
export interface SpecCreationMetadata {
@@ -73,6 +74,7 @@ export interface SpecCreationMetadata {
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
// Workspace mode - whether to use worktree isolation
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
}
export interface IdeationProgressData {
@@ -43,7 +43,7 @@ import {
shouldProactivelySwitch as shouldProactivelySwitchImpl,
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
} from './claude-profile/profile-scorer';
import { getCredentialsFromKeychain, normalizeWindowsPath } from './claude-profile/credential-utils';
import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils';
import {
CLAUDE_PROFILES_DIR,
generateProfileId as generateProfileIdImpl,
@@ -54,6 +54,24 @@ import {
getEmailFromConfigDir
} from './claude-profile/profile-utils';
/**
* Debug flag - only log verbose profile operations when DEBUG=true
*/
const IS_DEBUG = process.env.DEBUG === 'true';
/**
* Debug log helper - only logs when DEBUG=true
*/
function debugLog(message: string, data?: unknown): void {
if (IS_DEBUG) {
if (data !== undefined) {
console.warn(message, data);
} else {
console.warn(message);
}
}
}
/**
* Manages Claude Code profiles for multi-account support.
* Profiles are stored in the app's userData directory.
@@ -96,6 +114,10 @@ export class ClaudeProfileManager {
// This repairs emails that were truncated due to ANSI escape codes in terminal output
this.migrateCorruptedEmails();
// Populate missing subscription metadata for existing profiles
// This reads subscriptionType and rateLimitTier from Keychain credentials
this.populateSubscriptionMetadata();
this.initialized = true;
}
@@ -117,7 +139,7 @@ export class ClaudeProfileManager {
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail && profile.email !== configEmail) {
console.warn('[ClaudeProfileManager] Migrating corrupted email for profile:', {
debugLog('[ClaudeProfileManager] Migrating corrupted email for profile:', {
profileId: profile.id,
oldEmail: profile.email,
newEmail: configEmail
@@ -129,7 +151,59 @@ export class ClaudeProfileManager {
if (needsSave) {
this.save();
console.warn('[ClaudeProfileManager] Email migration complete');
debugLog('[ClaudeProfileManager] Email migration complete');
}
}
/**
* Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles.
*
* This reads from Keychain credentials and updates profiles that don't have this metadata.
* Runs on initialization to ensure existing profiles get the subscription info for UI display.
*/
private populateSubscriptionMetadata(): void {
let needsSave = false;
for (const profile of this.data.profiles) {
if (!profile.configDir) {
continue;
}
// Skip if profile already has subscription metadata
if (profile.subscriptionType && profile.rateLimitTier) {
continue;
}
// Expand ~ to home directory
const expandedConfigDir = normalizeWindowsPath(
profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, homedir())
: profile.configDir
);
// Use helper with onlyIfMissing option to preserve existing values
const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true });
if (result.subscriptionTypeUpdated) {
needsSave = true;
debugLog('[ClaudeProfileManager] Populated subscriptionType for profile:', {
profileId: profile.id,
subscriptionType: result.subscriptionType
});
}
if (result.rateLimitTierUpdated) {
needsSave = true;
debugLog('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
profileId: profile.id,
rateLimitTier: result.rateLimitTier
});
}
}
if (needsSave) {
this.save();
debugLog('[ClaudeProfileManager] Subscription metadata population complete');
}
}
@@ -147,7 +221,7 @@ export class ClaudeProfileManager {
const loadedData = loadProfileStore(this.storePath);
if (loadedData) {
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Loaded profiles:', {
debugLog('[ClaudeProfileManager] Loaded profiles:', {
count: loadedData.profiles.length,
activeProfileId: loadedData.activeProfileId,
profiles: loadedData.profiles.map(p => ({
@@ -273,19 +347,12 @@ export class ClaudeProfileManager {
// Fallback to default
const defaultProfile = this.data.profiles.find(p => p.isDefault);
if (defaultProfile) {
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile - using default:', {
id: defaultProfile.id,
name: defaultProfile.name,
email: defaultProfile.email
});
}
return defaultProfile;
}
// If somehow no default exists, return first profile
const fallback = this.data.profiles[0];
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile - using fallback:', {
debugLog('[ClaudeProfileManager] getActiveProfile - using fallback:', {
id: fallback.id,
name: fallback.name,
email: fallback.email
@@ -295,7 +362,7 @@ export class ClaudeProfileManager {
}
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile:', {
debugLog('[ClaudeProfileManager] getActiveProfile:', {
id: active.id,
name: active.name,
email: active.email
@@ -386,12 +453,12 @@ export class ClaudeProfileManager {
const previousProfileId = this.data.activeProfileId;
const profile = this.getProfile(profileId);
if (!profile) {
console.warn('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
debugLog('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
return false;
}
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] setActiveProfile:', {
debugLog('[ClaudeProfileManager] setActiveProfile:', {
from: previousProfileId,
to: profileId,
profileName: profile.name
@@ -506,10 +573,10 @@ export class ClaudeProfileManager {
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
debugLog('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', { profileName: profile.name, configDir: expandedConfigDir });
}
} else {
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
debugLog('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
}
return env;
@@ -729,7 +796,7 @@ export class ClaudeProfileManager {
);
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getProfileEnv:', {
debugLog('[ClaudeProfileManager] getProfileEnv:', {
profileId,
profileName: profile.name,
isDefault: profile.isDefault,
@@ -750,7 +817,7 @@ export class ClaudeProfileManager {
if (credentials.token) {
env.CLAUDE_CODE_OAUTH_TOKEN = credentials.token;
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
debugLog('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
}
}
} catch (error) {
@@ -806,7 +873,7 @@ export class ClaudeProfileManager {
}
this.save();
console.warn('[ClaudeProfileManager] Cleared migrated profile:', profileId);
debugLog('[ClaudeProfileManager] Cleared migrated profile:', profileId);
}
/**
@@ -36,6 +36,25 @@ function getTokenFingerprint(token: string | null | undefined): string {
return token.slice(0, 8) + '...' + token.slice(-4);
}
/**
* Debug flag - only log verbose credential operations when DEBUG=true
*/
const IS_DEBUG = process.env.DEBUG === 'true';
/**
* Debug log helper - only logs when DEBUG=true
* Uses console.warn to ensure visibility in Electron's main process
*/
function debugLog(message: string, ...args: unknown[]): void {
if (IS_DEBUG) {
if (args.length > 0) {
console.warn(message, ...args);
} else {
console.warn(message);
}
}
}
/**
* Escape a string for safe interpolation into PowerShell double-quoted strings.
* Escapes all PowerShell special characters to prevent injection attacks.
@@ -193,7 +212,7 @@ export function getKeychainServiceName(configDir?: string): string {
// No configDir provided - this should not happen with isolated profiles
// Fall back to unhashed name for backwards compatibility during migration
if (!configDir) {
console.warn('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
debugLog('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
return 'Claude Code-credentials';
}
@@ -396,13 +415,13 @@ function parseCredentialJson<T extends PlatformCredentials>(
try {
data = JSON.parse(credentialsJson);
} catch {
console.warn(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
debugLog(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
return extractFn({}) as T;
}
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
debugLog(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
return extractFn({}) as T;
}
@@ -439,7 +458,7 @@ function getCredentialsFromFile(
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
debugLog(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
credentialsPath,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
@@ -453,7 +472,7 @@ function getCredentialsFromFile(
// Defense-in-depth: Validate credentials path is within expected boundaries
if (!isValidCredentialsPath(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
}
const invalidResult = { token: null, email: null, error: 'Invalid credentials path' };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
@@ -463,7 +482,7 @@ function getCredentialsFromFile(
// Check if credentials file exists
if (!existsSync(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
}
const notFoundResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
@@ -478,7 +497,7 @@ function getCredentialsFromFile(
try {
data = JSON.parse(content);
} catch {
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
const errorResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
@@ -486,7 +505,7 @@ function getCredentialsFromFile(
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
const invalidResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
return invalidResult;
@@ -496,7 +515,7 @@ function getCredentialsFromFile(
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -506,7 +525,7 @@ function getCredentialsFromFile(
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
debugLog(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
@@ -516,7 +535,7 @@ function getCredentialsFromFile(
return credentials;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
debugLog(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
@@ -540,7 +559,7 @@ function getFullCredentialsFromFile(
// Defense-in-depth: Validate credentials path is within expected boundaries
if (!isValidCredentialsPath(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
}
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credentials path' };
}
@@ -548,7 +567,7 @@ function getFullCredentialsFromFile(
// Check if credentials file exists
if (!existsSync(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
}
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
}
@@ -561,13 +580,13 @@ function getFullCredentialsFromFile(
try {
data = JSON.parse(content);
} catch {
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
}
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
}
@@ -575,12 +594,12 @@ function getFullCredentialsFromFile(
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
debugLog(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
hasToken: !!token,
hasEmail: !!email,
hasRefreshToken: !!refreshToken,
@@ -593,7 +612,7 @@ function getFullCredentialsFromFile(
return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
debugLog(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Failed to read credentials: ${errorMessage}` };
}
}
@@ -616,15 +635,6 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
if (!forceRefresh && cached) {
const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS;
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:macOS:CACHE] Returning cached credentials:', {
serviceName,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
cacheAge: Math.round(cacheAge / 1000) + 's'
});
}
return cached.credentials;
}
}
@@ -664,7 +674,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
debugLog('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -674,7 +684,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
debugLog('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
@@ -685,7 +695,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
debugLog('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` };
// Use shorter TTL for errors
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
@@ -756,7 +766,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
debugLog('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
attribute,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
@@ -771,7 +781,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
const secretToolPath = findSecretToolPath();
if (!secretToolPath) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage');
debugLog('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage');
}
// Return a special result indicating Secret Service is unavailable
return { token: null, email: null, error: 'secret-tool not found' };
@@ -795,7 +805,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
debugLog('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -805,7 +815,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
debugLog('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
attribute,
hasToken: !!token,
hasEmail: !!email,
@@ -817,7 +827,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage);
debugLog('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage);
// Return error to trigger fallback to file storage
return { token: null, email: null, error: `Secret Service access failed: ${errorMessage}` };
}
@@ -840,7 +850,7 @@ function getCredentialsFromLinux(configDir?: string, forceRefresh = false): Plat
// If Secret Service had an error (not just "not found"), log it and try file fallback
if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) {
if (isDebug) {
console.warn('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
debugLog('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
}
}
@@ -894,7 +904,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
debugLog('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
targetName,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
@@ -910,7 +920,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
const invalidResult = { token: null, email: null, error: 'Invalid credential target name format' };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
debugLog('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
}
return invalidResult;
}
@@ -1017,7 +1027,7 @@ public static extern bool CredFree(IntPtr cred);
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Windows] Invalid token format for target:', targetName);
debugLog('[CredentialUtils:Windows] Invalid token format for target:', targetName);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -1027,7 +1037,7 @@ public static extern bool CredFree(IntPtr cred);
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
debugLog('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
@@ -1037,7 +1047,7 @@ public static extern bool CredFree(IntPtr cred);
return credentials;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
debugLog('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
const errorResult = { token: null, email: null, error: `Credential Manager access failed: ${errorMessage}` };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
@@ -1102,13 +1112,13 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
// If only one has a token, use that one
if (fileResult.token && !credManagerResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
debugLog('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
}
return fileResult;
}
if (credManagerResult.token && !fileResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
debugLog('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
}
return credManagerResult;
}
@@ -1120,7 +1130,7 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
// Both have tokens - prefer file since Claude CLI writes there after login
if (isDebug) {
console.warn('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
debugLog('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
}
return fileResult;
}
@@ -1242,12 +1252,12 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
debugLog('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
debugLog('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
hasToken: !!token,
hasEmail: !!email,
hasRefreshToken: !!refreshToken,
@@ -1261,7 +1271,7 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage);
debugLog('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Keychain access failed: ${errorMessage}` };
}
}
@@ -1277,7 +1287,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
const secretToolPath = findSecretToolPath();
if (!secretToolPath) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Full] secret-tool not found');
debugLog('[CredentialUtils:Linux:SecretService:Full] secret-tool not found');
}
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'secret-tool not found' };
}
@@ -1299,12 +1309,12 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
);
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
debugLog('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
debugLog('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
attribute,
hasToken: !!token,
hasEmail: !!email,
@@ -1319,7 +1329,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage);
debugLog('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Secret Service access failed: ${errorMessage}` };
}
}
@@ -1339,7 +1349,7 @@ function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials {
if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
debugLog('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
}
}
@@ -1366,7 +1376,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful
if (!isValidTargetName(targetName)) {
const invalidResult = { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credential target name format' };
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
debugLog('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
}
return invalidResult;
}
@@ -1461,12 +1471,12 @@ public static extern bool CredFree(IntPtr cred);
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
debugLog('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
debugLog('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
hasToken: !!token,
hasEmail: !!email,
hasRefreshToken: !!refreshToken,
@@ -1479,7 +1489,7 @@ public static extern bool CredFree(IntPtr cred);
return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage);
debugLog('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Credential Manager access failed: ${errorMessage}` };
}
}
@@ -1508,13 +1518,13 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
// If only one has a token, use that one
if (fileResult.token && !credManagerResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
debugLog('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
}
return fileResult;
}
if (credManagerResult.token && !fileResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
debugLog('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
}
return credManagerResult;
}
@@ -1529,7 +1539,7 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
// Using file as primary ensures consistency: the same token is returned whether
// calling getCredentialsFromKeychain() or getFullCredentialsFromKeychain().
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
debugLog('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
}
return fileResult;
}
@@ -1633,12 +1643,12 @@ function updateMacOSKeychainCredentials(
}
);
if (isDebug) {
console.warn('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
debugLog('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
}
} catch {
// Entry didn't exist - that's fine, we'll create it
if (isDebug) {
console.warn('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
debugLog('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
}
}
@@ -1656,7 +1666,7 @@ function updateMacOSKeychainCredentials(
);
if (isDebug) {
console.warn('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
debugLog('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
}
// Clear cached credentials to ensure fresh values are read
@@ -1689,7 +1699,7 @@ function updateLinuxSecretServiceCredentials(
const secretToolPath = findSecretToolPath();
if (!secretToolPath) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
debugLog('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
}
return { success: false, error: 'secret-tool not found' };
}
@@ -1730,7 +1740,7 @@ function updateLinuxSecretServiceCredentials(
);
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
debugLog('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
}
// Clear cached credentials to ensure fresh values are read
@@ -1766,7 +1776,7 @@ function updateLinuxCredentials(
return secretServiceResult;
}
if (isDebug) {
console.warn('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
debugLog('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
}
}
@@ -1827,7 +1837,7 @@ function updateLinuxFileCredentials(
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
if (isDebug) {
console.warn('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
debugLog('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
}
// Clear cached credentials to ensure fresh values are read
@@ -1966,7 +1976,7 @@ function updateWindowsCredentialManagerCredentials(
}
if (isDebug) {
console.warn('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
debugLog('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
}
// Clear cached credentials to ensure fresh values are read
@@ -2009,13 +2019,13 @@ function restrictWindowsFilePermissions(filePath: string): void {
});
if (isDebug) {
console.warn('[CredentialUtils:Windows] Set restrictive permissions on:', filePath);
debugLog('[CredentialUtils:Windows] Set restrictive permissions on:', filePath);
}
} catch (error) {
// Non-fatal: log warning but don't fail the operation
// The file is still protected by the user's home directory permissions
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
debugLog('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
}
}
@@ -2105,7 +2115,7 @@ function updateWindowsFileCredentials(
}
if (isDebug) {
console.warn('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
debugLog('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
}
// Clear cached credentials to ensure fresh values are read
@@ -2162,7 +2172,7 @@ function updateWindowsCredentials(
// Credential Manager failed but file succeeded - this is acceptable
// Claude CLI will use the file, which has the latest tokens
if (isDebug) {
console.warn('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
debugLog('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
}
}
}
@@ -2205,3 +2215,101 @@ export function updateKeychainCredentials(
return { success: false, error: `Unsupported platform: ${process.platform}` };
}
// =============================================================================
// Profile Subscription Metadata Helper
// =============================================================================
/**
* Result of updating profile subscription metadata
*/
export interface UpdateSubscriptionMetadataResult {
/** Whether subscriptionType was updated */
subscriptionTypeUpdated: boolean;
/** Whether rateLimitTier was updated */
rateLimitTierUpdated: boolean;
/** The subscriptionType value (if found) */
subscriptionType?: string | null;
/** The rateLimitTier value (if found) */
rateLimitTier?: string | null;
}
/**
* Options for updateProfileSubscriptionMetadata
*/
export interface UpdateSubscriptionMetadataOptions {
/**
* If true, only update fields that are currently missing (undefined/null/empty).
* This is useful for migration/initialization code that should not overwrite existing values.
* Default: false (always update if credentials have values)
*/
onlyIfMissing?: boolean;
}
/**
* Update a profile's subscription metadata (subscriptionType, rateLimitTier) from Keychain credentials.
*
* This helper centralizes the common pattern of reading subscription info from Keychain
* and updating a profile object. It's used after OAuth login, onboarding completion,
* and profile authentication verification.
*
* NOTE: This function mutates the profile object directly. The caller is responsible
* for saving the profile after calling this function.
*
* @param profile - The profile object to update (must have subscriptionType and rateLimitTier properties)
* @param configDirOrCredentials - Either a config directory path to read credentials from,
* or pre-fetched FullOAuthCredentials to avoid redundant reads
* @param options - Optional settings like onlyIfMissing
* @returns Information about what was updated
*
* @example
* ```typescript
* // Option 1: Pass configDir - helper fetches credentials
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir);
*
* // Option 2: Pass pre-fetched credentials (more efficient when already fetched)
* const fullCreds = getFullCredentialsFromKeychain(profile.configDir);
* const result = updateProfileSubscriptionMetadata(profile, fullCreds);
*
* // Option 3: Only populate if missing (for migration/initialization)
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir, { onlyIfMissing: true });
*
* if (result.subscriptionTypeUpdated || result.rateLimitTierUpdated) {
* profileManager.saveProfile(profile);
* }
* ```
*/
export function updateProfileSubscriptionMetadata(
profile: { subscriptionType?: string | null; rateLimitTier?: string | null },
configDirOrCredentials: string | undefined | FullOAuthCredentials,
options?: UpdateSubscriptionMetadataOptions
): UpdateSubscriptionMetadataResult {
const result: UpdateSubscriptionMetadataResult = {
subscriptionTypeUpdated: false,
rateLimitTierUpdated: false,
};
const onlyIfMissing = options?.onlyIfMissing ?? false;
// Determine if we received pre-fetched credentials or a configDir
const fullCreds: FullOAuthCredentials =
typeof configDirOrCredentials === 'object' && configDirOrCredentials !== null
? configDirOrCredentials
: getFullCredentialsFromKeychain(configDirOrCredentials);
// Update subscriptionType if credentials have it and (not onlyIfMissing OR profile doesn't have it)
if (fullCreds.subscriptionType && (!onlyIfMissing || !profile.subscriptionType)) {
profile.subscriptionType = fullCreds.subscriptionType;
result.subscriptionTypeUpdated = true;
result.subscriptionType = fullCreds.subscriptionType;
}
// Update rateLimitTier if credentials have it and (not onlyIfMissing OR profile doesn't have it)
if (fullCreds.rateLimitTier && (!onlyIfMissing || !profile.rateLimitTier)) {
profile.rateLimitTier = fullCreds.rateLimitTier;
result.rateLimitTierUpdated = true;
result.rateLimitTier = fullCreds.rateLimitTier;
}
return result;
}
@@ -319,12 +319,6 @@ export async function ensureValidToken(
? configDir.replace(/^~/, homedir())
: configDir;
if (isDebug) {
console.warn('[TokenRefresh:ensureValidToken] Checking token validity', {
configDir: expandedConfigDir || 'default'
});
}
// Step 1: Read full credentials from keychain
const creds = getFullCredentialsFromKeychain(expandedConfigDir);
@@ -90,60 +90,22 @@ const PROVIDER_USAGE_ENDPOINTS: readonly ProviderUsageEndpoint[] = [
export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string | null {
const isDebug = process.env.DEBUG === 'true';
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Constructing usage endpoint:', {
provider,
baseUrl
});
}
const endpointConfig = PROVIDER_USAGE_ENDPOINTS.find(e => e.provider === provider);
if (!endpointConfig) {
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Unknown provider - no endpoint configured:', {
provider,
availableProviders: PROVIDER_USAGE_ENDPOINTS.map(e => e.provider)
});
}
return null;
}
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Found endpoint config for provider:', {
provider,
usagePath: endpointConfig.usagePath
});
}
try {
const url = new URL(baseUrl);
const originalPath = url.pathname;
// Replace the path with the usage endpoint path
url.pathname = endpointConfig.usagePath;
// Note: quota/limit endpoint doesn't require query parameters
// The model-usage and tool-usage endpoints would need time windows, but we're using quota/limit
const finalUrl = url.toString();
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Successfully constructed endpoint:', {
provider,
originalPath,
newPath: endpointConfig.usagePath,
finalUrl
});
}
return finalUrl;
return url.toString();
} catch (error) {
console.error('[UsageMonitor] Invalid baseUrl for usage endpoint:', baseUrl);
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] URL construction failed:', {
baseUrl,
error: error instanceof Error ? error.message : String(error)
});
}
return null;
}
}
@@ -163,18 +125,7 @@ export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string
*/
export function detectProvider(baseUrl: string): ApiProvider {
// Wrapper around shared detectProvider with debug logging for main process
const isDebug = process.env.DEBUG === 'true';
const provider = sharedDetectProvider(baseUrl);
if (isDebug) {
console.warn('[UsageMonitor:PROVIDER_DETECTION] Detected provider:', {
baseUrl,
provider
});
}
return provider;
return sharedDetectProvider(baseUrl);
}
/**
@@ -844,18 +795,12 @@ export class UsageMonitor extends EventEmitter {
// Fallback: Try direct keychain read (e.g., if refresh token unavailable)
const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir);
if (keychainCreds.token) {
this.debugLog('[UsageMonitor:TRACE] Using fallback OAuth token from Keychain for profile: ' + activeProfile.name, {
tokenFingerprint: getCredentialFingerprint(keychainCreds.token)
});
return keychainCreds.token;
}
// Keychain read also failed
if (keychainCreds.error) {
this.debugLog('[UsageMonitor] Keychain access failed:', keychainCreds.error);
} else {
this.debugLog('[UsageMonitor:TRACE] No token in Keychain for profile: ' + activeProfile.name +
' - user may need to re-authenticate with claude /login');
}
// Mark profile as needing re-authentication since credentials are missing
@@ -863,7 +808,6 @@ export class UsageMonitor extends EventEmitter {
}
// No credential available
this.debugLog('[UsageMonitor:TRACE] No credential available (no API or OAuth profile active)');
return undefined;
}
@@ -0,0 +1,113 @@
/**
* Tests for the XState settled-state guard logic used in agent-events-handlers.
*
* The guard prevents execution-progress events from overwriting XState's
* persisted status when the state machine has already settled into a
* terminal/review state.
*/
import { describe, it, expect } from 'vitest';
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, TASK_STATE_NAMES } from '../../../shared/state-machines';
describe('XSTATE_SETTLED_STATES', () => {
it('should contain the expected settled states', () => {
expect(XSTATE_SETTLED_STATES.has('plan_review')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('human_review')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('error')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('creating_pr')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('pr_created')).toBe(true);
expect(XSTATE_SETTLED_STATES.has('done')).toBe(true);
});
it('should NOT contain active processing states', () => {
expect(XSTATE_SETTLED_STATES.has('backlog')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('planning')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('coding')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('qa_review')).toBe(false);
expect(XSTATE_SETTLED_STATES.has('qa_fixing')).toBe(false);
});
it('should only contain valid task state names', () => {
const validNames = new Set(TASK_STATE_NAMES);
for (const state of XSTATE_SETTLED_STATES) {
expect(validNames.has(state as typeof TASK_STATE_NAMES[number])).toBe(true);
}
});
});
describe('settled state guard behavior', () => {
/**
* Simulates the guard logic from agent-events-handlers execution-progress handler.
* Returns true if the event should be blocked (XState is in a settled state).
*/
function shouldBlockExecutionProgress(currentXState: string | undefined): boolean {
return !!(currentXState && XSTATE_SETTLED_STATES.has(currentXState));
}
it('should block execution-progress when XState is in plan_review', () => {
// After PLANNING_COMPLETE with requireReviewBeforeCoding=true,
// process exits with code 1 emitting phase='failed' — must be blocked
expect(shouldBlockExecutionProgress('plan_review')).toBe(true);
});
it('should block execution-progress when XState is in human_review', () => {
// After QA_PASSED, any stale events from the dying process must be blocked
expect(shouldBlockExecutionProgress('human_review')).toBe(true);
});
it('should block execution-progress when XState is in error', () => {
// After PLANNING_FAILED/CODING_FAILED, stale events must not overwrite error status
expect(shouldBlockExecutionProgress('error')).toBe(true);
});
it('should block execution-progress when XState is in done', () => {
expect(shouldBlockExecutionProgress('done')).toBe(true);
});
it('should allow execution-progress when XState is in planning', () => {
expect(shouldBlockExecutionProgress('planning')).toBe(false);
});
it('should allow execution-progress when XState is in coding', () => {
// After USER_RESUMED from error, XState transitions to coding synchronously.
// New agent events should flow through normally.
expect(shouldBlockExecutionProgress('coding')).toBe(false);
});
it('should allow execution-progress when XState is in qa_review', () => {
expect(shouldBlockExecutionProgress('qa_review')).toBe(false);
});
it('should allow execution-progress when no XState actor exists', () => {
// No actor yet (first event for this task) — must not block
expect(shouldBlockExecutionProgress(undefined)).toBe(false);
});
});
describe('XSTATE_TO_PHASE', () => {
it('should have a mapping for every task state', () => {
for (const state of TASK_STATE_NAMES) {
expect(XSTATE_TO_PHASE[state]).toBeDefined();
}
});
it('should map settled states to non-active phases', () => {
// Settled states should map to phases that indicate completion or stoppage
expect(XSTATE_TO_PHASE['plan_review']).toBe('planning');
expect(XSTATE_TO_PHASE['human_review']).toBe('complete');
expect(XSTATE_TO_PHASE['error']).toBe('failed');
expect(XSTATE_TO_PHASE['done']).toBe('complete');
expect(XSTATE_TO_PHASE['pr_created']).toBe('complete');
expect(XSTATE_TO_PHASE['creating_pr']).toBe('complete');
});
it('should map active states to processing phases', () => {
expect(XSTATE_TO_PHASE['planning']).toBe('planning');
expect(XSTATE_TO_PHASE['coding']).toBe('coding');
expect(XSTATE_TO_PHASE['qa_review']).toBe('qa_review');
expect(XSTATE_TO_PHASE['qa_fixing']).toBe('qa_fixing');
});
it('should return undefined for unknown states', () => {
expect(XSTATE_TO_PHASE['nonexistent']).toBeUndefined();
});
});
@@ -7,12 +7,13 @@ import type {
AuthFailureInfo,
ImplementationPlan,
} from "../../shared/types";
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, mapStateToLegacy } from "../../shared/state-machines";
import { AgentManager } from "../agent";
import type { ProcessType, ExecutionProgressData } from "../agent";
import { titleGenerator } from "../title-generator";
import { fileWatcher } from "../file-watcher";
import { notificationService } from "../notification-service";
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync } from "./task/plan-file-utils";
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync, persistPlanStatusAndReasonSync } from "./task/plan-file-utils";
import { findTaskWorktree } from "../worktree-paths";
import { findTaskAndProject } from "./task/shared";
import { safeSendToRenderer } from "./utils";
@@ -32,16 +33,22 @@ export function registerAgenteventsHandlers(
// Agent Manager Events → Renderer
// ============================================
agentManager.on("log", (taskId: string, log: string) => {
// Include projectId for multi-project filtering (issue #723)
const { project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, project?.id);
agentManager.on("log", (taskId: string, log: string, projectId?: string) => {
// Use projectId from event when available; fall back to lookup for backward compatibility
if (!projectId) {
const { project } = findTaskAndProject(taskId);
projectId = project?.id;
}
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, projectId);
});
agentManager.on("error", (taskId: string, error: string) => {
// Include projectId for multi-project filtering (issue #723)
const { project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
agentManager.on("error", (taskId: string, error: string, projectId?: string) => {
// Use projectId from event when available; fall back to lookup for backward compatibility
if (!projectId) {
const { project } = findTaskAndProject(taskId);
projectId = project?.id;
}
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, projectId);
});
// Handle SDK rate limit events from agent manager
@@ -82,10 +89,10 @@ export function registerAgenteventsHandlers(
safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
});
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => {
// Get task + project for context and multi-project filtering (issue #723)
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId);
const exitProjectId = exitProject?.id;
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => {
// Use projectId from event to scope the lookup (prevents cross-project contamination)
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId, projectId);
const exitProjectId = exitProject?.id || projectId;
taskStateManager.handleProcessExited(taskId, code, exitTask, exitProject);
@@ -109,7 +116,7 @@ export function registerAgenteventsHandlers(
return;
}
const { task, project } = findTaskAndProject(taskId);
const { task, project } = findTaskAndProject(taskId, projectId);
if (!task || !project) return;
const taskTitle = task.title || task.specId;
@@ -120,11 +127,11 @@ export function registerAgenteventsHandlers(
}
});
agentManager.on("task-event", (taskId: string, event) => {
console.log(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
agentManager.on("task-event", (taskId: string, event, projectId?: string) => {
console.debug(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
if (taskStateManager.getLastSequence(taskId) === undefined) {
const { task, project } = findTaskAndProject(taskId);
const { task, project } = findTaskAndProject(taskId, projectId);
if (task && project) {
try {
const planPath = getPlanPath(project, task);
@@ -140,20 +147,20 @@ export function registerAgenteventsHandlers(
}
}
const { task, project } = findTaskAndProject(taskId);
const { task, project } = findTaskAndProject(taskId, projectId);
if (!task || !project) {
console.log(`[agent-events-handlers] No task/project found for ${taskId}`);
console.debug(`[agent-events-handlers] No task/project found for ${taskId}`);
return;
}
console.log(`[agent-events-handlers] Task state before handleTaskEvent:`, {
console.debug(`[agent-events-handlers] Task state before handleTaskEvent:`, {
status: task.status,
reviewReason: task.reviewReason,
phase: task.executionProgress?.phase
});
const accepted = taskStateManager.handleTaskEvent(taskId, event, task, project);
console.log(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
console.debug(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
if (!accepted) {
return;
}
@@ -176,14 +183,27 @@ export function registerAgenteventsHandlers(
}
});
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData) => {
// Use shared helper to find task and project (issue #723 - deduplicate lookup)
const { task, project } = findTaskAndProject(taskId);
const taskProjectId = project?.id;
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData, projectId?: string) => {
// Use projectId from event to scope the lookup (prevents cross-project contamination)
const { task, project } = findTaskAndProject(taskId, projectId);
const taskProjectId = project?.id || projectId;
// Check if XState has already established a terminal/review state for this task.
// XState is the source of truth for status. When XState is in a terminal state
// (e.g., plan_review after PLANNING_COMPLETE), execution-progress events from the
// agent process are stale and must not overwrite XState's persisted status.
//
// Example: When requireReviewBeforeCoding=true, the process exits with code 1 after
// PLANNING_COMPLETE. The exit handler emits execution-progress with phase='failed',
// which would incorrectly overwrite status='human_review' with status='error' via
// persistPlanPhaseSync, and send a 'failed' phase to the renderer overwriting the
// 'planning' phase that XState already emitted via emitPhaseFromState.
const currentXState = taskStateManager.getCurrentState(taskId);
const xstateInTerminalState = currentXState && XSTATE_SETTLED_STATES.has(currentXState);
// Persist phase to plan file for restoration on app refresh
// Must persist to BOTH main project and worktree (if exists) since task may be loaded from either
if (task && project && progress.phase) {
if (task && project && progress.phase && !xstateInTerminalState) {
const mainPlanPath = getPlanPath(project, task);
persistPlanPhaseSync(mainPlanPath, progress.phase, project.id);
@@ -201,9 +221,16 @@ export function registerAgenteventsHandlers(
persistPlanPhaseSync(worktreePlanPath, progress.phase, project.id);
}
}
} else if (xstateInTerminalState && progress.phase) {
console.debug(`[agent-events-handlers] Skipping persistPlanPhaseSync for ${taskId}: XState in '${currentXState}', not overwriting with phase '${progress.phase}'`);
}
// Include projectId in execution progress event for multi-project filtering
// Skip sending execution-progress to renderer when XState has settled.
// XState's emitPhaseFromState already sent the correct phase to the renderer.
if (xstateInTerminalState) {
console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`);
return;
}
safeSendToRenderer(
getMainWindow,
IPC_CHANNELS.TASK_EXECUTION_PROGRESS,
@@ -218,13 +245,42 @@ export function registerAgenteventsHandlers(
// ============================================
fileWatcher.on("progress", (taskId: string, plan: ImplementationPlan) => {
// Use shared helper to find project (issue #723 - deduplicate lookup)
const { project } = findTaskAndProject(taskId);
// File watcher events don't carry projectId — fall back to lookup
const { task, project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_PROGRESS, taskId, plan, project?.id);
// Re-stamp XState status fields if the backend overwrote the plan file without them.
// The planner agent writes implementation_plan.json via the Write tool, which replaces
// the entire file and strips the frontend's status/xstateState/executionPhase fields.
// This causes tasks to snap back to backlog on refresh.
const planWithStatus = plan as { xstateState?: string; executionPhase?: string; status?: string };
const currentXState = taskStateManager.getCurrentState(taskId);
if (currentXState && !planWithStatus.xstateState && task && project) {
console.debug(`[agent-events-handlers] Re-stamping XState status on plan file for ${taskId} (state: ${currentXState})`);
const mainPlanPath = getPlanPath(project, task);
const { status, reviewReason } = mapStateToLegacy(currentXState);
const phase = XSTATE_TO_PHASE[currentXState] || 'idle';
persistPlanStatusAndReasonSync(mainPlanPath, status, reviewReason, project.id, currentXState, phase);
// Also re-stamp worktree copy if it exists
const worktreePath = findTaskWorktree(project.path, task.specId);
if (worktreePath) {
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const worktreePlanPath = path.join(
worktreePath,
specsBaseDir,
task.specId,
AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN
);
if (existsSync(worktreePlanPath)) {
persistPlanStatusAndReasonSync(worktreePlanPath, status, reviewReason, project.id, currentXState, phase);
}
}
}
});
fileWatcher.on("error", (taskId: string, error: string) => {
// Include projectId for multi-project filtering (issue #723)
// File watcher events don't carry projectId — fall back to lookup
const { project } = findTaskAndProject(taskId);
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
});
@@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths';
import { isWindows, isMacOS, isLinux } from '../platform';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { isValidConfigDir } from '../utils/config-path-validator';
import { clearKeychainCache, getCredentialsFromKeychain } from '../claude-profile/credential-utils';
import { clearKeychainCache, getCredentialsFromKeychain, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
import { getUsageMonitor } from '../claude-profile/usage-monitor';
import semver from 'semver';
@@ -1370,7 +1370,7 @@ export function registerClaudeCodeHandlers(): void {
}
}
// If authenticated, update the profile with the email
// If authenticated, update the profile with metadata from credentials
// NOTE: We intentionally do NOT store the OAuth token in the profile.
// Storing the token causes AutoClaude to use a stale cached token instead of
// letting Claude CLI read fresh tokens from Keychain (which auto-refreshes).
@@ -1384,7 +1384,11 @@ export function registerClaudeCodeHandlers(): void {
profile.email = result.email;
}
// Save profile metadata (email, isAuthenticated) but NOT the OAuth token
// Update subscription metadata from Keychain credentials
// These are needed to display "Max" vs "Pro" in the UI
updateProfileSubscriptionMetadata(profile, expandedConfigDir);
// Save profile metadata (email, isAuthenticated, subscriptionType, rateLimitTier) but NOT the OAuth token
profileManager.saveProfile(profile);
// CRITICAL: Clear keychain cache for this profile's configDir
@@ -118,6 +118,14 @@ export function registerEnvHandlers(
if (config.githubAutoSync !== undefined) {
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
}
// GitHub CI check exclusion (comma-separated list of check names to ignore)
if (config.githubExcludedCIChecks !== undefined) {
if (config.githubExcludedCIChecks.length > 0) {
existingVars['GITHUB_EXCLUDED_CI_CHECKS'] = config.githubExcludedCIChecks.join(',');
} else {
delete existingVars['GITHUB_EXCLUDED_CI_CHECKS'];
}
}
// GitLab Integration
if (config.gitlabEnabled !== undefined) {
existingVars[GITLAB_ENV_KEYS.ENABLED] = config.gitlabEnabled ? 'true' : 'false';
@@ -251,6 +259,9 @@ ${existingVars['LINEAR_REALTIME_SYNC'] !== undefined ? `LINEAR_REALTIME_SYNC=${e
${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}` : '# GITHUB_TOKEN='}
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
# CI check names to exclude from blocking during PR reviews (comma-separated)
# Useful for stuck/broken CI checks that never complete
${existingVars['GITHUB_EXCLUDED_CI_CHECKS'] ? `GITHUB_EXCLUDED_CI_CHECKS=${existingVars['GITHUB_EXCLUDED_CI_CHECKS']}` : '# GITHUB_EXCLUDED_CI_CHECKS='}
# =============================================================================
# GITLAB INTEGRATION (OPTIONAL)
@@ -430,6 +441,13 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
if (vars['GITHUB_AUTO_SYNC']?.toLowerCase() === 'true') {
config.githubAutoSync = true;
}
// Parse excluded CI checks (comma-separated list)
if (vars['GITHUB_EXCLUDED_CI_CHECKS']) {
config.githubExcludedCIChecks = vars['GITHUB_EXCLUDED_CI_CHECKS']
.split(',')
.map(s => s.trim())
.filter(Boolean);
}
// GitLab config
if (vars[GITLAB_ENV_KEYS.TOKEN]) {
@@ -1,680 +0,0 @@
/**
* @vitest-environment node
*/
/**
* Comprehensive tests for pr-handlers.ts
* Tests PR listing, fetching, review initiation, status updates, and GitHub integration
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { IPC_CHANNELS } from '../../../../shared/constants';
// Mock Electron modules
const mockIpcMain = {
handle: vi.fn(),
on: vi.fn(),
removeHandler: vi.fn(),
};
const mockBrowserWindow = vi.fn();
vi.mock('electron', () => ({
ipcMain: mockIpcMain,
BrowserWindow: mockBrowserWindow,
}));
// Mock child_process
const mockExecFileSync = vi.fn();
const mockExec = vi.fn();
const mockSpawn = vi.fn();
vi.mock('child_process', () => ({
execFileSync: mockExecFileSync,
exec: mockExec,
spawn: mockSpawn,
}));
// Mock fs
const mockExistsSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockWriteFileSync = vi.fn();
const mockMkdirSync = vi.fn();
const mockRmSync = vi.fn();
const mockFsPromises = {
writeFile: vi.fn(),
readFile: vi.fn(),
mkdir: vi.fn(),
rm: vi.fn(),
};
vi.mock('fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
mkdirSync: mockMkdirSync,
rmSync: mockRmSync,
promises: mockFsPromises,
}));
// Mock fetch for GitHub API calls
global.fetch = vi.fn();
// Mock GitHub utils
const mockGetGitHubConfig = vi.fn();
const mockGithubFetch = vi.fn();
const mockNormalizeRepoReference = vi.fn((repo: string) => repo);
vi.mock('../utils', () => ({
getGitHubConfig: mockGetGitHubConfig,
githubFetch: mockGithubFetch,
normalizeRepoReference: mockNormalizeRepoReference,
}));
// Mock settings-utils
vi.mock('../../../settings-utils', () => ({
readSettingsFile: vi.fn(() => ({})),
}));
// Mock env-utils
vi.mock('../../../env-utils', () => ({
getAugmentedEnv: vi.fn(() => ({})),
}));
// Mock memory-service
vi.mock('../../../memory-service', () => ({
getMemoryService: vi.fn(),
getDefaultDbPath: vi.fn(() => '/mock/memory/db'),
}));
// Mock project middleware
const mockWithProjectOrNull = vi.fn();
vi.mock('../utils/project-middleware', () => ({
withProjectOrNull: mockWithProjectOrNull,
}));
// Mock logger
vi.mock('../utils/logger', () => ({
createContextLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
// Mock IPC communicator
vi.mock('../utils/ipc-communicator', () => ({
createIPCCommunicators: vi.fn(() => ({
sendProgress: vi.fn(),
sendLog: vi.fn(),
sendError: vi.fn(),
})),
}));
// Mock runner env
vi.mock('../utils/runner-env', () => ({
getRunnerEnv: vi.fn(() => ({})),
}));
// Mock subprocess runner
vi.mock('../utils/subprocess-runner', () => ({
runPythonSubprocess: vi.fn(),
getPythonPath: vi.fn(() => '/usr/bin/python3'),
getRunnerPath: vi.fn(() => '/mock/runner/path'),
validateGitHubModule: vi.fn(),
buildRunnerArgs: vi.fn(() => []),
}));
describe('pr-handlers', () => {
let handlersRegistered: Map<string, Function>;
beforeEach(async () => {
vi.clearAllMocks();
handlersRegistered = new Map();
// Capture IPC handlers when they're registered
mockIpcMain.handle.mockImplementation((channel: string, handler: Function) => {
handlersRegistered.set(channel, handler);
});
// Setup default mock implementations
mockWithProjectOrNull.mockImplementation(async (_projectId: string, callback: Function) => {
const project = {
id: 'project-1',
path: '/mock/project',
name: 'Test Project',
};
return callback(project);
});
// Import and register handlers
const { registerPRHandlers } = await import('../pr-handlers');
registerPRHandlers(() => null);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('GITHUB_PR_LIST handler', () => {
it('should list open PRs successfully', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_LIST);
expect(handler).toBeDefined();
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
// Mock GraphQL response
(global.fetch as Mock).mockResolvedValue({
ok: true,
json: async () => ({
data: {
repository: {
pullRequests: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [
{
number: 123,
title: 'Test PR',
body: 'Test description',
state: 'OPEN',
author: { login: 'testuser' },
headRefName: 'feature-branch',
baseRefName: 'main',
additions: 50,
deletions: 10,
changedFiles: 3,
assignees: { nodes: [] },
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
url: 'https://github.com/owner/repo/pull/123',
},
],
},
},
},
}),
});
const result = await handler!({}, 'project-1');
expect(result.prs).toHaveLength(1);
expect(result.prs[0]).toEqual({
number: 123,
title: 'Test PR',
body: 'Test description',
state: 'open',
author: { login: 'testuser' },
headRefName: 'feature-branch',
baseRefName: 'main',
additions: 50,
deletions: 10,
changedFiles: 3,
assignees: [],
files: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
htmlUrl: 'https://github.com/owner/repo/pull/123',
});
expect(result.hasNextPage).toBe(false);
});
it('should return empty array when no GitHub config', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_LIST);
mockGetGitHubConfig.mockReturnValue(null);
const result = await handler!({}, 'project-1');
expect(result.prs).toEqual([]);
expect(result.hasNextPage).toBe(false);
});
it('should handle invalid repo format', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_LIST);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'invalid-repo-format',
});
const result = await handler!({}, 'project-1');
expect(result.prs).toEqual([]);
expect(result.hasNextPage).toBe(false);
});
it('should handle repository not found', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_LIST);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
(global.fetch as Mock).mockResolvedValue({
ok: true,
json: async () => ({
data: {
repository: null, // Repository doesn't exist or no access
},
}),
});
const result = await handler!({}, 'project-1');
expect(result.prs).toEqual([]);
expect(result.hasNextPage).toBe(false);
});
it('should handle GraphQL API errors', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_LIST);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
(global.fetch as Mock).mockResolvedValue({
ok: true,
json: async () => ({
errors: [{ message: 'API error' }],
}),
});
const result = await handler!({}, 'project-1');
expect(result.prs).toEqual([]);
expect(result.hasNextPage).toBe(false);
});
it('should handle network errors', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_LIST);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
(global.fetch as Mock).mockRejectedValue(new Error('Network error'));
const result = await handler!({}, 'project-1');
expect(result.prs).toEqual([]);
expect(result.hasNextPage).toBe(false);
});
it('should handle pagination', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_LIST);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
(global.fetch as Mock).mockResolvedValue({
ok: true,
json: async () => ({
data: {
repository: {
pullRequests: {
pageInfo: { hasNextPage: true, endCursor: 'cursor123' },
nodes: [],
},
},
},
}),
});
const result = await handler!({}, 'project-1');
expect(result.hasNextPage).toBe(true);
});
});
describe('GITHUB_PR_GET handler', () => {
it('should get single PR with files', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET);
expect(handler).toBeDefined();
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
mockGithubFetch
.mockResolvedValueOnce({
number: 123,
title: 'Test PR',
body: 'Test description',
state: 'open',
user: { login: 'testuser' },
head: { ref: 'feature-branch' },
base: { ref: 'main' },
additions: 50,
deletions: 10,
changed_files: 3,
assignees: [{ login: 'reviewer1' }],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
html_url: 'https://github.com/owner/repo/pull/123',
})
.mockResolvedValueOnce([
{
filename: 'src/file1.ts',
additions: 30,
deletions: 5,
status: 'modified',
},
{
filename: 'src/file2.ts',
additions: 20,
deletions: 5,
status: 'added',
},
]);
const result = await handler!({}, 'project-1', 123);
expect(result).toEqual({
number: 123,
title: 'Test PR',
body: 'Test description',
state: 'open',
author: { login: 'testuser' },
headRefName: 'feature-branch',
baseRefName: 'main',
additions: 50,
deletions: 10,
changedFiles: 3,
assignees: [{ login: 'reviewer1' }],
files: [
{ path: 'src/file1.ts', additions: 30, deletions: 5, status: 'modified' },
{ path: 'src/file2.ts', additions: 20, deletions: 5, status: 'added' },
],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
htmlUrl: 'https://github.com/owner/repo/pull/123',
});
});
it('should return null when no GitHub config', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET);
mockGetGitHubConfig.mockReturnValue(null);
const result = await handler!({}, 'project-1', 123);
expect(result).toBeNull();
});
it('should return null on API error', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
mockGithubFetch.mockRejectedValue(new Error('API error'));
const result = await handler!({}, 'project-1', 123);
expect(result).toBeNull();
});
});
describe('GITHUB_PR_GET_DIFF handler', () => {
it('should get PR diff using gh CLI', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_DIFF);
expect(handler).toBeDefined();
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
mockExecFileSync.mockReturnValue('diff --git a/file.ts b/file.ts\n...');
const result = await handler!({}, 'project-1', 123);
expect(result).toBe('diff --git a/file.ts b/file.ts\n...');
expect(mockExecFileSync).toHaveBeenCalledWith(
'gh',
['pr', 'diff', '123'],
expect.objectContaining({
cwd: '/mock/project',
encoding: 'utf-8',
})
);
});
it('should return null when no GitHub config', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_DIFF);
mockGetGitHubConfig.mockReturnValue(null);
const result = await handler!({}, 'project-1', 123);
expect(result).toBeNull();
});
it('should return null on command error', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_DIFF);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
mockExecFileSync.mockImplementation(() => {
throw new Error('gh CLI error');
});
const result = await handler!({}, 'project-1', 123);
expect(result).toBeNull();
});
it('should validate PR number', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_DIFF);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
// Invalid PR number should be rejected
mockExecFileSync.mockImplementation((cmd, args) => {
// Check that PR number is validated
if (args && args[2] === '-1') {
throw new Error('Invalid PR number');
}
return '';
});
const result = await handler!({}, 'project-1', -1);
expect(result).toBeNull();
});
});
describe('GITHUB_PR_GET_REVIEW handler', () => {
it('should get saved review result', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_REVIEW);
expect(handler).toBeDefined();
// Mock review file exists
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
prNumber: 123,
status: 'completed',
issues: [],
timestamp: '2024-01-01T00:00:00Z',
})
);
const result = await handler!({}, 'project-1', 123);
expect(result).toBeDefined();
});
it('should return null when no review exists', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_REVIEW);
mockExistsSync.mockReturnValue(false);
const result = await handler!({}, 'project-1', 123);
// Result depends on getReviewResult implementation
// If file doesn't exist, it should return null
expect(result === null || result === undefined).toBe(true);
});
});
describe('GITHUB_PR_GET_REVIEWS_BATCH handler', () => {
it('should batch get multiple reviews efficiently', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_REVIEWS_BATCH);
expect(handler).toBeDefined();
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes('123')) {
return JSON.stringify({ prNumber: 123, status: 'completed' });
}
if (path.includes('124')) {
return JSON.stringify({ prNumber: 124, status: 'pending' });
}
throw new Error('File not found');
});
const result = await handler!({}, 'project-1', [123, 124, 125]);
expect(result).toBeDefined();
expect(Object.keys(result)).toHaveLength(3);
});
it('should handle empty batch', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET_REVIEWS_BATCH);
const result = await handler!({}, 'project-1', []);
expect(result).toEqual({});
});
});
describe('GITHUB_PR_REVIEW handler', () => {
it('should start PR review successfully', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_REVIEW);
// This handler might not be implemented yet - check if it exists
if (!handler) {
console.log('GITHUB_PR_REVIEW handler not implemented yet');
return;
}
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
const result = await handler!({}, 'project-1', 123);
expect(result).toBeDefined();
});
});
describe('GITHUB_PR_REVIEW_CANCEL handler', () => {
it('should stop running review', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_REVIEW_CANCEL);
// This handler might not be implemented yet - check if it exists
if (!handler) {
console.log('GITHUB_PR_REVIEW_CANCEL handler not implemented yet');
return;
}
const result = await handler!({}, 'project-1', 123);
expect(result).toBeDefined();
});
});
describe('GITHUB_PR_POST_COMMENT handler', () => {
it('should post review comment successfully', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_POST_COMMENT);
// This handler might not be implemented yet - check if it exists
if (!handler) {
console.log('GITHUB_PR_POST_COMMENT handler not implemented yet');
return;
}
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
(global.fetch as Mock).mockResolvedValue({
ok: true,
json: async () => ({ id: 1 }),
});
const result = await handler!({}, 'project-1', 123, {
body: 'Test comment',
path: 'src/file.ts',
line: 10,
});
expect(result).toBeDefined();
});
it('should handle missing handler gracefully', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_POST_COMMENT);
// Either handler exists or doesn't - both are valid states
expect(handler === undefined || typeof handler === 'function').toBe(true);
});
});
describe('sanitizeNetworkData', () => {
it('should remove null bytes and control characters', async () => {
// This is an internal function, but we can test it indirectly through handlers
const handler = handlersRegistered.get(IPC_CHANNELS.GITHUB_PR_GET);
mockGetGitHubConfig.mockReturnValue({
token: 'mock-token',
repo: 'owner/repo',
});
mockGithubFetch
.mockResolvedValueOnce({
number: 123,
title: 'Test\x00PR', // Null byte
body: 'Description\x01with\x02control\x03chars',
state: 'open',
user: { login: 'testuser' },
head: { ref: 'feature' },
base: { ref: 'main' },
additions: 0,
deletions: 0,
changed_files: 0,
assignees: [],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
html_url: 'https://github.com/owner/repo/pull/123',
})
.mockResolvedValueOnce([]);
const result = await handler!({}, 'project-1', 123);
// The handler should successfully process the PR
expect(result).toBeDefined();
});
});
});
@@ -40,6 +40,23 @@ import {
* GraphQL response type for PR list query
* Note: repository can be null if the repo doesn't exist or user lacks access
*/
interface GraphQLPRNode {
number: number;
title: string;
body: string | null;
state: string;
author: { login: string } | null;
headRefName: string;
baseRefName: string;
additions: number;
deletions: number;
changedFiles: number;
assignees: { nodes: Array<{ login: string }> };
createdAt: string;
updatedAt: string;
url: string;
}
interface GraphQLPRListResponse {
data: {
repository: {
@@ -48,28 +65,37 @@ interface GraphQLPRListResponse {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: Array<{
number: number;
title: string;
body: string | null;
state: string;
author: { login: string } | null;
headRefName: string;
baseRefName: string;
additions: number;
deletions: number;
changedFiles: number;
assignees: { nodes: Array<{ login: string }> };
createdAt: string;
updatedAt: string;
url: string;
}>;
nodes: GraphQLPRNode[];
};
} | null;
};
errors?: Array<{ message: string }>;
}
/**
* Maps a GraphQL PR node to the frontend PRData format.
* Shared between listPRs and listMorePRs handlers.
*/
function mapGraphQLPRToData(pr: GraphQLPRNode): PRData {
return {
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state.toLowerCase(),
author: { login: pr.author?.login ?? "unknown" },
headRefName: pr.headRefName,
baseRefName: pr.baseRefName,
additions: pr.additions,
deletions: pr.deletions,
changedFiles: pr.changedFiles,
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
files: [],
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
htmlUrl: pr.url,
};
}
/**
* Make a GraphQL request to GitHub API
*/
@@ -219,6 +245,29 @@ function getClaudeMdEnv(project: Project): Record<string, string> | undefined {
return project.settings?.useClaudeMd !== false ? { USE_CLAUDE_MD: "true" } : undefined;
}
/**
* Builds extra environment variables for PR review subprocess.
* Includes Claude.md setting and GitHub-specific config like excluded CI checks.
*/
function getPRReviewExtraEnv(
project: Project,
config: { excludedCIChecks?: string[] } | null
): Record<string, string> {
const env: Record<string, string> = {};
// Add Claude.md setting
if (project.settings?.useClaudeMd !== false) {
env.USE_CLAUDE_MD = "true";
}
// Add excluded CI checks (for stuck/broken CI like license/cla)
if (config?.excludedCIChecks && config.excludedCIChecks.length > 0) {
env.GITHUB_EXCLUDED_CI_CHECKS = config.excludedCIChecks.join(",");
}
return env;
}
/**
* PR review finding from AI analysis
*/
@@ -487,6 +536,7 @@ export interface PRData {
export interface PRListResult {
prs: PRData[];
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
}
/**
@@ -870,6 +920,16 @@ function parseLogLine(line: string): { source: string; content: string; isError:
};
}
// Check for parallel SDK specialist logs (Specialist:name format)
const specialistMatch = line.match(/^\[Specialist:([\w-]+)\]\s*(.*)$/);
if (specialistMatch) {
return {
source: `Specialist:${specialistMatch[1]}`,
content: specialistMatch[2],
isError: false,
};
}
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
@@ -970,8 +1030,9 @@ function getPhaseFromSource(source: string): PRLogPhase {
if (contextSources.includes(source)) return "context";
if (analysisSources.includes(source)) return "analysis";
// Specialist agents (Agent:xxx) are part of analysis phase
// Specialist agents (Agent:xxx and Specialist:xxx) are part of analysis phase
if (source.startsWith("Agent:")) return "analysis";
if (source.startsWith("Specialist:")) return "analysis";
if (synthesisSources.includes(source)) return "synthesis";
return "synthesis"; // Default to synthesis for unknown sources
}
@@ -1266,8 +1327,8 @@ async function runPRReview(
const repo = config?.repo || project.name || "unknown";
const logCollector = new PRLogCollector(project, prNumber, repo, false);
// Build environment with project settings
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
// Build environment with project settings (including excluded CI checks)
const subprocessEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -1336,13 +1397,75 @@ async function runPRReview(
}
}
/**
* Shared helper to fetch PRs via GraphQL API.
* Used by both listPRs and listMorePRs handlers to avoid code duplication.
*/
async function fetchPRsFromGraphQL(
config: { token: string; repo: string },
cursor: string | null,
debugContext: string
): Promise<PRListResult> {
// Parse owner/repo from config - must be exactly "owner/repo" format
const normalizedRepo = normalizeRepoReference(config.repo);
const repoParts = normalizedRepo.split("/");
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
debugLog("Invalid repo format - expected 'owner/repo'", {
repo: config.repo,
normalized: normalizedRepo,
context: debugContext,
});
return { prs: [], hasNextPage: false, endCursor: null };
}
const [owner, repo] = repoParts;
try {
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
const response = await githubGraphQL<GraphQLPRListResponse>(
config.token,
LIST_PRS_QUERY,
{
owner,
repo,
first: 100, // GitHub GraphQL max is 100
after: cursor,
}
);
// Handle case where repository doesn't exist or user lacks access
if (!response.data.repository) {
debugLog("Repository not found or access denied", { owner, repo, context: debugContext });
return { prs: [], hasNextPage: false, endCursor: null };
}
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
debugLog(`Fetched PRs via GraphQL (${debugContext})`, {
count: prNodes.length,
hasNextPage: pageInfo.hasNextPage,
endCursor: pageInfo.endCursor,
});
return {
prs: prNodes.map(mapGraphQLPRToData),
hasNextPage: pageInfo.hasNextPage,
endCursor: pageInfo.endCursor,
};
} catch (error) {
debugLog(`Failed to fetch PRs (${debugContext})`, {
error: error instanceof Error ? error.message : error,
});
return { prs: [], hasNextPage: false, endCursor: null };
}
}
/**
* Register PR-related handlers
*/
export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void {
debugLog("Registering PR handlers");
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_LIST,
async (_, projectId: string): Promise<PRListResult> => {
@@ -1351,69 +1474,28 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
const config = getGitHubConfig(project);
if (!config) {
debugLog("No GitHub config found for project");
return { prs: [], hasNextPage: false };
}
try {
// Parse owner/repo from config - must be exactly "owner/repo" format
const normalizedRepo = normalizeRepoReference(config.repo);
const repoParts = normalizedRepo.split("/");
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
debugLog("Invalid repo format - expected 'owner/repo'", { repo: config.repo, normalized: normalizedRepo });
return { prs: [], hasNextPage: false };
}
const [owner, repo] = repoParts;
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
const response = await githubGraphQL<GraphQLPRListResponse>(
config.token,
LIST_PRS_QUERY,
{
owner,
repo,
first: 100, // GitHub GraphQL max is 100
after: null, // Start from beginning
}
);
// Handle case where repository doesn't exist or user lacks access
if (!response.data.repository) {
debugLog("Repository not found or access denied", { owner, repo });
return { prs: [], hasNextPage: false };
}
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
debugLog("Fetched PRs via GraphQL", { count: prNodes.length, hasNextPage: pageInfo.hasNextPage });
return {
prs: prNodes.map((pr) => ({
number: pr.number,
title: pr.title,
body: pr.body ?? "",
state: pr.state.toLowerCase(),
author: { login: pr.author?.login ?? "unknown" },
headRefName: pr.headRefName,
baseRefName: pr.baseRefName,
additions: pr.additions,
deletions: pr.deletions,
changedFiles: pr.changedFiles,
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
files: [],
createdAt: pr.createdAt,
updatedAt: pr.updatedAt,
htmlUrl: pr.url,
})),
hasNextPage: pageInfo.hasNextPage,
};
} catch (error) {
debugLog("Failed to fetch PRs", {
error: error instanceof Error ? error.message : error,
});
return { prs: [], hasNextPage: false };
return { prs: [], hasNextPage: false, endCursor: null };
}
return fetchPRsFromGraphQL(config, null, "initial");
});
return result ?? { prs: [], hasNextPage: false };
return result ?? { prs: [], hasNextPage: false, endCursor: null };
}
);
// Load more PRs (pagination) - fetches next page of PRs using cursor
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_LIST_MORE,
async (_, projectId: string, cursor: string): Promise<PRListResult> => {
debugLog("listMorePRs handler called", { projectId, cursor });
const result = await withProjectOrNull(projectId, async (project) => {
const config = getGitHubConfig(project);
if (!config) {
debugLog("No GitHub config found for project");
return { prs: [], hasNextPage: false, endCursor: null };
}
return fetchPRsFromGraphQL(config, cursor, "pagination");
});
return result ?? { prs: [], hasNextPage: false, endCursor: null };
}
);
@@ -2650,8 +2732,8 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
const repo = config?.repo || project.name || "unknown";
const logCollector = new PRLogCollector(project, prNumber, repo, true);
// Build environment with project settings
const followupEnv = await getRunnerEnv(getClaudeMdEnv(project));
// Build environment with project settings (including excluded CI checks)
const followupEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -19,8 +19,7 @@ import { getWhichCommand } from '../../platform';
*/
function checkGhCli(): { installed: boolean; error?: string } {
try {
const checkCmd = `${getWhichCommand()} gh`;
execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' });
execFileSync(getWhichCommand(), ['gh'], { encoding: 'utf-8', stdio: 'pipe' });
return { installed: true };
} catch {
return {
@@ -5,6 +5,7 @@
export interface GitHubConfig {
token: string;
repo: string;
excludedCIChecks?: string[];
}
export interface GitHubAPIIssue {
@@ -81,7 +81,14 @@ export function getGitHubConfig(project: Project): GitHubConfig | null {
}
if (!token || !repo) return null;
return { token, repo };
// Parse excluded CI checks (comma-separated list)
const excludedCIChecksRaw = vars['GITHUB_EXCLUDED_CI_CHECKS'];
const excludedCIChecks = excludedCIChecksRaw
? excludedCIChecksRaw.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
return { token, repo, excludedCIChecks };
} catch {
return null;
}
@@ -10,7 +10,8 @@ import type {
IPCResult,
InitializationResult,
AutoBuildVersionInfo,
GitStatus
GitStatus,
GitBranchDetail
} from '../../shared/types';
import { projectStore } from '../project-store';
import {
@@ -89,6 +90,96 @@ function getGitBranches(projectPath: string): string[] {
}
}
/**
* Get structured branch information for a directory (both local and remote)
* Returns GitBranchDetail[] with type indicators, keeping both local and remote versions
* when a branch exists in both places (no deduplication)
*/
function getGitBranchesWithInfo(projectPath: string): GitBranchDetail[] {
try {
// First fetch to ensure we have latest remote refs
try {
execFileSync(getToolPath('git'), ['fetch', '--prune'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000 // 10 second timeout for fetch
});
} catch {
// Fetch may fail if offline or no remote, continue with local refs
}
// Get current branch for isCurrent indicator
let currentBranch: string | null = null;
try {
const currentResult = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
currentBranch = currentResult.trim() || null;
} catch {
// Ignore - current branch detection may fail in some edge cases
}
// Get local branches
const localResult = execFileSync(getToolPath('git'), ['branch', '--format=%(refname:short)'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
const localBranches: GitBranchDetail[] = localResult.trim().split('\n')
.filter(b => b.trim())
.map(b => {
const name = b.trim();
return {
name,
type: 'local' as const,
displayName: name,
isCurrent: name === currentBranch
};
});
// Get remote branches
let remoteBranches: GitBranchDetail[] = [];
try {
const remoteResult = execFileSync(getToolPath('git'), ['branch', '-r', '--format=%(refname:short)'], {
cwd: projectPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
});
remoteBranches = remoteResult.trim().split('\n')
.filter(b => b.trim())
.map(b => b.trim())
// Remove HEAD pointer entries like "origin/HEAD"
.filter(b => !b.endsWith('/HEAD'))
.map(name => ({
name,
type: 'remote' as const,
displayName: name,
isCurrent: false
}));
} catch {
// Remote branches may not exist, continue with local only
}
// Combine and sort: local branches first, then remote branches, alphabetically within each group
const allBranches = [...localBranches, ...remoteBranches];
return allBranches.sort((a, b) => {
// Local branches come first
if (a.type === 'local' && b.type === 'remote') return -1;
if (a.type === 'remote' && b.type === 'local') return 1;
// Within same type, sort alphabetically
return a.name.localeCompare(b.name);
});
} catch {
return [];
}
}
/**
* Get the current git branch for a directory
*/
@@ -449,7 +540,7 @@ export function registerProjectHandlers(
// Git Operations
// ============================================
// Get all branches for a project
// Get all branches for a project (legacy - returns string[])
ipcMain.handle(
IPC_CHANNELS.GIT_GET_BRANCHES,
async (_, projectPath: string): Promise<IPCResult<string[]>> => {
@@ -468,6 +559,25 @@ export function registerProjectHandlers(
}
);
// Get all branches with structured type information (local vs remote)
ipcMain.handle(
IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO,
async (_, projectPath: string): Promise<IPCResult<GitBranchDetail[]>> => {
try {
if (!existsSync(projectPath)) {
return { success: false, error: 'Directory does not exist' };
}
const branches = getGitBranchesWithInfo(projectPath);
return { success: true, data: branches };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
);
// Get current branch for a project
ipcMain.handle(
IPC_CHANNELS.GIT_GET_CURRENT_BRANCH,
@@ -0,0 +1,157 @@
/**
* Tests for findTaskAndProject cross-project scoping.
* Verifies that projectId prevents cross-project task contamination
* when multiple projects have tasks with the same specId.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { findTaskAndProject } from '../shared';
import type { Task, Project } from '../../../../shared/types';
// Mock projectStore
const mockProjects: Project[] = [];
const mockTasksByProject: Map<string, Task[]> = new Map();
vi.mock('../../../project-store', () => ({
projectStore: {
getProjects: () => mockProjects,
getTasks: (projectId: string) => mockTasksByProject.get(projectId) || []
}
}));
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: `task-${Date.now()}-${Math.random().toString(36).substring(7)}`,
specId: 'test-spec',
projectId: 'project-1',
title: 'Test Task',
description: 'Test',
status: 'backlog',
subtasks: [],
logs: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides
};
}
function createProject(overrides: Partial<Project> = {}): Project {
return {
id: `project-${Date.now()}`,
name: 'Test Project',
path: '/test/project',
createdAt: new Date().toISOString(),
lastOpenedAt: new Date().toISOString(),
...overrides
} as Project;
}
describe('findTaskAndProject', () => {
beforeEach(() => {
mockProjects.length = 0;
mockTasksByProject.clear();
});
it('should find task by specId without projectId (backward compatibility)', () => {
const project = createProject({ id: 'proj-1' });
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', [task]);
const result = findTaskAndProject('write-to-file');
expect(result.task).toBe(task);
expect(result.project).toBe(project);
});
it('should scope search to specified project when projectId is provided', () => {
const projectA = createProject({ id: 'proj-a', name: 'Project A' });
const projectB = createProject({ id: 'proj-b', name: 'Project B' });
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
const taskB = createTask({ id: 'task-b', specId: 'write-to-file', projectId: 'proj-b' });
mockProjects.push(projectA, projectB);
mockTasksByProject.set('proj-a', [taskA]);
mockTasksByProject.set('proj-b', [taskB]);
// Without projectId - returns first match (Project A)
const resultNoScope = findTaskAndProject('write-to-file');
expect(resultNoScope.task).toBe(taskA);
expect(resultNoScope.project).toBe(projectA);
// With projectId for Project B - returns Project B's task
const resultScopedB = findTaskAndProject('write-to-file', 'proj-b');
expect(resultScopedB.task).toBe(taskB);
expect(resultScopedB.project).toBe(projectB);
// With projectId for Project A - returns Project A's task
const resultScopedA = findTaskAndProject('write-to-file', 'proj-a');
expect(resultScopedA.task).toBe(taskA);
expect(resultScopedA.project).toBe(projectA);
});
it('should NOT fall back to other projects when projectId is provided but task not found', () => {
const projectA = createProject({ id: 'proj-a' });
const projectB = createProject({ id: 'proj-b' });
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
mockProjects.push(projectA, projectB);
mockTasksByProject.set('proj-a', [taskA]);
mockTasksByProject.set('proj-b', []);
// Search Project B (which has no tasks) — should NOT find Project A's task
const result = findTaskAndProject('write-to-file', 'proj-b');
expect(result.task).toBeUndefined();
expect(result.project).toBeUndefined();
});
it('should return undefined when projectId refers to a non-existent project', () => {
const project = createProject({ id: 'proj-1' });
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', [task]);
// Search with a projectId that doesn't exist — should NOT fall back
const result = findTaskAndProject('write-to-file', 'non-existent-project');
expect(result.task).toBeUndefined();
expect(result.project).toBeUndefined();
});
it('should return undefined when task not found in any project', () => {
const project = createProject({ id: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', []);
const result = findTaskAndProject('nonexistent-task');
expect(result.task).toBeUndefined();
expect(result.project).toBeUndefined();
});
it('should find task by id as well as specId', () => {
const project = createProject({ id: 'proj-1' });
const task = createTask({ id: 'unique-uuid', specId: 'write-to-file', projectId: 'proj-1' });
mockProjects.push(project);
mockTasksByProject.set('proj-1', [task]);
const result = findTaskAndProject('unique-uuid', 'proj-1');
expect(result.task).toBe(task);
expect(result.project).toBe(project);
});
it('should log warning when provided projectId is not found', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockProjects.push(createProject({ id: 'proj-1' }));
findTaskAndProject('some-task', 'ghost-project');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('ghost-project'),
// Flexible match on the rest of the message
);
warnSpy.mockRestore();
});
});
@@ -1,837 +0,0 @@
/**
* @vitest-environment node
*/
/**
* Comprehensive tests for worktree-handlers.ts
* Tests worktree creation, status, diff, merge, cleanup, and PR creation handlers
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeMergeResult } from '../../../../shared/types';
import { IPC_CHANNELS } from '../../../../shared/constants';
// Mock Electron modules
const mockIpcMain = {
handle: vi.fn(),
on: vi.fn(),
removeHandler: vi.fn(),
};
const mockBrowserWindow = vi.fn();
const mockShell = { openPath: vi.fn() };
const mockApp = { getPath: vi.fn(() => '/mock/user/data') };
vi.mock('electron', () => ({
ipcMain: mockIpcMain,
BrowserWindow: mockBrowserWindow,
shell: mockShell,
app: mockApp,
}));
// Mock child_process
const mockExecFileSync = vi.fn();
const mockExecSync = vi.fn();
const mockSpawnSync = vi.fn();
const mockExec = vi.fn();
// execFile mock that works with promisify - calls callback immediately with success
const mockExecFile = vi.fn((_cmd, _args, callback) => {
if (typeof callback === 'function') {
callback(null, '', '');
}
});
const mockSpawn = vi.fn();
vi.mock('child_process', () => ({
execFileSync: mockExecFileSync,
execSync: mockExecSync,
spawnSync: mockSpawnSync,
exec: mockExec,
execFile: mockExecFile,
spawn: mockSpawn,
}));
// Mock fs
const mockExistsSync = vi.fn();
const mockReadFileSync = vi.fn();
const mockReaddirSync = vi.fn();
const mockStatSync = vi.fn();
const mockFsPromises = {
writeFile: vi.fn(),
readFile: vi.fn(),
mkdir: vi.fn(),
rm: vi.fn(),
};
vi.mock('fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
readdirSync: mockReaddirSync,
statSync: mockStatSync,
promises: mockFsPromises,
}));
// Mock project-store
const mockProjectStore = {
getProjects: vi.fn(() => []),
getProject: vi.fn(),
updateProject: vi.fn(),
};
vi.mock('../../../project-store', () => ({
projectStore: mockProjectStore,
}));
// Mock python-env-manager - use vi.hoisted to ensure mock is defined before vi.mock hoisting
const mockPythonEnvManager = vi.hoisted(() => ({
isEnvReady: vi.fn(() => true),
initialize: vi.fn(() => Promise.resolve({ ready: true })),
getPythonEnv: vi.fn(() => ({})),
}));
vi.mock('../../../python-env-manager', () => ({
getConfiguredPythonPath: vi.fn(() => '/usr/bin/python3'),
PythonEnvManager: vi.fn(),
pythonEnvManager: mockPythonEnvManager,
}));
// Mock updater path-resolver
vi.mock('../../../updater/path-resolver', () => ({
getEffectiveSourcePath: vi.fn(() => '/mock/source/path'),
}));
// Mock rate-limit-detector
vi.mock('../../../rate-limit-detector', () => ({
getBestAvailableProfileEnv: vi.fn(() => ({})),
}));
// Mock shared utilities
vi.mock('../shared', () => ({
findTaskAndProject: vi.fn(),
}));
// Mock worktree-paths
vi.mock('../../../worktree-paths', () => ({
getTaskWorktreeDir: vi.fn((projectPath: string, specId: string) =>
`/mock/worktrees/tasks/${specId}`
),
findTaskWorktree: vi.fn(),
}));
// Mock plan-file-utils
vi.mock('../plan-file-utils', () => ({
persistPlanStatus: vi.fn(),
updateTaskMetadataPrUrl: vi.fn(),
}));
// Mock git-isolation
vi.mock('../../../utils/git-isolation', () => ({
getIsolatedGitEnv: vi.fn(() => ({ GIT_DIR: '', GIT_WORK_TREE: '' })),
detectWorktreeBranch: vi.fn(),
refreshGitIndex: vi.fn(),
}));
// Mock worktree-cleanup - use vi.hoisted to allow modifying the mock per test
const mockCleanupWorktree = vi.hoisted(() => vi.fn(() => Promise.resolve({ success: true, warnings: [] })));
vi.mock('../../../utils/worktree-cleanup', () => ({
cleanupWorktree: mockCleanupWorktree,
}));
// Mock platform
vi.mock('../../../platform', () => ({
killProcessGracefully: vi.fn(),
}));
// Mock task-state-manager
vi.mock('../../../task-state-manager', () => ({
taskStateManager: {
getTaskState: vi.fn(),
updateTaskState: vi.fn(),
},
}));
// Mock cli-tool-manager
vi.mock('../../../cli-tool-manager', () => ({
getToolPath: vi.fn((tool: string) => tool), // Return the tool name as-is
}));
// Mock python-detector - returns [command, args] tuple
vi.mock('../../../python-detector', () => ({
parsePythonCommand: vi.fn(() => ['python3', []]),
}));
// Mock settings-utils
vi.mock('../../../settings-utils', () => ({
readSettingsFile: vi.fn(() => ({})),
}));
describe('worktree-handlers', () => {
let handlersRegistered: Map<string, Function>;
let findTaskAndProject: Mock;
let findTaskWorktree: Mock;
beforeEach(async () => {
vi.clearAllMocks();
handlersRegistered = new Map();
// Capture IPC handlers when they're registered
mockIpcMain.handle.mockImplementation((channel: string, handler: Function) => {
handlersRegistered.set(channel, handler);
});
// Setup default mock for readFileSync to avoid JSON parsing errors
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes('settings.json')) {
return JSON.stringify({});
}
if (path.includes('metadata.json')) {
return JSON.stringify({ baseBranch: 'main' });
}
return '{}';
});
// Setup default mock for existsSync
mockExistsSync.mockReturnValue(false);
// Setup mock implementations
const sharedModule = await import('../shared');
findTaskAndProject = sharedModule.findTaskAndProject as Mock;
const worktreePathsModule = await import('../../../worktree-paths');
findTaskWorktree = worktreePathsModule.findTaskWorktree as Mock;
// Import and register handlers
const { registerWorktreeHandlers } = await import('../worktree-handlers');
registerWorktreeHandlers(mockPythonEnvManager as any, () => null);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('validateWorktreeBranch', () => {
it('should return detected branch on exact match', async () => {
const { validateWorktreeBranch } = await import('../worktree-handlers');
const result = validateWorktreeBranch('auto-claude/001-feature', 'auto-claude/001-feature');
expect(result).toEqual({
branchToDelete: 'auto-claude/001-feature',
usedFallback: false,
reason: 'exact_match',
});
});
it('should return detected branch on pattern match', async () => {
const { validateWorktreeBranch } = await import('../worktree-handlers');
const result = validateWorktreeBranch('auto-claude/002-bugfix', 'auto-claude/001-feature');
expect(result).toEqual({
branchToDelete: 'auto-claude/002-bugfix',
usedFallback: false,
reason: 'pattern_match',
});
});
it('should use fallback when detected branch is invalid', async () => {
const { validateWorktreeBranch } = await import('../worktree-handlers');
const result = validateWorktreeBranch('main', 'auto-claude/001-feature');
expect(result).toEqual({
branchToDelete: 'auto-claude/001-feature',
usedFallback: true,
reason: 'invalid_pattern',
});
});
it('should use fallback when detection failed', async () => {
const { validateWorktreeBranch } = await import('../worktree-handlers');
const result = validateWorktreeBranch(null, 'auto-claude/001-feature');
expect(result).toEqual({
branchToDelete: 'auto-claude/001-feature',
usedFallback: true,
reason: 'detection_failed',
});
});
it('should reject auto-claude/ prefix without specId', async () => {
const { validateWorktreeBranch } = await import('../worktree-handlers');
const result = validateWorktreeBranch('auto-claude/', 'auto-claude/001-feature');
expect(result).toEqual({
branchToDelete: 'auto-claude/001-feature',
usedFallback: true,
reason: 'invalid_pattern',
});
});
});
describe('TASK_WORKTREE_STATUS handler', () => {
it('should return worktree status when worktree exists', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_STATUS);
expect(handler).toBeDefined();
// Mock task and project
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', settings: { mainBranch: 'main' } },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
// Mock git commands
mockExecFileSync
.mockReturnValueOnce('auto-claude/001-feature\n') // current branch
.mockReturnValueOnce('main\n') // current project branch
.mockReturnValueOnce('5\n') // commit count
.mockReturnValueOnce('3 files changed, 50 insertions(+), 10 deletions(-)\n'); // diff stat
const result = await handler!({}, 'task-1') as IPCResult<WorktreeStatus>;
expect(result.success).toBe(true);
expect(result.data).toEqual({
exists: true,
worktreePath: '/mock/worktrees/tasks/001-feature',
branch: 'auto-claude/001-feature',
baseBranch: expect.any(String),
currentProjectBranch: 'main',
commitCount: 5,
filesChanged: 3,
additions: 50,
deletions: 10,
});
});
it('should return exists: false when worktree does not exist', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_STATUS);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue(null);
const result = await handler!({}, 'task-1') as IPCResult<WorktreeStatus>;
expect(result.success).toBe(true);
expect(result.data).toEqual({ exists: false });
});
it('should return error when task not found', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_STATUS);
findTaskAndProject.mockReturnValue({ task: null, project: null });
const result = await handler!({}, 'invalid-task') as IPCResult<WorktreeStatus>;
expect(result.success).toBe(false);
expect(result.error).toBe('Task not found');
});
it('should handle git command errors gracefully', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_STATUS);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
// First call succeeds (branch), rest fail
let callCount = 0;
mockExecFileSync.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return 'auto-claude/001-feature\n';
}
throw new Error('Git command failed');
});
const result = await handler!({}, 'task-1') as IPCResult<WorktreeStatus>;
// Handler catches git errors internally and returns partial data
expect(result.success).toBe(true);
expect(result.data?.exists).toBe(true);
});
});
describe('TASK_WORKTREE_DIFF handler', () => {
it('should return diff with file stats', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_DIFF);
expect(handler).toBeDefined();
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', settings: { mainBranch: 'main' } },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
// Mock git diff commands
mockExecFileSync
.mockReturnValueOnce('10\t5\tsrc/file1.ts\n20\t3\tsrc/file2.ts\n') // numstat
.mockReturnValueOnce('M\tsrc/file1.ts\nA\tsrc/file2.ts\n'); // name-status
const result = await handler!({}, 'task-1') as IPCResult<WorktreeDiff>;
expect(result.success).toBe(true);
expect(result.data?.files).toHaveLength(2);
expect(result.data?.files[0]).toEqual({
path: 'src/file1.ts',
status: 'modified',
additions: 10,
deletions: 5,
});
expect(result.data?.files[1]).toEqual({
path: 'src/file2.ts',
status: 'added',
additions: 20,
deletions: 3,
});
expect(result.data?.summary).toBe('2 files changed, 30 insertions(+), 8 deletions(-)');
});
it('should return error when worktree not found', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_DIFF);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue(null);
const result = await handler!({}, 'task-1') as IPCResult<WorktreeDiff>;
expect(result.success).toBe(false);
expect(result.error).toBe('No worktree found for this task');
});
it('should handle deleted files correctly', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_DIFF);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
// Git diff returns name-status first, then numstat
mockExecFileSync
.mockReturnValueOnce('0\t50\tsrc/deleted.ts\n') // numstat (first call)
.mockReturnValueOnce('D\tsrc/deleted.ts\n'); // name-status (second call)
const result = await handler!({}, 'task-1') as IPCResult<WorktreeDiff>;
expect(result.success).toBe(true);
expect(result.data?.files).toBeDefined();
if (result.data?.files && result.data.files.length > 0) {
expect(result.data.files[0]).toMatchObject({
path: 'src/deleted.ts',
status: 'deleted',
});
}
});
it('should handle renamed files correctly', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_DIFF);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
mockExecFileSync
.mockReturnValueOnce('5\t2\tsrc/new-name.ts\n') // numstat
.mockReturnValueOnce('R\tsrc/old-name.ts\tsrc/new-name.ts\n'); // name-status
const result = await handler!({}, 'task-1') as IPCResult<WorktreeDiff>;
expect(result.success).toBe(true);
expect(result.data?.files).toBeDefined();
if (result.data?.files && result.data.files.length > 0) {
expect(result.data.files[0].status).toBe('renamed');
}
});
});
// TODO: Fix Python spawn mocking - merge handler uses complex spawn setup
describe.skip('TASK_WORKTREE_MERGE handler', () => {
it('should successfully merge worktree changes', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_MERGE);
expect(handler).toBeDefined();
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', autoBuildPath: '.auto-claude' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
mockExistsSync.mockReturnValue(true);
mockExecFileSync.mockReturnValue('true\n'); // isGitWorkTree check
// Mock Python subprocess result
mockSpawn.mockReturnValue({
stdout: { on: vi.fn((event, cb) => event === 'data' && cb('Merge successful\n')) },
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
}),
kill: vi.fn(),
});
// Call handler (it's async)
const resultPromise = handler!({}, 'task-1') as Promise<IPCResult<WorktreeMergeResult>>;
// Wait for completion
await new Promise(resolve => setTimeout(resolve, 100));
await resultPromise;
// Verify spawn was called (merge operation initiated)
expect(mockSpawn).toHaveBeenCalled();
});
it('should return error when Python environment not ready', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_MERGE);
mockPythonEnvManager.isEnvReady.mockReturnValue(false);
mockPythonEnvManager.initialize.mockResolvedValue({ ready: false });
const result = await handler!({}, 'task-1') as IPCResult<WorktreeMergeResult>;
expect(result.success).toBe(false);
expect(result.error).toContain('Python environment not ready');
});
it('should handle noCommit option for stage-only merge', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_MERGE);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', autoBuildPath: '.auto-claude' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
mockExistsSync.mockReturnValue(true);
// Mock that changes are already staged
mockSpawnSync.mockReturnValue({
status: 0,
stdout: Buffer.from('src/file1.ts\nsrc/file2.ts\n'),
stderr: Buffer.from(''),
output: [null, Buffer.from('src/file1.ts\nsrc/file2.ts\n'), Buffer.from('')],
pid: 12345,
signal: null,
});
const result = await handler!({}, 'task-1', { noCommit: true }) as IPCResult<WorktreeMergeResult>;
expect(result.success).toBe(true);
expect(result.data?.staged).toBe(true);
expect(result.data?.alreadyStaged).toBe(true);
});
it('should return error when spec directory not found', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_MERGE);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', autoBuildPath: '.auto-claude' },
});
mockExistsSync.mockReturnValue(false);
const result = await handler!({}, 'task-1') as IPCResult<WorktreeMergeResult>;
expect(result.success).toBe(false);
expect(result.error).toBe('Spec directory not found');
});
});
describe('TASK_WORKTREE_DISCARD handler', () => {
// TODO: Fix mock setup - cleanupWorktree mock not being applied correctly
it.skip('should successfully discard worktree', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_DISCARD);
expect(handler).toBeDefined();
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
// Use the hoisted mock - default returns success
mockCleanupWorktree.mockResolvedValue({ success: true, warnings: [] });
const result = await handler!({}, 'task-1');
expect(result.success).toBe(true);
expect(mockCleanupWorktree).toHaveBeenCalledWith(
expect.objectContaining({
worktreePath: '/mock/worktrees/tasks/001-feature',
projectPath: '/mock/project',
specId: '001-feature',
})
);
});
it('should succeed with no-op when worktree not found', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_DISCARD);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue(null);
const result = await handler!({}, 'task-1');
// Discarding when there's no worktree is a success (no-op)
expect(result.success).toBe(true);
expect(result.data?.message).toBe('No worktree to discard');
});
it('should handle cleanup errors gracefully', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_DISCARD);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
// Use the hoisted mock - simulate cleanup failure
mockCleanupWorktree.mockRejectedValue(new Error('Cleanup failed'));
const result = await handler!({}, 'task-1');
expect(result.success).toBe(false);
expect(result.error).toContain('Cleanup failed');
});
});
describe('TASK_WORKTREE_OPEN_IN_IDE handler', () => {
it('should open worktree in IDE', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_IDE);
expect(handler).toBeDefined();
// Handler takes worktreePath directly, not taskId
const worktreePath = '/mock/worktrees/tasks/001-feature';
mockExistsSync.mockReturnValue(true);
mockSpawn.mockReturnValue({
on: vi.fn((event, cb) => {
if (event === 'close') cb(0);
}),
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
});
const result = await handler!({}, worktreePath, 'vscode');
expect(result.success).toBe(true);
});
it('should return error when worktree path does not exist', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_IDE);
// Handler takes worktreePath directly and checks if it exists
mockExistsSync.mockReturnValue(false);
const result = await handler!({}, '/nonexistent/path', 'vscode');
expect(result.success).toBe(false);
expect(result.error).toBe('Worktree path does not exist');
});
});
describe('TASK_WORKTREE_OPEN_IN_TERMINAL handler', () => {
it('should open worktree in terminal', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_TERMINAL);
expect(handler).toBeDefined();
// Handler takes worktreePath directly, not taskId
const worktreePath = '/mock/worktrees/tasks/001-feature';
mockExistsSync.mockReturnValue(true);
// openInTerminal uses execFileAsync for macOS osascript
const result = await handler!({}, worktreePath, 'system');
expect(result.success).toBe(true);
});
it('should return error when worktree path does not exist', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_OPEN_IN_TERMINAL);
// Handler takes worktreePath directly and checks if it exists
mockExistsSync.mockReturnValue(false);
const result = await handler!({}, '/nonexistent/path', 'system');
expect(result.success).toBe(false);
expect(result.error).toBe('Worktree path does not exist');
});
});
describe('TASK_WORKTREE_CREATE_PR handler', () => {
beforeEach(() => {
// Reset Python env mock for each test - ensure it returns ready
mockPythonEnvManager.isEnvReady.mockReturnValue(true);
});
it('should create PR successfully', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_CREATE_PR);
expect(handler).toBeDefined();
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', autoBuildPath: '.auto-claude' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
mockExistsSync.mockReturnValue(true);
// statSync needs to not throw for spec dir check
mockStatSync.mockReturnValue({ isDirectory: () => true });
// Mock the spawn process for PR creation - handler expects JSON output
const mockProcess = {
stdout: {
on: vi.fn((event, cb) => {
if (event === 'data') {
// Simulate successful JSON response from Python backend
cb(Buffer.from('{"success": true, "pr_url": "https://github.com/owner/repo/pull/123"}\n'));
}
}),
},
stderr: { on: vi.fn() },
on: vi.fn((event, cb) => {
if (event === 'close') {
// Use setImmediate to ensure stdout data is processed first
setImmediate(() => cb(0));
}
}),
kill: vi.fn(),
};
mockSpawn.mockReturnValue(mockProcess);
const result = await handler!({}, 'task-1', {
title: 'Test PR',
body: 'Test description',
draft: false,
});
expect(result.success).toBe(true);
expect(result.data?.prUrl).toBe('https://github.com/owner/repo/pull/123');
});
it('should validate PR title length', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_CREATE_PR);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', autoBuildPath: '.auto-claude' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
mockExistsSync.mockReturnValue(true);
mockStatSync.mockReturnValue({ isDirectory: () => true });
const longTitle = 'a'.repeat(300);
const result = await handler!({}, 'task-1', {
title: longTitle,
body: 'Test',
draft: false,
});
expect(result.success).toBe(false);
expect(result.error).toContain('exceeds maximum length');
});
it('should validate PR title characters', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_CREATE_PR);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', autoBuildPath: '.auto-claude' },
});
findTaskWorktree.mockReturnValue('/mock/worktrees/tasks/001-feature');
mockExistsSync.mockReturnValue(true);
mockStatSync.mockReturnValue({ isDirectory: () => true });
const invalidTitle = 'Test\x00PR'; // Null byte
const result = await handler!({}, 'task-1', {
title: invalidTitle,
body: 'Test',
draft: false,
});
expect(result.success).toBe(false);
expect(result.error).toContain('contains invalid characters');
});
it('should return error when worktree not found', async () => {
const handler = handlersRegistered.get(IPC_CHANNELS.TASK_WORKTREE_CREATE_PR);
findTaskAndProject.mockReturnValue({
task: { id: 'task-1', specId: '001-feature' },
project: { path: '/mock/project', autoBuildPath: '.auto-claude' },
});
// statSync should work (spec dir exists) but worktree not found
mockStatSync.mockReturnValue({ isDirectory: () => true });
findTaskWorktree.mockReturnValue(null);
const result = await handler!({}, 'task-1', {
title: 'Test PR',
body: 'Test',
draft: false,
});
expect(result.success).toBe(false);
expect(result.error).toBe('No worktree found for this task');
});
});
describe('GIT_BRANCH_REGEX validation', () => {
it('should validate valid branch names', async () => {
const { GIT_BRANCH_REGEX } = await import('../worktree-handlers');
expect(GIT_BRANCH_REGEX.test('main')).toBe(true);
expect(GIT_BRANCH_REGEX.test('feature/new-feature')).toBe(true);
expect(GIT_BRANCH_REGEX.test('auto-claude/001-test')).toBe(true);
expect(GIT_BRANCH_REGEX.test('bugfix.123')).toBe(true);
});
it('should reject invalid branch names', async () => {
const { GIT_BRANCH_REGEX } = await import('../worktree-handlers');
expect(GIT_BRANCH_REGEX.test('.hidden')).toBe(false);
// Git actually allows branch- and -branch in some cases, so adjust expectations
// The regex /^[a-zA-Z0-9][a-zA-Z0-9._/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/
// allows alphanumeric at start and end, with symbols in middle
expect(GIT_BRANCH_REGEX.test('-branch')).toBe(false); // starts with -
// branch- ends with - which is actually allowed by the second part: ^[a-zA-Z0-9]$
// Let's test something definitely invalid
expect(GIT_BRANCH_REGEX.test('..invalid')).toBe(false);
});
});
});
@@ -246,7 +246,7 @@ export function registerTaskExecutionHandlers(
// Start spec creation process - pass the existing spec directory
// so spec_runner uses it instead of creating a new one
// Also pass baseBranch so worktrees are created from the correct branch
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch, project.id);
} else if (needsImplementation) {
// Spec exists but no subtasks - run run.py to create implementation plan and execute
// Read the spec.md to get the task description
@@ -268,8 +268,10 @@ export function registerTaskExecutionHandlers(
parallel: false, // Sequential for planning phase
workers: 1,
baseBranch,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
} else {
// Task has subtasks, start normal execution
@@ -284,8 +286,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
}
}
@@ -495,7 +499,7 @@ export function registerTaskExecutionHandlers(
// The QA process needs to run where the implementation_plan.json with completed subtasks is
const qaProjectPath = hasWorktree ? worktreePath : project.path;
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
agentManager.startQAProcess(taskId, qaProjectPath, task.specId, project.id);
taskStateManager.handleUiEvent(
taskId,
@@ -727,7 +731,7 @@ export function registerTaskExecutionHandlers(
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.warn('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate, project.id);
} else if (needsImplementation) {
// Spec exists but no subtasks - run run.py to create implementation plan and execute
console.warn('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId);
@@ -739,8 +743,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch: baseBranchForUpdate,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
} else {
// Task has subtasks, start normal execution
@@ -754,8 +760,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch: baseBranchForUpdate,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
}
@@ -1108,7 +1116,7 @@ export function registerTaskExecutionHandlers(
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.warn(`[Recovery] Starting spec creation for: ${task.specId}`);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery);
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery, project.id);
} else {
// Spec exists - run task execution
console.warn(`[Recovery] Starting task execution for: ${task.specId}`);
@@ -1120,8 +1128,10 @@ export function registerTaskExecutionHandlers(
parallel: false,
workers: 1,
baseBranch: baseBranchForRecovery,
useWorktree: task.metadata?.useWorktree
}
useWorktree: task.metadata?.useWorktree,
useLocalBranch: task.metadata?.useLocalBranch
},
project.id
);
}
@@ -314,8 +314,8 @@ export function persistPlanPhaseSync(
const phaseToStatus: Record<string, TaskStatus> = {
'planning': 'in_progress',
'coding': 'in_progress',
'qa_review': 'in_progress',
'qa_fixing': 'in_progress',
'qa_review': 'ai_review',
'qa_fixing': 'ai_review',
'complete': 'human_review',
'failed': 'error'
};
@@ -2,21 +2,39 @@ import type { Task, Project } from '../../../shared/types';
import { projectStore } from '../../project-store';
/**
* Helper function to find task and project by taskId
* Helper function to find task and project by taskId.
*
* When projectId is provided, the search is strictly scoped to that project.
* If the task is not found in the specified project, returns undefined (does NOT
* fall back to other projects). This prevents cross-project contamination when
* multiple projects have tasks with the same specId.
*
* When projectId is NOT provided, searches all projects for backward
* compatibility with callers that don't have projectId (e.g., file watcher events).
*/
export const findTaskAndProject = (taskId: string): { task: Task | undefined; project: Project | undefined } => {
export const findTaskAndProject = (taskId: string, projectId?: string): { task: Task | undefined; project: Project | undefined } => {
const projects = projectStore.getProjects();
let task: Task | undefined;
let project: Project | undefined;
// If projectId provided, search ONLY that project (no fallback)
if (projectId) {
const targetProject = projects.find((p) => p.id === projectId);
if (!targetProject) {
console.warn(`[findTaskAndProject] projectId "${projectId}" not found in projects list, returning undefined`);
return { task: undefined, project: undefined };
}
const tasks = projectStore.getTasks(targetProject.id);
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
return { task, project: task ? targetProject : undefined };
}
// No projectId: search all projects (backward compatibility for file watcher etc.)
for (const p of projects) {
const tasks = projectStore.getTasks(p.id);
task = tasks.find((t) => t.id === taskId || t.specId === taskId);
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
if (task) {
project = p;
break;
return { task, project: p };
}
}
return { task, project };
return { task: undefined, project: undefined };
};
@@ -55,10 +55,11 @@ export function registerTerminalHandlers(
}
);
ipcMain.on(
ipcMain.handle(
IPC_CHANNELS.TERMINAL_RESIZE,
(_, id: string, cols: number, rows: number) => {
terminalManager.resize(id, cols, rows);
async (_, id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> => {
const success = terminalManager.resize(id, cols, rows);
return { success, data: { success } };
}
);
@@ -345,9 +345,9 @@ function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktree
async function createTerminalWorktree(
request: CreateTerminalWorktreeRequest
): Promise<TerminalWorktreeResult> {
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch } = request;
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch, useLocalBranch } = request;
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch });
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch, useLocalBranch });
// Validate projectPath against registered projects
if (!isValidProjectPath(projectPath)) {
@@ -418,8 +418,13 @@ async function createTerminalWorktree(
// Already a remote ref, use as-is
baseRef = baseBranch;
debugLog('[TerminalWorktree] Using remote ref directly:', baseRef);
} else if (useLocalBranch) {
// User explicitly requested local branch - skip auto-switch to remote
// This preserves gitignored files (.env, configs) that may not exist on remote
baseRef = baseBranch;
debugLog('[TerminalWorktree] Using local branch (explicit):', baseRef);
} else {
// Check if remote version exists and use it for latest code
// Default behavior: check if remote version exists and use it for latest code
try {
execFileSync(getToolPath('git'), ['rev-parse', '--verify', `origin/${baseBranch}`], {
cwd: projectPath,
+7 -3
View File
@@ -9,6 +9,7 @@ import * as path from 'path';
import * as os from 'os';
import { existsSync, readdirSync } from 'fs';
import { isWindows, isMacOS, getHomebrewPath, joinPaths, getExecutableExtension } from './index';
import { getWhereExePath } from '../utils/windows-paths';
/**
* Resolve Claude CLI executable path
@@ -174,7 +175,7 @@ export function getWindowsShellPaths(): Record<string, string[]> {
return {};
}
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
// Note: path.join('C:', 'foo') produces 'C:foo' (relative to C: drive), not 'C:\foo'
// We must use 'C:\\' or raw paths like 'C:\\Program Files' to get absolute paths
@@ -297,11 +298,14 @@ export function getOllamaInstallCommand(): string {
/**
* Get the command to find executables in PATH
*
* Windows: where.exe
* Windows: Full path to where.exe (C:\Windows\System32\where.exe)
* Using full path ensures it works even when System32 isn't in PATH,
* which can happen in restricted environments or when Electron doesn't
* inherit the full system PATH.
* Unix: which
*/
export function getWhichCommand(): string {
return isWindows() ? 'where.exe' : 'which';
return isWindows() ? getWhereExePath() : 'which';
}
/**
+20 -62
View File
@@ -3,7 +3,7 @@ import type { ActorRefFrom } from 'xstate';
import type { BrowserWindow } from 'electron';
import type { TaskEventPayload } from './agent/task-event-schema';
import type { Project, Task, TaskStatus, ReviewReason, ExecutionPhase } from '../shared/types';
import { taskMachine, type TaskEvent } from '../shared/state-machines';
import { taskMachine, XSTATE_TO_PHASE, mapStateToLegacy, type TaskEvent } from '../shared/state-machines';
import { IPC_CHANNELS } from '../shared/constants';
import { safeSendToRenderer } from './ipc-handlers/utils';
import { getPlanPath, persistPlanStatusAndReasonSync } from './ipc-handlers/task/plan-file-utils';
@@ -14,21 +14,6 @@ import path from 'path';
type TaskActor = ActorRefFrom<typeof taskMachine>;
/** Maps XState states to execution phases. Shared by mapStateToExecutionPhase and emitPhaseFromState. */
const XSTATE_TO_PHASE: Record<string, ExecutionPhase> = {
'backlog': 'idle',
'planning': 'planning',
'plan_review': 'planning',
'coding': 'coding',
'qa_review': 'qa_review',
'qa_fixing': 'qa_fixing',
'human_review': 'complete',
'error': 'failed',
'creating_pr': 'complete',
'pr_created': 'complete',
'done': 'complete'
};
interface TaskContextEntry {
task: Task;
project: Project;
@@ -36,6 +21,7 @@ interface TaskContextEntry {
const TERMINAL_EVENTS = new Set<string>([
'QA_PASSED',
'PLANNING_COMPLETE',
'PLANNING_FAILED',
'CODING_FAILED',
'QA_MAX_ITERATIONS',
@@ -57,10 +43,10 @@ export class TaskStateManager {
handleTaskEvent(taskId: string, event: TaskEventPayload, task: Task, project: Project): boolean {
const lastSeq = this.lastSequenceByTask.get(taskId);
console.log(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
console.debug(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
if (!this.isNewSequence(taskId, event.sequence)) {
console.log(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
console.debug(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
return false;
}
this.setTaskContext(taskId, task, project);
@@ -72,10 +58,10 @@ export class TaskStateManager {
const actor = this.getOrCreateActor(taskId);
const stateBefore = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
console.debug(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
actor.send(event as TaskEvent);
const stateAfter = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
console.debug(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
return true;
}
@@ -92,22 +78,26 @@ export class TaskStateManager {
return;
}
const actor = this.getOrCreateActor(taskId);
// Only mark as unexpected if the process exited with a non-zero code.
// A code-0 exit is normal (e.g., spec creation finished, plan created, waiting for review).
// Sending unexpected:true for code-0 exits incorrectly transitions plan_review → error.
const isUnexpected = exitCode !== 0;
actor.send({
type: 'PROCESS_EXITED',
exitCode: exitCode ?? -1,
unexpected: true
unexpected: isUnexpected
} satisfies TaskEvent);
}
handleUiEvent(taskId: string, event: TaskEvent, task: Task, project: Project): void {
console.log(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
console.debug(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
this.setTaskContext(taskId, task, project);
const actor = this.getOrCreateActor(taskId);
const stateBefore = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
console.debug(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
actor.send(event);
const stateAfter = String(actor.getSnapshot().value);
console.log(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
console.debug(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
}
handleManualStatusChange(taskId: string, status: TaskStatus, task: Task, project: Project): boolean {
@@ -220,7 +210,7 @@ export class TaskStateManager {
private getOrCreateActor(taskId: string): TaskActor {
const existing = this.actors.get(taskId);
if (existing) {
console.log(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
console.debug(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
return existing;
}
@@ -230,14 +220,14 @@ export class TaskStateManager {
: undefined;
if (contextEntry) {
console.log(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
console.debug(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
status: contextEntry.task.status,
reviewReason: contextEntry.task.reviewReason,
phase: contextEntry.task.executionProgress?.phase,
initialState: snapshot ? String(snapshot.value) : 'default (backlog)'
});
} else {
console.log(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
console.debug(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
}
const actor = snapshot
@@ -247,8 +237,7 @@ export class TaskStateManager {
const stateValue = String(snapshot.value);
const lastState = this.lastStateByTask.get(taskId);
// Debug: Log all state transitions
console.log(`[TaskStateManager] XState transition for ${taskId}:`, {
console.debug(`[TaskStateManager] XState transition for ${taskId}:`, {
from: lastState,
to: stateValue,
contextReviewReason: snapshot.context.reviewReason
@@ -273,8 +262,7 @@ export class TaskStateManager {
// Map XState state to execution phase for persistence
const executionPhase = this.mapStateToExecutionPhase(stateValue);
// Debug: Log the mapped status and reviewReason
console.log(`[TaskStateManager] Emitting status for ${taskId}:`, {
console.debug(`[TaskStateManager] Emitting status for ${taskId}:`, {
status,
reviewReason,
xstateState: stateValue,
@@ -338,7 +326,7 @@ export class TaskStateManager {
console.warn(`[TaskStateManager] emitStatus: No main window, cannot emit status ${status} for ${taskId}`);
return;
}
console.log(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
console.debug(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
safeSendToRenderer(
this.getMainWindow,
IPC_CHANNELS.TASK_STATUS_CHANGE,
@@ -441,33 +429,3 @@ export class TaskStateManager {
}
export const taskStateManager = new TaskStateManager();
function mapStateToLegacy(
state: string,
reviewReason?: ReviewReason
): { status: TaskStatus; reviewReason?: ReviewReason } {
switch (state) {
case 'backlog':
return { status: 'backlog' };
case 'planning':
case 'coding':
return { status: 'in_progress' };
case 'plan_review':
return { status: 'human_review', reviewReason: 'plan_review' };
case 'qa_review':
case 'qa_fixing':
return { status: 'ai_review' };
case 'human_review':
return { status: 'human_review', reviewReason };
case 'error':
return { status: 'human_review', reviewReason: 'errors' };
case 'creating_pr':
return { status: 'human_review', reviewReason: 'completed' };
case 'pr_created':
return { status: 'pr_created' };
case 'done':
return { status: 'done' };
default:
return { status: 'backlog' };
}
}
@@ -377,23 +377,12 @@ export class TerminalSessionStore {
todaySessions[projectPath] = [];
}
// Debug: Log incoming outputBuffer info
const incomingBufferLen = session.outputBuffer?.length ?? 0;
debugLog('[TerminalSessionStore] Updating session in memory:', session.id,
'incoming outputBuffer:', incomingBufferLen, 'bytes',
'isClaudeMode:', session.isClaudeMode);
// Update existing or add new
const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id);
if (existingIndex >= 0) {
// Preserve displayOrder from existing session if not provided in incoming session
// This prevents periodic saves (which don't include displayOrder) from losing tab order
const existingSession = todaySessions[projectPath][existingIndex];
const existingBufferLen = existingSession.outputBuffer?.length ?? 0;
const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
debugLog('[TerminalSessionStore] Updating existing session:', session.id,
'existing outputBuffer:', existingBufferLen, 'bytes',
'new outputBuffer (after truncation):', truncatedLen, 'bytes');
todaySessions[projectPath][existingIndex] = {
...session,
@@ -404,10 +393,6 @@ export class TerminalSessionStore {
displayOrder: session.displayOrder ?? existingSession.displayOrder,
};
} else {
const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
debugLog('[TerminalSessionStore] Creating new session:', session.id,
'outputBuffer (after truncation):', truncatedLen, 'bytes');
todaySessions[projectPath].push({
...session,
outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER),
@@ -10,7 +10,7 @@ import * as path from 'path';
import * as crypto from 'crypto';
import { IPC_CHANNELS } from '../../shared/constants';
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils';
import { getFullCredentialsFromKeychain, clearKeychainCache, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
import { getUsageMonitor } from '../claude-profile/usage-monitor';
import { getEmailFromConfigDir } from '../claude-profile/profile-utils';
import * as OutputParser from './output-parser';
@@ -486,8 +486,8 @@ export function handleOAuthToken(
// Clear Keychain cache to get fresh credentials
clearKeychainCache(profile.configDir);
// Extract token from Keychain using the profile's configDir
const keychainCreds = getCredentialsFromKeychain(profile.configDir, true);
// Extract full credentials from Keychain including subscriptionType and rateLimitTier
const keychainCreds = getFullCredentialsFromKeychain(profile.configDir);
// Check if there was a keychain access error (not just "not found")
if (keychainCreds.error) {
@@ -525,6 +525,8 @@ export function handleOAuthToken(
if (email) {
profile.email = email;
}
// Update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(profile, keychainCreds);
profile.isAuthenticated = true;
profileManager.saveProfile(profile);
@@ -611,6 +613,8 @@ export function handleOAuthToken(
if (email) {
profile.email = email;
}
// Update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(profile, profile.configDir);
profile.isAuthenticated = true;
profileManager.saveProfile(profile);
@@ -673,6 +677,8 @@ export function handleOAuthToken(
if (email) {
activeProfile.email = email;
}
// Update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(activeProfile, activeProfile.configDir);
activeProfile.isAuthenticated = true;
profileManager.saveProfile(activeProfile);
@@ -766,11 +772,13 @@ export function handleOnboardingComplete(
bufferLength: terminal.outputBuffer.length
});
// Update profile with email if found and profile exists
// Update profile with email and subscription metadata if found and profile exists
// Always update - the newly extracted email from re-authentication should overwrite any stale/truncated email
if (profileId && email && profile) {
const previousEmail = profile.email;
profile.email = email;
// Also update subscription metadata from Keychain credentials
updateProfileSubscriptionMetadata(profile, profile.configDir);
profileManager.saveProfile(profile);
if (previousEmail !== email) {
console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')');
+24 -3
View File
@@ -168,6 +168,7 @@ export function spawnPtyProcess(
const shellArgs = isWindows() ? [] : ['-l'];
debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ', shellType:', shellType, ')');
debugLog('[PtyManager] PTY dimensions requested - cols:', cols, 'rows:', rows, 'cwd:', cwd || os.homedir());
// Create a clean environment without DEBUG to prevent Claude Code from
// enabling debug mode when the Electron app is run in development mode.
@@ -355,10 +356,30 @@ export function writeToPty(terminal: TerminalProcess, data: string): void {
}
/**
* Resize a PTY process
* Resize a PTY process with validation and error handling.
* @param terminal The terminal process to resize
* @param cols New column count
* @param rows New row count
* @returns true if resize was successful, false otherwise
*/
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): void {
terminal.pty.resize(cols, rows);
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): boolean {
// Validate dimensions
if (cols <= 0 || rows <= 0 || !Number.isFinite(cols) || !Number.isFinite(rows)) {
debugError('[PtyManager] Invalid resize dimensions - terminal:', terminal.id, 'cols:', cols, 'rows:', rows);
return false;
}
try {
const prevCols = terminal.pty.cols;
const prevRows = terminal.pty.rows;
debugLog('[PtyManager] Resizing PTY - terminal:', terminal.id, 'from:', prevCols, 'x', prevRows, 'to:', cols, 'x', rows);
terminal.pty.resize(cols, rows);
debugLog('[PtyManager] PTY resized - actual dimensions now:', terminal.pty.cols, 'x', terminal.pty.rows);
return true;
} catch (error) {
debugError('[PtyManager] Resize failed for terminal:', terminal.id, 'error:', error);
return false;
}
}
/**
@@ -137,12 +137,14 @@ export class TerminalManager {
/**
* Resize a terminal
* @returns true if resize was successful, false otherwise
*/
resize(id: string, cols: number, rows: number): void {
resize(id: string, cols: number, rows: number): boolean {
const terminal = this.terminals.get(id);
if (terminal) {
PtyManager.resizePty(terminal, cols, rows);
if (!terminal) {
return false;
}
return PtyManager.resizePty(terminal, cols, rows);
}
/**
+21 -6
View File
@@ -130,6 +130,19 @@ export function getWindowsExecutablePaths(
return validPaths;
}
/**
* Get the full path to where.exe.
* Using the full path ensures where.exe works even when System32 isn't in PATH,
* which can happen in restricted environments or when Electron doesn't inherit
* the full system PATH.
*
* @returns Full path to where.exe (e.g., C:\Windows\System32\where.exe)
*/
export function getWhereExePath(): string {
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
return path.join(systemRoot, 'System32', 'where.exe');
}
/**
* Find a Windows executable using the `where` command.
* This is the most reliable method as it searches:
@@ -158,9 +171,10 @@ export function findWindowsExecutableViaWhere(
}
try {
// Use 'where' command to find the executable
// where.exe is a built-in Windows command that finds executables
const result = execFileSync('where.exe', [executable], {
// Use full path to where.exe to ensure it works even when System32 isn't in PATH
// This fixes issues in restricted environments or when Electron doesn't inherit system PATH
const whereExe = getWhereExePath();
const result = execFileSync(whereExe, [executable], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
@@ -261,9 +275,10 @@ export async function findWindowsExecutableViaWhereAsync(
}
try {
// Use 'where' command to find the executable
// where.exe is a built-in Windows command that finds executables
const { stdout } = await execFileAsync('where.exe', [executable], {
// Use full path to where.exe to ensure it works even when System32 isn't in PATH
// This fixes issues in restricted environments or when Electron doesn't inherit system PATH
const whereExe = getWhereExePath();
const { stdout } = await execFileAsync(whereExe, [executable], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
@@ -269,6 +269,8 @@ export interface GitHubAPI {
// PR operations (fetches up to 100 open PRs at once - GitHub GraphQL limit)
listPRs: (projectId: string) => Promise<PRListResult>;
/** Load more PRs using cursor-based pagination */
listMorePRs: (projectId: string, cursor: string) => Promise<PRListResult>;
getPR: (projectId: string, prNumber: number) => Promise<PRData | null>;
runPRReview: (projectId: string, prNumber: number) => void;
cancelPRReview: (projectId: string, prNumber: number) => Promise<boolean>;
@@ -338,6 +340,7 @@ export interface PRData {
export interface PRListResult {
prs: PRData[];
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
}
/**
@@ -675,6 +678,10 @@ export const createGitHubAPI = (): GitHubAPI => ({
listPRs: (projectId: string): Promise<PRListResult> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId),
// Load more PRs using cursor-based pagination
listMorePRs: (projectId: string, cursor: string): Promise<PRListResult> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST_MORE, projectId, cursor),
getPR: (projectId: string, prNumber: number): Promise<PRData | null> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_GET, projectId, prNumber),
+8 -1
View File
@@ -12,7 +12,8 @@ import type {
GraphitiValidationResult,
GraphitiConnectionTestResult,
GitStatus,
KanbanPreferences
KanbanPreferences,
GitBranchDetail
} from '../../shared/types';
// Tab state interface (persisted in main process)
@@ -97,7 +98,10 @@ export interface ProjectAPI {
}) => void) => () => void;
// Git Operations
/** @deprecated Use getGitBranchesWithInfo for structured branch data with type indicators */
getGitBranches: (projectPath: string) => Promise<IPCResult<string[]>>;
/** Get branches with structured type information (local vs remote) */
getGitBranchesWithInfo: (projectPath: string) => Promise<IPCResult<GitBranchDetail[]>>;
getCurrentGitBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
detectMainBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
checkGitStatus: (projectPath: string) => Promise<IPCResult<GitStatus>>;
@@ -277,6 +281,9 @@ export const createProjectAPI = (): ProjectAPI => ({
getGitBranches: (projectPath: string): Promise<IPCResult<string[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES, projectPath),
getGitBranchesWithInfo: (projectPath: string): Promise<IPCResult<GitBranchDetail[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO, projectPath),
getCurrentGitBranch: (projectPath: string): Promise<IPCResult<string | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_CURRENT_BRANCH, projectPath),
@@ -33,7 +33,7 @@ export interface TerminalAPI {
createTerminal: (options: TerminalCreateOptions) => Promise<IPCResult>;
destroyTerminal: (id: string) => Promise<IPCResult>;
sendTerminalInput: (id: string, data: string) => void;
resizeTerminal: (id: string, cols: number, rows: number) => void;
resizeTerminal: (id: string, cols: number, rows: number) => Promise<IPCResult<{ success: boolean }>>;
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
generateTerminalName: (command: string, cwd?: string) => Promise<IPCResult<string>>;
setTerminalTitle: (id: string, title: string) => void;
@@ -137,8 +137,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
sendTerminalInput: (id: string, data: string): void =>
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, id, data),
resizeTerminal: (id: string, cols: number, rows: number): void =>
ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows),
resizeTerminal: (id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> =>
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows),
invokeClaudeInTerminal: (id: string, cwd?: string): void =>
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd),
+8
View File
@@ -9,3 +9,11 @@ contextBridge.exposeInMainWorld('electronAPI', electronAPI);
// Expose debug flag for debug logging
contextBridge.exposeInMainWorld('DEBUG', process.env.DEBUG === 'true');
// Expose platform information for platform-specific behavior (e.g., PTY resize timing)
contextBridge.exposeInMainWorld('platform', {
isWindows: process.platform === 'win32',
isMacOS: process.platform === 'darwin',
isLinux: process.platform === 'linux',
isUnix: process.platform !== 'win32',
});
@@ -192,6 +192,42 @@ describe('Task Store', () => {
originalDate.getTime()
);
});
it('should apply reviewReason when provided', () => {
useTaskStore.setState({
tasks: [createTestTask({ id: 'task-1', status: 'in_progress' })]
});
useTaskStore.getState().updateTaskStatus('task-1', 'human_review', 'plan_review');
const task = useTaskStore.getState().tasks[0];
expect(task.status).toBe('human_review');
expect(task.reviewReason).toBe('plan_review');
});
it('should clear reviewReason when not provided', () => {
useTaskStore.setState({
tasks: [createTestTask({ id: 'task-1', status: 'human_review', reviewReason: 'plan_review' })]
});
useTaskStore.getState().updateTaskStatus('task-1', 'in_progress');
const task = useTaskStore.getState().tasks[0];
expect(task.status).toBe('in_progress');
expect(task.reviewReason).toBeUndefined();
});
it('should update when only reviewReason changes', () => {
useTaskStore.setState({
tasks: [createTestTask({ id: 'task-1', status: 'human_review', reviewReason: 'plan_review' })]
});
useTaskStore.getState().updateTaskStatus('task-1', 'human_review', 'completed');
const task = useTaskStore.getState().tasks[0];
expect(task.status).toBe('human_review');
expect(task.reviewReason).toBe('completed');
});
});
describe('updateTaskFromPlan', () => {
@@ -23,7 +23,6 @@ import {
} from './ui/tooltip';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../stores/settings-store';
import { useClaudeProfileStore } from '../stores/claude-profile-store';
import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection';
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
@@ -50,13 +49,8 @@ const OAUTH_FALLBACK = {
} as const;
export function AuthStatusIndicator() {
// Subscribe to profile state from settings store (API profiles)
// Subscribe to profile state from settings store
const { profiles, activeProfileId } = useSettingsStore();
// Subscribe to Claude OAuth profile state
const claudeProfiles = useClaudeProfileStore((state) => state.profiles);
const activeClaudeProfileId = useClaudeProfileStore((state) => state.activeProfileId);
const { t } = useTranslation(['common']);
// Track usage data for warning badge
@@ -108,7 +102,6 @@ export function AuthStatusIndicator() {
// Compute auth status and provider detection using useMemo to avoid unnecessary re-renders
const authStatus = useMemo(() => {
// First check if user is using API profile auth (has active API profile)
if (activeProfileId) {
const activeProfile = profiles.find(p => p.id === activeProfileId);
if (activeProfile) {
@@ -126,36 +119,12 @@ export function AuthStatusIndicator() {
badgeColor: getProviderBadgeColor(provider)
};
}
// Profile ID set but profile not found - fallback to OAuth
return OAUTH_FALLBACK;
}
// No active API profile - check Claude OAuth profiles directly
if (activeClaudeProfileId && claudeProfiles.length > 0) {
const activeClaudeProfile = claudeProfiles.find(p => p.id === activeClaudeProfileId);
if (activeClaudeProfile) {
return {
type: 'oauth' as const,
name: activeClaudeProfile.email || activeClaudeProfile.name,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}
}
// Fallback to usage data if Claude profiles aren't loaded yet
if (usage && (usage.profileName || usage.profileEmail)) {
return {
type: 'oauth' as const,
name: usage.profileEmail || usage.profileName,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}
// No auth info available - fallback to generic OAuth
// No active profile - using OAuth
return OAUTH_FALLBACK;
}, [activeProfileId, profiles, activeClaudeProfileId, claudeProfiles, usage]);
}, [activeProfileId, profiles]);
// Helper function to truncate ID for display
const truncateId = (id: string): string => {
@@ -305,22 +274,6 @@ export function AuthStatusIndicator() {
</div>
</>
)}
{/* Account details for OAuth profiles */}
{isOAuth && authStatus.name && authStatus.name !== 'OAuth' && (
<>
<div className="pt-2 border-t space-y-2">
{/* Account name/email with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lock className="h-3 w-3" />
<span className="text-[10px]">{t('common:usage.account')}</span>
</div>
<span className="font-medium text-[10px]">{authStatus.name}</span>
</div>
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
@@ -24,6 +24,7 @@ import { Checkbox } from './ui/checkbox';
import { Progress } from './ui/progress';
import { ScrollArea } from './ui/scroll-area';
import type { Task, WorktreeCreatePRResult } from '../../shared/types';
import { useTaskStore } from '../stores/task-store';
/**
* Check if an error message indicates a worktree-related issue (missing worktree, no branch, etc.)
@@ -154,6 +155,15 @@ export function BulkPRDialog({
error: data.success ? undefined : (data.error || t('taskReview:pr.errors.unknown'))
} : r
));
// Update task state in store with new status and prUrl (more efficient than reloading all tasks)
if (data.success && data.prUrl && !data.alreadyExists) {
const currentTask = tasks[i];
useTaskStore.getState().updateTask(currentTask.id, {
status: 'done',
metadata: { ...currentTask.metadata, prUrl: data.prUrl }
});
}
} else {
const errorMsg = prResult?.error || '';
setTaskResults(prev => prev.map((r, idx) =>
@@ -28,7 +28,6 @@ import { TaskCard } from './TaskCard';
import { SortableTaskCard } from './SortableTaskCard';
import { QueueSettingsModal } from './QueueSettingsModal';
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
import { debugLog } from '../../shared/utils/debug-logger';
import { cn } from '../lib/utils';
import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore } from '../stores/task-store';
import { updateProjectSettings, useProjectStore } from '../stores/project-store';
@@ -1068,74 +1067,29 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
isProcessingQueueRef.current = true;
try {
// Track tasks we've already processed in this call to prevent duplicates
// This is critical because store updates happen synchronously but we need to ensure
// we never process the same task twice, even if there are timing issues
const processedTaskIds = new Set<string>();
// Track tasks we've already attempted to promote (to avoid infinite retries)
const attemptedTaskIds = new Set<string>();
let consecutiveFailures = 0;
const MAX_CONSECUTIVE_FAILURES = 10; // Safety limit to prevent infinite loop
// Track promotions in this call to enforce max parallel tasks limit
let promotedInThisCall = 0;
// Log initial state
const initialTasks = useTaskStore.getState().tasks;
const initialInProgress = initialTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const initialQueued = initialTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);
debugLog(`[Queue] === PROCESS QUEUE START ===`, {
maxParallelTasks,
initialInProgressCount: initialInProgress.length,
initialInProgressIds: initialInProgress.map(t => t.id),
initialQueuedCount: initialQueued.length,
initialQueuedIds: initialQueued.map(t => t.id),
projectId
});
// Loop until capacity is full or queue is empty
let iteration = 0;
while (true) {
iteration++;
// Calculate total in-progress count: tasks that were already in progress + tasks promoted in this call
const totalInProgressCount = initialInProgress.length + promotedInThisCall;
debugLog(`[Queue] --- Iteration ${iteration} ---`, {
initialInProgressCount: initialInProgress.length,
promotedInThisCall,
totalInProgressCount,
capacityCheck: totalInProgressCount >= maxParallelTasks,
processedCount: processedTaskIds.size
});
// Stop if no capacity (initial in-progress + promoted in this call)
if (totalInProgressCount >= maxParallelTasks) {
debugLog(`[Queue] Capacity reached (${totalInProgressCount}/${maxParallelTasks}), stopping queue processing`);
break;
}
// Get CURRENT state from store to find queued tasks
const latestTasks = useTaskStore.getState().tasks;
const latestInProgress = latestTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const queuedTasks = latestTasks.filter((t) =>
t.status === 'queue' && !t.metadata?.archivedAt && !processedTaskIds.has(t.id)
// Get CURRENT state from store to ensure accuracy
const currentTasks = useTaskStore.getState().tasks;
const inProgressCount = currentTasks.filter((t) =>
t.status === 'in_progress' && !t.metadata?.archivedAt
).length;
const queuedTasks = currentTasks.filter((t) =>
t.status === 'queue' && !t.metadata?.archivedAt && !attemptedTaskIds.has(t.id)
);
debugLog(`[Queue] Current store state:`, {
totalTasks: latestTasks.length,
inProgressCount: latestInProgress.length,
inProgressIds: latestInProgress.map(t => t.id),
queuedCount: queuedTasks.length,
queuedIds: queuedTasks.map(t => t.id),
processedIds: Array.from(processedTaskIds)
});
// Stop if no queued tasks or too many consecutive failures
if (queuedTasks.length === 0) {
debugLog('[Queue] No more queued tasks to process');
// Stop if no capacity, no queued tasks, or too many consecutive failures
if (inProgressCount >= maxParallelTasks || queuedTasks.length === 0) {
break;
}
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
debugLog(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
console.warn(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
break;
}
@@ -1146,62 +1100,28 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
return dateA - dateB; // Ascending order (oldest first)
})[0];
debugLog(`[Queue] Selected task for promotion:`, {
id: nextTask.id,
currentStatus: nextTask.status,
title: nextTask.title?.substring(0, 50)
});
// Mark task as processed BEFORE attempting promotion to prevent duplicates
processedTaskIds.add(nextTask.id);
debugLog(`[Queue] Promoting task ${nextTask.id} (${promotedInThisCall + 1}/${maxParallelTasks})`);
console.log(`[Queue] Auto-promoting task ${nextTask.id} from Queue to In Progress (${inProgressCount + 1}/${maxParallelTasks})`);
const result = await persistTaskStatus(nextTask.id, 'in_progress');
// Check store state after promotion
const afterPromoteTasks = useTaskStore.getState().tasks;
const afterPromoteInProgress = afterPromoteTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
const afterPromoteQueued = afterPromoteTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);
debugLog(`[Queue] After promotion attempt:`, {
resultSuccess: result.success,
promotedInThisCall,
inProgressCount: afterPromoteInProgress.length,
inProgressIds: afterPromoteInProgress.map(t => t.id),
queuedCount: afterPromoteQueued.length,
queuedIds: afterPromoteQueued.map(t => t.id)
});
if (result.success) {
// Increment our local promotion counter
promotedInThisCall++;
// Reset consecutive failures on success
consecutiveFailures = 0;
} else {
// If promotion failed, log error and continue to next task
// If promotion failed, log error, mark as attempted, and skip to next task
console.error(`[Queue] Failed to promote task ${nextTask.id} to In Progress:`, result.error);
attemptedTaskIds.add(nextTask.id);
consecutiveFailures++;
}
}
// Log summary
debugLog(`[Queue] === PROCESS QUEUE COMPLETE ===`, {
totalIterations: iteration,
tasksProcessed: processedTaskIds.size,
tasksPromoted: promotedInThisCall,
processedIds: Array.from(processedTaskIds)
});
// Trigger UI refresh if tasks were promoted to ensure UI reflects all changes
// This handles the case where store updates are batched/delayed via IPC events
if (promotedInThisCall > 0 && onRefresh) {
debugLog('[Queue] Triggering UI refresh after queue promotion');
onRefresh();
// Log if we had failed tasks
if (attemptedTaskIds.size > 0) {
console.warn(`[Queue] Skipped ${attemptedTaskIds.size} task(s) that failed to promote`);
}
} finally {
isProcessingQueueRef.current = false;
}
}, [maxParallelTasks, projectId, onRefresh]);
}, [maxParallelTasks]);
// Register task status change listener for queue auto-promotion
// This ensures processQueue() is called whenever a task leaves in_progress
@@ -1210,7 +1130,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
(taskId, oldStatus, newStatus) => {
// When a task leaves in_progress (e.g., goes to human_review), process the queue
if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
debugLog(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
console.log(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
processQueue();
}
}
@@ -105,7 +105,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
/>
{/* Content */}
<div className="flex-1 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<RoadmapTabs
roadmap={roadmap}
activeTab={activeTab}
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import { Loader2, ChevronDown, ChevronUp, RotateCcw, FolderTree, GitBranch, Info } from 'lucide-react';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { Combobox, type ComboboxOption } from './ui/combobox';
import { Combobox } from './ui/combobox';
import { TaskModalLayout } from './task-form/TaskModalLayout';
import { TaskFormFields } from './task-form/TaskFormFields';
import { type FileReferenceData } from './task-form/useImageUpload';
@@ -23,8 +23,9 @@ import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer';
import { FileAutocomplete } from './FileAutocomplete';
import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store';
import { useProjectStore } from '../stores/project-store';
import { buildBranchOptions } from '../lib/branch-utils';
import { cn } from '../lib/utils';
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types';
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile, GitBranchDetail } from '../../shared/types';
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
import {
DEFAULT_AGENT_PROFILES,
@@ -62,8 +63,8 @@ export function TaskCreationWizard({
const [showFileExplorer, setShowFileExplorer] = useState(false);
const [showGitOptions, setShowGitOptions] = useState(false);
// Git options state
const [branches, setBranches] = useState<string[]>([]);
// Git options state - using structured GitBranchDetail for type indicators
const [branches, setBranches] = useState<GitBranchDetail[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
@@ -77,22 +78,27 @@ export function TaskCreationWizard({
return project?.path ?? null;
}, [projects, projectId]);
// Convert branches to ComboboxOption[] format for searchable dropdown
const branchOptions: ComboboxOption[] = useMemo(() => {
const options: ComboboxOption[] = [
{
// Build branch options using shared utility - groups by local/remote with type indicators
const branchOptions = useMemo(() => {
return buildBranchOptions(branches, {
t,
includeProjectDefault: {
value: PROJECT_DEFAULT_BRANCH,
label: projectDefaultBranch
? t('tasks:wizard.gitOptions.useProjectDefaultWithBranch', { branch: projectDefaultBranch })
: t('tasks:wizard.gitOptions.useProjectDefault')
}
];
branches.forEach((branch) => {
options.push({ value: branch, label: branch });
branchName: projectDefaultBranch,
labelKey: projectDefaultBranch
? 'tasks:wizard.gitOptions.useProjectDefaultWithBranch'
: 'tasks:wizard.gitOptions.useProjectDefault',
},
});
return options;
}, [branches, projectDefaultBranch, t]);
// Determine if the selected branch is local (for useLocalBranch flag)
const isSelectedBranchLocal = useMemo(() => {
if (baseBranch === PROJECT_DEFAULT_BRANCH) return false;
const selectedGitBranchDetail = branches.find((b) => b.name === baseBranch);
return selectedGitBranchDetail?.type === 'local';
}, [baseBranch, branches]);
// Classification fields
const [category, setCategory] = useState<TaskCategory | ''>('');
const [priority, setPriority] = useState<TaskPriority | ''>('');
@@ -187,7 +193,7 @@ export function TaskCreationWizard({
}
}, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel, selectedProfile.phaseModels, selectedProfile.phaseThinking]);
// Fetch branches when dialog opens
// Fetch branches when dialog opens - using structured branch data with type indicators
useEffect(() => {
let isMounted = true;
@@ -195,7 +201,8 @@ export function TaskCreationWizard({
if (!projectPath) return;
if (isMounted) setIsLoadingBranches(true);
try {
const result = await window.electronAPI.getGitBranches(projectPath);
// Use structured branch data with type indicators
const result = await window.electronAPI.getGitBranchesWithInfo(projectPath);
if (isMounted && result.success && result.data) {
setBranches(result.data);
}
@@ -432,6 +439,9 @@ export function TaskCreationWizard({
}
// Pass worktree preference - false means use --direct mode
if (!useWorktree) metadata.useWorktree = false;
// Set useLocalBranch when user explicitly selects a local branch
// This preserves gitignored files (.env, configs) by not switching to origin
if (isSelectedBranchLocal) metadata.useLocalBranch = true;
const task = await createTask(projectId, title.trim(), description.trim(), metadata);
if (task) {
@@ -15,11 +15,30 @@ import { usePtyProcess } from './terminal/usePtyProcess';
import { useTerminalEvents } from './terminal/useTerminalEvents';
import { useAutoNaming } from './terminal/useAutoNaming';
import { useTerminalFileDrop } from './terminal/useTerminalFileDrop';
import { debugLog } from '../../shared/utils/debug-logger';
import { isWindows as checkIsWindows } from '../lib/os-detection';
// Minimum dimensions to prevent PTY creation with invalid sizes
const MIN_COLS = 10;
const MIN_ROWS = 3;
// Platform detection for platform-specific timing
// Windows ConPTY is slower than Unix PTY, so we need longer grace periods
const platformIsWindows = checkIsWindows();
// Threshold in milliseconds to allow for async PTY resize acknowledgment
// Mismatches within this window after a resize are expected and not logged as warnings
// Windows needs longer grace period due to slower ConPTY resize
const DIMENSION_MISMATCH_GRACE_PERIOD_MS = platformIsWindows ? 500 : 100;
// Cooldown between auto-corrections to prevent rapid-fire corrections
// Windows needs longer cooldown due to slower ConPTY operations
const AUTO_CORRECTION_COOLDOWN_MS = platformIsWindows ? 1000 : 300;
// Auto-correction frequency monitoring
const AUTO_CORRECTION_WARNING_THRESHOLD = 5; // Warn if > 5 corrections per minute
const AUTO_CORRECTION_WINDOW_MS = 60000; // 1 minute window
/**
* Handle interface exposed by Terminal component for external control.
* Used by parent components (e.g., SortableTerminalWrapper) to trigger operations
@@ -57,6 +76,23 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// Track last sent PTY dimensions to prevent redundant resize calls
// This ensures terminal.resize() stays in sync with PTY dimensions
const lastPtyDimensionsRef = useRef<{ cols: number; rows: number } | null>(null);
// Track when the last resize was sent to PTY for grace period logic
// This prevents false positive mismatch warnings during async resize acknowledgment
const lastResizeTimeRef = useRef<number>(0);
// Track previous isExpanded state to detect actual expansion changes
// This prevents forcing PTY resize on initial mount (only on actual state changes)
const prevIsExpandedRef = useRef<boolean | undefined>(undefined);
// Track when last auto-correction was performed to implement cooldown
const lastAutoCorrectionTimeRef = useRef<number>(0);
// Track auto-correction frequency to detect potential deeper issues
// If corrections exceed threshold, it may indicate a persistent sync problem
const autoCorrectionCountRef = useRef<number>(0);
const autoCorrectionWindowStartRef = useRef<number>(Date.now());
// Sequence number for resize operations to prevent race conditions
// When concurrent resize calls complete out-of-order, only the latest result is applied
const resizeSequenceRef = useRef<number>(0);
// Track post-creation dimension check timeout for cleanup
const postCreationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Worktree dialog state
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false);
@@ -108,18 +144,131 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// Track when xterm dimensions are ready for PTY creation
const [readyDimensions, setReadyDimensions] = useState<{ cols: number; rows: number } | null>(null);
/**
* Helper function to resize PTY with proper dimension tracking and race condition prevention.
* Uses sequence numbers to ensure only the latest resize result updates the tracked dimensions.
* This prevents stale dimension corruption when concurrent resize calls complete out-of-order.
*
* @param cols - Target column count
* @param rows - Target row count
* @param context - Context string for debug logging (e.g., "onResize", "performFit")
*/
const resizePtyWithTracking = useCallback((cols: number, rows: number, context: string) => {
// Increment sequence number for this resize operation
const sequence = ++resizeSequenceRef.current;
lastResizeTimeRef.current = Date.now();
window.electronAPI.resizeTerminal(id, cols, rows).then((result) => {
// Only update dimensions if this is still the latest resize operation
// This prevents race conditions where an earlier failed call overwrites a later successful one
if (sequence !== resizeSequenceRef.current) {
debugLog(`[Terminal ${id}] ${context}: Ignoring stale resize result (sequence ${sequence} vs current ${resizeSequenceRef.current})`);
return;
}
if (result.success) {
lastPtyDimensionsRef.current = { cols, rows };
} else {
debugLog(`[Terminal ${id}] ${context} resize failed: ${result.error || 'unknown error'}`);
}
}).catch((error) => {
// Only log if this is still the latest operation
if (sequence === resizeSequenceRef.current) {
debugLog(`[Terminal ${id}] ${context} resize error: ${error}`);
}
});
}, [id]);
// Callback when xterm has measured valid dimensions
const handleDimensionsReady = useCallback((cols: number, rows: number) => {
// Only set dimensions if they're valid (above minimum thresholds)
if (cols >= MIN_COLS && rows >= MIN_ROWS) {
debugLog(`[Terminal ${id}] handleDimensionsReady: cols=${cols}, rows=${rows} - setting readyDimensions`);
setReadyDimensions({ cols, rows });
} else {
debugLog(`[Terminal ${id}] handleDimensionsReady: dimensions below minimum: cols=${cols} (min=${MIN_COLS}), rows=${rows} (min=${MIN_ROWS})`);
}
}, []);
}, [id]);
/**
* Check for dimension mismatch between xterm and PTY.
* Logs a warning if dimensions differ outside the grace period after a resize.
* This helps diagnose text alignment issues that can occur when xterm and PTY
* have different ideas about terminal dimensions.
*
* @param xtermCols - Current xterm column count
* @param xtermRows - Current xterm row count
* @param context - Optional context string for the log message (e.g., "after resize", "on fit")
* @param autoCorrect - If true, automatically correct mismatches by resizing PTY
*/
const checkDimensionMismatch = useCallback((
xtermCols: number,
xtermRows: number,
context?: string,
autoCorrect: boolean = false
) => {
const ptyDims = lastPtyDimensionsRef.current;
// Skip check if PTY hasn't been created yet (no dimensions to compare)
if (!ptyDims) {
return;
}
// Skip check if we're within the grace period after a resize
// This prevents false positives during async PTY resize acknowledgment
const timeSinceLastResize = Date.now() - lastResizeTimeRef.current;
if (timeSinceLastResize < DIMENSION_MISMATCH_GRACE_PERIOD_MS) {
return;
}
// Check for mismatch
const colsMismatch = xtermCols !== ptyDims.cols;
const rowsMismatch = xtermRows !== ptyDims.rows;
if (colsMismatch || rowsMismatch) {
const contextStr = context ? ` (${context})` : '';
debugLog(
`[Terminal ${id}] DIMENSION MISMATCH DETECTED${contextStr}: ` +
`xterm=(cols=${xtermCols}, rows=${xtermRows}) vs PTY=(cols=${ptyDims.cols}, rows=${ptyDims.rows}) - ` +
`delta=(cols=${xtermCols - ptyDims.cols}, rows=${xtermRows - ptyDims.rows})`
);
// Auto-correct if enabled, PTY is created, and cooldown has passed
const timeSinceAutoCorrect = Date.now() - lastAutoCorrectionTimeRef.current;
if (
autoCorrect &&
isCreatedRef.current &&
timeSinceAutoCorrect >= AUTO_CORRECTION_COOLDOWN_MS &&
xtermCols >= MIN_COLS &&
xtermRows >= MIN_ROWS
) {
// Track auto-correction frequency for monitoring
const now = Date.now();
if (now - autoCorrectionWindowStartRef.current >= AUTO_CORRECTION_WINDOW_MS) {
// Log warning if previous window had excessive corrections
if (autoCorrectionCountRef.current >= AUTO_CORRECTION_WARNING_THRESHOLD) {
debugLog(
`[Terminal ${id}] AUTO-CORRECTION WARNING: ${autoCorrectionCountRef.current} corrections ` +
`in last minute - this may indicate a persistent sync issue`
);
}
// Reset the window
autoCorrectionCountRef.current = 0;
autoCorrectionWindowStartRef.current = now;
}
autoCorrectionCountRef.current++;
debugLog(`[Terminal ${id}] AUTO-CORRECTING (#${autoCorrectionCountRef.current}): resizing PTY to ${xtermCols}x${xtermRows}`);
lastAutoCorrectionTimeRef.current = Date.now();
resizePtyWithTracking(xtermCols, xtermRows, 'AUTO-CORRECTION');
}
}
}, [id, resizePtyWithTracking]);
// Initialize xterm with command tracking
const {
terminalRef,
xtermRef: _xtermRef,
xtermRef,
fit,
write: _write, // Output now handled by useGlobalTerminalListeners
writeln,
@@ -150,9 +299,8 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
return;
}
// Update tracked dimensions and send resize to PTY
lastPtyDimensionsRef.current = { cols, rows };
window.electronAPI.resizeTerminal(id, cols, rows);
// Use helper to resize PTY with proper tracking and race condition prevention
resizePtyWithTracking(cols, rows, 'onResize');
},
onDimensionsReady: handleDimensionsReady,
});
@@ -167,15 +315,14 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// This prevents creating PTY with default 80x24 when container is smaller
const ptyDimensions = useMemo(() => {
if (readyDimensions) {
debugLog(`[Terminal ${id}] ptyDimensions memo: using readyDimensions cols=${readyDimensions.cols}, rows=${readyDimensions.rows}`);
return readyDimensions;
}
// Fallback to current dimensions if they're valid
if (cols >= MIN_COLS && rows >= MIN_ROWS) {
return { cols, rows };
}
// Return null to prevent PTY creation until dimensions are ready
// Wait for actual measurement via onDimensionsReady callback
// Do NOT use current cols/rows as they may be initial defaults (80x24)
debugLog(`[Terminal ${id}] ptyDimensions memo: readyDimensions is null, returning null (skipCreation will be true)`);
return null;
}, [readyDimensions, cols, rows]);
}, [readyDimensions, id]);
// Create PTY process - only when we have valid dimensions
const { prepareForRecreate, resetForRecreate } = usePtyProcess({
@@ -190,10 +337,33 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
isRecreatingRef,
onCreated: () => {
isCreatedRef.current = true;
// Initialize PTY dimension tracking with creation dimensions
// This ensures the first resize check has a baseline to compare against
if (ptyDimensions) {
lastPtyDimensionsRef.current = { cols: ptyDimensions.cols, rows: ptyDimensions.rows };
// ALWAYS force PTY resize on creation/remount
// This ensures PTY matches xterm even if PTY existed before remount (expand/minimize)
// The root cause of text alignment issues is that when terminal remounts:
// 1. PTY persists with old dimensions (e.g., 80x20)
// 2. New xterm measures new container (e.g., 160x40)
// 3. Without this force resize, PTY never gets updated
// Read current dimensions from xterm ref to avoid stale closure values
const currentCols = xtermRef.current?.cols;
const currentRows = xtermRef.current?.rows;
if (currentCols !== undefined && currentRows !== undefined && currentCols >= MIN_COLS && currentRows >= MIN_ROWS) {
debugLog(`[Terminal ${id}] PTY created - forcing PTY resize to match xterm: cols=${currentCols}, rows=${currentRows}`);
// Use helper to resize PTY with proper tracking and race condition prevention
resizePtyWithTracking(currentCols, currentRows, 'PTY creation');
// Schedule initial dimension mismatch check after PTY creation
// This helps detect if xterm dimensions drifted during PTY setup
// Read fresh dimensions inside the timeout to avoid stale closure
// Store timeout ID for cleanup on unmount
postCreationTimeoutRef.current = setTimeout(() => {
const freshCols = xtermRef.current?.cols;
const freshRows = xtermRef.current?.rows;
if (freshCols !== undefined && freshRows !== undefined) {
checkDimensionMismatch(freshCols, freshRows, 'post-PTY creation');
}
}, DIMENSION_MISMATCH_GRACE_PERIOD_MS + 100);
} else {
debugLog(`[Terminal ${id}] PTY created - no valid dimensions available for tracking (cols=${currentCols}, rows=${currentRows})`);
}
// If there's a pending worktree config from a recreation attempt,
// sync it to main process now that the terminal exists.
@@ -217,6 +387,26 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
},
});
// Monitor for dimension mismatches between xterm and PTY
// This effect runs when xterm dimensions change and checks for mismatches
// after the grace period to help diagnose text alignment issues
// Auto-correction is enabled to automatically fix any detected mismatches
useEffect(() => {
// Only check if PTY has been created
if (!isCreatedRef.current) {
return;
}
// Schedule a mismatch check after the grace period
// This allows time for the PTY resize to be acknowledged
// Enable auto-correct to automatically fix any detected mismatches
const timeoutId = setTimeout(() => {
checkDimensionMismatch(cols, rows, 'periodic dimension sync check', true);
}, DIMENSION_MISMATCH_GRACE_PERIOD_MS + 100);
return () => clearTimeout(timeoutId);
}, [cols, rows, checkDimensionMismatch]);
// Handle terminal events (output is now handled globally via useGlobalTerminalListeners)
useTerminalEvents({
terminalId: id,
@@ -239,6 +429,13 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// Uses transitionend event listener and RAF-based retry logic instead of fixed timeout
// for more reliable resizing after CSS transitions complete
useEffect(() => {
// Detect if this is an actual expansion state change vs initial mount
// Only force PTY resize on actual state changes to avoid resizing with invalid dimensions on mount
const isFirstMount = prevIsExpandedRef.current === undefined;
const expansionStateChanged = !isFirstMount && prevIsExpandedRef.current !== isExpanded;
debugLog(`[Terminal ${id}] Expansion effect: isExpanded=${isExpanded}, isFirstMount=${isFirstMount}, expansionStateChanged=${expansionStateChanged}, prevIsExpanded=${prevIsExpandedRef.current}`);
prevIsExpandedRef.current = isExpanded;
// RAF fallback for test environments where requestAnimationFrame may not be defined
const raf = typeof requestAnimationFrame !== 'undefined'
? requestAnimationFrame
@@ -273,9 +470,20 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
// fit() returns boolean indicating success (true if container had valid dimensions)
const success = fit();
debugLog(`[Terminal ${id}] performFit: fit returned success=${success}, expansionStateChanged=${expansionStateChanged}, isCreatedRef=${isCreatedRef.current}`);
if (success) {
fitSucceeded = true;
// Force PTY resize only on actual expansion state changes (not initial mount)
// This ensures PTY stays in sync even when xterm.onResize() doesn't fire
// Read fresh dimensions from xterm ref after fit() to avoid stale closure values
const freshCols = xtermRef.current?.cols;
const freshRows = xtermRef.current?.rows;
if (expansionStateChanged && isCreatedRef.current && freshCols !== undefined && freshRows !== undefined && freshCols >= MIN_COLS && freshRows >= MIN_ROWS) {
debugLog(`[Terminal ${id}] performFit: Forcing PTY resize to cols=${freshCols}, rows=${freshRows}`);
// Use helper to resize PTY with proper tracking and race condition prevention
resizePtyWithTracking(freshCols, freshRows, 'performFit');
}
} else if (retryCount < MAX_RETRIES) {
// Container not ready yet, retry after a short delay
retryCount++;
@@ -343,7 +551,7 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
container.parentElement?.removeEventListener('transitionend', handleTransitionEnd);
}
};
}, [isExpanded, fit]);
}, [isExpanded, fit, id, resizePtyWithTracking]);
// Trigger deferred Claude resume when terminal becomes active
// This ensures Claude sessions are only resumed when the user actually views the terminal,
@@ -390,6 +598,12 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
isMountedRef.current = false;
cleanupAutoNaming();
// Clear post-creation dimension check timeout to prevent operations on unmounted component
if (postCreationTimeoutRef.current !== null) {
clearTimeout(postCreationTimeoutRef.current);
postCreationTimeoutRef.current = null;
}
setTimeout(() => {
if (!isMountedRef.current) {
dispose();
@@ -1,739 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Tests for AccountSettings component
* Tests profile management, OAuth flows, API profile configuration, and auto-switching
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TooltipProvider } from '../ui/tooltip';
import { AccountSettings } from '../settings/AccountSettings';
import type { AppSettings, ClaudeProfile, ClaudeAutoSwitchSettings } from '../../../shared/types';
import type { APIProfile } from '@shared/types/profile';
// Test wrapper with providers
function TestWrapper({ children }: { children: React.ReactNode }) {
return <TooltipProvider>{children}</TooltipProvider>;
}
// Helper to render with providers
function renderWithProviders(ui: React.ReactElement) {
return render(ui, { wrapper: TestWrapper });
}
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params) {
let result = key;
Object.entries(params).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v));
});
return result;
}
return key;
},
i18n: {
language: 'en',
changeLanguage: vi.fn(),
},
}),
Trans: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('../../hooks/use-toast', () => ({
useToast: () => ({
toast: vi.fn(),
}),
}));
vi.mock('../../stores/claude-profile-store', () => ({
loadClaudeProfiles: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../stores/settings-store', () => ({
useSettingsStore: vi.fn((selector) => {
const state = {
profiles: [],
activeProfileId: null,
deleteProfile: vi.fn().mockResolvedValue(true),
setActiveProfile: vi.fn().mockResolvedValue(true),
profilesError: null,
};
if (typeof selector === 'function') {
return selector(state);
}
return state;
}),
}));
// Mock electronAPI
const mockElectronAPI = {
getClaudeProfiles: vi.fn(),
saveClaudeProfile: vi.fn(),
deleteClaudeProfile: vi.fn(),
renameClaudeProfile: vi.fn(),
setActiveClaudeProfile: vi.fn(),
authenticateClaudeProfile: vi.fn(),
setClaudeProfileToken: vi.fn(),
getAutoSwitchSettings: vi.fn(),
updateAutoSwitchSettings: vi.fn(),
getAccountPriorityOrder: vi.fn(),
setAccountPriorityOrder: vi.fn(),
requestAllProfilesUsage: vi.fn(),
onAllProfilesUsageUpdated: vi.fn((_callback: (data: unknown) => void) => vi.fn()),
};
beforeEach(() => {
(window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = mockElectronAPI;
});
// Helper to create test Claude profile
function createClaudeProfile(overrides: Partial<ClaudeProfile> = {}): ClaudeProfile {
return {
id: `profile-${Date.now()}`,
name: 'Test Profile',
configDir: '~/.claude-profiles/test',
isDefault: false,
createdAt: new Date(),
isAuthenticated: false,
...overrides,
};
}
// Helper to create test API profile
function createAPIProfile(overrides: Partial<APIProfile> = {}): APIProfile {
return {
id: `api-${Date.now()}`,
name: 'Test API Profile',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key-123',
models: {},
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
};
}
// Helper to create test settings
function createTestSettings(overrides: Partial<AppSettings> = {}): AppSettings {
return {
theme: 'system',
defaultModel: 'claude-opus-4-5-20251101',
agentFramework: 'auto-claude',
autoUpdateAutoBuild: true,
autoNameTerminals: true,
notifications: {
onTaskComplete: true,
onTaskFailed: true,
onReviewNeeded: true,
sound: true,
},
...overrides,
};
}
describe('AccountSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
mockElectronAPI.getClaudeProfiles.mockResolvedValue({
success: true,
data: { profiles: [], activeProfileId: null },
});
mockElectronAPI.getAutoSwitchSettings.mockResolvedValue({
success: true,
data: {
enabled: false,
proactiveSwapEnabled: true,
sessionThreshold: 95,
weeklyThreshold: 99,
autoSwitchOnRateLimit: false,
},
});
mockElectronAPI.getAccountPriorityOrder.mockResolvedValue({
success: true,
data: [],
});
mockElectronAPI.requestAllProfilesUsage.mockResolvedValue({
success: true,
data: { allProfiles: [] },
});
});
describe('Tab Navigation', () => {
it('should render Claude Code and Custom Endpoints tabs', () => {
const settings = createTestSettings();
const { container } = renderWithProviders(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={true}
/>
);
// Verify both tab triggers are rendered (translation keys are returned as-is by mock)
expect(container.textContent).toContain('accounts.tabs.claudeCode');
expect(container.textContent).toContain('accounts.tabs.customEndpoints');
});
it('should switch between tabs', () => {
const settings = createTestSettings();
const { container } = renderWithProviders(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={true}
/>
);
// Verify tabs are rendered
const tabs = container.querySelectorAll('[role="tab"]');
expect(tabs.length).toBeGreaterThanOrEqual(2);
// Verify tab structure exists (even if clicking doesn't work in test environment)
expect(container.textContent).toContain('claudeCode');
expect(container.textContent).toContain('customEndpoints');
});
});
describe('Claude Code Profiles', () => {
it('should load Claude profiles successfully', async () => {
const profiles = [
createClaudeProfile({ id: 'profile-1', name: 'Profile 1' }),
createClaudeProfile({ id: 'profile-2', name: 'Profile 2' }),
];
mockElectronAPI.getClaudeProfiles.mockResolvedValue({
success: true,
data: { profiles, activeProfileId: 'profile-1' },
});
const result = await mockElectronAPI.getClaudeProfiles();
expect(result.success).toBe(true);
expect(result.data?.profiles).toHaveLength(2);
});
it('should display authenticated profile badge', () => {
const profile = createClaudeProfile({
isAuthenticated: true,
email: 'test@example.com',
});
expect(profile.isAuthenticated).toBe(true);
expect(profile.email).toBe('test@example.com');
});
it('should display active profile badge', () => {
const activeProfileId = 'profile-1';
const profile = createClaudeProfile({ id: 'profile-1' });
expect(profile.id).toBe(activeProfileId);
});
it('should handle profile creation', async () => {
const newProfileName = 'New Profile';
const profileSlug = newProfileName.toLowerCase().replace(/\s+/g, '-');
const expectedProfile = {
id: expect.stringContaining('profile-'),
name: newProfileName,
configDir: `~/.claude-profiles/${profileSlug}`,
isDefault: false,
createdAt: expect.any(Date),
};
expect(expectedProfile.name).toBe(newProfileName);
expect(expectedProfile.configDir).toContain(profileSlug);
});
it('should handle profile deletion', async () => {
mockElectronAPI.deleteClaudeProfile.mockResolvedValue({ success: true });
const result = await mockElectronAPI.deleteClaudeProfile('profile-1');
expect(result.success).toBe(true);
expect(mockElectronAPI.deleteClaudeProfile).toHaveBeenCalledWith('profile-1');
});
it('should handle profile rename', async () => {
const newName = 'Renamed Profile';
mockElectronAPI.renameClaudeProfile.mockResolvedValue({ success: true });
const result = await mockElectronAPI.renameClaudeProfile('profile-1', newName);
expect(result.success).toBe(true);
expect(mockElectronAPI.renameClaudeProfile).toHaveBeenCalledWith('profile-1', newName);
});
it('should handle setting active profile', async () => {
mockElectronAPI.setActiveClaudeProfile.mockResolvedValue({ success: true });
const result = await mockElectronAPI.setActiveClaudeProfile('profile-1');
expect(result.success).toBe(true);
expect(mockElectronAPI.setActiveClaudeProfile).toHaveBeenCalledWith('profile-1');
});
it('should prevent deletion of default profile', () => {
const profile = createClaudeProfile({ isDefault: true });
const canDelete = !profile.isDefault;
expect(canDelete).toBe(false);
});
it('should display usage bars for authenticated profiles', () => {
const usageData = {
profileId: 'profile-1',
sessionPercent: 75,
weeklyPercent: 50,
isRateLimited: false,
};
expect(usageData.sessionPercent).toBe(75);
expect(usageData.weeklyPercent).toBe(50);
});
it('should display needs reauthentication warning', () => {
const usageData = {
profileId: 'profile-1',
needsReauthentication: true,
};
expect(usageData.needsReauthentication).toBe(true);
});
});
describe('OAuth Authentication', () => {
it('should start OAuth authentication flow', async () => {
mockElectronAPI.authenticateClaudeProfile.mockResolvedValue({
success: true,
data: {
terminalId: 'term-123',
configDir: '~/.claude-profiles/test',
},
});
const result = await mockElectronAPI.authenticateClaudeProfile('profile-1');
expect(result.success).toBe(true);
expect(result.data?.terminalId).toBe('term-123');
});
it('should handle OAuth authentication failure', async () => {
mockElectronAPI.authenticateClaudeProfile.mockResolvedValue({
success: false,
error: 'Authentication failed',
});
const result = await mockElectronAPI.authenticateClaudeProfile('profile-1');
expect(result.success).toBe(false);
expect(result.error).toBe('Authentication failed');
});
it('should display auth terminal when authenticating', () => {
const authTerminal = {
terminalId: 'term-123',
configDir: '~/.claude-profiles/test',
profileId: 'profile-1',
profileName: 'Test Profile',
};
expect(authTerminal.terminalId).toBe('term-123');
expect(authTerminal.profileId).toBe('profile-1');
});
});
describe('Manual Token Entry', () => {
it('should save manual token', async () => {
const token = 'sk-ant-test-token-123';
const email = 'test@example.com';
mockElectronAPI.setClaudeProfileToken.mockResolvedValue({ success: true });
const result = await mockElectronAPI.setClaudeProfileToken('profile-1', token, email);
expect(result.success).toBe(true);
expect(mockElectronAPI.setClaudeProfileToken).toHaveBeenCalledWith('profile-1', token, email);
});
it('should toggle token visibility', async () => {
const profile = createClaudeProfile({ id: 'profile-1', name: 'Test Profile' });
mockElectronAPI.getClaudeProfiles.mockResolvedValue({
success: true,
data: { profiles: [profile], activeProfileId: null },
});
const settings = createTestSettings();
const { container } = renderWithProviders(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={true}
/>
);
await waitFor(() => {
// Look for any eye icon buttons (token visibility toggles)
const eyeButtons = container.querySelectorAll('button');
expect(eyeButtons.length).toBeGreaterThan(0);
});
});
it('should expand/collapse token entry section', async () => {
const profile = createClaudeProfile({ id: 'profile-1', name: 'Test Profile' });
mockElectronAPI.getClaudeProfiles.mockResolvedValue({
success: true,
data: { profiles: [profile], activeProfileId: null },
});
const settings = createTestSettings();
const { container } = renderWithProviders(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={true}
/>
);
await waitFor(() => {
// Component should render and show profile list
expect(container.textContent).toContain('Test Profile');
});
});
});
describe('API Profiles (Custom Endpoints)', () => {
it('should display API profile list', () => {
const profiles = [
createAPIProfile({ id: 'api-1', name: 'Profile 1' }),
createAPIProfile({ id: 'api-2', name: 'Profile 2' }),
];
expect(profiles).toHaveLength(2);
});
it('should display active API profile badge', () => {
const profile = createAPIProfile({ id: 'api-1' });
const isActive = profile.id === 'api-1'; // Check if active by ID comparison
expect(isActive).toBe(true);
});
it('should mask API key', () => {
const apiKey = 'sk-ant-api-test-key-123456';
const masked = `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`;
expect(masked).toBe('sk-ant-a...3456');
});
it('should extract hostname from URL', () => {
const url = 'https://api.anthropic.com/v1';
const host = new URL(url).host;
expect(host).toBe('api.anthropic.com');
});
it('should show empty state when no profiles', () => {
const profiles: APIProfile[] = [];
expect(profiles.length).toBe(0);
});
it('should prevent deletion of active profile', () => {
const profile = createAPIProfile({ id: 'active-profile' });
const activeProfileId = 'active-profile';
const canDelete = profile.id !== activeProfileId;
expect(canDelete).toBe(false);
});
});
describe('Auto-Switch Settings', () => {
it('should load auto-switch settings', async () => {
const settings: ClaudeAutoSwitchSettings = {
enabled: true,
proactiveSwapEnabled: true,
sessionThreshold: 95,
weeklyThreshold: 99,
autoSwitchOnRateLimit: false,
usageCheckInterval: 60000,
};
mockElectronAPI.getAutoSwitchSettings.mockResolvedValue({
success: true,
data: settings,
});
const result = await mockElectronAPI.getAutoSwitchSettings();
expect(result.data).toEqual(settings);
});
it('should update auto-switch settings', async () => {
const updates = { enabled: true };
mockElectronAPI.updateAutoSwitchSettings.mockResolvedValue({ success: true });
const result = await mockElectronAPI.updateAutoSwitchSettings(updates);
expect(result.success).toBe(true);
expect(mockElectronAPI.updateAutoSwitchSettings).toHaveBeenCalledWith(updates);
});
it('should show auto-switch section when multiple accounts exist', () => {
const claudeProfiles = [createClaudeProfile(), createClaudeProfile()];
const apiProfiles: APIProfile[] = [];
const totalAccounts = claudeProfiles.length + apiProfiles.length;
expect(totalAccounts).toBeGreaterThan(1);
});
it('should hide auto-switch section when only one account', () => {
const claudeProfiles = [createClaudeProfile()];
const apiProfiles: APIProfile[] = [];
const totalAccounts = claudeProfiles.length + apiProfiles.length;
expect(totalAccounts).toBe(1);
});
it('should validate session threshold range', () => {
const thresholds = [70, 85, 95, 99];
thresholds.forEach((threshold) => {
expect(threshold).toBeGreaterThanOrEqual(70);
expect(threshold).toBeLessThanOrEqual(99);
});
});
it('should validate weekly threshold range', () => {
const thresholds = [70, 85, 95, 99];
thresholds.forEach((threshold) => {
expect(threshold).toBeGreaterThanOrEqual(70);
expect(threshold).toBeLessThanOrEqual(99);
});
});
});
describe('Priority Order', () => {
it('should load priority order', async () => {
const order = ['oauth-profile-1', 'api-profile-1', 'oauth-profile-2'];
mockElectronAPI.getAccountPriorityOrder.mockResolvedValue({
success: true,
data: order,
});
const result = await mockElectronAPI.getAccountPriorityOrder();
expect(result.data).toEqual(order);
});
it('should save priority order', async () => {
const newOrder = ['oauth-profile-2', 'api-profile-1', 'oauth-profile-1'];
mockElectronAPI.setAccountPriorityOrder.mockResolvedValue({ success: true });
const result = await mockElectronAPI.setAccountPriorityOrder(newOrder);
expect(result.success).toBe(true);
expect(mockElectronAPI.setAccountPriorityOrder).toHaveBeenCalledWith(newOrder);
});
it('should build unified accounts list', () => {
const claudeProfiles = [
createClaudeProfile({ id: 'profile-1', name: 'Claude 1' }),
];
const apiProfiles = [
createAPIProfile({ id: 'api-1', name: 'API 1' }),
];
const unified = [
{ id: 'oauth-profile-1', name: 'Claude 1', type: 'oauth' },
{ id: 'api-api-1', name: 'API 1', type: 'api' },
];
expect(unified).toHaveLength(2);
expect(unified[0].type).toBe('oauth');
expect(unified[1].type).toBe('api');
});
it('should sort accounts by priority order', () => {
const accounts = [
{ id: 'oauth-profile-1' },
{ id: 'api-profile-1' },
{ id: 'oauth-profile-2' },
];
const priorityOrder = ['oauth-profile-2', 'api-profile-1', 'oauth-profile-1'];
const sorted = [...accounts].sort((a, b) => {
const aIndex = priorityOrder.indexOf(a.id);
const bIndex = priorityOrder.indexOf(b.id);
const aPos = aIndex === -1 ? Infinity : aIndex;
const bPos = bIndex === -1 ? Infinity : bIndex;
return aPos - bPos;
});
expect(sorted.map(a => a.id)).toEqual(priorityOrder);
});
});
describe('Usage Monitoring', () => {
it('should load profile usage data', async () => {
const usageData = {
allProfiles: [
{
profileId: 'profile-1',
sessionPercent: 75,
weeklyPercent: 50,
isRateLimited: false,
},
],
};
mockElectronAPI.requestAllProfilesUsage.mockResolvedValue({
success: true,
data: usageData,
});
const result = await mockElectronAPI.requestAllProfilesUsage();
expect(result.data).toEqual(usageData);
});
it('should subscribe to usage updates', () => {
const unsubscribe = mockElectronAPI.onAllProfilesUsageUpdated(() => {});
expect(unsubscribe).toBeDefined();
expect(typeof unsubscribe).toBe('function');
});
it('should calculate usage bar color based on percentage', () => {
const getColor = (percent: number) => {
if (percent >= 95) return 'red';
if (percent >= 91) return 'orange';
if (percent >= 71) return 'yellow';
return 'green';
};
expect(getColor(50)).toBe('green');
expect(getColor(75)).toBe('yellow');
expect(getColor(92)).toBe('orange');
expect(getColor(96)).toBe('red');
});
it('should detect rate limited profiles', () => {
const usageData = {
profileId: 'profile-1',
isRateLimited: true,
rateLimitType: 'session',
};
expect(usageData.isRateLimited).toBe(true);
expect(usageData.rateLimitType).toBe('session');
});
});
describe('Error Handling', () => {
it('should handle profile load failure', async () => {
mockElectronAPI.getClaudeProfiles.mockResolvedValue({
success: false,
error: 'Failed to load profiles',
});
const result = await mockElectronAPI.getClaudeProfiles();
expect(result.success).toBe(false);
expect(result.error).toBe('Failed to load profiles');
});
it('should handle profile save failure', async () => {
mockElectronAPI.saveClaudeProfile.mockResolvedValue({
success: false,
error: 'Failed to save profile',
});
const result = await mockElectronAPI.saveClaudeProfile(createClaudeProfile());
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should handle auto-switch update failure', async () => {
mockElectronAPI.updateAutoSwitchSettings.mockResolvedValue({
success: false,
error: 'Update failed',
});
const result = await mockElectronAPI.updateAutoSwitchSettings({ enabled: true });
expect(result.success).toBe(false);
expect(result.error).toBe('Update failed');
});
});
describe('Settings Persistence', () => {
it('should call onSettingsChange when settings updated', () => {
const onSettingsChange = vi.fn();
const newSettings = createTestSettings({ theme: 'dark' });
onSettingsChange(newSettings);
expect(onSettingsChange).toHaveBeenCalledWith(newSettings);
});
it('should persist settings to backend', () => {
const settings = createTestSettings();
expect(settings).toBeDefined();
expect(settings.theme).toBeDefined();
});
});
describe('Component Lifecycle', () => {
it('should load data when isOpen becomes true', async () => {
const settings = createTestSettings();
const { rerender } = renderWithProviders(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={false}
/>
);
// Initially closed, should not call API
expect(mockElectronAPI.getClaudeProfiles).not.toHaveBeenCalled();
// Open the settings
rerender(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={true}
/>
);
// Should now load profiles
await waitFor(() => {
expect(mockElectronAPI.getClaudeProfiles).toHaveBeenCalled();
});
});
it('should not load data when isOpen is false', () => {
const settings = createTestSettings();
renderWithProviders(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={false}
/>
);
// Should not call API when closed
expect(mockElectronAPI.getClaudeProfiles).not.toHaveBeenCalled();
});
it('should cleanup on unmount', async () => {
const settings = createTestSettings();
const { unmount } = renderWithProviders(
<AccountSettings
settings={settings}
onSettingsChange={vi.fn()}
isOpen={true}
/>
);
await waitFor(() => {
expect(mockElectronAPI.getClaudeProfiles).toHaveBeenCalled();
});
// Unmount should not throw
expect(() => unmount()).not.toThrow();
});
});
});
@@ -1,797 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Comprehensive tests for AgentTools component
* Tests MCP server configuration, agent configuration display, and custom server management
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ProjectEnvConfig, CustomMcpServer, McpHealthCheckResult } from '../../../shared/types';
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params) {
let result = key;
Object.entries(params).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v));
});
return result;
}
return key;
},
}),
}));
vi.mock('../stores/settings-store', () => ({
useSettingsStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector({
settings: {
selectedAgentProfile: 'auto',
},
});
}
return { settings: {} };
}),
}));
vi.mock('../stores/project-store', () => ({
useProjectStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector({
projects: [],
selectedProjectId: null,
});
}
return { projects: [], selectedProjectId: null };
}),
}));
vi.mock('../hooks', () => ({
useResolvedAgentSettings: () => ({
phaseModels: {
spec: 'opus' as const,
planning: 'opus' as const,
coding: 'opus' as const,
qa: 'opus' as const,
},
phaseThinking: {
spec: 'ultrathink' as const,
planning: 'high' as const,
coding: 'low' as const,
qa: 'low' as const,
},
featureModels: {
insights: 'sonnet' as const,
ideation: 'opus' as const,
roadmap: 'opus' as const,
githubIssues: 'opus' as const,
githubPrs: 'opus' as const,
utility: 'haiku' as const,
},
featureThinking: {
insights: 'medium' as const,
ideation: 'high' as const,
roadmap: 'high' as const,
githubIssues: 'medium' as const,
githubPrs: 'medium' as const,
utility: 'low' as const,
},
}),
resolveAgentSettings: (source: any, resolved: any) => {
if (source.type === 'phase') {
return {
model: resolved.phaseModels[source.phase],
thinking: resolved.phaseThinking[source.phase],
};
}
if (source.type === 'feature') {
return {
model: resolved.featureModels[source.feature],
thinking: resolved.featureThinking[source.feature],
};
}
if (source.type === 'fixed') {
return {
model: source.model,
thinking: source.thinking,
};
}
return { model: 'sonnet', thinking: 'medium' };
},
}));
// Mock window.electronAPI
const mockElectronAPI = {
getProjectEnv: vi.fn(),
updateProjectEnv: vi.fn(),
checkMcpHealth: vi.fn(),
testMcpConnection: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(global.window as any).electronAPI = mockElectronAPI;
});
// Helper to create test project env config
function createProjectEnvConfig(overrides: Partial<ProjectEnvConfig> = {}): ProjectEnvConfig {
return {
claudeAuthStatus: 'authenticated',
mcpServers: {
context7Enabled: true,
graphitiEnabled: false,
linearMcpEnabled: false,
electronEnabled: false,
puppeteerEnabled: false,
},
customMcpServers: [],
agentMcpOverrides: {},
...overrides,
} as ProjectEnvConfig;
}
// Helper to create custom MCP server
function createCustomServer(overrides: Partial<CustomMcpServer> = {}): CustomMcpServer {
return {
id: `custom-${Date.now()}`,
name: 'Custom Server',
type: 'command',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-custom'],
...overrides,
};
}
describe('AgentTools', () => {
describe('MCP Server Configuration', () => {
it('should calculate enabled MCP servers correctly', () => {
const mcpServers = {
context7Enabled: true,
graphitiEnabled: true,
linearMcpEnabled: true,
electronEnabled: false,
puppeteerEnabled: false,
};
const enabledCount = [
mcpServers.context7Enabled !== false,
mcpServers.graphitiEnabled,
mcpServers.linearMcpEnabled !== false,
mcpServers.electronEnabled,
mcpServers.puppeteerEnabled,
true, // auto-claude always enabled
].filter(Boolean).length;
expect(enabledCount).toBe(4); // context7, graphiti, linear, auto-claude
});
it('should handle all MCP servers disabled', () => {
const mcpServers = {
context7Enabled: false,
graphitiEnabled: false,
linearMcpEnabled: false,
electronEnabled: false,
puppeteerEnabled: false,
};
const enabledCount = [
mcpServers.context7Enabled !== false,
mcpServers.graphitiEnabled,
mcpServers.linearMcpEnabled !== false,
mcpServers.electronEnabled,
mcpServers.puppeteerEnabled,
true, // auto-claude always enabled
].filter(Boolean).length;
expect(enabledCount).toBe(1); // Only auto-claude
});
it('should handle Context7 toggle', () => {
const envConfig = createProjectEnvConfig({
mcpServers: { context7Enabled: true },
});
// Toggle off
envConfig.mcpServers!.context7Enabled = false;
expect(envConfig.mcpServers!.context7Enabled).toBe(false);
// Toggle on
envConfig.mcpServers!.context7Enabled = true;
expect(envConfig.mcpServers!.context7Enabled).toBe(true);
});
it('should handle Graphiti toggle with provider config check', () => {
const envConfig = createProjectEnvConfig({
mcpServers: { graphitiEnabled: true },
graphitiProviderConfig: {
embeddingProvider: 'openai',
openaiApiKey: 'test-key',
},
});
const isEnabled = envConfig.mcpServers!.graphitiEnabled && !!envConfig.graphitiProviderConfig;
expect(isEnabled).toBe(true);
});
it('should disable Graphiti when no provider config', () => {
const envConfig = createProjectEnvConfig({
mcpServers: { graphitiEnabled: true },
graphitiProviderConfig: undefined,
});
const isEnabled = envConfig.mcpServers!.graphitiEnabled && !!envConfig.graphitiProviderConfig;
expect(isEnabled).toBe(false);
});
it('should handle Linear toggle with Linear enabled check', () => {
const envConfig = createProjectEnvConfig({
mcpServers: { linearMcpEnabled: true },
linearEnabled: true,
});
const isEnabled = envConfig.mcpServers!.linearMcpEnabled && envConfig.linearEnabled;
expect(isEnabled).toBe(true);
});
});
describe('Agent MCP Overrides', () => {
it('should add MCP to agent override list', () => {
const envConfig = createProjectEnvConfig();
const agentId = 'coder';
const mcpId = 'electron';
// Add to override
envConfig.agentMcpOverrides = {
...envConfig.agentMcpOverrides,
[agentId]: {
add: [mcpId],
},
};
expect(envConfig.agentMcpOverrides![agentId]?.add).toContain(mcpId);
});
it('should remove MCP from agent (default MCP)', () => {
const envConfig = createProjectEnvConfig();
const agentId = 'coder';
const mcpId = 'context7'; // This is a default MCP
// Remove from defaults
envConfig.agentMcpOverrides = {
...envConfig.agentMcpOverrides,
[agentId]: {
remove: [mcpId],
},
};
expect(envConfig.agentMcpOverrides![agentId]?.remove).toContain(mcpId);
});
it('should restore removed MCP', () => {
const envConfig = createProjectEnvConfig({
agentMcpOverrides: {
coder: {
remove: ['context7'],
},
},
});
const agentId = 'coder';
const mcpId = 'context7';
// Remove from remove list (restore)
const currentRemove = envConfig.agentMcpOverrides![agentId]?.remove || [];
const newRemove = currentRemove.filter(m => m !== mcpId);
if (newRemove.length === 0) {
delete envConfig.agentMcpOverrides![agentId]?.remove;
} else {
envConfig.agentMcpOverrides![agentId] = {
...envConfig.agentMcpOverrides![agentId],
remove: newRemove,
};
}
expect(envConfig.agentMcpOverrides![agentId]?.remove).toBeUndefined();
});
it('should calculate effective MCPs for agent', () => {
const defaultMcps = ['context7', 'graphiti-memory', 'auto-claude'];
const optionalMcps = ['linear'];
const overrides = {
add: ['electron'],
remove: ['graphiti-memory'],
};
const allDefaults = [...defaultMcps, ...optionalMcps];
const added = overrides.add || [];
const removed = overrides.remove || [];
const effectiveMcps = [...new Set([...allDefaults, ...added])]
.filter(mcp => !removed.includes(mcp));
expect(effectiveMcps).toContain('context7');
expect(effectiveMcps).toContain('electron');
expect(effectiveMcps).not.toContain('graphiti-memory');
expect(effectiveMcps).toContain('auto-claude');
});
it('should filter MCPs by project-level settings', () => {
const effectiveMcps = ['context7', 'graphiti-memory', 'linear', 'electron'];
const mcpServerStates = {
context7Enabled: true,
graphitiEnabled: false, // Disabled at project level
linearMcpEnabled: true,
electronEnabled: false, // Disabled at project level
puppeteerEnabled: false,
};
const filteredMcps = effectiveMcps.filter(mcp => {
switch (mcp) {
case 'context7':
return mcpServerStates.context7Enabled !== false;
case 'graphiti-memory':
return mcpServerStates.graphitiEnabled !== false;
case 'linear':
return mcpServerStates.linearMcpEnabled !== false;
case 'electron':
return mcpServerStates.electronEnabled !== false;
case 'puppeteer':
return mcpServerStates.puppeteerEnabled !== false;
default:
return true;
}
});
expect(filteredMcps).toEqual(['context7', 'linear']);
});
});
describe('Custom MCP Servers', () => {
it('should add custom MCP server', () => {
const envConfig = createProjectEnvConfig();
const customServer = createCustomServer({
id: 'my-custom-server',
name: 'My Custom Server',
});
envConfig.customMcpServers = [...(envConfig.customMcpServers || []), customServer];
expect(envConfig.customMcpServers).toHaveLength(1);
expect(envConfig.customMcpServers?.[0].id).toBe('my-custom-server');
});
it('should update existing custom MCP server', () => {
const customServer = createCustomServer({ id: 'server-1', name: 'Original Name' });
const envConfig = createProjectEnvConfig({
customMcpServers: [customServer],
});
const updatedServer = { ...customServer, name: 'Updated Name' };
const existingIndex = envConfig.customMcpServers?.findIndex(s => s.id === updatedServer.id) ?? -1;
if (existingIndex >= 0 && envConfig.customMcpServers) {
envConfig.customMcpServers[existingIndex] = updatedServer;
}
expect(envConfig.customMcpServers?.[0].name).toBe('Updated Name');
});
it('should delete custom MCP server', () => {
const server1 = createCustomServer({ id: 'server-1' });
const server2 = createCustomServer({ id: 'server-2' });
const envConfig = createProjectEnvConfig({
customMcpServers: [server1, server2],
});
const serverIdToDelete = 'server-1';
envConfig.customMcpServers = envConfig.customMcpServers?.filter(
s => s.id !== serverIdToDelete
);
expect(envConfig.customMcpServers).toHaveLength(1);
expect(envConfig.customMcpServers?.[0].id).toBe('server-2');
});
it('should remove deleted custom server from agent overrides', () => {
const customServer = createCustomServer({ id: 'custom-1' });
const envConfig = createProjectEnvConfig({
customMcpServers: [customServer],
agentMcpOverrides: {
coder: {
add: ['custom-1', 'electron'],
},
planner: {
add: ['custom-1'],
},
},
});
const serverIdToDelete = 'custom-1';
// Remove from custom servers
envConfig.customMcpServers = envConfig.customMcpServers?.filter(
s => s.id !== serverIdToDelete
);
// Remove from all agent overrides
Object.keys(envConfig.agentMcpOverrides || {}).forEach(agentId => {
const override = envConfig.agentMcpOverrides?.[agentId];
if (override?.add?.includes(serverIdToDelete)) {
override.add = override.add.filter(m => m !== serverIdToDelete);
if (override.add.length === 0) {
delete override.add;
}
}
});
expect(envConfig.agentMcpOverrides?.coder?.add).toEqual(['electron']);
expect(envConfig.agentMcpOverrides?.planner?.add).toBeUndefined();
});
it('should create command-type custom server', () => {
const server = createCustomServer({
type: 'command',
command: 'node',
args: ['server.js'],
});
expect(server.type).toBe('command');
expect(server.command).toBe('node');
expect(server.args).toEqual(['server.js']);
});
it('should create http-type custom server', () => {
const server = createCustomServer({
type: 'http',
url: 'http://localhost:3000/mcp',
});
expect(server.type).toBe('http');
expect(server.url).toBe('http://localhost:3000/mcp');
});
it('should include custom servers in available MCPs list', () => {
const customServers = [
createCustomServer({ id: 'custom-1', name: 'Custom Server 1' }),
createCustomServer({ id: 'custom-2', name: 'Custom Server 2' }),
];
const builtInMcps = ['context7', 'graphiti-memory', 'linear', 'electron', 'puppeteer', 'auto-claude'];
const customMcpIds = customServers.map(s => s.id);
const allMcpIds = [...builtInMcps, ...customMcpIds];
expect(allMcpIds).toHaveLength(8);
expect(allMcpIds).toContain('custom-1');
expect(allMcpIds).toContain('custom-2');
});
});
describe('MCP Health Checks', () => {
it('should track health check status for custom servers', () => {
const server = createCustomServer({ id: 'server-1' });
const healthStatus: Record<string, McpHealthCheckResult> = {
'server-1': {
serverId: 'server-1',
status: 'healthy',
message: 'Server is responding',
responseTime: 150,
checkedAt: new Date().toISOString(),
},
};
const health = healthStatus['server-1'];
expect(health.status).toBe('healthy');
expect(health.responseTime).toBe(150);
});
it('should handle unhealthy server status', () => {
const healthStatus: McpHealthCheckResult = {
serverId: 'server-1',
status: 'unhealthy',
message: 'Connection refused',
checkedAt: new Date().toISOString(),
};
expect(healthStatus.status).toBe('unhealthy');
expect(healthStatus.message).toBe('Connection refused');
});
it('should handle needs_auth status', () => {
const healthStatus: McpHealthCheckResult = {
serverId: 'server-1',
status: 'needs_auth',
message: 'Authentication required',
checkedAt: new Date().toISOString(),
};
expect(healthStatus.status).toBe('needs_auth');
});
it('should handle checking status', () => {
const healthStatus: McpHealthCheckResult = {
serverId: 'server-1',
status: 'checking',
checkedAt: new Date().toISOString(),
};
expect(healthStatus.status).toBe('checking');
});
it('should track testing servers', () => {
const testingServers = new Set<string>();
// Add server to testing
testingServers.add('server-1');
expect(testingServers.has('server-1')).toBe(true);
// Remove server from testing
testingServers.delete('server-1');
expect(testingServers.has('server-1')).toBe(false);
});
});
describe('Agent Categories', () => {
it('should group agents by category', () => {
const agentConfigs = {
spec_gatherer: { category: 'spec' },
spec_researcher: { category: 'spec' },
planner: { category: 'build' },
coder: { category: 'build' },
qa_reviewer: { category: 'qa' },
qa_fixer: { category: 'qa' },
insights: { category: 'utility' },
ideation: { category: 'ideation' },
};
const grouped: Record<string, string[]> = {};
Object.entries(agentConfigs).forEach(([id, config]) => {
if (!grouped[config.category]) {
grouped[config.category] = [];
}
grouped[config.category].push(id);
});
expect(grouped.spec).toHaveLength(2);
expect(grouped.build).toHaveLength(2);
expect(grouped.qa).toHaveLength(2);
expect(grouped.utility).toHaveLength(1);
expect(grouped.ideation).toHaveLength(1);
});
it('should track expanded categories', () => {
const expandedCategories = new Set(['spec', 'build', 'qa']);
expect(expandedCategories.has('spec')).toBe(true);
expect(expandedCategories.has('utility')).toBe(false);
// Toggle category
const category = 'utility';
if (expandedCategories.has(category)) {
expandedCategories.delete(category);
} else {
expandedCategories.add(category);
}
expect(expandedCategories.has('utility')).toBe(true);
});
});
describe('Agent Model Configuration', () => {
it('should resolve phase-based agent model config', () => {
const phaseModels = {
spec: 'opus' as const,
planning: 'opus' as const,
coding: 'opus' as const,
qa: 'opus' as const,
};
const phaseThinking = {
spec: 'ultrathink' as const,
planning: 'high' as const,
coding: 'low' as const,
qa: 'low' as const,
};
// Spec phase agent
const specConfig = {
model: phaseModels.spec,
thinking: phaseThinking.spec,
};
expect(specConfig.model).toBe('opus');
expect(specConfig.thinking).toBe('ultrathink');
});
it('should resolve feature-based agent model config', () => {
const featureModels = {
insights: 'sonnet' as const,
ideation: 'opus' as const,
roadmap: 'opus' as const,
githubIssues: 'opus' as const,
githubPrs: 'opus' as const,
utility: 'haiku' as const,
};
const featureThinking = {
insights: 'medium' as const,
ideation: 'high' as const,
roadmap: 'high' as const,
githubIssues: 'medium' as const,
githubPrs: 'medium' as const,
utility: 'low' as const,
};
// Insights feature agent
const insightsConfig = {
model: featureModels.insights,
thinking: featureThinking.insights,
};
expect(insightsConfig.model).toBe('sonnet');
expect(insightsConfig.thinking).toBe('medium');
});
it('should format model label correctly', () => {
const modelLabels: Record<string, string> = {
opus: 'Opus 4.5',
sonnet: 'Sonnet 4.5',
haiku: 'Haiku 3.5',
};
expect(modelLabels.opus).toBe('Opus 4.5');
expect(modelLabels.sonnet).toBe('Sonnet 4.5');
expect(modelLabels.haiku).toBe('Haiku 3.5');
});
it('should format thinking label correctly', () => {
const thinkingLabels: Record<string, string> = {
ultrathink: 'Ultra (200K)',
high: 'High (100K)',
medium: 'Medium (50K)',
low: 'Low (10K)',
none: 'None',
};
expect(thinkingLabels.ultrathink).toBe('Ultra (200K)');
expect(thinkingLabels.high).toBe('High (100K)');
expect(thinkingLabels.medium).toBe('Medium (50K)');
expect(thinkingLabels.low).toBe('Low (10K)');
});
});
describe('Agent Tools', () => {
it('should list available tools for agent', () => {
const agentTools = [
'Read',
'Glob',
'Grep',
'Write',
'Edit',
'Bash',
'WebFetch',
'WebSearch',
];
expect(agentTools).toContain('Read');
expect(agentTools).toContain('Bash');
expect(agentTools).toContain('WebSearch');
});
it('should handle agents with no tools', () => {
const agentTools: string[] = [];
expect(agentTools).toHaveLength(0);
});
it('should determine if agent has tools', () => {
const hasTools = (tools: string[]) => tools.length > 0;
expect(hasTools(['Read', 'Write'])).toBe(true);
expect(hasTools([])).toBe(false);
});
});
describe('No Project State', () => {
it('should handle no project selected', () => {
const selectedProjectId = null;
const selectedProject = undefined;
expect(selectedProjectId).toBeNull();
expect(selectedProject).toBeUndefined();
});
it('should handle project not initialized', () => {
const selectedProject = {
id: 'proj-1',
name: 'Test Project',
path: '/path/to/project',
autoBuildPath: undefined, // Not initialized
};
const isInitialized = !!selectedProject.autoBuildPath;
expect(isInitialized).toBe(false);
});
it('should handle project initialized', () => {
const selectedProject = {
id: 'proj-1',
name: 'Test Project',
path: '/path/to/project',
autoBuildPath: '/path/to/project/.auto-claude',
};
const isInitialized = !!selectedProject.autoBuildPath;
expect(isInitialized).toBe(true);
});
});
describe('IPC Communication', () => {
it('should call getProjectEnv on project change', async () => {
mockElectronAPI.getProjectEnv.mockResolvedValue({
success: true,
data: createProjectEnvConfig(),
});
const projectId = 'test-project';
const result = await mockElectronAPI.getProjectEnv(projectId);
expect(mockElectronAPI.getProjectEnv).toHaveBeenCalledWith(projectId);
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
});
it('should call updateProjectEnv when toggling MCP server', async () => {
mockElectronAPI.updateProjectEnv.mockResolvedValue({ success: true });
const projectId = 'test-project';
const updates = {
mcpServers: {
context7Enabled: false,
},
};
await mockElectronAPI.updateProjectEnv(projectId, updates);
expect(mockElectronAPI.updateProjectEnv).toHaveBeenCalledWith(projectId, updates);
});
it('should call checkMcpHealth for custom servers', async () => {
const customServer = createCustomServer();
const healthResult: McpHealthCheckResult = {
serverId: customServer.id,
status: 'healthy',
checkedAt: new Date().toISOString(),
};
mockElectronAPI.checkMcpHealth.mockResolvedValue({
success: true,
data: healthResult,
});
const result = await mockElectronAPI.checkMcpHealth(customServer);
expect(mockElectronAPI.checkMcpHealth).toHaveBeenCalledWith(customServer);
expect(result.data?.status).toBe('healthy');
});
it('should call testMcpConnection for full server test', async () => {
const customServer = createCustomServer();
mockElectronAPI.testMcpConnection.mockResolvedValue({
success: true,
data: {
success: true,
message: 'Connection successful',
responseTime: 120,
},
});
const result = await mockElectronAPI.testMcpConnection(customServer);
expect(mockElectronAPI.testMcpConnection).toHaveBeenCalledWith(customServer);
expect(result.data?.success).toBe(true);
});
});
});
@@ -1,761 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Tests for KanbanBoard component
* Tests rendering, drag/drop, filtering, task state management, and column controls
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TooltipProvider } from '../ui/tooltip';
import { KanbanBoard } from '../KanbanBoard';
import type { Task, TaskStatus, Project } from '../../../shared/types';
import { TASK_STATUS_COLUMNS } from '../../../shared/constants';
// Test wrapper with providers
function TestWrapper({ children }: { children: React.ReactNode }) {
return <TooltipProvider>{children}</TooltipProvider>;
}
// Helper to render with providers
function renderWithProviders(ui: React.ReactElement) {
return render(ui, { wrapper: TestWrapper });
}
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
// Simple mock translator that handles interpolation
if (params) {
let result = key;
Object.entries(params).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v));
});
return result;
}
return key;
},
i18n: {
language: 'en',
changeLanguage: vi.fn(),
},
}),
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock ViewStateContext at module level
const mockUseViewState = vi.fn(() => ({
showArchived: false,
toggleShowArchived: vi.fn(),
}));
vi.mock('../../contexts/ViewStateContext', () => ({
useViewState: () => mockUseViewState(),
ViewStateProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('../stores/task-store', () => ({
persistTaskStatus: vi.fn().mockResolvedValue({ success: true }),
forceCompleteTask: vi.fn().mockResolvedValue({ success: true }),
archiveTasks: vi.fn().mockResolvedValue({ success: true }),
deleteTasks: vi.fn().mockResolvedValue({ success: true }),
useTaskStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector({
tasks: [],
taskOrder: {},
reorderTasksInColumn: vi.fn(),
moveTaskToColumnTop: vi.fn(),
saveTaskOrder: vi.fn(),
loadTaskOrder: vi.fn(),
setTaskOrder: vi.fn(),
registerTaskStatusChangeListener: vi.fn(() => vi.fn()),
getState: () => ({ tasks: [] }),
});
}
return {};
}),
}));
vi.mock('../stores/project-store', () => ({
updateProjectSettings: vi.fn().mockResolvedValue(true),
useProjectStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector({
projects: [],
});
}
return { projects: [] };
}),
}));
vi.mock('../stores/kanban-settings-store', () => ({
useKanbanSettingsStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector({
columnPreferences: {},
loadPreferences: vi.fn(),
savePreferences: vi.fn(),
toggleColumnCollapsed: vi.fn(),
setColumnCollapsed: vi.fn(),
setColumnWidth: vi.fn(),
toggleColumnLocked: vi.fn(),
});
}
return {};
}),
COLLAPSED_COLUMN_WIDTH: 60,
DEFAULT_COLUMN_WIDTH: 320,
MIN_COLUMN_WIDTH: 280,
MAX_COLUMN_WIDTH: 500,
}));
vi.mock('../hooks/use-toast', () => ({
useToast: () => ({
toast: vi.fn(),
}),
}));
// Mock dnd-kit
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DragOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
closestCorners: vi.fn(),
PointerSensor: vi.fn(),
KeyboardSensor: vi.fn(),
useSensor: vi.fn(),
useSensors: vi.fn(() => []),
useDroppable: vi.fn(() => ({ setNodeRef: vi.fn() })),
}));
vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
sortableKeyboardCoordinates: vi.fn(),
verticalListSortingStrategy: vi.fn(),
}));
// Helper to create test tasks
function createTestTask(overrides: Partial<Task> = {}): Task {
return {
id: `task-${Date.now()}-${Math.random().toString(36).substring(7)}`,
projectId: 'test-project',
title: 'Test Task',
description: 'Test task description',
status: 'backlog',
createdAt: new Date(),
updatedAt: new Date(),
subtasks: [],
...overrides,
} as Task;
}
describe('KanbanBoard', () => {
const mockOnTaskClick = vi.fn();
const mockOnNewTaskClick = vi.fn();
const mockOnRefresh = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render all kanban columns', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
/>
);
// Component should render all status columns
expect(TASK_STATUS_COLUMNS).toHaveLength(6);
expect(TASK_STATUS_COLUMNS).toEqual([
'backlog',
'queue',
'in_progress',
'ai_review',
'human_review',
'done',
]);
// Verify component renders (column labels should be in the text content)
const text = container.textContent || '';
expect(text.length).toBeGreaterThan(0);
});
it('should render with empty tasks array', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
/>
);
expect(tasks).toHaveLength(0);
// Should render the kanban board structure
expect(container.firstChild).toBeTruthy();
});
it('should group tasks by status correctly', () => {
const tasks = [
createTestTask({ id: 'task-1', status: 'backlog', title: 'Backlog Task' }),
createTestTask({ id: 'task-2', status: 'in_progress', title: 'In Progress Task' }),
createTestTask({ id: 'task-3', status: 'done', title: 'Done Task' }),
createTestTask({ id: 'task-4', status: 'backlog', title: 'Another Backlog Task' }),
];
// Group tasks by status
const grouped: Record<TaskStatus, Task[]> = {
backlog: [],
queue: [],
in_progress: [],
ai_review: [],
human_review: [],
done: [],
pr_created: [],
error: [],
};
tasks.forEach((task) => {
grouped[task.status].push(task);
});
expect(grouped.backlog).toHaveLength(2);
expect(grouped.in_progress).toHaveLength(1);
expect(grouped.done).toHaveLength(1);
expect(grouped.queue).toHaveLength(0);
});
it('should map pr_created tasks to done column', () => {
const task = createTestTask({ status: 'pr_created' });
// pr_created tasks should be displayed in 'done' column
const visualColumn = task.status === 'pr_created' ? 'done' : task.status;
expect(visualColumn).toBe('done');
});
it('should map error tasks to human_review column', () => {
const task = createTestTask({ status: 'error' });
// error tasks should be displayed in 'human_review' column
const visualColumn = task.status === 'error' ? 'human_review' : task.status;
expect(visualColumn).toBe('human_review');
});
});
describe('Task Filtering', () => {
it('should filter out archived tasks by default', () => {
const tasks = [
createTestTask({ id: 'task-1', status: 'done' }),
createTestTask({
id: 'task-2',
status: 'done',
metadata: { archivedAt: new Date().toISOString() }
}),
createTestTask({ id: 'task-3', status: 'backlog' }),
];
const showArchived = false;
const filtered = showArchived
? tasks
: tasks.filter((t) => !t.metadata?.archivedAt);
expect(filtered).toHaveLength(2);
expect(filtered.find(t => t.id === 'task-2')).toBeUndefined();
});
it('should show archived tasks when showArchived is true', () => {
const tasks = [
createTestTask({ id: 'task-1', status: 'done' }),
createTestTask({
id: 'task-2',
status: 'done',
metadata: { archivedAt: new Date().toISOString() }
}),
];
const showArchived = true;
const filtered = showArchived
? tasks
: tasks.filter((t) => !t.metadata?.archivedAt);
expect(filtered).toHaveLength(2);
});
it('should calculate archived count correctly', () => {
const tasks = [
createTestTask({ status: 'done' }),
createTestTask({
status: 'done',
metadata: { archivedAt: new Date().toISOString() }
}),
createTestTask({
status: 'done',
metadata: { archivedAt: new Date().toISOString() }
}),
createTestTask({ status: 'backlog' }),
];
const archivedCount = tasks.filter(t => t.metadata?.archivedAt).length;
expect(archivedCount).toBe(2);
});
});
describe('Column Collapse/Expand', () => {
it('should track collapsed columns correctly', () => {
const columnPreferences = {
backlog: { isCollapsed: false, width: 320, isLocked: false },
queue: { isCollapsed: true, width: 320, isLocked: false },
in_progress: { isCollapsed: true, width: 320, isLocked: false },
ai_review: { isCollapsed: false, width: 320, isLocked: false },
human_review: { isCollapsed: false, width: 320, isLocked: false },
done: { isCollapsed: true, width: 320, isLocked: false },
};
const collapsedCount = TASK_STATUS_COLUMNS.filter(
(status) => columnPreferences[status]?.isCollapsed
).length;
expect(collapsedCount).toBe(3);
});
it('should show expand all button when 3 or more columns are collapsed', () => {
const collapsedCount = 3;
const shouldShowExpandAll = collapsedCount >= 3;
expect(shouldShowExpandAll).toBe(true);
});
it('should not show expand all button when less than 3 columns are collapsed', () => {
const collapsedCount = 2;
const shouldShowExpandAll = collapsedCount >= 3;
expect(shouldShowExpandAll).toBe(false);
});
});
describe('Column Locking', () => {
it('should prevent resize when column is locked', () => {
const isLocked = true;
const shouldAllowResize = !isLocked;
expect(shouldAllowResize).toBe(false);
});
it('should allow resize when column is unlocked', () => {
const isLocked = false;
const shouldAllowResize = !isLocked;
expect(shouldAllowResize).toBe(true);
});
it('should track locked state correctly', () => {
const columnPreferences = {
backlog: { isCollapsed: false, width: 320, isLocked: true },
queue: { isCollapsed: false, width: 320, isLocked: false },
in_progress: { isCollapsed: false, width: 320, isLocked: true },
ai_review: { isCollapsed: false, width: 320, isLocked: false },
human_review: { isCollapsed: false, width: 320, isLocked: false },
done: { isCollapsed: false, width: 320, isLocked: false },
};
const lockedColumns = TASK_STATUS_COLUMNS.filter(
(status) => columnPreferences[status]?.isLocked
);
expect(lockedColumns).toHaveLength(2);
expect(lockedColumns).toContain('backlog');
expect(lockedColumns).toContain('in_progress');
});
});
describe('Task Selection (Bulk Actions)', () => {
it('should track selected tasks', () => {
const selectedTaskIds = new Set(['task-1', 'task-2', 'task-3']);
expect(selectedTaskIds.size).toBe(3);
expect(selectedTaskIds.has('task-1')).toBe(true);
expect(selectedTaskIds.has('task-4')).toBe(false);
});
it('should toggle task selection', () => {
const selectedTaskIds = new Set(['task-1', 'task-2']);
// Add task
const taskId = 'task-3';
if (selectedTaskIds.has(taskId)) {
selectedTaskIds.delete(taskId);
} else {
selectedTaskIds.add(taskId);
}
expect(selectedTaskIds.has('task-3')).toBe(true);
expect(selectedTaskIds.size).toBe(3);
// Remove task
if (selectedTaskIds.has('task-1')) {
selectedTaskIds.delete('task-1');
}
expect(selectedTaskIds.has('task-1')).toBe(false);
expect(selectedTaskIds.size).toBe(2);
});
it('should select all tasks in a column', () => {
const tasks = [
createTestTask({ id: 'task-1', status: 'human_review' }),
createTestTask({ id: 'task-2', status: 'human_review' }),
createTestTask({ id: 'task-3', status: 'backlog' }),
];
const columnTasks = tasks.filter(t => t.status === 'human_review');
const selectedTaskIds = new Set(columnTasks.map(t => t.id));
expect(selectedTaskIds.size).toBe(2);
expect(selectedTaskIds.has('task-1')).toBe(true);
expect(selectedTaskIds.has('task-2')).toBe(true);
expect(selectedTaskIds.has('task-3')).toBe(false);
});
it('should deselect all tasks', () => {
const selectedTaskIds = new Set(['task-1', 'task-2', 'task-3']);
selectedTaskIds.clear();
expect(selectedTaskIds.size).toBe(0);
});
it('should prune stale task IDs from selection', () => {
const selectedTaskIds = new Set(['task-1', 'task-2', 'task-3', 'task-deleted']);
const currentTasks = [
createTestTask({ id: 'task-1' }),
createTestTask({ id: 'task-2' }),
createTestTask({ id: 'task-3' }),
];
const currentTaskIds = new Set(currentTasks.map(t => t.id));
const prunedSelection = new Set(
[...selectedTaskIds].filter(id => currentTaskIds.has(id))
);
expect(prunedSelection.size).toBe(3);
expect(prunedSelection.has('task-deleted')).toBe(false);
});
});
describe('Queue System', () => {
it('should calculate max parallel tasks correctly', () => {
const projects: Project[] = [
{
id: 'proj-1',
name: 'Test Project',
path: '/path',
autoBuildPath: '/path/.auto-claude',
settings: {
maxParallelTasks: 5,
model: 'claude-opus-4-5-20251101',
memoryBackend: 'file',
linearSync: false,
notifications: { onTaskComplete: true, onTaskFailed: true, onReviewNeeded: true, sound: true },
graphitiMcpEnabled: false,
},
createdAt: new Date(),
updatedAt: new Date(),
}
];
const projectId = 'proj-1';
const project = projects.find(p => p.id === projectId);
const maxParallelTasks = project?.settings?.maxParallelTasks ?? 3;
expect(maxParallelTasks).toBe(5);
});
it('should use default max parallel tasks when not configured', () => {
const projects: Project[] = [];
const projectId = 'proj-1';
const project = projects.find(p => p.id === projectId);
const maxParallelTasks = project?.settings?.maxParallelTasks ?? 3;
expect(maxParallelTasks).toBe(3);
});
it('should determine if in_progress column is at capacity', () => {
const tasks = [
createTestTask({ status: 'in_progress' }),
createTestTask({ status: 'in_progress' }),
createTestTask({ status: 'in_progress' }),
];
const maxParallelTasks = 3;
const inProgressCount = tasks.filter(
t => t.status === 'in_progress' && !t.metadata?.archivedAt
).length;
const isAtCapacity = inProgressCount >= maxParallelTasks;
expect(isAtCapacity).toBe(true);
});
it('should determine if in_progress column has capacity', () => {
const tasks = [
createTestTask({ status: 'in_progress' }),
createTestTask({ status: 'in_progress' }),
];
const maxParallelTasks = 3;
const inProgressCount = tasks.filter(
t => t.status === 'in_progress' && !t.metadata?.archivedAt
).length;
const hasCapacity = inProgressCount < maxParallelTasks;
expect(hasCapacity).toBe(true);
});
});
describe('Task Ordering', () => {
it('should sort tasks by custom order', () => {
const tasks = [
createTestTask({ id: 'task-1', status: 'backlog', title: 'Task 1' }),
createTestTask({ id: 'task-2', status: 'backlog', title: 'Task 2' }),
createTestTask({ id: 'task-3', status: 'backlog', title: 'Task 3' }),
];
const customOrder = ['task-3', 'task-1', 'task-2'];
// Sort by custom order
const indexMap = new Map(customOrder.map((id, idx) => [id, idx]));
const sorted = [...tasks].sort((a, b) =>
(indexMap.get(a.id) ?? 0) - (indexMap.get(b.id) ?? 0)
);
expect(sorted.map(t => t.id)).toEqual(['task-3', 'task-1', 'task-2']);
});
it('should sort tasks by createdAt when no custom order', () => {
const now = new Date();
const tasks = [
createTestTask({
id: 'task-1',
status: 'backlog',
createdAt: new Date(now.getTime() - 3000),
}),
createTestTask({
id: 'task-2',
status: 'backlog',
createdAt: new Date(now.getTime() - 1000),
}),
createTestTask({
id: 'task-3',
status: 'backlog',
createdAt: new Date(now.getTime() - 2000),
}),
];
// Sort by createdAt (newest first)
const sorted = [...tasks].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA;
});
expect(sorted.map(t => t.id)).toEqual(['task-2', 'task-3', 'task-1']);
});
it('should prepend new tasks at top of custom order', () => {
const existingOrder = ['task-1', 'task-2'];
const newTaskId = 'task-3';
const newOrder = [newTaskId, ...existingOrder];
expect(newOrder).toEqual(['task-3', 'task-1', 'task-2']);
});
});
describe('Empty States', () => {
it('should show empty state for backlog column', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
onNewTaskClick={mockOnNewTaskClick}
/>
);
// Should render empty kanban board
expect(container.firstChild).toBeTruthy();
// Empty board should contain backlog empty state text
expect(container.textContent).toContain('kanban.emptyBacklog');
});
it('should show empty state for queue column', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
/>
);
// Should render queue column with empty state
expect(container.textContent).toContain('kanban.emptyQueue');
});
it('should show empty state for done column', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
/>
);
// Should render done column with empty state
expect(container.textContent).toContain('kanban.emptyDone');
});
});
describe('Drag and Drop', () => {
it('should identify valid drop columns', () => {
const validColumns = new Set(TASK_STATUS_COLUMNS);
expect(validColumns.has('backlog')).toBe(true);
expect(validColumns.has('queue')).toBe(true);
expect(validColumns.has('in_progress')).toBe(true);
expect(validColumns.has('ai_review')).toBe(true);
expect(validColumns.has('human_review')).toBe(true);
expect(validColumns.has('done')).toBe(true);
expect(validColumns.has('invalid_column' as never)).toBe(false);
});
it('should determine visual column for drag operations', () => {
// pr_created tasks display in done column
const getVisualColumn = (s: TaskStatus): string => {
if (s === 'pr_created') return 'done';
if (s === 'error') return 'human_review';
return s;
};
expect(getVisualColumn('pr_created')).toBe('done');
expect(getVisualColumn('error')).toBe('human_review');
expect(getVisualColumn('backlog')).toBe('backlog');
});
it('should detect same-column reordering', () => {
const draggedTask = createTestTask({ id: 'task-1', status: 'backlog' });
const overTask = createTestTask({ id: 'task-2', status: 'backlog' });
const isSameColumn = draggedTask.status === overTask.status;
expect(isSameColumn).toBe(true);
});
it('should detect cross-column move', () => {
const draggedTask = createTestTask({ id: 'task-1', status: 'backlog' });
const overTask = createTestTask({ id: 'task-2', status: 'in_progress' });
const isCrossColumn = draggedTask.status !== overTask.status;
expect(isCrossColumn).toBe(true);
});
});
describe('Refresh Functionality', () => {
it('should call onRefresh when refresh button is clicked', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
onRefresh={mockOnRefresh}
/>
);
// Component renders with refresh functionality
expect(container.firstChild).toBeTruthy();
// Verify the callback is defined and ready to use
expect(mockOnRefresh).toBeDefined();
});
it('should show refreshing state', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
onRefresh={mockOnRefresh}
isRefreshing={true}
/>
);
// Component should render with refreshing state
expect(container.firstChild).toBeTruthy();
});
it('should not show refreshing state when not refreshing', () => {
const tasks: Task[] = [];
const { container } = renderWithProviders(
<KanbanBoard
tasks={tasks}
onTaskClick={mockOnTaskClick}
isRefreshing={false}
/>
);
// Component should render normally
expect(container.firstChild).toBeTruthy();
});
});
describe('Column Selection Logic', () => {
it('should calculate column selection state correctly', () => {
const columnTasks = [
createTestTask({ id: 'task-1' }),
createTestTask({ id: 'task-2' }),
createTestTask({ id: 'task-3' }),
];
const selectedTaskIds = new Set(['task-1', 'task-2', 'task-3']);
const taskCount = columnTasks.length;
const selectedCount = columnTasks.filter(t => selectedTaskIds.has(t.id)).length;
const isAllSelected = taskCount > 0 && selectedCount === taskCount;
const isSomeSelected = selectedCount > 0 && selectedCount < taskCount;
expect(isAllSelected).toBe(true);
expect(isSomeSelected).toBe(false);
});
it('should calculate indeterminate selection state', () => {
const columnTasks = [
createTestTask({ id: 'task-1' }),
createTestTask({ id: 'task-2' }),
createTestTask({ id: 'task-3' }),
];
const selectedTaskIds = new Set(['task-1', 'task-2']);
const taskCount = columnTasks.length;
const selectedCount = columnTasks.filter(t => selectedTaskIds.has(t.id)).length;
const isAllSelected = taskCount > 0 && selectedCount === taskCount;
const isSomeSelected = selectedCount > 0 && selectedCount < taskCount;
expect(isAllSelected).toBe(false);
expect(isSomeSelected).toBe(true);
});
it('should calculate no selection state', () => {
const columnTasks = [
createTestTask({ id: 'task-1' }),
createTestTask({ id: 'task-2' }),
];
const selectedTaskIds = new Set<string>([]);
const taskCount = columnTasks.length;
const selectedCount = columnTasks.filter(t => selectedTaskIds.has(t.id)).length;
const isAllSelected = taskCount > 0 && selectedCount === taskCount;
const isSomeSelected = selectedCount > 0 && selectedCount < taskCount;
expect(isAllSelected).toBe(false);
expect(isSomeSelected).toBe(false);
});
});
});
@@ -1,906 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Tests for PRDetail component
* Tests PR information display, review actions, comment display, and workflow management
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Define types locally to avoid module resolution issues in tests
interface PRData {
number: number;
title: string;
body: string;
state: string;
author: string;
createdAt: string;
updatedAt: string;
headSha: string;
baseBranch: string;
headBranch: string;
url: string;
mergeable: boolean;
merged: boolean;
draft: boolean;
labels: string[];
assignees: string[];
reviewers: string[];
comments: number;
commits: number;
additions: number;
deletions: number;
changedFiles: number;
}
interface PRReviewFinding {
id: string;
severity: 'critical' | 'high' | 'medium' | 'low';
message: string;
file: string;
line: number;
}
interface PRReviewResult {
success: boolean;
overallStatus: 'approve' | 'request_changes' | 'comment';
summary: string;
findings: PRReviewFinding[];
reviewedAt: string;
reviewedCommitSha: string;
postedFindingIds: string[];
hasPostedFindings: boolean;
postedAt: string | null;
isFollowupReview: boolean;
error?: string;
resolvedFindings?: string[];
unresolvedFindings?: string[];
newFindingsSinceLastReview?: string[];
}
interface PRReviewProgress {
progress: number;
message: string;
phase: string;
}
interface NewCommitsCheck {
hasNewCommits: boolean;
newCommitCount: number;
lastReviewedCommit: string;
hasCommitsAfterPosting?: boolean;
}
interface MergeReadiness {
ready: boolean;
blockers: string[];
isBehind: boolean;
}
interface PRLogs {
pr_number: number;
is_followup: boolean;
phases: Array<{
name: string;
status: string;
started_at: string;
completed_at?: string;
}>;
}
interface WorkflowRun {
id: number;
name: string;
workflow_name: string;
html_url: string;
}
interface WorkflowsAwaitingApprovalResult {
awaiting_approval: number;
workflow_runs: WorkflowRun[];
}
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params) {
let result = key;
Object.entries(params).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v));
});
return result;
}
return key;
},
}),
}));
// Mock electronAPI
const mockElectronAPI = {
github: {
getWorkflowsAwaitingApproval: vi.fn(),
approveWorkflow: vi.fn(),
checkMergeReadiness: vi.fn(),
updatePRBranch: vi.fn(),
},
openExternal: vi.fn(),
};
beforeEach(() => {
(window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = mockElectronAPI;
});
// Helper to create test PR data
function createTestPR(overrides: Partial<PRData> = {}): PRData {
return {
number: 123,
title: 'Test PR',
body: 'Test PR description',
state: 'open',
author: 'testuser',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
headSha: 'abc123',
baseBranch: 'main',
headBranch: 'feature/test',
url: 'https://github.com/test/repo/pull/123',
mergeable: true,
merged: false,
draft: false,
labels: [],
assignees: [],
reviewers: [],
comments: 0,
commits: 5,
additions: 100,
deletions: 50,
changedFiles: 10,
...overrides,
};
}
// Helper to create test review result
function createReviewResult(overrides: Partial<PRReviewResult> = {}): PRReviewResult {
return {
success: true,
overallStatus: 'approve',
summary: 'Code looks good!',
findings: [],
reviewedAt: new Date().toISOString(),
reviewedCommitSha: 'abc123',
postedFindingIds: [],
hasPostedFindings: false,
postedAt: null,
isFollowupReview: false,
...overrides,
};
}
// Helper to create review progress
function createReviewProgress(overrides: Partial<PRReviewProgress> = {}): PRReviewProgress {
return {
progress: 50,
message: 'Analyzing code...',
phase: 'analyzing',
...overrides,
};
}
describe('PRDetail', () => {
const mockOnRunReview = vi.fn();
const mockOnRunFollowupReview = vi.fn();
const mockOnCheckNewCommits = vi.fn();
const mockOnCancelReview = vi.fn();
const mockOnPostReview = vi.fn();
const mockOnPostComment = vi.fn();
const mockOnMergePR = vi.fn();
const mockOnAssignPR = vi.fn();
const mockOnGetLogs = vi.fn();
const mockOnMarkReviewPosted = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockElectronAPI.github.getWorkflowsAwaitingApproval.mockResolvedValue({
awaiting_approval: 0,
workflow_runs: [],
});
mockElectronAPI.github.checkMergeReadiness.mockResolvedValue({
ready: true,
blockers: [],
isBehind: false,
});
mockOnGetLogs.mockResolvedValue(null);
mockOnCheckNewCommits.mockResolvedValue({
hasNewCommits: false,
newCommitCount: 0,
lastReviewedCommit: 'abc123',
});
});
describe('PR Header Display', () => {
it('should display PR title and number', () => {
const pr = createTestPR({ number: 123, title: 'Fix bug in authentication' });
expect(pr.number).toBe(123);
expect(pr.title).toBe('Fix bug in authentication');
});
it('should display PR author', () => {
const pr = createTestPR({ author: 'johndoe' });
expect(pr.author).toBe('johndoe');
});
it('should display PR state', () => {
const pr = createTestPR({ state: 'open' });
expect(pr.state).toBe('open');
});
it('should display PR stats', () => {
const pr = createTestPR({
commits: 5,
changedFiles: 10,
additions: 100,
deletions: 50,
});
expect(pr.commits).toBe(5);
expect(pr.changedFiles).toBe(10);
expect(pr.additions).toBe(100);
expect(pr.deletions).toBe(50);
});
it('should display draft badge', () => {
const pr = createTestPR({ draft: true });
expect(pr.draft).toBe(true);
});
it('should display merged badge', () => {
const pr = createTestPR({ merged: true });
expect(pr.merged).toBe(true);
});
});
describe('Review Status', () => {
it('should show not reviewed status', () => {
const reviewResult = null;
const isReviewing = false;
expect(reviewResult).toBeNull();
expect(isReviewing).toBe(false);
});
it('should show reviewing status', () => {
const isReviewing = true;
const progress = createReviewProgress({
progress: 50,
message: 'Analyzing code...',
});
expect(isReviewing).toBe(true);
expect(progress.progress).toBe(50);
});
it('should show review complete status', () => {
const reviewResult = createReviewResult({
success: true,
overallStatus: 'approve',
});
expect(reviewResult.success).toBe(true);
expect(reviewResult.overallStatus).toBe('approve');
});
it('should show changes requested status', () => {
const reviewResult = createReviewResult({
overallStatus: 'request_changes',
});
expect(reviewResult.overallStatus).toBe('request_changes');
});
it('should calculate ready to merge status', () => {
const reviewResult = createReviewResult({
success: true,
summary: 'READY TO MERGE',
overallStatus: 'approve',
});
const isReadyToMerge = reviewResult.summary?.includes('READY TO MERGE') ||
reviewResult.overallStatus === 'approve';
expect(isReadyToMerge).toBe(true);
});
it('should calculate clean review status', () => {
const reviewResult = createReviewResult({
success: true,
findings: [
{ id: '1', severity: 'low', message: 'Minor suggestion', file: 'test.ts', line: 10 },
],
});
const isClean = !reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
);
expect(isClean).toBe(true);
});
it('should detect blocking issues', () => {
const reviewResult = createReviewResult({
findings: [
{ id: '1', severity: 'high', message: 'Security issue', file: 'auth.ts', line: 50 },
],
});
const hasBlockers = reviewResult.findings.some(f =>
f.severity === 'critical' || f.severity === 'high'
);
expect(hasBlockers).toBe(true);
});
});
describe('Review Actions', () => {
it('should start initial review', () => {
mockOnRunReview();
expect(mockOnRunReview).toHaveBeenCalled();
});
it('should start follow-up review', () => {
mockOnRunFollowupReview();
expect(mockOnRunFollowupReview).toHaveBeenCalled();
});
it('should cancel ongoing review', () => {
mockOnCancelReview();
expect(mockOnCancelReview).toHaveBeenCalled();
});
it('should post selected findings', async () => {
const findingIds = ['finding-1', 'finding-2'];
mockOnPostReview.mockResolvedValue(true);
const result = await mockOnPostReview(findingIds);
expect(result).toBe(true);
expect(mockOnPostReview).toHaveBeenCalledWith(findingIds);
});
it('should post comment', async () => {
mockOnPostComment.mockResolvedValue(true);
const result = await mockOnPostComment('Test comment');
expect(result).toBe(true);
expect(mockOnPostComment).toHaveBeenCalledWith('Test comment');
});
it('should merge PR', () => {
mockOnMergePR('squash');
expect(mockOnMergePR).toHaveBeenCalledWith('squash');
});
it('should handle review error', () => {
const reviewResult = createReviewResult({
success: false,
error: 'Review failed',
});
expect(reviewResult.success).toBe(false);
expect(reviewResult.error).toBe('Review failed');
});
});
describe('Findings Management', () => {
it('should auto-select all findings on review complete', () => {
const reviewResult = createReviewResult({
findings: [
{ id: '1', severity: 'high', message: 'Issue 1', file: 'test.ts', line: 10 },
{ id: '2', severity: 'low', message: 'Issue 2', file: 'test.ts', line: 20 },
],
});
const selectedIds = new Set(reviewResult.findings.map(f => f.id));
expect(selectedIds.size).toBe(2);
expect(selectedIds.has('1')).toBe(true);
expect(selectedIds.has('2')).toBe(true);
});
it('should exclude posted findings from selection', () => {
const reviewResult = createReviewResult({
findings: [
{ id: '1', severity: 'high', message: 'Issue 1', file: 'test.ts', line: 10 },
{ id: '2', severity: 'low', message: 'Issue 2', file: 'test.ts', line: 20 },
],
postedFindingIds: ['1'],
});
const postedIds = new Set(reviewResult.postedFindingIds);
const selectedIds = new Set(
reviewResult.findings.filter(f => !postedIds.has(f.id)).map(f => f.id)
);
expect(selectedIds.size).toBe(1);
expect(selectedIds.has('2')).toBe(true);
});
it('should track posted findings', () => {
const postedIds = new Set(['finding-1', 'finding-2']);
expect(postedIds.has('finding-1')).toBe(true);
expect(postedIds.size).toBe(2);
});
it('should count selected findings', () => {
const selectedIds = new Set(['finding-1', 'finding-2', 'finding-3']);
expect(selectedIds.size).toBe(3);
});
it('should filter low severity findings', () => {
const reviewResult = createReviewResult({
findings: [
{ id: '1', severity: 'high', message: 'Issue 1', file: 'test.ts', line: 10 },
{ id: '2', severity: 'low', message: 'Issue 2', file: 'test.ts', line: 20 },
{ id: '3', severity: 'low', message: 'Issue 3', file: 'test.ts', line: 30 },
],
});
const lowFindings = reviewResult.findings.filter(f => f.severity === 'low');
expect(lowFindings).toHaveLength(2);
});
});
describe('New Commits Detection', () => {
it('should check for new commits after review', async () => {
mockOnCheckNewCommits.mockResolvedValue({
hasNewCommits: true,
newCommitCount: 2,
lastReviewedCommit: 'abc123',
});
const result = await mockOnCheckNewCommits();
expect(result.hasNewCommits).toBe(true);
expect(result.newCommitCount).toBe(2);
});
it('should detect no new commits', async () => {
mockOnCheckNewCommits.mockResolvedValue({
hasNewCommits: false,
newCommitCount: 0,
lastReviewedCommit: 'abc123',
});
const result = await mockOnCheckNewCommits();
expect(result.hasNewCommits).toBe(false);
});
it('should detect commits after posting', async () => {
mockOnCheckNewCommits.mockResolvedValue({
hasNewCommits: true,
newCommitCount: 1,
hasCommitsAfterPosting: true,
lastReviewedCommit: 'abc123',
});
const result = await mockOnCheckNewCommits();
expect(result.hasCommitsAfterPosting).toBe(true);
});
it('should show ready for follow-up status', () => {
const newCommitsCheck: NewCommitsCheck = {
hasNewCommits: true,
newCommitCount: 2,
hasCommitsAfterPosting: true,
lastReviewedCommit: 'abc123',
};
expect(newCommitsCheck.hasNewCommits).toBe(true);
expect(newCommitsCheck.hasCommitsAfterPosting).toBe(true);
});
});
describe('Follow-up Review', () => {
it('should display follow-up review badge', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
});
expect(reviewResult.isFollowupReview).toBe(true);
});
it('should show resolved findings', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
resolvedFindings: ['finding-1', 'finding-2'],
});
expect(reviewResult.resolvedFindings).toHaveLength(2);
});
it('should show unresolved findings', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
unresolvedFindings: ['finding-3'],
});
expect(reviewResult.unresolvedFindings).toHaveLength(1);
});
it('should show new findings', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
newFindingsSinceLastReview: ['finding-4', 'finding-5'],
});
expect(reviewResult.newFindingsSinceLastReview).toHaveLength(2);
});
it('should calculate all issues resolved', () => {
const reviewResult = createReviewResult({
isFollowupReview: true,
resolvedFindings: ['finding-1', 'finding-2'],
unresolvedFindings: [],
newFindingsSinceLastReview: [],
});
const allResolved = (reviewResult.unresolvedFindings?.length ?? 0) === 0 &&
(reviewResult.newFindingsSinceLastReview?.length ?? 0) === 0;
expect(allResolved).toBe(true);
});
});
describe('Merge Readiness', () => {
it('should check merge readiness', async () => {
mockElectronAPI.github.checkMergeReadiness.mockResolvedValue({
ready: true,
blockers: [],
isBehind: false,
});
const result = await mockElectronAPI.github.checkMergeReadiness('project-1', 123);
expect(result.ready).toBe(true);
expect(result.blockers).toHaveLength(0);
});
it('should detect merge blockers', async () => {
mockElectronAPI.github.checkMergeReadiness.mockResolvedValue({
ready: false,
blockers: ['CI checks failing', 'Requires approval'],
isBehind: false,
});
const result = await mockElectronAPI.github.checkMergeReadiness('project-1', 123);
expect(result.ready).toBe(false);
expect(result.blockers).toHaveLength(2);
});
it('should detect branch behind base', async () => {
mockElectronAPI.github.checkMergeReadiness.mockResolvedValue({
ready: false,
blockers: ['Branch is behind base'],
isBehind: true,
});
const result = await mockElectronAPI.github.checkMergeReadiness('project-1', 123);
expect(result.isBehind).toBe(true);
});
it('should update branch when behind', async () => {
mockElectronAPI.github.updatePRBranch.mockResolvedValue({
success: true,
});
const result = await mockElectronAPI.github.updatePRBranch('project-1', 123);
expect(result.success).toBe(true);
});
it('should handle branch update failure', async () => {
mockElectronAPI.github.updatePRBranch.mockResolvedValue({
success: false,
error: 'Merge conflict',
});
const result = await mockElectronAPI.github.updatePRBranch('project-1', 123);
expect(result.success).toBe(false);
expect(result.error).toBe('Merge conflict');
});
});
describe('Workflows', () => {
it('should load workflows awaiting approval', async () => {
mockElectronAPI.github.getWorkflowsAwaitingApproval.mockResolvedValue({
awaiting_approval: 2,
workflow_runs: [
{
id: 1,
name: 'Build',
workflow_name: 'CI',
html_url: 'https://github.com/test/repo/actions/runs/1',
},
{
id: 2,
name: 'Test',
workflow_name: 'CI',
html_url: 'https://github.com/test/repo/actions/runs/2',
},
],
});
const result = await mockElectronAPI.github.getWorkflowsAwaitingApproval('', 123);
expect(result.awaiting_approval).toBe(2);
expect(result.workflow_runs).toHaveLength(2);
});
it('should approve single workflow', async () => {
mockElectronAPI.github.approveWorkflow.mockResolvedValue(true);
const result = await mockElectronAPI.github.approveWorkflow('', 1);
expect(result).toBe(true);
});
it('should approve all workflows', async () => {
const workflows: WorkflowsAwaitingApprovalResult = {
awaiting_approval: 2,
workflow_runs: [
{ id: 1, name: 'Build', workflow_name: 'CI', html_url: '' },
{ id: 2, name: 'Test', workflow_name: 'CI', html_url: '' },
],
};
mockElectronAPI.github.approveWorkflow.mockResolvedValue(true);
for (const workflow of workflows.workflow_runs) {
await mockElectronAPI.github.approveWorkflow('', workflow.id);
}
expect(mockElectronAPI.github.approveWorkflow).toHaveBeenCalledTimes(2);
});
it('should show blocked by workflows status', () => {
const workflowsAwaiting: WorkflowsAwaitingApprovalResult = {
awaiting_approval: 1,
workflow_runs: [
{ id: 1, name: 'Build', workflow_name: 'CI', html_url: '' },
],
};
expect(workflowsAwaiting.awaiting_approval).toBeGreaterThan(0);
});
});
describe('Review Logs', () => {
it('should load review logs', async () => {
mockOnGetLogs.mockResolvedValue({
pr_number: 123,
is_followup: false,
phases: [
{
name: 'Analysis',
status: 'completed',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
},
],
});
const logs = await mockOnGetLogs();
expect(logs).toBeDefined();
expect(logs?.pr_number).toBe(123);
});
it('should expand logs when review starts', () => {
let logsExpanded = false;
const isReviewing = true;
if (isReviewing) {
logsExpanded = true;
}
expect(logsExpanded).toBe(true);
});
it('should collapse logs', () => {
let logsExpanded = true;
logsExpanded = false;
expect(logsExpanded).toBe(false);
});
it('should show logs badge for follow-up', () => {
const logs: PRLogs = {
pr_number: 123,
is_followup: true,
phases: [],
};
expect(logs.is_followup).toBe(true);
});
});
describe('Auto-Approval', () => {
it('should auto-approve clean PR', async () => {
const reviewResult = createReviewResult({
success: true,
overallStatus: 'approve',
findings: [
{ id: '1', severity: 'low', message: 'Minor suggestion', file: 'test.ts', line: 10 },
],
});
const lowFindings = reviewResult.findings.filter(f => f.severity === 'low');
const findingIds = lowFindings.map(f => f.id);
mockOnPostReview.mockResolvedValue(true);
const result = await mockOnPostReview(findingIds, { forceApprove: true });
expect(result).toBe(true);
});
it('should post clean review comment', async () => {
mockOnPostComment.mockResolvedValue(true);
const message = 'Clean review - no issues found';
const result = await mockOnPostComment(message);
expect(result).toBe(true);
expect(mockOnPostComment).toHaveBeenCalledWith(message);
});
it('should handle clean review post failure', async () => {
mockOnPostComment.mockRejectedValue(new Error('Failed to post'));
await expect(mockOnPostComment('message')).rejects.toThrow('Failed to post');
});
});
describe('Blocked Status', () => {
it('should post blocked status when no findings', async () => {
const reviewResult = createReviewResult({
success: true,
overallStatus: 'request_changes',
findings: [],
summary: 'PR is blocked due to CI failures',
});
mockOnPostComment.mockResolvedValue(true);
const result = await mockOnPostComment(reviewResult.summary);
expect(result).toBe(true);
});
it('should mark review as posted after blocked status', async () => {
mockOnMarkReviewPosted?.mockResolvedValue(undefined);
await mockOnMarkReviewPosted?.(123);
expect(mockOnMarkReviewPosted).toHaveBeenCalledWith(123);
});
});
describe('Progress Tracking', () => {
it('should display review progress', () => {
const progress = createReviewProgress({
progress: 75,
message: 'Analyzing security issues...',
phase: 'security',
});
expect(progress.progress).toBe(75);
expect(progress.message).toBe('Analyzing security issues...');
});
it('should update progress during review', () => {
const progress = createReviewProgress({ progress: 25 });
progress.progress = 50;
expect(progress.progress).toBe(50);
});
});
describe('State Management', () => {
it('should prevent state leaks when switching PRs', () => {
const currentPr = 123;
const actionPr = 123;
const shouldUpdate = currentPr === actionPr;
expect(shouldUpdate).toBe(true);
});
it('should not update state for different PR', () => {
const currentPr: number = 123;
const actionPr: number = 456;
const shouldUpdate = currentPr === actionPr;
expect(shouldUpdate).toBe(false);
});
it('should reset state when PR changes', () => {
let cleanReviewPosted = true;
let blockedStatusPosted = true;
cleanReviewPosted = false;
blockedStatusPosted = false;
expect(cleanReviewPosted).toBe(false);
expect(blockedStatusPosted).toBe(false);
});
});
describe('UI State', () => {
it('should toggle analysis section', () => {
let analysisExpanded = true;
analysisExpanded = !analysisExpanded;
expect(analysisExpanded).toBe(false);
});
it('should show success message after posting', () => {
const postSuccess = {
count: 3,
timestamp: Date.now(),
};
expect(postSuccess.count).toBe(3);
expect(postSuccess.timestamp).toBeLessThanOrEqual(Date.now());
});
it('should clear success message after timeout', () => {
let postSuccess: { count: number; timestamp: number } | null = {
count: 3,
timestamp: Date.now(),
};
postSuccess = null;
expect(postSuccess).toBeNull();
});
it('should show loading state when posting', () => {
let isPosting = false;
isPosting = true;
expect(isPosting).toBe(true);
isPosting = false;
expect(isPosting).toBe(false);
});
});
describe('Previous Review', () => {
it('should display previous review result', () => {
const previousReviewResult = createReviewResult({
success: true,
findings: [
{ id: '1', severity: 'high', message: 'Issue 1', file: 'test.ts', line: 10 },
],
});
expect(previousReviewResult.success).toBe(true);
expect(previousReviewResult.findings).toHaveLength(1);
});
it('should compare with current review', () => {
const previousReview = createReviewResult({
findings: [
{ id: '1', severity: 'high', message: 'Issue 1', file: 'test.ts', line: 10 },
],
});
const currentReview = createReviewResult({
findings: [],
});
expect(previousReview.findings.length).toBeGreaterThan(currentReview.findings.length);
});
});
});
@@ -1,731 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Tests for Worktrees component
* Tests worktree listing, actions (merge, delete, PR creation), and status display
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { WorktreeListItem, TerminalWorktreeConfig, Task, WorktreeStatus } from '../../../shared/types';
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (params) {
let result = key;
Object.entries(params).forEach(([k, v]) => {
result = result.replace(`{{${k}}}`, String(v));
});
return result;
}
return key;
},
}),
}));
vi.mock('../../hooks/use-toast', () => ({
useToast: () => ({
toast: vi.fn(),
}),
}));
vi.mock('../../stores/project-store', () => ({
useProjectStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector({
projects: [
{
id: 'test-project',
name: 'Test Project',
path: '/test/project',
autoBuildPath: '/test/project/.auto-claude',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
}
return { projects: [] };
}),
}));
vi.mock('../../stores/task-store', () => ({
useTaskStore: vi.fn((selector) => {
if (typeof selector === 'function') {
return selector({
tasks: [],
updateTask: vi.fn(),
getState: () => ({ tasks: [] }),
});
}
return { tasks: [] };
}),
}));
// Mock electronAPI
const mockElectronAPI = {
listWorktrees: vi.fn(),
listTerminalWorktrees: vi.fn(),
mergeWorktree: vi.fn(),
discardWorktree: vi.fn(),
discardOrphanedWorktree: vi.fn(),
createWorktreePR: vi.fn(),
removeTerminalWorktree: vi.fn(),
};
beforeEach(() => {
(window as unknown as { electronAPI: typeof mockElectronAPI }).electronAPI = mockElectronAPI;
});
// Helper to create test worktree
function createTestWorktree(overrides: Partial<WorktreeListItem> = {}): WorktreeListItem {
return {
specName: `spec-${Date.now()}`,
path: '/test/worktree/path',
branch: 'feature/test-branch',
baseBranch: 'main',
commitCount: 5,
filesChanged: 10,
additions: 50,
deletions: 20,
isOrphaned: false,
...overrides,
};
}
// Helper to create terminal worktree
function createTerminalWorktree(overrides: Partial<TerminalWorktreeConfig> = {}): TerminalWorktreeConfig {
return {
name: `terminal-${Date.now()}`,
worktreePath: '/test/terminal/worktree',
branchName: 'terminal/test-branch',
baseBranch: 'main',
hasGitBranch: true,
createdAt: new Date().toISOString(),
terminalId: `term-${Date.now()}`,
...overrides,
};
}
// Helper to create test task
function createTestTask(overrides: Partial<Task> = {}): Task {
return {
id: `task-${Date.now()}`,
projectId: 'test-project',
title: 'Test Task',
description: 'Test description',
status: 'in_progress',
specId: `spec-${Date.now()}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
subtasks: [],
...overrides,
} as Task;
}
describe('Worktrees', () => {
beforeEach(() => {
vi.clearAllMocks();
mockElectronAPI.listWorktrees.mockResolvedValue({
success: true,
data: { worktrees: [] },
});
mockElectronAPI.listTerminalWorktrees.mockResolvedValue({
success: true,
data: [],
});
});
describe('Loading', () => {
it('should load task worktrees successfully', async () => {
const worktrees = [
createTestWorktree({ specName: 'spec-1' }),
createTestWorktree({ specName: 'spec-2' }),
];
mockElectronAPI.listWorktrees.mockResolvedValue({
success: true,
data: { worktrees },
});
const result = await mockElectronAPI.listWorktrees('test-project', { includeStats: true });
expect(result.success).toBe(true);
expect(result.data?.worktrees).toHaveLength(2);
});
it('should display loading state', () => {
const isLoading = true;
expect(isLoading).toBe(true);
});
it('should handle worktree load failure', async () => {
mockElectronAPI.listWorktrees.mockResolvedValue({
success: false,
error: 'Failed to load worktrees',
});
const result = await mockElectronAPI.listWorktrees('test-project', { includeStats: true });
expect(result.success).toBe(false);
expect(result.error).toBe('Failed to load worktrees');
});
it('should load terminal worktrees successfully', async () => {
const terminalWorktrees = [
createTerminalWorktree({ name: 'terminal-1' }),
createTerminalWorktree({ name: 'terminal-2' }),
];
mockElectronAPI.listTerminalWorktrees.mockResolvedValue({
success: true,
data: terminalWorktrees,
});
const result = await mockElectronAPI.listTerminalWorktrees('/test/project');
expect(result.success).toBe(true);
expect(result.data).toHaveLength(2);
});
});
describe('Worktree Display', () => {
it('should display worktree branch name', () => {
const worktree = createTestWorktree({ branch: 'feature/new-feature' });
expect(worktree.branch).toBe('feature/new-feature');
});
it('should display worktree stats', () => {
const worktree = createTestWorktree({
commitCount: 5,
filesChanged: 10,
additions: 50,
deletions: 20,
});
expect(worktree.commitCount).toBe(5);
expect(worktree.filesChanged).toBe(10);
expect(worktree.additions).toBe(50);
expect(worktree.deletions).toBe(20);
});
it('should display orphaned worktree badge', () => {
const worktree = createTestWorktree({ isOrphaned: true });
expect(worktree.isOrphaned).toBe(true);
});
it('should display base branch info', () => {
const worktree = createTestWorktree({
baseBranch: 'develop',
branch: 'feature/test',
});
expect(worktree.baseBranch).toBe('develop');
expect(worktree.branch).toBe('feature/test');
});
it('should show empty state when no worktrees', () => {
const worktrees: WorktreeListItem[] = [];
const terminalWorktrees: TerminalWorktreeConfig[] = [];
expect(worktrees.length).toBe(0);
expect(terminalWorktrees.length).toBe(0);
});
it('should display spec name badge', () => {
const worktree = createTestWorktree({ specName: '001-feature-name' });
expect(worktree.specName).toBe('001-feature-name');
});
});
describe('Terminal Worktrees', () => {
it('should display terminal worktree name', () => {
const terminal = createTerminalWorktree({ name: 'my-terminal-workspace' });
expect(terminal.name).toBe('my-terminal-workspace');
});
it('should display terminal worktree branch', () => {
const terminal = createTerminalWorktree({
branchName: 'terminal/experiment',
baseBranch: 'main',
});
expect(terminal.branchName).toBe('terminal/experiment');
expect(terminal.baseBranch).toBe('main');
});
it('should display created date', () => {
const createdAt = new Date('2024-01-15').toISOString();
const terminal = createTerminalWorktree({ createdAt });
expect(terminal.createdAt).toBe(createdAt);
});
it('should track task association', () => {
const terminal = createTerminalWorktree({ taskId: 'task-123' });
expect(terminal.taskId).toBe('task-123');
});
});
describe('Merge Operations', () => {
it('should open merge dialog', () => {
const worktree = createTestWorktree();
let selectedWorktree: WorktreeListItem | null = null;
let showDialog = false;
selectedWorktree = worktree;
showDialog = true;
expect(selectedWorktree).toBe(worktree);
expect(showDialog).toBe(true);
});
it('should handle successful merge', async () => {
mockElectronAPI.mergeWorktree.mockResolvedValue({
success: true,
data: {
success: true,
message: 'Merge successful',
},
});
const result = await mockElectronAPI.mergeWorktree('task-1');
expect(result.success).toBe(true);
expect(result.data?.success).toBe(true);
});
it('should handle merge conflict', async () => {
mockElectronAPI.mergeWorktree.mockResolvedValue({
success: true,
data: {
success: false,
message: 'Merge conflict',
conflictFiles: ['file1.ts', 'file2.ts'],
},
});
const result = await mockElectronAPI.mergeWorktree('task-1');
expect(result.data?.success).toBe(false);
expect(result.data?.conflictFiles).toHaveLength(2);
});
it('should display merge confirmation dialog', () => {
const worktree = createTestWorktree({
branch: 'feature/test',
baseBranch: 'main',
commitCount: 5,
filesChanged: 10,
});
expect(worktree.branch).toBe('feature/test');
expect(worktree.baseBranch).toBe('main');
});
it('should close dialog after successful merge', () => {
let showDialog = true;
showDialog = false;
expect(showDialog).toBe(false);
});
});
describe('Delete Operations', () => {
it('should open delete confirmation dialog', () => {
const worktree = createTestWorktree();
let worktreeToDelete: WorktreeListItem | null = null;
let showConfirm = false;
worktreeToDelete = worktree;
showConfirm = true;
expect(worktreeToDelete).toBe(worktree);
expect(showConfirm).toBe(true);
});
it('should delete worktree via task ID', async () => {
mockElectronAPI.discardWorktree.mockResolvedValue({ success: true });
const result = await mockElectronAPI.discardWorktree('task-1');
expect(result.success).toBe(true);
expect(mockElectronAPI.discardWorktree).toHaveBeenCalledWith('task-1');
});
it('should delete orphaned worktree by spec name', async () => {
mockElectronAPI.discardOrphanedWorktree.mockResolvedValue({ success: true });
const result = await mockElectronAPI.discardOrphanedWorktree(
'project-1',
'spec-001'
);
expect(result.success).toBe(true);
expect(mockElectronAPI.discardOrphanedWorktree).toHaveBeenCalledWith(
'project-1',
'spec-001'
);
});
it('should delete terminal worktree', async () => {
mockElectronAPI.removeTerminalWorktree.mockResolvedValue({ success: true });
const result = await mockElectronAPI.removeTerminalWorktree(
'/project/path',
'terminal-1',
true
);
expect(result.success).toBe(true);
});
it('should handle delete failure', async () => {
mockElectronAPI.discardWorktree.mockResolvedValue({
success: false,
error: 'Delete failed',
});
const result = await mockElectronAPI.discardWorktree('task-1');
expect(result.success).toBe(false);
expect(result.error).toBe('Delete failed');
});
it('should refresh worktree list after delete', async () => {
mockElectronAPI.discardWorktree.mockResolvedValue({ success: true });
await mockElectronAPI.discardWorktree('task-1');
expect(mockElectronAPI.discardWorktree).toHaveBeenCalled();
});
});
describe('Bulk Delete', () => {
it('should select multiple worktrees', () => {
const selectedIds = new Set<string>();
selectedIds.add('task:spec-1');
selectedIds.add('task:spec-2');
selectedIds.add('terminal:terminal-1');
expect(selectedIds.size).toBe(3);
});
it('should toggle worktree selection', () => {
const selectedIds = new Set(['task:spec-1']);
const toggleId = 'task:spec-2';
if (selectedIds.has(toggleId)) {
selectedIds.delete(toggleId);
} else {
selectedIds.add(toggleId);
}
expect(selectedIds.has('task:spec-2')).toBe(true);
});
it('should select all worktrees', () => {
const worktrees = [
createTestWorktree({ specName: 'spec-1' }),
createTestWorktree({ specName: 'spec-2' }),
];
const terminalWorktrees = [
createTerminalWorktree({ name: 'terminal-1' }),
];
const allIds = [
...worktrees.map(w => `task:${w.specName}`),
...terminalWorktrees.map(w => `terminal:${w.name}`),
];
expect(allIds).toHaveLength(3);
});
it('should deselect all worktrees', () => {
const selectedIds = new Set(['task:spec-1', 'terminal:terminal-1']);
selectedIds.clear();
expect(selectedIds.size).toBe(0);
});
it('should calculate selection count', () => {
const selectedIds = new Set(['task:spec-1', 'task:spec-2']);
const validIds = new Set(['task:spec-1', 'task:spec-2', 'task:spec-3']);
let count = 0;
selectedIds.forEach(id => {
if (validIds.has(id)) {
count++;
}
});
expect(count).toBe(2);
});
it('should handle bulk delete confirmation', () => {
const selectedIds = new Set(['task:spec-1', 'terminal:terminal-1']);
let showConfirm = false;
if (selectedIds.size > 0) {
showConfirm = true;
}
expect(showConfirm).toBe(true);
});
it('should clear selection after bulk delete', () => {
const selectedIds = new Set(['task:spec-1']);
selectedIds.clear();
expect(selectedIds.size).toBe(0);
});
it('should parse task IDs from selection', () => {
const selectedIds = new Set(['task:spec-1', 'task:spec-2', 'terminal:term-1']);
const TASK_PREFIX = 'task:';
const taskSpecNames: string[] = [];
selectedIds.forEach(id => {
if (id.startsWith(TASK_PREFIX)) {
taskSpecNames.push(id.slice(TASK_PREFIX.length));
}
});
expect(taskSpecNames).toEqual(['spec-1', 'spec-2']);
});
it('should parse terminal IDs from selection', () => {
const selectedIds = new Set(['task:spec-1', 'terminal:term-1', 'terminal:term-2']);
const TERMINAL_PREFIX = 'terminal:';
const terminalNames: string[] = [];
selectedIds.forEach(id => {
if (id.startsWith(TERMINAL_PREFIX)) {
terminalNames.push(id.slice(TERMINAL_PREFIX.length));
}
});
expect(terminalNames).toEqual(['term-1', 'term-2']);
});
});
describe('Selection Mode', () => {
it('should enable selection mode', () => {
let isSelectionMode = false;
isSelectionMode = true;
expect(isSelectionMode).toBe(true);
});
it('should disable selection mode', () => {
let isSelectionMode = true;
isSelectionMode = false;
expect(isSelectionMode).toBe(false);
});
it('should clear selection when disabling selection mode', () => {
let isSelectionMode = true;
const selectedIds = new Set(['task:spec-1']);
isSelectionMode = false;
selectedIds.clear();
expect(isSelectionMode).toBe(false);
expect(selectedIds.size).toBe(0);
});
it('should calculate if all are selected', () => {
const worktrees = [
createTestWorktree({ specName: 'spec-1' }),
createTestWorktree({ specName: 'spec-2' }),
];
const selectedIds = new Set(['task:spec-1', 'task:spec-2']);
const allSelected = worktrees.every(w => selectedIds.has(`task:${w.specName}`));
expect(allSelected).toBe(true);
});
it('should calculate if some are selected', () => {
const worktrees = [
createTestWorktree({ specName: 'spec-1' }),
createTestWorktree({ specName: 'spec-2' }),
];
const selectedIds = new Set(['task:spec-1']);
const allSelected = worktrees.every(w => selectedIds.has(`task:${w.specName}`));
const someSelected = worktrees.some(w => selectedIds.has(`task:${w.specName}`)) && !allSelected;
expect(someSelected).toBe(true);
});
});
describe('PR Creation', () => {
it('should open PR creation dialog', () => {
const worktree = createTestWorktree();
const task = createTestTask();
let prWorktree: WorktreeListItem | null = null;
let prTask: Task | null = null;
let showDialog = false;
prWorktree = worktree;
prTask = task;
showDialog = true;
expect(prWorktree).toBe(worktree);
expect(prTask).toBe(task);
expect(showDialog).toBe(true);
});
it('should convert worktree to status for dialog', () => {
const worktree = createTestWorktree({
path: '/test/path',
branch: 'feature/test',
baseBranch: 'main',
commitCount: 5,
filesChanged: 10,
additions: 50,
deletions: 20,
});
const status: WorktreeStatus = {
exists: true,
worktreePath: worktree.path,
branch: worktree.branch,
baseBranch: worktree.baseBranch,
commitCount: worktree.commitCount ?? 0,
filesChanged: worktree.filesChanged ?? 0,
additions: worktree.additions ?? 0,
deletions: worktree.deletions ?? 0,
};
expect(status.exists).toBe(true);
expect(status.commitCount).toBe(5);
});
it('should create PR successfully', async () => {
mockElectronAPI.createWorktreePR.mockResolvedValue({
success: true,
data: {
success: true,
prUrl: 'https://github.com/test/repo/pull/123',
alreadyExists: false,
},
});
const result = await mockElectronAPI.createWorktreePR('task-1', {
title: 'Test PR',
body: 'Test PR body',
});
expect(result.data?.success).toBe(true);
expect(result.data?.prUrl).toBeDefined();
});
it('should handle PR already exists', async () => {
mockElectronAPI.createWorktreePR.mockResolvedValue({
success: true,
data: {
success: true,
prUrl: 'https://github.com/test/repo/pull/123',
alreadyExists: true,
},
});
const result = await mockElectronAPI.createWorktreePR('task-1', {});
expect(result.data?.alreadyExists).toBe(true);
});
it('should handle PR creation failure', async () => {
mockElectronAPI.createWorktreePR.mockResolvedValue({
success: false,
error: 'Failed to create PR',
});
const result = await mockElectronAPI.createWorktreePR('task-1', {});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('Task Association', () => {
it('should find task for worktree', () => {
const tasks = [
createTestTask({ id: 'task-1', specId: 'spec-001' }),
createTestTask({ id: 'task-2', specId: 'spec-002' }),
];
const task = tasks.find(t => t.specId === 'spec-001');
expect(task?.id).toBe('task-1');
});
it('should handle worktree without task', () => {
const tasks: Task[] = [];
const task = tasks.find(t => t.specId === 'spec-999');
expect(task).toBeUndefined();
});
it('should display task title for worktree', () => {
const task = createTestTask({ title: 'Implement feature X' });
expect(task.title).toBe('Implement feature X');
});
});
describe('Error Handling', () => {
it('should display error message', () => {
const error = 'Failed to load worktrees';
expect(error).toBe('Failed to load worktrees');
});
it('should clear error on successful operation', () => {
let error: string | null = 'Some error';
error = null;
expect(error).toBeNull();
});
it('should handle missing project', () => {
const projectId: string | null = null;
expect(projectId).toBeNull();
});
});
describe('Refresh', () => {
it('should refresh worktree list', async () => {
mockElectronAPI.listWorktrees.mockClear();
await mockElectronAPI.listWorktrees('test-project', { includeStats: true });
expect(mockElectronAPI.listWorktrees).toHaveBeenCalled();
});
it('should clear selection on refresh', () => {
const selectedIds = new Set(['task:spec-1']);
let isSelectionMode = true;
selectedIds.clear();
isSelectionMode = false;
expect(selectedIds.size).toBe(0);
expect(isSelectionMode).toBe(false);
});
});
describe('Path Operations', () => {
it('should copy worktree path to clipboard', () => {
const worktree = createTestWorktree({ path: '/test/worktree/path' });
expect(worktree.path).toBe('/test/worktree/path');
});
it('should copy terminal worktree path', () => {
const terminal = createTerminalWorktree({
worktreePath: '/test/terminal/path',
});
expect(terminal.worktreePath).toBe('/test/terminal/path');
});
});
});
@@ -58,6 +58,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
const {
prs,
isLoading,
isLoadingMore,
isLoadingPRDetails,
error,
selectedPRNumber,
@@ -78,6 +79,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
assignPR,
markReviewPosted,
refresh,
loadMore,
isConnected,
repoFullName,
getReviewStateForPR,
@@ -96,6 +98,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
setSearchQuery,
setContributors,
setStatuses,
setSortBy,
clearFilters,
hasActiveFilters,
} = usePRFiltering(prs, getReviewStateForPR);
@@ -226,6 +229,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
onSearchChange={setSearchQuery}
onContributorsChange={setContributors}
onStatusesChange={setStatuses}
onSortChange={setSortBy}
onClearFilters={clearFilters}
/>
<PRList
@@ -236,6 +240,8 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
error={error}
getReviewStateForPR={getReviewStateForPR}
onSelectPR={selectPR}
onLoadMore={loadMore}
isLoadingMore={isLoadingMore}
/>
</div>
}
@@ -4,7 +4,7 @@
* Multi-select dropdowns with visible chip selections
*/
import { useState, useMemo, useRef, useCallback } from 'react';
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import {
Search,
Users,
@@ -17,7 +17,10 @@ import {
X,
Filter,
Check,
Loader2
Loader2,
ArrowUpDown,
Clock,
FileCode
} from 'lucide-react';
import { Input } from '../../ui/input';
import { Badge } from '../../ui/badge';
@@ -29,7 +32,7 @@ import {
DropdownMenuTrigger,
} from '../../ui/dropdown-menu';
import { useTranslation } from 'react-i18next';
import type { PRFilterState, PRStatusFilter } from '../hooks/usePRFiltering';
import type { PRFilterState, PRStatusFilter, PRSortOption } from '../hooks/usePRFiltering';
import { cn } from '../../../lib/utils';
interface PRFilterBarProps {
@@ -39,6 +42,7 @@ interface PRFilterBarProps {
onSearchChange: (query: string) => void;
onContributorsChange: (contributors: string[]) => void;
onStatusesChange: (statuses: PRStatusFilter[]) => void;
onSortChange: (sortBy: PRSortOption) => void;
onClearFilters: () => void;
}
@@ -59,6 +63,17 @@ const STATUS_OPTIONS: Array<{
{ value: 'ready_for_followup', labelKey: 'prReview.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' },
];
// Sort options
const SORT_OPTIONS: Array<{
value: PRSortOption;
labelKey: string;
icon: typeof Clock;
}> = [
{ value: 'newest', labelKey: 'prReview.sort.newest', icon: Clock },
{ value: 'oldest', labelKey: 'prReview.sort.oldest', icon: Clock },
{ value: 'largest', labelKey: 'prReview.sort.largest', icon: FileCode },
];
/**
* Modern Filter Dropdown Component
*/
@@ -138,6 +153,13 @@ function FilterDropdown<T extends string>({
}
}, [filteredItems, focusedIndex, toggleItem]);
// Scroll focused item into view for keyboard navigation
useEffect(() => {
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) {
itemRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
}
}, [focusedIndex]);
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => {
setIsOpen(open);
@@ -279,6 +301,127 @@ function FilterDropdown<T extends string>({
);
}
/**
* Single-select Sort Dropdown Component
*/
function SortDropdown({
value,
onChange,
options,
title,
}: {
value: PRSortOption;
onChange: (value: PRSortOption) => void;
options: typeof SORT_OPTIONS;
title: string;
}) {
const { t } = useTranslation('common');
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const currentOption = options.find((opt) => opt.value === value) || options[0];
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (options.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < options.length) {
onChange(options[focusedIndex].value);
setIsOpen(false);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
}, [options, focusedIndex, onChange]);
return (
<DropdownMenu
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (open) {
// Focus current selection on open for better keyboard UX
setFocusedIndex(options.findIndex((o) => o.value === value));
} else {
setFocusedIndex(-1);
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 justify-start border-dashed bg-transparent"
>
<ArrowUpDown className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="truncate">{title}</span>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
{t(currentOption.labelKey)}
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[180px] p-0">
<div className="px-3 py-2 border-b border-border/50">
<div className="text-xs font-semibold text-muted-foreground">
{title}
</div>
</div>
<div
className="p-1"
role="listbox"
tabIndex={0}
onKeyDown={handleKeyDown}
>
{options.map((option, index) => {
const isSelected = value === option.value;
const isFocused = focusedIndex === index;
const Icon = option.icon;
return (
<div
key={option.value}
role="option"
aria-selected={isSelected}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent/50",
isFocused && "bg-accent text-accent-foreground"
)}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-primary/30",
isSelected ? "bg-primary border-primary text-primary-foreground" : "opacity-50"
)}>
{isSelected && <Check className="h-2.5 w-2.5" />}
</div>
<Icon className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span>{t(option.labelKey)}</span>
</div>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function PRFilterBar({
filters,
contributors,
@@ -286,6 +429,7 @@ export function PRFilterBar({
onSearchChange,
onContributorsChange,
onStatusesChange,
onSortChange,
onClearFilters,
}: PRFilterBarProps) {
const { t } = useTranslation('common');
@@ -393,6 +537,16 @@ export function PRFilterBar({
/>
</div>
{/* Sort Dropdown */}
<div className="flex-shrink-0">
<SortDropdown
value={filters.sortBy}
onChange={onSortChange}
options={SORT_OPTIONS}
title={t('prReview.sort.label')}
/>
</div>
{/* Reset All */}
{hasActiveFilters && (
<Button
@@ -1,6 +1,7 @@
import { GitPullRequest, User, Clock, FileDiff } from 'lucide-react';
import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react';
import { ScrollArea } from '../../ui/scroll-area';
import { Badge } from '../../ui/badge';
import { Button } from '../../ui/button';
import { cn } from '../../../lib/utils';
import type { PRData, PRReviewProgress, PRReviewResult } from '../hooks/useGitHubPRs';
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
@@ -170,6 +171,10 @@ interface PRListProps {
error: string | null;
getReviewStateForPR: (prNumber: number) => PRReviewInfo | null;
onSelectPR: (prNumber: number) => void;
/** Callback to load more PRs when hasMore is true */
onLoadMore?: () => void;
/** Whether additional PRs are currently being loaded */
isLoadingMore?: boolean;
}
function formatDate(dateString: string): string {
@@ -200,6 +205,8 @@ export function PRList({
error,
getReviewStateForPR,
onSelectPR,
onLoadMore,
isLoadingMore,
}: PRListProps) {
const { t } = useTranslation('common');
@@ -306,12 +313,30 @@ export function PRList({
);
})}
{/* Status indicator */}
{/* Status indicator / Load More button */}
{prs.length > 0 && (
<div className="py-4 flex justify-center">
<span className="text-xs text-muted-foreground opacity-50">
{hasMore ? t('prReview.maxPRsShown') : t('prReview.allPRsLoaded')}
</span>
{hasMore && onLoadMore ? (
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('prReview.loadingMore')}
</>
) : (
t('prReview.loadMore')
)}
</Button>
) : (
<span className="text-xs text-muted-foreground opacity-50">
{t('prReview.allPRsLoaded')}
</span>
)}
</div>
)}
</div>
@@ -61,7 +61,7 @@ const SOURCE_COLORS: Record<string, string> = {
'Progress': 'bg-green-500/20 text-green-400',
'PR Review Engine': 'bg-indigo-500/20 text-indigo-400',
'Summary': 'bg-emerald-500/20 text-emerald-400',
// Specialist agents (from parallel orchestrator)
// Specialist agents (from parallel orchestrator - old Task tool approach)
'Agent:logic-reviewer': 'bg-blue-600/20 text-blue-400',
'Agent:quality-reviewer': 'bg-indigo-600/20 text-indigo-400',
'Agent:security-reviewer': 'bg-red-600/20 text-red-400',
@@ -70,6 +70,11 @@ const SOURCE_COLORS: Record<string, string> = {
'Agent:resolution-verifier': 'bg-teal-600/20 text-teal-400',
'Agent:new-code-reviewer': 'bg-cyan-600/20 text-cyan-400',
'Agent:comment-analyzer': 'bg-gray-500/20 text-gray-400',
// Parallel SDK specialists (new approach using parallel SDK sessions)
'Specialist:security': 'bg-red-600/20 text-red-400',
'Specialist:quality': 'bg-indigo-600/20 text-indigo-400',
'Specialist:logic': 'bg-blue-600/20 text-blue-400',
'Specialist:codebase-fit': 'bg-emerald-600/20 text-emerald-400',
'default': 'bg-muted text-muted-foreground'
};
@@ -107,8 +112,8 @@ function groupEntriesByAgent(entries: PRLogEntry[]): {
const otherEntries: PRLogEntry[] = [];
for (const entry of entries) {
if (entry.source?.startsWith('Agent:')) {
// Agent results
if (entry.source?.startsWith('Agent:') || entry.source?.startsWith('Specialist:')) {
// Agent/Specialist results (both old Task tool and new parallel SDK approaches)
const existing = agentMap.get(entry.source) || [];
existing.push(entry);
agentMap.set(entry.source, existing);
@@ -451,15 +456,66 @@ interface AgentLogGroupProps {
onToggle: () => void;
}
// Patterns that are uninteresting as summary entries
const SKIP_AS_SUMMARY_PATTERNS = [
/^Starting analysis\.\.\.$/,
/^Processing SDK stream\.\.\.$/,
/^Processing\.\.\./,
/^Awaiting response stream\.\.\.$/,
];
function isBoringSummary(content: string): boolean {
return SKIP_AS_SUMMARY_PATTERNS.some(pattern => pattern.test(content));
}
// Find a meaningful summary entry - skip boring entries and prefer "AI response" or "Complete"
function findSummaryEntry(entries: PRLogEntry[]): { summaryEntry: PRLogEntry | undefined; otherEntries: PRLogEntry[] } {
if (entries.length === 0) return { summaryEntry: undefined, otherEntries: [] };
// Look for the most informative entry to show as summary
// Priority: 1) "Complete:" entry, 2) "AI response:" entry, 3) first non-boring entry
const completeEntry = entries.find(e => e.content.startsWith('Complete:'));
if (completeEntry) {
return {
summaryEntry: completeEntry,
otherEntries: entries.filter(e => e !== completeEntry),
};
}
const aiResponseEntry = entries.find(e => e.content.startsWith('AI response:'));
if (aiResponseEntry) {
return {
summaryEntry: aiResponseEntry,
otherEntries: entries.filter(e => e !== aiResponseEntry),
};
}
// Find first non-boring entry
const meaningfulEntry = entries.find(e => !isBoringSummary(e.content));
if (meaningfulEntry) {
return {
summaryEntry: meaningfulEntry,
otherEntries: entries.filter(e => e !== meaningfulEntry),
};
}
// Fallback to first entry
return {
summaryEntry: entries[0],
otherEntries: entries.slice(1),
};
}
function AgentLogGroup({ group, isExpanded, onToggle }: AgentLogGroupProps) {
const { t } = useTranslation(['common']);
const { agentName, entries } = group;
const hasMultipleEntries = entries.length > 1;
const firstEntry = entries[0];
const remainingEntries = entries.slice(1);
// Extract display name from "Agent:logic-reviewer" -> "logic-reviewer"
const displayName = agentName.replace('Agent:', '');
// Find a meaningful summary entry instead of just using the first one
const { summaryEntry, otherEntries } = findSummaryEntry(entries);
const hasMoreEntries = otherEntries.length > 0;
// Extract display name from "Agent:logic-reviewer" -> "logic-reviewer" or "Specialist:security" -> "security"
const displayName = agentName.replace('Agent:', '').replace('Specialist:', '');
const getSourceColor = (source: string) => {
return SOURCE_COLORS[source] || SOURCE_COLORS.default;
@@ -477,7 +533,7 @@ function AgentLogGroup({ group, isExpanded, onToggle }: AgentLogGroupProps) {
>
{displayName}
</Badge>
{hasMultipleEntries && (
{hasMoreEntries && (
<button
onClick={onToggle}
className={cn(
@@ -489,28 +545,28 @@ function AgentLogGroup({ group, isExpanded, onToggle }: AgentLogGroupProps) {
{isExpanded ? (
<>
<ChevronDown className="h-3 w-3" />
<span>{t('common:prReview.logs.hideMore', { count: remainingEntries.length })}</span>
<span>{t('common:prReview.logs.hideMore', { count: otherEntries.length })}</span>
</>
) : (
<>
<ChevronRight className="h-3 w-3" />
<span>{t('common:prReview.logs.showMore', { count: remainingEntries.length })}</span>
<span>{t('common:prReview.logs.showMore', { count: otherEntries.length })}</span>
</>
)}
</button>
)}
</div>
{/* First entry (summary) - always visible */}
{firstEntry && (
<LogEntry entry={{ ...firstEntry, source: undefined }} />
{/* Summary entry - always visible (most informative entry, not necessarily first) */}
{summaryEntry && (
<LogEntry entry={{ ...summaryEntry, source: undefined }} />
)}
</div>
{/* Collapsible section for remaining entries */}
{hasMultipleEntries && isExpanded && (
{/* Collapsible section for other entries */}
{hasMoreEntries && isExpanded && (
<div className="border-t border-border/30 bg-secondary/10 p-2 space-y-1">
{remainingEntries.map((entry, idx) => (
{otherEntries.map((entry, idx) => (
<LogEntry key={`${entry.timestamp}-${idx}`} entry={{ ...entry, source: undefined }} />
))}
</div>
@@ -23,6 +23,7 @@ interface UseGitHubPRsOptions {
interface UseGitHubPRsResult {
prs: PRData[];
isLoading: boolean;
isLoadingMore: boolean; // Loading additional PRs via pagination
isLoadingPRDetails: boolean; // Loading full PR details including files
error: string | null;
selectedPR: PRData | null;
@@ -38,6 +39,7 @@ interface UseGitHubPRsResult {
hasMore: boolean; // True when 100 PRs returned (GitHub limit) - more may exist
selectPR: (prNumber: number | null) => void;
refresh: () => Promise<void>;
loadMore: () => Promise<void>; // Load next page of PRs
runReview: (prNumber: number) => void;
runFollowupReview: (prNumber: number) => void;
checkNewCommits: (prNumber: number) => Promise<NewCommitsCheck>;
@@ -76,6 +78,8 @@ export function useGitHubPRs(
const [isConnected, setIsConnected] = useState(false);
const [repoFullName, setRepoFullName] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [endCursor, setEndCursor] = useState<string | null>(null);
// Track previous isActive state to detect tab navigation
const wasActiveRef = useRef(isActive);
@@ -85,6 +89,10 @@ export function useGitHubPRs(
const currentFetchPRNumberRef = useRef<number | null>(null);
// AbortController for cancelling pending checkNewCommits calls on rapid PR switching
const checkNewCommitsAbortRef = useRef<AbortController | null>(null);
// Track current projectId for staleness checks in async operations
const currentProjectIdRef = useRef(projectId);
// Counter to detect stale loadMore responses after a refresh
const fetchGenerationRef = useRef(0);
// Get PR review state from the global store
const prReviews = usePRReviewStore((state) => state.prReviews);
@@ -143,6 +151,9 @@ export function useGitHubPRs(
async () => {
if (!projectId) return;
// Increment generation to invalidate any in-flight loadMore requests
fetchGenerationRef.current += 1;
setIsLoading(true);
setError(null);
@@ -159,6 +170,8 @@ export function useGitHubPRs(
if (result) {
// Use hasNextPage from API to determine if more PRs exist
setHasMore(result.hasNextPage);
// Store endCursor for pagination
setEndCursor(result.endCursor ?? null);
setPrs(result.prs);
// Batch preload review results for PRs not in store (single IPC call)
@@ -223,11 +236,15 @@ export function useGitHubPRs(
// Reset state and selected PR when project changes
useEffect(() => {
currentProjectIdRef.current = projectId;
fetchGenerationRef.current += 1;
hasLoadedRef.current = false;
setHasMore(false);
setEndCursor(null);
setPrs([]);
setSelectedPRNumber(null);
setSelectedPRDetails(null);
setIsLoadingMore(false);
currentFetchPRNumberRef.current = null;
// Cancel any pending checkNewCommits request
if (checkNewCommitsAbortRef.current) {
@@ -377,6 +394,91 @@ export function useGitHubPRs(
await fetchPRs();
}, [fetchPRs]);
// Load more PRs using cursor-based pagination
const loadMore = useCallback(async () => {
if (!projectId || !endCursor || !hasMore || isLoadingMore) return;
// Capture current state for staleness checks
const requestProjectId = projectId;
const requestGeneration = fetchGenerationRef.current;
setIsLoadingMore(true);
setError(null);
try {
const result = await window.electronAPI.github.listMorePRs(projectId, endCursor);
// Discard response if project changed or a refresh happened while loading
if (
requestProjectId !== currentProjectIdRef.current ||
requestGeneration !== fetchGenerationRef.current
) {
return;
}
if (result) {
// Check if this is a failure response (empty result with no next page)
// In this case, preserve existing pagination state to allow retry
const isFailureResponse = result.prs.length === 0 && !result.hasNextPage && !result.endCursor;
if (!isFailureResponse) {
// Update pagination state only on successful response
setHasMore(result.hasNextPage);
setEndCursor(result.endCursor ?? null);
// Append new PRs to existing list, deduplicating by PR number
// (handles edge case where PR shifts position between pagination requests)
setPrs((prevPrs) => {
const existingNumbers = new Set(prevPrs.map((pr) => pr.number));
const newPrs = result.prs.filter((pr) => !existingNumbers.has(pr.number));
return [...prevPrs, ...newPrs];
});
}
// Batch preload review results for new PRs not in store
const prsNeedingPreload = result.prs.filter((pr) => {
const existingState = getPRReviewState(requestProjectId, pr.number);
return !existingState?.result && !existingState?.isReviewing;
});
if (prsNeedingPreload.length > 0) {
const prNumbers = prsNeedingPreload.map((pr) => pr.number);
const batchReviews = await window.electronAPI.github.getPRReviewsBatch(
requestProjectId,
prNumbers
);
// Check staleness again after async batch fetch
if (
requestProjectId !== currentProjectIdRef.current ||
requestGeneration !== fetchGenerationRef.current
) {
return;
}
// Update store with loaded results
for (const reviewResult of Object.values(batchReviews)) {
if (reviewResult) {
usePRReviewStore.getState().setPRReviewResult(requestProjectId, reviewResult, {
preserveNewCommitsCheck: true,
});
}
}
}
}
} catch (err) {
// Only show error if still relevant
if (
requestProjectId === currentProjectIdRef.current &&
requestGeneration === fetchGenerationRef.current
) {
setError(err instanceof Error ? err.message : "Failed to load more PRs");
}
} finally {
setIsLoadingMore(false);
}
}, [projectId, endCursor, hasMore, isLoadingMore, getPRReviewState]);
const runReview = useCallback(
(prNumber: number) => {
if (!projectId) return;
@@ -567,6 +669,7 @@ export function useGitHubPRs(
return {
prs,
isLoading,
isLoadingMore,
isLoadingPRDetails,
error,
selectedPR,
@@ -582,6 +685,7 @@ export function useGitHubPRs(
hasMore,
selectPR,
refresh,
loadMore,
runReview,
runFollowupReview,
checkNewCommits,

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